From 3144bd55b170d3f32346cb4a1ff8aee7189659a3 Mon Sep 17 00:00:00 2001 From: blueberrycongee Date: Tue, 28 Apr 2026 12:33:52 -0700 Subject: [PATCH 1/2] cloud/C4: useLicenseStore (zustand) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Holds the verified license string + decoded payload + lifecycle status. setLicense verifies via luminaCloud.verifyLicense and persists through saveLicense; persistence failures degrade gracefully (in-memory state stays valid for the session, just not across restarts). clearLicense flips back to idle. refreshFromKeychain hydrates on app start — empty / unreadable keychain → idle, stored token that no longer verifies → invalid, valid token → valid. Tests use vi.hoisted to mock the four luminaCloud touchpoints, then subscribe to capture status sequences. Covers the four transitions called out in C4 acceptance: idle→loading, loading→valid, loading→invalid, valid→idle. 9 tests, all pass. Booting hookup is the consumer's job — App should call useLicenseStore.getState().refreshFromKeychain() once after IPC bridge is ready. Wiring lands in C10 (Account tab mount). Co-Authored-By: Claude Opus 4.7 (1M context) --- src/stores/useLicenseStore.test.ts | 195 +++++++++++++++++++++++++++++ src/stores/useLicenseStore.ts | 82 ++++++++++++ 2 files changed, 277 insertions(+) create mode 100644 src/stores/useLicenseStore.test.ts create mode 100644 src/stores/useLicenseStore.ts diff --git a/src/stores/useLicenseStore.test.ts b/src/stores/useLicenseStore.test.ts new file mode 100644 index 00000000..fd338101 --- /dev/null +++ b/src/stores/useLicenseStore.test.ts @@ -0,0 +1,195 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import type { LicensePayload } from '@/services/luminaCloud'; + +const verifyLicense = vi.hoisted(() => vi.fn()); +const saveLicense = vi.hoisted(() => vi.fn()); +const loadLicense = vi.hoisted(() => vi.fn()); +const removeLicense = vi.hoisted(() => vi.fn()); + +vi.mock('@/services/luminaCloud', async () => { + const actual = await vi.importActual( + '@/services/luminaCloud' + ); + return { + ...actual, + verifyLicense, + saveLicense, + loadLicense, + removeLicense, + }; +}); + +import { useLicenseStore } from './useLicenseStore'; + +const VALID_PAYLOAD: LicensePayload = { + v: 1, + lid: 'lic_01HXTEST', + email: 'fixture@example.com', + sku: 'lumina-lifetime-founders', + features: ['cloud_ai', 'sync'], + issued_at: '2026-04-28T12:00:00Z', + expires_at: null, + order_id: 'creem_ord_test', + device_limit: 5, +}; + +describe('useLicenseStore', () => { + beforeEach(() => { + useLicenseStore.setState({ license: null, payload: null, status: 'idle' }); + verifyLicense.mockReset(); + saveLicense.mockReset(); + loadLicense.mockReset(); + removeLicense.mockReset(); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + describe('setLicense', () => { + it('idle → loading → valid for a verified token', async () => { + verifyLicense.mockReturnValue(VALID_PAYLOAD); + saveLicense.mockResolvedValue(undefined); + + const transitions: string[] = []; + const unsubscribe = useLicenseStore.subscribe((state, prev) => { + if (state.status !== prev.status) transitions.push(state.status); + }); + + await useLicenseStore.getState().setLicense('valid-token'); + unsubscribe(); + + expect(transitions).toEqual(['loading', 'valid']); + const after = useLicenseStore.getState(); + expect(after.status).toBe('valid'); + expect(after.license).toBe('valid-token'); + expect(after.payload).toEqual(VALID_PAYLOAD); + expect(saveLicense).toHaveBeenCalledWith('valid-token'); + }); + + it('idle → loading → invalid for a token that fails verification', async () => { + verifyLicense.mockReturnValue(null); + + const transitions: string[] = []; + const unsubscribe = useLicenseStore.subscribe((state, prev) => { + if (state.status !== prev.status) transitions.push(state.status); + }); + + await useLicenseStore.getState().setLicense('garbage'); + unsubscribe(); + + expect(transitions).toEqual(['loading', 'invalid']); + const after = useLicenseStore.getState(); + expect(after.status).toBe('invalid'); + expect(after.license).toBeNull(); + expect(after.payload).toBeNull(); + expect(saveLicense).not.toHaveBeenCalled(); + }); + + it('keeps in-memory state valid even when keychain save throws', async () => { + verifyLicense.mockReturnValue(VALID_PAYLOAD); + saveLicense.mockRejectedValue(new Error('keychain unavailable')); + const consoleErr = vi.spyOn(console, 'error').mockImplementation(() => {}); + + await useLicenseStore.getState().setLicense('valid-token'); + + const after = useLicenseStore.getState(); + expect(after.status).toBe('valid'); + expect(after.license).toBe('valid-token'); + expect(consoleErr).toHaveBeenCalled(); + }); + }); + + describe('clearLicense', () => { + it('valid → idle and removes from keychain', async () => { + useLicenseStore.setState({ + license: 'valid-token', + payload: VALID_PAYLOAD, + status: 'valid', + }); + removeLicense.mockResolvedValue(undefined); + + await useLicenseStore.getState().clearLicense(); + + const after = useLicenseStore.getState(); + expect(after.status).toBe('idle'); + expect(after.license).toBeNull(); + expect(after.payload).toBeNull(); + expect(removeLicense).toHaveBeenCalledTimes(1); + }); + + it('still clears in-memory state when keychain remove throws', async () => { + useLicenseStore.setState({ + license: 'valid-token', + payload: VALID_PAYLOAD, + status: 'valid', + }); + removeLicense.mockRejectedValue(new Error('keychain unavailable')); + vi.spyOn(console, 'error').mockImplementation(() => {}); + + await useLicenseStore.getState().clearLicense(); + + expect(useLicenseStore.getState().status).toBe('idle'); + }); + }); + + describe('refreshFromKeychain', () => { + it('idle → loading → valid when keychain holds a verifying token', async () => { + loadLicense.mockResolvedValue('stored-token'); + verifyLicense.mockReturnValue(VALID_PAYLOAD); + + const transitions: string[] = []; + const unsubscribe = useLicenseStore.subscribe((state, prev) => { + if (state.status !== prev.status) transitions.push(state.status); + }); + + await useLicenseStore.getState().refreshFromKeychain(); + unsubscribe(); + + expect(transitions).toEqual(['loading', 'valid']); + expect(useLicenseStore.getState().license).toBe('stored-token'); + }); + + it('idle → loading → idle when keychain is empty', async () => { + loadLicense.mockResolvedValue(null); + + const transitions: string[] = []; + const unsubscribe = useLicenseStore.subscribe((state, prev) => { + if (state.status !== prev.status) transitions.push(state.status); + }); + + await useLicenseStore.getState().refreshFromKeychain(); + unsubscribe(); + + expect(transitions).toEqual(['loading', 'idle']); + expect(useLicenseStore.getState().license).toBeNull(); + expect(verifyLicense).not.toHaveBeenCalled(); + }); + + it('idle → loading → invalid when stored token no longer verifies', async () => { + loadLicense.mockResolvedValue('stale-token'); + verifyLicense.mockReturnValue(null); + + const transitions: string[] = []; + const unsubscribe = useLicenseStore.subscribe((state, prev) => { + if (state.status !== prev.status) transitions.push(state.status); + }); + + await useLicenseStore.getState().refreshFromKeychain(); + unsubscribe(); + + expect(transitions).toEqual(['loading', 'invalid']); + expect(useLicenseStore.getState().license).toBeNull(); + }); + + it('treats a keychain failure like an empty keychain (idle)', async () => { + loadLicense.mockRejectedValue(new Error('keychain unavailable')); + vi.spyOn(console, 'error').mockImplementation(() => {}); + + await useLicenseStore.getState().refreshFromKeychain(); + + expect(useLicenseStore.getState().status).toBe('idle'); + }); + }); +}); diff --git a/src/stores/useLicenseStore.ts b/src/stores/useLicenseStore.ts new file mode 100644 index 00000000..0fc28293 --- /dev/null +++ b/src/stores/useLicenseStore.ts @@ -0,0 +1,82 @@ +import { create } from 'zustand'; + +import { + loadLicense, + removeLicense, + saveLicense, + verifyLicense, +} from '@/services/luminaCloud'; +import type { LicensePayload, LicenseStatus } from '@/services/luminaCloud'; + +interface LicenseStoreState { + license: string | null; + payload: LicensePayload | null; + status: LicenseStatus; + /** + * Verify a freshly-pasted license, persist it to the OS keychain on success, + * and update in-memory state. Persistence failure does not flip status away + * from `valid` — the in-memory token is still useable for the rest of the + * session, just not across restarts. + */ + setLicense: (token: string) => Promise; + /** + * Wipe the in-memory license + payload, then remove from the keychain. + * `idle` (not `invalid`) — the user explicitly cleared. + */ + clearLicense: () => Promise; + /** + * Read the keychain on app start. If the stored token still verifies, lift + * it into memory; otherwise discard. Call once at boot. + */ + refreshFromKeychain: () => Promise; +} + +export const useLicenseStore = create((set) => ({ + license: null, + payload: null, + status: 'idle', + + async setLicense(token) { + set({ status: 'loading' }); + const payload = verifyLicense(token); + if (!payload) { + set({ license: null, payload: null, status: 'invalid' }); + return; + } + try { + await saveLicense(token); + } catch (err) { + console.error('[license] saveLicense failed; in-memory only', err); + } + set({ license: token, payload, status: 'valid' }); + }, + + async clearLicense() { + try { + await removeLicense(); + } catch (err) { + console.error('[license] removeLicense failed', err); + } + set({ license: null, payload: null, status: 'idle' }); + }, + + async refreshFromKeychain() { + set({ status: 'loading' }); + let token: string | null = null; + try { + token = await loadLicense(); + } catch (err) { + console.error('[license] loadLicense failed', err); + } + if (!token) { + set({ license: null, payload: null, status: 'idle' }); + return; + } + const payload = verifyLicense(token); + if (!payload) { + set({ license: null, payload: null, status: 'invalid' }); + return; + } + set({ license: token, payload, status: 'valid' }); + }, +})); From 59d9410d951c3d290ef7f3394b534b665a0592e4 Mon Sep 17 00:00:00 2001 From: blueberrycongee Date: Tue, 28 Apr 2026 12:33:59 -0700 Subject: [PATCH 2/2] cloud/C4: mark C4 done in TASKS.md Co-Authored-By: Claude Opus 4.7 (1M context) --- cloud/TASKS.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/cloud/TASKS.md b/cloud/TASKS.md index ec21ffc5..d0d5d9ee 100644 --- a/cloud/TASKS.md +++ b/cloud/TASKS.md @@ -47,7 +47,7 @@ - **[BLOCKED: must NOT touch existing electron entry beyond a single named import]** — if the existing electron main is structured such that adding the new IPC handler requires non-additive edits, block and ask Lead. ### C4 — Zustand store `useLicenseStore` -- [ ] **Goal:** `src/stores/useLicenseStore.ts` with: `license: string | null`, `payload: LicensePayload | null`, `status: 'idle' | 'loading' | 'valid' | 'invalid'`, actions `setLicense(token)`, `clearLicense()`, `refreshFromKeychain()`. +- [x] **Goal:** `src/stores/useLicenseStore.ts` with: `license: string | null`, `payload: LicensePayload | null`, `status: 'idle' | 'loading' | 'valid' | 'invalid'`, actions `setLicense(token)`, `clearLicense()`, `refreshFromKeychain()`. - **Acceptance:** - `setLicense` calls `verifyLicense` and updates `payload`/`status` accordingly. - Hydrates from keychain on app start (calls `refreshFromKeychain` once). @@ -129,3 +129,4 @@ [x] C1 — 2026-04-28 — ba66b60 — scaffolded `src/services/luminaCloud/` (types + stubs); typecheck passes; no new runtime deps [x] C2 — 2026-04-28 — 3127814 — Ed25519 verifyLicense + JCS canonical-json + 24 tests; deps @noble/ed25519 ^3.1.0, @noble/hashes ^2.2.0 +[x] C4 — 2026-04-28 — 3144bd5 — useLicenseStore (zustand) with mocked luminaCloud; 9 tests cover all four status transitions