The keysat-smtp-emails plan is superseded: Keysat will not send buyer email itself (operators own that via their app plus the existing webhooks). The smtp_* columns from migration 0020 are never read to send mail; left in place (a removal migration isn't worth it) and flagged so no send path is built against them.
charge_order_with_profile errors on non-2xx, but on a 2xx try_auto_charge_zaprite returned Ok(true) regardless of the order status, reading it only for a log line. A 200 carrying a non-settled status (declined/expired/in-flight) suppressed the manual-pay notification and left the worker waiting for an order.paid webhook that never arrives, so the subscription silently lapsed.
Classify the response: success iff status is PAID/COMPLETE/OVERPAID (mirrors get_invoice_status's Settled mapping); anything else logs a WARN and returns Ok(false) so renew_one falls through to manual-pay. Allowlist by design -- Zaprite has no documented terminal-failure string, so unknown/missing statuses route to manual-pay too. Adds a unit test on the new zaprite_charge_settled helper.
The featured-pill on-state and the sidebar upgrade CTA filled with gold, which
the brand contract and the admin-UI pill convention forbid (gold is a marketing
accent, never a button fill). The Featured toggle is now navy-filled with a
cream pip; the upgrade CTA is cream-filled with navy text and aligned to the
8px button radius. CSS / inline-style only in the embedded web/index.html — no
schema, no SDK, no behavior change.
run-stage2.sh: rewrite AGENT_BRIEF to the four-step operator-order journey
(define a paid product + entitlement, integrate the SDK and verify the gate
is BLOCKED, connect BTCPay regtest and have a buyer pay, then the PURCHASED
license unlocks the gate) and add the sandbox-app section the SDK-gating half
needs. Header comment updated to match.
probe.sh: do what the README/brief already claim it does. In addition to the
de-risk payload dump, create both stores (wallet + no-wallet), generate the
on-chain regtest wallet, mint store-scoped tokens with the five documented
connect permissions, and write .live-env for run-stage2.sh / validate-gate.sh
to source. Previously .live-env had to be hand-built and went stale on down -v.
teardown-stage2.sh stops every Stage 2 run (daemon + docs + sandbox), kills any
orphaned sandbox dev server on :4311 the onboarding-tester left behind, and stops
the regtest BTCPay docker stack + volumes (--keep-btcpay to leave it up). README
documents it as the always-run cleanup step, and adds a harvest note: on a clean
run, check whether the existing public docs already cover the success story before
adding anything.
Ships the agent-delegable BTCPay connect gate: a scoped key with the
payment_providers:write a-la-carte scope can connect a non-mainnet BTCPay
provider on a sandbox daemon, fail-closed; master/mainnet/production stay
master-only. Migrations 0024-0025 additive; openapi documents the BTCPay
paths. api suite 65. No SDK change.
Disposable rig that runs the onboarding-tester agent docs-only against the
buyer-pays journey: a sandbox daemon wired to a Dockerized BTCPay regtest stack,
a scoped key with payment_providers:write, and a regtest buyer-pay helper.
Includes the de-risk probe + findings and an end-to-end gate check
(validate-gate.sh, 10/10). The doc-onboarding loop converged completed-clean;
see stage2/STAGE2-RESULT.md. Scratch (.live-env, probe-out/) is gitignored.
Slices 3-4 of agent-payment-connect: a scoped key carrying the a-la-carte
payment_providers:write scope may connect a BTCPay provider, but only on a
sandbox daemon (KEYSAT_SANDBOX_MODE) and only for a non-mainnet
(regtest/testnet/signet) store. Master may connect any network; disconnect and
production/mainnet reconnect stay master-only. A credential that can repoint
settlement is a fund-redirection key, so the gate is deliberately narrow and
fails closed.
- require_provider_connect: outer gate (sandbox flag) at start_connect
- btcpay/network.rs classify_address_network + client::fetch_onchain_network:
resolve the store network at finish_connect, fail-closed to mainnet on any
ambiguity (no on-chain method, non-2xx, non-JSON, unknown prefix), before any
webhook/persist side effect
- initiator carried across the OAuth round-trip via btcpay_authorize_state
(migration 0025: scoped_initiator + initiator_actor_hash); scoped connects
are audited
- the GET callback now returns the error's HTTP status (was a misleading 200 on
a denied connect)
- openapi.rs documents the BTCPay connect/callback/status/disconnect paths and
the key-creation scopes field
Validated end-to-end against a live regtest BTCPay. Full suite green; adds gate
+ network unit/integration tests.
- GET /v1/admin/licenses requires product_id (uuid), not a slug
- add the /v1/admin/licenses/search path (was referenced, never defined)
- drop the phantom GET /v1/admin/products (only POST exists; list is
the public GET /v1/products)
- clarify product price_value (write field) vs legacy price_sats
Disposable rig that runs the global onboarding-tester agent against the
developer SDK-integration journey: boots a fresh keysat fixture, mints a
merchant-onboard scoped key, serves keysat-docs as the published corpus,
scaffolds a pristine Next.js/TS proof-of-work, and has the agent gate it
docs-only. Stage 1 (no payments) reached completed-clean over three runs;
see onboarding-harness/STAGE1-RESULT.md. Stage 2 (regtest buyer-pays) is
gated on the agent-payment-connect scope work.
Foundation for agent-delegable payment-provider connect
(plans/agent-payment-connect-scope.md, slices 1-2 of 5). Not yet wired to any
connect endpoint — the gate (require_provider_connect + BTCPay non-mainnet
network check) is a follow-up.
- Config.sandbox_mode from KEYSAT_SANDBOX_MODE (daemon-level, never settable
via any API); surfaced read-only in /v1/admin/tier as "sandbox".
- Migration 0024: additive scoped_api_keys.extra_scopes column (JSON array).
- Per-key à-la-carte scopes: require_scope grants via role OR a key's
extra_scopes; GRANTABLE_EXTRA_SCOPES allowlist (payment_providers:write
only), validated on create and echoed in create/list responses.
- payment_providers:write is in NO role: grants() carves the à-la-carte set
out of full-admin's wildcard, so even a scoped full-admin key can't reach
it through its role — only a per-key grant does. extra_scopes parsing
fails closed (NULL/malformed -> no grant).
- Tests: invariant (no role grants the à-la-carte set), fail-closed parsing,
create/list round-trip, reject ungrantable scope. Suite green: lib 13, api 59.
New scoped API-key role granting read + products:write + policies:write +
licenses:write — the least-privilege credential for end-to-end catalog
setup and license issuance (create product, define policies/tiers, issue
licenses against them) without holding the master key.
The catalog write scopes already existed and were enforced on the
endpoints; only the role->scope expansion was missing. So this is a new
Role variant, not a scope-model change. grants() matches scope strings
explicitly (never by :write suffix) so the role can't widen into
settings / payment / merchant-profile / webhook writes, and every
master-only operation stays behind require_admin and so is structurally
unreachable. Existing tier caps still bound it (Creator: 5 products /
5 policies per product).
Migration 0023 rebuilds scoped_api_keys to widen the role CHECK (SQLite
can't alter a CHECK in place); the table has no FKs, so it's a plain
copy/drop/rename. Test covers the full onboard chain under the key's own
credential plus denial of master-only gates and support-only writes.
Multi-profile resolution shipped in :52 but nothing wrote
products.merchant_profile_id, so it was non-functional end to end.
Add merchant_profile_id to the Product model + all four product
SELECTs, a set_product_merchant_profile writer (validates the target
profile exists, returning 404 instead of a raw FK-violation 500), and
thread an optional field through CreateProductReq (post-write) and
UpdateProductReq (double-Option; Some(None) clears to default). The
admin SPA product form shows a profile picker only when >1 profile
exists. Mirrors the entitlements-catalog post-write pattern.
Tests: repo round-trip (attach/resolve/clear/bad-id) + HTTP handler
arms. api suite 54→56, full suite green.
Scoped API keys (P1): migrate 58 admin endpoints from require_admin to
require_scope so ks_ keys with Read-only/License-issuer/Support/Full-admin roles
work as intended. 12 sensitive endpoints stay master-key-only (issuer key,
provider connect/disconnect, web password, api-key CRUD, db-info, operator-name,
per-license tier change). require_scope is re-exported from api::admin so both
auth gates import from one place. Adds role-boundary tests.
Settle-amount tripwire (P1): get_invoice_status now returns
ProviderInvoiceSnapshot { status, amount }. On a confirmed settle,
audit_settle_amount (shared by the webhook and reconcile issue paths) compares
the provider-reported sat amount against the invoice's amount_sats and, on drift,
logs a warning + writes an invoice.amount_mismatch audit row, then issues anyway.
Advisory by design: a hard gate would fight an operator's BTCPay payment
tolerance, and Settled already implies paid-in-full. SAT-only — skips non-SAT
settles (fiat subscription renewals) and unparseable amounts.
The settle-webhook honored payment on the webhook body's claim alone.
Zaprite webhooks carry no signature, so a forged order.change/status=PAID
POST with a buyer-visible order id minted a signed license without payment.
handle_inner now re-fetches provider.get_invoice_status and requires Settled
before persisting "settled" or taking any settle-derived action (issuance,
tier-change, subscription renewal — the guard precedes all of them). On a
provider-API error it acks 200 without issuing, so a transient outage can't
trigger a webhook retry storm; the reconcile loop re-confirms and issues later.
Adds the always-compiled AppState::provider_override seam (None in prod),
honored by provider_from_row at every resolution site, so integration tests
drive the real resolver with a MockPaymentProvider. Greens the two
paid_purchase_* tests, deletes the dead payment_provider_preference_round_trip,
and adds forged-settle + provider-unreachable regression tests. api 47/47.
Not addressed: a literal paid-amount/currency check (needs a trait change).
get_merchant_profile_for_product selected the bare MERCHANT_PROFILE_COLS list
while JOINing products (which also has an id), so SQLite raised "ambiguous
column name: id" on every execution. The function runs on every purchase, so
every paid purchase on 0.2.0:52 returned HTTP 500. Replace the JOIN with an
equivalent correlated subquery, keeping merchant_profiles the only table in
FROM; behavior on NULL/missing merchant_profile_id is unchanged (no row, caller
falls back to the default profile).
Also from the verification pass:
- Add merchant_profile_provider_resolution_queries_round_trip, exercising the
previously untested runtime-prepared resolution / CRUD / preference queries.
- Repair three test call sites for the new create_invoice / create_subscription
params; capture the response body in the paid_purchase status assertion.
- Align manifest license to LicenseRef-Keysat-1.0; drop an unused import.
Final cut of the multi-merchant-profile work. Adds the Merchant Profiles
admin UI section (list/create/edit/delete profiles + per-profile Connect
BTCPay / Connect Zaprite), bumps the version, and writes the comprehensive
release notes flagging the one-way migration and the master-operator
post-migration manual step (update the Zaprite webhook URL to the new
path-keyed form, or click Disconnect + Reconnect in the new UI to have
Keysat re-register at the right URL automatically).
web/index.html
New sidebar nav entry + ROUTE_META + routes['merchant-profiles']:
- Lists every profile with: default badge, support email, brand
color preview, post-purchase redirect URL summary, attached
payment-providers table (kind / label / served rails / disconnect),
and Connect BTCPay / Connect Zaprite buttons for whichever kinds
aren't already attached.
- Set-default button on non-default profiles.
- Delete button on non-default profiles (the backend refuses if any
product or active subscription is still attached).
- Create modal: name, support URL, support email, post-purchase
redirect URL (with {invoice_id} substitution), brand color picker.
- Edit modal: same fields, populated from the profile row.
- Connect BTCPay opens the OAuth authorize URL in a new tab with the
merchant_profile_id baked into the CSRF state token (so the callback
knows which profile to attach the new provider row to).
- Connect Zaprite shows a small modal for the API key (+ optional
base_url for sandbox orgs); on success surfaces the new
provider-keyed webhook URL the operator pastes into Zaprite's
dashboard.
What this UI does NOT cover (deferred follow-ups, called out in the
release notes):
- Buy-page rail picker (defaults to first available rail today).
- Product-edit-page merchant-profile picker (new products always
attach to the default profile until the picker ships).
- Per-profile SMTP override form (the schema fields are in place,
consumed by the keysat-smtp-emails plan when it lands).
- Rail-preference editing UI (only matters when 2 providers on the
same profile both serve the same rail — settable today via
`PUT /v1/admin/merchant-profiles/:id/rail-preferences/:rail`).
startos/versions/v0.2.0.ts
Bumps to 0.2.0:52 with a comprehensive release note describing the
one-way migration, the post-migration manual Zaprite-webhook-URL step
for the master operator (you), the new tier-cap (unlimited_merchant_
profiles entitlement), and the four UI follow-ups deferred to later
releases.
Build: cargo check passes. Two warnings remaining — both expected:
- recover.rs unused-import (pre-existing, unrelated)
- SETTING_ACTIVE_PROVIDER inside the deprecated shim's own pre-
migration fallback branch
The shipped feature set:
- Migrations 0020 + 0021 + 0022 (one-way data port + invoice→provider
link + BTCPay-authorize-state profile column).
- Merchant profile + payment provider data model + repo helpers.
- Rail enum + served_rails() trait method + build_provider factory.
- AppState resolution layer (per-product, per-rail provider lookup
with explicit-preference → unique-candidate → deterministic-earliest-
connected fallback).
- Every backend call site (purchase, subscriptions, reconcile,
upgrade, tipping, capture, auto-charge, boot loader) ported.
- BTCPay + Zaprite connect/disconnect/status rewritten for the new
model (per-profile attachment + path-keyed webhook URLs).
- Webhook router with path-keyed deliveries + legacy back-compat.
- Thank-you page provider-kind copy reads the invoice's recorded
provider.
- Merchant profile CRUD + rail preference CRUD admin endpoints.
- Tier-cap wiring (enforce_merchant_profile_cap).
- Admin UI Merchant Profiles section (this commit).
- Comprehensive :52 release notes.
Master Keysat self-license note: the new `unlimited_merchant_profiles`
entitlement needs to be added to the Pro and Patron policies on the
master keysat.xyz admin UI for Pro/Patron customers to be able to
create multiple profiles. Pure data action, no code change.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Backend is now feature-complete for :52. Admin UI still has to consume
these endpoints (part 5) but every operation the UI needs has a
working API surface behind it.
api/merchant_profiles.rs (new module)
Axum handlers wrapping the merchant_profiles::* business-logic helpers
and the rail-preference repo helpers. Each endpoint writes an audit
entry so the operator can see every profile/rail-preference change
in the audit log.
GET /v1/admin/merchant-profiles list + summarize
POST /v1/admin/merchant-profiles create (tier-gated)
GET /v1/admin/merchant-profiles/:id detail + providers + rail prefs + counts
PATCH /v1/admin/merchant-profiles/:id partial update
DELETE /v1/admin/merchant-profiles/:id refuses if attached
POST /v1/admin/merchant-profiles/:id/set-default transactional flip
PUT /v1/admin/merchant-profiles/:id/rail-preferences/:rail validates + persists
DELETE /v1/admin/merchant-profiles/:id/rail-preferences/:rail clears the override
set_rail_preference validates THREE things before persisting: rail
name is one of lightning/onchain/card; the provider exists; the
provider is attached to THIS profile; AND it serves this rail. So
the operator can't pin "Card" to a BTCPay row, and can't pin a
provider that belongs to a different profile.
list/get redact SMTP password (smtp_configured: bool is enough for
the UI to render "configured/not configured" status; the actual
password stays write-only). The edit form submits a new password
only when the operator explicitly rotates it.
api/tier.rs
New enforce_merchant_profile_cap helper. Refuses with HTTP 402
AppError::PaymentRequired when a Creator-tier operator already has
one profile (the default) and the self-license lacks the new
`unlimited_merchant_profiles` entitlement. Same shape as the
existing enforce_product_cap / enforce_policy_cap helpers — the
admin UI's existing tier-cap modal renders the upgrade CTA from
the upgrade_url field.
Note: master Keysat's Pro and Patron policies need
`unlimited_merchant_profiles` added to their entitlement JSON as a
separate admin action on the master keysat.xyz instance — purely
data, no code change. Master operator self-license must be re-
issued (or naturally renewed) to pick up the new entitlement.
merchant_profiles.rs
create() now calls enforce_merchant_profile_cap before INSERT.
Replaces the TODO comment from part 1.
api/mod.rs
Registers the merchant_profiles module and wires the routes above.
Build: cargo check passes. Two warnings remaining — both expected:
- recover.rs unused-import (pre-existing, unrelated)
- SETTING_ACTIVE_PROVIDER inside the shim's own pre-migration
fallback branch
Backend status: every multi-provider story (purchase routing,
subscription snapshot, webhook delivery, connect/disconnect, profile
CRUD, tier gating) is now wired to the new schema. Only the admin UI
+ a version bump remain.
What's left for :52:
- Admin UI in web/index.html — Merchant Profiles section, product
picker, buy-page brand block + rail picker. Roughly 600-1000 lines
of HTML/CSS/JS consuming the new endpoints. Largest single
remaining piece.
- Version bump to :52 + release notes flagging the one-way migration
+ the post-migration manual Zaprite-webhook-URL update.
- End-to-end sandbox test against two profiles + two Zaprite orgs.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Closes out the remaining "all callers of the deprecated active-provider
shim" surface: BTCPay connect/disconnect/status now follows the same
merchant-profile-aware shape as Zaprite did in 3a, the webhook router
gets a path-keyed shape so deliveries go to the right provider's
secret, the thank-you page reads the invoice's recorded provider id
(not "the active one"), and the legacy `activate` endpoint is removed.
migrations/0022_btcpay_state_profile.sql (new)
Adds merchant_profile_id (nullable FK) to btcpay_authorize_state so
the BTCPay OAuth state token can round-trip the operator's profile
pick between start_connect and the callback. Without this, multi-
profile operators couldn't authorize a SECOND BTCPay store onto a
non-default profile.
btcpay/config.rs
record_authorize_state takes merchant_profile_id; consume_authorize_state
now returns Option<String> so the callback knows which profile to
attach the new provider row to.
api/btcpay_authorize.rs (full rewrite)
start_connect accepts an optional merchant_profile_id (defaulting to
the default profile), refuses if that profile already has a BTCPay
provider attached (unique-index-friendly 409 message), and records
the profile id on the CSRF state token. The OAuth round-trip carries
the profile id back via the state token, not via a query param —
state-token-by-row is more robust than depending on BTCPay preserving
redirect-URL query params during the consent dance.
finish_connect (the callback's inner path):
- Pre-generates the payment_providers row id so it can be baked into
the BTCPay-side webhook callback URL.
- The webhook URL we register with BTCPay is now path-keyed:
/v1/btcpay/webhook/{provider-id}. Each profile's BTCPay store gets
isolated deliveries.
- INSERTs into payment_providers (kind='btcpay', api_key, base_url,
webhook_id, webhook_secret, store_id, attached to the chosen
profile) instead of upserting the singleton btcpay_config row.
- Populates the back-compat state.payment singleton ONLY when this
is the first provider on the default profile (so the few remaining
legacy state.payment_provider() callers still work without a
daemon restart).
disconnect accepts an optional provider_id; defaults to "the BTCPay
provider on the default profile" for back-compat with the existing
admin UI's single Disconnect button. Best-effort BTCPay-side webhook
+ API key revocation unchanged. DELETE FROM payment_providers WHERE
id = ? instead of clearing btcpay_config.
status + payment_methods report on the default-profile BTCPay row for
the legacy admin UI. Multi-profile operators will use the new
/v1/admin/merchant-profiles endpoints (part 4).
api/webhook.rs
Split into two entry points:
- handle_for_provider — the new path-keyed shape
(`/v1/{kind}/webhook/:provider_id`). Looks up the named provider
via state.payment_provider_by_id, validates the payload against
THAT specific provider's secret, then runs the inner pipeline.
- handle — back-compat for the bare /v1/{kind}/webhook path. Routes
to whichever provider is on the default profile. Kept so any
in-flight pre-:52 webhook delivery or admin misconfiguration
doesn't silently drop on the floor.
Both share an extracted handle_inner that does the actual settle /
expire / refund processing.
api/mod.rs
Route registrations:
- Adds /v1/{btcpay,zaprite}/webhook/:provider_id POST handlers.
- Removes the legacy /v1/admin/payment-provider/activate route
(the shim function is gone).
Thank-you page provider-kind lookup ports from the deprecated
read_active_provider_preference to: invoice.payment_provider_id ->
payment_providers.kind -> ProviderKind. Falls back to the default
profile's first provider if the invoice predates migration 0021.
api/payment_provider.rs
Reduced to just the back-compat status endpoint. The activate
endpoint is removed entirely — there's no "active" preference to
flip in the merchant-profile model. Status returns the same
btcpay_configured / zaprite_configured / active shape the existing
admin UI consumes, plus a new providers[] array for callers that
want the full picture.
Build: cargo check passes. Only two warnings remaining — both
expected:
- recover.rs unused-import (pre-existing, unrelated)
- SETTING_ACTIVE_PROVIDER inside the shim itself (the legacy fallback
branch in read_active_provider_preference that runs during the
pre-:52 upgrade window before migration 0020 has dropped the
settings row)
What's left for :52:
- New admin endpoints for merchant-profile + rail-preference CRUD
- Admin UI in web/index.html (biggest remaining chunk — Merchant
Profiles section + product picker + buy-page brand block +
rail picker)
- Tier-cap wire-up for unlimited_merchant_profiles
- Version bump + release notes + sandbox test
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Replaces the singleton-config-row implementation in api/zaprite_authorize.rs
with the new payment_providers + merchant_profiles model. The connect
flow now takes an optional `merchant_profile_id` (default = the auto-
created default profile) and INSERTs a row in `payment_providers`
instead of upserting the singleton `zaprite_config` table. Operators on
Pro/Patron can pass a non-default profile id to set up per-business
Zaprite orgs side-by-side.
Webhook URLs returned to the operator now include the provider id —
`/v1/zaprite/webhook/{provider-id}` instead of the legacy bare
`/v1/zaprite/webhook` — so each profile's Zaprite org gets its own
isolated webhook receiver. The webhook router refactor that consumes
this URL shape lands in a follow-up commit (today the legacy route
still works because the path-param refactor hasn't happened yet — this
commit just changes what URL the connect endpoint reports back).
Disconnect now takes an optional `provider_id` body field. When NULL,
falls back to "the Zaprite provider on the default profile" for
back-compat with the existing single-profile admin UI's disconnect
button. Multi-profile operators name the specific provider via the
new merchant-profile-scoped admin endpoints (landing in part 4).
Status endpoint similarly reports on the default profile's Zaprite
attachment for the existing admin UI's payment-providers card.
Removed the `write_active_provider_preference` call (deprecated no-op
in the new model — providers aren't "active," they attach to profiles
and are looked up per-product). Removed the `state.set_payment_provider`
call EXCEPT when this is the very first provider on the default
profile — in that case we populate the back-compat singleton so the
small number of remaining state.payment_provider() callers (currently
just the thank-you page) keep working without a daemon restart.
Build: cargo check passes. Eight remaining deprecation warnings in
api/btcpay_authorize.rs (same rewrite due in part 3b),
api/payment_provider.rs (the legacy activate endpoint — to be
replaced), and api/mod.rs (thank-you page provider lookup).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Threads the merchant-profile + payment-provider snapshot semantics through
every call site that used to call state.payment_provider() (the legacy
"active provider" singleton). New invoices now record which provider
settled them; subscriptions snapshot both merchant_profile_id and
payment_provider_id at creation so mid-cycle product re-routing doesn't
redirect existing buyers; the reconciler picks the right provider per
invoice; tipping draws from the same Bitcoin balance that received the
purchase; tier-change invoices stick with the buyer's existing merchant
identity.
migrations/0021_invoice_provider_link.sql (new)
Adds invoices.payment_provider_id (nullable FK), backfills existing
pending/settled rows to the earliest-connected provider on the default
profile. Additive — no drops, no removals. Companion to 0020 from the
foundation commit.
models.rs
Invoice gains payment_provider_id: Option<String>.
db/repo.rs
row_to_invoice reads the new column. All three invoice SELECTs include
it. create_invoice + create_invoice_with_currency take a new optional
payment_provider_id parameter and persist it on INSERT.
subscriptions.rs
Subscription struct gains merchant_profile_id + payment_provider_id
(snapshotted on create). SUB_COLS + row_to_subscription + the manual
SELECT in find_lapsing_subscriptions all updated. create_subscription
accepts both new fields and writes them on the INSERT row.
renew_one — reads the sub's payment_provider_id snapshot and resolves
the provider via state.payment_provider_by_id(). Falls back to the
legacy state.payment_provider() for any subs created pre-:52 that
the migration backfill missed.
capture_zaprite_payment_profile — uses the INVOICE's provider, not
"the active one." Saved-profile ids are scoped per Zaprite org; using
the wrong provider would fail the lookup.
try_auto_charge_zaprite — uses the sub's snapshotted provider (same
rationale).
reconcile.rs
Per-invoice provider lookup. Each pending invoice is reconciled
against state.payment_provider_by_id(inv.payment_provider_id), with
graceful fallback for NULL provider ids. No more single-global-
provider assumption.
tipping.rs
Tip pay-out uses the provider that settled the license's purchase
invoice (joined via licenses.invoice_id). Same rationale as the
capture hook — the tip needs to draw from the right LN node.
api/upgrade.rs (both buyer-driven and admin-driven tier-change sites)
Tier-change invoices ride on existing licenses. The right provider
is whichever the license's subscription is snapshotted to (so the
proration charge settles to the same merchant identity that collects
renewal fees). Falls back to the invoice's recorded provider, then
the legacy default, for licenses with no subscription or pre-
snapshot rows.
api/purchase.rs
StartPurchaseReq gains an optional `rail` field
("lightning"/"onchain"/"card") for the future buy-page multi-rail
picker. When omitted (today's behavior), the daemon picks the first
rail the product's merchant profile exposes — which is correct for
single-provider operators AND back-compat for any pre-:52 client
not yet sending the field.
Provider resolution: product → merchant_profile → rail →
resolve_provider_for_profile_rail. The redirect_url defaults to the
profile's post_purchase_redirect_url (with {invoice_id} substitution)
if set, else Keysat's own /thank-you. New invoices carry their
provider's id via the new create_invoice_with_currency parameter.
api/webhook.rs
issue_license_for_invoice now passes snapshot fields when calling
subscriptions::create_subscription — both merchant_profile_id (from
product lookup) and payment_provider_id (from the invoice row).
main.rs
Replaces the legacy "active provider preference" boot loader with a
default-profile-first-provider warm-up. The legacy state.payment
singleton stays populated for back-compat with call sites that
haven't yet migrated to the on-demand resolution path. Pre-migration
fallback to the old singleton-config loaders preserved so the
daemon still boots cleanly on a DB that hasn't run 0020 yet.
Remaining for part 3:
- BTCPay + Zaprite connect flows take merchant_profile_id and
INSERT into payment_providers (currently still write to the
dropped singleton tables, broken post-migration).
- api/payment_provider.rs activate endpoint becomes irrelevant in
the new model — repurpose as list-providers, or delete.
- Thank-you page (api/mod.rs) provider-kind lookup ports to the
invoice's recorded provider.
- Webhook routes refactor to /v1/{kind}/webhook/{provider_id}.
- Admin UI for Merchant Profiles + product picker + buy-page brand
block + rail picker.
- Tier-cap wire-up for unlimited_merchant_profiles entitlement.
- Version bump to :52 + release notes.
Build: cargo check passes. Deprecation warnings remaining flag exactly
the call sites listed above as the part 3 todo list.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Lays the schema + types + resolution layer for the merchant-profile-aware
multi-provider model documented in plans/multi-provider-payment-model.md.
Does NOT yet migrate any existing call site — legacy `state.payment_provider()`
and the singleton config tables continue to work via deprecation shims so
the daemon keeps running unchanged on this checkpoint.
This commit is intentionally a WIP foundation, not a shippable release —
no version bump, no release notes, no admin UI, no call-site migration.
A follow-up cycle ports purchase / subscriptions / reconcile / upgrade /
tipping to the new resolution layer, rebuilds the BTCPay + Zaprite connect
flows around merchant_profile_id, refactors webhook URLs to
/v1/{kind}/webhook/{provider_id}, ships the Merchant Profiles admin UI
section, wires the tier-cap, and bumps to :52 with the one-way migration
release notes.
What landed:
migrations/0020_merchant_profiles.sql
Full schema + data port + DROP of the singleton tables. Creates
merchant_profiles, payment_providers (FK to profile, unique per
(profile, kind)), merchant_profile_rail_preferences (tie-breaker
when a profile has 2 providers serving the same rail). Adds
merchant_profile_id to products + (merchant_profile_id, payment_provider_id)
to subscriptions for the snapshot-on-create semantics. Ports
btcpay_config + zaprite_config + active_payment_provider setting
into the new tables, then drops them. Master operator post-migration
step: update the Zaprite webhook URL on the Zaprite dashboard to
the new /v1/zaprite/webhook/{provider-id} form (or click Reconnect
Zaprite in the new UI once it ships).
src/merchant_profiles.rs (new module)
MerchantProfile struct + NewMerchantProfile + MerchantProfileUpdate
input types. Business-logic CRUD helpers: create, get, get_default,
require_default, list, update, set_default, delete, for_product.
Delete refuses if products or active subs are attached or if it's
the default profile. Tier-cap check stubbed with a TODO for the
next chunk's tier.rs wire-up.
src/db/repo.rs (+469 lines)
Repo helpers: create/get_by_id/get_default/get_for_product/list/
update/set_default/delete for merchant_profiles + count helpers
for products/active_subscriptions per profile. PaymentProviderRow
struct + create/get/list_for_profile/list_all/delete. RailPreference
struct + list/set/clear helpers. update_merchant_profile builds a
dynamic SET clause so partial updates don't clobber fields the
caller didn't touch.
src/payment/mod.rs
Rail enum (Lightning / Onchain / Card) + ProviderKind::parse +
rails_for_kind static mapping. build_provider(row, public_base) ->
Arc<dyn PaymentProvider> factory that dispatches on kind to construct
a typed BtcpayProvider or ZapriteProvider from a payment_providers
row. PaymentProvider trait gains a default served_rails() impl
returning rails_for_kind(self.kind()).
Deprecation shims: SETTING_ACTIVE_PROVIDER constant +
read_active_provider_preference + write_active_provider_preference
stay callable so btcpay_authorize/zaprite_authorize/main.rs/the
thank-you page still build. read_active_provider_preference now
reads from the new payment_providers table (returns the kind of
the first provider attached to the default profile), falling back
to the legacy settings-table read pre-migration. write_* is a no-op.
Each shim has a #[deprecated] attribute so the build surfaces
exactly which call sites still need porting (lit up in the
follow-up cycle's TODO).
src/api/mod.rs (AppState)
New methods alongside the existing payment_provider() shim:
- payment_provider_by_id(id) — looks up a row, builds the provider
- merchant_profile_for_product(product_id) — resolves via products.merchant_profile_id, falls back to default
- resolve_provider_for_profile_rail(profile_id, rail) —
preference table -> single candidate -> deterministic earliest-
connected with WARN. Returns (row, Arc<dyn PaymentProvider>).
- resolve_provider_for_product_rail(product_id, rail) — convenience
wrapping the previous two.
src/lib.rs
Registers the new merchant_profiles module.
Build state: cargo check passes. Only warnings are the pre-existing
unused-import in recover.rs and the deprecation lint firing on the
five legacy call sites enumerated in the WIP plan.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Six routine bumps land together, all driven by end-to-end sandbox testing
of the Zaprite recurring auto-charge path that shipped in :45:
:46 Provider create-invoice failures now surface the underlying cause.
Switched user-facing format from `{e}` to `{e:#}` so the full anyhow
chain reaches the buy page; added `tracing::error!` for symmetric
daemon-log visibility. Without this, failed checkouts showed only
"ZapriteProvider.create_invoice" with no clue what actually went wrong.
:47 Zaprite recurring purchases now create the contact upfront. Sandbox
surfaced that `allowSavePaymentProfile: true` requires an explicit
`contactId` on the order — passing only `customerData: { email }`
returns 400. Added `client.create_contact(email, name)` + threaded
the returned id as `contactId`. Graceful degradation: recurring +
no buyer_email → one-shot mode with a warn log; renewals fall back
to manual-pay.
:48 Thank-you page copy is now provider-aware. The wait-page lede
hardcoded "Your Bitcoin payment was received" + Lightning/on-chain
timing — wrong for Zaprite card payments. Reads SETTING_ACTIVE_PROVIDER
and branches the copy + the JS polling-status text accordingly.
:49 Zaprite saved-profile capture: full diagnostic logging + reconciler
path. Discovered five recurring subscriptions settled successfully
but with NULL `zaprite_payment_profile_id`. Root cause: capture
hook had six silent early-return paths, AND the reconciler (which
catches missed webhooks) never called `on_invoice_settled` so subs
created via that path never got their profile captured. Added warn
logs on every early-return + wired capture into `reconcile.rs`'s
post-license-issuance flow.
:50 Webhook event-type extraction probes multiple field names. Confirmed
deliveries were arriving but all logged as "non-actionable event_type=
" — Zaprite doesn't use the convention-suggested `event` field. Now
probes `event` / `eventType` / `type` / `name`, first non-empty wins.
Also widened the order-id probe to include `data.object.id`. On a
miss, warn-logs the raw payload truncated to 2KB so the actual field
name can be added to the probe list.
:51 Zaprite `order.change` event is now actionable. The :50 probe-fix
surfaced that Zaprite's primary delivery shape is a generic
`order.change` event that just says "something about this order
changed" — the receiver has to look at `/data/status` to figure out
what actually changed. They do NOT send the convention-suggested
`order.paid` / `order.complete` events. Added an `order.change`
match arm that branches on status (PAID/COMPLETE/OVERPAID →
InvoiceSettled, EXPIRED → InvoiceExpired, INVALID/CANCELLED →
InvoiceInvalid, in-flight states → Other). End result: webhook-
driven settles now flip subscriptions within seconds of Zaprite's
callback instead of waiting ~45s for the reconciler.
Net effect of the batch: the recurring auto-charge flow is now validated
end-to-end against the Zaprite sandbox. Buyers paying with a card via
Stripe-backed Zaprite trigger contact + saved-profile creation, the
webhook fires `order.change` with status PAID, Keysat captures the
saved-profile id within seconds, and the renewal worker is wired to
auto-charge subsequent cycles. Manual-pay fallback intact for buyers
who decline save-card or pay via Bitcoin/Lightning.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Two routine bumps land together in this release:
:44 — Admin UI mobile pass. Adds a phone breakpoint (≤640px) and
hamburger-driven off-canvas drawer (≤720px) to the embedded
web/index.html so triage flows (status check, license lookup, revoke)
work from a phone. Tables now scroll horizontally inside their card,
tap targets bump to ~40px, stats grid collapses to 1-up, toolbar
inputs go full-width. Desktop layout unchanged. CSS + small JS toggle.
:45 — Zaprite recurring auto-charge wired end-to-end. Closes the gap
the subscriptions.rs module comment promised but never delivered:
first-cycle invoices on recurring policies set allow_save_payment_profile,
the on-settle hook captures the resulting Zaprite paymentProfileId
into four new nullable columns on the subscriptions table (migration
0019, additive only), and the renewal worker calls
POST /v1/orders/charge against the saved profile instead of waiting
for manual pay. On charge failure (declined card, expired profile,
network) the worker logs + audits + falls through to the existing
subscription.renewal_pending event so the buyer still has a recovery
path. Two new operator webhook events: subscription.auto_charge_initiated
and subscription.auto_charge_failed. BTCPay subs and Zaprite subs
whose buyer paid with Bitcoin/Lightning or declined the save-card
prompt are untouched. NOT yet end-to-end tested against the Zaprite
sandbox — control flow follows api.zaprite.com/llms.txt but exact
failure-body shapes for declined cards aren't documented; sandbox
validation pass recommended before relying in production.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The only affected license was the operator's own pre-launch
self-license under an earlier entitlement scheme. New Patron licenses
issued from the corrected master-Keysat policy carry the right
entitlements in their signed payload. The implicit expansion was
paying ongoing complexity (magic-slug behavior, hardcoded list
divergence on rename) for a one-shot migration case.
Affected operators: re-issue + Activate Keysat license. The new key
overwrites /data/keysat-license.txt and self_tier picks up live
without a restart.
Patron entitlement now expands to the full Pro surface
(unlimited_products / _policies / _codes, recurring_billing,
zaprite_payments) in tier::current(). Existing Patron customers get
the implied entitlements without re-issuing.
BTCPay Connect: replace the four-field paste form (Base URL + API key
+ Store id + Webhook secret) with the original one-click button that
fetches an authorize URL from /v1/admin/btcpay/connect, opens it in a
new tab, and polls /v1/admin/btcpay/status until the BTCPay callback
finishes. Zaprite path unchanged.
Eager reservation at /v1/purchase prevents code-cap races but leaked
slots if BTCPay never fired the expiry webhook. New 5-min background
reaper scans for pending redemptions tied to expired/invalid invoices
or pending invoices older than 30 min, cancels each, and decrements
used_count so the slot returns to the pool.
Previously the tier picker gated on `policies.len() < 2` and returned
an empty string when a product had only one public policy. Buyers
saw just the price card + form — none of the entitlements, marketing
bullets, or description the operator had carefully authored on that
tier. Reported against the Recap product, which has 3 policies but
only Pro public; Pro's bullets were invisible to buyers.
Fixed:
- render_tier_picker gate flipped from `< 2` to `is_empty()`. A
single public policy now renders a single tier card.
- New `.tiers-1` grid class: one centered column at ~480px max-width.
Keeps the single card from stretching to the full 1040px container.
- `n` computation extends to handle 1 in the existing match arm.
The price card below the picker still renders unchanged for the
single-policy case — acts as the buy-confirmation summary. Operators
keeping most tiers private and only exposing one to buyers now get
the same rich tier-card render that multi-tier products always had.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Two operator-reported bugs:
1. Create product had no Cancel. Added a secondary Cancel button
next to "Create product" — collapses the disclosure without
clearing typed input.
2. Edit product modal could grow taller than the viewport when
the entitlements catalog had many entries, with no way to
scroll. Cause: the modal card lacked max-height + overflow-y.
Fixed Edit product specifically, then defensively swept every
other dialog card in the admin UI for the same gap. 8 cards
that were missing max-height got `max-height:90vh; overflow-y:auto`
appended to their style block. Cards that already had the fix
(Edit policy, Edit discount code) untouched.
11 modal cards now consistent: tier-cap upgrade, force-delete
confirm, value-prompt, generic-confirm, license-issued display,
BTCPay-connect, scoped-API-key generate, scoped-API-key
show-once, edit-product, edit-policy, edit-discount-code.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Custom source-available license, sharpened against several
ambiguities flagged in license review. Substance unchanged from
intent: operators can audit, run, modify, and sell licenses for
their own products through an instance they operate; reselling
the software itself or running it as a managed service for third
parties is forbidden. SDKs remain MIT in their own repos.
Concrete edits versus the prior draft:
- Added SPDX-Short-Identifier line (LicenseRef-Keysat-1.0) so
package metadata scanners can reference a stable name.
- New Section 1 (Definitions): "the Software", "You", "Internal
Use", "Modifications", "Managed Service" — closes the "is a
rented VPS internal use?" / "is publicly hosting on Tor a
managed service?" gaps.
- Internal Use explicitly includes rented compute. Managed
Service explicitly carves out "your own customers receiving
signed license keys" so the intended buy-page flow can never
be misread as forbidden.
- Section 3 (Restrictions) reworded for clarity:
- (a) "distribute compiled binaries" rather than ambiguous
"redistribute the source code" — source forks for audit /
contribution remain permitted under the trailing paragraph.
- (b) "provide ... as a Managed Service" — clean Elastic-v2
style phrasing in place of "publicly host a copy that is
accessible to or operable by parties other than yourself."
- Public source-code forks on GitHub explicitly permitted.
- Section 4 (Contributions): clarified as a non-exclusive
license-back, not a copyright assignment. Contributors retain
ownership.
- Section 6 (Termination): added "destroy or permanently delete
all copies" on termination + named the survival sections.
- Section 7 (Entire Agreement): added.
For commercial redistribution, resale, or hosted-service rights,
operators still email licensing@keysat.xyz — that's now
explicitly the single contact point at the bottom of the file.
All four SDKs are now published to their registries:
- npm: @keysat/licensing-client
- crates.io: keysat-licensing-client
- PyPI: keysat-licensing-client
- Go module proxy: github.com/keysat-xyz/keysat-client-go
Changes:
- §7a / §7b / §7c install blocks collapsed from "Install (preferred)
/ GitHub fallback" pairs to single registry-install lines. The
ssh-vs-https / prepare-script troubleshooting is no longer
relevant for the install path.
- New §7d: Go integration. Same shape as the other languages:
install snippet, embed-pubkey pattern, verify-on-startup,
use-at-feature-gate. Uses the Go SDK's IsTrial() method (not
manual flag math). hex.EncodeToString for the LicenseID byte
array.
- Existing §7d (Hard-gate patterns), §7e (Packaging gotchas),
§7f (Frontend integration) renumbered to §7e / §7f / §7g.
- Cross-references updated everywhere (§0, §6, §15).
- Header line updated: doc now claims Go support alongside the
existing three languages.
Critical bug fixes — code an LLM would copy verbatim:
- Wire format §4: clarify FLAG_FINGERPRINT_BOUND = bit 0 (mask 0x01),
FLAG_TRIAL = bit 1 (mask 0x02). The doc previously claimed
FLAG_TRIAL=1, which is wrong — that's the fingerprint-bound bit.
- Trial detection across §7a / §7b / §7c / §14: stop doing
`(flags & 1)` manually. TS/Python/Go SDKs pre-parse isTrial /
is_trial / IsTrial() on the payload. Rust requires manual math
but with the FLAG_TRIAL constant from the crate, not bit 0.
- Field name sweep: TS payload field is `licenseUuid` (or top-
level `licenseId` on the result root), not `payload.licenseId`.
Rust payload's `license_id` is `[u8; 16]` raw bytes — render to
hex for display. Updated examples to match each SDK's actual API.
- §9a cross-product safety: rewritten end-to-end. The payload
carries product UUID (not slug). The old doc told the LLM to
assert `payload.product_slug !== MY_SLUG`, which silently passes
because the field doesn't exist. New doc covers both correct
paths: online via `validate(slug, …)` (daemon resolves
slug→UUID), or offline by embedding the operator's product UUID.
Stale references / improvements:
- §0 Q4: cross-reference for hard-gate flavors corrected to §7d
(was pointing at §8 which is entitlement-naming). Added a
"soft-gate is the safe default" nudge.
- §0 new Q8: ask whether the operator already has an entitlements
catalog before drafting the config card.
- §7a GitHub fallback: trimmed. SDK repos are public and have
`prepare` scripts, so the ssh-vs-https troubleshooting saga
isn't needed anymore.
- §7d "Mode::Enforce" reference removed — that build-time flag
was deprecated. Keysat itself dogfoods soft-gate (always boots,
tier caps enforce at create-time).
New content for one-shot integration success:
- §8 / §11a: hidden_entitlements (v0.2.0:24) explained — buy page
filters them out; SDK consumers should too.
- §11a "Rendering tier cards": multi-currency formatter
(priceCurrency + priceValue), marketing_bullets +
marketing_bullets_position, featured_discount auto-apply via
the `code` option on startPurchase.
- §11a Common mistakes: assuming all prices are in sats; skipping
the featured-discount surfacing.
- New §15a "Verify your integration with curl": four-command
health-check the LLM can run before writing app code. Catches
slug typos, missing policies, unreachable daemon early.
- §15 Common mistakes: added the product UUID gotcha and the
flag bit-math gotcha as explicit entries.
LLM-consumer impact: the previous version had three subtle bugs
that survived offline signature verification — wrong trial
detection on every fingerprint-bound license, missing product
isolation across multi-product Keysats, and a wrong PRO-tier
default selection. All three failure modes are now flagged or
fixed in the doc; an LLM that follows the new doc literally
produces correct integration code.
- New "Quick start" block at top — pitch line matches the keysat.xyz
hero, links to docs.keysat.xyz, and lists the four SDK install
commands (TS / Rust / Python / Go) so a GitHub visitor sees the
install path before the StartOS-package internals.
- Tagline updated to drop "Bitcoin-native" in favor of "payment
channels you control" (matches the landing-page de-emphasis).
- First-run flow step 6: drop the "default slug is canonical" myth.
Multi-tier ladders are first-class.
- Actions list: drop npub from license search (UI surfaces only
email + invoice id; backend supports npub but the purchase flow
hasn't shipped buyer npub capture yet).
- Limitations section: drop "no recurring subscriptions" and "no
tier upgrades" — both shipped in v0.2. Add Zaprite-pending caveat.
- YAML quick-reference: expand the features block to reflect the
current daemon (multi-currency, marketing bullets, hidden
entitlements, featured discounts, multi-policy scope, recurring
subs, tier upgrades, webhook DLQ, scoped API keys, OpenAPI spec,
Creator/Pro/Patron self-licensing tiers). Add SDK list.
Adds the word "discount" so buyers don't misread the limit as a
license count. "Limited: 10 remaining" was ambiguous; "Limited
discount: 10 remaining" is unmistakable.
Landing-page dynamic tier-card JS matches in a separate commit on
the keysat-xyz-landing repo.
Cosmetic.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Buy-page tier card's "Limited: N of M remaining" line now reads
just "Limited: N remaining". The total cap (M) is operator-private
— there's no upside to exposing initial launch volume to buyers,
and it can make a tier feel smaller than the operator intends.
Symmetric landing-page change (index.html dynamic tier-card JS)
ships alongside in a separate commit on the keysat-xyz-landing
repo.
Cosmetic; no API or schema change.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Previously the server rendered a 0-priced tier as "0 sats" (or
"0.00 USD") in the tier-card headline. The price card below the
tier picker already swapped to "FREE" via the JS path, so the
two surfaces disagreed.
Now: when post-discount price is 0, the tier card renders the
headline as "Free" with no unit suffix and no cadence-suffix
("Free /yr" would be incoherent). recurring_meta ("Renews
annually") still surfaces beneath for recurring-free edge cases,
so cadence isn't lost — just not stuffed into the headline.
Cosmetic; no API or schema change.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>