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:
Keysat
2026-06-12 19:39:41 -05:00
parent 576213b0ce
commit f574f025a6
18 changed files with 449 additions and 88 deletions
+25
View File
@@ -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.
+34
View File
@@ -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`).
+33
View File
@@ -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).
+34
View File
@@ -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).
+87
View File
@@ -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
00200022):
```
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:156175`.
- Zaprite is optional, gated by the `zaprite_payments` entitlement; recurring
auto-charge works via `charge_order_with_profile`.
## Migrations & gotchas
- Migrations 00200022 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).
+64
View File
@@ -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."
+59
View File
@@ -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).
+28
View File
@@ -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.