Restructure AGENTS.md into scoped guides
Trim AGENTS.md to whole-repo, every-session facts (154 -> 110 lines) and move subsystem guidance into docs/guides/*.md, each with paths: frontmatter and a one-line index entry in AGENTS.md. Symlink each guide from .claude/rules/ so Claude Code lazy-loads it by matching path; track those symlinks via a .gitignore exception (.claude/settings.local.json stays ignored).
This commit is contained in:
Symlink
+1
@@ -0,0 +1 @@
|
|||||||
|
../../docs/guides/admin-ui.md
|
||||||
+1
@@ -0,0 +1 @@
|
|||||||
|
../../docs/guides/crypto-wire-format.md
|
||||||
+1
@@ -0,0 +1 @@
|
|||||||
|
../../docs/guides/daemon-architecture.md
|
||||||
Symlink
+1
@@ -0,0 +1 @@
|
|||||||
|
../../docs/guides/licensing-tiers.md
|
||||||
Symlink
+1
@@ -0,0 +1 @@
|
|||||||
|
../../docs/guides/payments.md
|
||||||
+1
@@ -0,0 +1 @@
|
|||||||
|
../../docs/guides/startos-packaging.md
|
||||||
Symlink
+1
@@ -0,0 +1 @@
|
|||||||
|
../../docs/guides/testing.md
|
||||||
Symlink
+1
@@ -0,0 +1 @@
|
|||||||
|
../../docs/guides/website-copy.md
|
||||||
+3
-2
@@ -10,6 +10,7 @@
|
|||||||
/licensing-service-startos/
|
/licensing-service-startos/
|
||||||
/plans/
|
/plans/
|
||||||
|
|
||||||
# Local machine config / junk
|
# Local machine config / junk — but track the scoped-guide symlinks
|
||||||
/.claude/
|
/.claude/*
|
||||||
|
!/.claude/rules/
|
||||||
.DS_Store
|
.DS_Store
|
||||||
|
|||||||
@@ -1,122 +1,110 @@
|
|||||||
# AGENTS.md — Keysat workspace
|
# AGENTS.md — Keysat workspace
|
||||||
|
|
||||||
Self-hosted, Bitcoin-native software licensing service running as a StartOS 0.4.x package, with four wire-compatible SDKs and a public landing/docs site.
|
Self-hosted, Bitcoin-native software licensing service running as a StartOS 0.4.x
|
||||||
|
package, with four wire-compatible SDKs and a public landing/docs site.
|
||||||
|
|
||||||
|
This file holds whole-repo, every-session facts. Subsystem detail lives in scoped
|
||||||
|
guides under `docs/guides/` (symlinked to `.claude/rules/` so Claude Code
|
||||||
|
auto-loads each when you edit matching files). **Before editing a subsystem, read
|
||||||
|
its guide** — see the index below.
|
||||||
|
|
||||||
## Stack
|
## Stack
|
||||||
|
|
||||||
- **Daemon**: Rust 1.88, `axum`, `sqlx` + SQLite, Ed25519 signing.
|
- **Daemon**: Rust 1.88, `axum`, `sqlx` + SQLite, Ed25519 signing.
|
||||||
- **Wrapper**: TypeScript, `@start9labs/start-sdk ^1.3.2`, `@vercel/ncc` bundle, Node 22.
|
- **Wrapper**: TypeScript, `@start9labs/start-sdk ^1.3.2`, `@vercel/ncc` bundle, Node 22.
|
||||||
- **SDKs**: TS (npm), Rust (crates.io), Python (PyPI), Go (proxy.golang.org).
|
- **SDKs**: TS (npm), Rust (crates.io), Python (PyPI), Go (proxy.golang.org).
|
||||||
- **Platform**: StartOS 0.4.0.x (LXC under the hood — commands and paths reflect that, not Docker).
|
- **Platform**: StartOS 0.4.0.x (LXC under the hood — commands/paths reflect that, not Docker).
|
||||||
- **Payment providers**: BTCPay Server (required dep); Zaprite (optional, gated by `zaprite_payments` entitlement).
|
- **Payment providers**: BTCPay Server (required dep); Zaprite (optional, gated by `zaprite_payments`).
|
||||||
|
|
||||||
## Build / test / run
|
## Subsystem guides (read before editing the area)
|
||||||
|
|
||||||
From `licensing-service-startos/`:
|
- Before editing the daemon source, read `docs/guides/daemon-architecture.md`.
|
||||||
|
- Before editing payment / provider / merchant-profile code or migrations 0020–0022, read `docs/guides/payments.md`.
|
||||||
|
- Before touching self-license or tier-gating code, read `docs/guides/licensing-tiers.md`.
|
||||||
|
- Before changing the LIC1 wire format, crypto, or crosscheck fixtures, read `docs/guides/crypto-wire-format.md`.
|
||||||
|
- Before building, bumping the version, or editing the StartOS wrapper, read `docs/guides/startos-packaging.md`.
|
||||||
|
- Before editing the admin SPA (`web/index.html`), read `docs/guides/admin-ui.md`.
|
||||||
|
- Before editing public site/docs copy, read `docs/guides/website-copy.md`.
|
||||||
|
- Before adding/altering tests or relying on lint/CI, read `docs/guides/testing.md`.
|
||||||
|
|
||||||
```
|
## Build / test / run (quick ref)
|
||||||
make x86 # build keysat_x86_64.s9pk
|
|
||||||
make arm # build keysat_aarch64.s9pk
|
|
||||||
make universal # single multi-arch package
|
|
||||||
make install # install to StartOS; auth via the developer key at ~/.startos/developer.key.pem (private — never commit/share)
|
|
||||||
make clean
|
|
||||||
npm run check # tsc --noEmit on the wrapper
|
|
||||||
npm run prettier # format startos/*.ts
|
|
||||||
```
|
|
||||||
|
|
||||||
From `licensing-service-startos/licensing-service/`:
|
From `licensing-service-startos/`: `make x86` | `make arm` | `make universal` |
|
||||||
|
`make install` | `make clean` | `npm run check`. From
|
||||||
```
|
`licensing-service-startos/licensing-service/`: `cargo check` | `cargo build
|
||||||
cargo check # type-check daemon
|
--release` | `cargo test` | `cargo test --test <suite>` | `cargo test <name>`.
|
||||||
cargo build --release # release build
|
Details, the version-bump-before-build rule, and release scripts:
|
||||||
cargo test # all suites
|
`docs/guides/startos-packaging.md`. Test suites, the no-CI / formatting-not-enforced
|
||||||
cargo test --test api # one suite (suites: api crosscheck migrations subscriptions upgrades worker)
|
status, and known-failing tests: `docs/guides/testing.md`.
|
||||||
cargo test <name> # single test by name pattern
|
|
||||||
cargo fmt --check # TODO: confirm CI gate
|
|
||||||
```
|
|
||||||
|
|
||||||
Operator-local scripts (live in `~/.keysat/`, gitignored, not in this repo):
|
|
||||||
|
|
||||||
```
|
|
||||||
~/.keysat/publish.sh # version-gate → make x86 → FileBrowser upload → registry register → GitHub release mirror
|
|
||||||
~/.keysat/deploy-sites.sh landing docs # push static sites to FileBrowser; accepts: landing | docs | registry-landing
|
|
||||||
```
|
|
||||||
|
|
||||||
Credentials for those scripts: `~/.keysat/filebrowser.env` (`chmod 600`), env vars `KEYSAT_FB_USER`, `KEYSAT_FB_PASS`. Daemon env vars at runtime: `KEYSAT_ADMIN_API_KEY`, `KEYSAT_LICENSE`, `KEYSAT_OPERATOR_NAME`, `KEYSAT_PUBLIC_URL`, `BTCPAY_URL`, `BTCPAY_BROWSER_URL`, `BTCPAY_PUBLIC_URL`.
|
|
||||||
|
|
||||||
## Directory layout
|
## Directory layout
|
||||||
|
|
||||||
```
|
```
|
||||||
licensing-service-startos/ daemon + StartOS wrapper (s9pk package source)
|
licensing-service-startos/ daemon + StartOS wrapper (s9pk package source)
|
||||||
licensing-service/src/ Rust daemon
|
licensing-service/src/ Rust daemon → guides/daemon-architecture.md
|
||||||
licensing-service/migrations/ SQLite migrations (numbered, additive)
|
licensing-service/migrations/ SQLite migrations (numbered, additive)
|
||||||
licensing-service/web/index.html embedded admin SPA (rust-embed)
|
licensing-service/web/index.html embedded admin SPA → guides/admin-ui.md
|
||||||
licensing-service/tests/ integration suites
|
licensing-service/tests/ integration suites → guides/testing.md
|
||||||
licensing-service/tests/crosscheck/ wire-format fixtures the four SDKs cross-verify
|
startos/ wrapper TS → guides/startos-packaging.md
|
||||||
startos/ wrapper TS (manifest/, versions/, actions/, interfaces.ts, backups.ts, init/)
|
|
||||||
Dockerfile Makefile s9pk.mk build pipeline
|
Dockerfile Makefile s9pk.mk build pipeline
|
||||||
keysat-xyz-landing/ keysat.xyz
|
keysat-xyz-landing/ keysat-docs/ keysat-registry-landing/ public sites → guides/website-copy.md
|
||||||
keysat-docs/ docs.keysat.xyz (incl. shared docs.js)
|
|
||||||
keysat-registry-landing/ registry.keysat.xyz stub
|
|
||||||
licensing-client-{rust,ts,python,go}/ the four SDK source repos
|
licensing-client-{rust,ts,python,go}/ the four SDK source repos
|
||||||
activate-license-template/ Tauri desktop template for license activation
|
activate-license-template/ Tauri desktop template for license activation
|
||||||
keysat-design-system/ design tokens / brand assets
|
keysat-design-system/ design tokens / brand assets
|
||||||
plans/ historical plan files (gitignored or archived — TODO confirm)
|
plans/ design specs (multi-provider-payment-model.md, keysat-smtp-emails.md)
|
||||||
tests/ TODO: confirm vs licensing-service/tests/
|
tests/crosscheck/ cross-language LIC1 verifier → guides/crypto-wire-format.md
|
||||||
```
|
```
|
||||||
|
|
||||||
## Conventions
|
Note: `licensing-service-startos/` and each SDK are **separate git repos**; the
|
||||||
|
root `Licensing` repo is a workspace backup that tracks docs (AGENTS.md, plans,
|
||||||
|
guides) but not the code. Commit code changes in their own repo.
|
||||||
|
|
||||||
|
## Conventions (whole-repo)
|
||||||
|
|
||||||
- Daemon licensed `LicenseRef-Keysat-1.0` (custom, source-available); SDKs MIT.
|
- Daemon licensed `LicenseRef-Keysat-1.0` (custom, source-available); SDKs MIT.
|
||||||
- Commits in imperative mood, no AI co-authorship trailers, body only when the "why" isn't obvious.
|
- Commits in imperative mood, body only when the "why" isn't obvious. **Sign as
|
||||||
- Direct push to `main` + run `~/.keysat/publish.sh` is the authorized release flow until launch.
|
Keysat (Grant), not Claude** — git user is `Keysat`.
|
||||||
- Admin UI pills: navy-filled for selected/on; cream-outlined for off; opacity for muted; **gold reserved for marketing accents only** (most-popular badge, launch-special ribbon).
|
- Direct push to `main` + run `~/.keysat/publish.sh` is the authorized release flow
|
||||||
- No em-dashes in user-facing copy on the website or docs. Exceptions: decorative ornaments and verbatim third-party UI labels (e.g. BTCPay's "Network — mDNS"). Comments and docstrings inside source code are fine to keep em-dashes.
|
until launch.
|
||||||
- Pricing/tier copy on the docs site is a snapshot only. The canonical sources are `keysat.xyz#tiers` (live tier cards rendered from the master Keysat) and `GET licensing.keysat.xyz/v1/products/keysat/policies`.
|
- Never rewrite user-facing copy outside the explicit scope of a request.
|
||||||
|
|
||||||
## Always
|
|
||||||
|
|
||||||
- **Bump version + add changelog entry before building.** Edit `startos/versions/v0.2.0.ts` — increment the `version: '0.2.0:N'` field and prepend a `ROUTINE_NOTES[0]` entry — before running `make x86` or `publish.sh`. Start9 0.4.x silently no-ops an install of an un-bumped package.
|
|
||||||
- **`cargo check` from `licensing-service/` before bumping the version**, so the build doesn't fail downstream.
|
|
||||||
- **Use the SDK's StartOS dependency discovery for BTCPay**, not hardcoded hostnames. Pattern is in `licensing-service-startos/startos/main.ts:156–175` (`sdk.serviceInterface.getAll(effects, { packageId: 'btcpayserver' })`).
|
|
||||||
- **For tier gates, expect LIVE entitlements** from `licenses.entitlements` (refreshed hourly by `refresh_self_tier_from_db` in `src/license_self.rs`), not the entitlements baked into the signed payload at issue time.
|
|
||||||
- **BTCPay Connect: use the one-click authorize flow.** `POST /v1/admin/btcpay/connect` returns `{ authorize_url }`; the operator opens it, BTCPay callbacks `/v1/btcpay/authorize/callback`, and the daemon auto-detects store + registers webhook. The admin UI's `openBtcpayConnectModal` in `web/index.html` is the reference flow. Don't ask the operator to paste an API key + store id by hand.
|
|
||||||
- **Sign all release commits as Keysat (Grant), not Claude.** Git user is `Keysat` per repo config.
|
|
||||||
|
|
||||||
## Never
|
## Never
|
||||||
|
|
||||||
- **Don't add AI co-authorship to commits or PRs.** No "Co-Authored-By", no "Generated with Claude Code".
|
- **No AI co-authorship** on commits or PRs (no "Co-Authored-By", no "Generated with…").
|
||||||
- **Don't push `--no-verify`** or bypass pre-commit hooks unless explicitly authorized.
|
- **Don't push `--no-verify`** or bypass hooks unless explicitly authorized.
|
||||||
- **Don't rewrite user-facing copy outside the explicit scope of a request.**
|
- **Don't commit built artifacts** (`*.s9pk`, `keysat-*.s9pk`, `javascript/`) or
|
||||||
- **Don't silently expand entitlements in `tier::current()`** (e.g., "patron implies pro"). Tried in `0.2.0:41`, reverted in `0.2.0:42`. The right fix when an operator is stuck on an old-scheme self-license is: re-issue + run the StartOS "Activate Keysat license" action — the new key overwrites `/data/keysat-license.txt` and `self_tier` refreshes without a daemon restart.
|
**secrets** — reference env-var names; real values live in `~/.keysat/filebrowser.env`
|
||||||
- **Don't assume `start-cli registry info set-icon` is sufficient for the registry icon to render in the StartOS marketplace UI.** The icon round-trips through `registry info` correctly at 96×96 and 256×256 PNG, but the marketplace header may still show the storefront fallback. The operator may have to paste the data URL into the local "Configure Registry" modal manually. Confirm visually before claiming "done."
|
and `/data/keysat-license.txt` outside the repo.
|
||||||
- **Don't commit built artifacts** (`*.s9pk`, `keysat-*.s9pk`, `javascript/`) — they're build outputs.
|
|
||||||
- **Don't commit secrets.** Reference env-var names; real values live in `~/.keysat/filebrowser.env` and `/data/keysat-license.txt` outside the repo.
|
|
||||||
- **Don't claim multi-arch when only x86_64 ships.** Manifest currently declares `['x86_64', 'aarch64']` but `publish.sh` only builds + uploads x86_64. See TODO.
|
|
||||||
|
|
||||||
## Memory references
|
## Memory references
|
||||||
|
|
||||||
Persistent operator-specific memories live at `~/.claude/projects/-Users-macpro-Projects-licensing-Licensing/memory/`. Worth scanning before a major change:
|
Operator-specific memories at `~/.claude/projects/-Users-macpro-Projects-licensing-Licensing/memory/`
|
||||||
|
(scan before a major change): `keysat_release_workflow.md`,
|
||||||
|
`no_unauthorized_copy_changes.md`, `keysat_admin_ui_pill_convention.md`,
|
||||||
|
`startos_lxc.md`, `startos_registry_icon_unrenderable.md`, `keysat_open_threads.md`.
|
||||||
|
|
||||||
- `keysat_release_workflow.md` — push-to-main + publish.sh is authorized.
|
## Open TODOs
|
||||||
- `no_unauthorized_copy_changes.md` — never rewrite copy outside scope.
|
|
||||||
- `keysat_admin_ui_pill_convention.md` — navy/cream/gold pill rules.
|
|
||||||
- `startos_lxc.md` — StartOS 0.4 runtime model.
|
|
||||||
- `startos_registry_icon_unrenderable.md` — known issue with custom registry icons.
|
|
||||||
- `keysat_open_threads.md` — parked follow-ups from prior sessions.
|
|
||||||
|
|
||||||
## TODO (unverified or pending — flag if you touch)
|
- Extend `publish.sh` to build + upload aarch64 (arm builds fine; only x86 ships
|
||||||
|
today), or narrow the manifest's arch claim. `riscv` target unverified.
|
||||||
|
- StartOS Community Registry submission criteria — Start9 hasn't published the
|
||||||
|
checklist; reach out directly when ready.
|
||||||
|
- Registry icon doesn't render in the StartOS marketplace (see `guides/startos-packaging.md`).
|
||||||
|
|
||||||
- `cargo fmt --check` / `clippy` — confirm whether either gates CI.
|
## Current state (2026-06-12)
|
||||||
- Top-level `tests/` directory — confirm whether it's used or stale vs `licensing-service-startos/licensing-service/tests/`.
|
|
||||||
- Manifest `license: 'LicenseRef-Proprietary'` (in `startos/manifest/index.ts`) vs the LICENSE file's `LicenseRef-Keysat-1.0` header — align before submitting to any Start9-curated registry.
|
|
||||||
- Publish pipeline only builds x86_64; the Makefile targets `arm` and `riscv` exist and work. Either tighten the manifest's `arch` claim or extend `publish.sh` to build + upload all declared arches.
|
|
||||||
- StartOS Community Registry submission criteria — Start9 docs say "objective technical criteria" exist but don't publish the checklist. Reach out to Start9 directly when ready.
|
|
||||||
|
|
||||||
## Current state
|
- **Live**: operator's server `immense-voyage.local` runs daemon `0.2.0:52`
|
||||||
|
(installed from `:45`; migrations 0020–0022 applied). Registry
|
||||||
- **Live**: daemon `0.2.0:45` on `registry.keysat.xyz` (named "Keysat"); four SDKs published; `keysat.xyz` + `docs.keysat.xyz` deployed. BTCPay one-click connect and Zaprite recurring auto-charge both work.
|
`registry.keysat.xyz` still publishes `:45` — `:52` built/installed but **not
|
||||||
- **In progress**: source is at `0.2.0:52` (multi-merchant-profile / multi-provider payment model), committed, tree clean, **not yet published** (gap `:46–:52` unpublished). Migrations 0020–0022 are one-way; 0020 ports the singleton provider config into per-profile tables.
|
published**. Four SDKs published; `keysat.xyz` + `docs.keysat.xyz` deployed.
|
||||||
- **Decided, not built**: `:52` ships the data model but defers four UIs — buy-page rail picker, product-edit merchant-profile picker, per-profile SMTP override form, rail-preference editing. Master Keysat's Pro/Patron policies still need `unlimited_merchant_profiles` added (data action on keysat.xyz admin, no code).
|
- **`:52` = multi-provider/merchant-profile model**: data model + backend
|
||||||
- **Known issues**: custom registry icon round-trips through `start-cli registry info` but the StartOS marketplace shows the storefront fallback (likely needs the data URL pasted into the local Configure Registry modal). After `:52` installs, the master Zaprite webhook still points at the legacy URL — works via back-compat, needs re-register for per-provider isolation.
|
resolution shipped and audited sound. **Deferred (parked):** four UIs (buy-page
|
||||||
- **Next steps**: (1) publish `:52` via `~/.keysat/publish.sh`; (2) add `unlimited_merchant_profiles` to master Pro/Patron policies; (3) re-register master Zaprite webhook at the path-keyed URL; (4) build the buy-page rail picker + product-edit profile picker; (5) resolve the registry-icon render issue.
|
rail picker, product-edit profile picker, per-profile SMTP form, rail-preference
|
||||||
|
editor) and the `unlimited_merchant_profiles` master-policy entitlement. See
|
||||||
|
`docs/guides/payments.md`.
|
||||||
|
- **Open bug fix not yet shipped**: a purchase-path SQL bug (ambiguous column,
|
||||||
|
broke every paid purchase on `:52`) is **fixed in the working tree but
|
||||||
|
uncommitted**, so prod `:52` is still broken until a `:53` rebuild+reinstall. No
|
||||||
|
live buyers yet, so not urgent. Working tree also has the test compile-fixes, a
|
||||||
|
new query-audit test, the manifest license fix, and an unused-import removal —
|
||||||
|
all uncommitted. See `docs/guides/payments.md` and `docs/guides/testing.md`.
|
||||||
|
|||||||
@@ -0,0 +1,25 @@
|
|||||||
|
---
|
||||||
|
paths:
|
||||||
|
- "licensing-service-startos/licensing-service/web/**"
|
||||||
|
- "licensing-service-startos/licensing-service/src/api/admin_ui.rs"
|
||||||
|
---
|
||||||
|
|
||||||
|
# Admin SPA conventions (web/index.html)
|
||||||
|
|
||||||
|
The admin UI is a single embedded `web/index.html` (rust-embed), served via
|
||||||
|
`api/admin_ui.rs`.
|
||||||
|
|
||||||
|
## Pill / toggle colors
|
||||||
|
|
||||||
|
- **Navy-filled** — selected / on state.
|
||||||
|
- **Cream-outlined** — off state.
|
||||||
|
- **Opacity** (not strikethrough) — muted / disabled.
|
||||||
|
- **Gold** is reserved **strictly for marketing accents** (most-popular badge,
|
||||||
|
launch-special ribbon). Never use gold for ordinary selected/on state.
|
||||||
|
|
||||||
|
## Reference flows
|
||||||
|
|
||||||
|
- `openBtcpayConnectModal` is the reference implementation for the BTCPay
|
||||||
|
one-click connect flow — see [payments](payments.md).
|
||||||
|
|
||||||
|
Don't rewrite user-facing copy in here outside the explicit scope of a request.
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
---
|
||||||
|
paths:
|
||||||
|
- "licensing-service-startos/licensing-service/src/crypto/**"
|
||||||
|
- "licensing-service-startos/licensing-service/tests/crosscheck/**"
|
||||||
|
- "tests/crosscheck/**"
|
||||||
|
- "licensing-client-rust/**"
|
||||||
|
- "licensing-client-ts/**"
|
||||||
|
- "licensing-client-python/**"
|
||||||
|
- "licensing-client-go/**"
|
||||||
|
---
|
||||||
|
|
||||||
|
# Crypto & the LIC1 wire format
|
||||||
|
|
||||||
|
Ed25519 signing. The **LIC1 key byte layout** (defined in `src/crypto/mod.rs`) is
|
||||||
|
THE contract that all four SDKs (Rust, TS, Python, Go) implement. Two payload
|
||||||
|
layouts exist: legacy **v1** and current **v2** — both must keep validating.
|
||||||
|
|
||||||
|
## Change with extreme care
|
||||||
|
|
||||||
|
Any change to the byte layout, field order, or signing input ripples to four
|
||||||
|
SDKs and every license key already issued. Before changing it:
|
||||||
|
|
||||||
|
1. Update `src/crypto/` and every SDK in lockstep.
|
||||||
|
2. Update the cross-language fixtures.
|
||||||
|
|
||||||
|
## Cross-language verification
|
||||||
|
|
||||||
|
- **Top-level `tests/crosscheck/`** — an independent Python reference signer
|
||||||
|
(`reference_signer.py`, using `cryptography` for Ed25519) plus a TS runner
|
||||||
|
(`run_ts.mjs`) assert byte-for-byte agreement across Rust + TS + Python on both
|
||||||
|
v1 and v2 layouts. Agreement here is strong evidence the format is correct, not
|
||||||
|
just internally self-consistent.
|
||||||
|
- **`licensing-service/tests/crosscheck/`** — wire-format fixtures the four SDKs
|
||||||
|
cross-verify, run via the Rust `crosscheck` suite (`cargo test --test crosscheck`).
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
---
|
||||||
|
paths:
|
||||||
|
- "licensing-service-startos/licensing-service/src/**"
|
||||||
|
---
|
||||||
|
|
||||||
|
# Daemon architecture (licensing-service/src/)
|
||||||
|
|
||||||
|
`axum` HTTP service over `sqlx`/SQLite, Ed25519 signing. Request flow: `main.rs`
|
||||||
|
builds the router and spawns the background worker; `api/mod.rs` mounts ~30 route
|
||||||
|
modules (`purchase`, `validate`, `redeem`, `subscriptions`, `upgrade`, `admin*`,
|
||||||
|
plus `webhook*` for inbound provider callbacks). `api/auth.rs` + `session_layer.rs`
|
||||||
|
gate admin/session routes. `AppState` (in `api/mod.rs`) is the shared handle — DB
|
||||||
|
pool, config, the payment-provider resolution layer, and the self-license tier.
|
||||||
|
|
||||||
|
Module map:
|
||||||
|
|
||||||
|
- **crypto/** — Ed25519 signing and the LIC1 key byte layout. THE contract the
|
||||||
|
four SDKs implement. See [crypto-wire-format](crypto-wire-format.md).
|
||||||
|
- **payment/**, **merchant_profiles.rs** — provider abstraction over `btcpay` /
|
||||||
|
`zaprite` and the multi-merchant-profile routing model. See [payments](payments.md).
|
||||||
|
- **reconcile.rs / subscriptions.rs / upgrades.rs** — the background worker loop:
|
||||||
|
reconciles payment state, charges recurring subs, processes tier upgrades out of band.
|
||||||
|
- **license_self.rs** — the daemon licenses itself via its own scheme. See
|
||||||
|
[licensing-tiers](licensing-tiers.md).
|
||||||
|
- **db/repo.rs** — all SQLite access. Queries are **runtime-prepared**
|
||||||
|
(`sqlx::query(&format!(...))`), NOT the compile-checked `query!` macro — so bad
|
||||||
|
columns / ambiguous columns surface only when the query executes. Migrations are
|
||||||
|
numbered + additive in `migrations/`. See [testing](testing.md) for why this matters.
|
||||||
|
- **rates.rs / rate_limit.rs / analytics.rs / tipping.rs** — fiat rate lookup,
|
||||||
|
request throttling, opt-in analytics, and Lightning tipping.
|
||||||
|
|
||||||
|
The embedded admin SPA is a single `web/index.html` (rust-embed). See
|
||||||
|
[admin-ui](admin-ui.md).
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
---
|
||||||
|
paths:
|
||||||
|
- "licensing-service-startos/licensing-service/src/license_self.rs"
|
||||||
|
- "licensing-service-startos/licensing-service/src/api/tier.rs"
|
||||||
|
- "licensing-service-startos/licensing-service/src/api/self_license.rs"
|
||||||
|
- "licensing-service-startos/licensing-service/src/upgrades.rs"
|
||||||
|
- "licensing-service-startos/licensing-service/src/api/upgrade.rs"
|
||||||
|
---
|
||||||
|
|
||||||
|
# Self-license & tier gating
|
||||||
|
|
||||||
|
The daemon licenses **itself** via its own licensing scheme — the operator runs a
|
||||||
|
master Keysat that issues the license this instance validates.
|
||||||
|
|
||||||
|
## Live entitlements
|
||||||
|
|
||||||
|
Tier gates must read **LIVE** entitlements from `licenses.entitlements` (refreshed
|
||||||
|
hourly by `refresh_self_tier_from_db` in `license_self.rs`), **not** the
|
||||||
|
entitlements baked into the signed payload at issue time. The signed payload is a
|
||||||
|
point-in-time snapshot; entitlements can change after issuance.
|
||||||
|
|
||||||
|
## Never silently widen a tier
|
||||||
|
|
||||||
|
Do **not** expand entitlements in `tier::current()` (e.g. "patron implies pro").
|
||||||
|
Tried in `0.2.0:41`, reverted in `0.2.0:42`. When an operator is stuck on an
|
||||||
|
old-scheme self-license, the correct fix is: **re-issue** the license + run the
|
||||||
|
StartOS "Activate Keysat license" action — the new key overwrites
|
||||||
|
`/data/keysat-license.txt` and `self_tier` refreshes without a daemon restart.
|
||||||
|
|
||||||
|
## Entitlements in flight
|
||||||
|
|
||||||
|
`unlimited_merchant_profiles` (Creator = 1 merchant profile, Pro/Patron =
|
||||||
|
unlimited) still needs adding to the master Keysat's Pro/Patron policies — a data
|
||||||
|
action on the keysat.xyz admin, no code. See [payments](payments.md).
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
---
|
||||||
|
paths:
|
||||||
|
- "licensing-service-startos/licensing-service/src/payment/**"
|
||||||
|
- "licensing-service-startos/licensing-service/src/merchant_profiles.rs"
|
||||||
|
- "licensing-service-startos/licensing-service/src/btcpay/**"
|
||||||
|
- "licensing-service-startos/licensing-service/src/api/purchase.rs"
|
||||||
|
- "licensing-service-startos/licensing-service/src/api/btcpay_authorize.rs"
|
||||||
|
- "licensing-service-startos/licensing-service/src/api/zaprite_authorize.rs"
|
||||||
|
- "licensing-service-startos/licensing-service/src/api/payment_provider.rs"
|
||||||
|
- "licensing-service-startos/licensing-service/src/api/merchant_profiles.rs"
|
||||||
|
- "licensing-service-startos/licensing-service/src/subscriptions.rs"
|
||||||
|
- "licensing-service-startos/licensing-service/src/reconcile.rs"
|
||||||
|
- "licensing-service-startos/licensing-service/migrations/0020_merchant_profiles.sql"
|
||||||
|
- "licensing-service-startos/licensing-service/migrations/0021_invoice_provider_link.sql"
|
||||||
|
- "licensing-service-startos/licensing-service/migrations/0022_btcpay_state_profile.sql"
|
||||||
|
---
|
||||||
|
|
||||||
|
# Payments & the multi-provider / merchant-profile model
|
||||||
|
|
||||||
|
Full design spec: `plans/multi-provider-payment-model.md`. Companion email plan:
|
||||||
|
`plans/keysat-smtp-emails.md`.
|
||||||
|
|
||||||
|
## Model
|
||||||
|
|
||||||
|
One Keysat instance can host multiple businesses. The data model (migrations
|
||||||
|
0020–0022):
|
||||||
|
|
||||||
|
```
|
||||||
|
merchant_profiles (1) ──< (N) payment_providers
|
||||||
|
(1) ──< (N) products
|
||||||
|
(1) ──< (N) subscriptions [profile + provider snapshotted on create]
|
||||||
|
```
|
||||||
|
|
||||||
|
- **merchant_profiles** — business identity: name, branding, post-purchase
|
||||||
|
redirect, optional per-profile SMTP override fields. Exactly one `is_default`,
|
||||||
|
auto-created at first boot from the operator-name setting.
|
||||||
|
- **payment_providers** — one row per configured BTCPay/Zaprite account, attached
|
||||||
|
to a profile (`kind` ∈ `btcpay`|`zaprite`).
|
||||||
|
- **merchant_profile_rail_preferences** — tie-breaker `(profile, rail) → provider`
|
||||||
|
when a profile has two providers serving the same rail.
|
||||||
|
|
||||||
|
**Rails** are buyer-facing methods (`lightning`, `onchain`, `card`), declared per
|
||||||
|
provider kind via `rails_for_kind` (BTCPay → lightning+onchain; Zaprite →
|
||||||
|
card+lightning+onchain), NOT stored per row.
|
||||||
|
|
||||||
|
## Resolution (the production purchase path)
|
||||||
|
|
||||||
|
`purchase.rs` → `AppState::merchant_profile_for_product` → `resolve_provider_for_*`:
|
||||||
|
1. explicit `merchant_profile_rail_preferences` entry wins;
|
||||||
|
2. else the single provider serving the rail;
|
||||||
|
3. else earliest-`connected_at` (deterministic) + a warning.
|
||||||
|
|
||||||
|
`payment::build_provider(&row, ...)` constructs a **real** `BtcpayProvider` /
|
||||||
|
`ZapriteProvider` from the DB row — there is **no mock seam** in this path, so
|
||||||
|
integration tests can't drive it with `MockPaymentProvider` without one. The legacy
|
||||||
|
`state.payment` arc is a compat shim and is NOT consulted by the new resolver.
|
||||||
|
|
||||||
|
## Provider connect
|
||||||
|
|
||||||
|
- **Use BTCPay's one-click authorize flow**, not hand-pasted keys. `POST
|
||||||
|
/v1/admin/btcpay/connect` → `{ authorize_url }`; operator opens it, BTCPay
|
||||||
|
callbacks `/v1/btcpay/authorize/callback`, daemon auto-detects store + registers
|
||||||
|
webhook. Reference: `openBtcpayConnectModal` in `web/index.html`.
|
||||||
|
- **Discover BTCPay via the StartOS SDK**, not hardcoded hostnames:
|
||||||
|
`sdk.serviceInterface.getAll(effects, { packageId: 'btcpayserver' })` —
|
||||||
|
pattern at `startos/main.ts:156–175`.
|
||||||
|
- Zaprite is optional, gated by the `zaprite_payments` entitlement; recurring
|
||||||
|
auto-charge works via `charge_order_with_profile`.
|
||||||
|
|
||||||
|
## Migrations & gotchas
|
||||||
|
|
||||||
|
- Migrations 0020–0022 are **one-way**. 0020 ports the singleton
|
||||||
|
`btcpay_config`/`zaprite_config` into `payment_providers` + creates the default
|
||||||
|
profile, then **drops** the old singleton tables.
|
||||||
|
- These repo queries are runtime-prepared — a bad/ambiguous column 500s only when
|
||||||
|
executed. A JOIN that selects the bare `MERCHANT_PROFILE_COLS` while joining a
|
||||||
|
table with a colliding `id` is the trap that shipped the
|
||||||
|
`get_merchant_profile_for_product` bug; prefer a subquery or qualify columns.
|
||||||
|
`tests/api.rs::merchant_profile_provider_resolution_queries_round_trip` exercises
|
||||||
|
the resolution surface — keep it green when touching these queries.
|
||||||
|
- FK enforcement is **not** guaranteed at runtime: `PRAGMA foreign_keys = ON` in a
|
||||||
|
migration is connection-scoped. Confirm the pool sets it per-connection before
|
||||||
|
relying on cascade/constraint behavior (e.g. deleting a profile with products).
|
||||||
|
- Known: after a `:52`+ install the master Zaprite webhook still points at the
|
||||||
|
legacy URL — works via back-compat, needs re-register for per-provider isolation.
|
||||||
|
- `unlimited_merchant_profiles` entitlement gates profile count (Creator = 1,
|
||||||
|
Pro/Patron = unlimited). See [licensing-tiers](licensing-tiers.md).
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
---
|
||||||
|
paths:
|
||||||
|
- "licensing-service-startos/startos/**"
|
||||||
|
- "licensing-service-startos/Makefile"
|
||||||
|
- "licensing-service-startos/s9pk.mk"
|
||||||
|
- "licensing-service-startos/Dockerfile"
|
||||||
|
- "licensing-service-startos/package.json"
|
||||||
|
---
|
||||||
|
|
||||||
|
# StartOS packaging, build & release
|
||||||
|
|
||||||
|
Platform is **StartOS 0.4.0.x** — LXC under the hood, so commands/paths reflect
|
||||||
|
that, not Docker. Wrapper is TypeScript on `@start9labs/start-sdk ^1.3.2`, bundled
|
||||||
|
with `@vercel/ncc`, Node 22.
|
||||||
|
|
||||||
|
## Build / install (from `licensing-service-startos/`)
|
||||||
|
|
||||||
|
```
|
||||||
|
make x86 # build keysat_x86_64.s9pk (npm check+build → start-cli s9pk pack)
|
||||||
|
make arm # build keysat_aarch64.s9pk (verified to build; ~1.5 min Rust cross-compile)
|
||||||
|
make universal # single multi-arch package (both arches)
|
||||||
|
make install # install newest *.s9pk to the host in ~/.startos/config.yaml
|
||||||
|
make clean # wipe artifacts + node_modules (auto-reinstalls on next build)
|
||||||
|
npm run check # tsc --noEmit on the wrapper
|
||||||
|
npm run prettier # prettier --write startos (NOT enforced; see testing.md)
|
||||||
|
```
|
||||||
|
|
||||||
|
Auth for `make install` is the developer key at `~/.startos/developer.key.pem`
|
||||||
|
(private — never commit/share).
|
||||||
|
|
||||||
|
## ALWAYS: bump the version before building
|
||||||
|
|
||||||
|
Edit `startos/versions/v0.2.0.ts` — increment `version: '0.2.0:N'` and prepend a
|
||||||
|
`ROUTINE_NOTES[0]` entry — **before** `make x86` or `publish.sh`. Start9 0.4.x
|
||||||
|
silently no-ops an install whose version equals what's already installed. Run
|
||||||
|
`cargo check` from `licensing-service/` first so the build doesn't fail downstream.
|
||||||
|
|
||||||
|
## Release (operator-local scripts, in `~/.keysat/`, gitignored)
|
||||||
|
|
||||||
|
```
|
||||||
|
~/.keysat/publish.sh # version-gate → make x86 → FileBrowser upload → registry register → GitHub mirror
|
||||||
|
~/.keysat/deploy-sites.sh landing docs # push static sites; accepts: landing | docs | registry-landing
|
||||||
|
```
|
||||||
|
|
||||||
|
Credentials: `~/.keysat/filebrowser.env` (`chmod 600`); env `KEYSAT_FB_USER`,
|
||||||
|
`KEYSAT_FB_PASS`. Daemon runtime env: `KEYSAT_ADMIN_API_KEY`, `KEYSAT_LICENSE`,
|
||||||
|
`KEYSAT_OPERATOR_NAME`, `KEYSAT_PUBLIC_URL`, `BTCPAY_URL`, `BTCPAY_BROWSER_URL`,
|
||||||
|
`BTCPAY_PUBLIC_URL`.
|
||||||
|
|
||||||
|
## Arch & manifest
|
||||||
|
|
||||||
|
- Both `x86_64` and `aarch64` build cleanly (arm verified 2026-06-12). The
|
||||||
|
manifest declares `['x86_64', 'aarch64']` but `publish.sh` only uploads x86_64 —
|
||||||
|
**don't claim multi-arch as shipped** until publish handles both. (`riscv` target
|
||||||
|
exists, unverified.)
|
||||||
|
- Manifest `license` is `LicenseRef-Keysat-1.0`, matching the package-root
|
||||||
|
`LICENSE` SPDX id.
|
||||||
|
|
||||||
|
## Known issue: registry icon
|
||||||
|
|
||||||
|
`start-cli registry info set-icon` stores the icon fine (round-trips at 96×96 and
|
||||||
|
256×256 PNG), but the StartOS marketplace header may still show the storefront
|
||||||
|
fallback. The operator may have to paste the data URL into the local "Configure
|
||||||
|
Registry" modal manually. Confirm visually before claiming "done."
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
---
|
||||||
|
paths:
|
||||||
|
- "licensing-service-startos/licensing-service/tests/**"
|
||||||
|
---
|
||||||
|
|
||||||
|
# Testing, lint & CI status
|
||||||
|
|
||||||
|
## Running
|
||||||
|
|
||||||
|
From `licensing-service-startos/licensing-service/`:
|
||||||
|
|
||||||
|
```
|
||||||
|
cargo test # all suites
|
||||||
|
cargo test --test api # one suite — suites: api crosscheck migrations subscriptions upgrades worker
|
||||||
|
cargo test <name> # single test by name pattern
|
||||||
|
cargo clippy --all-targets # ~6 style lints (e.g. unnecessary_map_or)
|
||||||
|
cargo fmt --check # reports formatting diffs (does not write)
|
||||||
|
```
|
||||||
|
|
||||||
|
## There is no CI
|
||||||
|
|
||||||
|
No `.github/workflows`, no Gitea/Woodpecker/Drone config. **Nothing is enforced** —
|
||||||
|
not `cargo test`, not `clippy`, not `fmt`. The release gate is `publish.sh`.
|
||||||
|
|
||||||
|
## Formatting is not clean and not enforced
|
||||||
|
|
||||||
|
The tree has never been run through `cargo fmt` (56 daemon files differ) or
|
||||||
|
`prettier` (37 wrapper files differ). `cargo check` and `tsc --noEmit` pass clean.
|
||||||
|
If you ever format, do it as a **standalone commit** so the mechanical churn
|
||||||
|
doesn't bury logic changes.
|
||||||
|
|
||||||
|
## Runtime-prepared SQL → tests are the only safety net
|
||||||
|
|
||||||
|
`db/repo.rs` queries are `sqlx::query(&format!(...))`, prepared at runtime, so bad
|
||||||
|
or ambiguous columns 500 only when executed. A real prod regression (every paid
|
||||||
|
purchase returning 500 on `:52`) shipped this way because the new merchant-profile
|
||||||
|
resolution path had no passing test. When adding/altering a repo query, add a test
|
||||||
|
that actually executes it. See [payments](payments.md).
|
||||||
|
|
||||||
|
## Known-failing tests (3 in tests/api.rs)
|
||||||
|
|
||||||
|
Test-debt from the `:52` payments transition; the backend is sound, these are a
|
||||||
|
test-strategy decision, not bugs:
|
||||||
|
|
||||||
|
- `paid_purchase_creates_invoice_via_provider`, `paid_purchase_in_usd_records_listed_currency_and_rate`
|
||||||
|
— fail with a legitimate 400 ("no payment providers connected"); the fixture
|
||||||
|
seeds no provider. Reaching 200 needs the mock wired through
|
||||||
|
`resolve_provider_for_profile_rail` (`build_provider` only makes real clients;
|
||||||
|
`#[cfg(test)]` does not apply to integration tests against the lib).
|
||||||
|
- `payment_provider_preference_round_trip` — inserts into the dropped
|
||||||
|
`btcpay_config`/`zaprite_config`; superseded by
|
||||||
|
`merchant_profile_provider_resolution_queries_round_trip` (delete or rewrite).
|
||||||
|
|
||||||
|
The other 43 api tests + all other suites pass.
|
||||||
|
|
||||||
|
## Cross-language wire-format tests
|
||||||
|
|
||||||
|
The LIC1 format is verified across SDKs separately — see
|
||||||
|
[crypto-wire-format](crypto-wire-format.md).
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
---
|
||||||
|
paths:
|
||||||
|
- "keysat-xyz-landing/**"
|
||||||
|
- "keysat-docs/**"
|
||||||
|
- "keysat-registry-landing/**"
|
||||||
|
---
|
||||||
|
|
||||||
|
# Website & docs copy rules
|
||||||
|
|
||||||
|
Applies to the public sites: `keysat.xyz` (keysat-xyz-landing), `docs.keysat.xyz`
|
||||||
|
(keysat-docs, incl. shared `docs.js`), `registry.keysat.xyz` (keysat-registry-landing).
|
||||||
|
|
||||||
|
## No em-dashes in user-facing copy
|
||||||
|
|
||||||
|
No em-dashes in copy on the website or docs. Exceptions: decorative ornaments and
|
||||||
|
verbatim third-party UI labels (e.g. BTCPay's "Network — mDNS"). Comments and
|
||||||
|
docstrings inside source code are fine to keep em-dashes.
|
||||||
|
|
||||||
|
## Don't rewrite copy outside scope
|
||||||
|
|
||||||
|
Never rewrite user-facing copy or labels outside the explicit scope of a request.
|
||||||
|
|
||||||
|
## Pricing/tier copy is a snapshot only
|
||||||
|
|
||||||
|
Tier/pricing copy on the docs site is a snapshot, not a source of truth. The
|
||||||
|
canonical sources are `keysat.xyz#tiers` (live tier cards rendered from the master
|
||||||
|
Keysat) and `GET licensing.keysat.xyz/v1/products/keysat/policies`. When they
|
||||||
|
disagree, the live endpoints win.
|
||||||
Reference in New Issue
Block a user