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
This commit is contained in:
Keysat
2026-06-13 14:25:05 -05:00
parent db580abad7
commit 0ae59f3550
176 changed files with 23823 additions and 803 deletions
+261
View File
@@ -0,0 +1,261 @@
# 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.
+175
View File
@@ -0,0 +1,175 @@
# Core Decoupling — Implementation Plan (relay-owns)
**Status:****Implemented + build-ready, 2026-06-04.** Both sides code-
complete, typecheck/syntax clean, unit tests green. Relay bumped to
`0.2.119`, Recaps app to `0.2.143`. Not yet installed/configured on-device —
see "Install + configure runbook" at the bottom.
Scoped slice of `architecture-simplification-plan.md`, with the May plan's
"Recaps-owns billing" reversed per Grant's decision.
## Decisions locked (2026-06-03)
1. **The Recap Relay owns the Pro/Max subscription**, keyed by the Recaps
**user-id** (not a Keysat license). Recaps reads each user's tier from
the relay to gate features.
2. **No credit-pool migration** — no real customers yet; clean cutover.
3. **Keysat leaves the cloud path entirely.** A cloud user has no license.
Keysat/licenses remain ONLY for the (future) self-hosted-operator case.
4. **Server auth = a shared "operator key."** The `recaps.cc` server proves
itself to the relay with a shared secret; it then vouches for its users
via `X-Recap-User-Id`.
5. **Self-serve subscription purchase is DEFERRED.** For this slice, tiers
are **operator-set** (Grant grants Pro/Max). Self-serve BTCPay/card
subscription buying + expiry + renewal = the later "payment" slice.
## Goal (one sentence)
Replace "every cloud relay request carries the user's Keysat license" with
"the `recaps.cc` server authenticates once with an operator key and passes
the user's account-id; the relay tracks that user's tier + credits."
---
## How it works after the change
```
Cloud user's browser
│ (logged-in Recaps session cookie)
recaps.cc server ──reads user's tier from relay, gates features──┐
│ POST /relay/<...> │
│ X-Recap-User-Id: <recaps user id> │
│ X-Recap-Operator-Key: <shared secret> ← proves it's │
│ (NO per-user license bearer) Grant's server│
▼ │
Recap Relay │
• validates operator key → trusts the user-id │
• credit pool keyed by user:<id> │
• stores that user's TIER (+ optional expiry) on the pool ─────┘
• applies the tier's monthly credit quota
```
Self-hosted operators are unchanged: they send a license bearer and no
`X-Recap-User-Id`, so they keep the existing `lic:`/`inst:` path.
---
## Relay changes (recap-relay/)
1. **Identity resolver** (new helper used by every route in place of
`resolveLicense(auth)` + raw installId): if `X-Recap-User-Id` is present
AND `X-Recap-Operator-Key` matches config → identity =
`{ creditKey: "user:<id>", source: "cloud" }`. Else existing license/
install path (`credits.js` `getCreditKey` / `resolveLicense`).
2. **Tier-of-record on the pool** (`credits.js` ledger row): make `tier`
(+ optional `subscription_expires_at`) an authoritative, persisted field
for `user:` pools, instead of reading tier from a license each request.
Quota math (`getTierQuotas`) keys off it as today.
3. **Operator endpoint to set a user's tier**
`POST /admin/users/:userId/tier { tier, expires_at? }` (admin-auth
gated). This is how tiers get set in this slice; the future self-serve
purchase flow writes the same field.
4. **Report tier + balance for a user-id** — extend `/relay/balance` (and
the status surface) to answer for the `user:<id>` identity so Recaps can
read it.
5. **Config:** `relay_cloud_operator_key` (+ a StartOS action to set it,
Phase 1.5).
## Recaps changes (recap/)
6. **Cloud relay identity** (`providers/index.js` `pickRelayIdentity` +
`providers/relay.js` `buildHeaders`): multi-mode cloud user → send
`X-Recap-User-Id` + `X-Recap-Operator-Key` (from server config), DROP the
user-license bearer. Single-mode / self-hosted unchanged.
7. **Entitlement checks read the relay-reported tier**
(`license-middleware.js` multi-mode branch, `tts-routes.js`
`userHasTtsAccess`): derive tier from what the relay reports for this
user (cached on `req.user` / relay-status), not from a parsed license.
Single-mode keeps using the operator `LIC`.
8. **Stop attaching a Keysat license at cloud signup**
(`license-purchase.js`): cloud accounts no longer get a license. (The
existing flow stays available for the self-hosted operator-license case
only.)
9. **Config:** `recap_relay_operator_key` (server-side; never sent to the
browser).
---
## Explicitly deferred (later "payment" slice)
Self-serve Pro/Max subscription purchase (monthly BTCPay/card, expiry,
renewal emails, cancel) · removing the free signed-in tier · "Take Recaps
home" rework. None of these block the decoupling; tiers are operator-set
until then.
## Testing / rollout
1. Relay: identity resolver — cloud (valid key)→`user:` ; cloud (bad/no
key)→reject/fallback ; self-hosted→`lic:`/`inst:`.
2. Relay: operator-set tier → `/relay/balance` reports it → metered call
decrements the `user:` pool at the right quota.
3. Recaps: feature gates (clips, subscriptions, TTS) follow the relay tier.
4. Ship relay first (accepts both old + new), then Recaps cutover. Verify a
self-hosted-style license request still works. Both via `make install` /
sideload — **no registry deploys.**
## Effort
~**23 focused days** (smaller than the migration-laden version): ~1 on the
relay (resolver, tier-on-pool, operator endpoint, config), ~1 on Recaps
(headers, tier-read, gate rewiring), ~0.5 testing.
## Sequencing (resolved 2026-06-03)
Core decoupling ships first with **operator-set tiers**; self-serve
subscription purchase is the immediate next slice. Rationale: land the
structural de-licensing on its own and verify it, then add money-handling
code on a proven foundation rather than entangling a refactor with new
payment flows. No real customers yet, so no cost to this ordering.
---
## What landed (2026-06-04)
**Relay (`recap-relay/`, → 0.2.119):** `identity.js` resolver +
`verifyOperatorKey`; `credits.js` `setUserTier`/`getUserCreditRow` +
`creditKey` threading; `job-credits.js`/`envelope.js` `creditKey`;
`routes/user-tier.js` (`POST`/`GET /relay/user-tier`, operator-key authed);
balance/tts/transcribe/analyze/transcribe-url/summarize-url use
`resolveIdentity`; `config.js` `relay_cloud_operator_key`; admin Settings
expose it as a masked **"Cloud operator key"** field (dashboard +
`PUT /admin/settings`).
**Recaps (`recap/`, → 0.2.143):** `db.js` `users.tier` column +
`migrateUsersTier`; `relay-state.js` `computeCreditKey` keys `user:<id>`;
`providers/index.js` `pickRelayIdentity` emits the cloud identity for paid
users; `providers/relay.js` sends `X-Recap-User-Id`+`X-Recap-Operator-Key`
and adds `setRelayUserTier`/`getRelayUserTier`; `license.js` `viewForTier`;
`license-middleware.js` `/api/license-status` derives the view from
`req.user.tier`; `tts-routes.js` gate reads `req.user.tier`; **3 gates that
keyed off `!keysat_license` now exclude paid-tier users** so they aren't
misrouted to the free-tenant `tenant_credits` path
(`/api/relay/status` display, `/api/process` gate+debit); `config.js` polls
`recap_relay_operator_key` into a live binding; `relay-default.js`
`getRelayOperatorKey` reads env→live-binding; StartOS **"Set Relay Operator
Key"** action + config field; operator **Tenants panel** gets a per-row
tier badge + **Tier** selector (`POST /api/admin/tenants/:id/tier`, which
writes the relay first then caches `users.tier`).
## Install + configure runbook
1. `cd recap-relay && make x86 && make install` (relay 0.2.119). **Never**
`make deploy`/`redeploy` for the relay.
2. Relay dashboard → Settings → Endpoints & credentials → **Cloud operator
key** → paste a fresh secret (`openssl rand -hex 32`). Save.
3. `cd recap && make x86 && make install` (app 0.2.143).
4. Recaps StartOS → Actions → **Set Relay Operator Key** → paste the **same**
secret. (Picked up within one config poll — no restart.)
5. Sign in as operator → **Tenants** → open a user's row → **Tier****Max**
(or Pro). This writes the relay `user:<id>` tier, then caches `users.tier`.
A 502 here means the two operator keys don't match — fix + retry.
6. As that user: confirm the **MAX/PRO badge**, the **Listen** (TTS) button,
that a summarize run is metered against the relay `user:<id>` pool (not
`tenant_credits`), and that `/api/relay/status` shows the relay balance.
7. Regression: a self-hosted-style license request still works (license/
install path untouched — additive).
+282
View File
@@ -0,0 +1,282 @@
# Path 2B + Path 1 Interweave Plan
Companion doc to `architecture-simplification-plan.md` (Path 1) and the
chat thread that proposed Path 2A (relay-only upload, ship first).
This doc covers:
1. **Path 2B** — bringing internal-meeting analysis into the Recaps
cloud frontend as a first-class feature alongside YouTube/podcast
summaries.
2. **How Path 2B depends on Path 1** — what Path 1 unlocks vs. what
could be partially built without it.
3. **Migration path** — how Path 2A's relay-only upload data flows
forward into Path 2B's cloud-side library.
---
## Context
Recap Relay (the operator-side backend) is now generic enough to
analyze ANY audio — not just YouTube/podcasts. The download step is
the only YouTube-specific code; everything downstream (transcribe →
diarize → cluster → analyze → polish) applies cleanly to arbitrary
audio. Path 2A exposes this via a relay-admin-only upload UI so
operator Grant can run internal meeting analysis on his own hardware
TODAY without waiting on Recaps multi-tenant work.
Path 2B is the longer arc: same capability surfaced in the cloud
Recaps app, so signed-in users can submit private meeting audio,
manage it alongside their other content, and (optionally) share with
colleagues.
---
## Path 1 (recap) state
`architecture-simplification-plan.md` defines:
- One Recaps binary, two modes via `RECAP_MODE=single|multi` env var
- Magic-link + optional password auth via StartOS SMTP
- Per-user library at `/data/history/<userId>/<sessionId>.json`
- Per-user keysat license (mintable via Keysat admin API)
- BTCPay subscription + one-time credit-purchase flows
- Lite-settings UI for non-operator cloud users
- Self-hosted operator stays single-tenant by default; the .s9pk
ships free + open
Status: written but not built. The relay-side work has continued in
parallel (FIFO queue, clustering suppression, polish pass) which
strengthens the case for Path 1 — the cloud user experience benefits
from all of it, and the keysat-license layer is increasingly
friction-without-value for cloud users who already have email-verified
accounts.
---
## Path 2B — internal meetings in cloud Recaps
### What it looks like
A signed-in Recaps user gets a second submission affordance alongside
the existing "Paste a YouTube/podcast link" input:
```
┌─────────────────────────────────────────────────────────┐
│ Submit content │
│ ○ Paste a YouTube/podcast link │
│ ○ Upload audio file (private) [Choose file…] │
│ │
│ Title: [_____________________________] │
│ Participants: [_____________________________] (opt) │
│ Meeting type: [▼ default / 1:1 / all-hands / …] │
│ │
│ [ Summarize ] │
└─────────────────────────────────────────────────────────┘
```
The submission flows the same way YouTube submissions do:
Recaps-app → Relay (`/relay/v1/summarize-upload` for files,
existing `/relay/v1/summarize-url` for URLs). The relay handles both
via the same pipeline — only the input step differs (download vs.
multipart receive).
### Library + rendering
Each saved session in the cloud user's library has a `type` field:
- `"youtube"` — existing rendering (video player + topics + transcript chips)
- `"podcast"` — existing podcast rendering (audio player + topics + transcript)
- `"meeting"` — new rendering: no media player; topics + transcript chips
expandable below each topic card; PLUS a "Meeting analysis" block at
the top with Decisions / Action Items / Open Questions / Key Quotes
(the structured-extras pass from Phase 2 of Path 2A)
Library list view shows a small icon distinguishing meeting items so
the user can filter at a glance.
### Privacy + sharing
Meetings are PRIVATE by default — visible only to the submitting user.
Two later features:
- **Share with team** — a meeting can be optionally shared with N
named cloud-Recaps users (each looks up by email). Other users see
it in a "Shared with me" library section.
- **Export to markdown / PDF** — already exists for YouTube content;
add the meeting-extras blocks to the export.
### Audio handling
- Uploaded audio goes from browser → Recaps-app (Node Express,
multipart middleware) → Recap Relay (forward as multipart to
`/relay/v1/summarize-upload`).
- Recaps-app's tmp file is deleted immediately after the relay
acknowledges receipt.
- Relay's tmp file is deleted after the pipeline completes.
- NEITHER side keeps the audio. The TRANSCRIPT + analysis are saved.
If the user wants to re-process (different prompt set), they'd
re-upload the audio.
- The transcript stays in the user's per-user library at
`/data/history/<userId>/<sessionId>.json`, scoped + isolated.
---
## Path 2B's hard prerequisite: Path 1
Path 2B fundamentally requires multi-tenant auth in Recaps. Without
Path 1:
- No per-user library separation
- No way to know whether the meeting audio is private to a user vs.
visible to everyone running this Recaps instance
- No way to share with named other users
- No way to bill upload-heavy users differently from URL-only users
(uploads might warrant a different price tier given the storage
cost)
You COULD build a stripped-down Path 2B on single-tenant Recaps —
operator uploads audio, it's saved to the operator's library, no
sharing. But that's roughly equivalent to Path 2A with a fancier UI,
just on the wrong side of the codebase split (Recaps-app vs. relay).
Not worth the duplication.
So: **ship Path 2A as the immediate beachhead, do Path 1 next, then
Path 2B on top.**
### What Path 1 unlocks (relevant to 2B)
The Path 1 doc already covers most of what 2B needs:
- Per-user library at `/data/history/<userId>/*.json`
- Auth-aware request scoping (`req.userId`)
- Per-user keysat license OR the simplified user-id + tier headers
- BTCPay subscription tracking (for billing upload-heavy use)
Path 1's "Lite-settings panel for cloud users" already imagines a
post-auth Recaps UI without operator config noise. The submission
input would extend in 2B to add the upload option.
The relay-side header migration Path 1 proposes (`X-Recap-User-Id` +
`X-Recap-User-Tier` replacing the bearer license token) is also
beneficial for 2B — uploads from a single user with N concurrent
browsers all carry the same user-id, so credit accounting is per-user
not per-install.
---
## How Path 2A's data flows into Path 2B
When Path 2A ships (relay-only upload, results saved to
`/data/internal-meetings/<id>.json` on the relay), those summaries
are tied to the OPERATOR (Grant) — there's no cloud-user concept yet.
When Path 2B lands:
1. **Migration script** — walks `/data/internal-meetings/*.json` and
re-homes each entry under the operator's cloud user account
(`/data/history/owner/*.json` initially, then `/data/history/
<operator-user-id>/*.json` after Path 1's owner→admin rename).
2. **Same JSON shape** — Path 2A should save in a shape compatible
with Path 2B's expected library shape (chunks + entries + speakers
+ meeting-extras). One way to guarantee this: design the Phase 1
save shape now to match what Recaps' `saveToHistory` produces for
`type=meeting`, even though no Recaps UI consumes it yet.
3. **No re-processing needed** — transcripts and analysis transfer
verbatim. The user just sees them appear in their cloud library.
The relay-side upload endpoint (`/relay/v1/summarize-upload`) is the
same in both worlds. Path 2A calls it via the operator dashboard's
admin auth; Path 2B calls it via Recaps-app server proxying a
signed-in cloud user's POST.
So the relay code path is built once and serves both.
---
## Operator-editable prompt sets (Phase 3 in 2A; carries to 2B)
The "meeting type" dropdown is operator-editable. Each set has:
- Name (e.g. "1:1 with direct report")
- Topic-analysis prompt template (replaces the YouTube/podcast version)
- Meeting-extras prompt template (Decisions / Action Items / ...)
- Optional metadata schema overrides (e.g. "this meeting type always
expects 2 participants")
Stored in `relay_meeting_prompt_sets_json` config field. Operator
edits via the dashboard (similar to existing prompts panel). Cloud
Recaps users pick a set at submission time; the relay applies it
during analyze + polish.
Default sets ship built-in:
- `default` — neutral meeting prompt, all sections enabled
- `1on1` — emphasizes Action Items + Open Questions; light on Decisions
- `all-hands` — emphasizes Decisions + Key Quotes; less actionable
- `customer-interview` — emphasizes Key Quotes + Open Questions; light
on Decisions
- `standup` — short-form; Action Items + Open Questions only
---
## Suggested order of operations
1. **Path 2A Phase 1** — relay-only upload, no extras, basic
topic+transcript rendering. ~2-3 days.
2. **Path 2A Phase 2** — meeting-extras analysis pass (Decisions /
Action Items / Open Questions / Key Quotes). ~1-2 days.
3. **Path 2A Phase 3** — prompt sets dropdown. ~1 day.
4. (Use Path 2A in production for some weeks; gather feedback on
prompts, output quality, UX.)
5. **Path 1** — multi-tenant Recaps. ~3-4 weeks per the existing
architecture-simplification doc, modulo amendments.
6. **Path 2B** — surface internal meetings in cloud Recaps. ~1.5-2
weeks given Path 1 has shipped. Migrates the Path 2A artifacts
into the new per-user library.
Total wall time: ~6-8 weeks for the full arc. Path 2A capability
available to operator after step 1 (~2-3 days). Cloud users get
meetings after step 6.
---
## Open questions
These should be settled BEFORE Path 2B build starts:
1. **Pricing for uploads.** Does an upload count the same as a URL
submission against a user's monthly credit cap? Or is it priced
differently to reflect the upload bandwidth + storage cost? My
default: same price (1 credit per submission) — bandwidth cost is
trivial, storage is just JSON.
2. **Audio retention.** Default: never retain. Optional per-user
setting "keep audio for 7/30/90 days so I can re-process with a
different prompt set"? Adds operator storage cost; only worth it
if users actually want it.
3. **Sharing model.** Path 2B Phase 1 = no sharing, private only.
Phase 2 = shared with N named users. Phase 3 = optional public
share URL. Each phase adds auth/permission complexity. Worth
designing the data model now (`{ ownerId, sharedWith: [userId] }`)
even if only ownerId is populated in Phase 1.
4. **Speaker name persistence.** If a user identifies "Speaker_A" as
"Matt Hill" in one meeting, should that name auto-suggest in the
next meeting if a fingerprint match is found? Requires storing
fingerprints per-user across meetings. Big privacy + product
decision. My instinct: opt-in toggle in user settings, default
off.
5. **Meeting type defaults.** Should Recaps' submission flow have a
default meeting type, or force the user to pick? My instinct:
default to "default" set; let users pick if they want.
---
## Decision points for Grant
- Confirm the phasing above (2A → 1 → 2B) is the right order
- Pre-commit to the "uploaded audio is never retained" default — it's
the privacy-safer choice and aligns with how YouTube downloads
already work
- Pick a side on the open questions above before Path 2B starts, OR
defer them as Phase 2/3 of Path 2B
+131
View File
@@ -0,0 +1,131 @@
# Per-Tenant Subscriptions — Implementation Plan
**Status:****ALL STEPS DONE (app 0.2.149, 2026-06-04).** Per-tenant
subscriptions are live: every Pro/Max user gets their own subscriptions +
auto-queue, processed under their own account. The gate is flipped to
tier-based. Offline-verified (99 server tests incl. the ephemeral-session
mechanism + both-mode boot smokes); the actual processing-as-owner run is
the on-device test (see checklist below).
**Step 4 (the hard part), as built:** the background processor now finds
approved items across every scope (`listAutoQueueScopes`) and processes each
AS its owner — `processItemInternally(item, scope)` mints a short-lived real
session (`mintInternalSession` in auth-routes.js) for the owning user, sends
it as the `recap_session` cookie on the loopback `/api/process` call, and
deletes it on every exit path. No auth bypass — a bad/expired token just
401s and the item is marked failed. Single mode sends no cookie (resolves to
"owner"). `userIdForScope`: single→null; multi "owner"→admin; tenant→the
user id. Bonus: this also fixes the operator's OWN multi-mode auto-processing,
which previously ran the loopback with no identity.
**Gate flip:** `PRO_FEATURE_GATES` subscriptions gate is now tier-based in
multi mode (Pro/Max/admin pass, free → 402); frontend `canUseSubscriptions()
= hasEntitlement("subscriptions")` (operator-only clauses reverted).
## Landed in 0.2.147
- `server/subscriptions.js` — scope-keyed storage for subscriptions / skip /
seen / auto-queue + file-locked `mutateAutoQueue` (atomic read-modify-write,
replacing the global in-memory `autoQueue`), `listSubscriptionScopes()`,
`migrateGlobalSubscriptionsToOwner()`, plus the dedup (`getProcessedVideoIds`,
`isKnownVideo`).
- `index.js` — the check loop fans out over `listSubscriptionScopes()` into a
per-scope `checkScopeSubscriptions(scope)`; every endpoint resolves
`scope = subScope(req)` (= scopeForRequest, "owner" for the operator); the
processor + boot recovery use `mutateAutoQueue`; boot runs the migration +
per-scope library reconcile. Behind the gate every scope resolves to
"owner", so behaviour is unchanged for the operator — the plumbing is just
per-scope now.
- `history.js``addToSkipList(scope, videoId)` is scope-keyed.
---
## Goal
Each signed-in Pro/Max tenant manages their **own** channel/podcast
subscriptions; discovered episodes land in **their** auto-queue; approving
one summarizes it under **their** account (their credits, their library,
their relay identity). The operator (admin) keeps theirs. No tenant sees or
affects another's.
## What already exists (foundation)
- `server/subscriptions.js` — extracted, unit-tested. `getProcessedVideoIds(scope)`
(scope-aware library scan — the dedup fix) + `isKnownVideo()` (pure
predicate). Already takes a `scope`, so per-tenant dedup is free.
- `history.js``scopeForRequest(req)` (→ "owner" for admin/single, else
`safeComponent(user.id)`), `getScopeHistoryDir(scope)`, `scopeDir`,
`renameScopeDir`. The whole per-scope filesystem layer is in place.
- The interim isolation gate: `PRO_FEATURE_GATES[subscriptions].adminOnlyInMulti`
(server) + `canUseSubscriptions()` (frontend). Both get relaxed here.
## The work
### 1. Scope the storage (mechanical, testable)
Move the four global files into `subscriptions.js`, each keyed by scope and
rooted at `scopeDir(scope)` instead of the history root:
`subscriptions.json`, `auto-queue.json`, `skip-list.json`, `seen-list.json`.
Add `loadSubscriptions(scope)` / `saveSubscriptions(scope, …)` + the skip /
seen / auto-queue equivalents. **Drop the global in-memory `autoQueue`**
load/save per scope per request (the in-memory cache is what makes the
current code single-tenant). Unit-test the round-trips per scope.
### 2. Rescope the endpoints (mechanical)
All ~15 `/api/subscriptions*` + `/api/auto-queue*` handlers derive
`scope = scopeForRequest(req)` and read/write that scope's files. Relax the
gate: drop `adminOnlyInMulti`; gate on the `subscriptions` entitlement
(tier) instead, so paid tenants get in. Update `canUseSubscriptions()` to
`hasEntitlement("subscriptions")` (drop the `!isMulti||isAdmin` clause) and
revert the operator-only frontend branches.
### 3. Rescope the check loop (moderate)
`_checkSubscriptionsInner()` becomes per-scope. Enumerate scopes that have a
subscriptions file (readdir `history/`, keep subdirs whose `subscriptions.json`
is non-empty; plus "owner"). For each: load that scope's subs, dedup via
`getProcessedVideoIds(scope)` + the scope's skip/seen/queue, append to that
scope's auto-queue. The boot + hourly timers iterate all scopes.
### 4. Fix the processor identity (the risky bit — needs on-device test)
`backgroundProcessor()` + `processItemInternally(item)` must process each
item **as its owner**. The item already can carry `scope`/`ownerUserId`.
**Recommended: ephemeral session.** Before the loopback request, mint a
short-lived row in the existing `sessions` table for the owner, send its
token as the `Cookie`, then delete the row in a `finally`. tenant-auth then
resolves `req.user` = owner normally → correct scope, credits, relay
identity. Reuses real auth (no new trust path). **Failure mode is safe:** a
bad/missing session just makes the internal request 401 → the item is
marked `failed`, never an auth hole. Single mode unchanged (no auth, scope
already "owner").
*Alternative:* extract the core of the `/api/process` handler into a
function callable in-process with an explicit `{scope, identity}` (no HTTP,
no cookie). Cleaner long-term but a large refactor of a big handler — more
risk of behavioral drift. Prefer the ephemeral session first.
The single global processor loop stays fine — it just pulls approved items
across all scopes (each item knows its owner) and processes sequentially
with the existing inter-item delay.
### 5. Migration (one-time, on boot)
Move the existing history-root files
(`subscriptions.json`/`auto-queue.json`/`skip-list.json`/`seen-list.json`)
into `history/owner/` (the admin's scope) once, if present and the target
doesn't exist. Preserves the operator's current subscriptions under their
own scope. Idempotent.
## On-device test checklist (gates "done")
1. Tenant A subscribes to a channel; tenant B sees **nothing** of A's.
2. A's discovered episode appears only in A's queue; approving it
summarizes under **A** (A's credits decremented, saved to A's library,
A's relay `user:<id>` pool billed).
3. Operator's own subscriptions still discover + auto-process.
4. Per-scope dedup: a video already in A's library is not re-queued for A;
the same video can still be independently queued for B.
5. Crash-recovery: items stuck `processing` resume under the right owner.
6. Single mode unaffected.
## Effort / risk
Steps 13 + 5: ~half a day, low risk, unit-testable offline. Step 4: the
real work — small code, but auth/billing-adjacent, so it carries the
testing burden. Land 13 behind the existing operator-only gate first
(no behavior change for tenants), verify, then flip the gate + ship 4.
+94
View File
@@ -0,0 +1,94 @@
# Self-Serve Pro/Max Purchase — Implementation Plan
**Status:** Phases 14 BUILT + INSTALLED + LIVE on immense-voyage.local
(relay 0.2.121, app 0.2.153) as of 2026-06-08. Bitcoin rail end-to-end test
pending; card rail awaits operator Zaprite config. Phase 5 (SMTP expiry
reminders) remains. Follows the core decoupling (the relay owns tiers,
keyed by user-id) — this lets users buy their own Pro/Max instead of the
operator granting them by hand.
**Phase 4 (cards via Zaprite) — DONE.** Symmetric with the Bitcoin rail
(one-time prepaid checkout, NOT Zaprite recurring): relay `zaprite-client.js`
(POST /v1/orders + GET /v1/orders/:id), `POST /relay/tier-zaprite-order`
(operator-keyed), `POST /relay/zaprite/webhook` (re-fetch-to-verify — no
signature needed; both rails land at extendUserTier). Config:
`relay_zaprite_{api_key,base_url,currency}` + `relay_tier_prices_fiat_cents_json`
(default $21/$42), set via the new "Set Zaprite Connection" StartOS action.
`/api/billing/buy` gained `method:"card"`; tier-plans reports `card_available`
+ fiat prices; UI shows "Pay by card · $21" only when Zaprite is configured.
Operator TODO: run "Set Zaprite Connection" (paste API key) + register the
webhook at `https://<relay-host>/relay/zaprite/webhook` in Zaprite.
## Decisions (from Grant)
- **Prepaid periods, NOT auto-recurring.** A user pays for a fixed period
(default **30 days**) of Pro/Max. Near expiry they get an email reminder
and pay again to extend. At expiry, the tier drops to Core. No stored
payment method, no card-vault / dunning.
- **Two payment rails:**
- **Bitcoin / Lightning via BTCPay** — the *preferred* path. Relay already
has `btcpay-client.js` (createInvoice + webhook HMAC) used for credit
purchases; extend it for tier purchases.
- **Cards via Zaprite** — secondary. Grant has a Zaprite org + API +
webhooks. New integration.
- **UI:** the main pill button is **"Pay with Bitcoin"** (opens a BTCPay
invoice). Directly below it, a smaller **"Pay by card"** link (opens a
Zaprite checkout).
- **Expiry reminders via email.** Set up SMTP (Amazon SES per the Start9
recommendation) and send an automated "your Pro/Max expires in N days"
email. Recaps already has an SMTP transport (magic-link emails).
- **Prices:** from the relay's `relay_tier_prices_usd_json` (today Pro $5 /
Max $15 per period), USD-denominated, paid in sats for BTCPay.
## Phases (each shippable on its own)
### Phase 1 — Relay: prepaid tier model + expiry enforcement (foundation)
Rail-agnostic. Everything else depends on it.
- `setUserTier({userId, tier, periodDays})` → set tier, set
`subscription_expires_at`. **Extend from the current expiry** if the user
is already active (so paying early adds time, doesn't reset it).
- **Enforce expiry:** when the relay resolves a user's tier (identityTier /
the metered-route gate), treat `subscription_expires_at < now` as Core.
Add a lazy check + a periodic sweep so expired users actually drop.
- Keep the operator-grant path (`/relay/user-tier`) working — it's the comp
tool. A manual grant can set no-expiry (operator comp) vs a purchase sets
a dated period.
### Phase 2 — Bitcoin/Lightning purchase (BTCPay)
- Relay: `POST /relay/tier-invoice` (operator-key authed) — body
`{user_id, tier, period_days}``createInvoice` with metadata
`{kind:"tier_subscription", userId, tier, periodDays}` → returns the
checkout URL + invoice id.
- Relay webhook: on a settled `tier_subscription` invoice → `setUserTier`
(extend by periodDays). (Mirrors the existing credit-purchase webhook
branch.)
- Recaps: a `pending_purchases`-style record + the settle→poll→cache-tier
loop (reuse the credit-purchase machinery). On settle, refresh
`/api/license-status` so the badge flips to Pro/Max.
### Phase 3 — Purchase UI
- Tier picker (Pro / Max, price, "30 days") with the **"Pay with Bitcoin"**
pill + **"Pay by card"** link. Reuse / replace the existing buy modal.
- Bitcoin → opens the BTCPay checkout (Phase 2). Card → opens Zaprite
(Phase 4).
### Phase 4 — Cards via Zaprite
- Relay (or Recaps): create a Zaprite checkout for the tier (Grant's org +
API), metadata carrying `{userId, tier, periodDays}`.
- Zaprite webhook → verify signature → `setUserTier` (extend). Same landing
point as the BTCPay webhook.
- Wire the "Pay by card" link to it.
### Phase 5 — Expiry reminder emails
- SMTP via SES (Grant sets up SES + StartOS System SMTP; Recaps' transport
already exists).
- Periodic job: find users with `subscription_expires_at` in ~N days, email
a "renew" notice with a link back to the purchase UI. Idempotent (don't
double-send).
## Notes / open defaults (sensible unless Grant says otherwise)
- Period = 30 days. Grace = none beyond the advance email (downgrade on
expiry). Extend-from-current-expiry on early renewal.
- Relay stays **`make install` only** (private — never registry-deploy).
- The operator-key path authenticates Recaps→relay for invoice creation, the
same as the tier-grant flow.