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
+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.