cloud/C9: CloudUsagePanel with 60s polling#225
cloud/C9: CloudUsagePanel with 60s polling#225blueberrycongee wants to merge 7 commits intoloop/cloud-C5from
Conversation
verifyLicense decodes the two-part license string per CONTRACT.md §1.3, checks the Ed25519 signature against the bundled public key, and returns the parsed payload or null. Never throws — every malformed input path returns null (empty, missing dot, non-base64, wrong byte lengths, non-UTF-8 payload, non-object JSON). canonical-json.ts provides the simple JCS subset required by §1.2 (sorted keys, no whitespace, undefined drop). Sufficient for the license payload shape (only strings, integers, booleans, string arrays, null) — would need extending for non-integer numbers. verify.ts wires synchronous SHA-512 once at module load via @noble/hashes/sha2, so verifyLicense is sync as the contract pseudocode shows. An optional second arg (publicKeyB64) defaults to PUBLIC_KEY_B64 and exists for tests + multi-key scenarios. Tests cover: valid fixture round-trip, payload tampering, signature tampering, wrong public key, eight malformed-input shapes, no-throw guarantee, and that the bundled placeholder key is the wired default. Deps: @noble/ed25519@^3.1.0 (per task), @noble/hashes@^2.2.0 (needed to wire the sync sha512 slot left empty by ed25519 v3). Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
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) <[email protected]>
Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
Renders "X / Y tokens used. Resets on YYYY-MM-DD." when the user has a valid license, polls every 60s while mounted, returns null when no license is present (no empty-state flash). On network failure, keeps the last successful value and shows a quiet "Retrying…" hint instead of throwing or blanking the panel. Tests cover the three states the spec lists (loading / success / error-with-cache) plus four extras: invisible when status='invalid', the cold-start error path (no cache → "Could not fetch usage. Retrying…"), 60s poll cadence, and cleanup on unmount. 7 tests. Vitest fake timers are scoped to setInterval/clearInterval only — the React-Testing-Library waitFor + act flow needs real microtask handling for the initial mount fetch to settle. Mixing both worked once the fake timers were narrowed. Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 1d3c1b7889
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| async refreshFromKeychain() { | ||
| set({ status: 'loading' }); |
There was a problem hiding this comment.
Invoke keychain hydration during app startup
refreshFromKeychain is implemented, but there is no production call path that runs it on boot (repo-wide refreshFromKeychain usage is only in tests), so the store always starts as idle with license: null and ignores any token previously saved in keychain. That means persisted licenses do not rehydrate automatically and cloud-gated UI remains hidden until some later manual action invokes hydration.
Useful? React with 👍 / 👎.
| if (!payload) { | ||
| set({ license: null, payload: null, status: 'invalid' }); | ||
| return; |
There was a problem hiding this comment.
Clear persisted token when new license fails verification
When setLicense receives an invalid token, this branch clears only in-memory state and returns without touching keychain storage. If a previous valid token was saved, restarting the app can silently restore that old token, which conflicts with the immediate invalid result users just saw. This path should either preserve the current valid token in-memory or explicitly remove/replace persisted storage on failed replacement.
Useful? React with 👍 / 👎.
|
Closing — re-issued cleanly by integrator. |
What
Adds the cloud-usage panel: shows "X / Y tokens used this month, resets on YYYY-MM-DD" when the user has a valid license, polls
getUsageevery 60s while mounted, and degrades gracefully on network failure.Stacking note
C9 needs both C4 (
useLicenseStore) and C5 (getUsage) — those are independent stacks both forking from C1. This branch mergesloop/cloud-C4intoloop/cloud-C5and adds C9 on top. Merge order in this PR's diff:Merge to main order: #217 (C1) → #218 (C2) → #220 (C4) → #221 (C5) → this PR (C9). When you merge them in order, the merge commit here becomes a no-op.
Behavior
null. No empty-state flash, per the spec.clearIntervalon unmount; no leaked timers.Acceptance criteria
client.getUsage()on mount + every 60s while open.How I tested
npm run typecheck: pass.npm test -- --run src/components/settings/CloudUsagePanel.test.tsx: 7/7 pass.Coverage: license-absent (1), license-invalid (1), loading→success (1), success→error-with-cache (1), cold error (1), 60s poll cadence (1), unmount cleanup (1).
Touched files outside src/services/luminaCloud/
src/components/settings/CloudUsagePanel.tsx+ test — both inside the PRD §3 allow-list.cloud/TASKS.md— marked C9[x]and appended Done-log entry.Notes for Lead
vi.useFakeTimersis scoped tosetInterval/clearIntervalonly — the testing-librarywaitFor+actflow needs real microtask handling for the mount fetch to settle. Mixing both worked once the fake timers were narrowed.toLocaleString('en-US')—12345renders as12,345. If you want locale-aware formatting later, swap inuseIntlor similar.formatResetDateslices to YYYY-MM-DD; the contract'speriod_endis ISO 8601 UTC so the date is always valid. The Date.parse → ISO round-trip is defensive in case the server ever returns a non-canonical form.