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:
+87
-38
@@ -4,45 +4,88 @@
|
||||
// directly because library import predates the subscriptions module
|
||||
// having a public 'merge' helper (and the merge logic is library-
|
||||
// specific anyway).
|
||||
//
|
||||
// As of 0.2.77 (multi-tenant): all reads/writes are scoped to the
|
||||
// requesting user via scopeForRequest(req). In single mode the scope
|
||||
// is always "owner" — preserved single-user behavior. In multi mode
|
||||
// each tenant exports/imports their own library; the operator's
|
||||
// admin status doesn't grant cross-user export (a separate operator-
|
||||
// only "export everyone's library" endpoint can be added later if
|
||||
// the operator ever needs it).
|
||||
//
|
||||
// Subscriptions remain global (one /data/history/subscriptions.json
|
||||
// per install) for now. Per-user subscriptions are a Phase 1D/2 task.
|
||||
|
||||
import fs from "fs/promises";
|
||||
import path from "path";
|
||||
import express from "express";
|
||||
import { getHistoryDir, loadMeta, saveMeta } from "./history.js";
|
||||
import {
|
||||
getHistoryDir,
|
||||
getScopeHistoryDir,
|
||||
loadMeta,
|
||||
saveMeta,
|
||||
scopeForRequest,
|
||||
ROOT_SIDECARS,
|
||||
} from "./history.js";
|
||||
|
||||
// ── Routes ──────────────────────────────────────────────────────────────────
|
||||
// Both routes are gated by the Pro 'library' entitlement (the gate runs
|
||||
// upstream in license-middleware.js). They assume initHistory() has
|
||||
// already been called.
|
||||
export function setupLibraryRoutes(app) {
|
||||
function requireScope(req, res) {
|
||||
try {
|
||||
return scopeForRequest(req);
|
||||
} catch {
|
||||
res.status(401).json({ error: "auth_required" });
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// Bulk export everything: meta, sessions, subscriptions.
|
||||
app.get("/api/library/export", async (req, res) => {
|
||||
const scope = requireScope(req, res);
|
||||
if (!scope) return;
|
||||
try {
|
||||
const historyDir = getHistoryDir();
|
||||
const meta = await loadMeta();
|
||||
const files = await fs.readdir(historyDir);
|
||||
const scopeDir = getScopeHistoryDir(scope);
|
||||
const meta = await loadMeta(scope);
|
||||
let files = [];
|
||||
try {
|
||||
files = await fs.readdir(scopeDir);
|
||||
} catch {
|
||||
files = [];
|
||||
}
|
||||
const sessions = {};
|
||||
for (const file of files) {
|
||||
if (
|
||||
!file.endsWith(".json") ||
|
||||
file === "_meta.json" ||
|
||||
file === "subscriptions.json" ||
|
||||
file === "auto-queue.json" ||
|
||||
file === "skip-list.json" ||
|
||||
file === "seen-list.json"
|
||||
) continue;
|
||||
// Skip non-sessions: meta + the subscription sidecar files that now
|
||||
// live inside the scope dir (else they'd export as phantom sessions).
|
||||
if (!file.endsWith(".json") || ROOT_SIDECARS.has(file)) continue;
|
||||
try {
|
||||
const raw = await fs.readFile(path.join(historyDir, file), "utf-8");
|
||||
const raw = await fs.readFile(path.join(scopeDir, file), "utf-8");
|
||||
const id = file.replace(".json", "");
|
||||
sessions[id] = JSON.parse(raw);
|
||||
} catch {}
|
||||
}
|
||||
// Subscriptions live at the install-wide history root and are
|
||||
// operator-owned (single global store). Only the operator exports
|
||||
// them — in single mode (the operator owns the box) or, in multi
|
||||
// mode, the admin. A non-admin tenant's export must NOT leak the
|
||||
// operator's subscription list. Future per-user subscriptions move
|
||||
// this into the scope dir.
|
||||
let subscriptions = [];
|
||||
try {
|
||||
subscriptions = JSON.parse(
|
||||
await fs.readFile(path.join(historyDir, "subscriptions.json"), "utf-8")
|
||||
).subscriptions || [];
|
||||
} catch {}
|
||||
const ownsSubscriptions =
|
||||
req.recapMode !== "multi" || !!(req.user && req.user.is_admin);
|
||||
if (ownsSubscriptions) {
|
||||
try {
|
||||
subscriptions =
|
||||
JSON.parse(
|
||||
await fs.readFile(
|
||||
path.join(getHistoryDir(), "subscriptions.json"),
|
||||
"utf-8",
|
||||
),
|
||||
).subscriptions || [];
|
||||
} catch {}
|
||||
}
|
||||
|
||||
const exportData = {
|
||||
version: 1,
|
||||
@@ -54,7 +97,7 @@ export function setupLibraryRoutes(app) {
|
||||
res.setHeader("Content-Type", "application/json");
|
||||
res.setHeader(
|
||||
"Content-Disposition",
|
||||
'attachment; filename="recap-library.json"'
|
||||
'attachment; filename="recap-library.json"',
|
||||
);
|
||||
res.json(exportData);
|
||||
} catch (err) {
|
||||
@@ -68,6 +111,8 @@ export function setupLibraryRoutes(app) {
|
||||
"/api/library/import",
|
||||
express.json({ limit: "200mb" }),
|
||||
async (req, res) => {
|
||||
const scope = requireScope(req, res);
|
||||
if (!scope) return;
|
||||
try {
|
||||
const data = req.body;
|
||||
if (!data || !data.sessions) {
|
||||
@@ -76,13 +121,15 @@ export function setupLibraryRoutes(app) {
|
||||
.json({ error: "Invalid library file — missing sessions data" });
|
||||
}
|
||||
|
||||
const historyDir = getHistoryDir();
|
||||
const scopeDir = getScopeHistoryDir(scope);
|
||||
await fs.mkdir(scopeDir, { recursive: true }).catch(() => {});
|
||||
|
||||
let imported = 0;
|
||||
let skipped = 0;
|
||||
|
||||
// Sessions — skip if already present.
|
||||
for (const [id, session] of Object.entries(data.sessions)) {
|
||||
const filePath = path.join(historyDir, `${id}.json`);
|
||||
const filePath = path.join(scopeDir, `${id}.json`);
|
||||
try {
|
||||
await fs.access(filePath);
|
||||
skipped++;
|
||||
@@ -94,20 +141,20 @@ export function setupLibraryRoutes(app) {
|
||||
|
||||
// Meta — merge folders + add new uncategorized at the top.
|
||||
if (data.meta) {
|
||||
const existingMeta = await loadMeta();
|
||||
const existingMeta = await loadMeta(scope);
|
||||
const allExistingIds = new Set([
|
||||
...existingMeta.uncategorized,
|
||||
...existingMeta.folders.flatMap(f => f.items),
|
||||
...existingMeta.folders.flatMap((f) => f.items),
|
||||
]);
|
||||
|
||||
if (data.meta.folders) {
|
||||
for (const folder of data.meta.folders) {
|
||||
const existingFolder = existingMeta.folders.find(
|
||||
f => f.id === folder.id
|
||||
(f) => f.id === folder.id,
|
||||
);
|
||||
if (!existingFolder) {
|
||||
existingMeta.folders.push(folder);
|
||||
folder.items.forEach(id => allExistingIds.add(id));
|
||||
folder.items.forEach((id) => allExistingIds.add(id));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -120,29 +167,31 @@ export function setupLibraryRoutes(app) {
|
||||
}
|
||||
}
|
||||
|
||||
await saveMeta(existingMeta);
|
||||
await saveMeta(scope, existingMeta);
|
||||
}
|
||||
|
||||
// Subscriptions — merge, dedupe by URL.
|
||||
if (data.subscriptions && data.subscriptions.length > 0) {
|
||||
// Subscriptions — install-wide + operator-owned. Only the operator
|
||||
// (single mode, or multi-mode admin) may import them; otherwise a
|
||||
// tenant's import would inject into the operator's global list.
|
||||
const ownsSubscriptions =
|
||||
req.recapMode !== "multi" || !!(req.user && req.user.is_admin);
|
||||
if (ownsSubscriptions && data.subscriptions && data.subscriptions.length > 0) {
|
||||
const subsPath = path.join(getHistoryDir(), "subscriptions.json");
|
||||
let existingSubs = [];
|
||||
try {
|
||||
existingSubs = JSON.parse(
|
||||
await fs.readFile(
|
||||
path.join(historyDir, "subscriptions.json"),
|
||||
"utf-8"
|
||||
)
|
||||
).subscriptions || [];
|
||||
existingSubs =
|
||||
JSON.parse(await fs.readFile(subsPath, "utf-8"))
|
||||
.subscriptions || [];
|
||||
} catch {}
|
||||
const existingUrls = new Set(existingSubs.map(s => s.url));
|
||||
const existingUrls = new Set(existingSubs.map((s) => s.url));
|
||||
for (const sub of data.subscriptions) {
|
||||
if (!existingUrls.has(sub.url)) {
|
||||
existingSubs.push(sub);
|
||||
}
|
||||
}
|
||||
await fs.writeFile(
|
||||
path.join(historyDir, "subscriptions.json"),
|
||||
JSON.stringify({ subscriptions: existingSubs })
|
||||
subsPath,
|
||||
JSON.stringify({ subscriptions: existingSubs }),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -155,6 +204,6 @@ export function setupLibraryRoutes(app) {
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user