diff --git a/cloud/TASKS.md b/cloud/TASKS.md index 7623cb90..dc56d322 100644 --- a/cloud/TASKS.md +++ b/cloud/TASKS.md @@ -19,7 +19,7 @@ ## P1 — Lumina Cloud client integration ### C1 — Scaffold `src/services/luminaCloud/` -- [ ] **Goal:** Empty-but-typed module structure: `client.ts`, `types.ts`, `PUBLIC_KEY.ts`, `verify.ts`, `store.ts`, `revocations.ts`, `index.ts` (barrel). +- [x] **Goal:** Empty-but-typed module structure: `client.ts`, `types.ts`, `PUBLIC_KEY.ts`, `verify.ts`, `store.ts`, `revocations.ts`, `index.ts` (barrel). - **Acceptance:** - `types.ts` mirrors `cloud/CONTRACT.md` §1.1 license payload + §2.4 usage response + §6 error shapes. - `index.ts` re-exports the public surface. @@ -126,3 +126,5 @@ ## Done log (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 diff --git a/src/services/luminaCloud/PUBLIC_KEY.ts b/src/services/luminaCloud/PUBLIC_KEY.ts new file mode 100644 index 00000000..6b1b2765 --- /dev/null +++ b/src/services/luminaCloud/PUBLIC_KEY.ts @@ -0,0 +1,12 @@ +/** + * Ed25519 public key for verifying Lumina Cloud licenses (CONTRACT.md §1.2, §7). + * + * Format: base64-encoded 32-byte raw Ed25519 public key. + * + * LEAD: replace with real public key from lumina-cloud T3 output. + * + * Until the real key is delivered, tests use a fixture keypair (see C2 task). + * The placeholder below is deliberately a recognizably-fake all-`A` string so + * any accidental ship-to-prod fails verification immediately. + */ +export const PUBLIC_KEY_B64 = 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA='; diff --git a/src/services/luminaCloud/client.ts b/src/services/luminaCloud/client.ts new file mode 100644 index 00000000..ae0d7a74 --- /dev/null +++ b/src/services/luminaCloud/client.ts @@ -0,0 +1,32 @@ +import type { + LicenseVerifyResponse, + ModelsResponse, + RevocationsResponse, + UsageResponse, +} from './types'; + +/** + * Typed HTTP client for the Lumina Cloud REST surface (CONTRACT.md §2). + * Implemented in task C5 — this scaffold only fixes the public shape. + * + * Base URL is configurable via `LUMINA_CLOUD_BASE_URL`, default + * `https://api.lumina-note.com`. + */ + +export const DEFAULT_BASE_URL = 'https://api.lumina-note.com'; + +export async function verifyLicenseOnline(_license: string): Promise { + throw new Error('luminaCloud.client.verifyLicenseOnline: not implemented yet (task C5)'); +} + +export async function getModels(_license: string): Promise { + throw new Error('luminaCloud.client.getModels: not implemented yet (task C5)'); +} + +export async function getUsage(_license: string): Promise { + throw new Error('luminaCloud.client.getUsage: not implemented yet (task C5)'); +} + +export async function getRevocations(_since?: string): Promise { + throw new Error('luminaCloud.client.getRevocations: not implemented yet (task C5)'); +} diff --git a/src/services/luminaCloud/index.ts b/src/services/luminaCloud/index.ts new file mode 100644 index 00000000..7023de37 --- /dev/null +++ b/src/services/luminaCloud/index.ts @@ -0,0 +1,34 @@ +/** + * Lumina Cloud client — public barrel. + * + * Wire types and behaviors are pinned to `cloud/CONTRACT.md`. See + * `cloud/TASKS.md` for the per-file rollout (C1…C13). + */ + +export type { + CloudErrorBody, + CloudErrorCode, + CloudModel, + LicensePayload, + LicenseStatus, + LicenseVerifyResponse, + ModelsResponse, + RevocationsResponse, + UsageResponse, +} from './types'; + +export { PUBLIC_KEY_B64 } from './PUBLIC_KEY'; + +export { verifyLicense } from './verify'; + +export { loadLicense, removeLicense, saveLicense } from './store'; + +export { isRevoked } from './revocations'; + +export { + DEFAULT_BASE_URL, + getModels, + getRevocations, + getUsage, + verifyLicenseOnline, +} from './client'; diff --git a/src/services/luminaCloud/revocations.ts b/src/services/luminaCloud/revocations.ts new file mode 100644 index 00000000..a2157a0e --- /dev/null +++ b/src/services/luminaCloud/revocations.ts @@ -0,0 +1,8 @@ +/** + * Local revocation cache, refreshed daily from `GET /v1/license/revocations` + * (CONTRACT.md §2.5). Implemented in task C6. + */ + +export async function isRevoked(_lid: string): Promise { + throw new Error('luminaCloud.isRevoked: not implemented yet (task C6)'); +} diff --git a/src/services/luminaCloud/store.ts b/src/services/luminaCloud/store.ts new file mode 100644 index 00000000..5aad0999 --- /dev/null +++ b/src/services/luminaCloud/store.ts @@ -0,0 +1,19 @@ +/** + * License storage in the OS keychain (Electron `safeStorage`) with a Linux + * file fallback. Implemented in task C3. + * + * The functions are async because the IPC bridge to the Electron main process + * is async; the in-memory derived state lives in `useLicenseStore` (task C4). + */ + +export async function saveLicense(_license: string): Promise { + throw new Error('luminaCloud.saveLicense: not implemented yet (task C3)'); +} + +export async function loadLicense(): Promise { + throw new Error('luminaCloud.loadLicense: not implemented yet (task C3)'); +} + +export async function removeLicense(): Promise { + throw new Error('luminaCloud.removeLicense: not implemented yet (task C3)'); +} diff --git a/src/services/luminaCloud/types.ts b/src/services/luminaCloud/types.ts new file mode 100644 index 00000000..76c2093d --- /dev/null +++ b/src/services/luminaCloud/types.ts @@ -0,0 +1,88 @@ +/** + * Wire types mirroring `cloud/CONTRACT.md`. Keep this file in lock-step with + * the contract — when the contract changes, update here and bump tests. + */ + +// §1.1 License payload (canonical JSON, sorted keys, embedded in the license token) +export interface LicensePayload { + /** Schema version — currently 1. */ + v: number; + /** License id, ULID-shaped. */ + lid: string; + /** Buyer email, lowercased. */ + email: string; + /** SKU identifier — see CONTRACT.md §3. */ + sku: string; + /** Feature flags — see CONTRACT.md §4. Unknown flags must be ignored by the client. */ + features: string[]; + /** ISO 8601 UTC, `Z` suffix. */ + issued_at: string; + /** ISO 8601 UTC `Z`, or null for lifetime licenses. */ + expires_at: string | null; + /** Upstream order id from Creem. */ + order_id: string; + /** Soft, advisory device cap — clients may ignore. */ + device_limit: number; +} + +// §2.1 Online verification response +export type LicenseVerifyResponse = + | { + valid: true; + payload: LicensePayload; + revoked: boolean; + usage: UsageResponse; + } + | { + valid: false; + reason: 'signature_invalid' | 'revoked' | 'expired' | 'malformed'; + }; + +// §2.3 Models list +export interface CloudModel { + id: string; + upstream: string; + context: number; +} + +export interface ModelsResponse { + data: CloudModel[]; +} + +// §2.4 Usage response +export interface UsageResponse { + period_start: string; + period_end: string; + tokens_used: number; + tokens_quota: number; + requests_count: number; +} + +// §2.5 Revocations +export interface RevocationsResponse { + as_of: string; + revoked_lids: string[]; +} + +// §6 Error format +export type CloudErrorCode = + | 'bad_request' + | 'invalid_license' + | 'revoked_license' + | 'expired_license' + | 'quota_exceeded' + | 'feature_disabled' + | 'not_found' + | 'rate_limit' + | 'internal' + | 'upstream_unavailable'; + +export interface CloudErrorBody { + error: { + code: CloudErrorCode; + message: string; + }; +} + +// Client-side derived state — used by the Zustand store (C4) +export type LicenseStatus = 'idle' | 'loading' | 'valid' | 'invalid'; diff --git a/src/services/luminaCloud/verify.ts b/src/services/luminaCloud/verify.ts new file mode 100644 index 00000000..069ddcd9 --- /dev/null +++ b/src/services/luminaCloud/verify.ts @@ -0,0 +1,14 @@ +import type { LicensePayload } from './types'; + +/** + * 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. + * + * Implemented in task C2 with `@noble/ed25519`. + */ +export function verifyLicense(_license: string): LicensePayload | null { + throw new Error('luminaCloud.verifyLicense: not implemented yet (task C2)'); +}