initial
This commit is contained in:
+12
@@ -0,0 +1,12 @@
|
|||||||
|
# Build artifacts
|
||||||
|
*.s9pk
|
||||||
|
javascript/
|
||||||
|
|
||||||
|
# Node
|
||||||
|
node_modules/
|
||||||
|
|
||||||
|
# macOS / editor cruft
|
||||||
|
.DS_Store
|
||||||
|
*.swp
|
||||||
|
.vscode/
|
||||||
|
.idea/
|
||||||
+80
@@ -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"]
|
||||||
@@ -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
|
||||||
@@ -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.
|
||||||
@@ -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 |
Generated
+318
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
@@ -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.
|
||||||
@@ -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}`,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
@@ -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".'),
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
@@ -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}"}`,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
@@ -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}.` }
|
||||||
|
},
|
||||||
|
)
|
||||||
@@ -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)
|
||||||
@@ -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}`,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
@@ -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.',
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
@@ -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'),
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
@@ -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).`,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
@@ -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}.` }
|
||||||
|
},
|
||||||
|
)
|
||||||
@@ -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.',
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
@@ -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.` }
|
||||||
|
},
|
||||||
|
)
|
||||||
@@ -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.`,
|
||||||
|
}
|
||||||
|
})
|
||||||
@@ -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}.` }
|
||||||
|
},
|
||||||
|
)
|
||||||
@@ -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}.` }
|
||||||
|
},
|
||||||
|
)
|
||||||
@@ -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'),
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
@@ -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'),
|
||||||
|
])
|
||||||
@@ -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: [],
|
||||||
|
},
|
||||||
|
}
|
||||||
|
})
|
||||||
@@ -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,
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
}
|
||||||
@@ -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.
|
||||||
|
})
|
||||||
@@ -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])]
|
||||||
|
})
|
||||||
@@ -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: [],
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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.`,
|
||||||
|
}
|
||||||
@@ -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',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
@@ -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)
|
||||||
@@ -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'
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
import { v0_1_0 } from './v0.1.0'
|
||||||
|
|
||||||
|
export const versions = { 'v0.1.0:0': v0_1_0 }
|
||||||
@@ -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 }) => {},
|
||||||
|
},
|
||||||
|
})
|
||||||
@@ -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/**/*"]
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user