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 @@ -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).
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] 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
195 changes: 195 additions & 0 deletions src/stores/useLicenseStore.test.ts
Original file line number Diff line number Diff line change
@@ -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<typeof import('@/services/luminaCloud')>(
'@/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');
});
});
});
82 changes: 82 additions & 0 deletions src/stores/useLicenseStore.ts
Original file line number Diff line number Diff line change
@@ -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<void>;
/**
* Wipe the in-memory license + payload, then remove from the keychain.
* `idle` (not `invalid`) — the user explicitly cleared.
*/
clearLicense: () => Promise<void>;
/**
* Read the keychain on app start. If the stored token still verifies, lift
* it into memory; otherwise discard. Call once at boot.
*/
refreshFromKeychain: () => Promise<void>;
}

export const useLicenseStore = create<LicenseStoreState>((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' });
Comment on lines +47 to +51
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 Guard against stale async completions in setLicense

setLicense writes status: 'valid' after an await without checking whether a newer action already changed the store, so overlapping calls can revert newer state. For example, if setLicense('old') is waiting on saveLicense and the user then runs clearLicense() (or a second setLicense), the first call can finish later and restore the old token as valid. This creates incorrect and potentially unsafe state; track request/version IDs (or compare current state) before the final set.

Useful? React with 👍 / 👎.

},

async clearLicense() {
try {
await removeLicense();
} catch (err) {
console.error('[license] removeLicense failed', err);
}
set({ license: null, payload: null, status: 'idle' });
Comment on lines +56 to +60
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Clear in-memory license before awaiting keychain removal

The clear path waits for removeLicense() before resetting in-memory state, so if keychain IPC is slow or hangs, the store remains valid and the old license stays usable even after the user explicitly cleared it. This is also inconsistent with the action contract comment that says memory should be wiped and then persisted removal attempted; clear local state first, then perform/remediate keychain cleanup.

Useful? React with 👍 / 👎.

},

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' });
},
}));