# 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:` (cloud) or `lic:` (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 Cloud Recaps → Relay (after simplification): X-Recap-User-Id: X-Recap-User-Tier: pro Authorization: Bearer ← 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 ### Renewal-link emails (NEW, needed for Bitcoin path) 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=` - 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:` keys cloud requests, `lic:` 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 2–5 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.