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:
+21
-11
@@ -43,8 +43,14 @@ describe("initHistory + getHistoryDir", () => {
|
||||
});
|
||||
|
||||
describe("saveToHistory", () => {
|
||||
// saveToHistory is scope-aware: it takes a `scope` first and writes under
|
||||
// history/<scope>/. Tests use the "owner" scope (single-mode / operator).
|
||||
const SCOPE = "owner";
|
||||
const scopeFile = (id) => path.join(historyDir, SCOPE, `${id}.json`);
|
||||
|
||||
test("returns an id and writes a file with the expected shape", async () => {
|
||||
const id = await history.saveToHistory(
|
||||
SCOPE,
|
||||
"videoId123",
|
||||
"https://youtu.be/videoId123",
|
||||
"My title",
|
||||
@@ -56,7 +62,7 @@ describe("saveToHistory", () => {
|
||||
);
|
||||
assert.match(id, /^\d+-videoId123$/);
|
||||
|
||||
const raw = await fs.readFile(path.join(historyDir, `${id}.json`), "utf-8");
|
||||
const raw = await fs.readFile(scopeFile(id), "utf-8");
|
||||
const record = JSON.parse(raw);
|
||||
assert.equal(record.id, id);
|
||||
assert.equal(record.videoId, "videoId123");
|
||||
@@ -74,6 +80,7 @@ describe("saveToHistory", () => {
|
||||
|
||||
test("falls back to 'Untitled' when title is empty", async () => {
|
||||
const id = await history.saveToHistory(
|
||||
SCOPE,
|
||||
"noTitleX",
|
||||
"url",
|
||||
"",
|
||||
@@ -83,20 +90,20 @@ describe("saveToHistory", () => {
|
||||
"",
|
||||
"youtube"
|
||||
);
|
||||
const raw = await fs.readFile(path.join(historyDir, `${id}.json`), "utf-8");
|
||||
const raw = await fs.readFile(scopeFile(id), "utf-8");
|
||||
const record = JSON.parse(raw);
|
||||
assert.equal(record.title, "Untitled");
|
||||
});
|
||||
|
||||
test("defaults type to 'youtube' when not specified", async () => {
|
||||
const id = await history.saveToHistory("vid", "url", "t", [], [], [], "", null);
|
||||
const raw = await fs.readFile(path.join(historyDir, `${id}.json`), "utf-8");
|
||||
const id = await history.saveToHistory(SCOPE, "vid", "url", "t", [], [], [], "", null);
|
||||
const raw = await fs.readFile(scopeFile(id), "utf-8");
|
||||
assert.equal(JSON.parse(raw).type, "youtube");
|
||||
});
|
||||
|
||||
test("encodes long podcast guids into a base64-truncated id suffix", async () => {
|
||||
const longGuid = "https://example.com/podcasts/feed.xml#episode-uuid-very-long-string";
|
||||
const id = await history.saveToHistory(longGuid, longGuid, "ep", [], [], [], "", "podcast");
|
||||
const id = await history.saveToHistory(SCOPE, longGuid, longGuid, "ep", [], [], [], "", "podcast");
|
||||
// suffix should be 16 base64 chars, not the raw URL
|
||||
assert.ok(!id.includes("https"));
|
||||
assert.match(id, /^\d+-[A-Za-z0-9_-]{16}$/);
|
||||
@@ -104,9 +111,10 @@ describe("saveToHistory", () => {
|
||||
});
|
||||
|
||||
describe("loadMeta + saveMeta", () => {
|
||||
// loadMeta/saveMeta are scope-aware (history/<scope>/_meta.json).
|
||||
test("loadMeta returns default empty shape when file missing", async () => {
|
||||
// Use a fresh sub-history to ensure no prior _meta.json
|
||||
const meta = await history.loadMeta();
|
||||
// A scope that has never been written → default shape.
|
||||
const meta = await history.loadMeta("never-written-scope");
|
||||
assert.ok(Array.isArray(meta.folders));
|
||||
assert.ok(Array.isArray(meta.uncategorized));
|
||||
});
|
||||
@@ -116,14 +124,16 @@ describe("loadMeta + saveMeta", () => {
|
||||
folders: [{ id: "f1", name: "Bitcoin podcasts", collapsed: false, items: ["s1", "s2"] }],
|
||||
uncategorized: ["s3"],
|
||||
};
|
||||
await history.saveMeta(original);
|
||||
const loaded = await history.loadMeta();
|
||||
await history.saveMeta("owner", original);
|
||||
const loaded = await history.loadMeta("owner");
|
||||
assert.deepEqual(loaded, original);
|
||||
});
|
||||
|
||||
test("loadMeta returns default when _meta.json is corrupt", async () => {
|
||||
await fs.writeFile(path.join(historyDir, "_meta.json"), "{ this is not json");
|
||||
const loaded = await history.loadMeta();
|
||||
const dir = path.join(historyDir, "corrupt-scope");
|
||||
await fs.mkdir(dir, { recursive: true });
|
||||
await fs.writeFile(path.join(dir, "_meta.json"), "{ this is not json");
|
||||
const loaded = await history.loadMeta("corrupt-scope");
|
||||
assert.deepEqual(loaded.folders, []);
|
||||
assert.deepEqual(loaded.uncategorized, []);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user