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
+343
View File
@@ -0,0 +1,343 @@
import { test, describe } from "node:test";
import { strict as assert } from "node:assert";
import { mkdtempSync, mkdirSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import path from "node:path";
import { readFileSync, existsSync } from "node:fs";
import { initHistory, getScopeHistoryDir, getHistoryDir } from "../history.js";
import {
getProcessedVideoIds,
isKnownVideo,
loadSubscriptions,
saveSubscriptions,
loadSkipList,
addToSkipList,
loadSeenList,
addToSeenList,
loadAutoQueue,
saveAutoQueue,
mutateAutoQueue,
listSubscriptionScopes,
migrateGlobalSubscriptionsToOwner,
} from "../subscriptions.js";
// ── isKnownVideo (pure dedup predicate) ──────────────────────────────────
describe("isKnownVideo", () => {
const sets = {
processedIds: new Set(["P1"]),
queuedIds: new Set(["Q1"]),
skippedIds: new Set(["S1"]),
seenIds: new Set(["N1"]),
};
test("true when the id is in the library (processed)", () => {
assert.equal(isKnownVideo("P1", sets), true);
});
test("true when the id is already queued", () => {
assert.equal(isKnownVideo("Q1", sets), true);
});
test("true when the id was declined (skip list)", () => {
assert.equal(isKnownVideo("S1", sets), true);
});
test("true when the id was offered before (seen list)", () => {
assert.equal(isKnownVideo("N1", sets), true);
});
test("false for a genuinely new id", () => {
assert.equal(isKnownVideo("NEW", sets), false);
});
test("returns a real boolean, not a Set/undefined", () => {
assert.equal(typeof isKnownVideo("NEW", sets), "boolean");
assert.equal(typeof isKnownVideo("P1", sets), "boolean");
});
test("tolerates missing/partial set bags", () => {
assert.equal(isKnownVideo("X", {}), false);
assert.equal(isKnownVideo("X", undefined), false);
assert.equal(isKnownVideo("X", { processedIds: new Set(["X"]) }), true);
});
});
// ── getProcessedVideoIds (scope-aware library scan) ──────────────────────
// This is the function whose wrong-directory bug caused the auto-queue to
// re-offer already-summarized videos: it must scan history/<scope>/, NOT
// the top-level history dir, and must not leak ids across scopes.
describe("getProcessedVideoIds", () => {
function freshDataDir() {
const dataDir = mkdtempSync(path.join(tmpdir(), "recap-subs-"));
return dataDir;
}
test("scans the scope's library dir and collects videoIds", async () => {
const dataDir = freshDataDir();
await initHistory({ dataDir, mode: "multi" });
const ownerDir = getScopeHistoryDir("owner");
mkdirSync(ownerDir, { recursive: true });
writeFileSync(path.join(ownerDir, "a.json"), JSON.stringify({ videoId: "VID_A" }));
writeFileSync(path.join(ownerDir, "b.json"), JSON.stringify({ videoId: "VID_B" }));
const ids = await getProcessedVideoIds("owner");
assert.equal(ids.size, 2);
assert.ok(ids.has("VID_A"));
assert.ok(ids.has("VID_B"));
});
test("does NOT leak ids across scopes (per-tenant isolation)", async () => {
const dataDir = freshDataDir();
await initHistory({ dataDir, mode: "multi" });
const ownerDir = getScopeHistoryDir("owner");
const tenantDir = getScopeHistoryDir("tenant123");
mkdirSync(ownerDir, { recursive: true });
mkdirSync(tenantDir, { recursive: true });
writeFileSync(path.join(ownerDir, "a.json"), JSON.stringify({ videoId: "OWNER_VID" }));
writeFileSync(path.join(tenantDir, "c.json"), JSON.stringify({ videoId: "TENANT_VID" }));
const ownerIds = await getProcessedVideoIds("owner");
assert.ok(ownerIds.has("OWNER_VID"));
assert.equal(ownerIds.has("TENANT_VID"), false);
});
test("ignores _meta.json and non-JSON / malformed files", async () => {
const dataDir = freshDataDir();
await initHistory({ dataDir, mode: "multi" });
const ownerDir = getScopeHistoryDir("owner");
mkdirSync(ownerDir, { recursive: true });
writeFileSync(path.join(ownerDir, "good.json"), JSON.stringify({ videoId: "GOOD" }));
writeFileSync(path.join(ownerDir, "_meta.json"), JSON.stringify({ folders: [], uncategorized: [] }));
writeFileSync(path.join(ownerDir, "notes.txt"), "videoId: NOPE");
writeFileSync(path.join(ownerDir, "broken.json"), "{ not valid json");
writeFileSync(path.join(ownerDir, "nofield.json"), JSON.stringify({ title: "no videoId here" }));
const ids = await getProcessedVideoIds("owner");
assert.deepEqual([...ids], ["GOOD"]);
});
test("returns an empty set for a scope with no library yet", async () => {
const dataDir = freshDataDir();
await initHistory({ dataDir, mode: "multi" });
const ids = await getProcessedVideoIds("never-summarized");
assert.equal(ids.size, 0);
});
test("defaults to the owner scope when none is passed", async () => {
const dataDir = freshDataDir();
await initHistory({ dataDir, mode: "single" });
const ownerDir = getScopeHistoryDir("owner");
mkdirSync(ownerDir, { recursive: true });
writeFileSync(path.join(ownerDir, "a.json"), JSON.stringify({ videoId: "DEFAULT_OWNER" }));
const ids = await getProcessedVideoIds(); // no arg → "owner"
assert.ok(ids.has("DEFAULT_OWNER"));
});
// Regression: after the 0.2.147 migration the subscription sidecar files
// live inside the scope dir. They must NOT be treated as session records
// (that produced the phantom "Invalid Date · undefined topics" library
// entry). Verify the ROOT_SIDECARS filter skips them even if one happens
// to carry a top-level videoId.
test("skips subscription sidecar files when scanning the library", async () => {
const dataDir = freshDataDir();
await initHistory({ dataDir, mode: "multi" });
const ownerDir = getScopeHistoryDir("owner");
mkdirSync(ownerDir, { recursive: true });
writeFileSync(path.join(ownerDir, "real.json"), JSON.stringify({ videoId: "REAL" }));
for (const f of ["subscriptions.json", "auto-queue.json", "skip-list.json", "seen-list.json"]) {
// Even with a (bogus) top-level videoId, a sidecar must be ignored.
writeFileSync(path.join(ownerDir, f), JSON.stringify({ videoId: "SIDECAR_" + f, items: [] }));
}
const ids = await getProcessedVideoIds("owner");
assert.deepEqual([...ids], ["REAL"]);
});
});
// ── Per-scope storage (subscriptions / skip / seen / auto-queue) ─────────
describe("per-scope storage", () => {
function fresh() {
return mkdtempSync(path.join(tmpdir(), "recap-store-"));
}
test("subscriptions round-trip and stay isolated per scope", async () => {
await initHistory({ dataDir: fresh(), mode: "multi" });
assert.deepEqual(await loadSubscriptions("owner"), []); // empty default
await saveSubscriptions("owner", [{ id: "s1", url: "u1" }]);
await saveSubscriptions("tenantA", [{ id: "s2", url: "u2" }]);
assert.equal((await loadSubscriptions("owner")).length, 1);
assert.equal((await loadSubscriptions("owner"))[0].id, "s1");
assert.equal((await loadSubscriptions("tenantA"))[0].id, "s2");
});
test("skip + seen lists are per-scope Sets", async () => {
await initHistory({ dataDir: fresh(), mode: "multi" });
await addToSkipList("owner", "VID1");
await addToSkipList("owner", "VID2");
await addToSeenList("owner", ["S1", "S2"]);
const skip = await loadSkipList("owner");
const seen = await loadSeenList("owner");
assert.ok(skip.has("VID1") && skip.has("VID2"));
assert.ok(seen.has("S1") && seen.has("S2"));
assert.equal((await loadSkipList("tenantA")).size, 0); // isolated
});
test("auto-queue save/load round-trips", async () => {
await initHistory({ dataDir: fresh(), mode: "multi" });
await saveAutoQueue("owner", [{ id: "a", status: "pending" }]);
const q = await loadAutoQueue("owner");
assert.equal(q.length, 1);
assert.equal(q[0].status, "pending");
});
test("mutateAutoQueue serializes concurrent read-modify-writes (no lost updates)", async () => {
await initHistory({ dataDir: fresh(), mode: "multi" });
await saveAutoQueue("owner", []);
// Fire 20 concurrent appends. Under a naive load→mutate→save these would
// clobber each other; mutateAutoQueue must serialize them.
await Promise.all(
Array.from({ length: 20 }, (_, i) =>
mutateAutoQueue("owner", (items) => {
items.push({ id: `item-${i}` });
}),
),
);
const q = await loadAutoQueue("owner");
assert.equal(q.length, 20, "all 20 concurrent appends survived");
assert.equal(new Set(q.map((x) => x.id)).size, 20, "no duplicates/drops");
});
test("mutateAutoQueue can replace the array (filter/remove)", async () => {
await initHistory({ dataDir: fresh(), mode: "multi" });
await saveAutoQueue("owner", [{ id: "keep" }, { id: "drop" }]);
await mutateAutoQueue("owner", (items) => items.filter((x) => x.id !== "drop"));
const q = await loadAutoQueue("owner");
assert.deepEqual(q.map((x) => x.id), ["keep"]);
});
});
// ── Auto-queue endpoint mutation patterns ────────────────────────────────
// The /api/auto-queue/* handlers run these exact callbacks through
// mutateAutoQueue. Verify the logic directly (the HTTP layer just adds the
// license gate + scope resolution, covered elsewhere).
describe("auto-queue endpoint mutation logic", () => {
async function seed(items) {
await initHistory({ dataDir: mkdtempSync(path.join(tmpdir(), "recap-ep-")), mode: "multi" });
await saveAutoQueue("owner", items);
}
test("approve: pending → approved; rejects non-pending", async () => {
await seed([{ id: "x", status: "pending" }, { id: "y", status: "completed" }]);
// approve x
let item = null, badStatus = null;
await mutateAutoQueue("owner", (items) => {
const it = items.find((q) => q.id === "x");
if (!it) return;
if (it.status !== "pending") { badStatus = it.status; return; }
it.status = "approved";
item = { ...it };
});
assert.equal(badStatus, null);
assert.equal(item.status, "approved");
assert.equal((await loadAutoQueue("owner")).find((q) => q.id === "x").status, "approved");
// approving y (completed) is rejected
badStatus = null;
await mutateAutoQueue("owner", (items) => {
const it = items.find((q) => q.id === "y");
if (it && it.status !== "pending") badStatus = it.status;
});
assert.equal(badStatus, "completed");
});
test("skip: captures videoId then removes the item", async () => {
await seed([{ id: "x", videoId: "VID_X", status: "pending" }, { id: "z", status: "pending" }]);
let videoId = null;
await mutateAutoQueue("owner", (items) => {
const it = items.find((q) => q.id === "x");
if (it && it.videoId) videoId = it.videoId;
return items.filter((q) => q.id !== "x");
});
assert.equal(videoId, "VID_X");
const q = await loadAutoQueue("owner");
assert.deepEqual(q.map((i) => i.id), ["z"]);
});
test("clear-finished: drops completed + failed, keeps active", async () => {
await seed([
{ id: "a", status: "pending" },
{ id: "b", status: "completed" },
{ id: "c", status: "failed" },
{ id: "d", status: "approved" },
]);
let removed = 0;
await mutateAutoQueue("owner", (items) => {
const before = items.length;
const kept = items.filter((q) => !["completed", "failed"].includes(q.status));
removed = before - kept.length;
return kept;
});
assert.equal(removed, 2);
assert.deepEqual((await loadAutoQueue("owner")).map((i) => i.id).sort(), ["a", "d"]);
});
test("delete-by-subscription: removes that sub's items only", async () => {
await seed([
{ id: "a", subscriptionId: "sub1", status: "pending" },
{ id: "b", subscriptionId: "sub2", status: "pending" },
{ id: "c", subscriptionId: "sub1", status: "approved" },
]);
await mutateAutoQueue("owner", (items) => items.filter((q) => q.subscriptionId !== "sub1"));
assert.deepEqual((await loadAutoQueue("owner")).map((i) => i.id), ["b"]);
});
});
// ── Scope enumeration ────────────────────────────────────────────────────
describe("listSubscriptionScopes", () => {
test("always includes owner; adds scopes with non-empty subscriptions", async () => {
await initHistory({ dataDir: mkdtempSync(path.join(tmpdir(), "recap-enum-")), mode: "multi" });
await saveSubscriptions("tenantA", [{ id: "s", url: "u" }]);
await saveSubscriptions("tenantB", []); // empty → excluded
const scopes = await listSubscriptionScopes();
assert.ok(scopes.includes("owner"));
assert.ok(scopes.includes("tenantA"));
assert.ok(!scopes.includes("tenantB"));
});
test("returns just owner when nobody has subscriptions", async () => {
await initHistory({ dataDir: mkdtempSync(path.join(tmpdir(), "recap-enum2-")), mode: "multi" });
const scopes = await listSubscriptionScopes();
assert.deepEqual(scopes, ["owner"]);
});
});
// ── Migration: history-root globals → owner scope ────────────────────────
describe("migrateGlobalSubscriptionsToOwner", () => {
test("moves root files into owner/, idempotently, without clobbering", async () => {
const dataDir = mkdtempSync(path.join(tmpdir(), "recap-mig-"));
await initHistory({ dataDir, mode: "multi" });
const root = getHistoryDir();
mkdirSync(root, { recursive: true });
// Legacy global files at the history root.
writeFileSync(path.join(root, "subscriptions.json"), JSON.stringify({ subscriptions: [{ id: "s1" }] }));
writeFileSync(path.join(root, "auto-queue.json"), JSON.stringify({ items: [{ id: "q1" }] }));
const moved = await migrateGlobalSubscriptionsToOwner();
assert.equal(moved, 2);
// Now readable via the owner scope.
assert.equal((await loadSubscriptions("owner"))[0].id, "s1");
assert.equal((await loadAutoQueue("owner"))[0].id, "q1");
// Source files gone.
assert.equal(existsSync(path.join(root, "subscriptions.json")), false);
// Re-running is a no-op (nothing left to move).
assert.equal(await migrateGlobalSubscriptionsToOwner(), 0);
});
test("does not clobber an existing owner-scope file", async () => {
const dataDir = mkdtempSync(path.join(tmpdir(), "recap-mig2-"));
await initHistory({ dataDir, mode: "multi" });
const root = getHistoryDir();
const ownerDir = getScopeHistoryDir("owner");
mkdirSync(ownerDir, { recursive: true });
// Owner already has subscriptions; a stale root file must NOT overwrite.
writeFileSync(path.join(ownerDir, "subscriptions.json"), JSON.stringify({ subscriptions: [{ id: "KEEP" }] }));
writeFileSync(path.join(root, "subscriptions.json"), JSON.stringify({ subscriptions: [{ id: "STALE" }] }));
await migrateGlobalSubscriptionsToOwner();
assert.equal((await loadSubscriptions("owner"))[0].id, "KEEP");
});
});