Files
recap/docs/per-tenant-subscriptions-plan.md
T
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

132 lines
7.0 KiB
Markdown
Raw 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.
# 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.