Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion cloud/TASKS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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<boolean>`.
- [x] **Goal:** `revocations.ts` keeps a daily-refreshed local cache and exports `isRevoked(lid): Promise<boolean>`.
- **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).
Expand Down Expand Up @@ -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
14 changes: 13 additions & 1 deletion src/services/luminaCloud/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
157 changes: 157 additions & 0 deletions src/services/luminaCloud/revocations.test.ts
Original file line number Diff line number Diff line change
@@ -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<typeof vi.spyOn>;

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<RevocationsResponse>>().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<RevocationsResponse>>();
// 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<RevocationsResponse>>().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<RevocationsResponse>>()
.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<RevocationsResponse>>()
.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<RevocationsResponse>((r) => {
resolveFetch = r;
});
const fetchFresh = vi.fn<() => Promise<RevocationsResponse>>(() => 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);
});
});
136 changes: 132 additions & 4 deletions src/services/luminaCloud/revocations.ts
Original file line number Diff line number Diff line change
@@ -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<boolean> {
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<RevocationCacheData | null>;
write(cache: RevocationCacheData): Promise<void>;
}

export interface RevocationCacheOptions {
storage: RevocationStorage;
fetchFresh: (since?: string) => Promise<RevocationsResponse>;
/** 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<boolean>;
/** Force a refresh from the server, ignoring cache age. */
refresh(): Promise<RevocationCacheData | null>;
}

export function createRevocationCache(opts: RevocationCacheOptions): RevocationCache {
const now = opts.now ?? (() => Date.now());
const ttlMs = opts.ttlMs ?? DEFAULT_TTL_MS;
let inFlight: Promise<RevocationCacheData | null> | null = null;

async function getCurrent(): Promise<RevocationCacheData | null> {
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<RevocationCacheData | null> {
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,
Comment on lines +63 to +65
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Merge incremental revocations into cached set

This refresh path overwrites the local list with fresh.revoked_lids even though the fetch is made with since (opts.fetchFresh(stale?.as_of)). If the backend treats since as an incremental cursor, older revoked IDs are dropped after the first delta poll (for example, cached ['lic_old'] + response ['lic_new'] becomes only ['lic_new']), so previously revoked licenses can be treated as valid. Preserve existing IDs when applying incremental responses, or avoid using since for snapshot replacement.

Useful? React with 👍 / 👎.

};
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<boolean> {
return defaultCache.isRevoked(lid);
}

export async function refreshRevocations(): Promise<RevocationCacheData | null> {
return defaultCache.refresh();
}