diff --git a/cloud/TASKS.md b/cloud/TASKS.md index 90364521..84d07ce2 100644 --- a/cloud/TASKS.md +++ b/cloud/TASKS.md @@ -66,7 +66,7 @@ - **Dep:** add `msw` to `devDependencies` if not present, or use a simple manual fetch mock. ### C6 — Revocation cache -- [ ] **Goal:** `revocations.ts` keeps a daily-refreshed local cache and exports `isRevoked(lid): Promise`. +- [x] **Goal:** `revocations.ts` keeps a daily-refreshed local cache and exports `isRevoked(lid): Promise`. - **Storage:** under `app.getPath('userData')/lumina-cloud-revocations.json`, `{ as_of, revoked_lids: [...] }`. - **Acceptance:** - Refreshes if cache age > 24h (and quietly succeeds with stale cache on network failure). @@ -129,3 +129,4 @@ [x] C1 — 2026-04-28 — ba66b60 — scaffolded `src/services/luminaCloud/` (types + stubs); typecheck passes; no new runtime deps [x] C5 — 2026-04-28 — 0d7eb75 — typed HTTP client + LuminaCloudError; 21 tests; no new runtime deps (manual fetch mock) +[x] C6 — 2026-04-28 — f015911 — createRevocationCache + in-memory default (pending C3 IPC for disk persistence); 7 tests cover cold/warm/expired/network-fail/coalesce diff --git a/src/services/luminaCloud/index.ts b/src/services/luminaCloud/index.ts index 43de1e9c..5c7cf793 100644 --- a/src/services/luminaCloud/index.ts +++ b/src/services/luminaCloud/index.ts @@ -23,7 +23,19 @@ export { verifyLicense } from './verify'; export { loadLicense, removeLicense, saveLicense } from './store'; -export { isRevoked } from './revocations'; +export { + createRevocationCache, + DEFAULT_TTL_MS as REVOCATIONS_DEFAULT_TTL_MS, + isRevoked, + refreshRevocations, +} from './revocations'; + +export type { + RevocationCache, + RevocationCacheData, + RevocationCacheOptions, + RevocationStorage, +} from './revocations'; export { DEFAULT_BASE_URL, diff --git a/src/services/luminaCloud/revocations.test.ts b/src/services/luminaCloud/revocations.test.ts new file mode 100644 index 00000000..9387e458 --- /dev/null +++ b/src/services/luminaCloud/revocations.test.ts @@ -0,0 +1,157 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { + createRevocationCache, + DEFAULT_TTL_MS, + type RevocationCacheData, + type RevocationStorage, +} from './revocations'; +import type { RevocationsResponse } from './types'; + +function memoryStorage(initial: RevocationCacheData | null = null) { + const slot: { current: RevocationCacheData | null } = { current: initial }; + const storage: RevocationStorage = { + read: vi.fn(async () => slot.current), + write: vi.fn(async (cache) => { + slot.current = cache; + }), + }; + return { storage, slot }; +} + +function fixedNow(ms: number): () => number { + return () => ms; +} + +const T0 = Date.parse('2026-04-28T00:00:00Z'); + +describe('createRevocationCache', () => { + let warnSpy: ReturnType; + + beforeEach(() => { + warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + }); + + afterEach(() => { + warnSpy.mockRestore(); + }); + + it('cold cache: fetches fresh, writes through storage, returns membership', async () => { + const { storage } = memoryStorage(null); + const fetchFresh = vi.fn<() => Promise>().mockResolvedValue({ + as_of: '2026-04-28T00:00:00Z', + revoked_lids: ['lic_revoked_a'], + }); + const cache = createRevocationCache({ storage, fetchFresh, now: fixedNow(T0 + 1000) }); + + expect(await cache.isRevoked('lic_revoked_a')).toBe(true); + expect(await cache.isRevoked('lic_clean')).toBe(false); + expect(fetchFresh).toHaveBeenCalledTimes(1); + expect(storage.write).toHaveBeenCalledWith({ + as_of: '2026-04-28T00:00:00Z', + revoked_lids: ['lic_revoked_a'], + }); + }); + + it('warm cache: reads stored data without hitting the network', async () => { + const stored: RevocationCacheData = { + as_of: '2026-04-28T00:00:00Z', + revoked_lids: ['lic_revoked_a'], + }; + const { storage } = memoryStorage(stored); + const fetchFresh = vi.fn<() => Promise>(); + // 12h after as_of — well within the 24h TTL + const cache = createRevocationCache({ + storage, + fetchFresh, + now: fixedNow(T0 + 12 * 60 * 60 * 1000), + }); + + expect(await cache.isRevoked('lic_revoked_a')).toBe(true); + expect(await cache.isRevoked('lic_clean')).toBe(false); + expect(fetchFresh).not.toHaveBeenCalled(); + }); + + it('expired cache: refreshes and replaces the stored data', async () => { + const stored: RevocationCacheData = { + as_of: '2026-04-28T00:00:00Z', + revoked_lids: ['lic_old'], + }; + const { storage, slot } = memoryStorage(stored); + const fetchFresh = vi.fn<(since?: string) => Promise>().mockResolvedValue({ + as_of: '2026-04-29T00:00:00Z', + revoked_lids: ['lic_old', 'lic_new'], + }); + // 25h after as_of — past the 24h TTL + const cache = createRevocationCache({ + storage, + fetchFresh, + now: fixedNow(T0 + 25 * 60 * 60 * 1000), + }); + + expect(await cache.isRevoked('lic_new')).toBe(true); + expect(fetchFresh).toHaveBeenCalledTimes(1); + // refresh should pass `since` from the previous as_of + expect(fetchFresh).toHaveBeenCalledWith('2026-04-28T00:00:00Z'); + expect(slot.current?.as_of).toBe('2026-04-29T00:00:00Z'); + }); + + it('network failure with stale cache: keeps serving the stale data', async () => { + const stored: RevocationCacheData = { + as_of: '2026-04-27T00:00:00Z', + revoked_lids: ['lic_old'], + }; + const { storage, slot } = memoryStorage(stored); + const fetchFresh = vi + .fn<() => Promise>() + .mockRejectedValue(new Error('network down')); + // 26h after as_of — expired + const cache = createRevocationCache({ + storage, + fetchFresh, + now: fixedNow(Date.parse('2026-04-27T00:00:00Z') + 26 * 60 * 60 * 1000), + }); + + expect(await cache.isRevoked('lic_old')).toBe(true); + expect(await cache.isRevoked('lic_new')).toBe(false); + expect(fetchFresh).toHaveBeenCalled(); + // Storage should not have been overwritten with the failed refresh. + expect(slot.current).toEqual(stored); + expect(warnSpy).toHaveBeenCalled(); + }); + + it('cold cache + network failure: fails open (returns false)', async () => { + const { storage } = memoryStorage(null); + const fetchFresh = vi + .fn<() => Promise>() + .mockRejectedValue(new Error('network down')); + const cache = createRevocationCache({ storage, fetchFresh, now: fixedNow(T0) }); + + expect(await cache.isRevoked('lic_anything')).toBe(false); + expect(storage.write).not.toHaveBeenCalled(); + }); + + it('coalesces concurrent refreshes into a single fetch', async () => { + const { storage } = memoryStorage(null); + let resolveFetch!: (value: RevocationsResponse) => void; + const fetchPromise = new Promise((r) => { + resolveFetch = r; + }); + const fetchFresh = vi.fn<() => Promise>(() => fetchPromise); + const cache = createRevocationCache({ storage, fetchFresh, now: fixedNow(T0) }); + + const a = cache.isRevoked('lic_a'); + const b = cache.isRevoked('lic_b'); + const c = cache.refresh(); + resolveFetch({ as_of: '2026-04-28T00:00:00Z', revoked_lids: ['lic_a'] }); + const [resA, resB] = await Promise.all([a, b, c]); + + expect(fetchFresh).toHaveBeenCalledTimes(1); + expect(resA).toBe(true); + expect(resB).toBe(false); + }); + + it('uses DEFAULT_TTL_MS when ttlMs is not provided', () => { + expect(DEFAULT_TTL_MS).toBe(24 * 60 * 60 * 1000); + }); +}); diff --git a/src/services/luminaCloud/revocations.ts b/src/services/luminaCloud/revocations.ts index a2157a0e..b2ed8275 100644 --- a/src/services/luminaCloud/revocations.ts +++ b/src/services/luminaCloud/revocations.ts @@ -1,8 +1,136 @@ +import { getRevocations as defaultFetchRevocations } from './client'; +import type { RevocationsResponse } from './types'; + /** - * Local revocation cache, refreshed daily from `GET /v1/license/revocations` - * (CONTRACT.md §2.5). Implemented in task C6. + * Daily-refreshed local cache of revoked license ids per CONTRACT.md §2.5. + * + * The cache is intentionally fail-open: any storage or network failure + * resolves to "not revoked" rather than blocking the user. Revocation is + * a defense-in-depth signal layered on top of `expires_at`; a temporary + * outage shouldn't lock people out of cloud features. */ -export async function isRevoked(_lid: string): Promise { - throw new Error('luminaCloud.isRevoked: not implemented yet (task C6)'); +export const DEFAULT_TTL_MS = 24 * 60 * 60 * 1000; + +export interface RevocationCacheData { + as_of: string; + revoked_lids: string[]; +} + +export interface RevocationStorage { + read(): Promise; + write(cache: RevocationCacheData): Promise; +} + +export interface RevocationCacheOptions { + storage: RevocationStorage; + fetchFresh: (since?: string) => Promise; + /** Override for tests; defaults to `Date.now`. */ + now?: () => number; + /** Cache TTL in ms; defaults to 24h. */ + ttlMs?: number; +} + +export interface RevocationCache { + /** Returns true iff `lid` is in a fresh-or-stale-but-non-empty cache. */ + isRevoked(lid: string): Promise; + /** Force a refresh from the server, ignoring cache age. */ + refresh(): Promise; +} + +export function createRevocationCache(opts: RevocationCacheOptions): RevocationCache { + const now = opts.now ?? (() => Date.now()); + const ttlMs = opts.ttlMs ?? DEFAULT_TTL_MS; + let inFlight: Promise | null = null; + + async function getCurrent(): Promise { + let stored: RevocationCacheData | null = null; + try { + stored = await opts.storage.read(); + } catch (err) { + console.warn('[revocations] storage read failed', err); + stored = null; + } + if (stored && isFresh(stored, now(), ttlMs)) return stored; + return refreshCoalesced(stored); + } + + function refreshCoalesced(stale: RevocationCacheData | null): Promise { + if (inFlight) return inFlight; + inFlight = (async () => { + try { + const fresh = await opts.fetchFresh(stale?.as_of); + const next: RevocationCacheData = { + as_of: fresh.as_of, + revoked_lids: fresh.revoked_lids, + }; + try { + await opts.storage.write(next); + } catch (err) { + console.warn('[revocations] storage write failed; in-memory cache only', err); + } + return next; + } catch (err) { + console.warn('[revocations] refresh failed; using stale cache', err); + return stale; + } finally { + inFlight = null; + } + })(); + return inFlight; + } + + return { + async isRevoked(lid) { + const cache = await getCurrent(); + if (!cache) return false; + return cache.revoked_lids.includes(lid); + }, + async refresh() { + let stored: RevocationCacheData | null = null; + try { + stored = await opts.storage.read(); + } catch { + stored = null; + } + return refreshCoalesced(stored); + }, + }; +} + +function isFresh(cache: RevocationCacheData, nowMs: number, ttlMs: number): boolean { + const asOf = Date.parse(cache.as_of); + if (!Number.isFinite(asOf)) return false; + return nowMs - asOf < ttlMs; +} + +// ── Default singleton ────────────────────────────────────────────────────── +// +// Until persistent storage lands (pending the C3 IPC decision in PR #219), the +// default cache is in-memory only. It still de-dupes refresh calls inside a +// session and degrades to fail-open on network failure; persistence across +// restarts will follow when C3 unblocks. + +const memory: { current: RevocationCacheData | null } = { current: null }; + +const memoryStorage: RevocationStorage = { + async read() { + return memory.current; + }, + async write(cache) { + memory.current = cache; + }, +}; + +const defaultCache = createRevocationCache({ + storage: memoryStorage, + fetchFresh: (since) => defaultFetchRevocations(since), +}); + +export async function isRevoked(lid: string): Promise { + return defaultCache.isRevoked(lid); +} + +export async function refreshRevocations(): Promise { + return defaultCache.refresh(); }