-
Notifications
You must be signed in to change notification settings - Fork 87
cloud/C6: revocation cache #222
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Closed
Closed
Changes from all commits
Commits
Show all changes
2 commits
Select commit
Hold shift + click to select a range
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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); | ||
| }); | ||
| }); |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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, | ||
| }; | ||
| 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(); | ||
| } | ||
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This refresh path overwrites the local list with
fresh.revoked_lidseven though the fetch is made withsince(opts.fetchFresh(stale?.as_of)). If the backend treatssinceas 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 usingsincefor snapshot replacement.Useful? React with 👍 / 👎.