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

7.0 KiB
Raw Blame History

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.jsaddToSkipList(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.jsscopeForRequest(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.