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