Compare commits
11 Commits
f036871111
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 255d669cf0 | |||
| 178e4677a4 | |||
| 6e45e3f3e4 | |||
| 6133b28ced | |||
| 2f502dd4c1 | |||
| 4becb76443 | |||
| 833d0235f9 | |||
| b7a07f981c | |||
| 17b6749254 | |||
| 0007f917b9 | |||
| 601ccea39c |
@@ -95,42 +95,36 @@ Operator-specific memories at `~/.claude/projects/-Users-macpro-Projects-keysat/
|
||||
- `riscv` build target is unverified and not declared in the manifest; the wrapper `Makefile`
|
||||
now pins `ARCHES` to `x86 arm` so no target (even a bare `make`) attempts it. Revisit only if
|
||||
a riscv StartOS target appears.
|
||||
- StartOS Community Registry submission — remaining gap is a `prepare.sh` for the clean-Debian
|
||||
first build (plus the on-box manual verification); functional criteria otherwise pass. Detail
|
||||
in ROADMAP. Submission criteria themselves still unpublished; reach out when ready.
|
||||
- StartOS Community Registry submission — `prepare.sh` shipped (2026-06-18). Submission is
|
||||
**email-based** (no PR, no form): mail `submissions@start9labs.com` a link to the public wrapper
|
||||
repo; Start9 builds-from-source on a clean box → Community Beta → production-on-reply. Resolve two
|
||||
unknowns with Start9 *before* submitting: (1) source-available `LicenseRef-Keysat-1.0` acceptability,
|
||||
(2) whether the 0.4.x build still invokes `prepare.sh`. On-box manual verification still pending. Detail in ROADMAP.
|
||||
- Split `audit:read` out of the blanket `:read` scope into its own tier so a
|
||||
Read-only scoped key can read dashboards/licenses but NOT the full audit log
|
||||
(`api/api_keys.rs::Role::grants`). Deferred from the scoped-keys session.
|
||||
- **Operator action (manual; needs the master admin key — a read-only key can't
|
||||
write):** grant `unlimited_merchant_profiles` to the **Pro and Patron** tiers on
|
||||
the live master. Confirmed 2026-06-16 against `licensing.keysat.xyz` that the slug
|
||||
is absent from all three keysat policies (Creator/Pro/Patron), from the master's
|
||||
own Patron self-license, and from the product `entitlements_catalog`. Steps: add
|
||||
the slug to the keysat product `entitlements_catalog`, then to the Pro + Patron
|
||||
policy entitlements (admin UI), then re-issue the master self-license so it takes
|
||||
effect.
|
||||
|
||||
## Current state (2026-06-17)
|
||||
## Current state (2026-06-18)
|
||||
|
||||
- **Live / canonical: `0.2.0:58`** — universal s9pk at `files.keysat.xyz/keysat.s9pk` + GitHub `v0.2.0-58`;
|
||||
live box `immense-voyage.local` on `:58`. Migrations through 0025; four SDKs published; two public sites
|
||||
(keysat.xyz, docs.keysat.xyz) live. `keysat-registry-landing` deleted this session — local + all refs gone;
|
||||
the GitHub + Gitea remote repos still need operator deletion (gh needs `delete_repo` scope).
|
||||
- **Live / canonical: `0.2.0:60`** — universal s9pk at `files.keysat.xyz/keysat.s9pk` (byte-verified) + GitHub
|
||||
release `v0.2.0-60` + registry-registered; installed on the live box `immense-voyage.local` and serving
|
||||
(master `licensing.keysat.xyz` returns 200 post-restart). Migrations through 0025; four SDKs published; two
|
||||
public sites (keysat.xyz, docs.keysat.xyz) live. All repos synced to **both** GitHub + gitea.
|
||||
`keysat-registry-landing` remotes deleted by the operator.
|
||||
|
||||
- **This session — documentation audit + fix sweep across every public repo and both sites** (all committed +
|
||||
pushed to GitHub + gitea; sites redeployed and verified live): daemon docs → 0.2.0 (admin-UI replaces the
|
||||
removed StartOS actions, Zaprite shipped, roles, runtime image, validate reasons); SDK READMEs fixed (the
|
||||
Rust crate name/version was a copy-paste blocker) and expanded (TS/Python tiers, seats, free-license);
|
||||
landing SDK snippets + tier-card fallback prices; docs change-tier example + install-step resequence;
|
||||
Makefile pins `ARCHES=x86 arm`. No daemon source touched.
|
||||
|
||||
- **Start9 Community Registry:** functional criteria pass; remaining gap is a `prepare.sh` for the clean-Debian
|
||||
first build (+ on-box verification) (ROADMAP). (Note: `registry.keysat.xyz` works as a marketplace on a Start9
|
||||
box; a plain browser/curl GET 404s **by design** — no HTML page is served there. Not an outage.)
|
||||
|
||||
- **Next (priority):** 1) Operator data action (master key): grant `unlimited_merchant_profiles` to Pro/Patron
|
||||
on live master (steps in Open TODOs). 2) Delete registry-landing GitHub + Gitea remotes. 3) 3 multi-profile
|
||||
UIs + split `audit:read`.
|
||||
|
||||
- **Tests/build:** docs-only session, no code touched; last full suite green (lib/api/subscriptions/upgrades/
|
||||
worker/crosscheck/migrations through 0025), `cargo check` + `npm run check` clean. Debt (P2/P3) in ROADMAP.
|
||||
- **This session — full eval + three P1 fixes (all committed & pushed).** Ran the five-agent `/full-eval`
|
||||
(evaluator, security-auditor, exerciser, doc-auditor, start9-spec-checker); report in `EVALUATION.md`
|
||||
(no P0s; strong crypto/auth/webhook posture). Fixed all three P1s: (1) crosscheck harness `run_ts.mjs`
|
||||
hardcoded `/sessions/...` path → resolves relative to repo (keysat-root); (2) Rust SDK + `keysat-docs`
|
||||
imported `licensing_client` not `keysat_licensing_client` — fixed, plus two latent bugs it masked (example's
|
||||
undeclared `anyhow` → stdlib; doctest `include_str!` of a missing file → inline PEM); (3) added
|
||||
`licensing-service-startos/prepare.sh` clean-Debian build bootstrap. Reviewer-approved; verified green.
|
||||
- **Registry submission mechanism researched.** Email-based (no PR/form) — see Open TODOs + ROADMAP. Two
|
||||
blocking unknowns to clear with Start9 first: license acceptability + whether 0.4.x still uses `prepare.sh`.
|
||||
- **Prior context still current:** `:60` Zaprite silent-lapse fix shipped; Keysat sends no buyer email
|
||||
(SMTP path dormant); docs reconciled; `unlimited_merchant_profiles` live on Pro+Patron (not Creator).
|
||||
- **Next (priority):** 1) email Start9 re: license + 0.4.x build flow (gates the whole submission). 2) eval
|
||||
P2 hardening — XFF rate-limit bypass, dep-advisory bumps, admin/public port split (ROADMAP "Security &
|
||||
hardening"). 3) automated multi-profile webhook routing test (Effort S). 4) split `audit:read` scope.
|
||||
- **Tests/build:** daemon `cargo test` ~117–131 green across 8 suites; wrapper `tsc` clean; Rust SDK
|
||||
`cargo build --examples` + doctest now green; crosscheck harness passes end-to-end. No CI enforces any of it.
|
||||
|
||||
+66
-52
@@ -1,72 +1,86 @@
|
||||
# Evaluation — Keysat (licensing-service-startos) — 2026-06-13
|
||||
# Evaluation — Keysat — 2026-06-18
|
||||
|
||||
Intent: a self-hosted, Bitcoin-native software-licensing service that runs as a StartOS 0.4.x package — a Rust/axum/sqlx+SQLite daemon with Ed25519 license signing plus a TypeScript StartOS wrapper — recently extended with a multi-merchant-profile, multi-provider (BTCPay required, Zaprite optional) payment model.
|
||||
Intent: a self-hosted, Bitcoin-native software-licensing service that runs as a StartOS 0.4.x package (Rust/axum/sqlx+SQLite daemon with Ed25519 license signing + a TypeScript StartOS wrapper), with four wire-compatible client SDKs (TS, Rust, Python, Go), a public landing/docs site, and BTCPay Server (required) + Zaprite (optional) Bitcoin payment providers.
|
||||
|
||||
Evaluation target: `licensing-service-startos/` (the daemon + StartOS wrapper). The SDK repos, public sites, and `plans/` were out of scope.
|
||||
|
||||
Agents run: evaluator, security-auditor, exerciser, start9-spec-checker (all completed). reviewer **skipped** — the guide runs it only on uncommitted changes, and the daemon tree was clean at evaluation time.
|
||||
Agents run: evaluator, security-auditor, exerciser, doc-auditor, start9-spec-checker. **reviewer skipped** — working tree is clean (no uncommitted diff to review).
|
||||
|
||||
## Verdict
|
||||
|
||||
Keysat substantially achieves its intent: the cryptographic licensing core (offline-verifiable LIC1 keys, Ed25519, cross-language-tested) is reference-grade, the payment-provider trait is well-factored, the SQL surface is injection-clear despite runtime-prepared queries, and the security fundamentals (Argon2id, constant-time HMAC/compare, 256-bit sessions, generic-error recovery, FK enforcement confirmed) are above the bar for a project this young. The one release-blocking defect is the **Zaprite settle-webhook: it performs no authentication of any kind and issues a signed license off a buyer-visible order id — anyone who starts a real Zaprite checkout can forge a "paid" callback and mint licenses without paying** (the `externalUniqId` "trust anchor" the code comments describe is never actually read in the webhook path). Behind that sits a systemic test gap — the production purchase/settle path has no integration-test seam, which is exactly how the `:52` 500-on-every-purchase bug shipped — and a cluster of API-hygiene and StartOS-submission issues. None of the deeper architecture is rotten; the work is hardening a sound core, not rebuilding it.
|
||||
Keysat substantially achieves its stated intent and is mature, security-conscious work well above typical solo-project quality on the surfaces that handle money and crypto. The daemon builds clean, the full test suite is green (≈117–131 tests across 8 suites), the LIC1 wire format is genuinely implemented byte-identically across the daemon and all four SDKs, and the payment/settle path is built fail-closed with real anti-forgery thinking (webhooks re-fetch authoritative provider status before issuing anything — this defeats Zaprite's unsigned-webhook forgery). No agent found a P0; the security auditor found no P0/P1. The headline risks are all below release-blocking: two shipped developer-experience breakages (the Rust SDK's published examples/README import the wrong crate name so they don't compile, and the cross-language crosscheck harness has a hardcoded machine-specific path), a cluster of P2 hardening/hygiene items (X-Forwarded-For rate-limit bypass, unpatched dependency advisories, admin UI co-located with the public API, runtime-prepared SQL with no compile-time safety net), and the still-missing `prepare.sh` that blocks Start9 community-registry submission. The live `0.2.0:60` deployment is sound; the work below is polish, hardening, and unblocking the registry milestone.
|
||||
|
||||
## Cross-referenced findings
|
||||
|
||||
- **Zaprite webhook forgery (P0).** Flagged by BOTH the evaluator (as P2) and the security-auditor (escalated to **P0** after tracing it end-to-end: public route → no signature → `externalUniqId` never read → no amount check → no `pending`-state precondition → `webhook.rs:194` issues a signed license). One finding, two agents, severity resolved to P0 (funds loss). `payment/zaprite/provider.rs:234-362`, `api/webhook.rs:62-196`, route `api/mod.rs:429-430`.
|
||||
- **Untested production purchase path (P1).** Evaluator named it the #1 issue (mock injects into the bypassed legacy `state.payment` singleton at `tests/api.rs:457`, not the real `resolve_provider_for_profile_rail` at `purchase.rs:482`); the exerciser independently reported it could not exercise the live payment/settle path at all (medium confidence). Same gap, two kinds of evidence. This is also the root of the two `paid_purchase_*` red tests.
|
||||
- **FK enforcement (resolved, not a finding).** A prior "latent/unchecked" worry; the security-auditor and daemon-architecture both confirm the sqlx pool sets `foreign_keys(true)` per connection (`db/mod.rs`). Closed.
|
||||
- **Rust SDK wrong crate name** — corroborated by two agents. The **exerciser** proved it breaks the build (`cargo build --examples` → `E0432: unresolved import 'licensing_client'`; crate is `keysat-licensing-client` → module `keysat_licensing_client`). The **doc-auditor** found the same wrong import propagated through `licensing-client-rust/README.md:26,39,56`, `src/lib.rs:20`, both `examples/*.rs`, and the public docs page `keysat-docs/integrate.html:130`. ONE finding, two kinds of evidence (build break + doc propagation) → **P1**.
|
||||
- **`unlimited_merchant_profiles` entitlement status** — the **doc-auditor** found `docs/guides/licensing-tiers.md:43-45` AND the code comment `src/api/tier.rs:234-237` both say it "still needs adding" to Pro/Patron policies, while AGENTS.md Current state says it's "confirmed live." Internal contradiction that needs a live `GET /v1/products/keysat/policies` check to resolve → **P2**.
|
||||
- **Health-check / readiness** — the **start9-spec-checker** noted the StartOS health check is port-listening only (flips green when 8080 binds, before migrations finish); the **exerciser** noted the daemon actually exposes a richer `GET /healthz` (and `/health` 404s). Same underlying surface → one **P3**.
|
||||
- **StartOS actions surface** — the **start9-spec-checker** found 21 registered actions vs the **doc-auditor**'s confirmation that `instructions.md`/README describe only ~4. Doc drift + extra test surface for Start9 review → **P3**.
|
||||
- **Test-count drift** — evaluator counted ≈131, exerciser ran 117, doc-auditor found the `api` suite at 65 vs the documented 54. All green; the *documented* counts in `docs/guides/testing.md:57` are stale → **P3** (doc only).
|
||||
|
||||
## Priority queue
|
||||
|
||||
- **[P0]** Zaprite settle-webhook is unauthenticated — forged `order.change`/`status=PAID` with a buyer-visible order id mints a free signed license; no amount or state check — `payment/zaprite/provider.rs:234-362` + `api/webhook.rs:62-196` — security-auditor (P0) + evaluator (P2)
|
||||
- **[P1]** Production purchase/settle path has no integration-test seam; mock targets the dead `state.payment` singleton, not `resolve_provider_for_profile_rail` — `payment/mod.rs:177`, `tests/api.rs:457`, `purchase.rs:482` — evaluator + exerciser
|
||||
- **[P1]** Settle handler issues without verifying paid amount/currency or requiring `pending` state (underpaid/replayed invoices honored at full entitlement; BTCPay forgery blocked by HMAC but replay is a no-op only after first issuance) — `api/webhook.rs:121-194` — security-auditor
|
||||
- **[P1]** Scoped API keys (`ks_…`) are advertised + issuable but return 403 on every admin endpoint — the `require_admin`→`require_scope` migration was never done — `api/api_keys.rs:14` — exerciser
|
||||
- **[P2]** `/v1/purchase` and `/v1/redeem` have no rate limiting (invoice-spam / upstream-provider exhaustion; unthrottled `free_license` code guessing) — `api/mod.rs:343-345` — security-auditor
|
||||
- **[P2]** Per-IP rate-limit bucket keys on attacker-controllable `X-Forwarded-For`; bypassable if the daemon's shared `0.0.0.0:8080` listener is reachable without the StartOS proxy rewriting XFF (unverified) — `recover.rs:65-70`, `validate.rs:95-111`, `admin.rs:56-67` — security-auditor
|
||||
- **[P2]** All `422`/`415` responses return naked axum plain-text rejection strings instead of the JSON error envelope — SDK consumers that `JSON.parse()` a 422 throw — every JSON endpoint — exerciser
|
||||
- **[P2]** Product `slug` has no validation — empty string, 300-char, and SQL-meta characters all stored as valid slugs; empty-slug product pollutes the public catalog — `POST /v1/admin/products` — exerciser
|
||||
- **[P2]** `GET /v1/admin/products` returns 405 though the served OpenAPI documents it (`products:read`); only `post` is wired — `api/mod.rs:431` — exerciser
|
||||
- **[P2]** Dependency advisories: `sqlx 0.7.4` (RUSTSEC-2024-0363, fixed ≥0.8.1), `rustls-webpki 0.101.7` (CRL panic / name-constraint bypass, fixed ≥0.103.12); `rsa` reachable only via unused MySQL backend; `paste`/`rustls-pemfile` unmaintained — `cargo audit` — security-auditor
|
||||
- **[P2]** StartOS submission blocker: `instructions.md` absent (spec says build should fail; `start-cli` built silently anyway) — start9-spec-checker
|
||||
- **[P2]** StartOS submission blocker: `packageRepo` is a dead link — manifest points to `…/keysat-startos` (404); real repo is `…/keysat` — `startos/manifest/index.ts` — start9-spec-checker
|
||||
- **[P2]** StartOS submission blocker: `docsUrls` dead link — points to `/docs/INTEGRATION.md`; file is at `/licensing-service/docs/INTEGRATION.md` — `startos/manifest/index.ts` — start9-spec-checker
|
||||
- **[P2]** StartOS submission blocker: manifest declares `aarch64` but only x86_64 is published (Start9 review tests on Pi-class hardware) — `publish.sh` / manifest `arch` — start9-spec-checker (corroborates existing AGENTS.md TODO)
|
||||
- **[P2]** No CI; `cargo test`/`clippy`/`fmt` all unenforced (56 files differ from `fmt`); the only release gate is `publish.sh` — for a payment daemon the untested suite is the load-bearing weakness behind the P0/P1s — `docs/guides/testing.md` — evaluator
|
||||
- **[P3]** Dead test `payment_provider_preference_round_trip` inserts into the dropped `btcpay_config`/`zaprite_config` tables — delete it — `tests/api.rs:1224` — evaluator + exerciser
|
||||
- **[P3]** `/v1/purchase` (400) and `/v1/btcpay/webhook` (503) return different status/error codes for the same "no provider configured" condition — exerciser
|
||||
- **[P3]** `POST /v1/admin/discount-codes` requires an undocumented `kind` field → opaque 422 — exerciser
|
||||
- **[P3]** Field-naming inconsistencies across related endpoints: `license_id` vs `id`; machines use `key` vs documented `license_key`; `redeem`/`purchase` use `product` vs `validate`'s `product_slug` — exerciser (Surprises)
|
||||
- **[P3]** Migration self-heal auto-deletes `_sqlx_migrations` rows on checksum drift, gated only by a hand-maintained allowlist — foot-gun as the list grows — `db/mod.rs` / migration 0009 — evaluator (Surprise) + security-auditor
|
||||
- **[P3]** Zaprite `validate_webhook` WARN-logs up to 2 KB of raw unauthenticated payload — log-flood / log-injection from a public endpoint — `provider.rs:262-278` — security-auditor
|
||||
- **[P3]** Outbound webhook delivery POSTs to operator-supplied URLs with no SSRF allow-list (operator-only input; a stolen admin token could pivot to internal hosts) — `webhooks.rs:150-156` — security-auditor
|
||||
- **[P3]** Stale comment in `startos/versions/v0.2.0.ts:3-4` says "NOT YET WIRED INTO versions/index.ts" but it is wired as `current` at `:53` — evaluator
|
||||
**P0** — none.
|
||||
|
||||
- [P1] Rust SDK examples + README + `integrate.html` import `licensing_client` but the crate is `keysat-licensing-client`; examples don't compile — `licensing-client-rust/{README.md:26,39,56, src/lib.rs:20, examples/offline_verify.rs:10, examples/online_validate.rs:9}`, `keysat-docs/integrate.html:130` — exerciser + doc-auditor
|
||||
- [P1] Cross-language crosscheck harness has a hardcoded dev-machine path `/sessions/hopeful-determined-edison/...`, breaks on every other machine — `tests/crosscheck/run_ts.mjs:9` — exerciser
|
||||
- [P1] No `prepare.sh` for the clean-Debian first build → blocks Start9 community-registry submission (already tracked in AGENTS.md/ROADMAP; not blocking the live deploy) — repo root of `licensing-service-startos/` — start9-spec-checker
|
||||
- [P2] `X-Forwarded-For` is trusted verbatim as the rate-limit bucket key on brute-forceable endpoints; rotating XFF defeats login/recover/validate throttles (exploitability hinges on whether the StartOS front proxy overwrites XFF) — `api/auth.rs:137`, `api/recover.rs:65-70`, `api/admin.rs:63-68`, `api/validate.rs:95-98` — security-auditor
|
||||
- [P2] Dependency advisories with fixes available: `sqlx 0.7.4` (RUSTSEC-2024-0363), `rustls-webpki 0.101.7` (RUSTSEC-2026-0098/0099/0104), wrapper `fast-xml-parser` via start-sdk (GHSA-5wm8-gmm8-39j9 high) — `licensing-service/Cargo.lock`, wrapper `package-lock.json` — security-auditor
|
||||
- [P2] Admin UI + `/v1/admin/*` share the single `:8080` interface with the public `/v1/validate` + `/buy` surfaces (path-routed only); operator can't network-isolate the admin surface — `startos/interfaces.ts:22-77`, `Dockerfile:95` — security-auditor
|
||||
- [P2] Admin webhook-endpoint registration accepts arbitrary URLs incl. loopback IPs and `file://` scheme (no allowlist on the `reqwest` worker); admin-gated but `file://` should never be accepted — `webhooks.rs:106` — exerciser
|
||||
- [P2] `db/repo.rs` (+ `subscriptions.rs`) use runtime-prepared `sqlx::query(&format!(...))` with no compile-time column checking; this class already shipped a prod regression (every paid purchase 500'd on `:52`) — `db/repo.rs:662,718`, `subscriptions.rs:180` — evaluator
|
||||
- [P2] `rate_buckets` grows unbounded — one row per distinct client IP on a public endpoint, with no reaper (sessions/redemptions have reapers; rate buckets don't) — `rate_limit.rs:63-78`, cf. `main.rs:151,174` — evaluator
|
||||
- [P2] No CI enforces anything; tree has never been `cargo fmt`'d (56 daemon files differ, 37 wrapper files differ from prettier); green depends on the operator remembering to run the suite before `publish.sh` — `docs/guides/testing.md:22-30` — evaluator
|
||||
- [P2] `unlimited_merchant_profiles` live-status contradiction between docs/code ("still needs adding") and AGENTS.md ("confirmed live") — `docs/guides/licensing-tiers.md:43-45`, `src/api/tier.rs:234-237` vs AGENTS.md — doc-auditor
|
||||
- [P3] Go SDK alone accepts trailing payload bytes; daemon + Rust + TS + Python all reject them — divergence in the "wire-compatible" contract (not a forgery hole) — `licensing-client-go/keysat.go:192-193` — evaluator
|
||||
- [P3] Fingerprint-bound payload check runs *after* machine-row activation + `machine.activated` webhook dispatch; a bound-mismatch creates a row + fires a webhook before rejecting — `validate.rs:412` vs `:326-345` — evaluator
|
||||
- [P3] Rate-limit consume is read-then-write (non-atomic TOCTOU); concurrent requests to one bucket can both pass — `rate_limit.rs:31-78` — evaluator
|
||||
- [P3] Buyer-controlled `req.redirect_url` passed unvalidated to BTCPay as post-purchase `redirectURL` (self-targeted open redirect) — `api/purchase.rs:513` — security-auditor
|
||||
- [P3] `html_escape` omits `'` (safe only because templates use double-quoted attributes) — `api/buy_page.rs:1536` — security-auditor
|
||||
- [P3] `audit:read` still folded into the blanket `:read` scope (a read-only key can read the full audit log) — `api/api_keys.rs` (already in AGENTS.md TODOs) — security-auditor
|
||||
- [P3] Container runs as root; justified by StartOS LXC isolation but a non-root user + chowned volume is stronger — `Dockerfile:70-95` — security-auditor
|
||||
- [P3] `run_migrations_with_self_heal` deletes `_sqlx_migrations` rows on checksum mismatch to avoid crash-loops — pragmatic but quietly self-modifying; worth a second look — `db/mod.rs:44` — evaluator (Surprise)
|
||||
- [P3] Health check is port-listening only and flips green before migrations finish; daemon exposes richer `GET /healthz` (and `/health` 404s, could confuse probes) — `startos/main.ts:237` — start9-spec-checker + exerciser
|
||||
- [P3] 21 registered StartOS actions vs ~4 documented in `instructions.md`/README; extra (legacy) surface Start9 will exercise during review — `startos/actions/`, `instructions.md` — start9-spec-checker + doc-auditor
|
||||
- [P3] Source-available license (`LicenseRef-Keysat-1.0`, not OSI) may trigger a Start9 policy question; source is public but redistribution is restricted — confirm with submissions@start9.com — start9-spec-checker
|
||||
- [P3] Universal s9pk publish path never exercised end-to-end ("confirm the registry index lists both arches on first publish") — `docs/guides/startos-packaging.md:63` — start9-spec-checker
|
||||
- [P3] Built manifest emits `donationUrl: null`; confirm Start9 registry ingest tolerates a null vs an omitted field — start9-spec-checker
|
||||
- [P3] ROADMAP.md:8 still lists the Zaprite auto-charge silent-lapse as open; fix shipped in `:60` — `ROADMAP.md:8` vs `src/subscriptions.rs:1408-1428` — doc-auditor
|
||||
- [P3] BUILDING.md stale: "rust:1.75-slim-bookworm" (now `1.88`) and "Node 20+" (now Node 22) — `BUILDING.md:15,20` vs `Dockerfile`/`startos-packaging.md:14`/`package.json` — doc-auditor
|
||||
- [P3] Landing-page footer links `docs.keysat.xyz/changelog` but no `changelog.html` exists — `keysat-xyz-landing/index.html:1072` — doc-auditor
|
||||
- [P3] README action mislabeled "Show credentials"; actual action name is "Show admin API key" — `licensing-service-startos/README.md:160` vs `startos/actions/showCredentials.ts:21` — doc-auditor
|
||||
- [P3] `PORTING_SDK_TO_NEW_LANGUAGES.md` describes Python/Go as "v0.2 goals" (both shipped) and calls the Rust crate `licensing-client` (renamed) — `PORTING_SDK_TO_NEW_LANGUAGES.md:1-13` — doc-auditor
|
||||
- [P3] `docs/guides/testing.md:57` test counts stale (54 vs actual 65 in `api` suite) — doc-auditor
|
||||
- [P3] `ZAPRITE_INTEGRATION_SPEC.md:401` references receipt emails (email plan dropped); add a SUPERSEDED banner — doc-auditor
|
||||
- [P3] `MASTER_KEYPAIR_PROCEDURE.md:199-210` references a "v0.3 rolling-rotation flow" with no documented timeline; verify still planned or move to ROADMAP — doc-auditor
|
||||
- [P3] Max int64 price (9.2e18 sats) accepted/stored with no upper bound — exerciser (Surprise)
|
||||
- [P3] `validate` silently ignores an unknown `product_id` body field (only `product_slug` is checked) — a dev sending `product_id` gets a false `ok:true` — `api/validate.rs` — exerciser (Surprise)
|
||||
- [P3] Deprecated `SETTING_ACTIVE_PROVIDER` constant emits a build warning on every compile — evaluator + exerciser
|
||||
- [P3] CORS `allow *` on all endpoints incl. admin (by design for SDK callers; relies on key auth + TLS) — exerciser
|
||||
|
||||
## Scorecard
|
||||
|
||||
The evaluator's lens table, with one adjustment from cross-agent evidence:
|
||||
|
||||
| Lens | Score /5 | Note |
|
||||
|------|----------|------|
|
||||
| Architecture | 4 | Clean trait + per-profile resolver; legacy `state.payment` singleton coexists and is what tests inject into — the fork behind the test gap. |
|
||||
| Security | **3** (was 4) | **Adjusted down** by the security-auditor's P0 forgery + P1 missing-amount-check (funds loss). Fundamentals remain 4–5 (Argon2id, HMAC, injection-clear, FK-enforced); the Zaprite P0 is the pull. Zaprite is optional, which bounds blast radius. |
|
||||
| Performance | 4 | WAL + 8-conn pool, indexed lookups, bounded workers; no load test run. |
|
||||
| Testing | 3 | Cross-language LIC1 fixtures are a real strength; the production purchase/settle path is untested — corroborated by the exerciser's inability to reach it and the shipped-but-broken scoped-API-key feature. |
|
||||
| Code quality | 5 | Disciplined dynamic SQL (all `.bind()`, const-only interpolation); honest comments. Caveat (not score-changing): the exerciser found absent input validation (slug) and plain-text error leaks. |
|
||||
| Documentation | 5 | Scoped guides + wire-format spec are reference-grade and candid (testing.md names its own known failures). |
|
||||
| Lens | Score /5 | Notes (adjustments from cross-evidence) |
|
||||
|---|---|---|
|
||||
| Architecture | 4 | Clean module separation, provider abstraction with a test seam, bounded background workers. −1 for runtime-prepared SQL throughout `db/repo.rs`. (evaluator) |
|
||||
| Security | **4** | Adjusted down from evaluator's 5. Core crypto/auth/SQL surfaces are genuinely 5-grade (constant-time compares, parameterized SQL, fail-closed webhook re-fetch). But a dedicated auditor surfaced **three independent P2s** (XFF bypass, dependency advisories, admin/public co-location) plus the exerciser's `file://` webhook-registration P2 — more hardening debt than a 5 carries until addressed. |
|
||||
| Performance | 4 | WAL + 8-conn pool + FK + busy_timeout; reconcile loop bounded. −1 for unbounded `rate_buckets` and the read-then-write limiter. (evaluator) |
|
||||
| Testing | 4 | ≈117–131 tests, 8 suites, all green; crosscheck verifies v1+v2 across all SDKs. No CI enforces it; harness has a portability bug (P1) and Rust examples don't build (P1). (evaluator + exerciser) |
|
||||
| Code quality | 4 | Comments explain *why*; idempotency where it matters. `cargo fmt` never run; a few very long handlers. (evaluator) |
|
||||
| Documentation | **4** | Adjusted down from evaluator's 5. Subsystem guides the evaluator read are accurate, but the doc-auditor's broader sweep found real drift: a published SDK README import that breaks compilation, a broken changelog link, stale Node/Rust versions, a contradicted merchant-profile entitlement status, and stale test counts. Solid but not pristine. |
|
||||
|
||||
## Disagreements & gaps
|
||||
|
||||
- **Severity disagreement, resolved:** evaluator rated the Zaprite webhook P2; security-auditor traced it end-to-end and rated it P0. Resolved to **P0** — it is unauthenticated, public, and mints signed licenses (direct funds loss for any operator running the optional Zaprite provider — which includes the master operator).
|
||||
- **Shared blind spot (all agents):** none could exercise the live BTCPay/Zaprite settlement→issuance path without real provider credentials. The evaluator and exerciser both labeled their payment-path confidence "medium." The single highest-value verification missing is a full purchase cycle against a real BTCPay/Zaprite sandbox — which also exposes the P0/P1 settle-path logic to a real test.
|
||||
- **Quality tension:** evaluator scored code-quality 5 on SQL discipline; the exerciser found input-validation gaps (slug) and framework error leaks. Both are true at different layers — the score holds, the caveat is noted.
|
||||
- **Security lens (5 vs 4):** the evaluator scored Security 5 from the crypto/auth/SQL code; the security-auditor (whose whole job is the adversarial pass) surfaced three P2 hygiene/hardening items. Resolved by adjusting to 4 with the core surfaces explicitly credited — no factual conflict, just depth of coverage.
|
||||
- **Documentation lens (5 vs 4):** the evaluator judged the subsystem guides it read (accurate → 5); the doc-auditor swept the SDK READMEs and public sites and found drift the evaluator didn't reach. Adjusted to 4.
|
||||
- **Shared blind spot — the live payment path:** every runtime-exercising agent stopped at the same wall. The exerciser could not test `POST /v1/purchase`, recover, upgrade, subscription-cancel, or BTCPay connect/disconnect (no live BTCPay/Zaprite); the security-auditor could not confirm whether the StartOS front proxy strips client-supplied XFF (which decides if the P2 rate-limit bypass is remotely reachable); the start9-spec-checker could not run on-box install/backup/restore. The single highest-value next test, named independently by multiple agents: run the `onboarding-harness` Stage 1+2 against a **regtest BTCPay** to exercise purchase → webhook-settle → license-issue end-to-end.
|
||||
- **No 0.4.x submission docs exist publicly:** the start9-spec-checker could only verify against the 0.3.5.x submission page (the only one Start9 links). Whether `prepare.sh` is still required for the 0.4.x SDK era is unconfirmable from public docs (the 0.4.x `hello-world` template also lacks one) — treated as a blocker pending Start9 confirmation.
|
||||
|
||||
## Suggested order of work
|
||||
|
||||
1. **Fix the Zaprite P0 with its test alongside.** On any settle event, re-fetch `get_invoice_status` from the provider and require `pending` local state + matching paid amount/currency before issuing — this closes both the P0 forgery and the P1 missing-amount-check in one change. (The reconciler already does the safe re-fetch; mirror it in the webhook handler.)
|
||||
2. **Add the provider-injection test seam first** (an always-compiled `Option<Arc<dyn PaymentProvider>>` override on `AppState`, checked in `resolve_provider_for_profile_rail`). This is a prerequisite for trusting any payment-path test result — it greens the two `paid_purchase_*` red tests AND gives step 1 a regression harness. Delete the dead `payment_provider_preference_round_trip` in the same pass.
|
||||
3. **Decide scoped API keys:** either finish the `require_scope` migration or stop advertising/issuing `ks_…` keys until it lands — a shipped-but-403 feature is worse than an absent one.
|
||||
4. **Quick-win hardening batch:** rate-limit `/v1/purchase` + `/v1/redeem`; JSON-ify 422/415; add slug validation; wire `GET /v1/admin/products`; bump `sqlx` ≥0.8.1 and `rustls`/`rustls-webpki`.
|
||||
5. **StartOS submission fixes** (before any registry submission): the two manifest dead links and the aarch64-vs-manifest decision are trivial; add `instructions.md`; confirm with Start9 whether the custom `LicenseRef-Keysat-1.0` satisfies their "source available" bar.
|
||||
6. **Add a CI gate** (Gitea Actions/Woodpecker running `cargo test` + `clippy`) so the honor-system test gap — the meta-cause behind the shipped `:52` and Zaprite defects — closes permanently.
|
||||
7. **Then resume feature work:** the product→merchant-profile picker (already scoped at the top of the AGENTS.md roadmap; the write path is the gating piece that makes multi-profile usable end-to-end) and the remaining deferred UIs.
|
||||
1. **Fix the two shipped P1 DX breakages first** (cheap, adopter-facing): correct every `licensing_client` → `keysat_licensing_client` import across the Rust SDK + `integrate.html`, and make `tests/crosscheck/run_ts.mjs` resolve the TS SDK build relative to its sibling dir instead of the hardcoded `/sessions/...` path. Add a crosscheck case feeding trailing bytes to all four SDKs (also closes the Go P3).
|
||||
2. **Patch the dependency advisories** (`sqlx ≥0.8.1`, pull `rustls-webpki ≥0.103.13` via the reqwest/rustls bump, update start-sdk for `fast-xml-parser`); re-run `cargo audit`/`npm audit`. Low-risk, mechanical, and removes the only externally-known CVEs.
|
||||
3. **Resolve the XFF rate-limit bypass** — first confirm whether the StartOS front proxy overwrites `X-Forwarded-For` (this decides reachability); then derive the client IP from the trusted-proxy connection / configured allowlist with a peer-socket fallback rather than raw XFF.
|
||||
4. **Stand up minimal CI** (one job: `cargo test && cargo clippy && tsc --noEmit`, ideally `cargo fmt --check`). This makes the already-good suite *trustworthy* and is a precondition for relying on the green status in everything below.
|
||||
5. **Retire the runtime-prepared-SQL risk class** on the money paths — migrate the purchase/settle/resolution queries in `db/repo.rs` + `subscriptions.rs` to compile-checked `sqlx::query!`, and add a `rate_buckets` reaper mirroring the session/redemption reapers.
|
||||
6. **Clear the doc drift in one pass** — resolve the `unlimited_merchant_profiles` contradiction with a live `GET /v1/products/keysat/policies` (then fix whichever of the guide/code-comment/AGENTS.md is wrong), and fix the ROADMAP/BUILDING/README/PORTING/testing-count/changelog-link items together.
|
||||
7. **Unblock the registry milestone** — author `prepare.sh` for the clean-Debian build, then do the on-box manual checklist (install → connect BTCPay → backup → uninstall → restore → verify key+licenses survive) and email submissions@start9.com about the source-available license before submitting.
|
||||
|
||||
+4
-4
@@ -4,7 +4,7 @@ This is a plain-English walkthrough of what Keysat actually does, written for pe
|
||||
|
||||
## The cast
|
||||
|
||||
Alice runs her software on her own Start9 server at home. She has two services there: her **BTCPay Server** (a self-hosted Bitcoin payment processor), and a new one she just installed called **licensing-service** — the thing this whole project is about. Her licensing-service is single-tenant: it sells *her* software only, not anyone else's. If a fellow developer wants the same functionality, they install their own copy on their own Start9, just like Alice did.
|
||||
Alice runs her software on her own Start9 server at home. She has two services there: her **BTCPay Server** (a self-hosted Bitcoin payment processor), and a new one she just installed called **licensing-service** — the thing this whole project is about. Alice runs her own copy: Keysat is not a shared SaaS, so a fellow developer who wants the same functionality installs their own on their own Start9, just like Alice did. Within her own instance, though, Alice isn't limited to a single business — on the paid tiers she can sell for several distinct brands side by side (more in *What this setup is not*, below).
|
||||
|
||||
Bob is a customer. He may or may not own a Start9. Whether he does affects the experience, not the mechanics — we'll get to that.
|
||||
|
||||
@@ -42,7 +42,7 @@ Nothing was copied or pasted. Bob never saw the license string. The key got capt
|
||||
|
||||
### Bob is on a regular computer, and Alice's software is not a Start9 package
|
||||
|
||||
This is the more common case for today's world. Bob goes to Alice's website, clicks "Buy," and is taken to a BTCPay checkout. He pays. BTCPay shows a success page that includes the license string — at that point it's the only way he could get it, short of an email. Bob copies the string and pastes it into Alice's app's settings dialog. Alice's app (which has embedded her public key at build time) verifies the signature and unlocks. From then on, no network is required.
|
||||
This is the more common case for today's world. Bob goes to Alice's website, clicks "Buy," and is taken to a BTCPay checkout. He pays. BTCPay shows a success page that includes the license string — that page is how Bob gets his key (Keysat itself never emails it; if he loses it, Alice can look it up and reissue, covered below). Bob copies the string and pastes it into Alice's app's settings dialog. Alice's app (which has embedded her public key at build time) verifies the signature and unlocks. From then on, no network is required.
|
||||
|
||||
### Bob never paid — Alice gave him a comp
|
||||
|
||||
@@ -86,11 +86,11 @@ Webhook deliveries are also not the only signal. A background task inside licens
|
||||
|
||||
## What this setup is *not*
|
||||
|
||||
Not a subscription service. A license here is perpetual — one payment, one key, forever. If Alice wants subscriptions she would need to add expiry to the payload and a renewal flow; the payload format has a `flags` byte and a future version bump to support that, but it is not in scope for the current version.
|
||||
Not subscription-*only*. The default license is perpetual — one payment, one key, forever — and that's the simplest thing Alice can sell. But Keysat also supports recurring **subscriptions** on the paid tiers: a time-boxed license whose payload carries an expiry, plus a background renewal worker that re-bills the buyer and re-issues the key each cycle (auto-charging a saved payment method where one exists, falling back to a manual-pay link otherwise). Perpetual and subscription products coexist on one instance.
|
||||
|
||||
Not a DRM system. It does not prevent someone with a debugger from patching out the license check. Nothing running on the user's machine can, by construction — the user controls their CPU. This is a licensing system for reasonable people who want to pay.
|
||||
|
||||
Not multi-tenant. Each Alice runs her own licensing-service. Two Alices cannot share one server to sell two different products. That simplification is deliberate — it keeps the data model small, makes disaster recovery obvious ("back up one SQLite file"), and keeps Alice in control of the signing key. If someone wants a SaaS version that hosts many sellers, that's a different product.
|
||||
Not a multi-seller SaaS. Each Alice runs her own licensing-service; two independent sellers don't share one box. That keeps disaster recovery obvious ("back up one SQLite file") and keeps Alice in sole control of her signing key. What *did* change: one instance is no longer limited to one business. On the paid tiers Alice can define multiple **merchant profiles** — separate brands, each with its own payment accounts, checkout branding, and products — and run them all from the one server. So "one operator, one box" still holds; "one box, one business" no longer does.
|
||||
|
||||
## The bet
|
||||
|
||||
|
||||
+56
-43
@@ -4,11 +4,8 @@ Longer-term backlog. Near-term state lives in `AGENTS.md` → Current state.
|
||||
|
||||
## Payments & subscriptions
|
||||
|
||||
- Per-profile SMTP override (schema fields exist from the keysat-smtp-emails plan; needs the form + send path).
|
||||
- Rail-preference editing UI — only matters when two providers on one profile both serve the same rail; settable today via `PUT /v1/admin/merchant-profiles/:id/rail-preferences/:rail`.
|
||||
- Keysat-side dedup cache for Zaprite contacts (same buyer purchasing recurring twice can create duplicate Zaprite contacts).
|
||||
- Zaprite declined-card / expired-profile failure-body shapes are undocumented — harden `try_auto_charge_zaprite` once observed in production.
|
||||
|
||||
- **Auto-charge silently lapses a subscription on a 200-with-failure response (money-path bug; elevated above the other parked payments items).** `try_auto_charge_zaprite` returns `Ok(true)` on *any* HTTP 2xx (`subscriptions.rs:1403-1410`), reading the order `status` only for a log line. If Zaprite returns 200 carrying a `FAILED`/`DECLINED`/`EXPIRED` order status, the daemon fires `auto_charge_initiated` and then waits for an `order.paid` webhook that never arrives — the subscription silently lapses, no error surfaced, the customer churns. Safe fix (no production data needed): treat any non-`PAID` terminal order status as not-success and fall through to the manual-pay path — a conservative fail-safe, ~10 lines + a mock-provider test. (Found during the 2026-06-17 adjudication; it replaces the old "harden Zaprite failure-body shapes" item, which was already satisfied for non-2xx responses — those route correctly to WARN + `auto_charge_failed` audit + webhook + manual-pay fallback.)
|
||||
## Agent compatibility & scoped API keys
|
||||
|
||||
- **Agent-delegable payment-provider connect** (approved, not urgent — see
|
||||
@@ -31,12 +28,60 @@ Longer-term backlog. Near-term state lives in `AGENTS.md` → Current state.
|
||||
|
||||
## Packaging & distribution
|
||||
|
||||
- Start9 Community Registry submission — a 2026-06-17 spec check found the wrapper passes the functional
|
||||
criteria (manifest, interfaces, health check, backup/restore, BTCPay dep, actions). Remaining gap before
|
||||
submission: add a `prepare.sh` to set up a clean Debian box for the first build (copy the one from
|
||||
`hello-world-startos`), then run the on-box manual verification (install / backup / restore / logs).
|
||||
Submission criteria themselves remain unpublished; reach out to Start9 when ready. (Icon-render and the
|
||||
source-available license are intentionally not treated as blockers.)
|
||||
- **Start9 Community Registry submission.** Mechanism (researched 2026-06-18): **email-based, not a PR or
|
||||
form.** Mail `submissions@start9labs.com` (the 0.3.5.x docs say `submissions@start9.com` — addresses are
|
||||
inconsistent) a link to the public wrapper repo (+ detailed README); both wrapper and upstream source must
|
||||
be public. Start9 snapshots the repo, **builds from source on a clean Debian box** (`prepare.sh` + `make`; a
|
||||
failed first build bounces the submission), installs + tests on real hardware (metadata, install/uninstall,
|
||||
interfaces, health, backup/restore, low-resource device), lands it in Community **Beta**, and promotes to
|
||||
production when you reply asking. Updates follow the same loop. `start-cli s9pk publish` is **self-hosted-registry
|
||||
only** — unrelated to community intake. `prepare.sh` shipped this session (`licensing-service-startos/prepare.sh`).
|
||||
**Clear with Start9 before submitting:** (1) is the custom source-available `LicenseRef-Keysat-1.0` acceptable
|
||||
(docs conflict: "source available" vs "Open Source License") — highest-leverage; a hard No blocks regardless of
|
||||
build-readiness; (2) does the 0.4.x build flow still invoke `prepare.sh` (a 0.3.5.x concept, absent from 0.4.x
|
||||
docs). Then the on-box manual verification. Functional criteria otherwise pass (2026-06-17 spec check).
|
||||
|
||||
## Operability & alerts
|
||||
|
||||
- **Surface internal failure conditions to the operator via StartOS-native notifications / health checks** —
|
||||
NOT a bespoke email/SMTP subsystem. The need is real and not covered by the webhook-delegation model: when
|
||||
the failure IS the webhook path (a dead-lettered endpoint, or the operator's receiver being down), a webhook
|
||||
can't report it, and the operator's own email pipeline is downstream of the same webhooks. So Keysat must own
|
||||
an out-of-band operator-visible channel — and on StartOS that channel is the OS notification/health surface,
|
||||
not a mailer Keysat ships itself. Conditions worth alerting on (the surviving kernel of the dropped email plan):
|
||||
payment-provider auth dead (repeated 401s ⇒ key revoked/rotated), a webhook endpoint hitting dead-letter,
|
||||
master self-license expiring-soon / expired, and (optional) renewals failing across the pool. The health-check
|
||||
path is already wired; verify the `start-sdk` 1.3.2 notification API before committing to a delivery mechanism.
|
||||
Background + the original alert catalog (Tier 1/2/3, throttling) live in the superseded
|
||||
`plans/keysat-smtp-emails.md`. **Buyer-facing email and the per-profile SMTP send path are dropped** (decided
|
||||
2026-06-18): operators selling via Keysat already own their buyer relationship and email pipeline through their
|
||||
own app + the existing webhooks, so Keysat emailing buyers is redundant and a branding/double-send liability.
|
||||
The dormant `merchant_profiles.smtp_*` columns (migration 0020) are now dead weight — left in place (a removal
|
||||
migration isn't worth it) and flagged in `src/merchant_profiles.rs`.
|
||||
|
||||
## Security & hardening (2026-06-18 full-eval P2s; EVALUATION.md has full detail but is overwritten each run, so the durable list lives here)
|
||||
|
||||
- **X-Forwarded-For rate-limit bypass.** Login/recover/validate buckets key off the raw first XFF value
|
||||
(`api/auth.rs:137`, `api/recover.rs:65`, `api/admin.rs:63`, `api/validate.rs:95`); rotating XFF defeats the
|
||||
throttle. First confirm whether the StartOS front proxy overwrites XFF (decides real-world reachability), then
|
||||
derive client IP from the trusted-proxy connection with a peer-socket fallback.
|
||||
- **Dependency advisories** (mechanical, low-risk): bump `sqlx 0.7.4` → ≥0.8.1 (RUSTSEC-2024-0363),
|
||||
`rustls-webpki 0.101.7` → ≥0.103.13 (RUSTSEC-2026-0098/0099/0104), update start-sdk for the wrapper's
|
||||
`fast-xml-parser` (GHSA-5wm8-gmm8-39j9); re-run `cargo audit` / `npm audit`.
|
||||
- **Admin UI co-located with the public API** on the single `:8080` interface (`startos/interfaces.ts`) — operator
|
||||
can't network-isolate admin. Split the admin SPA + `/v1/admin/*` onto their own port/interface.
|
||||
- **Webhook-endpoint registration accepts `file://` / loopback URLs** (`webhooks.rs:106`, admin-gated) — add a
|
||||
scheme + host allowlist (reject non-http(s), loopback, link-local).
|
||||
- **Runtime-prepared SQL** in `db/repo.rs` + `subscriptions.rs` (no compile-time column check; this class already
|
||||
500'd every paid purchase once on `:52`) — migrate the money-path queries to compile-checked `sqlx::query!`.
|
||||
- **`rate_buckets` grows unbounded** (`rate_limit.rs:63`, one row per client IP on a public endpoint, no reaper) —
|
||||
add a reaper mirroring the session/redemption reapers in `main.rs`.
|
||||
- **No CI.** Stand up one job (`cargo test && cargo clippy && tsc --noEmit`, ideally `cargo fmt --check`); the suite
|
||||
is good but unenforced, so green depends on the operator remembering to run it before `publish.sh`.
|
||||
- Doc-drift P3 cluster (each one-liners, see EVALUATION.md): BUILDING.md Node 20→22 / Rust 1.75→1.88, broken docs
|
||||
`/changelog` footer link, README "Show credentials" → "Show admin API key", PORTING_SDK stale (Python/Go shipped;
|
||||
crate renamed), testing.md stale test counts, and the `unlimited_merchant_profiles` guide/code-comment ("still
|
||||
needs adding") vs AGENTS ("confirmed live") contradiction — resolve with a live `GET /v1/products/keysat/policies`.
|
||||
|
||||
## Licensing model
|
||||
|
||||
@@ -45,36 +90,4 @@ Longer-term backlog. Near-term state lives in `AGENTS.md` → Current state.
|
||||
## Validation
|
||||
|
||||
- Re-test `KEYSAT_INTEGRATION.md` against a fresh downstream app to confirm a clean one-shot SDK integration.
|
||||
- End-to-end Zaprite sandbox pass on the multi-merchant-profile webhook routing before relying on it in production.
|
||||
|
||||
## Design (contract conformance)
|
||||
|
||||
The brand contract now lives in `design/DESIGN.md` + `design/tokens.tokens.json` (distilled
|
||||
2026-06-16 from the prior Claude Design system, now archived in `design/_imports/`). A
|
||||
`design-checker` audit (2026-06-16) found high fidelity overall, with these items where the
|
||||
**code contradicts the contract's stated rules** or bypasses the token scale:
|
||||
|
||||
**Blockers (code violates a named "never" rule):**
|
||||
- Gold used as an actionable *fill* (contract: gold is accent/border only, never a fill).
|
||||
(a) admin SPA `.featured-pill-toggle.on` → `web/index.html:418`; (b) admin sidebar
|
||||
upgrade CTA `#tier-banner-cta` → `web/index.html:537-542`. Fix to navy-fill or
|
||||
gold-border/text.
|
||||
- Primary buy CTA uses pill radius `999px` (contract: buttons are `r-md` 8px; pill is
|
||||
badges-only) — `keysat-xyz-landing/index.html:384-385`. Set to 8px.
|
||||
|
||||
**Structural (headline):**
|
||||
- All three surfaces inline their own copy of the CSS variables instead of importing the
|
||||
canonical `design/brand/palette.css` (landing :33-56, docs.css :7-21, admin :9-25). Copies
|
||||
are currently exact but one edit from drift. Consolidate onto `palette.css`.
|
||||
|
||||
**Token gaps / drift (decide: tokenize the as-built value, or snap to an existing token):**
|
||||
- `14px` card radius used throughout the marketing landing — not a token (between `r-lg` 12
|
||||
and `r-xl` 18). Snap to a token or add one.
|
||||
- Wordmark letter-spacing is `0.30em` (landing) vs `0.28em` (docs/admin) and has no token —
|
||||
pick one value, add a `letterSpacing.wordmark` token.
|
||||
- Semantic badge *text* colors (`#205c47`/`#7a5814`/`#8a2828`) are darker one-offs with no
|
||||
token — add `semantic.*-text` tokens or reference existing ones.
|
||||
- Syntax-highlight colors hardcoded as hex (`#d4b985`=gold-400, `#a6b7cf`=navy-300) — switch
|
||||
to `var()`. One admin hex `#f6f1e7` isn't a token (closest cream-50/100) — reconcile.
|
||||
- Sticky-header backdrop on docs/admin (`blur(10px)`/`blur(8px)`) diverges from the contract's
|
||||
`blur(12px)` — align if a single header treatment is wanted.
|
||||
- **Add an automated regression test for multi-profile webhook routing** (adjudicated 2026-06-17 → DO, low blast radius — replaces the parked "manual Zaprite sandbox pass"). The routing is a deterministic provider-id→profile primary-key lookup with an anti-forgery re-fetch backstop, so the manual sandbox ceremony isn't worth it — but the path-keyed route (`/v1/{provider}/webhook/:provider_id` → `handle_for_provider`) currently has zero automated coverage on the money path. Plan: in `tests/api.rs`, reuse the two-provider fixture (~:3958), POST a Settled webhook to `/v1/zaprite/webhook/{provider-A-id}`, assert only profile A settles (B untouched; an unknown path-id 404s). Existing mock seam, no external account, runs in `cargo test`. Effort S.
|
||||
|
||||
+6
-3
@@ -137,6 +137,9 @@ When building or changing a Keysat UI:
|
||||
icons, sentence-case labels, no emoji.
|
||||
- If a needed value genuinely isn't in the tokens, **add it to the tokens file** (and ideally
|
||||
`palette.css`) rather than inlining a one-off — keep the contract the single source of truth.
|
||||
- Known debt (see ROADMAP): the three surfaces currently inline their own copy of the
|
||||
variables. Prefer importing `design/brand/palette.css`; when you touch a surface, move it
|
||||
toward that shared source rather than perpetuating a private copy.
|
||||
- Known debt: the three surfaces inline their own copy of the variables. For **landing and
|
||||
docs** (static files) prefer importing `design/brand/palette.css` when you touch them. The
|
||||
**admin SPA is rust-embedded and cannot `@import` at runtime**, so its copy stays inline by
|
||||
necessity — keep it a verbatim copy of `palette.css`, don't let it drift. (A blanket
|
||||
"consolidate all three onto `palette.css`" task was adjudicated and **dropped** 2026-06-18
|
||||
for exactly this reason — it can't remove the admin duplication it targets.)
|
||||
|
||||
@@ -17,8 +17,10 @@ paths:
|
||||
|
||||
# Payments & the multi-provider / merchant-profile model
|
||||
|
||||
Full design spec: `plans/multi-provider-payment-model.md`. Companion email plan:
|
||||
`plans/keysat-smtp-emails.md`.
|
||||
Full design spec: `plans/multi-provider-payment-model.md`. The companion email
|
||||
plan `plans/keysat-smtp-emails.md` is **superseded** — Keysat does not send email;
|
||||
operators receive events via webhooks and run their own email pipelines (see the
|
||||
"Operability & alerts" item in `ROADMAP.md`).
|
||||
|
||||
## Model
|
||||
|
||||
@@ -32,7 +34,8 @@ merchant_profiles (1) ──< (N) payment_providers
|
||||
```
|
||||
|
||||
- **merchant_profiles** — business identity: name, branding, post-purchase
|
||||
redirect, optional per-profile SMTP override fields. Exactly one `is_default`,
|
||||
redirect. (The `smtp_*` columns from migration 0020 are **dormant** — never read
|
||||
by any send path; the email plan was dropped.) 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`).
|
||||
|
||||
@@ -28,6 +28,18 @@ 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).
|
||||
|
||||
## Clean-box build bootstrap (`prepare.sh`)
|
||||
|
||||
`prepare.sh` (in `licensing-service-startos/`) installs every HOST prerequisite a
|
||||
fresh Debian/Ubuntu box needs before `make`: apt prereqs (build-essential, jq, git,
|
||||
squashfs-tools(-ng)), Node 22 (NodeSource), Docker (+ QEMU binfmt for cross-arch),
|
||||
and **start-cli** via the official installer —
|
||||
`curl -fsSL https://start9.com/start-cli/install.sh | sh` (drops the binary in
|
||||
`~/.local/bin`). Rust is **not** installed on the host; the daemon compiles inside
|
||||
the Dockerfile (`FROM rust:1.88`). Idempotent; apt-based only. Caveat: `prepare.sh`
|
||||
is a 0.3.5.x submission convention — the 0.4.x docs never mention it, so Start9's
|
||||
0.4.x build flow may not actually invoke it (confirm before relying on it).
|
||||
|
||||
## ALWAYS: bump the version before building
|
||||
|
||||
Edit `startos/versions/v0.2.0.ts` — increment `version: '0.2.0:N'` and prepend a
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
import { readFileSync } from 'node:fs'
|
||||
import {
|
||||
// Resolve the built TS SDK relative to this file so the harness runs on any
|
||||
// machine. Build it first: `cd licensing-client-ts && npm run build` (see README).
|
||||
const {
|
||||
Verifier,
|
||||
PublicKey,
|
||||
hashFingerprint,
|
||||
parseLicenseKey,
|
||||
isExpiredAt,
|
||||
hasEntitlement,
|
||||
} from '/sessions/hopeful-determined-edison/ts-sdk-build/dist/index.js'
|
||||
} = await import(new URL('../../licensing-client-ts/dist/index.js', import.meta.url))
|
||||
|
||||
const vector = JSON.parse(readFileSync(new URL('./vector.json', import.meta.url), 'utf8'))
|
||||
|
||||
|
||||
Reference in New Issue
Block a user