diff --git a/cloud/TASKS.md b/cloud/TASKS.md index dc56d322..90364521 100644 --- a/cloud/TASKS.md +++ b/cloud/TASKS.md @@ -54,7 +54,7 @@ - Tests cover all four `status` transitions. ### C5 — Lumina Cloud HTTP client -- [ ] **Goal:** `client.ts` exports a typed client for `CONTRACT.md` §2.1, §2.2 (chat — see C7), §2.3, §2.4, §2.5. +- [x] **Goal:** `client.ts` exports a typed client for `CONTRACT.md` §2.1, §2.2 (chat — see C7), §2.3, §2.4, §2.5. - **Acceptance:** - `verifyLicenseOnline(license)` → `§2.1` shape. - `getModels(license)` → `§2.3` shape. @@ -128,3 +128,4 @@ (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] C5 — 2026-04-28 — 0d7eb75 — typed HTTP client + LuminaCloudError; 21 tests; no new runtime deps (manual fetch mock) diff --git a/src/services/luminaCloud/client.test.ts b/src/services/luminaCloud/client.test.ts new file mode 100644 index 00000000..9c316e02 --- /dev/null +++ b/src/services/luminaCloud/client.test.ts @@ -0,0 +1,228 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { + DEFAULT_BASE_URL, + getBaseUrl, + getModels, + getRevocations, + getUsage, + LuminaCloudError, + verifyLicenseOnline, +} from './client'; +import type { LuminaCloudErrorCode } from './client'; + +type FetchMock = ReturnType; + +function mockFetch(): FetchMock { + const fn = vi.fn(); + vi.stubGlobal('fetch', fn); + return fn; +} + +function jsonOk(body: unknown, init: { status?: number; headers?: Record } = {}): Response { + return new Response(JSON.stringify(body), { + status: init.status ?? 200, + headers: { 'Content-Type': 'application/json', ...init.headers }, + }); +} + +function jsonError( + status: number, + body: unknown = { error: { code: 'internal', message: 'oops' } }, + init: { headers?: Record } = {} +): Response { + return new Response(JSON.stringify(body), { + status, + headers: { 'Content-Type': 'application/json', ...init.headers }, + }); +} + +describe('luminaCloud client', () => { + let fetchMock: FetchMock; + + beforeEach(() => { + fetchMock = mockFetch(); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + }); + + describe('getBaseUrl', () => { + it('defaults to https://api.lumina-note.com', () => { + expect(getBaseUrl()).toBe(DEFAULT_BASE_URL); + }); + + it('uses VITE_LUMINA_CLOUD_BASE_URL when set, stripping trailing slash', () => { + vi.stubEnv('VITE_LUMINA_CLOUD_BASE_URL', 'https://staging.lumina-note.com/'); + expect(getBaseUrl()).toBe('https://staging.lumina-note.com'); + vi.unstubAllEnvs(); + }); + + it('falls back to default when env var is empty / whitespace', () => { + vi.stubEnv('VITE_LUMINA_CLOUD_BASE_URL', ' '); + expect(getBaseUrl()).toBe(DEFAULT_BASE_URL); + vi.unstubAllEnvs(); + }); + }); + + describe('verifyLicenseOnline', () => { + it('POSTs to /v1/license/verify with the license body and no Authorization header', async () => { + fetchMock.mockResolvedValue(jsonOk({ valid: false, reason: 'malformed' })); + + const result = await verifyLicenseOnline('some-token'); + + expect(result).toEqual({ valid: false, reason: 'malformed' }); + expect(fetchMock).toHaveBeenCalledTimes(1); + const [url, init] = fetchMock.mock.calls[0]; + expect(url).toBe(`${DEFAULT_BASE_URL}/v1/license/verify`); + expect(init.method).toBe('POST'); + expect((init.headers as Record)['Content-Type']).toBe('application/json'); + expect((init.headers as Record).Authorization).toBeUndefined(); + expect(JSON.parse(init.body as string)).toEqual({ license: 'some-token' }); + }); + + it('returns the §2.1 valid:true response shape on 200', async () => { + const payloadResponse = { + valid: true, + payload: { v: 1, lid: 'lic_x', email: 'a@b.c', sku: 'lumina-lifetime-founders', features: [], issued_at: '2026-04-28T00:00:00Z', expires_at: null, order_id: 'o', device_limit: 5 }, + revoked: false, + usage: { period_start: '2026-04-01T00:00:00Z', period_end: '2026-04-30T23:59:59Z', tokens_used: 0, tokens_quota: 5_000_000, requests_count: 0 }, + }; + fetchMock.mockResolvedValue(jsonOk(payloadResponse)); + + const result = await verifyLicenseOnline('token'); + + expect(result).toEqual(payloadResponse); + }); + }); + + describe('getModels', () => { + it('GETs /v1/ai/models with Bearer auth and returns the §2.3 shape', async () => { + const body = { + data: [ + { id: 'lumina:claude-opus-4-7', upstream: 'anthropic/claude-opus-4-7', context: 1_000_000 }, + ], + }; + fetchMock.mockResolvedValue(jsonOk(body)); + + const result = await getModels('LIC'); + + expect(result).toEqual(body); + const [url, init] = fetchMock.mock.calls[0]; + expect(url).toBe(`${DEFAULT_BASE_URL}/v1/ai/models`); + expect(init.method).toBe('GET'); + expect((init.headers as Record).Authorization).toBe('Bearer LIC'); + }); + }); + + describe('getUsage', () => { + it('GETs /v1/account/usage with Bearer auth and returns the §2.4 shape', async () => { + const body = { + 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, + }; + fetchMock.mockResolvedValue(jsonOk(body)); + + const result = await getUsage('LIC'); + + expect(result).toEqual(body); + const [url, init] = fetchMock.mock.calls[0]; + expect(url).toBe(`${DEFAULT_BASE_URL}/v1/account/usage`); + expect((init.headers as Record).Authorization).toBe('Bearer LIC'); + }); + }); + + describe('getRevocations', () => { + it('GETs without auth and without ?since when none provided', async () => { + fetchMock.mockResolvedValue(jsonOk({ as_of: 't', revoked_lids: [] })); + + await getRevocations(); + + const [url, init] = fetchMock.mock.calls[0]; + expect(url).toBe(`${DEFAULT_BASE_URL}/v1/license/revocations`); + expect((init.headers as Record | undefined)?.Authorization).toBeUndefined(); + }); + + it('appends ?since= when provided', async () => { + fetchMock.mockResolvedValue(jsonOk({ as_of: 't', revoked_lids: [] })); + + await getRevocations('2026-04-28T00:00:00Z'); + + const [url] = fetchMock.mock.calls[0]; + expect(url).toBe(`${DEFAULT_BASE_URL}/v1/license/revocations?since=2026-04-28T00%3A00%3A00Z`); + }); + }); + + describe('error mapping', () => { + it.each([ + [400, 'bad_request'], + [401, 'invalid_license'], + [402, 'quota_exceeded'], + [403, 'feature_disabled'], + [404, 'not_found'], + [429, 'rate_limit'], + [500, 'internal'], + [502, 'upstream_unavailable'], + ])('maps HTTP %i to code %s when body is unparseable', async (status, expected) => { + fetchMock.mockResolvedValue(new Response('not json', { status })); + + const err = await getUsage('LIC').catch((e: unknown) => e); + + expect(err).toBeInstanceOf(LuminaCloudError); + const e = err as LuminaCloudError; + expect(e.code).toBe(expected as LuminaCloudErrorCode); + expect(e.status).toBe(status); + }); + + it('uses the server-provided code when the error body parses', async () => { + fetchMock.mockResolvedValue( + jsonError(401, { error: { code: 'revoked_license', message: 'license is revoked' } }) + ); + + const err = await getUsage('LIC').catch((e: unknown) => e); + + expect(err).toBeInstanceOf(LuminaCloudError); + const e = err as LuminaCloudError; + expect(e.code).toBe('revoked_license'); + expect(e.message).toBe('license is revoked'); + expect(e.status).toBe(401); + }); + + it('exposes Retry-After on 429', async () => { + fetchMock.mockResolvedValue( + jsonError(429, { error: { code: 'rate_limit', message: 'slow down' } }, { headers: { 'Retry-After': '12' } }) + ); + + const err = await getUsage('LIC').catch((e: unknown) => e); + + const e = err as LuminaCloudError; + expect(e.code).toBe('rate_limit'); + expect(e.retryAfterSeconds).toBe(12); + }); + + it('translates fetch rejection into LuminaCloudError code=network', async () => { + fetchMock.mockRejectedValue(new TypeError('failed to fetch')); + + const err = await getUsage('LIC').catch((e: unknown) => e); + + expect(err).toBeInstanceOf(LuminaCloudError); + const e = err as LuminaCloudError; + expect(e.code).toBe('network'); + expect(e.status).toBeNull(); + expect(e.message).toBe('failed to fetch'); + }); + + it('falls back to code=unknown for an unmapped 4xx without body', async () => { + fetchMock.mockResolvedValue(new Response('', { status: 418 })); + + const err = await getUsage('LIC').catch((e: unknown) => e); + + expect((err as LuminaCloudError).code).toBe('unknown'); + expect((err as LuminaCloudError).status).toBe(418); + }); + }); +}); diff --git a/src/services/luminaCloud/client.ts b/src/services/luminaCloud/client.ts index ae0d7a74..6e89d725 100644 --- a/src/services/luminaCloud/client.ts +++ b/src/services/luminaCloud/client.ts @@ -1,4 +1,6 @@ import type { + CloudErrorBody, + CloudErrorCode, LicenseVerifyResponse, ModelsResponse, RevocationsResponse, @@ -7,26 +9,142 @@ import type { /** * 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`. + * Base URL is configurable via `VITE_LUMINA_CLOUD_BASE_URL` (Vite-style env); + * 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)'); +/** + * `code` is either a server-defined CONTRACT.md §6 value, or a client-side + * label (`'network'` / `'unknown'`) for failures that never reached the wire + * or arrived without a parseable error body. + */ +export type LuminaCloudErrorCode = CloudErrorCode | 'network' | 'unknown'; + +export class LuminaCloudError extends Error { + readonly code: LuminaCloudErrorCode; + readonly status: number | null; + readonly retryAfterSeconds: number | null; + + constructor(opts: { + code: LuminaCloudErrorCode; + message: string; + status: number | null; + retryAfterSeconds?: number | null; + }) { + super(opts.message); + this.name = 'LuminaCloudError'; + this.code = opts.code; + this.status = opts.status; + this.retryAfterSeconds = opts.retryAfterSeconds ?? null; + } +} + +export function getBaseUrl(): string { + const raw = readEnv('VITE_LUMINA_CLOUD_BASE_URL'); + const trimmed = typeof raw === 'string' ? raw.trim() : ''; + if (!trimmed) return DEFAULT_BASE_URL; + return trimmed.replace(/\/+$/, ''); +} + +function readEnv(name: string): string | undefined { + // Vite renderer: `import.meta.env` is the canonical source. Some test + // harnesses (vitest's `vi.stubEnv`) only mirror to `process.env`, so check + // both. `import.meta` is always defined in this ESM file; the optional + // chaining guards a hypothetical future CJS consumer. + const fromMeta = (import.meta as ImportMeta | undefined)?.env?.[name as keyof ImportMetaEnv]; + if (typeof fromMeta === 'string' && fromMeta.length > 0) return fromMeta; + if (typeof process !== 'undefined' && process.env) { + const fromProcess = process.env[name]; + if (typeof fromProcess === 'string' && fromProcess.length > 0) return fromProcess; + } + return undefined; +} + +export async function verifyLicenseOnline(license: string): Promise { + return fetchJson(`${getBaseUrl()}/v1/license/verify`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ license }), + }); +} + +export async function getModels(license: string): Promise { + return fetchJson(`${getBaseUrl()}/v1/ai/models`, { + method: 'GET', + headers: { Authorization: `Bearer ${license}` }, + }); +} + +export async function getUsage(license: string): Promise { + return fetchJson(`${getBaseUrl()}/v1/account/usage`, { + method: 'GET', + headers: { Authorization: `Bearer ${license}` }, + }); } -export async function getModels(_license: string): Promise { - throw new Error('luminaCloud.client.getModels: not implemented yet (task C5)'); +export async function getRevocations(since?: string): Promise { + const url = new URL(`${getBaseUrl()}/v1/license/revocations`); + if (since) url.searchParams.set('since', since); + return fetchJson(url.toString(), { method: 'GET' }); } -export async function getUsage(_license: string): Promise { - throw new Error('luminaCloud.client.getUsage: not implemented yet (task C5)'); +async function fetchJson(url: string, init: RequestInit): Promise { + let response: Response; + try { + response = await fetch(url, init); + } catch (err) { + throw new LuminaCloudError({ + code: 'network', + message: err instanceof Error ? err.message : 'Network failure', + status: null, + }); + } + if (!response.ok) { + throw await readError(response); + } + // Tolerate empty 204 etc., though the contract has no 2xx-without-body cases. + return (await response.json()) as T; +} + +async function readError(response: Response): Promise { + const status = response.status; + const retryAfterRaw = response.headers.get('Retry-After'); + const parsedRetry = retryAfterRaw !== null ? Number.parseInt(retryAfterRaw, 10) : NaN; + const retryAfterSeconds = Number.isFinite(parsedRetry) ? parsedRetry : null; + + let body: Partial | null = null; + try { + body = (await response.json()) as Partial; + } catch { + body = null; + } + + // Server-supplied code wins (per CONTRACT.md §6 — server is authoritative). + const code = body?.error?.code ?? codeForStatus(status); + const message = body?.error?.message ?? `HTTP ${status}`; + return new LuminaCloudError({ code, message, status, retryAfterSeconds }); } -export async function getRevocations(_since?: string): Promise { - throw new Error('luminaCloud.client.getRevocations: not implemented yet (task C5)'); +function codeForStatus(status: number): LuminaCloudErrorCode { + switch (status) { + case 400: + return 'bad_request'; + case 401: + return 'invalid_license'; + case 402: + return 'quota_exceeded'; + case 403: + return 'feature_disabled'; + case 404: + return 'not_found'; + case 429: + return 'rate_limit'; + case 502: + return 'upstream_unavailable'; + default: + return status >= 500 ? 'internal' : 'unknown'; + } } diff --git a/src/services/luminaCloud/index.ts b/src/services/luminaCloud/index.ts index 7023de37..43de1e9c 100644 --- a/src/services/luminaCloud/index.ts +++ b/src/services/luminaCloud/index.ts @@ -27,8 +27,12 @@ export { isRevoked } from './revocations'; export { DEFAULT_BASE_URL, + getBaseUrl, getModels, getRevocations, getUsage, + LuminaCloudError, verifyLicenseOnline, } from './client'; + +export type { LuminaCloudErrorCode } from './client';