Files
recap/docs/architecture-simplification-plan.md
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

262 lines
13 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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
### 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=<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.