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).
This commit is contained in:
@@ -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 `<select>` from
|
||||
`GET /v1/admin/merchant-profiles` in the create+edit product forms, shown only
|
||||
when >1 profile; no migration), the 3 other deferred UIs (rail picker,
|
||||
per-profile SMTP, rail-pref editor), and `unlimited_merchant_profiles` on
|
||||
master Pro/Patron policies.
|
||||
- **Done this session (source only, awaiting `:55`)** — the two open P1s:
|
||||
1. **Settle-amount tripwire.** `get_invoice_status` now returns
|
||||
`ProviderInvoiceSnapshot { status, amount }`; `audit_settle_amount` (shared by
|
||||
webhook + reconcile issue paths) WARNs + writes an `invoice.amount_mismatch`
|
||||
audit row on drift, then **issues anyway** (advisory, not a gate — a hard gate
|
||||
would fight BTCPay payment tolerance). SAT-only: skips non-SAT (fiat sub
|
||||
renewals) and `None`. Reviewed (caught + fixed a fiat-renewal false-positive).
|
||||
See `docs/guides/payments.md`.
|
||||
2. **Scoped API keys wired.** 58 admin endpoints migrated `require_admin`→
|
||||
`require_scope`; 12 sensitive ones stay master-only (issuer key, provider
|
||||
connect/disconnect, set-password, api-key CRUD, db-info, operator-name,
|
||||
per-license tier change). `require_scope` re-exported from `api::admin`. Role
|
||||
boundary tests added. Boundary documented in `api/api_keys.rs` module doc.
|
||||
|
||||
- **GAP — multi-profile still non-functional end-to-end**: nothing writes
|
||||
`products.merchant_profile_id` (INSERT in `create_product_with_currency` omits
|
||||
it; `update_product_with_currency` has no field; `Product` in `models.rs` lacks
|
||||
it). Resolver fully supports it; only the product→profile **write path** is
|
||||
missing. **Gating piece for multi-profile.**
|
||||
|
||||
- **Work queue (next, in order)**:
|
||||
1. **product→merchant-profile picker** (the GAP — add `merchant_profile_id` to
|
||||
`Product` + `repo.rs` SELECT; `set_product_merchant_profile` writer mirroring
|
||||
`set_product_entitlements_catalog`; field on `CreateProductReq`/
|
||||
`UpdateProductReq` applied post-write; profile `<select>` 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.
|
||||
|
||||
+22
-4
@@ -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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user