commit 432250bffc44e0aaf79c6ea3d76aa510aced7a00 Author: MacPro Date: Wed Apr 22 17:46:43 2026 -0500 initial diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..c6c9cf8 --- /dev/null +++ b/.gitignore @@ -0,0 +1,12 @@ +# Build artifacts +*.s9pk +javascript/ + +# Node +node_modules/ + +# macOS / editor cruft +.DS_Store +*.swp +.vscode/ +.idea/ diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..f7080af --- /dev/null +++ b/Dockerfile @@ -0,0 +1,80 @@ +# Multi-stage Dockerfile for the Keysat daemon. +# +# Stage 1: build the Rust binary with musl so it's statically linked and +# needs no libc/ssl in the runtime stage. This keeps the final image tiny +# (~20 MB) and boot times fast, which matters on a home server. +# +# Stage 2: a bare-bones runtime image that just runs the binary. +# +# The upstream source directory is still called `licensing-service` on disk +# for continuity with earlier revisions; the binary it produces is `keysat`. + +# syntax=docker/dockerfile:1.6 + +ARG RUST_VERSION=1.75 + +# -------- builder -------- +FROM rust:${RUST_VERSION}-slim-bookworm AS builder + +RUN apt-get update && apt-get install -y --no-install-recommends \ + pkg-config musl-tools ca-certificates \ + && rm -rf /var/lib/apt/lists/* + +# Add musl target for the current architecture. Docker fills in +# TARGETARCH/TARGETPLATFORM when the image is built with buildx for multi-arch. +ARG TARGETARCH +RUN case "${TARGETARCH}" in \ + amd64) rustup target add x86_64-unknown-linux-musl ;; \ + arm64) rustup target add aarch64-unknown-linux-musl ;; \ + *) echo "unsupported TARGETARCH: ${TARGETARCH}" && exit 1 ;; \ + esac + +WORKDIR /src + +# Cache dependencies: copy only the manifest/lock first so `cargo fetch` +# can be re-used across builds that don't change deps. +COPY licensing-service/Cargo.toml licensing-service/Cargo.lock* ./licensing-service/ +COPY licensing-service/migrations ./licensing-service/migrations + +# Make a dummy src to let cargo fetch resolve deps. Real src comes next. +RUN mkdir -p licensing-service/src && \ + echo 'fn main() {}' > licensing-service/src/main.rs && \ + cd licensing-service && cargo fetch + +# Copy the actual source. +COPY licensing-service/src ./licensing-service/src + +# Build. +ARG TARGETARCH +RUN case "${TARGETARCH}" in \ + amd64) TARGET=x86_64-unknown-linux-musl ;; \ + arm64) TARGET=aarch64-unknown-linux-musl ;; \ + esac && \ + cd licensing-service && \ + CARGO_NET_RETRY=10 \ + cargo build --release --target ${TARGET} --locked && \ + cp target/${TARGET}/release/keysat /keysat + +# -------- runtime -------- +FROM debian:bookworm-slim AS runtime + +RUN apt-get update && apt-get install -y --no-install-recommends \ + ca-certificates tini \ + && rm -rf /var/lib/apt/lists/* + +# Non-root user to avoid running as root even though we're in a container. +RUN useradd --system --create-home --uid 10001 keysat +USER keysat +WORKDIR /home/keysat + +COPY --from=builder /keysat /usr/local/bin/keysat + +ENV KEYSAT_BIND=0.0.0.0:8080 \ + KEYSAT_DB_PATH=/data/keysat.db + +EXPOSE 8080 + +# tini reaps zombie processes and forwards signals — StartOS sends SIGTERM +# on service stop; the binary installs a graceful handler for it. +ENTRYPOINT ["/usr/bin/tini", "--"] +CMD ["/usr/local/bin/keysat"] diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..1349a58 --- /dev/null +++ b/Makefile @@ -0,0 +1,18 @@ +# Licensing-service StartOS package build. +# +# Most of the build logic is shared across all StartOS packages and lives in +# `s9pk.mk`, which is copied from the `hello-world-startos` template. Pull +# that file in alongside this Makefile. +# +# Common targets: +# make -- build for all supported architectures +# make x86 -- build for x86_64 only +# make arm -- build for aarch64 only +# make universal -- build a single universal package +# make install -- install to the StartOS server referenced by +# your ~/.startos/developer.key.pem +# make clean -- wipe build artifacts +# +# Chain targets when needed: `make clean arm install`. + +include s9pk.mk diff --git a/README.md b/README.md new file mode 100644 index 0000000..bd866ac --- /dev/null +++ b/README.md @@ -0,0 +1,128 @@ +# keysat-startos + +StartOS 0.4.0.x wrapper package for [Keysat](../licensing-service) (the Rust daemon in `../licensing-service/`). This directory turns the upstream Rust daemon into an installable `.s9pk`. + +The source directory is still called `licensing-service/` on disk for continuity; the binary it produces, the manifest id, and all operator-visible strings use the new name **Keysat**. + +## Prerequisites + +- A working StartOS 0.4.0.x development environment (see [docs.start9.com](https://docs.start9.com)). +- `start-cli` installed, with `~/.startos/developer.key.pem` initialized. +- Node.js and npm (the StartOS SDK is TypeScript). +- Docker (via buildx) for the multi-arch image build. + +## Getting the shared build logic + +The `Makefile` includes `s9pk.mk`, which is shared build boilerplate maintained by the Start9 team. Fetch it once: + +```bash +curl -o s9pk.mk https://raw.githubusercontent.com/Start9Labs/hello-world-startos/master/s9pk.mk +``` + +(Or copy it from any other 0.4.0.x package you have locally.) + +## Installing dependencies + +```bash +npm install +``` + +## Building and installing + +```bash +# Build for all supported architectures +make + +# Or just the architecture of your dev StartOS box +make arm # for Raspberry Pi / Apple Silicon StartOS +make x86 # for an x86 StartOS server + +# Push to your StartOS server (requires the developer key) +make install +``` + +`make install` will prompt for your StartOS password the first time; subsequent installs use the cached session. + +## Project layout + +This follows the standard 0.4.0.x layout: + +``` +keysat-startos/ +├── Dockerfile # multi-stage Rust build +├── Makefile # delegates to s9pk.mk +├── s9pk.mk # (fetch from hello-world-startos) +├── package.json / tsconfig.json +├── icon.png # 512×512 StartOS tile +├── assets/ +│ ├── ABOUT.md +│ └── keysat-thumbnail.png # 1024×1024 marketing hero +└── startos/ + ├── manifest/index.ts # setupManifest() + ├── manifest/i18n.ts # descriptions, translatable + ├── main.ts # daemon definition + ├── interfaces.ts # network exposure (API on 8080) + ├── dependencies.ts # requires BTCPay Server + ├── actions/ # user-facing StartOS buttons + │ ├── configureBtcpay.ts # one-click BTCPay authorize + │ ├── createPolicy.ts # reusable license template + │ ├── createProduct.ts + │ ├── deactivateMachine.ts # force-kick an install + │ ├── issueLicense.ts # comp / press keys + │ ├── listMachines.ts # inspect a license's seats + │ ├── listWebhooks.ts + │ ├── registerWebhook.ts # outbound event subscriber + │ ├── revokeLicense.ts # one-way permanent block + │ ├── searchLicenses.ts # lost-key recovery + │ ├── setOperatorName.ts + │ ├── showCredentials.ts + │ ├── suspendLicense.ts # reversible lockout + │ ├── unsuspendLicense.ts + │ └── viewAuditLog.ts + ├── fileModels/store.ts # persistent wrapper state + ├── init/index.ts # first-boot setup + ├── versions/ # migration history + │ ├── index.ts + │ └── v0.1.0.ts + ├── backups.ts # volume backup declaration + ├── sdk.ts # manifest-bound SDK instance + ├── utils.ts # small helpers + └── index.ts # ties everything together +``` + +## Dockerfile notes + +The Dockerfile expects the `licensing-service/` source to be available at the parent directory (`..`). The manifest sets `images.main.source.dockerBuild.workdir` to `'..'` so `start-cli s9pk pack` runs `docker build` with the parent `Licensing/` directory as the context — Docker then sees the licensing-service source alongside this wrapper. A `.dockerignore` at the parent level keeps the uploaded context small. + +If you're laying out the repositories differently — e.g., separate GitHub repos for service and wrapper — you'll want to add a git submodule or adjust the `workdir`/`COPY` paths accordingly. + +## Operator workflow after install + +1. Open the service in your StartOS dashboard. +2. **Set operator name** → your display name, shown on the public homepage. +3. **Connect BTCPay** → one-click authorize flow. Opens BTCPay's consent page in your browser; after you approve, the daemon auto-detects your store and registers its inbound webhook. No API keys to copy. +4. **Check BTCPay connection** to confirm the authorize succeeded. +5. **Create product** once per thing you want to sell. +6. **Create policy** at least once per product, slugged `default`, to set the shape of keys issued through the public purchase flow (duration, grace period, entitlements, seat cap). +7. Share the public service URL with buyers. That's enough for the standard purchase flow. + +### Customer support + +- **Search licenses** — look up a buyer by email, Nostr npub, or BTCPay invoice id. +- **Suspend license** / **Unsuspend license** — reversible lockout (e.g., for payment disputes). +- **Revoke license** — permanent, one-way kill. +- **Issue license manually** — comp / press / grandfathered keys. +- **List machines** — see which installs are bound to a license. +- **Deactivate machine** — force-kick a specific install, freeing a seat. + +### Integrations & operations + +- **Register webhook endpoint** — POST signed event notifications to an HTTPS URL you control (license.issued, license.revoked, machine.activated, etc.). HMAC-SHA256 in `X-Keysat-Signature: sha256=`. +- **List webhook endpoints** — see what's subscribed. +- **View audit log** — most recent admin mutations, filterable by action slug. Useful for compliance and debugging. +- **Show admin API key** — only needed if you want to script against `/v1/admin/*` from outside the box; every built-in action already carries the key for you. + +## Limitations in v0.1 + +- No in-dashboard list view for invoices/products/licenses — use `/v1/admin/...` via the admin API key if you need a bulk view beyond what the built-in actions surface. +- Webhook delivery retries are bounded; if a subscriber is down past the retry window, the event is dropped. Invoice reconciliation runs as a background task so dropped BTCPay webhooks get replayed. diff --git a/assets/ABOUT.md b/assets/ABOUT.md new file mode 100644 index 0000000..3953012 --- /dev/null +++ b/assets/ABOUT.md @@ -0,0 +1,12 @@ +# About Keysat + +**Keysat** is a self-hosted, Bitcoin-paid software licensing server: every operator runs their own instance on their own hardware, so there is no central authority, no shared database, and no lock-in. You own the signing key, the customer records, and the payment rails. + +After installing: + +1. Click **Connect BTCPay** once to authorize the daemon against your BTCPay Server (one-click — nothing to copy and paste). +2. Click **Create product** for each thing you want to sell. +3. Optionally click **Create policy** to set per-product defaults (duration, grace period, entitlements, seat cap, trial flag) — a policy slugged `default` is used by the public purchase flow. +4. Share your Keysat URL with buyers. They call `POST /v1/purchase`, pay via BTCPay, and Keysat issues an Ed25519-signed license key your software can verify offline. + +The same in-dashboard action buttons cover license issuance (for comps, press, trials), suspension / unsuspension, revocation, machine management, outbound webhook subscriptions, and an audit log viewer. Full developer docs live in the upstream repository. diff --git a/assets/keysat-thumbnail.png b/assets/keysat-thumbnail.png new file mode 100644 index 0000000..542c743 Binary files /dev/null and b/assets/keysat-thumbnail.png differ diff --git a/icon.png b/icon.png new file mode 100644 index 0000000..c9a1355 Binary files /dev/null and b/icon.png differ diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..525f7bb --- /dev/null +++ b/package-lock.json @@ -0,0 +1,318 @@ +{ + "name": "keysat-startos", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "keysat-startos", + "version": "0.1.0", + "license": "SEE LICENSE IN ../licensing-service/LICENSE", + "dependencies": { + "@start9labs/start-sdk": "^0.4.0" + }, + "devDependencies": { + "@types/node": "^20.0.0", + "typescript": "^5.3.0" + } + }, + "node_modules/@iarna/toml": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@iarna/toml/-/toml-3.0.0.tgz", + "integrity": "sha512-td6ZUkz2oS3VeleBcN+m//Q6HlCFCPrnI0FZhrt/h4XqLEdOyYp2u21nd8MdsR+WJy5r9PTDaHTDDfhf4H4l6Q==", + "license": "ISC" + }, + "node_modules/@noble/curves": { + "version": "1.9.7", + "resolved": "https://registry.npmjs.org/@noble/curves/-/curves-1.9.7.tgz", + "integrity": "sha512-gbKGcRUYIjA3/zCCNaWDciTMFI0dCkvou3TL8Zmy5Nc7sJ47a0jtOeZoTaMxkuqRo9cRhjOdZJXegxYE5FN/xw==", + "license": "MIT", + "dependencies": { + "@noble/hashes": "1.8.0" + }, + "engines": { + "node": "^14.21.3 || >=16" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@noble/hashes": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.8.0.tgz", + "integrity": "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==", + "license": "MIT", + "engines": { + "node": "^14.21.3 || >=16" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@nodable/entities": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@nodable/entities/-/entities-2.1.0.tgz", + "integrity": "sha512-nyT7T3nbMyBI/lvr6L5TyWbFJAI9FTgVRakNoBqCD+PmID8DzFrrNdLLtHMwMszOtqZa8PAOV24ZqDnQrhQINA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/nodable" + } + ], + "license": "MIT" + }, + "node_modules/@start9labs/start-sdk": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/@start9labs/start-sdk/-/start-sdk-0.4.0.tgz", + "integrity": "sha512-PFfO7tV9nzQFZL3KXaZyf16C5VZtM+dCDlRhLHpmwssTKtcjyCEhBrB9locuS2yFqu69rj+5kLFzCWDHeRRibg==", + "license": "MIT", + "dependencies": { + "@iarna/toml": "^3.0.0", + "@noble/curves": "^1.8.2", + "@noble/hashes": "^1.7.2", + "@types/ini": "^4.1.1", + "deep-equality-data-structures": "^2.0.0", + "fast-xml-parser": "^5.5.6", + "ini": "^5.0.0", + "isomorphic-fetch": "^3.0.0", + "mime": "^4.0.7", + "yaml": "^2.7.1", + "zod": "^4.3.6", + "zod-deep-partial": "^1.2.0" + } + }, + "node_modules/@types/ini": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/@types/ini/-/ini-4.1.1.tgz", + "integrity": "sha512-MIyNUZipBTbyUNnhvuXJTY7B6qNI78meck9Jbv3wk0OgNwRyOOVEKDutAkOs1snB/tx0FafyR6/SN4Ps0hZPeg==", + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "20.19.39", + "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.39.tgz", + "integrity": "sha512-orrrD74MBUyK8jOAD/r0+lfa1I2MO6I+vAkmAWzMYbCcgrN4lCrmK52gRFQq/JRxfYPfonkr4b0jcY7Olqdqbw==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/deep-equality-data-structures": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/deep-equality-data-structures/-/deep-equality-data-structures-2.0.0.tgz", + "integrity": "sha512-qgrUr7MKXq7VRN+WUpQ48QlXVGL0KdibAoTX8KRg18lgOgqbEKMAW1WZsVCtakY4+XX42pbAJzTz/DlXEFM2Fg==", + "license": "MIT", + "dependencies": { + "object-hash": "^3.0.0" + } + }, + "node_modules/fast-xml-builder": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/fast-xml-builder/-/fast-xml-builder-1.1.5.tgz", + "integrity": "sha512-4TJn/8FKLeslLAH3dnohXqE3QSoxkhvaMzepOIZytwJXZO69Bfz0HBdDHzOTOon6G59Zrk6VQ2bEiv1t61rfkA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT", + "dependencies": { + "path-expression-matcher": "^1.1.3" + } + }, + "node_modules/fast-xml-parser": { + "version": "5.7.1", + "resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-5.7.1.tgz", + "integrity": "sha512-8Cc3f8GUGUULg34pBch/KGyPLglS+OFs05deyOlY7fL2MTagYPKrVQNmR1fLF/yJ9PH5ZSTd3YDF6pnmeZU+zA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT", + "dependencies": { + "@nodable/entities": "^2.1.0", + "fast-xml-builder": "^1.1.5", + "path-expression-matcher": "^1.5.0", + "strnum": "^2.2.3" + }, + "bin": { + "fxparser": "src/cli/cli.js" + } + }, + "node_modules/ini": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/ini/-/ini-5.0.0.tgz", + "integrity": "sha512-+N0ngpO3e7cRUWOJAS7qw0IZIVc6XPrW4MlFBdD066F2L4k1L6ker3hLqSq7iXxU5tgS4WGkIUElWn5vogAEnw==", + "license": "ISC", + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/isomorphic-fetch": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/isomorphic-fetch/-/isomorphic-fetch-3.0.0.tgz", + "integrity": "sha512-qvUtwJ3j6qwsF3jLxkZ72qCgjMysPzDfeV240JHiGZsANBYd+EEuu35v7dfrJ9Up0Ak07D7GGSkGhCHTqg/5wA==", + "license": "MIT", + "dependencies": { + "node-fetch": "^2.6.1", + "whatwg-fetch": "^3.4.1" + } + }, + "node_modules/mime": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-4.1.0.tgz", + "integrity": "sha512-X5ju04+cAzsojXKes0B/S4tcYtFAJ6tTMuSPBEn9CPGlrWr8Fiw7qYeLT0XyH80HSoAoqWCaz+MWKh22P7G1cw==", + "funding": [ + "https://github.com/sponsors/broofa" + ], + "license": "MIT", + "bin": { + "mime": "bin/cli.js" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/node-fetch": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz", + "integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==", + "license": "MIT", + "dependencies": { + "whatwg-url": "^5.0.0" + }, + "engines": { + "node": "4.x || >=6.0.0" + }, + "peerDependencies": { + "encoding": "^0.1.0" + }, + "peerDependenciesMeta": { + "encoding": { + "optional": true + } + } + }, + "node_modules/object-hash": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/object-hash/-/object-hash-3.0.0.tgz", + "integrity": "sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==", + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/path-expression-matcher": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/path-expression-matcher/-/path-expression-matcher-1.5.0.tgz", + "integrity": "sha512-cbrerZV+6rvdQrrD+iGMcZFEiiSrbv9Tfdkvnusy6y0x0GKBXREFg/Y65GhIfm0tnLntThhzCnfKwp1WRjeCyQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/strnum": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/strnum/-/strnum-2.2.3.tgz", + "integrity": "sha512-oKx6RUCuHfT3oyVjtnrmn19H1SiCqgJSg+54XqURKp5aCMbrXrhLjRN9TjuwMjiYstZ0MzDrHqkGZ5dFTKd+zg==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT" + }, + "node_modules/tr46": { + "version": "0.0.3", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", + "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==", + "license": "MIT" + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/webidl-conversions": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", + "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==", + "license": "BSD-2-Clause" + }, + "node_modules/whatwg-fetch": { + "version": "3.6.20", + "resolved": "https://registry.npmjs.org/whatwg-fetch/-/whatwg-fetch-3.6.20.tgz", + "integrity": "sha512-EqhiFU6daOA8kpjOWTL0olhVOF3i7OrFzSYiGsEMB8GcXS+RrzauAERX65xMeNWVqxA6HXH2m69Z9LaKKdisfg==", + "license": "MIT" + }, + "node_modules/whatwg-url": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", + "integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==", + "license": "MIT", + "dependencies": { + "tr46": "~0.0.3", + "webidl-conversions": "^3.0.0" + } + }, + "node_modules/yaml": { + "version": "2.8.3", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.3.tgz", + "integrity": "sha512-AvbaCLOO2Otw/lW5bmh9d/WEdcDFdQp2Z2ZUH3pX9U2ihyUY0nvLv7J6TrWowklRGPYbB/IuIMfYgxaCPg5Bpg==", + "license": "ISC", + "bin": { + "yaml": "bin.mjs" + }, + "engines": { + "node": ">= 14.6" + }, + "funding": { + "url": "https://github.com/sponsors/eemeli" + } + }, + "node_modules/zod": { + "version": "4.3.6", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz", + "integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, + "node_modules/zod-deep-partial": { + "version": "1.4.4", + "resolved": "https://registry.npmjs.org/zod-deep-partial/-/zod-deep-partial-1.4.4.tgz", + "integrity": "sha512-aWkPl7hVStgE01WzbbSxCgX4O+sSpgt8JOjvFUtMTF75VgL6MhWQbiZi+AWGN85SfSTtI9gsOtL1vInoqfDVaA==", + "license": "MIT", + "peerDependencies": { + "zod": "^4.1.13" + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..e8be880 --- /dev/null +++ b/package.json @@ -0,0 +1,18 @@ +{ + "name": "keysat-startos", + "version": "0.1.0", + "private": true, + "description": "StartOS 0.4.0.x package wrapping the Keysat daemon", + "license": "SEE LICENSE IN ../licensing-service/LICENSE", + "scripts": { + "build": "tsc", + "check": "tsc --noEmit" + }, + "dependencies": { + "@start9labs/start-sdk": "^0.4.0" + }, + "devDependencies": { + "typescript": "^5.3.0", + "@types/node": "^20.0.0" + } +} diff --git a/s9pk.mk b/s9pk.mk new file mode 100644 index 0000000..888159f --- /dev/null +++ b/s9pk.mk @@ -0,0 +1,128 @@ +# ** Plumbing. DO NOT EDIT **. +# This file is imported by ./Makefile. Make edits there + +PACKAGE_ID := $(shell awk -F"'" '/id:/ {print $$2}' startos/manifest/index.ts) +INGREDIENTS := $(shell start-cli s9pk list-ingredients 2>/dev/null) +ARCHES ?= x86 arm riscv +TARGETS ?= arches +ifdef VARIANT +BASE_NAME := $(PACKAGE_ID)_$(VARIANT) +else +BASE_NAME := $(PACKAGE_ID) +endif + +.PHONY: all arches aarch64 x86_64 riscv64 arm arm64 x86 riscv arch/* clean install check-deps check-init package ingredients +.DELETE_ON_ERROR: +.SECONDARY: + +define SUMMARY + @manifest=$$(start-cli s9pk inspect $(1) manifest); \ + size=$$(du -h $(1) | awk '{print $$1}'); \ + title=$$(printf '%s' "$$manifest" | jq -r .title); \ + version=$$(printf '%s' "$$manifest" | jq -r .version); \ + arches=$$(printf '%s' "$$manifest" | jq -r '[.images[].arch // []] | flatten | unique | join(", ")'); \ + sdkv=$$(printf '%s' "$$manifest" | jq -r .sdkVersion); \ + gitHash=$$(printf '%s' "$$manifest" | jq -r .gitHash | sed -E 's/(.*-modified)$$/\x1b[0;31m\1\x1b[0m/'); \ + printf "\n"; \ + printf "\033[1;32m✅ Build Complete!\033[0m\n"; \ + printf "\n"; \ + printf "\033[1;37m📦 $$title\033[0m \033[36mv$$version\033[0m\n"; \ + printf "───────────────────────────────\n"; \ + printf " \033[1;36mFilename:\033[0m %s\n" "$(1)"; \ + printf " \033[1;36mSize:\033[0m %s\n" "$$size"; \ + printf " \033[1;36mArch:\033[0m %s\n" "$$arches"; \ + printf " \033[1;36mSDK:\033[0m %s\n" "$$sdkv"; \ + printf " \033[1;36mGit:\033[0m %s\n" "$$gitHash"; \ + echo "" +endef + +all: $(TARGETS) + +arches: $(ARCHES) + +universal: $(BASE_NAME).s9pk + $(call SUMMARY,$<) + +arch/%: $(BASE_NAME)_%.s9pk + $(call SUMMARY,$<) + +x86 x86_64: arch/x86_64 +arm arm64 aarch64: arch/aarch64 +riscv riscv64: arch/riscv64 + +$(BASE_NAME).s9pk: $(INGREDIENTS) .git/HEAD .git/index + @$(MAKE) --no-print-directory ingredients + @echo " Packing '$@'..." + start-cli s9pk pack -o $@ + +$(BASE_NAME)_%.s9pk: $(INGREDIENTS) .git/HEAD .git/index + @$(MAKE) --no-print-directory ingredients + @echo " Packing '$@'..." + start-cli s9pk pack --arch=$* -o $@ + +ingredients: $(INGREDIENTS) + @echo " Re-evaluating ingredients..." + +install: | check-deps check-init + @HOST=$$(awk -F'/' '/^host:/ {print $$3}' ~/.startos/config.yaml); \ + if [ -z "$$HOST" ]; then \ + echo "Error: You must define \"host: http://server-name.local\" in ~/.startos/config.yaml"; \ + exit 1; \ + fi; \ + S9PK=$$(ls -t *.s9pk 2>/dev/null | head -1); \ + if [ -z "$$S9PK" ]; then \ + echo "Error: No .s9pk file found. Run 'make' first."; \ + exit 1; \ + fi; \ + printf "\n🚀 Installing %s to %s ...\n" "$$S9PK" "$$HOST"; \ + start-cli package install -s "$$S9PK" + +publish: | all + @REGISTRY=$$(awk -F'/' '/^registry:/ {print $$3}' ~/.startos/config.yaml); \ + if [ -z "$$REGISTRY" ]; then \ + echo "Error: You must define \"registry: https://my-registry.tld\" in ~/.startos/config.yaml"; \ + exit 1; \ + fi; \ + S3BASE=$$(awk -F'/' '/^s9pk-s3base:/ {print $$3}' ~/.startos/config.yaml); \ + if [ -z "$$S3BASE" ]; then \ + echo "Error: You must define \"s3base: https://s9pks.my-s3-bucket.tld\" in ~/.startos/config.yaml"; \ + exit 1; \ + fi; \ + command -v s3cmd >/dev/null || \ + (echo "Error: s3cmd not found. It must be installed to publish using s3." && exit 1); \ + printf "\n🚀 Publishing to %s; indexing on %s ...\n" "$$S3BASE" "$$REGISTRY"; \ + for s9pk in *.s9pk; do \ + age=$$(( $$(date +%s) - $$(stat -c %Y "$$s9pk") )); \ + if [ "$$age" -gt 3600 ]; then \ + printf "\033[1;33m⚠️ %s is %d minutes old. Publish anyway? [y/N] \033[0m" "$$s9pk" "$$((age / 60))"; \ + read -r ans; \ + case "$$ans" in [yY]*) ;; *) echo "Skipping $$s9pk"; continue ;; esac; \ + fi; \ + start-cli s9pk publish "$$s9pk"; \ + done + +check-deps: + @command -v start-cli >/dev/null || \ + (echo "Error: start-cli not found. Please see https://docs.start9.com/latest/developer-guide/sdk/installing-the-sdk" && exit 1) + @command -v npm >/dev/null || \ + (echo "Error: npm not found. Please install Node.js and npm." && exit 1) + +check-init: + @if [ ! -f ~/.startos/developer.key.pem ]; then \ + echo "Initializing StartOS developer environment..."; \ + start-cli init-key; \ + fi + +javascript/index.js: $(shell find startos -type f) tsconfig.json node_modules + npm run check + npm run build + +node_modules: package-lock.json + npm ci + +package-lock.json: package.json + npm i + +clean: + @echo "Cleaning up build artifacts..." + @rm -rf $(PACKAGE_ID).s9pk $(PACKAGE_ID)_x86_64.s9pk $(PACKAGE_ID)_aarch64.s9pk $(PACKAGE_ID)_riscv64.s9pk javascript node_modules diff --git a/s9pk.mk.README b/s9pk.mk.README new file mode 100644 index 0000000..e366648 --- /dev/null +++ b/s9pk.mk.README @@ -0,0 +1,15 @@ +`s9pk.mk` is the shared Start9 build logic. It's a boilerplate file that +the Start9 team maintains — every 0.4.0.x package ships a copy of it that +hardly changes between services. + +To add it to this project: + + curl -o s9pk.mk https://raw.githubusercontent.com/Start9Labs/hello-world-startos/master/s9pk.mk + +Or clone the `hello-world-startos` repo and copy the file over. The canonical +source is whatever the Start9 team maintains in that template. + +Reason we don't commit a copy here by default: the file is updated by the +Start9 team as the SDK evolves, and pinning a copy means we'd fall behind. +The Makefile `include s9pk.mk` line will fail loudly if it's missing, +reminding you to fetch it. diff --git a/startos/actions/configureBtcpay.ts b/startos/actions/configureBtcpay.ts new file mode 100644 index 0000000..bed71ba --- /dev/null +++ b/startos/actions/configureBtcpay.ts @@ -0,0 +1,97 @@ +// Action: one-click "Connect BTCPay". +// +// Instead of asking the operator to generate and paste an API key, we use +// BTCPay's built-in authorize flow: +// 1. Action calls POST /v1/admin/btcpay/connect on the local daemon. +// 2. Daemon returns an authorize URL pointing at the sibling BTCPay +// instance, with the permissions we need pre-filled. +// 3. Operator opens that URL in their browser, approves on BTCPay's +// consent page, and BTCPay calls back into /v1/btcpay/authorize/callback +// with the freshly-minted API key. +// 4. Daemon auto-detects the store, registers the webhook, and persists +// everything. +// +// The operator never sees or types an API key, store id, or webhook secret. + +import { sdk } from '../sdk' +import { adminCall, LICENSING_URL } from '../utils' + +export const configureBtcpay = sdk.Action.withoutInput( + 'configureBtcpay', + async ({ effects }) => ({ + name: 'Connect BTCPay', + description: + "One-click connect to your BTCPay Server. Opens a consent page in " + + "your browser where you click 'Authorize'; Keysat then auto-detects " + + "your store and registers the webhook.", + warning: null, + allowedStatuses: 'only-running', + group: 'BTCPay', + visibility: 'enabled', + }), + async ({ effects }) => { + const store = await sdk.store.getOwn(effects, sdk.StorePath).const() + const resp = await adminCall( + LICENSING_URL, + store.admin_api_key, + '/v1/admin/btcpay/connect', + { method: 'POST' }, + ) + if (!resp.ok) { + throw new Error(`Connect initialisation failed: HTTP ${resp.status} — ${await resp.text()}`) + } + const body = (await resp.json()) as { authorize_url: string } + + return { + message: + 'Open the URL below in your browser. You will be taken to your ' + + 'BTCPay Server, where you click "Authorize". After that BTCPay ' + + 'sends the API key back to Keysat automatically — you do not ' + + 'need to copy anything.\n\n' + + body.authorize_url + + '\n\nYou can confirm the connection succeeded with the "Check BTCPay ' + + 'connection" action once approval is complete.', + } + }, +) + +/** Optional companion action: show current BTCPay connection state. */ +export const btcpayStatus = sdk.Action.withoutInput( + 'btcpayStatus', + async ({ effects }) => ({ + name: 'Check BTCPay connection', + description: 'Shows whether BTCPay is currently connected, and the store id.', + warning: null, + allowedStatuses: 'only-running', + group: 'BTCPay', + visibility: 'enabled', + }), + async ({ effects }) => { + const store = await sdk.store.getOwn(effects, sdk.StorePath).const() + const resp = await adminCall( + LICENSING_URL, + store.admin_api_key, + '/v1/admin/btcpay/status', + { method: 'GET' }, + ) + if (!resp.ok) { + throw new Error(`Status check failed: HTTP ${resp.status} — ${await resp.text()}`) + } + const body = (await resp.json()) as + | { connected: false } + | { connected: true; store_id: string; webhook_id: string | null; base_url: string } + + if (!body.connected) { + return { + message: 'BTCPay is not connected yet. Run the "Connect BTCPay" action to authorize.', + } + } + return { + message: + `BTCPay is connected.\n` + + `Store id: ${body.store_id}\n` + + `Webhook id: ${body.webhook_id ?? '(not registered — check BTCPay manually)'}\n` + + `Base URL: ${body.base_url}`, + } + }, +) diff --git a/startos/actions/createPolicy.ts b/startos/actions/createPolicy.ts new file mode 100644 index 0000000..ae13b6d --- /dev/null +++ b/startos/actions/createPolicy.ts @@ -0,0 +1,148 @@ +// Action: create a license policy (reusable template) for a product. +// +// Policies let the operator capture "when someone buys this product, issue a +// license with these defaults" (duration, grace period, entitlements, seat +// cap, trial flag). A policy slugged `default` is used automatically by the +// normal purchase flow. + +import { sdk } from '../sdk' +import { adminCall, LICENSING_URL } from '../utils' + +const input = sdk.InputSpec.of({ + product_slug: { + type: 'text', + name: 'Product slug', + description: 'The product this policy applies to.', + required: true, + default: null, + }, + slug: { + type: 'text', + name: 'Policy slug', + description: + 'URL-safe name, e.g., "default", "annual", "trial". ' + + 'Use "default" for the one consumed by the public purchase flow.', + required: true, + default: null, + patterns: [{ regex: '^[a-z0-9-]{2,40}$', description: 'lowercase letters, digits, and dashes' }], + }, + name: { + type: 'text', + name: 'Display name', + description: 'Shown in admin listings. E.g., "Annual subscription".', + required: true, + default: null, + }, + duration_seconds: { + type: 'number', + name: 'Duration (seconds)', + description: '0 = perpetual. 31536000 = one year. 7776000 = 90 days.', + required: true, + default: 0, + min: 0, + max: null, + integer: true, + }, + grace_seconds: { + type: 'number', + name: 'Grace period (seconds)', + description: + 'After expiry, how long a cached validation remains honoured ' + + 'before the client must reach the server again. 0 = no grace.', + required: true, + default: 0, + min: 0, + max: null, + integer: true, + }, + max_machines: { + type: 'number', + name: 'Max machines', + description: '0 = unlimited, 1 = single-seat, n>1 = multi-seat cap.', + required: true, + default: 1, + min: 0, + max: null, + integer: true, + }, + is_trial: { + type: 'toggle', + name: 'Trial policy', + description: 'Mark issued keys as trial (sets the TRIAL flag in the payload).', + default: false, + }, + entitlements: { + type: 'text', + name: 'Entitlements', + description: + 'Comma-separated list of feature slugs embedded in the license key. ' + + 'E.g., "pro,multi-device". Leave blank for none.', + required: false, + default: null, + }, + price_sats_override: { + type: 'number', + name: 'Price override (sats, optional)', + description: + "Override the product's default price for licenses issued under this " + + 'policy. Leave at -1 to use the product price.', + required: true, + default: -1, + min: -1, + max: null, + integer: true, + }, +}) + +export const createPolicy = sdk.Action.withInput( + 'createPolicy', + async ({ effects }) => ({ + name: 'Create policy', + description: + 'Add a reusable license template to a product. The public purchase ' + + 'flow picks up the policy slugged "default"; other policies are used ' + + 'by the admin "Issue license manually" action.', + warning: null, + allowedStatuses: 'only-running', + group: 'Policies', + visibility: 'enabled', + }), + input, + async ({ effects, input: formInput }) => { + const store = await sdk.store.getOwn(effects, sdk.StorePath).const() + const entitlements = (formInput.entitlements ?? '') + .split(',') + .map((s) => s.trim()) + .filter((s) => s.length > 0) + const body: Record = { + product_slug: formInput.product_slug, + name: formInput.name, + slug: formInput.slug, + duration_seconds: formInput.duration_seconds, + grace_seconds: formInput.grace_seconds, + max_machines: formInput.max_machines, + is_trial: formInput.is_trial, + entitlements, + metadata: {}, + } + if (formInput.price_sats_override >= 0) { + body.price_sats_override = formInput.price_sats_override + } + + const resp = await adminCall(LICENSING_URL, store.admin_api_key, '/v1/admin/policies', { + method: 'POST', + body: JSON.stringify(body), + }) + if (!resp.ok) { + throw new Error(`Create policy failed: HTTP ${resp.status} — ${await resp.text()}`) + } + const policy = (await resp.json()) as { id: string; slug: string; name: string } + return { + message: + `Created policy '${policy.slug}' (id ${policy.id}).\n` + + (formInput.slug === 'default' + ? 'Because the slug is "default", this policy will be used by the public purchase flow.' + : 'Use this slug when calling "Issue license manually".'), + } + }, +) diff --git a/startos/actions/createProduct.ts b/startos/actions/createProduct.ts new file mode 100644 index 0000000..969f18c --- /dev/null +++ b/startos/actions/createProduct.ts @@ -0,0 +1,79 @@ +// Action: create a new product for sale. +// +// Hits the service's admin API through localhost using the in-store admin +// key. No need for the operator to touch curl or handle tokens. + +import { sdk } from '../sdk' +import { adminCall, LICENSING_URL } from '../utils' + +const input = sdk.InputSpec.of({ + slug: { + type: 'text', + name: 'Slug', + description: 'URL-safe short name, e.g., "my-app". Used in product links.', + required: true, + default: null, + patterns: [{ regex: '^[a-z0-9-]{2,40}$', description: 'lowercase letters, digits, and dashes' }], + }, + name: { + type: 'text', + name: 'Name', + description: 'Display name shown to buyers.', + required: true, + default: null, + }, + description: { + type: 'textarea', + name: 'Description', + description: 'Public description of what the buyer is getting.', + required: false, + default: null, + }, + price_sats: { + type: 'number', + name: 'Price (sats)', + description: 'Price per license in satoshis. 100,000,000 sats = 1 BTC.', + required: true, + default: null, + min: 1, + max: null, + integer: true, + }, +}) + +export const createProduct = sdk.Action.withInput( + 'createProduct', + async ({ effects }) => ({ + name: 'Create product', + description: 'Add a new product that can be purchased through this service.', + warning: null, + allowedStatuses: 'only-running', + group: 'Products', + visibility: 'enabled', + }), + input, + async ({ effects, input: formInput }) => { + const store = await sdk.store.getOwn(effects, sdk.StorePath).const() + const resp = await adminCall(LICENSING_URL, store.admin_api_key, '/v1/admin/products', { + method: 'POST', + body: JSON.stringify({ + slug: formInput.slug, + name: formInput.name, + description: formInput.description ?? '', + price_sats: formInput.price_sats, + metadata: {}, + }), + }) + if (!resp.ok) { + throw new Error(`Create product failed: HTTP ${resp.status} — ${await resp.text()}`) + } + const body = await resp.json() + return { + message: + `Created product '${body.slug}' (id ${body.id}).\n` + + `Priced at ${body.price_sats} sats.\n\n` + + `Buyers can purchase by POSTing to your Keysat URL:\n` + + `/v1/purchase with body: {"product":"${body.slug}"}`, + } + }, +) diff --git a/startos/actions/deactivateMachine.ts b/startos/actions/deactivateMachine.ts new file mode 100644 index 0000000..951ff74 --- /dev/null +++ b/startos/actions/deactivateMachine.ts @@ -0,0 +1,57 @@ +// Action: force-kick an install off a license. +// +// The buyer's copy on that device will fail its next online validation +// with `not_activated`, freeing up a seat for another install. + +import { sdk } from '../sdk' +import { adminCall, LICENSING_URL } from '../utils' + +const input = sdk.InputSpec.of({ + machine_id: { + type: 'text', + name: 'Machine ID', + description: 'UUID of the machine to deactivate. Find via list-machines.', + required: true, + default: null, + }, + reason: { + type: 'text', + name: 'Reason', + description: 'Stored for audit. E.g., "laptop stolen", "support request".', + required: false, + default: null, + }, +}) + +export const deactivateMachine = sdk.Action.withInput( + 'deactivateMachine', + async ({ effects }) => ({ + name: 'Deactivate machine', + description: + 'Force an install off a license. Frees up a seat and causes that ' + + "install's next online validation to fail.", + warning: + 'The affected client may continue running from cache until its grace ' + + 'window expires.', + allowedStatuses: 'only-running', + group: 'Machines', + visibility: 'enabled', + }), + input, + async ({ effects, input: formInput }) => { + const store = await sdk.store.getOwn(effects, sdk.StorePath).const() + const resp = await adminCall( + LICENSING_URL, + store.admin_api_key, + `/v1/admin/machines/${encodeURIComponent(formInput.machine_id)}/deactivate`, + { + method: 'POST', + body: JSON.stringify({ reason: formInput.reason ?? '' }), + }, + ) + if (!resp.ok) { + throw new Error(`Deactivate failed: HTTP ${resp.status} — ${await resp.text()}`) + } + return { message: `Deactivated machine ${formInput.machine_id}.` } + }, +) diff --git a/startos/actions/index.ts b/startos/actions/index.ts new file mode 100644 index 0000000..dcc6aa9 --- /dev/null +++ b/startos/actions/index.ts @@ -0,0 +1,44 @@ +// Register every action with StartOS. + +import { sdk } from '../sdk' +import { btcpayStatus, configureBtcpay } from './configureBtcpay' +import { createPolicy } from './createPolicy' +import { createProduct } from './createProduct' +import { deactivateMachine } from './deactivateMachine' +import { issueLicense } from './issueLicense' +import { listMachines } from './listMachines' +import { listWebhooks } from './listWebhooks' +import { registerWebhook } from './registerWebhook' +import { revokeLicense } from './revokeLicense' +import { searchLicenses } from './searchLicenses' +import { setOperatorName } from './setOperatorName' +import { showCredentials } from './showCredentials' +import { suspendLicense } from './suspendLicense' +import { unsuspendLicense } from './unsuspendLicense' +import { viewAuditLog } from './viewAuditLog' + +export const actions = sdk.Actions.of() + // General + .addAction(setOperatorName) + // BTCPay + .addAction(configureBtcpay) + .addAction(btcpayStatus) + // Credentials + .addAction(showCredentials) + // Products + Policies + .addAction(createProduct) + .addAction(createPolicy) + // Licenses + .addAction(issueLicense) + .addAction(searchLicenses) + .addAction(suspendLicense) + .addAction(unsuspendLicense) + .addAction(revokeLicense) + // Machines + .addAction(listMachines) + .addAction(deactivateMachine) + // Webhooks + .addAction(registerWebhook) + .addAction(listWebhooks) + // Diagnostics + .addAction(viewAuditLog) diff --git a/startos/actions/issueLicense.ts b/startos/actions/issueLicense.ts new file mode 100644 index 0000000..aff19bf --- /dev/null +++ b/startos/actions/issueLicense.ts @@ -0,0 +1,53 @@ +// Action: manually issue a license for a product (comp, press, dev). + +import { sdk } from '../sdk' +import { adminCall, LICENSING_URL } from '../utils' + +const input = sdk.InputSpec.of({ + product_slug: { + type: 'text', + name: 'Product slug', + description: 'Which product to issue a license for.', + required: true, + default: null, + }, + note: { + type: 'text', + name: 'Note (optional)', + description: 'Audit trail — e.g., "comp for @alice", "press key".', + required: false, + default: null, + }, +}) + +export const issueLicense = sdk.Action.withInput( + 'issueLicense', + async ({ effects }) => ({ + name: 'Issue license manually', + description: 'Generate a license key outside the purchase flow. Useful for comps and press.', + warning: null, + allowedStatuses: 'only-running', + group: 'Licenses', + visibility: 'enabled', + }), + input, + async ({ effects, input: formInput }) => { + const store = await sdk.store.getOwn(effects, sdk.StorePath).const() + const resp = await adminCall(LICENSING_URL, store.admin_api_key, '/v1/admin/licenses', { + method: 'POST', + body: JSON.stringify({ + product_slug: formInput.product_slug, + note: formInput.note ?? null, + }), + }) + if (!resp.ok) { + throw new Error(`Issue failed: HTTP ${resp.status} — ${await resp.text()}`) + } + const body = await resp.json() + return { + message: + `License issued.\nID: ${body.license_id}\n\n` + + `Key (give this to the recipient):\n${body.license_key}`, + } + }, +) diff --git a/startos/actions/listMachines.ts b/startos/actions/listMachines.ts new file mode 100644 index 0000000..4d34f5a --- /dev/null +++ b/startos/actions/listMachines.ts @@ -0,0 +1,85 @@ +// Action: list machines (installs) currently bound to a license. +// +// Useful when a buyer asks "which devices am I active on?" or when +// troubleshooting a multi-seat cap ("can't activate, too many machines"). + +import { sdk } from '../sdk' +import { adminCall, LICENSING_URL } from '../utils' + +const input = sdk.InputSpec.of({ + license_id: { + type: 'text', + name: 'License ID', + description: 'UUID of the license to inspect.', + required: true, + default: null, + }, + include_inactive: { + type: 'toggle', + name: 'Include deactivated machines', + description: 'Show rows for machines that were previously deactivated.', + default: false, + }, +}) + +export const listMachines = sdk.Action.withInput( + 'listMachines', + async ({ effects }) => ({ + name: 'List machines', + description: 'Show installs currently bound to a license.', + warning: null, + allowedStatuses: 'only-running', + group: 'Machines', + visibility: 'enabled', + }), + input, + async ({ effects, input: formInput }) => { + const store = await sdk.store.getOwn(effects, sdk.StorePath).const() + const params = new URLSearchParams() + params.set('license_id', formInput.license_id) + if (formInput.include_inactive) params.set('include_inactive', 'true') + + const resp = await adminCall( + LICENSING_URL, + store.admin_api_key, + `/v1/admin/machines?${params.toString()}`, + { method: 'GET' }, + ) + if (!resp.ok) { + throw new Error(`List machines failed: HTTP ${resp.status} — ${await resp.text()}`) + } + const body = (await resp.json()) as { + machines: Array<{ + id: string + active: number | boolean + hostname: string | null + platform: string | null + last_heartbeat_at: string | null + activated_at: string + fingerprint_hash: string + }> + } + if (body.machines.length === 0) { + return { message: 'No machines bound to this license.' } + } + const lines = body.machines.map((m) => { + const activeStr = + m.active === true || m.active === 1 ? 'ACTIVE' : 'deactivated' + const bits = [ + m.id, + activeStr, + m.hostname ?? 'unknown host', + m.platform ?? '', + `fp=${m.fingerprint_hash.slice(0, 12)}…`, + `last_hb=${m.last_heartbeat_at ?? 'never'}`, + ] + return '• ' + bits.filter(Boolean).join(' ') + }) + return { + message: + `${body.machines.length} machine(s) on license ${formInput.license_id}:\n\n` + + lines.join('\n') + + '\n\nTo free a seat, use the "Deactivate machine" action with the machine id.', + } + }, +) diff --git a/startos/actions/listWebhooks.ts b/startos/actions/listWebhooks.ts new file mode 100644 index 0000000..3f5d456 --- /dev/null +++ b/startos/actions/listWebhooks.ts @@ -0,0 +1,56 @@ +// Action: list currently registered outbound webhook endpoints. +// +// Shows each endpoint's id, URL, event list, and active flag. Secrets are +// masked — rotate by deleting and recreating an endpoint. + +import { sdk } from '../sdk' +import { adminCall, LICENSING_URL } from '../utils' + +export const listWebhooks = sdk.Action.withoutInput( + 'listWebhooks', + async ({ effects }) => ({ + name: 'List webhook endpoints', + description: 'Show all currently-registered outbound webhook subscribers.', + warning: null, + allowedStatuses: 'only-running', + group: 'Webhooks', + visibility: 'enabled', + }), + async ({ effects }) => { + const store = await sdk.store.getOwn(effects, sdk.StorePath).const() + const resp = await adminCall( + LICENSING_URL, + store.admin_api_key, + '/v1/admin/webhook-endpoints', + { method: 'GET' }, + ) + if (!resp.ok) { + throw new Error(`List webhooks failed: HTTP ${resp.status} — ${await resp.text()}`) + } + const body = (await resp.json()) as { + endpoints: Array<{ + id: string + url: string + event_types: string[] + active: number | boolean + description: string + }> + } + if (body.endpoints.length === 0) { + return { + message: + 'No webhook endpoints registered. Use "Register webhook endpoint" ' + + 'to add one.', + } + } + const lines = body.endpoints.map((ep) => { + const activeStr = ep.active === true || ep.active === 1 ? 'active' : 'disabled' + return `• ${ep.id} [${activeStr}] ${ep.url} events=${ep.event_types.join(',')}` + + (ep.description ? ` ("${ep.description}")` : '') + }) + return { + message: + `${body.endpoints.length} endpoint(s):\n\n` + lines.join('\n'), + } + }, +) diff --git a/startos/actions/registerWebhook.ts b/startos/actions/registerWebhook.ts new file mode 100644 index 0000000..aa82089 --- /dev/null +++ b/startos/actions/registerWebhook.ts @@ -0,0 +1,97 @@ +// Action: register an outbound webhook subscriber. +// +// After registration, Keysat will POST signed JSON bodies to the URL when +// relevant events fire (license.issued, license.revoked, machine.activated, +// machine.deactivated, invoice.settled, etc.). Signatures use HMAC-SHA256 +// over the body, carried in the `X-Keysat-Signature` header as +// `sha256=` — same shape as BTCPay's outbound hooks. + +import { sdk } from '../sdk' +import { adminCall, LICENSING_URL } from '../utils' + +const input = sdk.InputSpec.of({ + url: { + type: 'text', + name: 'Webhook URL', + description: 'HTTPS endpoint that will receive POSTed event bodies.', + required: true, + default: null, + patterns: [{ regex: '^https?://', description: 'must be an HTTP(S) URL' }], + }, + event_types: { + type: 'text', + name: 'Event types', + description: + 'Comma-separated list of events to subscribe to, or "*" for all. ' + + 'E.g., "license.issued,license.revoked". Known events: license.issued, ' + + 'license.revoked, license.suspended, license.unsuspended, ' + + 'machine.activated, machine.deactivated, invoice.settled.', + required: true, + default: '*', + }, + description: { + type: 'text', + name: 'Description', + description: 'Free-form label, shown in the admin list.', + required: false, + default: null, + }, +}) + +export const registerWebhook = sdk.Action.withInput( + 'registerWebhook', + async ({ effects }) => ({ + name: 'Register webhook endpoint', + description: + 'Tell Keysat to POST signed event notifications to an HTTPS URL you ' + + 'control. A fresh HMAC secret is generated and shown once — save it.', + warning: + 'The HMAC secret is returned in plaintext exactly once, on creation. ' + + 'If you lose it, you will need to delete and recreate the endpoint.', + allowedStatuses: 'only-running', + group: 'Webhooks', + visibility: 'enabled', + }), + input, + async ({ effects, input: formInput }) => { + const store = await sdk.store.getOwn(effects, sdk.StorePath).const() + const eventTypes = formInput.event_types + .split(',') + .map((s) => s.trim()) + .filter((s) => s.length > 0) + if (eventTypes.length === 0) { + throw new Error('Provide at least one event type (or "*" for all).') + } + const resp = await adminCall( + LICENSING_URL, + store.admin_api_key, + '/v1/admin/webhook-endpoints', + { + method: 'POST', + body: JSON.stringify({ + url: formInput.url, + event_types: eventTypes, + description: formInput.description ?? '', + }), + }, + ) + if (!resp.ok) { + throw new Error(`Register webhook failed: HTTP ${resp.status} — ${await resp.text()}`) + } + const ep = (await resp.json()) as { + id: string + url: string + secret: string + event_types: string[] + } + return { + message: + `Registered webhook endpoint (id ${ep.id}).\n` + + `URL: ${ep.url}\n` + + `Events: ${ep.event_types.join(', ')}\n\n` + + `HMAC secret (save this now — will not be shown again):\n${ep.secret}\n\n` + + `Verify incoming requests with header X-Keysat-Signature: sha256= ` + + `(HMAC-SHA256 of the raw request body using this secret).`, + } + }, +) diff --git a/startos/actions/revokeLicense.ts b/startos/actions/revokeLicense.ts new file mode 100644 index 0000000..a12e855 --- /dev/null +++ b/startos/actions/revokeLicense.ts @@ -0,0 +1,53 @@ +// Action: revoke an existing license. + +import { sdk } from '../sdk' +import { adminCall, LICENSING_URL } from '../utils' + +const input = sdk.InputSpec.of({ + license_id: { + type: 'text', + name: 'License ID', + description: 'UUID of the license to revoke. Find via list-licenses action.', + required: true, + default: null, + }, + reason: { + type: 'text', + name: 'Reason', + description: 'Stored for audit. E.g., "chargeback", "key leaked".', + required: false, + default: null, + }, +}) + +export const revokeLicense = sdk.Action.withInput( + 'revokeLicense', + async ({ effects }) => ({ + name: 'Revoke license', + description: + 'Mark a license as revoked (one-way; use "Suspend license" for a ' + + 'reversible lockout). The next time downstream software checks ' + + 'revocation, it will be denied.', + warning: 'Revocation takes effect on the next online validation. Clients with cached results may continue running until their cache expires.', + allowedStatuses: 'only-running', + group: 'Licenses', + visibility: 'enabled', + }), + input, + async ({ effects, input: formInput }) => { + const store = await sdk.store.getOwn(effects, sdk.StorePath).const() + const resp = await adminCall( + LICENSING_URL, + store.admin_api_key, + `/v1/admin/licenses/${encodeURIComponent(formInput.license_id)}/revoke`, + { + method: 'POST', + body: JSON.stringify({ reason: formInput.reason ?? 'admin revoke' }), + }, + ) + if (!resp.ok) { + throw new Error(`Revoke failed: HTTP ${resp.status} — ${await resp.text()}`) + } + return { message: `Revoked license ${formInput.license_id}.` } + }, +) diff --git a/startos/actions/searchLicenses.ts b/startos/actions/searchLicenses.ts new file mode 100644 index 0000000..3925a12 --- /dev/null +++ b/startos/actions/searchLicenses.ts @@ -0,0 +1,95 @@ +// Action: search licenses by buyer email / Nostr npub / invoice id. +// +// The typical use case is "a buyer emailed me saying they lost their key." +// Operator runs this with the buyer's email and gets back up to 100 +// matching licenses with IDs, product slugs, and current status. + +import { sdk } from '../sdk' +import { adminCall, LICENSING_URL } from '../utils' + +const input = sdk.InputSpec.of({ + buyer_email: { + type: 'text', + name: 'Buyer email', + description: 'Exact-match email address (leave blank if searching by another field).', + required: false, + default: null, + }, + nostr_npub: { + type: 'text', + name: 'Nostr npub', + description: 'Nostr public key (npub…). Optional.', + required: false, + default: null, + }, + invoice_id: { + type: 'text', + name: 'BTCPay invoice ID', + description: 'The BTCPay invoice ID associated with a purchase. Optional.', + required: false, + default: null, + }, +}) + +export const searchLicenses = sdk.Action.withInput( + 'searchLicenses', + async ({ effects }) => ({ + name: 'Search licenses', + description: + "Look up a buyer's licenses by email, Nostr npub, or BTCPay " + + 'invoice ID. Intended for "lost key recovery" support requests.', + warning: null, + allowedStatuses: 'only-running', + group: 'Licenses', + visibility: 'enabled', + }), + input, + async ({ effects, input: formInput }) => { + const store = await sdk.store.getOwn(effects, sdk.StorePath).const() + + const params = new URLSearchParams() + if (formInput.buyer_email) params.set('buyer_email', formInput.buyer_email) + if (formInput.nostr_npub) params.set('nostr_npub', formInput.nostr_npub) + if (formInput.invoice_id) params.set('invoice_id', formInput.invoice_id) + if ([...params.keys()].length === 0) { + throw new Error('Provide at least one search field (email, npub, or invoice).') + } + + const resp = await adminCall( + LICENSING_URL, + store.admin_api_key, + `/v1/admin/licenses/search?${params.toString()}`, + { method: 'GET' }, + ) + if (!resp.ok) { + throw new Error(`Search failed: HTTP ${resp.status} — ${await resp.text()}`) + } + const body = (await resp.json()) as { + licenses: Array<{ + id: string + product_id: string + status: string + buyer_email: string | null + issued_at: string + expires_at: string | null + }> + } + if (body.licenses.length === 0) { + return { message: 'No licenses matched.' } + } + const lines = body.licenses.map( + (l) => + `• ${l.id} (${l.status}) product=${l.product_id}` + + (l.buyer_email ? ` buyer=${l.buyer_email}` : '') + + ` issued=${l.issued_at}` + + (l.expires_at ? ` expires=${l.expires_at}` : ''), + ) + return { + message: + `Found ${body.licenses.length} license(s):\n\n` + + lines.join('\n') + + '\n\nTo reissue the key to the buyer, look up the license details ' + + 'via /v1/admin/licenses with the admin API key.', + } + }, +) diff --git a/startos/actions/setOperatorName.ts b/startos/actions/setOperatorName.ts new file mode 100644 index 0000000..5a42865 --- /dev/null +++ b/startos/actions/setOperatorName.ts @@ -0,0 +1,36 @@ +// Action: set the operator display name shown on the service homepage. + +import { sdk } from '../sdk' + +const input = sdk.InputSpec.of({ + operator_name: { + type: 'text', + name: 'Operator name', + description: + 'Displayed on the service homepage so buyers know whose Keysat ' + + 'instance they are interacting with. E.g., your name or business name.', + required: true, + default: null, + }, +}) + +export const setOperatorName = sdk.Action.withInput( + 'setOperatorName', + async ({ effects }) => ({ + name: 'Set operator name', + description: 'Edit the operator name shown publicly.', + warning: null, + allowedStatuses: 'any', + group: 'General', + visibility: 'enabled', + }), + input, + async ({ effects, input: formInput }) => { + const current = await sdk.store.getOwn(effects, sdk.StorePath).const() + await sdk.store.setOwn(effects, sdk.StorePath, { + ...current, + operator_name: formInput.operator_name, + }) + return { message: `Operator name set to ${formInput.operator_name}. Restart the service to apply.` } + }, +) diff --git a/startos/actions/showCredentials.ts b/startos/actions/showCredentials.ts new file mode 100644 index 0000000..71bf74a --- /dev/null +++ b/startos/actions/showCredentials.ts @@ -0,0 +1,36 @@ +// Action: reveal the auto-generated admin API key. +// +// The operator rarely needs this — every other action in StartOS already +// carries the key for them — but it's useful if they want to script against +// the admin HTTP API directly. +// +// The BTCPay webhook secret used to live in the StartOS store; it now lives +// inside the daemon's own SQLite database, generated automatically during +// the "Connect BTCPay" authorize flow. Operators don't need to know it. + +import { sdk } from '../sdk' + +export const showCredentials = sdk.Action.withoutInput( + 'showCredentials', + async ({ effects }) => ({ + name: 'Show admin API key', + description: + 'Display the auto-generated admin API key. Treat it like a password — ' + + 'anyone with this key can mint and revoke licenses on this server.', + warning: + 'Anyone with this value has full control of your Keysat server. ' + + 'Do not share it.', + allowedStatuses: 'any', + group: 'Credentials', + visibility: 'enabled', + }), +).withoutRunner(async ({ effects }) => { + const store = await sdk.store.getOwn(effects, sdk.StorePath).const() + return { + message: + `Admin API key:\n${store.admin_api_key}\n\n` + + `Used as 'Authorization: Bearer ' against /v1/admin/*. All ` + + `StartOS actions already supply this for you — only export it if ` + + `you intend to script against the admin API from outside the box.`, + } +}) diff --git a/startos/actions/suspendLicense.ts b/startos/actions/suspendLicense.ts new file mode 100644 index 0000000..95a233f --- /dev/null +++ b/startos/actions/suspendLicense.ts @@ -0,0 +1,59 @@ +// Action: suspend an existing license (reversible). +// +// Unlike revoke (which is one-way), suspend temporarily blocks validation +// and can be cleared with the "Unsuspend" action. Useful for payment +// disputes where the outcome isn't yet known. + +import { sdk } from '../sdk' +import { adminCall, LICENSING_URL } from '../utils' + +const input = sdk.InputSpec.of({ + license_id: { + type: 'text', + name: 'License ID', + description: 'UUID of the license to suspend. Find via search-licenses action.', + required: true, + default: null, + }, + reason: { + type: 'text', + name: 'Reason', + description: 'Stored for audit. E.g., "payment dispute pending".', + required: false, + default: null, + }, +}) + +export const suspendLicense = sdk.Action.withInput( + 'suspendLicense', + async ({ effects }) => ({ + name: 'Suspend license', + description: + 'Temporarily disable a license. Validation calls will fail with a ' + + '`suspended` status until you unsuspend. Use this for reversible ' + + 'situations (e.g., payment disputes) instead of revoke.', + warning: + 'Suspension takes effect on the next online validation. Clients with ' + + 'cached results may continue running until their cache expires.', + allowedStatuses: 'only-running', + group: 'Licenses', + visibility: 'enabled', + }), + input, + async ({ effects, input: formInput }) => { + const store = await sdk.store.getOwn(effects, sdk.StorePath).const() + const resp = await adminCall( + LICENSING_URL, + store.admin_api_key, + `/v1/admin/licenses/${encodeURIComponent(formInput.license_id)}/suspend`, + { + method: 'POST', + body: JSON.stringify({ reason: formInput.reason ?? 'admin suspend' }), + }, + ) + if (!resp.ok) { + throw new Error(`Suspend failed: HTTP ${resp.status} — ${await resp.text()}`) + } + return { message: `Suspended license ${formInput.license_id}.` } + }, +) diff --git a/startos/actions/unsuspendLicense.ts b/startos/actions/unsuspendLicense.ts new file mode 100644 index 0000000..e63e45e --- /dev/null +++ b/startos/actions/unsuspendLicense.ts @@ -0,0 +1,43 @@ +// Action: clear a previously-applied suspension. + +import { sdk } from '../sdk' +import { adminCall, LICENSING_URL } from '../utils' + +const input = sdk.InputSpec.of({ + license_id: { + type: 'text', + name: 'License ID', + description: 'UUID of the suspended license to re-enable.', + required: true, + default: null, + }, +}) + +export const unsuspendLicense = sdk.Action.withInput( + 'unsuspendLicense', + async ({ effects }) => ({ + name: 'Unsuspend license', + description: + 'Lift a previous suspension. Validation will succeed again on the ' + + 'next call. Has no effect if the license is already active or if it ' + + 'has been revoked.', + warning: null, + allowedStatuses: 'only-running', + group: 'Licenses', + visibility: 'enabled', + }), + input, + async ({ effects, input: formInput }) => { + const store = await sdk.store.getOwn(effects, sdk.StorePath).const() + const resp = await adminCall( + LICENSING_URL, + store.admin_api_key, + `/v1/admin/licenses/${encodeURIComponent(formInput.license_id)}/unsuspend`, + { method: 'POST', body: JSON.stringify({}) }, + ) + if (!resp.ok) { + throw new Error(`Unsuspend failed: HTTP ${resp.status} — ${await resp.text()}`) + } + return { message: `Unsuspended license ${formInput.license_id}.` } + }, +) diff --git a/startos/actions/viewAuditLog.ts b/startos/actions/viewAuditLog.ts new file mode 100644 index 0000000..348099a --- /dev/null +++ b/startos/actions/viewAuditLog.ts @@ -0,0 +1,88 @@ +// Action: view recent admin audit log entries. +// +// Every admin mutation writes an audit row recording: who (hashed bearer +// token), what (action slug), target id, client IP, user agent, and a +// free-form JSON detail blob. This action surfaces them in StartOS so the +// operator can skim without curl. + +import { sdk } from '../sdk' +import { adminCall, LICENSING_URL } from '../utils' + +const input = sdk.InputSpec.of({ + limit: { + type: 'number', + name: 'Limit', + description: 'Number of most recent entries to return (1–1000).', + required: true, + default: 50, + min: 1, + max: 1000, + integer: true, + }, + action: { + type: 'text', + name: 'Filter action', + description: + 'Optional action slug to filter on. E.g., "license.revoke", ' + + '"license.suspend", "policy.create", "webhook_endpoint.create".', + required: false, + default: null, + }, +}) + +export const viewAuditLog = sdk.Action.withInput( + 'viewAuditLog', + async ({ effects }) => ({ + name: 'View audit log', + description: + 'Show the most recent admin mutations recorded by the service — ' + + 'useful for compliance, debugging, or checking what an API-key holder ' + + 'has been up to.', + warning: null, + allowedStatuses: 'only-running', + group: 'Diagnostics', + visibility: 'enabled', + }), + input, + async ({ effects, input: formInput }) => { + const store = await sdk.store.getOwn(effects, sdk.StorePath).const() + const params = new URLSearchParams() + params.set('limit', String(formInput.limit)) + if (formInput.action) params.set('action', formInput.action) + + const resp = await adminCall( + LICENSING_URL, + store.admin_api_key, + `/v1/admin/audit?${params.toString()}`, + { method: 'GET' }, + ) + if (!resp.ok) { + throw new Error(`Audit fetch failed: HTTP ${resp.status} — ${await resp.text()}`) + } + const body = (await resp.json()) as { + entries: Array<{ + id: string + created_at: string + action: string + target_type: string | null + target_id: string | null + actor_hash: string | null + client_ip: string | null + detail: unknown + }> + } + if (body.entries.length === 0) { + return { message: 'No audit entries match the filter.' } + } + const lines = body.entries.map((e) => { + const target = e.target_type && e.target_id ? `${e.target_type}:${e.target_id}` : '(no target)' + const actor = e.actor_hash ? `actor=${e.actor_hash.slice(0, 10)}…` : 'actor=?' + const ip = e.client_ip ? `ip=${e.client_ip}` : '' + return `• ${e.created_at} ${e.action} ${target} ${actor} ${ip}` + }) + return { + message: + `${body.entries.length} entry(ies):\n\n` + lines.join('\n'), + } + }, +) diff --git a/startos/backups.ts b/startos/backups.ts new file mode 100644 index 0000000..f785553 --- /dev/null +++ b/startos/backups.ts @@ -0,0 +1,11 @@ +// Backup & restore. +// +// Everything important lives in the `main` volume (SQLite DB, which in turn +// contains the signing key). StartOS's default backup mechanism captures +// the whole volume, so we don't need custom backup logic — we just opt in. + +import { sdk } from './sdk' + +export const { createBackup, restoreBackup } = sdk.setupBackups(async ({ effects }) => [ + sdk.Backups.volumes('main'), +]) diff --git a/startos/dependencies.ts b/startos/dependencies.ts new file mode 100644 index 0000000..ca9777a --- /dev/null +++ b/startos/dependencies.ts @@ -0,0 +1,16 @@ +// Declare our dependency on BTCPay Server. StartOS uses this to: +// - prevent starting if BTCPay isn't installed, +// - gate our service's health status on BTCPay's, +// - provide the `btcpayserver.startos` hostname inside our container. + +import { sdk } from './sdk' + +export const setDependencies = sdk.setupDependencies(async ({ effects }) => { + return { + btcpayserver: { + kind: 'running', + versionRange: '>=1.11.0', + healthChecks: [], + }, + } +}) diff --git a/startos/fileModels/store.ts b/startos/fileModels/store.ts new file mode 100644 index 0000000..6057536 --- /dev/null +++ b/startos/fileModels/store.ts @@ -0,0 +1,29 @@ +// Package-local persistent state. This is separate from the SQLite database +// inside the container — it's metadata the StartOS wrapper needs to remember +// between service starts (e.g., the generated admin API key so we don't +// regenerate it on every restart). +// +// StartOS persists this JSON through upgrades and backs it up automatically. + +import { matches } from '@start9labs/start-sdk' + +const { arr, num, obj, oneOf, literal, string } = matches + +export const storeShape = obj({ + // Admin API key for /v1/admin/* endpoints. Auto-generated on first init. + admin_api_key: string, + // Shared webhook secret configured on both sides (BTCPay + our service). + btcpay_webhook_secret: string, + // Operator display name shown on the service homepage. + operator_name: string, + // Tracks which version's init has already been applied. + schema_version: num, +}) + +export type Store = typeof storeShape._TYPE + +export const store = { + shape: storeShape, + // Defaults. Populated for real during init. + path: 'store.json' as const, +} diff --git a/startos/index.ts b/startos/index.ts new file mode 100644 index 0000000..9cddc8c --- /dev/null +++ b/startos/index.ts @@ -0,0 +1,29 @@ +// StartOS entry point. Glues every module together so `start-cli` can pack +// the package. + +import { sdk } from './sdk' + +import { actions } from './actions' +import { createBackup, restoreBackup } from './backups' +import { setDependencies } from './dependencies' +import { initFn, uninitFn } from './init' +import { setInterfaces } from './interfaces' +import { main } from './main' +import { manifest } from './manifest' +import { versions } from './versions' + +export const { packageInit, packageUninit, containerInit } = sdk.setupPackageInit({ + init: initFn, + uninit: uninitFn, +}) + +export { + manifest, + main, + actions, + setDependencies, + setInterfaces, + createBackup, + restoreBackup, + versions, +} diff --git a/startos/init/index.ts b/startos/init/index.ts new file mode 100644 index 0000000..381d879 --- /dev/null +++ b/startos/init/index.ts @@ -0,0 +1,34 @@ +// First-boot initialization. +// +// On fresh install: +// - Generate an admin API key (stored in the StartOS store; user can +// retrieve it via an action if they need to script against the API). +// +// The BTCPay webhook secret is no longer stored here — the daemon generates +// and persists it in its own DB during the one-click "Connect BTCPay" flow. +// The field is kept in the store shape for backward compatibility with +// installs made before v0.1.0; it is not used. +// +// On subsequent boots this is a no-op (keys already exist). + +import { sdk } from '../sdk' +import { generateSecret } from '../utils' + +export const initFn = sdk.setupOnInit(async ({ effects }) => { + const current = await sdk.store.getOwn(effects, sdk.StorePath).const() + + if (!current || current.schema_version === 0 || current.schema_version === undefined) { + await sdk.store.setOwn(effects, sdk.StorePath, { + admin_api_key: current?.admin_api_key || generateSecret(32), + // Kept in the shape for backcompat; no longer authoritative. + btcpay_webhook_secret: current?.btcpay_webhook_secret || '', + operator_name: current?.operator_name || '', + schema_version: 1, + }) + } +}) + +export const uninitFn = sdk.setupOnUninit(async ({ effects }) => { + // Nothing to tear down at the StartOS level — the DB volume is handled by + // StartOS directly when the package is uninstalled. +}) diff --git a/startos/interfaces.ts b/startos/interfaces.ts new file mode 100644 index 0000000..7754f94 --- /dev/null +++ b/startos/interfaces.ts @@ -0,0 +1,38 @@ +// Network interfaces exposed by the service. +// +// Two logical interfaces: +// - `api` — the REST API that buyers (purchase flow) and licensed +// software (validate flow) hit. Must be reachable from +// outside the StartOS host if you're selling to the public, +// so we expose it on LAN + Tor + optional clearnet. +// - `webhook` — the BTCPay webhook landing endpoint. Only BTCPay needs to +// reach it; same-host LAN is sufficient. +// +// In practice both live on the same HTTP port (8080) because the service +// routes by path. StartOS's interface concept is about *access surfaces* +// and *display grouping*, not separate ports. + +import { sdk } from './sdk' + +export const setInterfaces = sdk.setupInterfaces(async ({ effects }) => { + const apiMulti = sdk.MultiHost.of(effects, 'api-multi') + await apiMulti.bindPort(8080, { protocol: 'http', preferredExternalPort: 443 }) + + const api = sdk.createInterface(effects, { + name: 'Licensing API', + id: 'api', + description: + 'REST API for buyers and licensed software. Public-facing: this is ' + + 'the URL you share with customers and bake into your own software ' + + 'builds as the licensing endpoint.', + type: 'api', + hasPrimary: true, + masked: false, + schemeOverride: null, + username: null, + path: '', + query: {}, + }) + + return [await api.export([apiMulti])] +}) diff --git a/startos/main.ts b/startos/main.ts new file mode 100644 index 0000000..1498dad --- /dev/null +++ b/startos/main.ts @@ -0,0 +1,57 @@ +// Daemon definition — the thing that actually runs when the service is +// started. Passes configuration into the Rust binary via environment +// variables, same interface as `.env.example` in the upstream project. + +import { sdk } from './sdk' + +export const main = sdk.setupMain(async ({ effects, started }) => { + const store = await sdk.store.getOwn(effects, sdk.StorePath).const() + + // Public URL the service advertises to buyers / referenced in webhooks. + // We read our own primary interface address from StartOS at runtime so + // this works whether the operator exposes on Tor, LAN, or clearnet. + const publicUrl = await sdk.serviceInterface + .getOwn(effects, 'api') + .const() + .then((i) => i?.addressInfo?.urls?.[0] ?? 'http://localhost:8080') + + const sub = await sdk.SubContainer.of( + effects, + { imageId: 'main' }, + [{ mountpoint: '/data', volumeId: 'main', subpath: null, readonly: false }], + 'keysat', + ) + + return sdk.Daemons.of({ effects, started, healthReceipts: [] }).addDaemon('primary', { + subcontainer: sub, + exec: { + command: sdk.useEntrypoint(), + env: { + KEYSAT_BIND: '0.0.0.0:8080', + KEYSAT_DB_PATH: '/data/keysat.db', + KEYSAT_PUBLIC_URL: publicUrl, + KEYSAT_ADMIN_API_KEY: store.admin_api_key, + KEYSAT_OPERATOR_NAME: store.operator_name, + // Reachable because of our dependency on btcpayserver. + BTCPAY_URL: 'http://btcpayserver.startos:23000', + // The three credentials below are left empty in the normal case — + // the daemon now persists them in its own DB after the one-click + // "Connect BTCPay" action completes. Only seed them here if you are + // migrating from a pre-authorize-flow install. + BTCPAY_API_KEY: '', + BTCPAY_STORE_ID: '', + BTCPAY_WEBHOOK_SECRET: '', + RUST_LOG: 'info,sqlx=warn,hyper=warn', + }, + }, + ready: { + display: 'API', + fn: () => + sdk.healthCheck.checkPortListening(effects, 8080, { + successMessage: 'Keysat API is accepting requests', + errorMessage: 'Keysat API is not responding on port 8080', + }), + }, + requires: [], + }) +}) diff --git a/startos/manifest/i18n.ts b/startos/manifest/i18n.ts new file mode 100644 index 0000000..bcfdcda --- /dev/null +++ b/startos/manifest/i18n.ts @@ -0,0 +1,16 @@ +// Human-readable description strings, separated so they can be translated +// later. Only English is filled in here; add more locales as needed. + +export const short = { + en_US: 'Keysat — self-hosted Bitcoin-paid software license server.', +} + +export const long = { + en_US: `Keysat lets you sell licenses to your own software products using +Bitcoin payments via BTCPay Server. Every instance runs on the operator's own +StartOS — there is no central authority. The service issues Ed25519-signed +license keys that downstream software can verify offline, with optional +expiry, entitlements, fingerprint binding, and per-seat activation caps. +Supports multiple products per instance and closed-source, open-core, and +open-source distribution models.`, +} diff --git a/startos/manifest/index.ts b/startos/manifest/index.ts new file mode 100644 index 0000000..c0dcba7 --- /dev/null +++ b/startos/manifest/index.ts @@ -0,0 +1,64 @@ +// StartOS package manifest. Run through `setupManifest()` from the SDK. +// +// NOTE: This service's source code is source-available but not open source +// (see ../../../licensing-service/LICENSE). The `license` field here is +// set to 'Proprietary' accordingly — StartOS displays this on the install +// page so users know what they're installing. + +import { setupManifest } from '@start9labs/start-sdk' +import { short, long } from './i18n' + +export const manifest = setupManifest({ + id: 'keysat', + title: 'Keysat', + license: 'Proprietary', + packageRepo: 'https://github.com/ten31/keysat-startos', + upstreamRepo: 'https://github.com/ten31/keysat', + marketingUrl: 'https://ten31.xyz/keysat', + donationUrl: null, + docsUrls: [ + 'https://github.com/ten31/keysat/blob/main/README.md', + 'https://github.com/ten31/keysat/blob/main/docs/INTEGRATION.md', + ], + description: { short, long }, + // A single data volume holds the SQLite database (which in turn holds the + // server signing key). StartOS encrypts and backs this up automatically. + volumes: ['main'], + images: { + main: { + // Built from the project's Dockerfile. The build context is the parent + // `Licensing/` directory so the Dockerfile can COPY from the sibling + // `licensing-service/` Rust source; a top-level .dockerignore keeps the + // uploaded context small. + source: { + dockerBuild: { + workdir: '..', + dockerfile: 'licensing-service-startos/Dockerfile', + }, + }, + arch: ['x86_64', 'aarch64'], + }, + }, + alerts: { + install: null, + update: null, + uninstall: { + en_US: + 'Uninstalling will delete your server signing key and all license ' + + 'records. Previously-issued license keys will no longer validate ' + + 'against this server. Back up first if you plan to reinstall.', + }, + restore: null, + start: null, + stop: null, + }, + dependencies: { + btcpayserver: { + description: 'Required to receive Bitcoin payments and confirm settlement via webhook.', + optional: false, + metadata: { + title: 'BTCPay Server', + }, + }, + }, +}) diff --git a/startos/sdk.ts b/startos/sdk.ts new file mode 100644 index 0000000..e4f698b --- /dev/null +++ b/startos/sdk.ts @@ -0,0 +1,9 @@ +// Re-export of the SDK pre-bound to our manifest and file models. Import +// `sdk` from here everywhere else in `startos/` so every call benefits from +// the typed narrowing of our package-specific store shape. + +import { StartSdk } from '@start9labs/start-sdk' +import { manifest } from './manifest' +import { store } from './fileModels/store' + +export const sdk = StartSdk.of().withManifest(manifest).withStore(store).build(true) diff --git a/startos/utils.ts b/startos/utils.ts new file mode 100644 index 0000000..b156794 --- /dev/null +++ b/startos/utils.ts @@ -0,0 +1,28 @@ +// Small helpers used across actions and init. + +import { randomBytes } from 'crypto' + +/** Generate a hex string secret (default 32 bytes = 64 hex chars). */ +export function generateSecret(bytes = 32): string { + return randomBytes(bytes).toString('hex') +} + +/** Thin wrapper around fetch that attaches the admin bearer token. */ +export async function adminCall( + baseUrl: string, + adminKey: string, + path: string, + init: RequestInit = {}, +): Promise { + return fetch(`${baseUrl}${path}`, { + ...init, + headers: { + ...(init.headers || {}), + 'content-type': 'application/json', + authorization: `Bearer ${adminKey}`, + }, + }) +} + +/** Resolve the in-container licensing API URL from inside action scripts. */ +export const LICENSING_URL = 'http://localhost:8080' diff --git a/startos/versions/index.ts b/startos/versions/index.ts new file mode 100644 index 0000000..488bc64 --- /dev/null +++ b/startos/versions/index.ts @@ -0,0 +1,3 @@ +import { v0_1_0 } from './v0.1.0' + +export const versions = { 'v0.1.0:0': v0_1_0 } diff --git a/startos/versions/v0.1.0.ts b/startos/versions/v0.1.0.ts new file mode 100644 index 0000000..7ed2977 --- /dev/null +++ b/startos/versions/v0.1.0.ts @@ -0,0 +1,18 @@ +// First version of the package. Migrations get added here as versions +// increment. For v0.1.0 there's nothing to migrate because nothing exists +// yet. + +import { sdk } from '../sdk' + +export const v0_1_0 = sdk.Version.of({ + version: '0.1.0:0', + releaseNotes: `Initial release:\n` + + `- Core licensing API: products, purchase, validate, revoke.\n` + + `- BTCPay Server integration with HMAC-verified webhooks.\n` + + `- Ed25519-signed license keys (offline-verifiable).\n` + + `- Admin actions in StartOS UI.\n`, + migrations: { + up: async ({ effects }) => {}, + down: async ({ effects }) => {}, + }, +}) diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..25903ba --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,16 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ES2022", + "moduleResolution": "bundler", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "declaration": true, + "sourceMap": true, + "outDir": "dist", + "rootDir": "startos" + }, + "include": ["startos/**/*"] +}