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:
@@ -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