diff --git a/cloud/TASKS.md b/cloud/TASKS.md index 90364521..5d149140 100644 --- a/cloud/TASKS.md +++ b/cloud/TASKS.md @@ -27,7 +27,7 @@ - No new runtime dependencies yet (those come in C2). ### C2 — Add Ed25519 verification with `@noble/ed25519` -- [ ] **Goal:** `verify.ts` exports `verifyLicense(token: string): LicensePayload | null` per `CONTRACT.md` §1.3. +- [x] **Goal:** `verify.ts` exports `verifyLicense(token: string): LicensePayload | null` per `CONTRACT.md` §1.3. - **Files:** `src/services/luminaCloud/verify.ts`, `src/services/luminaCloud/verify.test.ts`, `src/services/luminaCloud/canonical-json.ts` + test. - **Public key:** `PUBLIC_KEY.ts` exports a `PUBLIC_KEY_B64` placeholder constant. Real value will be filled in by Lead from `lumina-cloud` keypair generation. Mark with `// LEAD: replace with real public key from lumina-cloud T3 output`. - **Acceptance:** @@ -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). @@ -94,7 +94,7 @@ - **Acceptance:** Tests cover the three status states. Component renders without throwing in jsdom. ### C9 — `CloudUsagePanel.tsx` -- [ ] **Goal:** Show "X / Y tokens used this month, resets on …". +- [x] **Goal:** Show "X / Y tokens used this month, resets on …". - **Files:** `src/components/settings/CloudUsagePanel.tsx` + test. - **Behavior:** - When license valid: fetch `client.getUsage()` on mount + every 60s while open. @@ -128,4 +128,7 @@ (Loop agent appends `[x] C` here as tasks complete, mirroring the `[x]` above.) [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 [x] C5 — 2026-04-28 — 0d7eb75 — typed HTTP client + LuminaCloudError; 21 tests; no new runtime deps (manual fetch mock) +[x] C9 — 2026-04-28 — ae19918 — CloudUsagePanel with 60s polling and stale-cache-on-error; 7 tests cover loading/success/error-with-cache + cold error + cadence + cleanup diff --git a/package-lock.json b/package-lock.json index 6aa07400..0a2262af 100644 --- a/package-lock.json +++ b/package-lock.json @@ -21,6 +21,8 @@ "@codemirror/view": "^6.38.8", "@excalidraw/excalidraw": "^0.18.1", "@lezer/markdown": "^1.6.0", + "@noble/ed25519": "^3.1.0", + "@noble/hashes": "^2.2.0", "@opencode-ai/sdk": "^1.14.20", "@openrouter/ai-sdk-provider": "^2.8.0", "ai": "^6.0.168", @@ -320,7 +322,6 @@ "integrity": "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@babel/code-frame": "^7.27.1", "@babel/generator": "^7.28.5", @@ -746,7 +747,6 @@ "resolved": "https://registry.npmmirror.com/@codemirror/lang-markdown/-/lang-markdown-6.5.0.tgz", "integrity": "sha512-0K40bZ35jpHya6FriukbgaleaqzBLZfOh7HuzqbMxBXkbYMJDxfF39c23xOgxFezR+3G+tR2/Mup+Xk865OMvw==", "license": "MIT", - "peer": true, "dependencies": { "@codemirror/autocomplete": "^6.7.1", "@codemirror/lang-html": "^6.0.0", @@ -762,7 +762,6 @@ "resolved": "https://registry.npmmirror.com/@codemirror/language/-/language-6.11.3.tgz", "integrity": "sha512-9HBM2XnwDj7fnu0551HkGdrUrrqmYq/WC5iv6nbY2WdicXdGbhR/gfbZOH73Aqj4351alY1+aoG9rCNfiwS1RA==", "license": "MIT", - "peer": true, "dependencies": { "@codemirror/state": "^6.0.0", "@codemirror/view": "^6.23.0", @@ -788,7 +787,6 @@ "resolved": "https://registry.npmmirror.com/@codemirror/state/-/state-6.5.2.tgz", "integrity": "sha512-FVqsPqtPWKVVL3dPSxy8wEF/ymIEuVzF1PK3VbUgrxXpJUSHQWWZz4JMToquRxnkw+36LTamCZG2iua2Ptq0fA==", "license": "MIT", - "peer": true, "dependencies": { "@marijn/find-cluster-break": "^1.0.0" } @@ -798,7 +796,6 @@ "resolved": "https://registry.npmmirror.com/@codemirror/view/-/view-6.38.8.tgz", "integrity": "sha512-XcE9fcnkHCbWkjeKyi0lllwXmBLtyYb5dt89dJyx23I9+LSh5vZDIuk7OLG4VM1lgrXZQcY6cxyZyk5WVPRv/A==", "license": "MIT", - "peer": true, "dependencies": { "@codemirror/state": "^6.5.0", "crelt": "^1.0.6", @@ -918,7 +915,6 @@ } ], "license": "MIT", - "peer": true, "engines": { "node": ">=20.19.0" }, @@ -967,7 +963,6 @@ } ], "license": "MIT", - "peer": true, "engines": { "node": ">=20.19.0" } @@ -996,7 +991,6 @@ "integrity": "sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", @@ -1345,6 +1339,7 @@ "dev": true, "license": "BSD-2-Clause", "optional": true, + "peer": true, "dependencies": { "cross-dirname": "^0.1.0", "debug": "^4.3.4", @@ -1366,6 +1361,7 @@ "dev": true, "license": "MIT", "optional": true, + "peer": true, "dependencies": { "graceful-fs": "^4.2.0", "jsonfile": "^6.0.1", @@ -1382,6 +1378,7 @@ "dev": true, "license": "MIT", "optional": true, + "peer": true, "dependencies": { "universalify": "^2.0.0" }, @@ -2061,7 +2058,6 @@ "resolved": "https://registry.npmjs.org/chevrotain/-/chevrotain-11.0.3.tgz", "integrity": "sha512-ci2iJH6LeIkvP9eJW6gpueU8cnZhv85ELY8w8WiFtNjMHA5ad6pQLaJo9mEly/9qUyCpvqX8/POVUTf18/HFdw==", "license": "Apache-2.0", - "peer": true, "dependencies": { "@chevrotain/cst-dts-gen": "11.0.3", "@chevrotain/gast": "11.0.3", @@ -2433,7 +2429,6 @@ "resolved": "https://registry.npmmirror.com/@lezer/markdown/-/markdown-1.6.0.tgz", "integrity": "sha512-AXb98u3M6BEzTnreBnGtQaF7xFTiMA92Dsy5tqEjpacbjRxDSFdN4bKJo9uvU4cEEOS7D2B9MT7kvDgOEIzJSw==", "license": "MIT", - "peer": true, "dependencies": { "@lezer/common": "^1.0.0", "@lezer/highlight": "^1.0.0" @@ -2713,6 +2708,27 @@ "node": ">= 10" } }, + "node_modules/@noble/ed25519": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@noble/ed25519/-/ed25519-3.1.0.tgz", + "integrity": "sha512-pfcObRY3CtvwfaG9Mt5XqZdKmAQppl37tHUeuBhDUbiwJBCVY4/A4lbMvb1xKhMDx96AqAqZpMWuBX1HulhX4g==", + "license": "MIT", + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@noble/hashes": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-2.2.0.tgz", + "integrity": "sha512-IYqDGiTXab6FniAgnSdZwgWbomxpy9FtYvLKs7wCUs2a8RkITG+DFGO1DM9cr+E3/RgADRpFjrKVaJ1z6sjtEg==", + "license": "MIT", + "engines": { + "node": ">= 20.19.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, "node_modules/@nodable/entities": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/@nodable/entities/-/entities-2.1.0.tgz", @@ -4018,6 +4034,7 @@ "integrity": "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@babel/code-frame": "^7.10.4", "@babel/runtime": "^7.12.5", @@ -4038,6 +4055,7 @@ "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "ansi-regex": "^5.0.1", "ansi-styles": "^5.0.0", @@ -4052,7 +4070,8 @@ "resolved": "https://registry.npmmirror.com/react-is/-/react-is-17.0.2.tgz", "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/@testing-library/jest-dom": { "version": "6.9.1", @@ -4142,7 +4161,8 @@ "resolved": "https://registry.npmmirror.com/@types/aria-query/-/aria-query-5.0.4.tgz", "integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/@types/babel__core": { "version": "7.20.5", @@ -4552,7 +4572,6 @@ "integrity": "sha512-LCCV0HdSZZZb34qifBsyWlUmok6W7ouER+oQIGBScS8EsZsQbrtFTUrDX4hOl+CS6p7cnNC4td+qrSVGSCTUfQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "undici-types": "~6.21.0" } @@ -4595,7 +4614,6 @@ "integrity": "sha512-cisd7gxkzjBKU2GgdYrTdtQx1SORymWyaAFhaxQPK9bYO9ot3Y5OikQRvY0VYQtvwjeQnizCINJAenh/V7MK2w==", "devOptional": true, "license": "MIT", - "peer": true, "dependencies": { "@types/prop-types": "*", "csstype": "^3.2.2" @@ -4607,7 +4625,6 @@ "integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==", "devOptional": true, "license": "MIT", - "peer": true, "peerDependencies": { "@types/react": "^18.0.0" } @@ -4707,7 +4724,6 @@ "integrity": "sha512-x7FptB5oDruxNPDNY2+S8tCh0pcq7ymCe1gTHcsp733jYjrJl8V1gMUlVysuCD9Kz46Xz9t1akkv08dPcYDs1w==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@bcoe/v8-coverage": "^1.0.2", "@vitest/utils": "4.1.4", @@ -4913,7 +4929,6 @@ "resolved": "https://registry.npmjs.org/ai/-/ai-6.0.168.tgz", "integrity": "sha512-2HqCJuO+1V2aV7vfYs5LFEUfxbkGX+5oa54q/gCCTL7KLTdbxcCu5D7TdLA5kwsrs3Szgjah9q6D9tpjHM3hUQ==", "license": "Apache-2.0", - "peer": true, "dependencies": { "@ai-sdk/gateway": "3.0.104", "@ai-sdk/provider": "3.0.8", @@ -4943,6 +4958,7 @@ "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=10" }, @@ -5518,7 +5534,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "baseline-browser-mapping": "^2.8.25", "caniuse-lite": "^1.0.30001754", @@ -5883,7 +5898,6 @@ "resolved": "https://registry.npmjs.org/chevrotain/-/chevrotain-12.0.0.tgz", "integrity": "sha512-csJvb+6kEiQaqo1woTdSAuOWdN0WTLIydkKrBnS+V5gZz0oqBrp4kQ35519QgK6TpBThiG3V1vNSHlIkv4AglQ==", "license": "Apache-2.0", - "peer": true, "dependencies": { "@chevrotain/cst-dts-gen": "12.0.0", "@chevrotain/gast": "12.0.0", @@ -6209,7 +6223,8 @@ "integrity": "sha512-+R08/oI0nl3vfPcqftZRpytksBXDzOUveBq/NBVx0sUp1axwzPQrKinNx5yd5sxPu8j1wIy8AfnVQ+5eFdha6Q==", "dev": true, "license": "MIT", - "optional": true + "optional": true, + "peer": true }, "node_modules/cross-env": { "version": "7.0.3", @@ -6307,7 +6322,6 @@ "resolved": "https://registry.npmmirror.com/cytoscape/-/cytoscape-3.33.1.tgz", "integrity": "sha512-iJc4TwyANnOGR1OmWhsS9ayRS3s+XQ185FmuHObThD+5AeJCakAAbWv8KimMTt08xCCLNgneQwFp+JRJOr9qGQ==", "license": "MIT", - "peer": true, "engines": { "node": ">=0.10" } @@ -6717,7 +6731,6 @@ "resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz", "integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==", "license": "ISC", - "peer": true, "engines": { "node": ">=12" } @@ -7075,7 +7088,6 @@ "integrity": "sha512-glMJgnTreo8CFINujtAhCgN96QAqApDMZ8Vl1r8f0QT8QprvC1UCltV4CcWj20YoIyLZx6IUskaJZ0NV8fokcg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "app-builder-lib": "26.8.1", "builder-util": "26.8.1", @@ -7173,7 +7185,8 @@ "resolved": "https://registry.npmmirror.com/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz", "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/dompurify": { "version": "3.4.1", @@ -7964,6 +7977,7 @@ "dev": true, "hasInstallScript": true, "license": "MIT", + "peer": true, "dependencies": { "@electron/asar": "^3.2.1", "debug": "^4.1.1", @@ -7984,6 +7998,7 @@ "integrity": "sha512-YJDaCJZEnBmcbw13fvdAM9AwNOJwOzrE4pqMqBq5nFiEqXUqHwlK4B+3pUw6JNvfSPtX05xFHtYy/1ni01eGCw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "graceful-fs": "^4.1.2", "jsonfile": "^4.0.0", @@ -7999,6 +8014,7 @@ "integrity": "sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">= 4.0.0" } @@ -9469,7 +9485,6 @@ "integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==", "dev": true, "license": "MIT", - "peer": true, "bin": { "jiti": "bin/jiti.js" } @@ -9479,7 +9494,6 @@ "resolved": "https://registry.npmjs.org/jotai/-/jotai-2.11.0.tgz", "integrity": "sha512-zKfoBBD1uDw3rljwHkt0fWuja1B76R7CjznuBO+mSX6jpsO1EBeWNRKpeaQho9yPI/pvCv4recGfgOXGxwPZvQ==", "license": "MIT", - "peer": true, "engines": { "node": ">=12.20.0" }, @@ -9862,6 +9876,7 @@ "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==", "dev": true, "license": "MIT", + "peer": true, "bin": { "lz-string": "bin/bin.js" } @@ -10328,6 +10343,7 @@ "integrity": "sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "minimist": "^1.2.6" }, @@ -11105,7 +11121,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", @@ -11256,6 +11271,7 @@ "dev": true, "license": "MIT", "optional": true, + "peer": true, "dependencies": { "commander": "^9.4.0" }, @@ -11273,6 +11289,7 @@ "dev": true, "license": "MIT", "optional": true, + "peer": true, "engines": { "node": "^12.20.0 || >=14" } @@ -11451,7 +11468,6 @@ "resolved": "https://registry.npmmirror.com/react/-/react-18.3.1.tgz", "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", "license": "MIT", - "peer": true, "dependencies": { "loose-envify": "^1.1.0" }, @@ -11464,7 +11480,6 @@ "resolved": "https://registry.npmmirror.com/react-dom/-/react-dom-18.3.1.tgz", "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", "license": "MIT", - "peer": true, "dependencies": { "loose-envify": "^1.1.0", "scheduler": "^0.23.2" @@ -11781,6 +11796,7 @@ "deprecated": "Rimraf versions prior to v4 are no longer supported", "dev": true, "license": "ISC", + "peer": true, "dependencies": { "glob": "^7.1.3" }, @@ -12555,6 +12571,7 @@ "integrity": "sha512-yYrrsWnrXMcdsnu/7YMYAofM1ktpL5By7vZhf15CrXijWWrEYZks5AXBudalfSWJLlnen/QUJUB5aoB0kqZUGA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "mkdirp": "^0.5.1", "rimraf": "~2.6.2" @@ -12723,7 +12740,6 @@ "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -12978,7 +12994,6 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "dev": true, "license": "Apache-2.0", - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -13222,7 +13237,6 @@ "integrity": "sha512-Bby3NOsna2jsjfLVOHKes8sGwgl4TT0E6vvpYgnAYDIF/tie7MRaFthmKuHx1NSXjiTueXH3do80FMQgvEktRg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "esbuild": "^0.27.0", "fdir": "^6.5.0", @@ -13316,7 +13330,6 @@ "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -13330,7 +13343,6 @@ "integrity": "sha512-tFuJqTxKb8AvfyqMfnavXdzfy3h3sWZRWwfluGbkeR7n0HUev+FmNgZ8SDrRBTVrVCjgH5cA21qGbCffMNtWvg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@vitest/expect": "4.1.4", "@vitest/mocker": "4.1.4", @@ -13747,7 +13759,6 @@ "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.3.tgz", "integrity": "sha512-AvbaCLOO2Otw/lW5bmh9d/WEdcDFdQp2Z2ZUH3pX9U2ihyUY0nvLv7J6TrWowklRGPYbB/IuIMfYgxaCPg5Bpg==", "license": "ISC", - "peer": true, "bin": { "yaml": "bin.mjs" }, @@ -13826,7 +13837,6 @@ "resolved": "https://registry.npmmirror.com/zod/-/zod-4.1.13.tgz", "integrity": "sha512-AvvthqfqrAhNH9dnfmrfKzX5upOdjUVJYFqNSlkmGf64gRaTzlPwz99IHYnVs28qYAybvAlBV+H7pn0saFY4Ig==", "license": "MIT", - "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" } diff --git a/package.json b/package.json index 855048f2..5196ffcb 100644 --- a/package.json +++ b/package.json @@ -50,6 +50,8 @@ "@codemirror/view": "^6.38.8", "@excalidraw/excalidraw": "^0.18.1", "@lezer/markdown": "^1.6.0", + "@noble/ed25519": "^3.1.0", + "@noble/hashes": "^2.2.0", "@opencode-ai/sdk": "^1.14.20", "@openrouter/ai-sdk-provider": "^2.8.0", "ai": "^6.0.168", diff --git a/src/components/settings/CloudUsagePanel.test.tsx b/src/components/settings/CloudUsagePanel.test.tsx new file mode 100644 index 00000000..34ce8ce0 --- /dev/null +++ b/src/components/settings/CloudUsagePanel.test.tsx @@ -0,0 +1,152 @@ +import { act, render, screen, waitFor } from '@testing-library/react'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import type { LicensePayload, UsageResponse } from '@/services/luminaCloud'; +import { useLicenseStore } from '@/stores/useLicenseStore'; + +import { CloudUsagePanel } from './CloudUsagePanel'; + +const getUsage = vi.hoisted(() => vi.fn()); + +vi.mock('@/services/luminaCloud', async () => { + const actual = await vi.importActual( + '@/services/luminaCloud' + ); + return { + ...actual, + getUsage, + }; +}); + +const VALID_PAYLOAD: LicensePayload = { + v: 1, + lid: 'lic_01HXTEST', + email: 'fixture@example.com', + sku: 'lumina-lifetime-founders', + features: ['cloud_ai'], + issued_at: '2026-04-28T12:00:00Z', + expires_at: null, + order_id: 'creem_ord_test', + device_limit: 5, +}; + +const USAGE: UsageResponse = { + period_start: '2026-04-01T00:00:00Z', + period_end: '2026-04-30T23:59:59Z', + tokens_used: 12345, + tokens_quota: 5_000_000, + requests_count: 17, +}; + +beforeEach(() => { + useLicenseStore.setState({ license: null, payload: null, status: 'idle' }); + getUsage.mockReset(); +}); + +afterEach(() => { + vi.useRealTimers(); +}); + +describe('CloudUsagePanel — license absent', () => { + it('renders nothing when no license is present', () => { + const { container } = render(); + expect(container.firstChild).toBeNull(); + expect(getUsage).not.toHaveBeenCalled(); + }); + + it('renders nothing when status is invalid', () => { + useLicenseStore.setState({ license: 'bad', payload: null, status: 'invalid' }); + const { container } = render(); + expect(container.firstChild).toBeNull(); + expect(getUsage).not.toHaveBeenCalled(); + }); +}); + +describe('CloudUsagePanel — license valid', () => { + beforeEach(() => { + useLicenseStore.setState({ + license: 'valid-token', + payload: VALID_PAYLOAD, + status: 'valid', + }); + }); + + it('shows a loading hint, then the formatted usage line on success', async () => { + getUsage.mockResolvedValue(USAGE); + + render(); + + expect(screen.getByText(/Loading usage/i)).toBeInTheDocument(); + + await waitFor(() => { + expect(screen.getByText(/12,345/)).toBeInTheDocument(); + }); + expect(screen.getByText(/5,000,000/)).toBeInTheDocument(); + expect(screen.getByText(/2026-04-30/)).toBeInTheDocument(); + expect(screen.queryByText(/Retrying/i)).not.toBeInTheDocument(); + }); + + it('keeps showing the last successful value and a Retrying hint after a poll fails', async () => { + vi.useFakeTimers({ toFake: ['setInterval', 'clearInterval'] }); + getUsage.mockResolvedValueOnce(USAGE).mockRejectedValueOnce(new Error('network')); + vi.spyOn(console, 'warn').mockImplementation(() => {}); + + render(); + + // First fetch succeeds. + await waitFor(() => expect(screen.getByText(/12,345/)).toBeInTheDocument()); + + // Advance one poll interval — the second fetch rejects. + await act(async () => { + vi.advanceTimersByTime(60_000); + }); + + await waitFor(() => expect(screen.getByText(/Retrying/i)).toBeInTheDocument()); + // The last successful value is still rendered. + expect(screen.getByText(/12,345/)).toBeInTheDocument(); + }); + + it('shows the no-cache retrying hint when the very first fetch fails', async () => { + getUsage.mockRejectedValue(new Error('network')); + vi.spyOn(console, 'warn').mockImplementation(() => {}); + + render(); + + await waitFor(() => expect(screen.getByText(/Could not fetch usage/i)).toBeInTheDocument()); + expect(screen.queryByText(/Loading usage/i)).not.toBeInTheDocument(); + }); + + it('refetches every 60s while mounted', async () => { + vi.useFakeTimers({ toFake: ['setInterval', 'clearInterval'] }); + getUsage.mockResolvedValue(USAGE); + + render(); + + await waitFor(() => expect(getUsage).toHaveBeenCalledTimes(1)); + + await act(async () => { + vi.advanceTimersByTime(60_000); + }); + await waitFor(() => expect(getUsage).toHaveBeenCalledTimes(2)); + + await act(async () => { + vi.advanceTimersByTime(60_000); + }); + await waitFor(() => expect(getUsage).toHaveBeenCalledTimes(3)); + }); + + it('clears the polling interval on unmount', async () => { + vi.useFakeTimers({ toFake: ['setInterval', 'clearInterval'] }); + getUsage.mockResolvedValue(USAGE); + + const { unmount } = render(); + await waitFor(() => expect(getUsage).toHaveBeenCalledTimes(1)); + + unmount(); + + await act(async () => { + vi.advanceTimersByTime(60_000 * 5); + }); + expect(getUsage).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/components/settings/CloudUsagePanel.tsx b/src/components/settings/CloudUsagePanel.tsx new file mode 100644 index 00000000..9c7732c7 --- /dev/null +++ b/src/components/settings/CloudUsagePanel.tsx @@ -0,0 +1,90 @@ +import { useEffect, useState } from 'react'; + +import { getUsage } from '@/services/luminaCloud'; +import type { UsageResponse } from '@/services/luminaCloud'; +import { useLicenseStore } from '@/stores/useLicenseStore'; + +const POLL_MS = 60_000; + +/** + * "X / Y tokens used this month, resets on …" panel. Renders nothing when + * no valid license is present (no empty-state flash). On network failure, + * keeps showing the last known good value with a quiet "Retrying…" hint. + */ +export function CloudUsagePanel(): JSX.Element | null { + const license = useLicenseStore((s) => s.license); + const status = useLicenseStore((s) => s.status); + const [usage, setUsage] = useState(null); + const [retrying, setRetrying] = useState(false); + + useEffect(() => { + if (status !== 'valid' || !license) { + setUsage(null); + setRetrying(false); + return; + } + + let cancelled = false; + + async function tick(): Promise { + try { + const next = await getUsage(license as string); + if (cancelled) return; + setUsage(next); + setRetrying(false); + } catch (err) { + if (cancelled) return; + setRetrying(true); + console.warn('[cloud-usage] fetch failed', err); + } + } + + void tick(); + const interval = setInterval(tick, POLL_MS); + return () => { + cancelled = true; + clearInterval(interval); + }; + }, [license, status]); + + if (status !== 'valid' || !license) return null; + + return ( +
+

Cloud usage this month

+ + {usage ? ( +

+ {formatTokens(usage.tokens_used)} + {' / '} + {formatTokens(usage.tokens_quota)} + {' tokens used. Resets on '} + {formatResetDate(usage.period_end)}. + {retrying && ( + + Retrying… + + )} +

+ ) : retrying ? ( +

+ Could not fetch usage. Retrying… +

+ ) : ( +

+ Loading usage… +

+ )} +
+ ); +} + +function formatTokens(n: number): string { + return n.toLocaleString('en-US'); +} + +function formatResetDate(iso: string): string { + const ms = Date.parse(iso); + if (!Number.isFinite(ms)) return iso; + return new Date(ms).toISOString().slice(0, 10); +} diff --git a/src/services/luminaCloud/canonical-json.test.ts b/src/services/luminaCloud/canonical-json.test.ts new file mode 100644 index 00000000..13c131ba --- /dev/null +++ b/src/services/luminaCloud/canonical-json.test.ts @@ -0,0 +1,59 @@ +import { describe, expect, it } from 'vitest'; + +import { CanonicalJsonError, canonicalize, canonicalizeToBytes } from './canonical-json'; + +describe('canonicalize', () => { + it('sorts object keys alphabetically', () => { + expect(canonicalize({ b: 1, a: 2 })).toBe('{"a":2,"b":1}'); + expect(canonicalize({ z: { y: 1, x: 2 }, a: 1 })).toBe('{"a":1,"z":{"x":2,"y":1}}'); + }); + + it('preserves array order', () => { + expect(canonicalize([3, 1, 2])).toBe('[3,1,2]'); + expect(canonicalize([{ b: 1, a: 2 }, { d: 4, c: 3 }])).toBe('[{"a":2,"b":1},{"c":3,"d":4}]'); + }); + + it('handles primitives', () => { + expect(canonicalize('hello')).toBe('"hello"'); + expect(canonicalize(42)).toBe('42'); + expect(canonicalize(true)).toBe('true'); + expect(canonicalize(false)).toBe('false'); + expect(canonicalize(null)).toBe('null'); + }); + + it('drops undefined object properties', () => { + expect(canonicalize({ a: 1, b: undefined, c: 3 })).toBe('{"a":1,"c":3}'); + }); + + it('replaces undefined array elements with null (matches JSON.stringify)', () => { + expect(canonicalize([1, undefined, 2])).toBe('[1,null,2]'); + }); + + it('throws on non-finite numbers', () => { + expect(() => canonicalize(Number.NaN)).toThrow(CanonicalJsonError); + expect(() => canonicalize(Number.POSITIVE_INFINITY)).toThrow(CanonicalJsonError); + }); + + it('throws when root is undefined', () => { + expect(() => canonicalize(undefined)).toThrow(CanonicalJsonError); + }); + + it('escapes strings the same way as JSON.stringify', () => { + expect(canonicalize('a"b\\c')).toBe(JSON.stringify('a"b\\c')); + expect(canonicalize('über')).toBe(JSON.stringify('über')); + }); + + it('produces a stable, byte-identical encoding', () => { + const a = canonicalize({ b: 2, a: 1, c: [3, 2, 1] }); + const b = canonicalize({ c: [3, 2, 1], a: 1, b: 2 }); + expect(a).toBe(b); + expect(a).toBe('{"a":1,"b":2,"c":[3,2,1]}'); + }); +}); + +describe('canonicalizeToBytes', () => { + it('returns UTF-8 encoded bytes of the canonical string', () => { + const bytes = canonicalizeToBytes({ a: 1 }); + expect(new TextDecoder().decode(bytes)).toBe('{"a":1}'); + }); +}); diff --git a/src/services/luminaCloud/canonical-json.ts b/src/services/luminaCloud/canonical-json.ts new file mode 100644 index 00000000..3f4f9578 --- /dev/null +++ b/src/services/luminaCloud/canonical-json.ts @@ -0,0 +1,56 @@ +/** + * Canonical JSON serialization (sorted keys, no whitespace) for license + * payload signing per CONTRACT.md §1.2. Matches the simple subset of RFC 8785: + * recursively sort object keys, drop `undefined` values, re-use `JSON.stringify` + * for primitives. + * + * Sufficient for the license payload (CONTRACT.md §1.1) which only contains + * strings, integers, booleans, arrays of strings, and `null`. If the payload + * grows non-integer numbers, replace this with a full JCS implementation. + */ + +export class CanonicalJsonError extends Error { + constructor(message: string) { + super(message); + this.name = 'CanonicalJsonError'; + } +} + +export function canonicalize(value: unknown): string { + if (value === null) return 'null'; + if (value === undefined) { + throw new CanonicalJsonError('Cannot canonicalize `undefined` at root'); + } + return canonicalizeInner(value); +} + +function canonicalizeInner(value: unknown): string { + if (value === null) return 'null'; + const t = typeof value; + if (t === 'boolean') return (value as boolean) ? 'true' : 'false'; + if (t === 'number') { + const n = value as number; + if (!Number.isFinite(n)) { + throw new CanonicalJsonError(`Cannot canonicalize non-finite number: ${n}`); + } + return JSON.stringify(n); + } + if (t === 'string') return JSON.stringify(value); + if (Array.isArray(value)) { + const items = value.map((v) => (v === undefined ? 'null' : canonicalizeInner(v))); + return '[' + items.join(',') + ']'; + } + if (t === 'object') { + const obj = value as Record; + const keys = Object.keys(obj) + .filter((k) => obj[k] !== undefined) + .sort(); + const parts = keys.map((k) => JSON.stringify(k) + ':' + canonicalizeInner(obj[k])); + return '{' + parts.join(',') + '}'; + } + throw new CanonicalJsonError(`Cannot canonicalize value of type ${t}`); +} + +export function canonicalizeToBytes(value: unknown): Uint8Array { + return new TextEncoder().encode(canonicalize(value)); +} diff --git a/src/services/luminaCloud/verify.test.ts b/src/services/luminaCloud/verify.test.ts new file mode 100644 index 00000000..a4d89c22 --- /dev/null +++ b/src/services/luminaCloud/verify.test.ts @@ -0,0 +1,106 @@ +import * as ed from '@noble/ed25519'; +import { sha512 } from '@noble/hashes/sha2.js'; +import { describe, expect, it } from 'vitest'; + +import { canonicalizeToBytes } from './canonical-json'; +import type { LicensePayload } from './types'; +import { verifyLicense } from './verify'; + +ed.hashes.sha512 = sha512; + +// Deterministic test seed — keeps fixture licenses reproducible across runs +// without checking in any private key the production system would use. +const TEST_SECRET = new Uint8Array(32).map((_, i) => (i * 7 + 1) & 0xff); +const TEST_PUBLIC = ed.getPublicKey(TEST_SECRET); +const TEST_PUBLIC_B64 = bytesToBase64(TEST_PUBLIC); + +const FIXTURE_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, +}; + +function signFixture(payload: LicensePayload, secretKey: Uint8Array = TEST_SECRET): string { + const payloadBytes = canonicalizeToBytes(payload); + const sig = ed.sign(payloadBytes, secretKey); + return bytesToBase64Url(payloadBytes) + '.' + bytesToBase64Url(sig); +} + +function bytesToBase64(bytes: Uint8Array): string { + let bin = ''; + for (let i = 0; i < bytes.length; i++) bin += String.fromCharCode(bytes[i]); + return btoa(bin); +} + +function bytesToBase64Url(bytes: Uint8Array): string { + return bytesToBase64(bytes).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, ''); +} + +describe('verifyLicense', () => { + it('returns the payload for a valid fixture license', () => { + const license = signFixture(FIXTURE_PAYLOAD); + const result = verifyLicense(license, TEST_PUBLIC_B64); + expect(result).toEqual(FIXTURE_PAYLOAD); + }); + + it('returns null when a payload byte is tampered', () => { + const license = signFixture(FIXTURE_PAYLOAD); + const [payloadB64, sigB64] = license.split('.'); + // Flip one char in the payload — any base64url char will decode to + // different bytes (unless equal to the original, which is exceedingly unlikely + // for a deterministic seed). + const tamperedPayload = payloadB64.slice(0, 5) + (payloadB64[5] === 'A' ? 'B' : 'A') + payloadB64.slice(6); + const tampered = tamperedPayload + '.' + sigB64; + expect(verifyLicense(tampered, TEST_PUBLIC_B64)).toBeNull(); + }); + + it('returns null when a signature byte is tampered', () => { + const license = signFixture(FIXTURE_PAYLOAD); + const [payloadB64, sigB64] = license.split('.'); + const tamperedSig = sigB64.slice(0, 5) + (sigB64[5] === 'A' ? 'B' : 'A') + sigB64.slice(6); + const tampered = payloadB64 + '.' + tamperedSig; + expect(verifyLicense(tampered, TEST_PUBLIC_B64)).toBeNull(); + }); + + it('returns null when verified against the wrong public key', () => { + const license = signFixture(FIXTURE_PAYLOAD); + const wrongSecret = new Uint8Array(32).fill(0x42); + const wrongPublic = bytesToBase64(ed.getPublicKey(wrongSecret)); + expect(verifyLicense(license, wrongPublic)).toBeNull(); + }); + + it.each([ + ['empty string', ''], + ['missing signature', 'AAAA'], + ['too many parts', 'a.b.c'], + ['empty payload part', '.AAAA'], + ['empty signature part', 'AAAA.'], + ['non-base64 payload', '!!!.AAAA'], + ['non-base64 signature', 'AAAA.!!!'], + ['signature wrong length', 'AAAA.BBBB'], + ])('returns null for malformed input: %s', (_label, license) => { + expect(verifyLicense(license, TEST_PUBLIC_B64)).toBeNull(); + }); + + it('does not throw on malformed input — returns null instead', () => { + expect(() => verifyLicense('garbage', TEST_PUBLIC_B64)).not.toThrow(); + // Non-string input shouldn't throw either, even with the type guard. + // Cast through `unknown` so the type-checker lets us pass garbage. + expect(() => verifyLicense(null as unknown as string, TEST_PUBLIC_B64)).not.toThrow(); + expect(() => verifyLicense(undefined as unknown as string, TEST_PUBLIC_B64)).not.toThrow(); + }); + + it('uses the bundled PUBLIC_KEY_B64 when no second argument is provided', () => { + // The bundled placeholder pubkey is all-zero; signing with our test key + // will not verify against it. So the bundled-key path must return null, + // proving the default is wired up. + const license = signFixture(FIXTURE_PAYLOAD); + expect(verifyLicense(license)).toBeNull(); + }); +}); diff --git a/src/services/luminaCloud/verify.ts b/src/services/luminaCloud/verify.ts index 069ddcd9..9609ab5a 100644 --- a/src/services/luminaCloud/verify.ts +++ b/src/services/luminaCloud/verify.ts @@ -1,14 +1,90 @@ +import * as ed from '@noble/ed25519'; +import { sha512 } from '@noble/hashes/sha2.js'; + +import { PUBLIC_KEY_B64 } from './PUBLIC_KEY'; import type { LicensePayload } from './types'; +// Wire SHA-512 once at module load so synchronous Ed25519 verify works in any +// environment (Node, Electron renderer, jsdom). `@noble/ed25519` v3 leaves +// this slot empty by design. +ed.hashes.sha512 = sha512; + /** * Offline license verification per CONTRACT.md §1.3. * * Returns the decoded payload iff the Ed25519 signature verifies against the - * bundled public key, otherwise returns `null`. Never throws — malformed - * input yields `null` too. + * public key. Returns `null` for any invalid input — never throws. The + * caller is still responsible for downstream checks (`expires_at`, revocation + * list — CONTRACT.md §1.3 second paragraph). * - * Implemented in task C2 with `@noble/ed25519`. + * The optional second argument exists for tests and multi-key scenarios; + * production callers should use the bundled `PUBLIC_KEY_B64` default. */ -export function verifyLicense(_license: string): LicensePayload | null { - throw new Error('luminaCloud.verifyLicense: not implemented yet (task C2)'); +export function verifyLicense( + license: string, + publicKeyB64: string = PUBLIC_KEY_B64 +): LicensePayload | null { + if (typeof license !== 'string' || license.length === 0) return null; + + const dot = license.indexOf('.'); + if (dot <= 0 || dot === license.length - 1) return null; + const payloadB64 = license.slice(0, dot); + const sigB64 = license.slice(dot + 1); + if (sigB64.includes('.')) return null; + + const payloadBytes = base64urlDecode(payloadB64); + const sigBytes = base64urlDecode(sigB64); + const pubBytes = base64Decode(publicKeyB64); + if (!payloadBytes || !sigBytes || !pubBytes) return null; + if (sigBytes.length !== 64 || pubBytes.length !== 32) return null; + + let ok = false; + try { + ok = ed.verify(sigBytes, payloadBytes, pubBytes); + } catch { + return null; + } + if (!ok) return null; + + return decodePayload(payloadBytes); +} + +function decodePayload(bytes: Uint8Array): LicensePayload | null { + let text: string; + try { + text = new TextDecoder('utf-8', { fatal: true }).decode(bytes); + } catch { + return null; + } + let parsed: unknown; + try { + parsed = JSON.parse(text); + } catch { + return null; + } + if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) { + return null; + } + return parsed as LicensePayload; +} + +function base64urlDecode(s: string): Uint8Array | null { + if (typeof s !== 'string' || s.length === 0) return null; + if (!/^[A-Za-z0-9_-]+$/.test(s)) return null; + let b64 = s.replace(/-/g, '+').replace(/_/g, '/'); + while (b64.length % 4 !== 0) b64 += '='; + return base64Decode(b64); +} + +function base64Decode(s: string): Uint8Array | null { + if (typeof s !== 'string' || s.length === 0) return null; + if (!/^[A-Za-z0-9+/]+=*$/.test(s)) return null; + try { + const bin = atob(s); + const out = new Uint8Array(bin.length); + for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i); + return out; + } catch { + return null; + } } 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' }); + }, +}));