Files
recap/docs/architecture-simplification-plan.md
T
Keysat 0ae59f3550 Add multi-tenant cloud mode: self-serve purchase, credit metering, core-decoupling
Introduces RECAP_MODE=multi alongside single-mode self-host:
- Tenant auth + accounts (magic-link via System SMTP), per-tenant credit pool,
  anonymous trial minting with per-IP/-64 caps
- Self-serve Pro/Max purchase: inline Lightning (BTCPay) + card (Zaprite),
  prepaid 30-day periods, expiry-reminder emails
- Core-decoupling: relay owns cloud tier/expiry keyed by Recaps user-id
- SQLite (better-sqlite3) schema for multi-mode; filesystem unchanged for single
- StartOS actions/versions through 0.2.155
2026-06-13 14:25:05 -05:00

13 KiB
Raw Blame History

Recaps Architecture Simplification — Plan of Record

Status: Agreed direction, 2026-05-19. Not yet implemented.

Why this exists

The current Recaps architecture has the Keysat license doing too many jobs. For a cloud Pro/Max tenant, the license is acting as: (a) the entitlement check, (b) the relay credit-pool key, (c) the subscription-expiry source, AND (d) the take-it-home portability token. Only (d) actually requires a cryptographically-signed token. The other three are accidental — the license just happens to carry that data because Keysat was already minting license tokens during the MVP.

This doc captures the agreed simplification so we can revisit later before implementation.


The three products, cleanly separated

Recap Relay — Backend compute service. AI provider routing (Gemini / Claude / Whisper / Ollama / etc.), credit ledger, BTCPay-backed top-ups. Runs on Grant's StartOS. Sold as a service (subscription gives monthly credit allotment + a la carte top-ups).

Recaps — Frontend cloud SaaS for summarizing podcasts/videos. People sign up, pay a subscription, and use the relay for compute. Hosted at recaps.cc. Also freely available as a .s9pk for self-hosting.

Keysat — Standalone licensing-as-a-service software, separately monetized as a B2B product. Recaps happens to use Keysat for the one specific case of minting a portable license token when a cloud user clicks "Take Recaps home." Otherwise unrelated to Recaps day-to-day.

These are three products that happen to share an author. Tangling them is what made the current architecture confusing.


What each layer owns after the simplification

Concern Lives in Why
Subscription tier + expires_at Recaps DB (users.tier, users.subscription_expires_at) Billing state belongs with the billing surface. Zaprite/BTCPay webhooks fire at Recaps and update one row.
Credit balance (remaining/consumed) Relay's ledger (credits.json) Compute happens at the relay. The relay knows when N credits were actually spent on Gemini tokens. Race conditions get ugly if billing and consumption drift.
Purchased credit top-ups (one-shot Lightning) Relay's ledger BTCPay webhook → relay → bump purchased_balance on the relevant pool. Unchanged from today.
Monthly tier allotment Relay computes from tier header passed by Recaps Recaps sends X-Recap-User-Tier: pro; relay applies its quota config (pro.monthly = 50).
Per-user identity at the relay Keyed by user:<recaps_user_id> (cloud) or lic:<fp> (self-hosted) Removes the license-as-credential coupling for cloud requests. License only matters for self-hosted.

The header change

Cloud Recaps → Relay (today):
  Authorization: Bearer <user's LIC1 token>

Cloud Recaps → Relay (after simplification):
  X-Recap-User-Id: <recaps_user_id>
  X-Recap-User-Tier: pro
  Authorization: Bearer <OPERATOR's LIC1>      ← proves THIS Recaps server is authorized

The operator's bearer token is still needed because the relay needs to verify that this is Grant's cloud Recaps server (vs. someone else trying to forge user-id headers). The per-user identity comes from the explicit headers.


Cloud Recaps is paid-only

Decided: cloud Recaps drops the "free signed-in" tier entirely. Self- hosted IS the free path.

User states on cloud:

  • Anon trial — cookie-tracked, no account. Gets a small allowance of credits to taste-test. After the trial runs out, must subscribe to continue using the cloud service.
  • Subscribed — account exists, users.tier ∈ {pro, max}, subscription_expires_at in the future.
  • Expired subscription — account preserves the library, can't summarize new things until renewed. Renewal anytime restores access.

What this means for the codebase: tenant_credits table's "free signed-in" codepath stops applying to cloud users. The table stays in the codebase because self-hosted multi-tenant operators (Alice running Recaps for her family) still need per-tenant accounting locally.


Payment provider strategy

Pro/Max purchase page has two paths:

┌─────────────────────────────────────────────┐
│  Upgrade to Pro — $X/month                  │
│                                             │
│   • 50 Recap credits per month              │
│   • Channel + podcast subscriptions         │
│   • Auto-queue + priority processing        │
│                                             │
│      ┌──────────────────────────────────┐   │
│      │  ⚡ Pay with Bitcoin             │   │  ← primary, inline BTCPay
│      └──────────────────────────────────┘   │
│                                             │
│            Pay with card →                  │  ← link → Zaprite hosted
│                                             │
└─────────────────────────────────────────────┘

Bitcoin path: monthly upfront, manual renewal

  • User pays one BTCPay invoice for 30 days of Pro
  • Inline Lightning QR + BOLT11 + copy button (same UX as credit packs)
  • On settle, Recaps sets subscription_expires_at = now + 30 days
  • No card on file, no autorenewal — Lightning doesn't have a clean recurring-billing primitive

Recaps sends automated emails near expiry:

  • 7 days before expiry — "Your Pro sub renews in 7 days. Tap to renew →"
  • Day of expiry — "Your Pro sub expired. Tap to renew →"
  • 7 days after expiry — "We've paused your Pro features. Renew anytime →"
  • After 30 days post-expiry — stop emailing (avoid being a nag)

Mechanism:

  • Email contains a tokenized URL: https://recaps.cc/renew?token=<base64>
  • Token encodes { user_id, action: "renew_pro" }, short-lived (~14 days)
  • Click → Recaps mints fresh BTCPay invoice, renders inline Lightning UI
  • On settle, subscription_expires_at += 30 days (extends from whichever is later: existing expiry, or current time — so a user who renews 5 days early doesn't lose those days)

Card subscribers don't need this — Zaprite handles recurring billing natively via Stripe.

Card path: Zaprite handles everything

  • Click "Pay with card" → redirect to Zaprite hosted checkout
  • Zaprite collects card info, charges monthly, handles retries on failure
  • Zaprite webhook fires → Recaps updates subscription_expires_at
  • Cancel button in Settings → Plan calls Zaprite cancel API
  • Card premium pricing handled inside Zaprite admin (not in Recaps code) — Grant configures the card-monthly price to be N% higher than Bitcoin-monthly in Zaprite's product settings

Self-hosted scenarios

Self-hosted Recaps is free + open source. Run the .s9pk anywhere, no license check to run the software.

Scenario Subscriber to Grant's relay? How relay access works
Bob runs Recaps for himself, brings his own Gemini key No Bob pays Google directly. Default .s9pk has no relay configured — Bob enters his AI provider keys in Settings.
Bob runs Recaps for himself + wants Grant's relay Yes Bob has a cloud Pro subscription. He clicks "Take Recaps home" in cloud settings → Recaps mints a fresh LIC1 token via Keysat with expires_at = subscription_expires_at. Bob pastes it into his self-hosted install. Self-hosted Recaps uses the token as Authorization: Bearer LIC1-... to the relay.
Alice runs Recaps for family, brings own Gemini key No Alice's family is invisible to Grant.
Alice runs Recaps for family + uses Grant's relay Yes Alice has a cloud subscription. Family-tenants on Alice's install all share Alice's relay-credit pool via Alice's pasted license. Alice manages per-family-member accounting locally via the existing tenant_credits + operator-grant flow. If Alice needs more credit headroom for her family, she upgrades her cloud sub to Max or buys credit packs.

What the license is for, after the simplification

One job: the credential that authenticates a self-hosted Recaps install against Grant's relay.

Not "are you Pro on cloud" — Recaps DB knows that. Not "credit pool key" — user:<recaps_user_id> keys cloud requests, lic:<fp> keys self-hosted. Not "subscription expiry" — users.subscription_expires_at in Recaps DB is the source of truth.

The license is a deliverable artifact, minted on demand only when a cloud Pro user explicitly clicks "Take Recaps home." Most cloud users never click it; they don't need a license. The license's expires_at mirrors the user's subscription expiry, so when their cloud sub lapses, the self-hosted install's relay access stops working naturally.

Grace period for self-hosted licenses

Decided: Keysat handles this. When a cloud subscription lapses, Keysat can keep the license valid for a short grace period (e.g., 7 days) before revoking. Recaps doesn't manage this — it's a Keysat-internal policy.


Migration order

When ready to implement:

  1. Decide self-hosted = free + open source ← already decided. Removes the "is this install licensed" check from the cloud path; keeps it only as a guard on relay access.
  2. Recaps schema additions: users.tier, users.subscription_expires_at, users.zaprite_customer_id, users.zaprite_subscription_id, users.bitcoin_renewal_token_hash (single-use). Migrate existing users by deriving tier + expires_at from their attached license at boot time.
  3. Zaprite webhook handler: Recaps endpoint accepting Zaprite's order
    • subscription lifecycle events; updates user row accordingly.
  4. Relay header migration: Cloud Recaps sends X-Recap-User-Id + X-Recap-User-Tier. Relay accepts BOTH old (license-keyed) and new (user-id-keyed) headers for one release as a compat window.
  5. Drop license attachment from cloud signup: Pro/Max purchase via Zaprite or BTCPay now updates users.tier + users.subscription_expires_at directly. No LIC1 token attached at signup.
  6. Renewal-email pipeline: Scheduled job in Recaps that scans for subscriptions approaching expiry, sends the renewal email with a one-time-use renewal token. Token consumption flow on /renew?token=….
  7. Pro/Max purchase UX redesign: Bitcoin primary + Card secondary layout. Bitcoin path uses existing inline BTCPay flow. Card path redirects to Zaprite hosted checkout.
  8. "Take Recaps home" rework: Becomes an explicit user-initiated action that mints a fresh LIC1 token via Keysat at click time, not at signup. UI shows the token with copy-to-clipboard + install instructions.
  9. Cancel-subscription button: Settings → Plan → cancel. Hits Zaprite cancel API for card subs; for Bitcoin subs there's nothing to cancel (just let it lapse — they paid one month, they get one month).
  10. Remove "free signed-in" path from cloud: Anon trial credits stay as taste-test; signup grant goes away. Self-hosted is the free path going forward.

Estimated effort: ~1 week of focused work end-to-end. Steps 25 are the core; the rest are polish on top.


Decided open questions

Q Decision
Annual or monthly Bitcoin subscription? Monthly upfront, manual renewal with automated renewal-link emails
Same price across paths, or premium/discount? Card premium configured inside Zaprite admin, not in Recaps code
Grace period on self-hosted licenses when cloud sub lapses? Keysat handles this — Recaps doesn't manage
Free tier on cloud? No — cloud is paid-only, self-hosted is the free path

What stays (don't break)

  • Relay's credit ledger + tier-quota math
  • BTCPay webhook → relay-pool crediting (for one-shot Lightning credit packs)
  • Inline Lightning UX for credit packs (already shipped, working)
  • Anon trial mechanic (cookie-tracked taste-test)
  • The "Take Recaps home" feature itself (just changes when the token is minted)
  • The Keysat license-issuing pipeline (just changes who calls it and when)

What changes

  • Recaps stops attaching a license at every Pro/Max signup
  • Recaps gains its own subscription state (users.tier + expires_at)
  • Relay accepts X-Recap-User-Id for cloud requests (compat with old license-keyed for self-hosted)
  • Pro/Max purchase UX gets the two-option layout (Bitcoin primary, Card link)
  • New renewal-email pipeline for Bitcoin subscribers
  • Cancel-subscription button wired to Zaprite
  • Cloud loses the "free signed-in" tier; self-hosted IS the free path
  • "Take Recaps home" reshapes as on-demand mint, surfaced as an explicit button in cloud settings

What's deleted

  • Nothing — only deprecated. Old license attachment code stays as a fallback for one release window. After migration is fully verified, can be cleaned up in a follow-up.