11 KiB
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.
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
- Daemon: Rust 1.88,
axum,sqlx+ SQLite, Ed25519 signing. - Wrapper: TypeScript,
@start9labs/start-sdk ^1.3.2,@vercel/nccbundle, Node 22. - SDKs: TS (npm), Rust (crates.io), Python (PyPI), Go (proxy.golang.org).
- 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).
Subsystem guides (read before editing the area)
- 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), readdocs/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)
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 --release | cargo test | cargo test --test <suite> | cargo test <name>.
Details, the version-bump-before-build rule, and release scripts:
docs/guides/startos-packaging.md. Test suites, the no-CI / formatting-not-enforced
status, and known-failing tests: docs/guides/testing.md.
Directory layout
licensing-service-startos/ daemon + StartOS wrapper (s9pk package source)
licensing-service/src/ Rust daemon → guides/daemon-architecture.md
licensing-service/migrations/ SQLite migrations (numbered, additive)
licensing-service/web/index.html embedded admin SPA → guides/admin-ui.md
licensing-service/tests/ integration suites → guides/testing.md
startos/ wrapper TS → guides/startos-packaging.md
Dockerfile Makefile s9pk.mk build pipeline
keysat-xyz-landing/ keysat-docs/ keysat-registry-landing/ public sites → guides/website-copy.md
licensing-client-{rust,ts,python,go}/ the four SDK source repos
activate-license-template/ Tauri desktop template for license activation
keysat-design-system/ design tokens / brand assets
plans/ design specs (multi-provider-payment-model.md, keysat-smtp-emails.md)
tests/crosscheck/ cross-language LIC1 verifier → guides/crypto-wire-format.md
Note: the daemon (licensing-service-startos, repo keysat), each SDK, and
plans/ are separate git repos — commit code/plan changes in their own repo.
The root Licensing repo (keysat-root) tracks only AGENTS.md + docs/guides/
.claude/rules/+EVALUATION.md(the latest full-eval report; overwritten each run, history in git log). Remotes differ per repo: the daemon'smaintracks GitHub (origin, the public upstream) with agiteabackup — plaingit pushgoes to GitHub, so alsogit push gitea main; root + plans are Gitea-only. Rungit remote -v(full) and check what the branch tracks before pushing.
Conventions (whole-repo)
- Daemon licensed
LicenseRef-Keysat-1.0(custom, source-available); SDKs MIT. - Commits in imperative mood, body only when the "why" isn't obvious. Sign as
Keysat (Grant), not Claude — git user is
Keysat. - Direct push to
main+ run~/.keysat/publish.shis the authorized release flow until launch. - Never rewrite user-facing copy outside the explicit scope of a request.
Never
- No AI co-authorship on commits or PRs (no "Co-Authored-By", no "Generated with…").
- Don't push
--no-verifyor bypass hooks unless explicitly authorized. - Don't commit built artifacts (
*.s9pk,keysat-*.s9pk,javascript/) or secrets — reference env-var names; real values live in~/.keysat/filebrowser.envand/data/keysat-license.txtoutside the repo.
Memory references
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.
Open TODOs
- Extend
publish.shto build + upload aarch64 (arm builds fine; only x86 ships today), or narrow the manifest's arch claim.riscvtarget 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).
Current state (2026-06-13)
-
Live: server
immense-voyage.localruns daemon0.2.0:54(migrations 0020–0022 applied). Registryregistry.keysat.xyzpublishes:54too (GitHub releasev0.2.0-54cut;files.keysat.xyzserves the s9pk). Four SDKs published;keysat.xyz+docs.keysat.xyzdeployed. -
:52/:53= multi-provider/merchant-profile model: data model + backend resolution shipped and audited sound; the resolution/CRUD query surface now has test coverage. Seedocs/guides/payments.md. -
Two payment-path fixes shipped 2026-06-13: (a)
:53fixed the:52ambiguous-column bug that broke every paid purchase (daemon31f4670); (b):54fixed the P0 Zaprite webhook-forgery — settle now re-confirms against the provider API before issuing (daemon783372c, bump495fbbf). Both built, installed to prod, and published to the registry. Live purchase + settle paths are sound. -
GAP — multi-profile is non-functional end-to-end: nothing in the shipped app writes
products.merchant_profile_id(the INSERT increate_product_with_currencyomits it;update_product_with_currencyhas no field for it; theProductstruct inmodels.rsdoesn't even carry it). So every product created post-migration stays on the default profile, and a Pro operator can create extra profiles + attach providers but cannot route any product's sales to them. The data model + resolver fully support it; only the product→profile write path is missing. This is the gating piece for multi-profile — see the scoped slice below. -
Triage from
EVALUATION.md(full-eval, 2026-06-13) — P0/P1 = work queue, P2 = known debt, P3+ = deferred. The report at repo root has file:line evidence; it's tracked, so re-running full-eval overwrites it andgit log -- EVALUATION.mdpreserves prior runs. -
Work queue (P0/P1 — do first, in this order):
- ✅ SHIPPED in
:54— Provider-injection test seam. Added the always-compiledAppState::provider_override+provider_from_rowhelper at every resolution site; greened the twopaid_purchase_*tests; deleted the deadpayment_provider_preference_round_trip. Seedocs/guides/testing.md. - ✅ SHIPPED in
:54— Zaprite webhook forgery fix.webhook.rs::handle_innerre-fetchesprovider.get_invoice_statusand requiresSettledbefore any settle-derived action; acks 200 (no issue) when the provider is unreachable. Two regression tests (forged-settle, provider-unreachable). api 47/47. Still open (auditor P1): a literal paid-amount/currency check — needs a trait change (get_invoice_statusreturns only a status enum). Seedocs/guides/payments.md. This is now the top remaining security item. - [P1] Scoped API keys (
ks_…) are non-functional — issuable but 403 on every admin endpoint; therequire_admin→require_scopemigration was never done. Finish it, or stop advertising/issuing them.api/api_keys.rs:14.
- Then resume feature work: the product→merchant-profile picker (the GAP
above — slice: add
merchant_profile_idto theProductmodel +repo.rsSELECT mapping; aset_product_merchant_profilefollow-up writer mirroringset_product_entitlements_catalog; the field onCreateProductReq/UpdateProductReqapplied post-write; a profile<select>fromGET /v1/admin/merchant-profilesin the create+edit product forms, shown only when >1 profile; no migration), the 3 other deferred UIs (rail picker, per-profile SMTP, rail-pref editor), andunlimited_merchant_profileson master Pro/Patron policies.
- ✅ SHIPPED in
-
Known debt (P2 — schedule, not urgent): no rate-limit on
/v1/purchase+/v1/redeem; rate-limit bucket keys on spoofableX-Forwarded-For(bypass conditional on whether the StartOS proxy rewrites XFF — unverified);422/415errors return plain-text not JSON (breaks SDKJSON.parse); productslughas no validation (empty/300-char/meta chars stored);GET /v1/admin/productsreturns 405 though OpenAPI documents it; dep advisories (sqlx→≥0.8.1 RUSTSEC-2024-0363,rustls-webpki→≥0.103.12); 4 StartOS submission blockers — missinginstructions.md, deadpackageRepo(…/keysat-startos→…/keysat) +docsUrls(/docs/→/licensing-service/docs/) manifest links, aarch64 declared-but-not-shipped; no CI + fmt/clippy/prettier unenforced. -
Deferred (P3+ — bulk or later decision):
/v1/purchase400 vs/v1/btcpay/webhook503 for the same no-provider cause; undocumented requiredkindon discount-codes; field-naming drift (license_id/id, machineskeyvslicense_key,redeem/purchaseproductvsvalidateproduct_slug); migration self-heal_sqlx_migrationsallowlist foot-gun; 2 KB unauth Zaprite payload WARN-log; outbound-webhook SSRF (operator-only); staleversions/v0.2.0.ts:3-4"NOT YET WIRED" comment; re-register the master Zaprite webhook at the path-keyed URL; registry icon non-render (known platform limit); optional fmt/prettier standalone commit. -
Tests/build:
cargo checkclean (1 intentional deprecation warning); api 43 pass / 3 known-fail (now tracked in the work queue above), other suites green. FK enforcement confirmed — sqlx pool setsforeign_keys(true)per connection (db/mod.rs). CI/fmt status is in Known debt.