From ef8261d1fdd9272ef0de7113a3c654774894d190 Mon Sep 17 00:00:00 2001 From: DocNR Date: Sat, 9 May 2026 20:33:21 -0400 Subject: [PATCH] feat(proxy): add entitlements storage factory module MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Introduces the data layer for the premium-tier entitlement system (plan: ~/.claude/plans/i-d-like-to-create-eventual-moon.md). Pure addition — proxy.js does not import this yet, so behavior is unchanged in production. `relay-proxy/entitlements.js` mirrors the `clients.js` factory pattern with one key shape difference: storage is keyed by hex pubkey (object) rather than an array of pairs. Schema per entry: { tier: "free"|"premium", granted_at, granted_by?, expires_at, note?, devices_seen[] } Methods: - loadAll, getByPubkey, setEntitlement (upsert), revoke - tierForPubkey — defaults unknown to "free"; downgrades expired premium to "free" so Phase 2 time-bounded grants are forward-compat - recordDevice — abuse-tripwire device tracking, dedupes by token_prefix, no-ops for unknown pubkeys (free-tier doesn't accumulate device data) - auditMultiDevice — flags pubkeys exceeding N devices in a window - listByTier — for the grant CLI's `list` subcommand - maxAccountsForTier / maxClientsForTier — locked caps: free 4 accts / 5 clients → premium 10 / 30. Matches shipped baseline so premium is purely additive (no migration risk). Atomic temp+rename writes; defensive load tolerates missing / malformed / schema-drifted files. Pubkey keys validated against `/^[0-9a-f]{64}$/`. Tests: 32 new (`relay-proxy/test/entitlements.test.js`), full proxy suite green at 111/111 (79 existing + 32 new). Co-Authored-By: Claude Opus 4.7 (1M context) --- relay-proxy/entitlements.js | 188 +++++++++++++ relay-proxy/test/entitlements.test.js | 375 ++++++++++++++++++++++++++ 2 files changed, 563 insertions(+) create mode 100644 relay-proxy/entitlements.js create mode 100644 relay-proxy/test/entitlements.test.js diff --git a/relay-proxy/entitlements.js b/relay-proxy/entitlements.js new file mode 100644 index 0000000..3614743 --- /dev/null +++ b/relay-proxy/entitlements.js @@ -0,0 +1,188 @@ +const fs = require("node:fs"); + +const FREE_TIER_CAPS = Object.freeze({ + maxAccounts: 4, + maxClients: 5, +}); + +const PREMIUM_TIER_CAPS = Object.freeze({ + maxAccounts: 10, + maxClients: 30, +}); + +const VALID_TIERS = new Set(["free", "premium"]); +const HEX_PUBKEY_RE = /^[0-9a-f]{64}$/; + +// Compute effective tier from a stored entry, treating expired premium as free. +// expires_at: null → no expiry. expires_at: number → unix seconds; tier downgrades +// to "free" once now >= expires_at. Phase 1 only writes null; Phase 2 (Lightning +// time-bounded grants, if introduced) writes timestamps. +function effectiveTier(entry) { + if (!entry) return "free"; + if (entry.expires_at !== null && typeof entry.expires_at === "number") { + const now = Math.floor(Date.now() / 1000); + if (entry.expires_at <= now) return "free"; + } + return entry.tier; +} + +function createEntitlementsStorage(filePath) { + function isValidEntry(e) { + return e && typeof e === "object" + && typeof e.tier === "string" + && VALID_TIERS.has(e.tier) + && typeof e.granted_at === "number" + && (e.granted_by === undefined || typeof e.granted_by === "string") + && (e.expires_at === null || typeof e.expires_at === "number") + && (e.note === undefined || typeof e.note === "string") + && (e.devices_seen === undefined || Array.isArray(e.devices_seen)); + } + + function loadAll() { + if (!fs.existsSync(filePath)) return {}; + try { + const raw = fs.readFileSync(filePath, "utf8"); + if (!raw.trim()) return {}; + const data = JSON.parse(raw); + if (!data || typeof data !== "object" || Array.isArray(data)) return {}; + const filtered = {}; + for (const [pubkey, entry] of Object.entries(data)) { + if (HEX_PUBKEY_RE.test(pubkey) && isValidEntry(entry)) { + filtered[pubkey] = entry; + } + } + return filtered; + } catch { + return {}; + } + } + + function saveAll(entries) { + const tmp = filePath + ".tmp"; + fs.writeFileSync(tmp, JSON.stringify(entries, null, 2), { mode: 0o644 }); + fs.renameSync(tmp, filePath); + } + + function getByPubkey(pubkeyHex) { + return loadAll()[pubkeyHex] || null; + } + + // Upserts entitlement at pubkeyHex. Preserves granted_at and devices_seen + // across updates; granted_by/note are overwritten when supplied (use undefined + // to keep existing). Set expires_at: null for no-expiry; pass a number to + // override. Throws on invalid pubkey hex or tier. + function setEntitlement(pubkeyHex, fields) { + if (!HEX_PUBKEY_RE.test(pubkeyHex)) throw new Error("invalid_pubkey_hex"); + if (!fields || typeof fields !== "object") throw new Error("invalid_fields"); + const { tier, granted_by, note } = fields; + const expires_at = fields.expires_at !== undefined ? fields.expires_at : null; + if (!VALID_TIERS.has(tier)) throw new Error("invalid_tier"); + if (expires_at !== null && typeof expires_at !== "number") throw new Error("invalid_expires_at"); + + const all = loadAll(); + const existing = all[pubkeyHex]; + const now = Math.floor(Date.now() / 1000); + const next = { + tier, + granted_at: existing ? existing.granted_at : now, + expires_at, + devices_seen: existing && Array.isArray(existing.devices_seen) ? existing.devices_seen : [], + }; + const resolvedGrantedBy = granted_by !== undefined ? granted_by : existing && existing.granted_by; + const resolvedNote = note !== undefined ? note : existing && existing.note; + if (resolvedGrantedBy !== undefined) next.granted_by = resolvedGrantedBy; + if (resolvedNote !== undefined) next.note = resolvedNote; + all[pubkeyHex] = next; + saveAll(all); + return next; + } + + function revoke(pubkeyHex) { + const all = loadAll(); + if (!all[pubkeyHex]) return null; + const removed = all[pubkeyHex]; + delete all[pubkeyHex]; + saveAll(all); + return removed; + } + + function tierForPubkey(pubkeyHex) { + return effectiveTier(getByPubkey(pubkeyHex)); + } + + // Records a device's APNs token prefix seen for this pubkey. Returns true if + // recorded, false if pubkey has no entitlement (we don't auto-create) or + // tokenPrefix is empty. Dedupes by token_prefix and updates last_seen_at on + // existing entries. + function recordDevice(pubkeyHex, tokenPrefix) { + if (typeof tokenPrefix !== "string" || tokenPrefix.length === 0) return false; + const all = loadAll(); + if (!all[pubkeyHex]) return false; + const entry = all[pubkeyHex]; + if (!Array.isArray(entry.devices_seen)) entry.devices_seen = []; + const now = Math.floor(Date.now() / 1000); + const idx = entry.devices_seen.findIndex((d) => d && d.token_prefix === tokenPrefix); + if (idx >= 0) { + entry.devices_seen[idx].last_seen_at = now; + } else { + entry.devices_seen.push({ token_prefix: tokenPrefix, first_seen_at: now, last_seen_at: now }); + } + saveAll(all); + return true; + } + + // Returns pubkeys whose distinct device count within the past `withinDays` + // exceeds `thresholdDevices`. Used by the abuse-tripwire audit CLI. + function auditMultiDevice(thresholdDevices, withinDays) { + const cutoff = Math.floor(Date.now() / 1000) - withinDays * 86400; + const all = loadAll(); + const flagged = []; + for (const [pubkey, entry] of Object.entries(all)) { + if (!Array.isArray(entry.devices_seen)) continue; + const recent = entry.devices_seen.filter( + (d) => d && typeof d.last_seen_at === "number" && d.last_seen_at >= cutoff + ); + if (recent.length > thresholdDevices) { + flagged.push({ pubkey, deviceCount: recent.length, tier: effectiveTier(entry) }); + } + } + return flagged; + } + + function listByTier(tier) { + if (!VALID_TIERS.has(tier)) return []; + const all = loadAll(); + const out = []; + for (const [pubkey, entry] of Object.entries(all)) { + if (effectiveTier(entry) === tier) out.push({ pubkey, ...entry }); + } + return out; + } + + function maxAccountsForTier(tier) { + return tier === "premium" ? PREMIUM_TIER_CAPS.maxAccounts : FREE_TIER_CAPS.maxAccounts; + } + + function maxClientsForTier(tier) { + return tier === "premium" ? PREMIUM_TIER_CAPS.maxClients : FREE_TIER_CAPS.maxClients; + } + + return { + loadAll, + getByPubkey, + setEntitlement, + revoke, + tierForPubkey, + recordDevice, + auditMultiDevice, + listByTier, + maxAccountsForTier, + maxClientsForTier, + }; +} + +module.exports = { + createEntitlementsStorage, + FREE_TIER_CAPS, + PREMIUM_TIER_CAPS, +}; diff --git a/relay-proxy/test/entitlements.test.js b/relay-proxy/test/entitlements.test.js new file mode 100644 index 0000000..538874b --- /dev/null +++ b/relay-proxy/test/entitlements.test.js @@ -0,0 +1,375 @@ +const test = require("node:test"); +const assert = require("node:assert/strict"); +const fs = require("node:fs"); +const path = require("node:path"); +const os = require("node:os"); + +function tmpFile() { + return path.join(os.tmpdir(), `clave-entitlements-test-${Date.now()}-${Math.random()}.json`); +} + +const PUB_A = "a".repeat(64); +const PUB_B = "b".repeat(64); +const PUB_C = "c".repeat(64); +const ADMIN_NPUB_HEX = "1".repeat(64); + +// ---------- loadAll ---------- + +test("loadAll returns empty object when file doesn't exist", () => { + const file = tmpFile(); + const { createEntitlementsStorage } = require("../entitlements"); + const storage = createEntitlementsStorage(file); + assert.deepEqual(storage.loadAll(), {}); +}); + +test("loadAll ignores malformed JSON", () => { + const file = tmpFile(); + fs.writeFileSync(file, "not json {{{"); + const { createEntitlementsStorage } = require("../entitlements"); + const storage = createEntitlementsStorage(file); + assert.deepEqual(storage.loadAll(), {}); +}); + +test("loadAll returns {} when top-level value is an array (schema drift)", () => { + const file = tmpFile(); + fs.writeFileSync(file, JSON.stringify([{ tier: "premium", granted_at: 1 }])); + const { createEntitlementsStorage } = require("../entitlements"); + const storage = createEntitlementsStorage(file); + assert.deepEqual(storage.loadAll(), {}); +}); + +test("loadAll filters out non-hex pubkey keys", () => { + const file = tmpFile(); + fs.writeFileSync(file, JSON.stringify({ + [PUB_A]: { tier: "premium", granted_at: 1, expires_at: null }, + "not-a-pubkey": { tier: "premium", granted_at: 1, expires_at: null }, + "ABC123": { tier: "premium", granted_at: 1, expires_at: null }, + })); + const { createEntitlementsStorage } = require("../entitlements"); + const storage = createEntitlementsStorage(file); + const all = storage.loadAll(); + assert.deepEqual(Object.keys(all), [PUB_A]); +}); + +test("loadAll filters out entries missing required fields", () => { + const file = tmpFile(); + fs.writeFileSync(file, JSON.stringify({ + [PUB_A]: { tier: "premium", granted_at: 1, expires_at: null }, + [PUB_B]: { tier: "premium" }, // missing granted_at + expires_at + [PUB_C]: { tier: "platinum", granted_at: 1, expires_at: null }, // invalid tier + })); + const { createEntitlementsStorage } = require("../entitlements"); + const storage = createEntitlementsStorage(file); + const all = storage.loadAll(); + assert.deepEqual(Object.keys(all), [PUB_A]); +}); + +// ---------- setEntitlement ---------- + +test("setEntitlement creates new entry with granted_at + expires_at:null", () => { + const file = tmpFile(); + const { createEntitlementsStorage } = require("../entitlements"); + const storage = createEntitlementsStorage(file); + storage.setEntitlement(PUB_A, { tier: "premium", granted_by: `admin:${ADMIN_NPUB_HEX}`, note: "tester" }); + const entry = storage.getByPubkey(PUB_A); + assert.equal(entry.tier, "premium"); + assert.equal(entry.granted_by, `admin:${ADMIN_NPUB_HEX}`); + assert.equal(entry.note, "tester"); + assert.equal(entry.expires_at, null); + assert.ok(typeof entry.granted_at === "number"); + assert.deepEqual(entry.devices_seen, []); +}); + +test("setEntitlement preserves granted_at across upsert", async () => { + const file = tmpFile(); + const { createEntitlementsStorage } = require("../entitlements"); + const storage = createEntitlementsStorage(file); + storage.setEntitlement(PUB_A, { tier: "premium" }); + const firstGrantedAt = storage.getByPubkey(PUB_A).granted_at; + await new Promise((r) => setTimeout(r, 1100)); + storage.setEntitlement(PUB_A, { tier: "premium", note: "updated" }); + const entry = storage.getByPubkey(PUB_A); + assert.equal(entry.granted_at, firstGrantedAt, "granted_at preserved on upsert"); + assert.equal(entry.note, "updated"); +}); + +test("setEntitlement preserves devices_seen across upsert", () => { + const file = tmpFile(); + const { createEntitlementsStorage } = require("../entitlements"); + const storage = createEntitlementsStorage(file); + storage.setEntitlement(PUB_A, { tier: "premium" }); + storage.recordDevice(PUB_A, "abc12345"); + storage.setEntitlement(PUB_A, { tier: "premium", note: "still tester" }); + const entry = storage.getByPubkey(PUB_A); + assert.equal(entry.devices_seen.length, 1); + assert.equal(entry.devices_seen[0].token_prefix, "abc12345"); +}); + +test("setEntitlement keeps prior granted_by/note when not supplied", () => { + const file = tmpFile(); + const { createEntitlementsStorage } = require("../entitlements"); + const storage = createEntitlementsStorage(file); + storage.setEntitlement(PUB_A, { tier: "premium", granted_by: "admin:original", note: "original-note" }); + storage.setEntitlement(PUB_A, { tier: "premium" }); // no granted_by/note → keep prior + const entry = storage.getByPubkey(PUB_A); + assert.equal(entry.granted_by, "admin:original"); + assert.equal(entry.note, "original-note"); +}); + +test("setEntitlement throws on invalid pubkey hex", () => { + const file = tmpFile(); + const { createEntitlementsStorage } = require("../entitlements"); + const storage = createEntitlementsStorage(file); + assert.throws(() => storage.setEntitlement("not-hex", { tier: "premium" }), /invalid_pubkey_hex/); + assert.throws(() => storage.setEntitlement("A".repeat(64), { tier: "premium" }), /invalid_pubkey_hex/, "uppercase rejected"); + assert.throws(() => storage.setEntitlement("a".repeat(63), { tier: "premium" }), /invalid_pubkey_hex/); +}); + +test("setEntitlement throws on invalid tier", () => { + const file = tmpFile(); + const { createEntitlementsStorage } = require("../entitlements"); + const storage = createEntitlementsStorage(file); + assert.throws(() => storage.setEntitlement(PUB_A, { tier: "platinum" }), /invalid_tier/); + assert.throws(() => storage.setEntitlement(PUB_A, {}), /invalid_tier/); +}); + +test("setEntitlement throws on invalid expires_at", () => { + const file = tmpFile(); + const { createEntitlementsStorage } = require("../entitlements"); + const storage = createEntitlementsStorage(file); + assert.throws(() => storage.setEntitlement(PUB_A, { tier: "premium", expires_at: "soon" }), /invalid_expires_at/); +}); + +test("setEntitlement accepts expires_at: number for time-bounded grants", () => { + const file = tmpFile(); + const { createEntitlementsStorage } = require("../entitlements"); + const storage = createEntitlementsStorage(file); + const future = Math.floor(Date.now() / 1000) + 30 * 86400; + storage.setEntitlement(PUB_A, { tier: "premium", expires_at: future }); + assert.equal(storage.getByPubkey(PUB_A).expires_at, future); +}); + +// ---------- revoke ---------- + +test("revoke removes entry and returns it", () => { + const file = tmpFile(); + const { createEntitlementsStorage } = require("../entitlements"); + const storage = createEntitlementsStorage(file); + storage.setEntitlement(PUB_A, { tier: "premium", note: "bye" }); + const removed = storage.revoke(PUB_A); + assert.equal(removed.tier, "premium"); + assert.equal(removed.note, "bye"); + assert.equal(storage.getByPubkey(PUB_A), null); +}); + +test("revoke returns null for unknown pubkey", () => { + const file = tmpFile(); + const { createEntitlementsStorage } = require("../entitlements"); + const storage = createEntitlementsStorage(file); + assert.equal(storage.revoke(PUB_A), null); +}); + +// ---------- tierForPubkey ---------- + +test("tierForPubkey defaults to 'free' for unknown pubkey", () => { + const file = tmpFile(); + const { createEntitlementsStorage } = require("../entitlements"); + const storage = createEntitlementsStorage(file); + assert.equal(storage.tierForPubkey(PUB_A), "free"); +}); + +test("tierForPubkey returns 'premium' for granted unexpired pubkey", () => { + const file = tmpFile(); + const { createEntitlementsStorage } = require("../entitlements"); + const storage = createEntitlementsStorage(file); + storage.setEntitlement(PUB_A, { tier: "premium" }); + assert.equal(storage.tierForPubkey(PUB_A), "premium"); +}); + +test("tierForPubkey downgrades expired premium to 'free'", () => { + const file = tmpFile(); + const { createEntitlementsStorage } = require("../entitlements"); + const storage = createEntitlementsStorage(file); + const past = Math.floor(Date.now() / 1000) - 86400; // 1 day ago + storage.setEntitlement(PUB_A, { tier: "premium", expires_at: past }); + assert.equal(storage.tierForPubkey(PUB_A), "free", "expired premium reads as free"); +}); + +test("tierForPubkey honors future expiry as 'premium'", () => { + const file = tmpFile(); + const { createEntitlementsStorage } = require("../entitlements"); + const storage = createEntitlementsStorage(file); + const future = Math.floor(Date.now() / 1000) + 86400; + storage.setEntitlement(PUB_A, { tier: "premium", expires_at: future }); + assert.equal(storage.tierForPubkey(PUB_A), "premium"); +}); + +// ---------- recordDevice ---------- + +test("recordDevice no-ops for pubkey without entitlement (returns false)", () => { + const file = tmpFile(); + const { createEntitlementsStorage } = require("../entitlements"); + const storage = createEntitlementsStorage(file); + // We deliberately don't auto-create entitlements from device queries — + // free-tier pubkeys shouldn't accumulate device-tracking data. + assert.equal(storage.recordDevice(PUB_A, "abc12345"), false); + assert.equal(storage.getByPubkey(PUB_A), null); +}); + +test("recordDevice no-ops for empty/non-string tokenPrefix", () => { + const file = tmpFile(); + const { createEntitlementsStorage } = require("../entitlements"); + const storage = createEntitlementsStorage(file); + storage.setEntitlement(PUB_A, { tier: "premium" }); + assert.equal(storage.recordDevice(PUB_A, ""), false); + assert.equal(storage.recordDevice(PUB_A, null), false); + assert.equal(storage.recordDevice(PUB_A, undefined), false); + assert.equal(storage.getByPubkey(PUB_A).devices_seen.length, 0); +}); + +test("recordDevice adds new device entry with timestamps", () => { + const file = tmpFile(); + const { createEntitlementsStorage } = require("../entitlements"); + const storage = createEntitlementsStorage(file); + storage.setEntitlement(PUB_A, { tier: "premium" }); + assert.equal(storage.recordDevice(PUB_A, "abc12345"), true); + const entry = storage.getByPubkey(PUB_A); + assert.equal(entry.devices_seen.length, 1); + const dev = entry.devices_seen[0]; + assert.equal(dev.token_prefix, "abc12345"); + assert.ok(typeof dev.first_seen_at === "number"); + assert.ok(typeof dev.last_seen_at === "number"); +}); + +test("recordDevice deduplicates by token_prefix and updates last_seen_at", async () => { + const file = tmpFile(); + const { createEntitlementsStorage } = require("../entitlements"); + const storage = createEntitlementsStorage(file); + storage.setEntitlement(PUB_A, { tier: "premium" }); + storage.recordDevice(PUB_A, "abc12345"); + const firstSeen = storage.getByPubkey(PUB_A).devices_seen[0].first_seen_at; + await new Promise((r) => setTimeout(r, 1100)); + storage.recordDevice(PUB_A, "abc12345"); + const entry = storage.getByPubkey(PUB_A); + assert.equal(entry.devices_seen.length, 1, "no duplicate row created"); + assert.equal(entry.devices_seen[0].first_seen_at, firstSeen, "first_seen_at preserved"); + assert.ok(entry.devices_seen[0].last_seen_at > firstSeen, "last_seen_at updates"); +}); + +test("recordDevice keeps multiple distinct device entries", () => { + const file = tmpFile(); + const { createEntitlementsStorage } = require("../entitlements"); + const storage = createEntitlementsStorage(file); + storage.setEntitlement(PUB_A, { tier: "premium" }); + storage.recordDevice(PUB_A, "device1a"); + storage.recordDevice(PUB_A, "device2b"); + storage.recordDevice(PUB_A, "device3c"); + assert.equal(storage.getByPubkey(PUB_A).devices_seen.length, 3); +}); + +// ---------- auditMultiDevice ---------- + +test("auditMultiDevice flags pubkeys exceeding device threshold within window", () => { + const file = tmpFile(); + const { createEntitlementsStorage } = require("../entitlements"); + const storage = createEntitlementsStorage(file); + storage.setEntitlement(PUB_A, { tier: "premium" }); + for (let i = 0; i < 7; i++) { + storage.recordDevice(PUB_A, `device${i}_`); + } + const flagged = storage.auditMultiDevice(5, 30); // > 5 devices in last 30 days + assert.equal(flagged.length, 1); + assert.equal(flagged[0].pubkey, PUB_A); + assert.equal(flagged[0].deviceCount, 7); + assert.equal(flagged[0].tier, "premium"); +}); + +test("auditMultiDevice excludes devices outside window", () => { + const file = tmpFile(); + const { createEntitlementsStorage } = require("../entitlements"); + const storage = createEntitlementsStorage(file); + storage.setEntitlement(PUB_A, { tier: "premium" }); + // Manually inject old + recent devices. + const ageOld = Math.floor(Date.now() / 1000) - 60 * 86400; // 60 days ago + const ageNew = Math.floor(Date.now() / 1000) - 1 * 86400; // 1 day ago + const all = storage.loadAll(); + all[PUB_A].devices_seen = [ + { token_prefix: "old1", first_seen_at: ageOld, last_seen_at: ageOld }, + { token_prefix: "old2", first_seen_at: ageOld, last_seen_at: ageOld }, + { token_prefix: "old3", first_seen_at: ageOld, last_seen_at: ageOld }, + { token_prefix: "old4", first_seen_at: ageOld, last_seen_at: ageOld }, + { token_prefix: "old5", first_seen_at: ageOld, last_seen_at: ageOld }, + { token_prefix: "old6", first_seen_at: ageOld, last_seen_at: ageOld }, + { token_prefix: "newR", first_seen_at: ageNew, last_seen_at: ageNew }, + ]; + fs.writeFileSync(file, JSON.stringify(all, null, 2)); + // Within 30 days, only "newR" counts → 1 device → does NOT exceed threshold 5 + assert.equal(storage.auditMultiDevice(5, 30).length, 0); + // Within 90 days, all 7 count → exceeds threshold 5 + assert.equal(storage.auditMultiDevice(5, 90).length, 1); +}); + +test("auditMultiDevice returns empty array when nothing flagged", () => { + const file = tmpFile(); + const { createEntitlementsStorage } = require("../entitlements"); + const storage = createEntitlementsStorage(file); + storage.setEntitlement(PUB_A, { tier: "premium" }); + storage.recordDevice(PUB_A, "abc12345"); + assert.deepEqual(storage.auditMultiDevice(5, 30), []); +}); + +// ---------- listByTier ---------- + +test("listByTier returns entries matching tier, including pubkey", () => { + const file = tmpFile(); + const { createEntitlementsStorage } = require("../entitlements"); + const storage = createEntitlementsStorage(file); + storage.setEntitlement(PUB_A, { tier: "premium", note: "tester1" }); + storage.setEntitlement(PUB_B, { tier: "premium", note: "tester2" }); + storage.setEntitlement(PUB_C, { tier: "free" }); + const premium = storage.listByTier("premium"); + assert.equal(premium.length, 2); + assert.ok(premium.some((e) => e.pubkey === PUB_A && e.note === "tester1")); + assert.ok(premium.some((e) => e.pubkey === PUB_B && e.note === "tester2")); +}); + +test("listByTier treats expired premium as free for filtering", () => { + const file = tmpFile(); + const { createEntitlementsStorage } = require("../entitlements"); + const storage = createEntitlementsStorage(file); + const past = Math.floor(Date.now() / 1000) - 86400; + storage.setEntitlement(PUB_A, { tier: "premium" }); // active + storage.setEntitlement(PUB_B, { tier: "premium", expires_at: past }); // expired + const premium = storage.listByTier("premium"); + assert.equal(premium.length, 1); + assert.equal(premium[0].pubkey, PUB_A); +}); + +// ---------- maxAccountsForTier / maxClientsForTier ---------- + +test("maxAccountsForTier returns 4 for free, 10 for premium", () => { + const file = tmpFile(); + const { createEntitlementsStorage } = require("../entitlements"); + const storage = createEntitlementsStorage(file); + assert.equal(storage.maxAccountsForTier("free"), 4); + assert.equal(storage.maxAccountsForTier("premium"), 10); +}); + +test("maxClientsForTier returns 5 for free, 30 for premium", () => { + const file = tmpFile(); + const { createEntitlementsStorage } = require("../entitlements"); + const storage = createEntitlementsStorage(file); + assert.equal(storage.maxClientsForTier("free"), 5); + assert.equal(storage.maxClientsForTier("premium"), 30); +}); + +// ---------- atomic write durability ---------- + +test("atomic write: temp file is cleaned up after rename", () => { + const file = tmpFile(); + const { createEntitlementsStorage } = require("../entitlements"); + const storage = createEntitlementsStorage(file); + storage.setEntitlement(PUB_A, { tier: "premium" }); + assert.ok(!fs.existsSync(file + ".tmp"), "temp file should not remain after write"); + assert.ok(fs.existsSync(file), "target file should exist"); +});