Commit Graph

89 Commits

Author SHA1 Message Date
Grant be8688de80 Fix OpenAPI spec inaccuracies found by the onboarding test
- 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
2026-06-16 22:48:09 -05:00
Grant 3afac078d4 Add sandbox flag + per-key à-la-carte scopes (payment-connect foundation)
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.
2026-06-16 21:16:20 -05:00
Grant d5885d1d97 Add merchant-onboard scoped-key role for self-serve onboarding
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.
2026-06-16 18:55:18 -05:00
Grant d2846ac6ae Fix stale scoped-API-key panel note in api_keys.rs doc comment 2026-06-16 13:05:26 -05:00
Grant b088bfc062 Wire product→merchant-profile write path
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.
2026-06-15 21:38:24 -05:00
Keysat 0508690d5a Wire scoped API keys and add advisory settle-amount tripwire
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.
2026-06-13 00:10:45 -05:00
Grant 783372c03b Confirm settle with provider API before issuing; add test-injection seam
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).
2026-06-12 22:36:42 -05:00
Grant 31f4670efa Fix ambiguous-column bug in merchant-profile resolution
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.
2026-06-12 19:39:33 -05:00
Grant 8bf3d646ab v0.2.0:52 — multi-merchant-profile + multi-provider payment model
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>
2026-06-04 07:35:22 -05:00
Grant 89f1b89705 WIP — merchant profile CRUD endpoints + tier-cap wire-up (part 4)
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>
2026-06-03 22:48:54 -05:00
Grant 9df1908328 WIP — BTCPay connect rewrite + webhook URL refactor + thank-you fix (part 3b)
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>
2026-06-03 22:45:43 -05:00
Grant cf251fc63f WIP — rewrite Zaprite connect/disconnect/status for merchant profiles (part 3a)
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>
2026-06-03 22:28:36 -05:00
Grant 7c4dfbacd2 WIP — port purchase/subscriptions/reconcile/upgrade/tipping to merchant-profile resolution (part 2)
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>
2026-06-03 22:26:22 -05:00
Grant 04e0dcd591 WIP — merchant profile foundation (multi-provider payment model, part 1)
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>
2026-06-03 22:00:00 -05:00
Grant 4cde540b60 v0.2.0:51 — Zaprite recurring polish from sandbox testing (:46-:51)
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>
2026-06-03 21:23:09 -05:00
Grant fea6995192 v0.2.0:45 — Zaprite recurring auto-charge + mobile-friendly admin UI
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>
2026-05-18 18:20:53 -05:00
Grant c71345f002 v0.2.0:43 — BTCPay success page: return to Keysat, not StartOS
Connect now lives inside Keysat's admin UI, so the post-authorize
return target is Keysat's own tab. One-line copy in two paths.
2026-05-12 12:42:10 -05:00
Grant 17d5df72d3 v0.2.0:42 — revert implicit Patron→Pro expansion from :41
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.
2026-05-12 12:27:18 -05:00
Grant a3662de6d8 v0.2.0:41 — Patron implies Pro; BTCPay Connect back to one-click authorize
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.
2026-05-12 12:12:54 -05:00
Grant d927e4940f v0.2.0:40 — discount-code slot reaper for abandoned checkouts
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.
2026-05-12 01:01:08 -05:00
Grant 1a14b9c2e3 v0.2.0:39 — Buy page: render tier card for single-public-policy products
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>
2026-05-11 22:48:33 -05:00
Grant 5c7d66dbb2 v0.2.0:38 — Create-product Cancel button + modal overflow fix
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>
2026-05-11 22:05:20 -05:00
Grant aaf8bddfe4 v0.2.0:37 — "Limited" → "Limited discount" on launch-special meta
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>
2026-05-11 18:32:20 -05:00
Grant e05d357a5a v0.2.0:36 — Launch-special remaining: "N remaining", drop the total
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>
2026-05-11 18:26:43 -05:00
Grant a0995c9c31 v0.2.0:35 — Free tiers render as "Free" on the buy-page tier card
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>
2026-05-11 18:15:28 -05:00
Grant 6c8df98cfd v0.2.0:34 — Buy page: pre-populate featured code in discount input
Previously a tier's featured (launch-special) discount auto-applied
silently at payment time but the discount-code input was empty,
leaving buyers unsure whether they needed to type anything to claim
the slashed price.

Now: when a tier has an active featured discount, selectTier()
pre-fills codeInput with the code string and flips into the
"applied" state — appliedCode set, status badge shows "Launch
special applied". The price card has always rendered the
struck-original + discounted-current price; this change just makes
the form match what's already visually claimed.

New `autoAppliedFeatured` flag distinguishes auto-populated codes
from buyer-typed ones:
- On tier switch, the reset block also clears the input when
  autoAppliedFeatured was true (the prior featured code doesn't
  necessarily apply to the new tier; better to start fresh).
- Buyer-typed codes are NOT cleared on tier switch — they may be
  valid for the new tier, and the buyer can hit Apply to check.
- Any keystroke in codeInput, or a successful manual Apply, flips
  the flag to false.

JS / template only; no API or schema change.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 17:57:47 -05:00
Grant 752beff429 v0.2.0:33 — Drop unused invoice_id_safe warning
`let invoice_id_safe = html_escape(&invoice_id);` in
api::thank_you was computed but never referenced — the template
uses invoice_id_json for the inline JS, and the visible invoice id
renders from that JSON via JS. One-line removal; cargo check now
warning-free. No behavior change.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 16:59:16 -05:00
Grant 70ce20951b v0.2.0:32 — Per-product policy cap pre-check + grandfather banner
Closes the third tier-enforced surface (Creator caps policies at 5
per product). Same UX shape as the global products + codes pre-check
in v0.2.0:31, scoped to a single product instead of the whole
instance.

- routes.policies fetches /v1/admin/tier once on render and threads
  the status into renderPolicyCardGrid.
- renderPolicyCardGrid renders a grandfather banner above the tier
  grid when policies.length > caps.policies_per_product (per-
  product, since the cap is per-product).
- renderDraftTierCard accepts (tierStatus, productPolicyCount) and
  shows the same pre-check warning at the top of the draft form
  when used == cap - 1 (approaching) or used >= cap (over).
- Reuses existing helpers (capPreCheckCard, grandfatherBanner) by
  synthesizing a tierStatus shape with caps.policies mapped to the
  per-product cap. No new component code.

UI-only.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 16:43:10 -05:00
Grant 3d7cf166db v0.2.0:31 — Punchlist clear: cap pre-check, grandfather banner, webhooks empty state, help-icon overhaul
Four outstanding admin-UI items shipped:

- Cap-hit pre-check. Products + Discount Codes pages fetch
  /v1/admin/tier on render and inline a gold-bordered "Approaching
  cap" warning above the submit button when usage is at cap-1.
  Includes a direct upgrade link. The existing 402 modal still
  fires if the operator submits anyway.
- Grandfather banner. When usage > current tier cap (e.g. downgrade
  from Pro to Creator with 8 products under a 5-product cap), the
  relevant page renders a persistent banner explaining the
  grandfather state and that new creates are blocked until upgrade.
  The daemon enforcement was already correct; the UI was silent.
- Webhooks empty state. Replaced the bare "No webhooks registered."
  table with a centered CTA card: eyebrow, headline, 2-sentence
  explainer of what webhooks are good for, and a primary "Add your
  first webhook" button that opens the create disclosure + focuses
  the URL input. Mirrors the Machines empty state.
- Help-icon click-to-toggle. helpIcon() now renders a small
  outlined button that opens a navy popover anchored next to it on
  click. Click outside / Esc / click again closes. Focus + Enter /
  Space opens. Visually less prominent. Replaces the prior native
  title= hover tooltip. Single function used everywhere, so the
  refactor ripples across the whole admin.

Three reusable helpers added: loadTierStatus, capPreCheckCard,
grandfatherBanner.

UI-only. No schema, API, or SDK change.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 16:27:40 -05:00
Grant 52deb82ad2 v0.2.0:30 — Two copy fixes: pubkey tip + Licenses search
- "Embed your public key" tip now says "your product's source code"
  instead of "your app's source" — clearer for operators distributing
  libraries, services, or anything that isn't an app.
- Licenses search row: dropped Nostr npub from the placeholder, the
  description text, and the search-field dropdown. The purchase flow
  doesn't capture buyer npubs yet, so the option had nothing to
  find. Backend search-by-npub path is untouched — re-expose the UI
  option once buyer npub capture lands in the purchase flow.

UI copy only.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 15:36:43 -05:00
Grant 1bd1bde895 v0.2.0:29 — Tier-card cross-card horizontal alignment via subgrid
Visually equivalent sections of each tier card (names, prices,
first feature bullet, Select button) now line up horizontally
across all visible tier cards. Cards with fewer / shorter
sections get extra whitespace in the rows they don't fill — the
explicit tradeoff the operator asked for, in service of a
cleaner grid.

- .tiers parent grid now declares 8 explicit row tracks. Each
  .tier is a subgrid that shares those rows.
- Each section class (.tier-launch-meta, .tier-name, .tier-price-
  original, .tier-price, .tier-meta-block, .tier-description,
  .tier-features, .tier-select-btn) gets an explicit grid-row.
  Missing sections leave the row empty without breaking
  alignment.
- Meta lines (duration, recurring, trial banner, trial flag) now
  wrapped in a single .tier-meta-block so they land in one row
  as a flex-column.
- Launch-meta separated from featured_ribbon so each can occupy
  its own grid row independently (vs. the ribbon string previously
  embedding the meta div in-flow).
- Side fix: .tier.has-launch swapped from overflow:hidden to
  clip-path polygon that preserves 20px above the card. The
  popular pill returns to top:-10px (above the card) without
  being clipped. Removed the v0.2.0:26-27 padding-top:36px
  workaround that pushed the pill inside.

CSS + HTML composition only; public API JSON unchanged.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 15:31:29 -05:00
Grant 559e657b90 v0.2.0:28 — Settings polish, operator-name fix, Hide-revoked toggle
Three small admin-UI fixes:

- Settings page intro card removed. The preamble was redundant with
  the page title + section headers.
- Operator-name save no longer 404s. The JS was POSTing to
  /v1/admin/operator-name; the daemon mounts the endpoint at
  /v1/admin/settings/operator-name. Fixed both GET and POST paths.
- Licenses page: pill toggle "Hide revoked" between the product
  filter row and stat cards. Filters rendered rows; stat cards
  still show the true revoked count so operators don't lose
  visibility.

UI-only; no schema, API, or SDK change.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 15:18:09 -05:00
Grant 4377dfbb34 v0.2.0:27 — Single tier-features ul; popular pill spacing fix
Two remaining buy-page issues from :26:

- Tier-card feature list. Stop fighting the two-<ul> boundary with
  margin tweaks. Build ONE <ul class="tier-features"> server-side
  containing marketing bullets and entitlements in the operator-
  controlled order. Both groups render with identical ✓ + li styling,
  visually indistinguishable to the buyer. No list boundary = no gap.
- "MOST POPULAR" + "Limited: ..." collision. The :26 fix moved the
  popular pill to top:8px (inside the card) for has-launch tiers,
  but that landed it on top of the launch-meta line. Push the card
  content down via padding-top:36px on .tier.has-launch.highlighted
  (35px when also .selected to compensate for the thicker border).

CSS + HTML composition only; no schema, API, or SDK change.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 15:11:37 -05:00
Grant 9628001f69 v0.2.0:26 — Buy-page + entitlement-picker visual polish
Cluster of small visual fixes:

- Tier-card feature list seam. Zeroed margin-top between adjacent
  marketing-bullet + entitlement lists in either order so the gap
  between lists matches the within-list gap. Reads as one column.
- MOST POPULAR clip. When a tier was both highlighted AND had a
  launch ribbon, overflow:hidden (for the ribbon overhang) was also
  clipping the popular pill that floats above the card. Pill drops
  to top:8px (inside the card) only for the highlighted + has-launch
  combination.
- Price card width. :23 stretched the price card to 1040px alongside
  the headline; that overpowered everything below the tier picker.
  Constrained back to 560px (centered); headline stays full-width.
- Entitlement bubble picker theme. Selected chips switch from gold-
  filled to navy-filled with cream text (matches "Selected" tier-
  select-btn + Featured-ON toggle). Hidden-on-buy state drops the
  strikethrough — opacity:0.5 on the whole pill is the signal.
- Discount-code policy multi-pickers follow the same navy theme on
  Create + Edit (re-aligned from the brief gold pass in :25).
- Admin Policies grid also drops strikethrough on hidden chips;
  opacity-only, italic "(hidden on buy)" hint stays.

CSS + inline-style only; no data, schema, or API change.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 14:57:42 -05:00
Grant f4861eec44 v0.2.0:25 — Match discount-code policy pills to admin palette
The "Restrict to policies" multi-pickers (Create + Edit forms) were
rendering selected pills as dark navy with gold text — visually
off-key against the gold-filled / cream-outlined pill convention
used elsewhere in the admin (entitlement bubbles, marketing-bullets
position, etc.). Aligned both pickers to the shared style.

Cosmetic only; no data or behavior change.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 14:49:43 -05:00
Grant 033a1f4a6a v0.2.0:24 — Per-entitlement "hide on buy page" toggle
Decouples "what the license grants" from "what the buyer sees on the
tier card." Operator can mark individual entitlements as hidden from
the buy page tier-card display; the issued license still carries
them. Enables the "Everything in Creator, plus:" marketing pattern
without duplicating implied entitlements on higher-tier cards.

- entitlementBubblePicker accepts a third `initialHidden` param and
  exposes a `readHidden()` method alongside `read()`. Each granted
  chip gets a small eye toggle (👁 visible / 👁‍🗨 hidden). Click chip
  name = grant/revoke. Click eye = hide-on-buy toggle. De-selecting
  a chip clears its hidden state automatically.
- New per-policy metadata: hidden_entitlements: string[]. Buy page
  filters before rendering tier-card entitlement chips. Public
  /v1/products/<slug>/policies exposes the array so SDKs and dynamic
  pricing pages stay in sync.
- Admin Policies grid still shows ALL entitlements (operator-truth
  view) but hidden ones get muted opacity + strikethrough + a small
  "(hidden on buy)" italic hint.

No schema change; pure metadata pass-through.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 14:40:56 -05:00
Grant 0e46ce399d v0.2.0:23 — Buy-page polish: width balance, auto-discount, bullet gap
Three concrete fixes after :21 rolled the wider buy page:

- Layout proportions. Headline + price card span the full 1040px
  container with center-aligned text (matches the tier picker
  width). Only the email/discount/pay form stays narrow at 560px
  since input fields look stretched at 1040px.
- Featured discount auto-applies on the headline price. Tier JSON
  now carries each tier's featured-discount snapshot, and the JS
  selectTier() renders strike-through + discounted price when an
  active featured code applies. Tier switching also re-applies the
  featured code for the new tier instead of resetting to base.
- Marketing-bullets gap. Added mirror CSS rule
  `.tier-entitlements + .tier-bullets { margin-top:2px }` so the
  bullets-below layout has the same tight visual continuity that
  bullets-above already had.

Public buy-page CSS + JS only.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 14:27:45 -05:00
Grant 3c054c65db v0.2.0:22 — Policy scope is editable on discount codes
Lifts the "scope cannot be edited" rule for policies. Product scope
remains read-only (moving a code between products has weird semantics
for historical redemptions), but the tiers a code applies to can now
be refined in-place via the Edit form's pill multi-picker.

- repo::update_discount_code: new applies_to_policy_id param
  (Option<Option<String>>) alongside the existing applies_to_policy_ids
  multi field. Both update the right columns; caller passes a
  consistent pair so singular + JSON columns don't drift.
- Admin PATCH endpoint: new optional `policy_slugs` field. Server
  resolves slugs against the code's existing product, then normalizes:
  - [] → both columns NULL (any policy on the product)
  - [one] → singular column set, JSON column cleared
  - [two+] → JSON column set, singular column cleared
  Sending no `policy_slugs` leaves scope alone (back-compat).
- Edit form: pill multi-picker replaces the read-only Applies-to label.
  Pre-selected from the code's current allowed-policy set. Product
  label stays read-only above the picker.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 14:19:49 -05:00
Grant 6fd7dd9302 v0.2.0:21 — Wider buy page (1040px) so 3-tier grids breathe
The public /buy/<slug> page was capped at 560px. With three tier
cards side-by-side that made everything narrow and tall in a desktop
browser. Bumped the outer container to 1040px so the tier picker
matches the admin Policies page layout.

- .wrap max-width: 560px → 1040px.
- .wrap > :not(.tiers) max-width:560px + margin-auto so the form,
  price card, and intro text stay centered at reading width below
  the wider tier picker.
- .topbar .inner widened 680px → 1040px to align with .wrap.
- .eyebrow display:inline-flex → flex + width:fit-content so the
  margin:auto centering rule applies.

Mobile breakpoints unchanged.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 14:09:15 -05:00
Grant 094cf75e52 v0.2.0:20 — Multi-policy scope for discount codes
A discount code can now apply to a subset of policies on a product
(e.g. "Patron and Pro but not Creator") instead of being limited to
exactly one policy or the entire product.

- Migration 0018 adds `applies_to_policy_ids_json` (nullable JSON array
  of policy ids). Legacy `applies_to_policy_id` stays as the singular
  fallback when the JSON column is empty/NULL.
- `DiscountCode::allowed_policy_ids()` helper unifies multi + singular
  into one Vec. Purchase + preview scope checks consult it.
- `find_applicable_featured_discount` now narrows multi-policy
  candidates in Rust (small candidate set; index-friendly SQL would
  require json_each, deferred).
- Admin API: `POST /v1/admin/discount-codes` accepts `policy_slugs`
  (array) alongside the existing `policy_slug` (singular). Multi wins
  when both are present. PATCH does not allow scope edits — same rule
  as the singular field (disable + recreate to re-scope).
- UI: pill multi-select replaces the policy dropdown on the create
  form. Edit modal's scope label renders the comma-separated list.

UI + schema both back-compat: existing codes keep working unchanged.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 14:01:51 -05:00
Grant eb360a325e v0.2.0:19 — Marketing bullets: choose above or below entitlements
Operator picks where the free-form ✓ checkmark copy renders on each
tier card. Default "above" matches prior behavior; "below" is opt-in
per policy.

- New metadata field metadata.marketing_bullets_position ("above" |
  "below"). Persisted only when bullets exist AND choice != default.
- UI: select next to the bullets textarea on create + edit forms.
- Admin grid: swaps marketingList + entChips order accordingly,
  including the top-margin tighten-up so the lists hug each other.
- Buy page (buy_page.rs): swaps marketing_html + entitlements_html in
  the tier-card template via destructured (first, second) tuple.
- Public /v1/products/<slug>/policies: exposes the position field as
  "above" | "below" (normalized) so SDK consumers stay in sync.

UI-only/metadata-only; no schema, no SDK breaking change.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 13:31:53 -05:00
Grant bb53d708a1 v0.2.0:18 — Discount Codes form polish
Three small admin-UI changes that make the create + edit forms less
footgun-prone:

- Max-uses: "Limit total uses" checkbox + dependent number input
  (default 100), replacing the "0 = unlimited" pattern that read like
  "0 uses allowed." Unchecked sends no cap.
- Currency dropdown hides for percent + free_license kinds (neither
  has a currency). Stays for fixed_amount.
- Featured flag promoted from buried checkbox to a prominent gold
  pill toggle. Edit form starts in correct state.

UI-only; no schema, no SDK, no behavior change for buyers.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 13:26:50 -05:00
Grant 11cf1808c6 v0.2.0:17 — Discount Codes form usability
Three improvements to the Discount Codes tab:

1. Scope pickers replace text inputs. The create form's
   'Restrict to product slug' free-text input is now a dropdown
   populated from /v1/products. A dependent 'Restrict to policy'
   dropdown loads policies for the selected product on the fly.
   Both default to 'Any' so the no-scope global-code behavior
   is preserved.

2. datetime-local picker on expires_at. Native calendar + time
   spinner on both create + edit forms. Submit converts back to
   RFC3339 UTC automatically. Empty = no expiry.

3. Edit form shows scope read-only. 'Applies to: [product]
   -> [policy]' (or 'all products on this instance' for global
   codes) renders as a muted info block at the top. Scope
   remains immutable (disable + create new to change).

routes.codes now pre-fetches /v1/products once at the top
(reused for both the create form scope pickers and the per-
product table grouping). No more duplicate fetch.

UI-only release.
2026-05-11 13:08:49 -05:00
Grant 4334a9f044 v0.2.0:16 — Launch-special discount codes + marketing bullets
Major feature release.

Featured (launch-special) discount codes:
  - New 'featured' flag on discount_codes (migration 0017). When true,
    the buy page renders a diagonal LAUNCH SPECIAL ribbon + slashed
    original price + new price for every applicable tier. Purchase
    endpoint auto-applies the discount for buyers who don't type a
    code. Operator-typed codes still win.
  - find_applicable_featured_discount repo helper: most-specific match
    (policy > product > global), tiebreak by created_at.
  - GET /v1/products/<slug>/policies now returns featured_discount per
    policy with the post-discount price computed server-side. SDK
    consumers + the dynamic pricing page get this for free.

Marketing bullets on policies:
  - metadata.marketing_bullets — operator-controlled copy that renders
    as additional checkmarks above the entitlement bullets on both the
    admin grid tier card and the buy page tier. For things like 'Up
    to 5 products' or 'BTCPay integration' that aren't real
    entitlement gates.
  - Authored via textarea on draft + edit policy forms.

UI:
  - 'Most popular' checkbox now on the draft tier card (was edit-only).
  - Discount codes tab grouped by product (matching Licenses /
    Subscriptions tabs). Each code row gets a 'featured' badge when
    flagged.

All 87 tests still pass. Migration is additive, no SDK changes,
backwards-compatible.
2026-05-11 12:47:45 -05:00
Grant 2789d1da1f v0.2.0:15 — Multi-draft tier authoring + custom durations on draft cards
Two papercut fixes for the policy create flow:

1. Multi-draft survival. Previously, committing one draft tier card
   triggered a full grid reload via onMutate(), wiping any sibling
   drafts the operator had open. Now the commit callback receives the
   saved policy and replaces ONLY that draft's grid slot with a
   finalized tier card — sibling drafts keep their input state intact.
   Author Creator / Pro / Patron in parallel and click Create on each
   as it's ready, in any order.

2. Custom duration on draft cards. The Duration dropdown gains a
   "Custom (days)" option at the bottom; selecting it reveals a number
   input. On submit, days * 86400 = seconds is what gets sent. Matches
   the Edit-policy modal's existing custom pattern (which is in raw
   seconds); the draft uses days because day-based input is friendlier
   for the cadences operators actually pick.

UI-only release. No daemon code changes, no schema.
2026-05-11 11:38:47 -05:00
Grant 519fa1a8e6 v0.2.0:14 — Entitlements catalog read fix + drag-and-drop tier ordering
Bug fix:
  Product entitlements catalog reads were silently dropping. Every
  SELECT against the products table was missing entitlements_catalog_json
  from the column list, so the PATCH handler wrote the catalog correctly
  but every subsequent read returned null. Admin UI edits appeared to
  vanish on save. Fix: added the column to all four product SELECTs
  in repo.rs (list_products, get_product_by_slug, get_product_by_id —
  one column list, replace_all). Added regression test
  product_entitlements_catalog_round_trips_through_list_endpoint that
  exercises the full PATCH → list round-trip the admin UI hits.

UX:
  Drag-and-drop reordering on the tier-card grid. Operator drags any
  tier card to a new position; on drop, parallel PATCH requests set
  tier_rank 1..N based on the new visual order. Archived tiers are
  excluded (their position in the ladder is moot). Edit-policy modal
  retains the tier_rank number field for the two cases drag-and-drop
  can't express (precise override + blank-to-remove-from-ladder).
  Cursor signals grab/grabbing on hover/drag; dragging card lifts +
  fades for visual feedback.

Copy:
  Policies-tab section headers now show just the product name
  ("Keysat") instead of redundant "Keysat — keysat". Entitlements-
  catalog row editor description placeholder shortened from
  "Description (shown on buy page tooltip)" to "Description (buyer
  tooltip)" so it fits the column; full hover hint kept on the
  input's title attribute.

Test count: 87.
2026-05-11 11:14:20 -05:00
Grant 76fe7fe6b9 v0.2.0:13 — CORS on public endpoints
Adds tower-http CorsLayer at the outermost router position so:

- Browsers can fetch /v1/products/<slug>/policies, /v1/openapi.json,
  /v1/issuer/public-key, /v1/validate from any origin. Unblocks the
  dynamic pricing page on docs.keysat.xyz reading live tier config
  from licensing.keysat.xyz.
- Preflight OPTIONS is handled by the CorsLayer directly, never
  reaches the session-bridge or any handler — so admin endpoints
  don't 401 on preflight.

Security posture unchanged. Access-Control-Allow-Credentials is OFF.
The combination of ACAO=* and no-credentials means a cross-origin
page can read public responses but can't ride a logged-in admin
session cookie to hit /v1/admin/*. Admin endpoints still require
an explicit Bearer token, which browsers don't auto-attach
cross-origin.

Tests: +2 CORS regression tests (cors_allows_cross_origin_on_public_
endpoints, cors_preflight_returns_2xx_without_auth). Full suite:
85 passing.
2026-05-11 10:17:15 -05:00
Grant 257669092b v0.2.0:11 + v0.2.0:12 — Archive, Settings, agent surface, machines redesign
Two release cycles prepared together: v0.2.0:11 (policy archive + safe-
delete cleanup + brand-consistent confirm modals) and v0.2.0:12 (Settings
tab + agent-friendly operator API + machines tab redesign + buyer-facing
copy alignment).

Highlights:

- Migration 0015: policies.archived_at column. Archive button on tier
  cards; safe-delete relaxed to ignore revoked-license tombstones;
  renewal worker refuses archived policies.
- Migration 0016: scoped_api_keys table. Four roles (read-only,
  license-issuer, support, full-admin) with bounded scopes. Master
  admin_api_key still works on every endpoint; scoped keys gated on
  endpoints wired through require_scope().
- New /v1/openapi.json — public, no auth. Curated OpenAPI 3.1 spec
  for agent / SDK discovery.
- New Settings tab: Operator name + Payment providers panel + API
  keys management. Replaces 8 StartOS Actions (Zaprite all, BTCPay
  all, operator name, switch-provider). StartOS Actions pruned to 4
  install-time essentials.
- Machines tab rewritten: global default view grouped by product,
  filter pills with counts, quick-stats row, drill-down via new
  "Machines" button on each Licenses-tab row. New repo helper
  list_machines_admin joins machines x licenses x products
  server-side.
- Branded confirmModal replaces every native window.confirm() call
  in the admin UI (7 callsites).
- Enforce mode killed: KEYSAT_LICENSE_ENFORCE compile-time flag
  retired; daemon always boots; missing self-license -> Creator
  (free) tier. "Unlicensed" label gone from admin UI.
- Zaprite gated on the new zaprite_payments entitlement (renamed
  from card_payments to reflect the broader gateway).
- Creator code cap 5 -> 10.
- KEYSAT_AGENT_GUIDE.md: auth, role-to-scope mapping, error envelope,
  webhook events, worked recipes.
- Buyer-facing copy aligned with new positioning: "Bitcoin-native
  self-hosted software licensing" everywhere on production surfaces.
- Cross-product safety section (Section 9a) added to KEYSAT_INTEGRATION.md.
- 5 new API integration smoke tests covering OpenAPI, scoped API
  keys CRUD, role-elevation guard, and Zaprite-tier gating.

Test count: 83 passing (was 78). All migration tests pass against
0015 and 0016 applied to populated DBs.
2026-05-11 08:45:25 -05:00
Grant 20b5293c81 v0.2.0:10 — Licenses + Subscriptions tabs reorganized by product
Both tabs now group by product (matching the per-product card
sections in Products + Policies), with product-filter pills + per-
product counts at the top. Multi-product instances see one section
per product with a status breakdown subtitle ("3 active · 1
revoked · 2 expired"); single-product instances continue to see a
flat table with no chrome overhead. Search results bypass grouping
(search is global across all products).

Three new shared helpers added at the top of the script:

- clickToCopy(fullValue, displayLabel) — clickable code element
  that copies the full ID to clipboard with a "✓ copied"
  indicator. Replaces the older hover-to-see-full-id UX for
  license / subscription IDs.

- relativeDate(rfc3339, opts) — renders an RFC3339 timestamp as
  a human-relative string ("in 3 days" / "12 hours ago") with
  the absolute timestamp in a hover tooltip. Applied to license
  issued/expires + subscription next_renewal.

- reasonModal({title, message, warning, confirmLabel,
  confirmVariant}) — inline overlay-card replacement for the
  native prompt() / confirm() dialogs. Used by:
  * Subscription cancellation flow
  * License suspend / unsuspend / revoke flows
  Same UX language as the Change Tier modal.

Subscriptions tab specifics:
- Product filter pills with per-product counts (filtered by
  active status filter so the counts reflect what the operator
  is currently viewing).
- Status filter pills gain counts (Active (3), Past due (0), etc.)
- New Product column shows display name + slug.
- Status badges have hover tooltips explaining each state's meaning.
- Cancel button uses reasonModal instead of prompt().

Licenses tab specifics:
- Quick-stats row: Licenses / Active / Revoked / Expiring < 30d.
  Scope follows the active product filter; hover "?" icons
  define each stat. Mirrors the Overview dashboard style.
- Search affordance preserved; search results render as a single
  flat table titled "Search results" (not grouped by product).
- Manual-issue form's hint blocks replaced with help icons on
  every field. Compact-form treatment to match Products + Policies.
- Suspend / unsuspend / revoke buttons use reasonModal with
  per-action context (irreversible warning on revoke, etc.)
  instead of confirm() + prompt() double-dialog.
- Entitlements rendered with display name primary + description
  tooltip (resolves against the product's catalog from
  /v1/products's response).

Pure UI release. 78/78 tests still pass. No schema, SDK, or
behavior change.
2026-05-10 12:07:06 -05:00
Grant 0ea3469899 v0.2.0:9 — side-by-side tier-card policy authoring + form polish
The Policies tab gets the redesign Grant asked for: replace the
table view + verbose disclosure form with a card grid where each
existing policy renders as a buy-page-style tier card sitting next
to a dashed "+ Add tier" placeholder. Click the placeholder, it
morphs into an editable draft tier card with inline form fields;
submit Create on the card and it flips into a read-only preview.
Multiple drafts can coexist for parallel multi-tier authoring with
side-by-side comparison.

New JS helpers:
- helpIcon(text) — small "?" hover tooltip for compact form labels
- slugify(s) — URL-safe slug derivation from display name
- renderTierCard(pol, product, onMutate) — read-only buy-page-style
  preview card with Edit / Hide-Show / Delete actions
- renderAddTierCard(onClick) — dashed placeholder with "+" affordance
- renderDraftTierCard(product, onCommit, onCancel) — inline editable
  card with name + slug + price + duration + entitlement bubble
  picker + recurring/trial toggles
- renderPolicyCardGrid(product, policies, byPolicyCounts, onMutate) —
  ties them together. Submitting "+ Add tier" appends a fresh
  placeholder, so operators can keep clicking to author multiple
  tiers in one session.

formInput() upgraded:
- New `help:` option renders a helpIcon next to the label (replaces
  verbose hint text under the input)
- New `placeholder:` option for cleaner empty-state cues

Auto-slug:
- Product create form's Display name field mirrors a slugified
  version into the Slug field as the operator types — until they
  manually edit the slug, which arms a "userOverridden" guard so
  manual edits stick. Re-arms when the slug field is cleared.

Legacy "Create a new policy" disclosure form unsurfaced from
the Policies route — the card grid replaces it. Advanced fields
(custom grace seconds, tip recipient, tier rank) still live on the
existing Edit modal of an already-committed tier card. Power-user
flow: card grid creates the basics, Edit modal refines.

Test count unchanged (78). UI-only release.
2026-05-10 10:23:07 -05:00