From 6d4efc8a331e9d12c688d20bf6eb8dede27a7d4b Mon Sep 17 00:00:00 2001 From: Keysat Date: Sat, 13 Jun 2026 00:10:53 -0500 Subject: [PATCH] Record scoped-keys + settle-tripwire work; document boundary and TODOs Update Current state for the two P1 fixes done this session (source-only, awaiting :55). Document the advisory settle-amount tripwire in payments.md. Add Open TODOs: split audit:read into its own scope tier, and build the admin API-keys management panel (both deferred to later sessions). --- AGENTS.md | 99 +++++++++++++++++++---------------------- docs/guides/payments.md | 26 +++++++++-- 2 files changed, 69 insertions(+), 56 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 89ac066..c93f44c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -95,60 +95,54 @@ Operator-specific memories at `~/.claude/projects/-Users-macpro-Projects-licensi - StartOS Community Registry submission criteria — Start9 hasn't published the checklist; reach out directly when ready. - Registry icon doesn't render in the StartOS marketplace (see `guides/startos-packaging.md`). +- 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. +- Build the admin SPA "API keys" management panel (create w/ role picker, list, + revoke) — backend is wired; UI deferred to a design-focused session. ## Current state (2026-06-13) - **Live**: server `immense-voyage.local` runs daemon `0.2.0:54` (migrations - 0020–0022 applied). Registry `registry.keysat.xyz` publishes `:54` too - (GitHub release `v0.2.0-54` cut; `files.keysat.xyz` serves the s9pk). Four SDKs - published; `keysat.xyz` + `docs.keysat.xyz` deployed. + 0020–0022). Registry `registry.keysat.xyz` publishes `:54`; four SDKs published; + `keysat.xyz` + `docs.keysat.xyz` deployed. **Prod is still `:54` — this + session's two P1 fixes are committed to source but NOT yet built/installed/ + published. Next release builds `:55`.** - **`:52`/`:53` = multi-provider/merchant-profile model**: data model + backend - resolution shipped and audited sound; the resolution/CRUD query surface now has - test coverage. See `docs/guides/payments.md`. -- **Two payment-path fixes shipped 2026-06-13**: (a) `:53` fixed the `:52` - ambiguous-column bug that broke *every* paid purchase (daemon `31f4670`); (b) - `:54` fixed the **P0 Zaprite webhook-forgery** — settle now re-confirms against - the provider API before issuing (daemon `783372c`, bump `495fbbf`). Both built, - installed to prod, and published to the registry. Live purchase + settle paths - are sound. -- **GAP — multi-profile is non-functional end-to-end**: nothing in the shipped - app writes `products.merchant_profile_id` (the INSERT in - `create_product_with_currency` omits it; `update_product_with_currency` has no - field for it; the `Product` struct in `models.rs` doesn't even carry it). So - every product created post-migration stays on the default profile, and a Pro - operator can create extra profiles + attach providers but cannot route any - product's sales to them. The data model + resolver fully support it; only the - product→profile **write path** is missing. **This is the gating piece for - multi-profile** — see the scoped slice below. -- **Triage from `EVALUATION.md` (full-eval, 2026-06-13)** — P0/P1 = work queue, - P2 = known debt, P3+ = deferred. The report at repo root has file:line evidence; - it's tracked, so re-running full-eval overwrites it and `git log -- EVALUATION.md` - preserves prior runs. + resolution shipped and audited sound; resolution/CRUD query surface has tests. + Both `:54` P0s (provider-injection test seam; Zaprite webhook-forgery re-confirm) + remain fixed; live purchase + settle paths sound. -- **Work queue (P0/P1 — do first, in this order)**: - 1. ✅ **SHIPPED in `:54` — Provider-injection test seam.** Added the - always-compiled `AppState::provider_override` + `provider_from_row` helper at - every resolution site; greened the two `paid_purchase_*` tests; deleted the - dead `payment_provider_preference_round_trip`. See `docs/guides/testing.md`. - 2. ✅ **SHIPPED in `:54` — Zaprite webhook forgery fix.** `webhook.rs::handle_inner` - re-fetches `provider.get_invoice_status` and requires `Settled` before any - settle-derived action; acks 200 (no issue) when the provider is unreachable. - Two regression tests (forged-settle, provider-unreachable). api 47/47. - **Still open (auditor P1):** a literal paid-amount/currency check — needs a - trait change (`get_invoice_status` returns only a status enum). See - `docs/guides/payments.md`. **This is now the top remaining security item.** - 3. **[P1] Scoped API keys (`ks_…`) are non-functional** — issuable but 403 on - every admin endpoint; the `require_admin`→`require_scope` migration was never - done. Finish it, or stop advertising/issuing them. `api/api_keys.rs:14`. - - **Then resume feature work**: the **product→merchant-profile picker** (the GAP - above — slice: add `merchant_profile_id` to the `Product` model + `repo.rs` - SELECT mapping; a `set_product_merchant_profile` follow-up writer mirroring - `set_product_entitlements_catalog`; the field on `CreateProductReq`/ - `UpdateProductReq` applied post-write; a profile `` from + `GET /v1/admin/merchant-profiles`, shown only when >1 profile; no migration). + 2. 3 other deferred UIs (rail picker, per-profile SMTP, rail-pref editor); + `unlimited_merchant_profiles` on master Pro/Patron policies. + 3. Deferred this session (now in Open TODOs): split `audit:read` out of the + blanket `:read` scope; build the admin "API keys" management SPA panel. - **Known debt (P2 — schedule, not urgent)**: no rate-limit on `/v1/purchase` + `/v1/redeem`; rate-limit bucket keys on spoofable `X-Forwarded-For` (bypass @@ -171,7 +165,8 @@ Operator-specific memories at `~/.claude/projects/-Users-macpro-Projects-licensi webhook at the path-keyed URL; registry icon non-render (known platform limit); optional fmt/prettier standalone commit. -- **Tests/build**: `cargo check` clean (1 intentional deprecation warning); api - 43 pass / 3 known-fail (now tracked in the work queue above), other suites - green. FK enforcement **confirmed** — sqlx pool sets `foreign_keys(true)` per - connection (`db/mod.rs`). CI/fmt status is in Known debt. +- **Tests/build**: `cargo check` clean (1 intentional deprecation warning); full + suite green — api **54** (incl. new settle-tripwire + scoped-key role-boundary + tests), subscriptions 7, upgrades 9, worker 3, crosscheck 4, migrations 9. No new + clippy warnings. FK enforcement **confirmed** — sqlx pool sets `foreign_keys(true)` + per connection (`db/mod.rs`). CI/fmt status is in Known debt. diff --git a/docs/guides/payments.md b/docs/guides/payments.md index 4291456..990ce4e 100644 --- a/docs/guides/payments.md +++ b/docs/guides/payments.md @@ -65,10 +65,28 @@ webhook body's claim. `api/webhook.rs::handle_inner` re-fetches taking ANY settle-derived action (license issuance, tier-change, subscription renewal — the guard sits ahead of all of them). On a provider-API error it acks `200` without issuing — the reconcile loop re-confirms and issues on its next tick -(fail-closed on issuance, and a 2xx avoids a provider retry-storm). **Not yet -done**: a literal paid-amount/currency check (the trait exposes only a status -enum); trusting the provider's own `Settled` determination is the current -boundary — see the auditor's open P1. +(fail-closed on issuance, and a 2xx avoids a provider retry-storm). + +**Settle-amount tripwire (advisory, not a gate).** `get_invoice_status` returns a +`ProviderInvoiceSnapshot { status, amount }`; on a confirmed settle, +`audit_settle_amount` (shared by the webhook + reconcile issue paths) compares the +provider's reported sat amount against the invoice's `amount_sats` and, on +mismatch, logs a WARN + writes an `invoice.amount_mismatch` audit row — then +**issues anyway**. It is deliberately NOT a hard gate: a literal "amount actually +paid" check is redundant with the `Settled` requirement (both providers only +report `Settled` for a paid-in-full invoice — Zaprite maps `UNDERPAID` → +`Pending`), and a strict `paid >= owed` gate would false-reject operators running +a BTCPay payment tolerance (a deliberate config we must not second-guess). The +tripwire catches *drift* — settling a SAT invoice for an amount other than the +`amount_sats` we recorded. It applies ONLY to SAT-denominated settles: one-shot +purchases and SAT subscriptions always charge `Money::sats` (= `amount_sats`), but +fiat-priced subscription RENEWALS (`subscriptions.rs`) create the order in the +listed fiat currency, where `amount_sats` is not the charged amount — those report +a non-SAT currency and are **skipped** (no clean comparison basis; `Settled` +already covers them). `amount` is likewise `None`/skipped when the provider +response carries no parseable positive amount. Regression tests in `tests/api.rs`: +`settled_amount_mismatch_issues_license_but_audits`, +`settled_non_sat_settle_skips_amount_tripwire`. ## Provider connect