178 lines
11 KiB
Markdown
178 lines
11 KiB
Markdown
# 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 <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'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:54` (migrations
|
||
0020–0022 applied). Registry `registry.keysat.xyz` publishes `:54` too
|
||
(GitHub release `v0.2.0-54` 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`.
|
||
- **Two payment-path fixes shipped 2026-06-13**: (a) `:53` fixed the `:52`
|
||
ambiguous-column bug that broke *every* paid purchase (daemon `31f4670`); (b)
|
||
`:54` fixed the **P0 Zaprite webhook-forgery** — settle now re-confirms against
|
||
the provider API before issuing (daemon `783372c`, bump `495fbbf`). 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 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;
|
||
it's tracked, so re-running full-eval overwrites it and `git log -- EVALUATION.md`
|
||
preserves prior runs.
|
||
|
||
- **Work queue (P0/P1 — do first, in this order)**:
|
||
1. ✅ **SHIPPED in `:54` — Provider-injection test seam.** Added the
|
||
always-compiled `AppState::provider_override` + `provider_from_row` helper at
|
||
every resolution site; greened the two `paid_purchase_*` tests; deleted the
|
||
dead `payment_provider_preference_round_trip`. See `docs/guides/testing.md`.
|
||
2. ✅ **SHIPPED in `:54` — Zaprite webhook forgery fix.** `webhook.rs::handle_inner`
|
||
re-fetches `provider.get_invoice_status` and requires `Settled` before 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_status` returns only a status enum). See
|
||
`docs/guides/payments.md`. **This is now the top remaining security item.**
|
||
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 `<select>` from
|
||
`GET /v1/admin/merchant-profiles` in 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), and `unlimited_merchant_profiles` on
|
||
master Pro/Patron policies.
|
||
|
||
- **Known debt (P2 — schedule, not urgent)**: no rate-limit on `/v1/purchase` +
|
||
`/v1/redeem`; rate-limit bucket keys on spoofable `X-Forwarded-For` (bypass
|
||
conditional on whether the StartOS proxy rewrites XFF — unverified); `422`/`415`
|
||
errors return plain-text not JSON (breaks SDK `JSON.parse`); product `slug` has
|
||
no validation (empty/300-char/meta chars stored); `GET /v1/admin/products`
|
||
returns 405 though OpenAPI documents it; dep advisories (`sqlx`→≥0.8.1
|
||
RUSTSEC-2024-0363, `rustls-webpki`→≥0.103.12); **4 StartOS submission blockers** —
|
||
missing `instructions.md`, dead `packageRepo` (`…/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/purchase` 400 vs
|
||
`/v1/btcpay/webhook` 503 for the same no-provider cause; undocumented required
|
||
`kind` on discount-codes; field-naming drift (`license_id`/`id`, machines `key`
|
||
vs `license_key`, `redeem`/`purchase` `product` vs `validate` `product_slug`);
|
||
migration self-heal `_sqlx_migrations` allowlist foot-gun; 2 KB unauth Zaprite
|
||
payload WARN-log; outbound-webhook SSRF (operator-only); stale
|
||
`versions/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 check` clean (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 sets `foreign_keys(true)` per
|
||
connection (`db/mod.rs`). CI/fmt status is in Known debt.
|