# 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/ncc` bundle, 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`), 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) 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 ` | `cargo test `. 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/`. **Remotes differ per repo**: the daemon's `main` tracks **GitHub** (`origin`, the public upstream) with a `gitea` backup — plain `git push` goes to GitHub, so also `git push gitea main`; root + plans are **Gitea-only**. Run `git 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.sh` is 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-verify`** or 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.env` and `/data/keysat-license.txt` outside 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.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`). ## Current state (2026-06-13) - **Live**: server `immense-voyage.local` runs daemon `0.2.0:53` (migrations 0020–0022 applied). Registry `registry.keysat.xyz` now publishes `:53` too (GitHub release `v0.2.0-53` cut; `files.keysat.xyz` serves the s9pk). Four SDKs published; `keysat.xyz` + `docs.keysat.xyz` deployed. - **`:52`/`:53` = multi-provider/merchant-profile model**: data model + backend resolution shipped and audited sound; the resolution/CRUD query surface now has test coverage. See `docs/guides/payments.md`. - **Purchase-path bug fixed and shipped**: the `:52` ambiguous-column bug (broke *every* paid purchase) was fixed in daemon `31f4670`; `:53` (version bump `8c4bacc`) built, installed to prod, and published to the registry on 2026-06-13. The live purchase path works again. - **GAP — multi-profile is non-functional end-to-end**: nothing in the shipped app writes `products.merchant_profile_id` (the INSERT in `create_product_with_currency` omits it; `update_product_with_currency` has no field for it; the `Product` struct in `models.rs` doesn'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. - **Work queue (P0/P1 — do first, in this order)**: 1. **[P1] Provider-injection test seam — BEFORE #2's fix.** The prod purchase/ settle path has no mock seam (the mock injects into the dead `state.payment` singleton, not `resolve_provider_for_profile_rail` — this is how the `:52` 500 shipped). Add an always-compiled `Option>` override on `AppState`, checked first in `resolve_provider_for_profile_rail`. Greens the two `paid_purchase_*` red tests and gives #2 a regression harness. **Delete** the dead `payment_provider_preference_round_trip` in the same pass. 2. **[P0] Zaprite webhook forgery.** The settle-webhook is unauthenticated — a forged `order.change`/`status=PAID` with a buyer-visible order id mints a signed license (the `externalUniqId` "trust anchor" in the comments is never read in the webhook path). Fix: on settle, re-fetch `get_invoice_status` from the provider and require local `pending` state + matching amount/currency before issuing (mirror the reconciler's safe re-fetch). Closes the P1 below too. `payment/zaprite/provider.rs:234-362`, `api/webhook.rs:62-196,121-194`. 3. **[P1] Scoped API keys (`ks_…`) are non-functional** — issuable but 403 on every admin endpoint; the `require_admin`→`require_scope` migration 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_id` to the `Product` model + `repo.rs` SELECT mapping; a `set_product_merchant_profile` follow-up writer mirroring `set_product_entitlements_catalog`; the field on `CreateProductReq`/ `UpdateProductReq` applied post-write; a profile `