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
+285
View File
@@ -0,0 +1,285 @@
// Subscription storage + discovery helpers, keyed by scope.
//
// Today the subscription feature is operator-only in multi mode (gated in
// license-middleware), so the only active scope is "owner". But the storage,
// dedup, check-enumeration, and migration here are all scope-parameterized,
// so flipping the gate to per-tenant (see docs/per-tenant-subscriptions-plan.md)
// is a matter of passing each user's scope instead of "owner" — no storage
// rework. Step 4 of that plan (the background processor acting as each
// owning user) is the only remaining piece and needs on-device testing.
//
// Each scope's state lives under scopeDir(scope) = history/<scope>/:
// subscriptions.json — the user's channel/podcast subscriptions
// auto-queue.json — discovered videos awaiting approval / processing
// skip-list.json — videoIds the user declined (never re-offer)
// seen-list.json — videoIds already offered (don't re-surface)
import fs from "fs/promises";
import path from "path";
import { getScopeHistoryDir, getHistoryDir, ROOT_SIDECARS } from "./history.js";
// ── Per-scope file paths ─────────────────────────────────────────────────
function subsPath(scope) {
return path.join(getScopeHistoryDir(scope), "subscriptions.json");
}
function skipPath(scope) {
return path.join(getScopeHistoryDir(scope), "skip-list.json");
}
function seenPath(scope) {
return path.join(getScopeHistoryDir(scope), "seen-list.json");
}
function autoQueuePath(scope) {
return path.join(getScopeHistoryDir(scope), "auto-queue.json");
}
async function ensureScopeDir(scope) {
await fs.mkdir(getScopeHistoryDir(scope), { recursive: true }).catch(() => {});
}
// Serialize read-modify-write on a given file path so two concurrent
// handlers can't each load the same snapshot, mutate, and have the second
// write clobber the first. Keyed by absolute path → naturally per-scope.
const _fileLocks = new Map();
function withFileLock(key, fn) {
const prev = _fileLocks.get(key) || Promise.resolve();
const next = prev.then(fn, fn); // run fn whether prev resolved or rejected
_fileLocks.set(
key,
next.catch(() => {}),
);
return next;
}
// ── Subscriptions ────────────────────────────────────────────────────────
export async function loadSubscriptions(scope) {
try {
return (
JSON.parse(await fs.readFile(subsPath(scope), "utf-8")).subscriptions || []
);
} catch {
return [];
}
}
export async function saveSubscriptions(scope, subs) {
return withFileLock(subsPath(scope), async () => {
await ensureScopeDir(scope);
await fs.writeFile(
subsPath(scope),
JSON.stringify({ subscriptions: subs }, null, 2),
);
});
}
// ── Skip list (declined videos — never re-add) ───────────────────────────
export async function loadSkipList(scope) {
try {
return new Set(
JSON.parse(await fs.readFile(skipPath(scope), "utf-8")).videoIds || [],
);
} catch {
return new Set();
}
}
export async function addToSkipList(scope, videoId) {
return withFileLock(skipPath(scope), async () => {
const ids = await loadSkipList(scope);
ids.add(videoId);
await ensureScopeDir(scope);
await fs.writeFile(skipPath(scope), JSON.stringify({ videoIds: [...ids] }));
});
}
// ── Seen list (already offered — don't re-surface) ───────────────────────
export async function loadSeenList(scope) {
try {
return new Set(
JSON.parse(await fs.readFile(seenPath(scope), "utf-8")).videoIds || [],
);
} catch {
return new Set();
}
}
export async function addToSeenList(scope, videoIds) {
return withFileLock(seenPath(scope), async () => {
const seen = await loadSeenList(scope);
for (const id of videoIds) seen.add(id);
await ensureScopeDir(scope);
await fs.writeFile(seenPath(scope), JSON.stringify({ videoIds: [...seen] }));
});
}
// ── Auto-queue ───────────────────────────────────────────────────────────
// Read-only load. For mutations use mutateAutoQueue so the read-modify-write
// is atomic per scope (the old in-memory global array gave this implicitly).
export async function loadAutoQueue(scope) {
try {
return JSON.parse(await fs.readFile(autoQueuePath(scope), "utf-8")).items || [];
} catch {
return [];
}
}
export async function saveAutoQueue(scope, items) {
return withFileLock(autoQueuePath(scope), async () => {
await ensureScopeDir(scope);
await fs.writeFile(
autoQueuePath(scope),
JSON.stringify({ items }, null, 2),
);
});
}
// Atomic read-modify-write. `fn(items)` may mutate `items` in place and/or
// return a replacement array. Returns the saved array. Use for every
// status change / add / remove so concurrent handlers don't lose updates.
export async function mutateAutoQueue(scope, fn) {
return withFileLock(autoQueuePath(scope), async () => {
let items = [];
try {
items =
JSON.parse(await fs.readFile(autoQueuePath(scope), "utf-8")).items || [];
} catch {}
const result = await fn(items);
const toSave = Array.isArray(result) ? result : items;
await ensureScopeDir(scope);
await fs.writeFile(
autoQueuePath(scope),
JSON.stringify({ items: toSave }, null, 2),
);
return toSave;
});
}
// ── Dedup ────────────────────────────────────────────────────────────────
// All videoIds already summarized in a scope's library.
//
// CRITICAL: summaries live under scopeDir(scope) = history/<scope>/, NOT the
// top-level history dir. Scanning the top level (the historical bug) found
// zero processed videos, so the subscription check never deduped against the
// library and re-queued already-summarized videos every run.
export async function getProcessedVideoIds(scope = "owner") {
const ids = new Set();
const dir = getScopeHistoryDir(scope);
try {
const files = await fs.readdir(dir);
for (const file of files.filter(
(f) => f.endsWith(".json") && !ROOT_SIDECARS.has(f),
)) {
try {
const raw = await fs.readFile(path.join(dir, file), "utf-8");
const data = JSON.parse(raw);
if (data.videoId) ids.add(data.videoId);
} catch {}
}
} catch {}
return ids;
}
// Pure dedup predicate: is this discovered video already accounted for?
// Known = in the library (processed), already queued, declined (skip), or
// offered before (seen). Used identically by the podcast + YouTube branches.
export function isKnownVideo(
id,
{ processedIds, queuedIds, skippedIds, seenIds } = {},
) {
return !!(
(processedIds && processedIds.has(id)) ||
(queuedIds && queuedIds.has(id)) ||
(skippedIds && skippedIds.has(id)) ||
(seenIds && seenIds.has(id))
);
}
// ── Scope enumeration (for the periodic check loop) ──────────────────────
// Scopes that have at least one subscription. Always includes "owner" (the
// operator). Behind the operator-only gate this returns just ["owner"]; when
// per-tenant subscriptions ship it picks up each tenant scope automatically.
export async function listSubscriptionScopes() {
const root = getHistoryDir();
const scopes = new Set(["owner"]);
try {
const entries = await fs.readdir(root, { withFileTypes: true });
for (const e of entries) {
if (!e.isDirectory() || e.name === "owner") continue;
try {
const subs =
JSON.parse(
await fs.readFile(
path.join(root, e.name, "subscriptions.json"),
"utf-8",
),
).subscriptions || [];
if (subs.length > 0) scopes.add(e.name);
} catch {}
}
} catch {}
return [...scopes];
}
// Scopes that have a non-empty auto-queue. The background processor walks
// these to find approved items across all owners (a scope can have queued
// items even after its subscriptions were deleted, so this is a superset of
// listSubscriptionScopes for processing purposes).
export async function listAutoQueueScopes() {
const root = getHistoryDir();
const scopes = new Set(["owner"]);
try {
const entries = await fs.readdir(root, { withFileTypes: true });
for (const e of entries) {
if (!e.isDirectory() || e.name === "owner") continue;
try {
const items =
JSON.parse(
await fs.readFile(
path.join(root, e.name, "auto-queue.json"),
"utf-8",
),
).items || [];
if (items.length > 0) scopes.add(e.name);
} catch {}
}
} catch {}
return [...scopes];
}
// ── Migration: history-root globals → owner scope (one-time, idempotent) ──
// The pre-0.2.147 layout kept subscription state at the history root (one
// install-wide store). Move it under the operator's own scope so the
// storage is uniformly per-scope. Only moves a file if the source exists
// and the destination doesn't (never clobbers).
const SUB_FILES = [
"subscriptions.json",
"auto-queue.json",
"skip-list.json",
"seen-list.json",
];
export async function migrateGlobalSubscriptionsToOwner() {
const root = getHistoryDir();
const ownerDir = getScopeHistoryDir("owner");
let moved = 0;
for (const f of SUB_FILES) {
const from = path.join(root, f);
const to = path.join(ownerDir, f);
try {
await fs.access(from);
} catch {
continue; // source missing → nothing to move
}
try {
await fs.access(to);
continue; // dest already exists → don't clobber
} catch {}
try {
await fs.mkdir(ownerDir, { recursive: true });
await fs.rename(from, to);
moved++;
} catch {
// rename can fail across devices — fall back to copy + unlink.
try {
await fs.mkdir(ownerDir, { recursive: true });
await fs.copyFile(from, to);
await fs.unlink(from);
moved++;
} catch {}
}
}
return moved;
}