0ae59f3550
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
262 lines
13 KiB
Markdown
262 lines
13 KiB
Markdown
# 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 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.
|