This commit is contained in:
MacPro
2026-04-22 17:46:43 -05:00
commit 432250bffc
41 changed files with 2223 additions and 0 deletions
+12
View File
@@ -0,0 +1,12 @@
# Build artifacts
*.s9pk
javascript/
# Node
node_modules/
# macOS / editor cruft
.DS_Store
*.swp
.vscode/
.idea/
+80
View File
@@ -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"]
+18
View File
@@ -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
+128
View File
@@ -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=<hex>`.
- **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.
+12
View File
@@ -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.
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 MiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 200 KiB

+318
View File
@@ -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"
}
}
}
}
+18
View File
@@ -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"
}
}
+128
View File
@@ -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
+15
View File
@@ -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.
+97
View File
@@ -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}`,
}
},
)
+148
View File
@@ -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<string, unknown> = {
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".'),
}
},
)
+79
View File
@@ -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` +
`<your Keysat URL>/v1/purchase with body: {"product":"${body.slug}"}`,
}
},
)
+57
View File
@@ -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}.` }
},
)
+44
View File
@@ -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)
+53
View File
@@ -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}`,
}
},
)
+85
View File
@@ -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.',
}
},
)
+56
View File
@@ -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'),
}
},
)
+97
View File
@@ -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=<hex>` — 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=<hex> ` +
`(HMAC-SHA256 of the raw request body using this secret).`,
}
},
)
+53
View File
@@ -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}.` }
},
)
+95
View File
@@ -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.',
}
},
)
+36
View File
@@ -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.` }
},
)
+36
View File
@@ -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 <key>' 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.`,
}
})
+59
View File
@@ -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}.` }
},
)
+43
View File
@@ -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}.` }
},
)
+88
View File
@@ -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 (11000).',
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'),
}
},
)
+11
View File
@@ -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'),
])
+16
View File
@@ -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: [],
},
}
})
+29
View File
@@ -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,
}
+29
View File
@@ -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,
}
+34
View File
@@ -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.
})
+38
View File
@@ -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])]
})
+57
View File
@@ -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: [],
})
})
+16
View File
@@ -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.`,
}
+64
View File
@@ -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',
},
},
},
})
+9
View File
@@ -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)
+28
View File
@@ -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<Response> {
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'
+3
View File
@@ -0,0 +1,3 @@
import { v0_1_0 } from './v0.1.0'
export const versions = { 'v0.1.0:0': v0_1_0 }
+18
View File
@@ -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 }) => {},
},
})
+16
View File
@@ -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/**/*"]
}