diff --git a/.project-docs/30-worklog/tasks/20260826-ml03-main-data-service-7c4e2b18.md b/.project-docs/30-worklog/tasks/20260826-ml03-main-data-service-7c4e2b18.md new file mode 100644 index 0000000..ce9d865 --- /dev/null +++ b/.project-docs/30-worklog/tasks/20260826-ml03-main-data-service-7c4e2b18.md @@ -0,0 +1,85 @@ +# Task: Implement ML-03 Main cloud client and Host routes + +## Identity + +- Task ID: 20260826-ml03-main-data-service-7c4e2b18 +- Mode: Feature +- Branch: codex/20260826-ml03-main-data-service-7c4e2b18-ml03-main-data-service +- Worktree: D:\Datas\OthersProjects\makelore-ml03-main-data-service-7c4e2b18 +- Base commit: 5ac08d509f8962a3c2c0ec1b1afef84a435f116d +- Owner: codex-ml03 +- Status: Ready for integration + +## Scope + +- Implement the Main-owned Data Service cloud client and the fixed operational + Host routes from ML-03, including strict DTO/error projection, session token + refresh/replay, active durable-project resolution, destructive confirmations, + and route registration before the Works catch-all. +- Expose one `DataServiceOperations` adapter through the coding composition for + Host routes and the later Pi product-tool ticket. +- Add focused client/adapter/route/registration tests without changing preview + sessions, Pi tools, Renderer credential handling, or loopback transport. + +## Intent And Constraints + +- Base is the exact post-ML-01 commit `5ac08d509f8962a3c2c0ec1b1afef84a435f116d`. +- Keep Works Square session ownership in the existing Main session service. A + logical request may replay only once after an authoritative upstream 401; + timeout, disconnect, 5xx, and other ambiguous failures are never replayed. +- Derive owner/project identity from `requireActiveRealProjectWithIdentity()`; + caller-controlled owner, durable project ID, local path, token, and endpoint + inputs are not accepted. `listProjects` is the only owner-wide operation. +- Keep request and upstream response parsing bounded and strict, project only + documented fields, and return the safe Host envelope. Keep canonical project + memory unchanged in feature mode. +- Preserve existing Host dispatcher behavior and register Data Service before + `handleWorksRoutes`; do not add preview capability, generated SDK, Pi tools, + or unrelated retry/loopback changes in this ticket. + +## Outcome + +- Implemented the Main-owned Data Service cloud client, strict shared DTOs, and the + single `DataServiceOperations` adapter. Active operations resolve the durable ID + through `requireActiveRealProjectWithIdentity()`; owner-wide `listProjects` is the + only operation without active-project resolution. Request/upstream JSON is bounded, + remote DTOs and accepted error context are projected to safe fields, and only an + authoritative cloud 401 can trigger one refresh/replay. Ambiguous transport and + upstream failures are never retried or exposed. +- Added the fixed `/api/works/data-service` Host routes with strict bodies, query + allowlists, strong `If-Match` projection, literal destructive confirmations, and + no caller-controlled owner, durable project ID, local path, token, or endpoint. + Registered the handler before the Works catch-all and added a regression test for + that precedence. The owner-wide list supports the documented recovery sequence: + identify an orphan, explicitly bind a disposable local project in the UI, then + confirm removal while that project is active. +- Wired the adapter through coding composition for Host and the later Pi tool slice; + no preview sessions, Pi tools, Renderer credentials, second session store, or + loopback transport changes were added. + +## Verification + +- `pnpm exec vitest run tests/unit/data-service-client.test.ts + tests/unit/data-service-routes.test.ts + tests/unit/data-service-server-registration.test.ts --maxWorkers=1` — 3 files, + 17 tests passed. +- `pnpm exec vitest run tests/unit/coding-core-routes.test.ts + tests/unit/coding-project-identity.test.ts tests/unit/host-api-proxy.test.ts + tests/unit/works-routes.test.ts --maxWorkers=1` — 4 files, 82 tests passed. +- `pnpm typecheck` — passed. +- `pnpm lint:check` — 0 errors; 5 existing warnings remain in + `src/pages/Home/index.tsx` and `src/pages/Makelore/index.tsx`. +- `git diff --check` — passed (only existing LF/CRLF normalization warnings on + modified files). + +## Follow-ups + +- Run signed-in cross-repository acceptance against the reviewed Works Square server + branch, including real PostgreSQL behavior and the UI bind/recovery flow. +- ML-04 should consume this `DataServiceOperations` seam for in-process parent-worker + tools and preserve the trusted project-path equality check when its context is + available. + +## Promotion Candidates + +- None recorded. diff --git a/electron/api/coding-composition.ts b/electron/api/coding-composition.ts index 2aa3d52..8929b44 100644 --- a/electron/api/coding-composition.ts +++ b/electron/api/coding-composition.ts @@ -31,6 +31,7 @@ import { refreshCodingProviderCredential, } from './coding-provider-auth'; import { createCodingProductHost, type CodingProductComposition } from './coding-product-services'; +import { createDataServiceOperations } from '../services/data-service-client'; import { archivePiConversationSession } from '../coding-runtime/pi/resource-loader'; import { resolveLegacyProjectModel } from '../coding-projects/legacy-v1'; @@ -205,8 +206,10 @@ export function createCodingComposition( productTools, listPiCommands: (conversationId) => conversations.listLiveCommands(conversationId), }); + const dataService = createDataServiceOperations({ projects }); return { attachments, + dataService, productTools, projects, conversations, diff --git a/electron/api/coding-product-services.ts b/electron/api/coding-product-services.ts index 1ec8365..ff84bd8 100644 --- a/electron/api/coding-product-services.ts +++ b/electron/api/coding-product-services.ts @@ -17,6 +17,7 @@ import { import type { CodingConversationService } from '../coding-runtime/conversation-service'; import type { CodingConversationRuntime } from '../coding-runtime/contracts'; import type { PiProductTools } from '../coding-runtime/pi/product-tools'; +import type { DataServiceOperations } from '../services/data-service-client'; export interface ActiveCodingProject { id: string; @@ -35,6 +36,7 @@ export interface CodingProductHost { export interface CodingProductComposition { attachments: CodingAttachmentStore; + dataService: DataServiceOperations; productTools: PiProductTools; projects: CodingProjectService; conversations: CodingConversationService; diff --git a/electron/api/route-handlers.ts b/electron/api/route-handlers.ts index 7697885..fee308e 100644 --- a/electron/api/route-handlers.ts +++ b/electron/api/route-handlers.ts @@ -7,6 +7,7 @@ import { handleAuthRoutes } from './routes/auth'; import { handleImageWorkspaceRoutes } from './routes/image-workspace'; import { handleImagePromptMuseumRoutes } from './routes/image-prompt-museum'; import { handleLearningRoutes } from './routes/learning'; +import { handleDataServiceRoutes } from './routes/data-service'; import { handleWorksRoutes } from './routes/works'; import { handleUserSyncRoutes } from './routes/user-sync'; import { handleSettingsRoutes } from './routes/settings'; @@ -42,6 +43,7 @@ export const hostApiRouteHandlers: readonly HostApiRouteHandler[] = [ handleImageWorkspaceRoutes, handleImagePromptMuseumRoutes, handleLearningRoutes, + handleDataServiceRoutes, handleWorksRoutes, handleAgentBrowserRoutes, handleUserSyncRoutes, diff --git a/electron/api/routes/data-service.ts b/electron/api/routes/data-service.ts new file mode 100644 index 0000000..0968c88 --- /dev/null +++ b/electron/api/routes/data-service.ts @@ -0,0 +1,335 @@ +import type { IncomingMessage, ServerResponse } from 'node:http'; +import type { HostApiContext } from '../context'; +import { sendJson } from '../route-utils'; +import { + type DataServiceErrorContext, + type DataServiceHostResult, +} from '../../../shared/data-service'; + +const LOCAL_ROOT = '/api/works/data-service'; +const MAX_REQUEST_BYTES = 98_304; +const MAX_CURSOR_LENGTH = 1_024; +const COLLECTION_PATTERN = /^[a-z][a-z0-9_-]{0,47}$/; +const DOCUMENT_ID_PATTERN = /^[A-Za-z0-9._~-]{1,128}$/; + +class DataServiceRouteError extends Error { + constructor( + readonly status: number, + readonly code: string, + message: string, + readonly retryable = false, + ) { + super(message); + } +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +function boundedString(value: unknown, maximum: number): string | null { + if (typeof value !== 'string') return null; + return value && value.length <= maximum ? value : null; +} + +function routeFailure( + status: number, + code: string, + error: string, + retryable = false, + extra: { retry_after_seconds?: number; context?: DataServiceErrorContext } = {}, +): DataServiceHostResult { + return { + success: false, + status, + code, + error, + retryable, + ...(extra.retry_after_seconds === undefined ? {} : { retry_after_seconds: extra.retry_after_seconds }), + ...(extra.context === undefined ? {} : { context: extra.context }), + data: null, + }; +} + +function unavailable(): DataServiceHostResult { + return routeFailure(503, 'data_service_unavailable', 'Data Service is temporarily unavailable', true); +} + +function methodNotAllowed(): DataServiceHostResult { + return routeFailure(405, 'method_not_allowed', 'Data Service method is not allowed'); +} + +async function readBoundedJson(req: IncomingMessage): Promise> { + const declared = req.headers['content-length']; + const declaredValue = Array.isArray(declared) ? declared[0] : declared; + if (declaredValue && /^\d+$/.test(declaredValue) && Number(declaredValue) > MAX_REQUEST_BYTES) { + throw new DataServiceRouteError(413, 'request_too_large', 'Data Service request is too large'); + } + const chunks: Buffer[] = []; + let size = 0; + for await (const chunk of req) { + const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk); + size += buffer.byteLength; + if (size > MAX_REQUEST_BYTES) { + throw new DataServiceRouteError(413, 'request_too_large', 'Data Service request is too large'); + } + chunks.push(buffer); + } + try { + const value = JSON.parse(Buffer.concat(chunks).toString('utf8')) as unknown; + if (!isRecord(value)) throw new Error('object required'); + return value; + } catch { + throw new DataServiceRouteError(422, 'invalid_request', 'Data Service request is invalid'); + } +} + +function requireExactKeys(value: Record, keys: readonly string[]): void { + const expected = new Set(keys); + if (Object.keys(value).some((key) => !expected.has(key)) + || keys.some((key) => !Object.prototype.hasOwnProperty.call(value, key))) { + throw new DataServiceRouteError(422, 'invalid_request', 'Data Service request is invalid'); + } +} + +function decodeSegment(value: string, maximum: number, pattern: RegExp): string { + let decoded: string; + try { + decoded = decodeURIComponent(value); + } catch { + throw new DataServiceRouteError(422, 'invalid_request', 'Data Service request is invalid'); + } + if (decoded.length > maximum || !pattern.test(decoded)) { + throw new DataServiceRouteError(422, 'invalid_request', 'Data Service request is invalid'); + } + return decoded; +} + +function parseCollection(value: string): string { + return decodeSegment(value, 48, COLLECTION_PATTERN); +} + +function parseDocumentId(value: string): string { + const documentId = decodeSegment(value, 128, DOCUMENT_ID_PATTERN); + if (documentId === '.' || documentId === '..') { + throw new DataServiceRouteError(422, 'invalid_request', 'Data Service request is invalid'); + } + return documentId; +} + +function requireQueryKeys(url: URL, allowed: readonly string[]): void { + const accepted = new Set(allowed); + if ([...url.searchParams.keys()].some((key) => !accepted.has(key))) { + throw new DataServiceRouteError(422, 'invalid_request', 'Data Service request is invalid'); + } +} + +function requireConfirmed(url: URL): void { + requireQueryKeys(url, ['confirmed']); + if (url.searchParams.getAll('confirmed').length !== 1 + || url.searchParams.get('confirmed') !== 'true') { + throw new DataServiceRouteError( + 400, + 'confirmation_required', + 'Explicit confirmation is required for this Data Service operation', + ); + } +} + +function parseLimit(url: URL): number | undefined { + const values = url.searchParams.getAll('limit'); + if (values.length === 0) return undefined; + if (values.length !== 1 || !/^[1-9]\d*$/.test(values[0])) { + throw new DataServiceRouteError(422, 'invalid_request', 'Data Service request is invalid'); + } + const limit = Number(values[0]); + if (!Number.isSafeInteger(limit) || limit < 1 || limit > 100) { + throw new DataServiceRouteError(422, 'invalid_request', 'Data Service request is invalid'); + } + return limit; +} + +function parseCursor(url: URL): string | undefined { + const values = url.searchParams.getAll('cursor'); + if (values.length === 0) return undefined; + const cursor = values.length === 1 ? boundedString(values[0], MAX_CURSOR_LENGTH) : null; + if (!cursor) throw new DataServiceRouteError(422, 'invalid_request', 'Data Service request is invalid'); + return cursor; +} + +function parseIfMatch(req: IncomingMessage): number | undefined { + const value = req.headers['if-match']; + if (value === undefined) return undefined; + if (Array.isArray(value) || !/^"[1-9]\d*"$/.test(value)) { + throw new DataServiceRouteError(422, 'invalid_revision', 'Data Service document revision is invalid'); + } + const revision = Number(value.slice(1, -1)); + if (!Number.isSafeInteger(revision) || revision < 1) { + throw new DataServiceRouteError(422, 'invalid_revision', 'Data Service document revision is invalid'); + } + return revision; +} + +function parseCollections(value: unknown): string[] { + if (!Array.isArray(value) || value.length > 20 + || value.some((item) => typeof item !== 'string' || !COLLECTION_PATTERN.test(item))) { + throw new DataServiceRouteError(422, 'invalid_request', 'Data Service request is invalid'); + } + return value as string[]; +} + +function parseData(value: unknown): Record { + if (!isRecord(value)) { + throw new DataServiceRouteError(422, 'invalid_request', 'Data Service request is invalid'); + } + return value; +} + +function sendResult(res: ServerResponse, result: DataServiceHostResult): void { + res.setHeader('Cache-Control', 'private, no-store'); + // Host IPC and the browser fallback both consume the safe envelope. Keep the + // transport status successful so a remote Data Service status cannot be + // mistaken for a Host transport failure by the existing proxy. + sendJson(res, 200, result); +} + +function isDataServicePath(pathname: string): boolean { + return pathname === LOCAL_ROOT || pathname.startsWith(`${LOCAL_ROOT}/`); +} + +export async function handleDataServiceRoutes( + req: IncomingMessage, + res: ServerResponse, + url: URL, + ctx: HostApiContext, +): Promise { + if (!isDataServicePath(url.pathname)) return false; + + const operations = ctx.codingProducts?.dataService; + if (!operations) { + sendResult(res, unavailable()); + return true; + } + + try { + const method = (req.method ?? 'GET').toUpperCase(); + const projectListPath = `${LOCAL_ROOT}/projects`; + const projectPath = `${LOCAL_ROOT}/project`; + if (url.pathname === projectListPath) { + requireQueryKeys(url, []); + if (method !== 'GET') { + sendResult(res, methodNotAllowed()); + } else { + sendResult(res, await operations.listProjects()); + } + return true; + } + if (url.pathname === projectPath) { + if (method === 'PUT') { + requireQueryKeys(url, []); + const body = await readBoundedJson(req); + requireExactKeys(body, ['collections']); + sendResult(res, await operations.configure({ collections: parseCollections(body.collections) })); + } else if (method === 'GET') { + requireQueryKeys(url, []); + sendResult(res, await operations.inspect()); + } else if (method === 'DELETE') { + requireConfirmed(url); + sendResult(res, await operations.removeProject({ confirmed: true })); + } else { + sendResult(res, methodNotAllowed()); + } + return true; + } + if (url.pathname === `${projectPath}/reset`) { + if (method !== 'POST') { + sendResult(res, methodNotAllowed()); + } else { + requireConfirmed(url); + sendResult(res, await operations.reset({ confirmed: true })); + } + return true; + } + + const collectionPath = url.pathname.match( + new RegExp(`^${LOCAL_ROOT}/project/collections/([^/]+)$`), + ); + if (collectionPath) { + if (method !== 'DELETE') { + sendResult(res, methodNotAllowed()); + } else { + requireConfirmed(url); + sendResult(res, await operations.removeCollection({ + collection: parseCollection(collectionPath[1]), + confirmed: true, + })); + } + return true; + } + + const documentCollectionPath = url.pathname.match( + new RegExp(`^${LOCAL_ROOT}/project/collections/([^/]+)/documents$`), + ); + if (documentCollectionPath) { + const collection = parseCollection(documentCollectionPath[1]); + if (method !== 'GET') { + sendResult(res, methodNotAllowed()); + } else { + requireQueryKeys(url, ['limit', 'cursor']); + sendResult(res, await operations.listDocuments({ + collection, + limit: parseLimit(url), + cursor: parseCursor(url), + })); + } + return true; + } + + const documentPath = url.pathname.match( + new RegExp(`^${LOCAL_ROOT}/project/collections/([^/]+)/documents/([^/]+)$`), + ); + if (documentPath) { + const collection = parseCollection(documentPath[1]); + const documentId = parseDocumentId(documentPath[2]); + if (method === 'GET') { + requireQueryKeys(url, []); + sendResult(res, await operations.getDocument({ collection, document_id: documentId })); + } else if (method === 'PUT') { + requireQueryKeys(url, []); + const body = await readBoundedJson(req); + requireExactKeys(body, ['data']); + const ifRevision = parseIfMatch(req); + sendResult(res, await operations.putDocument({ + collection, + document_id: documentId, + data: parseData(body.data), + ...(ifRevision === undefined ? {} : { if_revision: ifRevision }), + })); + } else if (method === 'DELETE') { + requireConfirmed(url); + const ifRevision = parseIfMatch(req); + sendResult(res, await operations.deleteDocument({ + collection, + document_id: documentId, + ...(ifRevision === undefined ? {} : { if_revision: ifRevision }), + confirmed: true, + })); + } else { + sendResult(res, methodNotAllowed()); + } + return true; + } + + sendResult(res, routeFailure(404, 'route_not_found', 'Data Service route was not found')); + return true; + } catch (error) { + if (error instanceof DataServiceRouteError) { + sendResult(res, routeFailure(error.status, error.code, error.message, error.retryable)); + return true; + } + sendResult(res, unavailable()); + return true; + } +} + +export const handleDataServiceRoute = handleDataServiceRoutes; diff --git a/electron/services/data-service-client.ts b/electron/services/data-service-client.ts new file mode 100644 index 0000000..a1d40f8 --- /dev/null +++ b/electron/services/data-service-client.ts @@ -0,0 +1,767 @@ +import { + CodingProjectServiceError, + type CodingProjectService, +} from '../coding-projects/project-service'; +import { + getValidWorksSquareAccessToken, +} from './works-square-session'; +import { WORKS_SQUARE_CONFIG } from '../api/works-config'; +import { proxyAwareFetch } from '../utils/proxy-fetch'; +import { + type DataServiceCollectionRemoval, + type DataServiceCollectionTargetInput, + type DataServiceConfirmationInput, + type DataServiceDocument, + type DataServiceDocumentList, + type DataServiceDocumentTargetInput, + type DataServiceErrorContext, + type DataServiceHostResult, + type DataServiceInstanceList, + type DataServiceInstanceRemoval, + type DataServiceInstanceState, + type DataServicePutDocumentInput, +} from '../../shared/data-service'; + +const MAX_REQUEST_BYTES = 98_304; +const MAX_RESPONSE_BYTES = 1_310_720; +const MAX_CURSOR_LENGTH = 1_024; +const MAX_RETRY_AFTER_SECONDS = 86_400; +const PROJECT_ID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/; +const COLLECTION_PATTERN = /^[a-z][a-z0-9_-]{0,47}$/; +const DOCUMENT_ID_PATTERN = /^[A-Za-z0-9._~-]{1,128}$/; +const KNOWN_ERROR_CODES = new Set([ + 'invalid_project_id', + 'invalid_collection_name', + 'invalid_document_id', + 'invalid_document_data', + 'invalid_revision', + 'invalid_cursor', + 'cursor_expired', + 'instance_not_found', + 'collection_not_found', + 'document_not_found', + 'revision_conflict', + 'document_too_large', + 'quota_exceeded', + 'rate_limited', +]); +const CONTEXT_KEYS = new Set([ + 'resource', + 'limit', + 'current', + 'attempted', + 'actual', + 'allowed', + 'current_revision', + 'retry_after_seconds', +]); +const CONTEXT_RESOURCES = new Set(['instances', 'collections', 'documents', 'bytes']); + +type FetchImplementation = typeof fetch; +type AccessTokenGetter = typeof getValidWorksSquareAccessToken; + +export type DataServiceOperations = { + configure(input: { collections: string[] }): Promise>; + inspect(): Promise>; + listProjects(): Promise>; + getDocument(input: { collection: string; document_id: string }): Promise>; + listDocuments(input: { collection: string; limit?: number; cursor?: string }): Promise>; + putDocument(input: DataServicePutDocumentInput): Promise>; + deleteDocument(input: DataServiceDocumentTargetInput & { confirmed: true }): Promise>; + removeCollection(input: DataServiceCollectionTargetInput): Promise>; + reset(input: DataServiceConfirmationInput): Promise>; + removeProject(input: DataServiceConfirmationInput): Promise>; +}; + +export type DataServiceCloudClientDependencies = { + fetchImpl?: FetchImplementation; + getAccessToken?: AccessTokenGetter; + apiBaseUrl?: string; +}; + +type RequestSpec = { + method: 'GET' | 'PUT' | 'POST' | 'DELETE'; + path: string; + body?: unknown; + ifRevision?: number; + expectedStatus: number | readonly number[]; + project(payload: unknown): T; +}; + +class InvalidRemoteResponseError extends Error {} + +class OversizedRemoteResponseError extends Error {} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +function boundedString(value: unknown, maximum: number): string | null { + if (typeof value !== 'string') return null; + return value && value.length <= maximum ? value : null; +} + +function nonNegativeInteger(value: unknown): number | null { + return Number.isSafeInteger(value) && (value as number) >= 0 ? value as number : null; +} + +function positiveInteger(value: unknown): number | null { + return Number.isSafeInteger(value) && (value as number) > 0 ? value as number : null; +} + +function projectUuid(value: unknown): string { + const uuid = boundedString(value, 36); + if (!uuid || !PROJECT_ID_PATTERN.test(uuid)) throw new InvalidRemoteResponseError(); + return uuid; +} + +function projectTimestamp(value: unknown): string { + const timestamp = boundedString(value, 64); + if (!timestamp || !timestamp.endsWith('Z') || !Number.isFinite(Date.parse(timestamp))) { + throw new InvalidRemoteResponseError(); + } + return timestamp; +} + +function projectCollectionName(value: unknown): string { + const name = boundedString(value, 48); + if (!name || !COLLECTION_PATTERN.test(name)) throw new InvalidRemoteResponseError(); + return name; +} + +function projectDocumentId(value: unknown): string { + const id = boundedString(value, 128); + if (!id || id === '.' || id === '..' || !DOCUMENT_ID_PATTERN.test(id)) { + throw new InvalidRemoteResponseError(); + } + return id; +} + +function projectData(value: unknown): Record { + if (!isRecord(value)) throw new InvalidRemoteResponseError(); + return value; +} + +function projectUsage(value: unknown): { document_count: number; total_bytes: number } { + if (!isRecord(value)) throw new InvalidRemoteResponseError(); + const documentCount = nonNegativeInteger(value.document_count); + const totalBytes = nonNegativeInteger(value.total_bytes); + if (documentCount === null || totalBytes === null) throw new InvalidRemoteResponseError(); + return { document_count: documentCount, total_bytes: totalBytes }; +} + +function projectLimits(value: unknown) { + if (!isRecord(value)) throw new InvalidRemoteResponseError(); + const fields = [ + 'max_collections', + 'max_documents', + 'max_total_bytes', + 'max_document_bytes', + 'list_default_limit', + 'list_max_limit', + 'list_max_data_bytes', + 'mutations_per_minute', + ] as const; + const projected = {} as Record<(typeof fields)[number], number>; + for (const field of fields) { + const item = positiveInteger(value[field]); + if (item === null) throw new InvalidRemoteResponseError(); + projected[field] = item; + } + return projected; +} + +function projectCollection(value: unknown) { + if (!isRecord(value)) throw new InvalidRemoteResponseError(); + const documentCount = nonNegativeInteger(value.document_count); + const totalBytes = nonNegativeInteger(value.total_bytes); + if (documentCount === null || totalBytes === null) throw new InvalidRemoteResponseError(); + return { + name: projectCollectionName(value.name), + document_count: documentCount, + total_bytes: totalBytes, + created_at: projectTimestamp(value.created_at), + }; +} + +function projectInstance(value: unknown): DataServiceInstanceState { + if (!isRecord(value) || !Array.isArray(value.collections) || value.collections.length > 20) { + throw new InvalidRemoteResponseError(); + } + const projectId = projectUuid(value.project_id); + const instanceId = projectUuid(value.instance_id); + const collections = value.collections.map(projectCollection); + if (new Set(collections.map(({ name }) => name)).size !== collections.length) { + throw new InvalidRemoteResponseError(); + } + const usage = projectUsage(value.usage); + return { + instance_id: instanceId, + project_id: projectId, + collections, + usage, + limits: projectLimits(value.limits), + created_at: projectTimestamp(value.created_at), + updated_at: projectTimestamp(value.updated_at), + }; +} + +function projectInstanceForProject(projectId: string) { + return (value: unknown): DataServiceInstanceState => { + const instance = projectInstance(value); + if (instance.project_id !== projectId) throw new InvalidRemoteResponseError(); + return instance; + }; +} + +function projectInstanceList(value: unknown): DataServiceInstanceList { + if (!isRecord(value) || !Array.isArray(value.items) || value.items.length > 20) { + throw new InvalidRemoteResponseError(); + } + const total = nonNegativeInteger(value.total); + const instanceLimit = positiveInteger(value.instance_limit); + if (total === null || instanceLimit === null) throw new InvalidRemoteResponseError(); + const items = value.items.map(projectInstance); + if (new Set(items.map(({ project_id }) => project_id)).size !== items.length || total !== items.length) { + throw new InvalidRemoteResponseError(); + } + return { items, total, instance_limit: instanceLimit }; +} + +function projectCollectionRemoval(value: unknown): DataServiceCollectionRemoval { + if (!isRecord(value) || typeof value.removed !== 'boolean') throw new InvalidRemoteResponseError(); + return { removed: value.removed, usage: projectUsage(value.usage) }; +} + +function projectInstanceRemoval(value: unknown): DataServiceInstanceRemoval { + if (!isRecord(value) || typeof value.removed !== 'boolean') throw new InvalidRemoteResponseError(); + return { removed: value.removed }; +} + +function projectDocument(value: unknown): DataServiceDocument { + if (!isRecord(value)) throw new InvalidRemoteResponseError(); + const revision = positiveInteger(value.revision); + if (revision === null) throw new InvalidRemoteResponseError(); + return { + id: projectDocumentId(value.id), + data: projectData(value.data), + revision, + created_at: projectTimestamp(value.created_at), + updated_at: projectTimestamp(value.updated_at), + }; +} + +function projectDocumentList(value: unknown): DataServiceDocumentList { + if (!isRecord(value) || !Array.isArray(value.items)) throw new InvalidRemoteResponseError(); + if (value.items.length > 100) throw new InvalidRemoteResponseError(); + const limit = positiveInteger(value.limit); + if (limit === null || limit > 100) throw new InvalidRemoteResponseError(); + if (value.next_cursor !== null && boundedString(value.next_cursor, MAX_CURSOR_LENGTH) === null) { + throw new InvalidRemoteResponseError(); + } + const items = value.items.map(projectDocument); + if (new Set(items.map(({ id }) => id)).size !== items.length) { + throw new InvalidRemoteResponseError(); + } + return { + items, + next_cursor: value.next_cursor === null ? null : value.next_cursor as string, + limit, + }; +} + +function resultSuccess(status: number, data: T | null): DataServiceHostResult { + return { + success: true, + status, + code: null, + error: null, + retryable: false, + data, + }; +} + +function resultFailure( + status: number, + code: string, + error: string, + retryable: boolean, + extra: Pick, 'retry_after_seconds' | 'context'> = {}, +): DataServiceHostResult { + return { + success: false, + status, + code, + error, + retryable, + ...(extra.retry_after_seconds === undefined ? {} : { retry_after_seconds: extra.retry_after_seconds }), + ...(extra.context === undefined ? {} : { context: extra.context }), + data: null, + }; +} + +function authRequired(): DataServiceHostResult { + return resultFailure(401, 'authentication_required', 'Works Square sign-in is required', false); +} + +function unavailable(): DataServiceHostResult { + return resultFailure(503, 'data_service_unavailable', 'Data Service is temporarily unavailable', true); +} + +function invalidResponse(): DataServiceHostResult { + return resultFailure(502, 'upstream_invalid_response', 'Data Service returned an invalid response', false); +} + +function invalidRequest(): DataServiceHostResult { + return resultFailure(422, 'invalid_request', 'Data Service request is invalid', false); +} + +function confirmationRequired(): DataServiceHostResult { + return resultFailure( + 400, + 'confirmation_required', + 'Explicit confirmation is required for this Data Service operation', + false, + ); +} + +function errorMessageForCode(code: string): string { + switch (code) { + case 'invalid_cursor': return 'Data Service cursor is invalid'; + case 'cursor_expired': return 'Data Service cursor has expired'; + case 'instance_not_found': return 'Data Service project instance was not found'; + case 'collection_not_found': return 'Data Service collection was not found'; + case 'document_not_found': return 'Data Service document was not found'; + case 'revision_conflict': return 'Data Service document revision conflicts'; + case 'quota_exceeded': return 'Data Service quota exceeded'; + case 'document_too_large': return 'Data Service document is too large'; + case 'invalid_revision': return 'Data Service document revision is invalid'; + case 'invalid_project_id': return 'Data Service project ID is invalid'; + case 'invalid_collection_name': return 'Data Service collection is invalid'; + case 'invalid_document_id': return 'Data Service document ID is invalid'; + case 'invalid_document_data': return 'Data Service document data is invalid'; + case 'rate_limited': return 'Data Service request rate limit exceeded'; + case 'data_service_unavailable': return 'Data Service is temporarily unavailable'; + default: return 'Data Service request was rejected'; + } +} + +function projectContext(value: unknown): DataServiceErrorContext | undefined { + if (!isRecord(value)) return undefined; + const context: DataServiceErrorContext = {}; + for (const [key, item] of Object.entries(value)) { + if (!CONTEXT_KEYS.has(key)) continue; + if (key === 'resource') { + const resource = boundedString(item, 64); + if (!resource || !CONTEXT_RESOURCES.has(resource)) throw new InvalidRemoteResponseError(); + context[key] = resource; + } else if (Number.isSafeInteger(item) && (item as number) >= 0) { + context[key] = item as number; + } else { + throw new InvalidRemoteResponseError(); + } + } + return Object.keys(context).length > 0 ? context : undefined; +} + +function retryAfterSeconds(response: Response): number | undefined { + const raw = response.headers.get('retry-after')?.trim(); + if (!raw || !/^\d+$/.test(raw)) return undefined; + const seconds = Number(raw); + return Number.isSafeInteger(seconds) && seconds <= MAX_RETRY_AFTER_SECONDS ? seconds : undefined; +} + +function projectError(payload: unknown, response: Response): DataServiceHostResult { + if (!isRecord(payload) || !isRecord(payload.detail)) return invalidResponse(); + const detail = payload.detail; + const rawCode = boundedString(detail.code, 64); + if (!rawCode || !KNOWN_ERROR_CODES.has(rawCode)) return invalidResponse(); + if (detail.retryable !== undefined && typeof detail.retryable !== 'boolean') return invalidResponse(); + if (detail.context !== undefined && !isRecord(detail.context)) return invalidResponse(); + const code = rawCode; + // The upstream message is intentionally not forwarded: even bounded error + // text is not a safe boundary for exception details or account data. + const message = errorMessageForCode(code); + const retryable = typeof detail.retryable === 'boolean' + ? detail.retryable + : code === 'rate_limited' || code === 'data_service_unavailable'; + const retryAfter = retryAfterSeconds(response); + const context = projectContext(detail.context); + return resultFailure( + response.status >= 400 && response.status <= 599 ? response.status : 502, + code, + message, + retryable, + { + ...(retryAfter === undefined ? {} : { retry_after_seconds: retryAfter }), + ...(context === undefined ? {} : { context }), + }, + ); +} + +async function readBoundedJson(response: Response): Promise { + const declaredLength = response.headers.get('content-length'); + if (declaredLength && /^\d+$/.test(declaredLength) && Number(declaredLength) > MAX_RESPONSE_BYTES) { + await response.body?.cancel().catch(() => undefined); + throw new OversizedRemoteResponseError(); + } + if (!response.body) { + const text = await response.text(); + if (Buffer.byteLength(text, 'utf8') > MAX_RESPONSE_BYTES) throw new OversizedRemoteResponseError(); + if (!text.trim()) return null; + try { + return JSON.parse(text) as unknown; + } catch { + throw new InvalidRemoteResponseError(); + } + } + const reader = response.body.getReader(); + const chunks: Uint8Array[] = []; + let size = 0; + try { + while (true) { + const { done, value } = await reader.read(); + if (done) break; + if (!value) continue; + size += value.byteLength; + if (size > MAX_RESPONSE_BYTES) { + await reader.cancel().catch(() => undefined); + throw new OversizedRemoteResponseError(); + } + chunks.push(value); + } + } finally { + reader.releaseLock(); + } + const text = Buffer.concat(chunks.map((chunk) => Buffer.from(chunk))).toString('utf8'); + if (!text.trim()) return null; + try { + return JSON.parse(text) as unknown; + } catch { + throw new InvalidRemoteResponseError(); + } +} + +function assertProjectId(projectId: string): boolean { + return typeof projectId === 'string' && PROJECT_ID_PATTERN.test(projectId); +} + +function assertCollection(collection: string): boolean { + return typeof collection === 'string' && COLLECTION_PATTERN.test(collection); +} + +function assertDocumentId(documentId: string): boolean { + return typeof documentId === 'string' + && documentId !== '.' + && documentId !== '..' + && DOCUMENT_ID_PATTERN.test(documentId); +} + +function assertRevision(value: number | undefined): boolean { + return value === undefined || (Number.isSafeInteger(value) && value > 0); +} + +function requestBytes(body: unknown): string | null { + try { + const encoded = JSON.stringify(body); + if (typeof encoded !== 'string') return null; + if (Buffer.byteLength(encoded, 'utf8') > MAX_REQUEST_BYTES) return null; + return encoded; + } catch { + return null; + } +} + +export class DataServiceCloudClient { + private readonly fetchImpl: FetchImplementation; + private readonly getAccessToken: AccessTokenGetter; + private readonly apiBaseUrl: string; + + constructor(dependencies: DataServiceCloudClientDependencies = {}) { + this.fetchImpl = dependencies.fetchImpl ?? proxyAwareFetch; + this.getAccessToken = dependencies.getAccessToken ?? getValidWorksSquareAccessToken; + this.apiBaseUrl = (dependencies.apiBaseUrl ?? WORKS_SQUARE_CONFIG.apiBaseUrl).replace(/\/+$/, ''); + } + + listProjects(): Promise> { + return this.request({ + method: 'GET', + path: '/api/data-service/v1/projects', + expectedStatus: 200, + project: projectInstanceList, + }); + } + + configure(projectId: string, collections: string[]): Promise> { + if (!assertProjectId(projectId) || !Array.isArray(collections) || collections.length > 20 + || collections.some((name) => !assertCollection(name))) return Promise.resolve(invalidRequest()); + return this.request({ + method: 'PUT', + path: `/api/data-service/v1/projects/${encodeURIComponent(projectId)}`, + body: { collections }, + expectedStatus: 200, + project: projectInstanceForProject(projectId), + }); + } + + inspect(projectId: string): Promise> { + if (!assertProjectId(projectId)) return Promise.resolve(invalidRequest()); + return this.request({ + method: 'GET', + path: `/api/data-service/v1/projects/${encodeURIComponent(projectId)}`, + expectedStatus: 200, + project: projectInstanceForProject(projectId), + }); + } + + removeCollection(projectId: string, collection: string): Promise> { + if (!assertProjectId(projectId) || !assertCollection(collection)) return Promise.resolve(invalidRequest()); + return this.request({ + method: 'DELETE', + path: `/api/data-service/v1/projects/${encodeURIComponent(projectId)}/collections/${encodeURIComponent(collection)}`, + expectedStatus: 200, + project: projectCollectionRemoval, + }); + } + + reset(projectId: string): Promise> { + if (!assertProjectId(projectId)) return Promise.resolve(invalidRequest()); + return this.request({ + method: 'POST', + path: `/api/data-service/v1/projects/${encodeURIComponent(projectId)}/reset`, + expectedStatus: 200, + project: projectInstanceForProject(projectId), + }); + } + + removeProject(projectId: string): Promise> { + if (!assertProjectId(projectId)) return Promise.resolve(invalidRequest()); + return this.request({ + method: 'DELETE', + path: `/api/data-service/v1/projects/${encodeURIComponent(projectId)}`, + expectedStatus: 200, + project: projectInstanceRemoval, + }); + } + + getDocument(projectId: string, collection: string, documentId: string): Promise> { + if (!assertProjectId(projectId) || !assertCollection(collection) || !assertDocumentId(documentId)) { + return Promise.resolve(invalidRequest()); + } + return this.request({ + method: 'GET', + path: `/api/data/v1/projects/${encodeURIComponent(projectId)}/collections/${encodeURIComponent(collection)}/documents/${encodeURIComponent(documentId)}`, + expectedStatus: 200, + project: projectDocument, + }); + } + + listDocuments( + projectId: string, + collection: string, + limit?: number, + cursor?: string, + ): Promise> { + if (!assertProjectId(projectId) || !assertCollection(collection) + || (limit !== undefined && (!Number.isSafeInteger(limit) || limit < 1 || limit > 100)) + || (cursor !== undefined && (typeof cursor !== 'string' || !cursor || cursor.length > MAX_CURSOR_LENGTH))) { + return Promise.resolve(invalidRequest()); + } + const query = new URLSearchParams(); + if (limit !== undefined) query.set('limit', String(limit)); + if (cursor !== undefined) query.set('cursor', cursor); + const suffix = query.toString() ? `?${query.toString()}` : ''; + return this.request({ + method: 'GET', + path: `/api/data/v1/projects/${encodeURIComponent(projectId)}/collections/${encodeURIComponent(collection)}/documents${suffix}`, + expectedStatus: 200, + project: projectDocumentList, + }); + } + + putDocument( + projectId: string, + input: DataServicePutDocumentInput, + ): Promise> { + if (!isRecord(input)) return Promise.resolve(invalidRequest()); + const encodedBody = requestBytes({ data: input.data }); + if (!assertProjectId(projectId) || !assertCollection(input.collection) + || !assertDocumentId(input.document_id) || !isRecord(input.data) + || encodedBody === null || !assertRevision(input.if_revision)) return Promise.resolve(invalidRequest()); + return this.request({ + method: 'PUT', + path: `/api/data/v1/projects/${encodeURIComponent(projectId)}/collections/${encodeURIComponent(input.collection)}/documents/${encodeURIComponent(input.document_id)}`, + body: { data: input.data }, + ifRevision: input.if_revision, + expectedStatus: 200, + project: projectDocument, + }); + } + + deleteDocument( + projectId: string, + input: DataServiceDocumentTargetInput, + ): Promise> { + if (!isRecord(input)) return Promise.resolve(invalidRequest()); + if (!assertProjectId(projectId) || !assertCollection(input.collection) + || !assertDocumentId(input.document_id) || !assertRevision(input.if_revision)) { + return Promise.resolve(invalidRequest()); + } + return this.request({ + method: 'DELETE', + path: `/api/data/v1/projects/${encodeURIComponent(projectId)}/collections/${encodeURIComponent(input.collection)}/documents/${encodeURIComponent(input.document_id)}`, + ifRevision: input.if_revision, + expectedStatus: 204, + project: () => null, + }); + } + + private async request(spec: RequestSpec): Promise> { + const body = spec.body === undefined ? undefined : requestBytes(spec.body); + if (spec.body !== undefined && body === null) return invalidRequest(); + let token: string | null; + try { + token = await this.getAccessToken({ fetchImpl: this.fetchImpl }); + } catch { + return authRequired(); + } + if (!token) return authRequired(); + + const send = async (accessToken: string): Promise => await this.fetchImpl( + `${this.apiBaseUrl}${spec.path}`, + { + method: spec.method, + headers: { + Accept: 'application/json', + Authorization: `Bearer ${accessToken}`, + ...(body === undefined ? {} : { 'Content-Type': 'application/json' }), + ...(spec.ifRevision === undefined ? {} : { 'If-Match': `"${spec.ifRevision}"` }), + }, + ...(body === undefined ? {} : { body }), + redirect: 'manual', + }, + ); + + let response: Response; + try { + response = await send(token); + } catch { + return unavailable(); + } + if (response.status === 401) { + await response.body?.cancel().catch(() => undefined); + let refreshed: string | null; + try { + refreshed = await this.getAccessToken({ fetchImpl: this.fetchImpl, forceRefresh: true }); + } catch { + refreshed = null; + } + if (!refreshed) return authRequired(); + try { + response = await send(refreshed); + } catch { + return unavailable(); + } + if (response.status === 401) { + await response.body?.cancel().catch(() => undefined); + return authRequired(); + } + } + + if (!response.ok) { + if (response.status >= 500) { + await response.body?.cancel().catch(() => undefined); + return unavailable(); + } + let payload: unknown; + try { + payload = await readBoundedJson(response); + } catch { + await response.body?.cancel().catch(() => undefined); + return invalidResponse(); + } + return projectError(payload, response); + } + const expected = Array.isArray(spec.expectedStatus) + ? spec.expectedStatus + : [spec.expectedStatus]; + if (!expected.includes(response.status)) { + await response.body?.cancel().catch(() => undefined); + return invalidResponse(); + } + if (response.status === 204) return resultSuccess(response.status, null); + let payload: unknown; + try { + payload = await readBoundedJson(response); + return resultSuccess(response.status, spec.project(payload)); + } catch { + return invalidResponse(); + } + } +} + +function projectLocalProjectError(error: unknown): DataServiceHostResult { + if (!(error instanceof CodingProjectServiceError)) return unavailable(); + switch (error.code) { + case 'CODING_PROJECT_IDENTITY_REQUIRED': + return resultFailure(409, 'project_identity_required', 'A durable coding project identity is required', false); + case 'CODING_ACTIVE_PROJECT_REQUIRED': + return resultFailure(409, 'active_project_required', 'An active coding project is required', false); + case 'CODING_ACTIVE_PROJECT_PATH_MISMATCH': + return resultFailure(409, 'active_project_path_mismatch', 'The active coding project path is invalid', false); + case 'CODING_ACTIVE_PROJECT_INVALID': + case 'CODING_PROJECT_CONFIG_INVALID': + return resultFailure(409, 'active_project_invalid', 'The active coding project is unavailable', false); + default: + return error.status >= 500 + ? unavailable() + : resultFailure(error.status, 'active_project_invalid', 'The active coding project is unavailable', false); + } +} + +export type DataServiceOperationsDependencies = { + projects: Pick; + client?: DataServiceCloudClient; +}; + +export function createDataServiceOperations( + dependencies: DataServiceOperationsDependencies, +): DataServiceOperations { + const client = dependencies.client ?? new DataServiceCloudClient(); + + async function withActive( + operation: (projectId: string) => Promise>, + ): Promise> { + try { + const active = await dependencies.projects.requireActiveRealProjectWithIdentity(); + return await operation(active.projectId); + } catch (error) { + return projectLocalProjectError(error); + } + } + + return { + listProjects: () => client.listProjects(), + configure: ({ collections }) => withActive((projectId) => client.configure(projectId, collections)), + inspect: () => withActive((projectId) => client.inspect(projectId)), + getDocument: ({ collection, document_id }) => withActive( + (projectId) => client.getDocument(projectId, collection, document_id), + ), + listDocuments: ({ collection, limit, cursor }) => withActive( + (projectId) => client.listDocuments(projectId, collection, limit, cursor), + ), + putDocument: (input) => withActive((projectId) => client.putDocument(projectId, input)), + deleteDocument: ({ collection, document_id, if_revision, confirmed }) => confirmed === true + ? withActive((projectId) => client.deleteDocument(projectId, { collection, document_id, if_revision })) + : Promise.resolve(confirmationRequired()), + removeCollection: ({ collection, confirmed }) => confirmed === true + ? withActive((projectId) => client.removeCollection(projectId, collection)) + : Promise.resolve(confirmationRequired()), + reset: ({ confirmed }) => confirmed === true + ? withActive((projectId) => client.reset(projectId)) + : Promise.resolve(confirmationRequired()), + removeProject: ({ confirmed }) => confirmed === true + ? withActive((projectId) => client.removeProject(projectId)) + : Promise.resolve(confirmationRequired()), + }; +} diff --git a/shared/data-service.ts b/shared/data-service.ts new file mode 100644 index 0000000..d348c1b --- /dev/null +++ b/shared/data-service.ts @@ -0,0 +1,96 @@ +export type DataServiceUsage = { + document_count: number; + total_bytes: number; +}; + +export type DataServiceCollection = { + name: string; + document_count: number; + total_bytes: number; + created_at: string; +}; + +export type DataServiceLimits = { + max_collections: number; + max_documents: number; + max_total_bytes: number; + max_document_bytes: number; + list_default_limit: number; + list_max_limit: number; + list_max_data_bytes: number; + mutations_per_minute: number; +}; + +export type DataServiceInstanceState = { + instance_id: string; + project_id: string; + collections: DataServiceCollection[]; + usage: DataServiceUsage; + limits: DataServiceLimits; + created_at: string; + updated_at: string; +}; + +export type DataServiceInstanceList = { + items: DataServiceInstanceState[]; + total: number; + instance_limit: number; +}; + +export type DataServiceCollectionRemoval = { + removed: boolean; + usage: DataServiceUsage; +}; + +export type DataServiceInstanceRemoval = { + removed: boolean; +}; + +export type DataServiceDocument = { + id: string; + data: Record; + revision: number; + created_at: string; + updated_at: string; +}; + +export type DataServiceDocumentList = { + items: DataServiceDocument[]; + next_cursor: string | null; + limit: number; +}; + +export type DataServiceErrorContext = Record; + +export type DataServiceHostResult = { + success: boolean; + status: number; + code: string | null; + error: string | null; + retryable: boolean; + retry_after_seconds?: number; + context?: DataServiceErrorContext; + data: T | null; +}; + +export type DataServicePutDocumentInput = { + collection: string; + document_id: string; + data: Record; + if_revision?: number; +}; + +export type DataServiceDocumentTargetInput = { + collection: string; + document_id: string; + if_revision?: number; +}; + +export type DataServiceCollectionTargetInput = { + collection: string; + confirmed: true; +}; + +export type DataServiceConfirmationInput = { + confirmed: true; +}; diff --git a/tests/unit/data-service-client.test.ts b/tests/unit/data-service-client.test.ts new file mode 100644 index 0000000..08d2ded --- /dev/null +++ b/tests/unit/data-service-client.test.ts @@ -0,0 +1,304 @@ +import { describe, expect, it, vi } from 'vitest'; +import { + createDataServiceOperations, + DataServiceCloudClient, +} from '@electron/services/data-service-client'; + +const projectId = '11111111-1111-4111-8111-111111111111'; + +function jsonResponse(value: unknown, init: ResponseInit = {}): Response { + const headers = new Headers(init.headers); + headers.set('content-type', 'application/json'); + return new Response(JSON.stringify(value), { ...init, headers }); +} + +function instance(project = projectId) { + return { + instance_id: '22222222-2222-4222-8222-222222222222', + project_id: project, + collections: [], + usage: { document_count: 0, total_bytes: 0 }, + limits: { + max_collections: 20, + max_documents: 5000, + max_total_bytes: 20971520, + max_document_bytes: 65536, + list_default_limit: 50, + list_max_limit: 100, + list_max_data_bytes: 1048576, + mutations_per_minute: 120, + }, + created_at: '2026-08-26T08:00:00Z', + updated_at: '2026-08-26T08:00:00Z', + }; +} + +function document() { + return { + id: 'todo-1', + data: { title: 'Ship P0', done: false }, + revision: 7, + created_at: '2026-08-26T08:00:00Z', + updated_at: '2026-08-26T08:00:00Z', + }; +} + +describe('DataServiceCloudClient', () => { + it('projects the control-plane route and direct DTO', async () => { + const fetchImpl = vi.fn().mockResolvedValue( + jsonResponse(instance(), { status: 200 }), + ); + const client = new DataServiceCloudClient({ + fetchImpl, + getAccessToken: vi.fn().mockResolvedValue('access-token'), + apiBaseUrl: 'https://square.example/', + }); + + const result = await client.configure(projectId, ['todos']); + + expect(result).toMatchObject({ success: true, status: 200, data: instance() }); + expect(fetchImpl).toHaveBeenCalledWith( + `https://square.example/api/data-service/v1/projects/${projectId}`, + expect.objectContaining({ + method: 'PUT', + body: JSON.stringify({ collections: ['todos'] }), + headers: expect.objectContaining({ + Accept: 'application/json', + Authorization: 'Bearer access-token', + 'Content-Type': 'application/json', + }), + }), + ); + }); + + it('refreshes and replays exactly once after an authoritative 401', async () => { + const fetchImpl = vi.fn() + .mockResolvedValueOnce(new Response(null, { status: 401 })) + .mockResolvedValueOnce(jsonResponse(instance(), { status: 200 })); + const getAccessToken = vi.fn(async (options?: { forceRefresh?: boolean }) => ( + options?.forceRefresh ? 'refreshed-token' : 'stale-token' + )); + const client = new DataServiceCloudClient({ fetchImpl, getAccessToken }); + + const result = await client.inspect(projectId); + + expect(result.success).toBe(true); + expect(fetchImpl).toHaveBeenCalledTimes(2); + expect(getAccessToken).toHaveBeenNthCalledWith(1, { fetchImpl }); + expect(getAccessToken).toHaveBeenNthCalledWith(2, { fetchImpl, forceRefresh: true }); + expect((fetchImpl.mock.calls[1][1] as RequestInit).headers).toEqual( + expect.objectContaining({ Authorization: 'Bearer refreshed-token' }), + ); + }); + + it('does not replay an ambiguous transport failure', async () => { + const fetchImpl = vi.fn().mockRejectedValue(new Error('socket closed')); + const getAccessToken = vi.fn().mockResolvedValue('access-token'); + const client = new DataServiceCloudClient({ fetchImpl, getAccessToken }); + + const result = await client.inspect(projectId); + + expect(result).toMatchObject({ + success: false, + status: 503, + code: 'data_service_unavailable', + retryable: true, + data: null, + }); + expect(fetchImpl).toHaveBeenCalledTimes(1); + expect(getAccessToken).toHaveBeenCalledTimes(1); + }); + + it('keeps error fields safe while preserving accepted code and context', async () => { + const fetchImpl = vi.fn().mockResolvedValue( + jsonResponse({ + detail: { + code: 'quota_exceeded', + message: 'owner secret must not cross the Host boundary', + retryable: false, + context: { + resource: 'bytes', + limit: 20971520, + current: 20900000, + attempted: 80000, + owner_user_id: 'private', + }, + }, + }, { status: 409 }), + ); + const client = new DataServiceCloudClient({ + fetchImpl, + getAccessToken: vi.fn().mockResolvedValue('access-token'), + }); + + const result = await client.putDocument(projectId, { + collection: 'todos', + document_id: 'todo-1', + data: { done: false }, + }); + + expect(result).toEqual({ + success: false, + status: 409, + code: 'quota_exceeded', + error: 'Data Service quota exceeded', + retryable: false, + context: { resource: 'bytes', limit: 20971520, current: 20900000, attempted: 80000 }, + data: null, + }); + }); + + it('normalizes an invalid success DTO', async () => { + const client = new DataServiceCloudClient({ + fetchImpl: vi.fn().mockResolvedValue(jsonResponse({ items: [] })), + getAccessToken: vi.fn().mockResolvedValue('access-token'), + }); + + const result = await client.listProjects(); + + expect(result).toEqual({ + success: false, + status: 502, + code: 'upstream_invalid_response', + error: 'Data Service returned an invalid response', + retryable: false, + data: null, + }); + }); + + it('rejects a control-plane response for a different project', async () => { + const client = new DataServiceCloudClient({ + fetchImpl: vi.fn().mockResolvedValue( + jsonResponse(instance('33333333-3333-4333-8333-333333333333'), { status: 200 }), + ), + getAccessToken: vi.fn().mockResolvedValue('access-token'), + }); + + const result = await client.inspect(projectId); + + expect(result).toMatchObject({ + success: false, + status: 502, + code: 'upstream_invalid_response', + data: null, + }); + }); + + it('rejects malformed known error fields', async () => { + const client = new DataServiceCloudClient({ + fetchImpl: vi.fn().mockResolvedValue( + jsonResponse({ detail: { code: 'quota_exceeded', retryable: 'no' } }, { status: 409 }), + ), + getAccessToken: vi.fn().mockResolvedValue('access-token'), + }); + + const result = await client.inspect(projectId); + + expect(result).toMatchObject({ + success: false, + status: 502, + code: 'upstream_invalid_response', + data: null, + }); + }); + + it('normalizes malformed client errors and all upstream 5xx responses safely', async () => { + const fetchImpl = vi.fn() + .mockResolvedValueOnce(new Response(null, { status: 404 })) + .mockResolvedValueOnce(new Response('private upstream exception', { status: 500 })); + const client = new DataServiceCloudClient({ + fetchImpl, + getAccessToken: vi.fn().mockResolvedValue('access-token'), + }); + + const malformed = await client.inspect(projectId); + const unavailable = await client.inspect(projectId); + + expect(malformed).toMatchObject({ success: false, status: 502, code: 'upstream_invalid_response', data: null }); + expect(unavailable).toMatchObject({ + success: false, + status: 503, + code: 'data_service_unavailable', + retryable: true, + data: null, + }); + }); + + it('projects data-plane paths, query parameters, and strong revision headers exactly', async () => { + const fetchImpl = vi.fn() + .mockResolvedValueOnce(jsonResponse(document(), { status: 200 })) + .mockResolvedValueOnce(jsonResponse({ items: [document()], next_cursor: null, limit: 50 }, { status: 200 })) + .mockResolvedValueOnce(jsonResponse(document(), { status: 200 })) + .mockResolvedValueOnce(new Response(null, { status: 204 })); + const client = new DataServiceCloudClient({ + fetchImpl, + getAccessToken: vi.fn().mockResolvedValue('access-token'), + apiBaseUrl: 'https://square.example', + }); + + await client.getDocument(projectId, 'todos', 'todo-1'); + await client.listDocuments(projectId, 'todos', 50, 'cursor/next'); + await client.putDocument(projectId, { + collection: 'todos', + document_id: 'todo-1', + data: document().data, + if_revision: 7, + }); + await client.deleteDocument(projectId, { collection: 'todos', document_id: 'todo-1', if_revision: 7 }); + + expect(fetchImpl.mock.calls.map(([url]) => url)).toEqual([ + `https://square.example/api/data/v1/projects/${projectId}/collections/todos/documents/todo-1`, + `https://square.example/api/data/v1/projects/${projectId}/collections/todos/documents?limit=50&cursor=cursor%2Fnext`, + `https://square.example/api/data/v1/projects/${projectId}/collections/todos/documents/todo-1`, + `https://square.example/api/data/v1/projects/${projectId}/collections/todos/documents/todo-1`, + ]); + expect((fetchImpl.mock.calls[2][1] as RequestInit).headers).toEqual( + expect.objectContaining({ 'If-Match': '"7"' }), + ); + expect((fetchImpl.mock.calls[3][1] as RequestInit).headers).toEqual( + expect.objectContaining({ 'If-Match': '"7"' }), + ); + }); + + it('preserves opaque cursor text when projecting the query', async () => { + const listDocuments = vi.fn().mockResolvedValue( + jsonResponse({ items: [], next_cursor: null, limit: 50 }, { status: 200 }), + ); + const client = new DataServiceCloudClient({ + fetchImpl: listDocuments, + getAccessToken: vi.fn().mockResolvedValue('access-token'), + }); + + const result = await client.listDocuments(projectId, 'todos', 50, ' cursor '); + + expect(result).toMatchObject({ success: true, status: 200 }); + expect(listDocuments.mock.calls[0][0]).toContain('cursor=+cursor+'); + }); +}); + +describe('DataServiceOperations', () => { + it('derives active project identity for every operation except owner-wide listing', async () => { + const client = { + listProjects: vi.fn().mockResolvedValue({ success: true, status: 200, code: null, error: null, retryable: false, data: { items: [], total: 0, instance_limit: 20 } }), + inspect: vi.fn().mockResolvedValue({ success: true, status: 200, code: null, error: null, retryable: false, data: instance() }), + } as unknown as DataServiceCloudClient; + const requireActiveRealProjectWithIdentity = vi.fn().mockResolvedValue({ + project: {}, + path: 'C:\\projects\\active', + projectId, + }); + const operations = createDataServiceOperations({ + projects: { requireActiveRealProjectWithIdentity }, + client, + }); + + await operations.listProjects(); + expect(requireActiveRealProjectWithIdentity).not.toHaveBeenCalled(); + const unconfirmed = await operations.removeProject({ confirmed: false as true }); + expect(unconfirmed).toMatchObject({ success: false, status: 400, code: 'confirmation_required', data: null }); + expect(requireActiveRealProjectWithIdentity).not.toHaveBeenCalled(); + await operations.inspect(); + expect(requireActiveRealProjectWithIdentity).toHaveBeenCalledTimes(1); + expect(client.inspect).toHaveBeenCalledWith(projectId); + }); +}); diff --git a/tests/unit/data-service-routes.test.ts b/tests/unit/data-service-routes.test.ts new file mode 100644 index 0000000..4013b17 --- /dev/null +++ b/tests/unit/data-service-routes.test.ts @@ -0,0 +1,159 @@ +import { EventEmitter } from 'node:events'; +import type { IncomingMessage, ServerResponse } from 'node:http'; +import { describe, expect, it, vi } from 'vitest'; +import { handleDataServiceRoutes } from '@electron/api/routes/data-service'; +import type { HostApiContext } from '@electron/api/context'; +import type { DataServiceOperations } from '@electron/services/data-service-client'; + +function request( + method: string, + body?: unknown, + headers: Record = {}, +): IncomingMessage { + const req = new EventEmitter(); + const raw = body === undefined ? undefined : typeof body === 'string' ? body : JSON.stringify(body); + Object.assign(req, { + method, + headers: { + ...(raw === undefined ? {} : { 'content-length': String(Buffer.byteLength(raw, 'utf8')) }), + ...headers, + }, + [Symbol.asyncIterator]: async function* () { + if (raw !== undefined) yield Buffer.from(raw, 'utf8'); + }, + }); + return req as IncomingMessage; +} + +function response() { + const chunks: string[] = []; + const headers = new Map(); + const res = new EventEmitter(); + Object.assign(res, { + statusCode: 0, + setHeader: vi.fn((name: string, value: string) => headers.set(name.toLowerCase(), value)), + end: vi.fn((chunk?: string) => { if (chunk) chunks.push(chunk); }), + }); + return { + res: res as unknown as ServerResponse, + get status() { return (res as { statusCode: number }).statusCode; }, + header: (name: string) => headers.get(name.toLowerCase()), + json: () => JSON.parse(chunks.join('')) as Record, + }; +} + +function success(data: T): { success: true; status: 200; code: null; error: null; retryable: false; data: T } { + return { success: true, status: 200, code: null, error: null, retryable: false, data }; +} + +function setup(overrides: Partial>> = {}) { + const operations = { + configure: vi.fn().mockResolvedValue(success({})), + inspect: vi.fn().mockResolvedValue(success({})), + listProjects: vi.fn().mockResolvedValue(success({ items: [], total: 0, instance_limit: 20 })), + getDocument: vi.fn().mockResolvedValue(success({})), + listDocuments: vi.fn().mockResolvedValue(success({ items: [], next_cursor: null, limit: 50 })), + putDocument: vi.fn().mockResolvedValue(success({})), + deleteDocument: vi.fn().mockResolvedValue(success(null)), + removeCollection: vi.fn().mockResolvedValue(success({ removed: true, usage: { document_count: 0, total_bytes: 0 } })), + reset: vi.fn().mockResolvedValue(success({})), + removeProject: vi.fn().mockResolvedValue(success({ removed: true })), + ...overrides, + } as unknown as DataServiceOperations; + const ctx = { codingProducts: { dataService: operations } } as unknown as HostApiContext; + return { operations, ctx }; +} + +async function invoke( + ctx: HostApiContext, + method: string, + path: string, + body?: unknown, + headers: Record = {}, +) { + const target = response(); + const handled = await handleDataServiceRoutes( + request(method, body, headers), + target.res, + new URL(`http://localhost${path}`), + ctx, + ); + return { handled, ...target, payload: target.json() }; +} + +describe('Data Service Host routes', () => { + it('uses the owner-wide listing without an active-project argument', async () => { + const { operations, ctx } = setup(); + + const result = await invoke(ctx, 'GET', '/api/works/data-service/projects'); + + expect(result.handled).toBe(true); + expect(result.status).toBe(200); + expect(result.header('cache-control')).toBe('private, no-store'); + expect(operations.listProjects).toHaveBeenCalledOnce(); + expect(result.payload).toMatchObject({ success: true, data: { total: 0 } }); + }); + + it('passes only strict collection input to active-project configure', async () => { + const { operations, ctx } = setup(); + + await invoke(ctx, 'PUT', '/api/works/data-service/project', { collections: ['todos', 'settings'] }, { + 'content-type': 'application/json', + }); + + expect(operations.configure).toHaveBeenCalledWith({ collections: ['todos', 'settings'] }); + }); + + it('requires the literal confirmation before removing the active project', async () => { + const { operations, ctx } = setup(); + + const rejected = await invoke(ctx, 'DELETE', '/api/works/data-service/project'); + const accepted = await invoke(ctx, 'DELETE', '/api/works/data-service/project?confirmed=true'); + + expect(rejected.payload).toMatchObject({ success: false, status: 400, code: 'confirmation_required', data: null }); + expect(operations.removeProject).toHaveBeenCalledOnce(); + expect(operations.removeProject).toHaveBeenCalledWith({ confirmed: true }); + expect(accepted.payload).toMatchObject({ success: true, status: 200 }); + }); + + it('projects a document precondition and rejects extra request fields locally', async () => { + const { operations, ctx } = setup(); + + await invoke( + ctx, + 'PUT', + '/api/works/data-service/project/collections/todos/documents/todo-1', + { data: { done: false } }, + { 'content-type': 'application/json', 'if-match': '"7"' }, + ); + const rejected = await invoke( + ctx, + 'PUT', + '/api/works/data-service/project/collections/todos/documents/todo-2', + { data: {}, extra: true }, + { 'content-type': 'application/json' }, + ); + + expect(operations.putDocument).toHaveBeenCalledWith({ + collection: 'todos', + document_id: 'todo-1', + data: { done: false }, + if_revision: 7, + }); + expect(rejected.payload).toMatchObject({ success: false, status: 422, code: 'invalid_request', data: null }); + expect(operations.putDocument).toHaveBeenCalledOnce(); + }); + + it('keeps malformed paths and unsupported query keys out of the operations adapter', async () => { + const { operations, ctx } = setup(); + + const result = await invoke( + ctx, + 'GET', + '/api/works/data-service/project/collections/todos/documents?limit=101&unexpected=x', + ); + + expect(result.payload).toMatchObject({ success: false, status: 422, code: 'invalid_request', data: null }); + expect(operations.listDocuments).not.toHaveBeenCalled(); + }); +}); diff --git a/tests/unit/data-service-server-registration.test.ts b/tests/unit/data-service-server-registration.test.ts new file mode 100644 index 0000000..399e548 --- /dev/null +++ b/tests/unit/data-service-server-registration.test.ts @@ -0,0 +1,18 @@ +import { readFile } from 'node:fs/promises'; +import { resolve } from 'node:path'; +import { describe, expect, it } from 'vitest'; + +describe('Data Service Host API registration', () => { + it('registers before the existing Works catch-all', async () => { + const source = await readFile(resolve('electron/api/route-handlers.ts'), 'utf8'); + expect(source).toMatch( + /import\s+\{\s*handleDataServiceRoutes\s*\}\s+from\s+['"]\.\/routes\/data-service['"]/, + ); + const routeList = source.match(/hostApiRouteHandlers[^=]*=\s*\[([\s\S]*?)\];/); + expect(routeList?.[1]).toBeDefined(); + const dataServiceIndex = routeList?.[1].indexOf('handleDataServiceRoutes') ?? -1; + const worksIndex = routeList?.[1].indexOf('handleWorksRoutes') ?? -1; + expect(dataServiceIndex).toBeGreaterThanOrEqual(0); + expect(worksIndex).toBeGreaterThan(dataServiceIndex); + }); +});