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
7.0 KiB
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-lockedmutateAutoQueue(atomic read-modify-write, replacing the global in-memoryautoQueue),listSubscriptionScopes(),migrateGlobalSubscriptionsToOwner(), plus the dedup (getProcessedVideoIds,isKnownVideo).index.js— the check loop fans out overlistSubscriptionScopes()into a per-scopecheckScopeSubscriptions(scope); every endpoint resolvesscope = subScope(req)(= scopeForRequest, "owner" for the operator); the processor + boot recovery usemutateAutoQueue; 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 ascope, so per-tenant dedup is free.history.js—scopeForRequest(req)(→ "owner" for admin/single, elsesafeComponent(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")
- Tenant A subscribes to a channel; tenant B sees nothing of A's.
- 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). - Operator's own subscriptions still discover + auto-process.
- 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.
- Crash-recovery: items stuck
processingresume under the right owner. - Single mode unaffected.
Effort / risk
Steps 1–3 + 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 1–3 behind the existing operator-only gate first (no behavior change for tenants), verify, then flip the gate + ship 4.