diff --git a/.project-docs/30-worklog/tasks/20260826-ml05-preview-data-session-5c8e2a71.md b/.project-docs/30-worklog/tasks/20260826-ml05-preview-data-session-5c8e2a71.md new file mode 100644 index 0000000..3b226fd --- /dev/null +++ b/.project-docs/30-worklog/tasks/20260826-ml05-preview-data-session-5c8e2a71.md @@ -0,0 +1,120 @@ +# Task: ML-05 preview session manager and local data routes + +## Identity + +- Task ID: 20260826-ml05-preview-data-session-5c8e2a71 +- Mode: Feature +- Branch: codex/20260826-ml05-preview-data-session-5c8e2a71-ml05-preview-data-session-5c8e2a71 +- Worktree: D:\Datas\OthersProjects\makelore-ml05-preview-data-session-5c8e2a71 +- Base commit: bec67082b3dade05e900911c13ad804e781309f6 +- Owner: ml05_preview_session +- Status: Ready for integration + +## Scope + +- Implement the Main-owned ephemeral preview data-session manager and the + exact `/api/runtime/data/v1` local runtime routes from the ML-05 contract. +- Wire session creation and invalidation through Coding composition, Works + Square session lifecycle, active project/identity transitions, Agent Browser + lifecycle callbacks, and Main shutdown without implementing ML-06 injection. +- Add focused unit coverage and real loopback HTTP coverage for auth, Origin, + method/header, bounds, rate, route-precedence, forwarding, and invalidation + behavior. + +## Intent And Constraints + +- Keep the bearer capability ephemeral: 32 random bytes encoded base64url, + bound to the active real project path, canonical durable project ID, exact + preview Origin, and browser generation. Never persist or log it. +- Expose only data-plane GET/list/PUT/DELETE plus OPTIONS. Require exact-Origin + CORS and `Authorization` on non-OPTIONS requests, enforce 96 KiB request and + 1.25 MiB response bounds, and charge one token per authorized non-OPTIONS + operation using a 5/s, capacity-30 bucket; OPTIONS is free. +- Insert the trusted project path through the existing ML-03/ML-04 + `DataServiceOperations` seam. Do not accept project/owner/path authority + from the local URL or request body and do not reach lifecycle/general Host + routes, `/api/me/*`, or the Renderer in-process dispatcher. +- Dispatch the exact `/api/runtime/data/v1` branch in `electron/api/server.ts` + before global Host-token, generic CORS, OPTIONS, or content-type gates. +- Invalidate synchronously on logout/session clear, project deactivation or + identity change, browser close/detach/crash/generation replacement or + cross-Origin main-frame navigation, and Main shutdown. Same-Origin preview + navigation may retain the session. +- Preserve existing Works session observer behavior; change + `works-square-session.ts` only when its public observer seam is insufficient. +- Keep changes limited to ML-05 ownership paths and do not edit Agent Browser + injection or ML-06-specific contracts. + +## Planning Gate + +- Result: Passed on 2026-08-26. +- Concurrent Task Gate passed after creating the isolated worktree from exact + base `bec67082b3dade05e900911c13ad804e781309f6`; owner/status matched this + task ID, branch, and absolute worktree. +- Read the required MakeLore entry documents, integrated state, accepted auth + and Pi decisions, architecture/domain/evidence/commitment/stale indexes, + cross-repository ML-05 plan/spec/canonical contract/ADR, and every peer task + record's required scope sections. Nine planning peers remain placeholders; + the coordinator is the only semantically overlapping peer and explicitly + delegates ML-05 to this isolated worktree. No unresolved conflict blocks + implementation. + +## Implementation Plan + +1. Inspect the integrated ML-03/ML-04 interfaces and current Host server, + Coding composition, Works session, project lifecycle, and browser callback + seams; keep the session API narrow and Main-private. +2. Implement session binding, token/Origin/method/header checks, bounds, + token-bucket accounting, CORS, cloud forwarding, and exact early route + dispatch. +3. Wire creation and every required invalidation source, including shutdown, + while preserving observer snapshot/unsubscribe semantics. +4. Add focused unit and real loopback HTTP tests proving successful data + operations, rejection/isolation, limits, rate behavior, and invalidation. +5. Run focused tests, typecheck/lint/build where affected, diff checks, and + task-aware documentation gates before producing one commit. + +## Outcome + +- Added `electron/services/preview-data-session.ts` with a Main-owned in-memory + 32-byte base64url bearer, active real-project/durable-identity binding, exact + Origin and browser-generation binding, Works Square account observation, a + continuous 5/s capacity-30 bucket, and synchronous invalidation/disposal. +- Added `electron/api/routes/runtime-data.ts` for direct data-only document/list + DTOs, exact-Origin CORS, request-header/method checks, bounded JSON bodies and + responses, strong ETags, conditional writes/deletes, trusted project-path + forwarding, and no lifecycle/project-management/general Host operations. +- Added the exact early `/api/runtime/data/v1` branch in `electron/api/server.ts` + and wired composition/Main project, Works session, Agent Browser lifecycle, + and shutdown invalidation. The Agent Browser change is limited to a narrow + lifecycle observer; it contains no ML-06 data injection, CDP new-document + script, script-ID, or navigation-injection behavior. +- Added focused manager and real loopback HTTP coverage for successful + GET/list/PUT/DELETE/OPTIONS operations, route precedence and general Host + isolation, trusted path forwarding, CORS/auth/header rejection, limits, rate + accounting, ETags, response validation, and all session invalidation paths. + +## Verification + +- `pnpm exec vitest run tests/unit/preview-data-session.test.ts tests/unit/data-service-routes.test.ts tests/unit/coding-core-routes.test.ts tests/unit/agent-browser-core.test.ts tests/unit/coding-attachments-routes.test.ts`: 5 files / 107 tests passed, including real loopback HTTP. +- `pnpm typecheck`: passed. +- `pnpm exec eslint` on all changed production/test files: passed. +- `pnpm lint:check`: passed with 0 errors and 5 pre-existing warnings in `src/pages/Home/index.tsx` and `src/pages/Makelore/index.tsx`. +- `pnpm test`: 187 files / 1,581 passed / 2 skipped, plus the single pressure test passed. +- `pnpm test:electron:windows`: 2 files / 4 tests passed. +- `pnpm build`: passed Vite, Electron, Pi runtime, and Windows NSIS packaging; only existing chunk/dynamic-import, optional-platform-dependency, and absent optional packaged-resource warnings were reported. +- `git diff --check`: passed (only Git LF/CRLF normalization warnings). +- ML-06 exclusion scan: no `injectProjectData`, `__MAKELORE_DATA__`, `Page.addScriptToEvaluateOnNewDocument`, script-ID tracking, or loopback-target navigation injection was added. + +## Follow-ups + +- ML-06 must consume the narrow session/injection-value interface and implement + opt-in pre-document injection, script cleanup, and its own browser-open + acceptance tests after integrating this task; those behaviors are + intentionally absent here. +- Cross-repository signed-in PostgreSQL acceptance and the full Electron + preview write/read flow remain integration-stage work. + +## Promotion Candidates + +- None recorded. diff --git a/electron/agent-browser/index.ts b/electron/agent-browser/index.ts index 0018876..ce82a97 100644 --- a/electron/agent-browser/index.ts +++ b/electron/agent-browser/index.ts @@ -14,6 +14,8 @@ export { agentBrowserPartition, } from './module'; export type { + AgentBrowserLifecycleEvent, + AgentBrowserLifecycleListener, AgentBrowserModuleOptions, AgentBrowserNavigateInput, AgentBrowserOpenInput, diff --git a/electron/agent-browser/module.ts b/electron/agent-browser/module.ts index c4cff45..ea7e321 100644 --- a/electron/agent-browser/module.ts +++ b/electron/agent-browser/module.ts @@ -159,8 +159,21 @@ export interface AgentBrowserReadPayloadInput { export interface AgentBrowserModuleOptions { payloadStore?: AgentBrowserPayloadStore; cdpGuard?: AgentBrowserCdpGuard; + onLifecycle?(event: AgentBrowserLifecycleEvent): void; } +export type AgentBrowserLifecycleEvent = Readonly<{ + type: 'closed' | 'detached' | 'crashed' | 'cross-origin-navigation' | 'generation-replaced'; + projectId: string; + projectPath: string; + generation: number; + url: string; + previousUrl?: string; + nextUrl?: string; +}>; + +export type AgentBrowserLifecycleListener = (event: AgentBrowserLifecycleEvent) => void; + export function agentBrowserPartition(projectPath: string): string { const normalized = normalizePath(projectPath); const key = createHash('sha256').update(normalized).digest('hex').slice(0, 32); @@ -172,6 +185,7 @@ export class AgentBrowserModule { private readonly cdpGuard: AgentBrowserCdpGuard; private readonly eventWaiters = new Set<() => void>(); private readonly commandCancellers = new Set<(fault: AgentBrowserFault) => void>(); + private readonly lifecycleListeners = new Set(); private record: BrowserRecord | null = null; private commandTail: Promise = Promise.resolve(); private lifecycleBarrier: Promise = Promise.resolve(); @@ -185,6 +199,15 @@ export class AgentBrowserModule { ) { this.payloadStore = options.payloadStore ?? new AgentBrowserPayloadStore(); this.cdpGuard = options.cdpGuard ?? new AgentBrowserCdpGuard(); + if (options.onLifecycle) this.lifecycleListeners.add(options.onLifecycle); + } + + subscribeLifecycle(listener: AgentBrowserLifecycleListener): () => void { + if (this.disposed) return () => undefined; + this.lifecycleListeners.add(listener); + return () => { + this.lifecycleListeners.delete(listener); + }; } async preflightCurrentProject(projectPath: string): Promise<{ ok: true }> { @@ -671,6 +694,13 @@ export class AgentBrowserModule { record.generation = ++this.generation; record.childSessions.clear(); record.ioHandles.clear(); + this.notifyLifecycle({ + type: 'generation-replaced', + projectId: record.projectId, + projectPath: record.projectPath, + generation: record.generation, + url: record.url, + }); } record.state = 'attaching'; record.error = undefined; @@ -728,17 +758,52 @@ export class AgentBrowserModule { ? '原生 DevTools 正在使用当前页面调试器。' : `开发浏览器调试器已断开:${String(reasonValue ?? 'unknown')}`, }; + this.notifyLifecycle({ + type: 'detached', + projectId: record.projectId, + projectPath: record.projectPath, + generation: record.generation, + url: record.url, + }); this.notifyEventWaiters(); }; this.addDebuggerListener(record, 'message', onDebuggerMessage); this.addDebuggerListener(record, 'detach', onDebuggerDetach); this.addWebContentsListener(record, 'did-navigate', (_event, urlValue) => { - if (typeof urlValue === 'string') record.url = urlValue; + const previousUrl = record.url; + if (typeof urlValue === 'string') { + record.url = urlValue; + if (crossOriginNavigation(previousUrl, urlValue)) { + this.notifyLifecycle({ + type: 'cross-origin-navigation', + projectId: record.projectId, + projectPath: record.projectPath, + generation: record.generation, + url: urlValue, + previousUrl, + nextUrl: urlValue, + }); + } + } this.refreshMetadata(record); }); - this.addWebContentsListener(record, 'did-navigate-in-page', (_event, urlValue) => { - if (typeof urlValue === 'string') record.url = urlValue; + this.addWebContentsListener(record, 'did-navigate-in-page', (_event, urlValue, isMainFrameValue) => { + const previousUrl = record.url; + if (typeof urlValue === 'string' && isMainFrameValue !== false) { + record.url = urlValue; + if (crossOriginNavigation(previousUrl, urlValue)) { + this.notifyLifecycle({ + type: 'cross-origin-navigation', + projectId: record.projectId, + projectPath: record.projectPath, + generation: record.generation, + url: urlValue, + previousUrl, + nextUrl: urlValue, + }); + } + } this.refreshMetadata(record); }); this.addWebContentsListener(record, 'page-title-updated', (_event, titleValue) => { @@ -785,6 +850,13 @@ export class AgentBrowserModule { code: 'RENDERER_CRASHED', message: '开发浏览器页面进程已退出。', }; + this.notifyLifecycle({ + type: 'crashed', + projectId: record.projectId, + projectPath: record.projectPath, + generation: record.generation, + url: record.url, + }); record.eventBuffer.markGap('view-recreated'); record.childSessions.clear(); record.ioHandles.clear(); @@ -797,6 +869,13 @@ export class AgentBrowserModule { code: 'TARGET_GONE', message: '开发浏览器页面已关闭。', }; + this.notifyLifecycle({ + type: 'crashed', + projectId: record.projectId, + projectPath: record.projectPath, + generation: record.generation, + url: record.url, + }); record.eventBuffer.markGap('view-recreated'); this.notifyEventWaiters(); }); @@ -1137,6 +1216,13 @@ export class AgentBrowserModule { } if (expected && this.record !== expected) return; record.state = 'closing'; + this.notifyLifecycle({ + type: 'closed', + projectId: record.projectId, + projectPath: record.projectPath, + generation: record.generation, + url: record.url, + }); this.removeListeners(record); this.record = null; const interrupted = new AgentBrowserFault( @@ -1435,6 +1521,16 @@ export class AgentBrowserModule { this.eventWaiters.clear(); for (const wake of waiters) wake(); } + + private notifyLifecycle(event: AgentBrowserLifecycleEvent): void { + for (const listener of this.lifecycleListeners) { + try { + listener(event); + } catch { + // Lifecycle observers must not interrupt browser teardown or navigation. + } + } + } } function normalizeRequiredPath(projectPath: string): string { @@ -1470,6 +1566,15 @@ function normalizeUrl(value: string): string { return url.toString(); } +function crossOriginNavigation(previousUrl: string, nextUrl: string): boolean { + if (previousUrl === 'about:blank' || nextUrl === 'about:blank') return false; + try { + return new URL(previousUrl).origin !== new URL(nextUrl).origin; + } catch { + return false; + } +} + async function beforePublishPreflightDeadline( operation: Promise, deadline: number, diff --git a/electron/api/coding-composition.ts b/electron/api/coding-composition.ts index f24ad36..e9749a6 100644 --- a/electron/api/coding-composition.ts +++ b/electron/api/coding-composition.ts @@ -32,6 +32,7 @@ import { } from './coding-provider-auth'; import { createCodingProductHost, type CodingProductComposition } from './coding-product-services'; import { createDataServiceOperations } from '../services/data-service-client'; +import { createPreviewDataSessionManager, type PreviewDataSessionManager } from '../services/preview-data-session'; import { archivePiConversationSession } from '../coding-runtime/pi/resource-loader'; import { resolveLegacyProjectModel } from '../coding-projects/legacy-v1'; @@ -164,6 +165,7 @@ export function createCodingComposition( ? { acquireBackgroundLease: options.acquireBackgroundLease } : {}), }); + let previewDataSession: PreviewDataSessionManager | undefined; const projects = new CodingProjectService(projectStore, { migration: { resolveLegacyModel: async ({ legacyModel }) => resolveLegacyProjectModel( @@ -180,9 +182,11 @@ export function createCodingComposition( for (const conversation of conversations) registry.forget(conversation.id); }, onProjectIdentityChanging: async (project) => { + previewDataSession?.invalidate('project_identity_changed'); await options.browser.close(project.path); }, onProjectDeactivated: async (project, reason) => { + previewDataSession?.invalidate('project_deactivated'); const conversations = await conversationStoreForProject(project.path).read() .then((file) => file.conversations) .catch(() => []); @@ -208,9 +212,16 @@ export function createCodingComposition( }); const dataService = createDataServiceOperations({ projects }); productTools.configureDataService(dataService); + previewDataSession = createPreviewDataSessionManager({ projects }); + const unsubscribeBrowserLifecycle = typeof options.browser.subscribeLifecycle === 'function' + ? options.browser.subscribeLifecycle((event) => { + previewDataSession?.handleAgentBrowserLifecycle(event); + }) + : () => undefined; return { attachments, dataService, + previewDataSession, productTools, projects, conversations, @@ -224,6 +235,8 @@ export function createCodingComposition( ))); }, async shutdown() { + previewDataSession?.dispose(); + unsubscribeBrowserLifecycle(); await subagents.close(); await runtime.shutdown(); }, diff --git a/electron/api/coding-product-services.ts b/electron/api/coding-product-services.ts index ff84bd8..1cf7a9d 100644 --- a/electron/api/coding-product-services.ts +++ b/electron/api/coding-product-services.ts @@ -18,6 +18,7 @@ import type { CodingConversationService } from '../coding-runtime/conversation-s import type { CodingConversationRuntime } from '../coding-runtime/contracts'; import type { PiProductTools } from '../coding-runtime/pi/product-tools'; import type { DataServiceOperations } from '../services/data-service-client'; +import type { PreviewDataSessionManager } from '../services/preview-data-session'; export interface ActiveCodingProject { id: string; @@ -37,6 +38,7 @@ export interface CodingProductHost { export interface CodingProductComposition { attachments: CodingAttachmentStore; dataService: DataServiceOperations; + previewDataSession?: PreviewDataSessionManager; productTools: PiProductTools; projects: CodingProjectService; conversations: CodingConversationService; diff --git a/electron/api/context.ts b/electron/api/context.ts index 17264bb..9865e48 100644 --- a/electron/api/context.ts +++ b/electron/api/context.ts @@ -14,6 +14,7 @@ import type { StaticArtifactSnapshot } from '../services/static-release-server'; import type { BackgroundLifecycleController } from '../main/background-lifecycle'; import type { ReleaseJobManager } from '../services/release-job'; import type { CodingProductComposition } from './coding-product-services'; +import type { PreviewDataSessionManager } from '../services/preview-data-session'; export type WorksSubmissionBindingStore = ReturnType; @@ -77,4 +78,5 @@ export interface HostApiContext { lifecycle?: BackgroundLifecycleController; releaseJobs?: ReleaseJobManager; codingProducts?: CodingProductComposition; + previewDataSession?: PreviewDataSessionManager; } diff --git a/electron/api/routes/runtime-data.ts b/electron/api/routes/runtime-data.ts new file mode 100644 index 0000000..46cfbcf --- /dev/null +++ b/electron/api/routes/runtime-data.ts @@ -0,0 +1,391 @@ +import type { IncomingMessage, ServerResponse } from 'node:http'; +import type { HostApiContext } from '../context'; +import { PREVIEW_DATA_MAX_REQUEST_BYTES, PREVIEW_DATA_MAX_RESPONSE_BYTES, PREVIEW_DATA_ROUTE_ROOT, type PreviewDataSessionManager } from '../../services/preview-data-session'; +import { sendNoContent } from '../route-utils'; +import type { + DataServiceErrorContext, + DataServiceHostResult, +} from '../../../shared/data-service'; + +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}$/; +const CORS_METHODS = 'GET, PUT, DELETE, OPTIONS'; +const CORS_HEADERS = 'Authorization, Content-Type, If-Match'; + +class PreviewDataRouteError extends Error { + constructor( + readonly status: number, + readonly code: string, + message: string, + readonly retryable = false, + ) { + super(message); + this.name = 'PreviewDataRouteError'; + } +} + +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, + message: string, + retryable = false, + extra: { retry_after_seconds?: number; context?: DataServiceErrorContext } = {}, +): DataServiceHostResult { + return { + success: false, + status, + code, + error: message, + 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, 'runtime_unavailable', 'Preview data runtime is unavailable', true); +} + +function invalidResponse(): DataServiceHostResult { + return routeFailure(502, 'upstream_invalid_response', 'Data Service returned an invalid response'); +} + +function isPreviewDataPath(pathname: string): boolean { + return pathname === PREVIEW_DATA_ROUTE_ROOT || pathname.startsWith(`${PREVIEW_DATA_ROUTE_ROOT}/`); +} + +function managerFor(ctx: HostApiContext): PreviewDataSessionManager | undefined { + return ctx.previewDataSession ?? ctx.codingProducts?.previewDataSession; +} + +function applyCors(res: ServerResponse, manager: PreviewDataSessionManager | undefined, req: IncomingMessage): void { + const origin = manager?.corsOrigin(req); + if (origin) { + res.setHeader('Access-Control-Allow-Origin', origin); + res.setHeader('Vary', 'Origin'); + } + res.setHeader('Access-Control-Allow-Methods', CORS_METHODS); + res.setHeader('Access-Control-Allow-Headers', CORS_HEADERS); +} + +function serializeBounded(value: unknown): string | null { + let encoded: string; + try { + encoded = JSON.stringify(value); + } catch { + return null; + } + if (typeof encoded !== 'string') return null; + return Buffer.byteLength(encoded, 'utf8') <= PREVIEW_DATA_MAX_RESPONSE_BYTES + ? encoded + : null; +} + +function sendBoundedJson(res: ServerResponse, status: number, payload: unknown): boolean { + const encoded = serializeBounded(payload); + if (encoded === null) return false; + res.statusCode = status; + res.setHeader('Content-Type', 'application/json; charset=utf-8'); + res.end(encoded); + return true; +} + +function sendError(res: ServerResponse, result: DataServiceHostResult): void { + const detail = { + code: result.code ?? 'runtime_unavailable', + message: result.error ?? 'Preview data runtime is unavailable', + retryable: result.retryable, + ...(result.context && Object.keys(result.context).length > 0 ? { context: result.context } : {}), + }; + if (result.retry_after_seconds !== undefined) { + res.setHeader('Retry-After', String(result.retry_after_seconds)); + } + if (!sendBoundedJson(res, result.status >= 400 && result.status <= 599 ? result.status : 503, { detail })) { + sendBoundedJson(res, 503, { + detail: { + code: 'runtime_unavailable', + message: 'Preview data runtime is unavailable', + retryable: true, + }, + }); + } +} + +function sendResult(res: ServerResponse, result: DataServiceHostResult): void { + res.setHeader('Cache-Control', 'private, no-store'); + if (!result.success) { + sendError(res, result as DataServiceHostResult); + return; + } + if (result.status === 204) { + sendNoContent(res); + return; + } + if (result.data === null) { + sendError(res, invalidResponse()); + return; + } + if (isRecord(result.data) && Number.isSafeInteger(result.data.revision) && result.data.revision > 0) { + res.setHeader('ETag', `"${result.data.revision}"`); + } + if (!sendBoundedJson(res, result.status, result.data)) { + sendError(res, invalidResponse()); + } +} + +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) > PREVIEW_DATA_MAX_REQUEST_BYTES) { + throw new PreviewDataRouteError(413, 'request_too_large', 'Preview data 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 > PREVIEW_DATA_MAX_REQUEST_BYTES) { + throw new PreviewDataRouteError(413, 'request_too_large', 'Preview data 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 PreviewDataRouteError(422, 'invalid_request', 'Preview data 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 PreviewDataRouteError(422, 'invalid_request', 'Preview data request is invalid'); + } +} + +function decodeSegment(value: string, maximum: number, pattern: RegExp): string { + let decoded: string; + try { + decoded = decodeURIComponent(value); + } catch { + throw new PreviewDataRouteError(422, 'invalid_request', 'Preview data request is invalid'); + } + if (decoded.length > maximum || !pattern.test(decoded)) { + throw new PreviewDataRouteError(422, 'invalid_request', 'Preview data 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 PreviewDataRouteError(422, 'invalid_request', 'Preview data 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 PreviewDataRouteError(422, 'invalid_request', 'Preview data request is invalid'); + } +} + +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 PreviewDataRouteError(422, 'invalid_request', 'Preview data request is invalid'); + } + const limit = Number(values[0]); + if (!Number.isSafeInteger(limit) || limit < 1 || limit > 100) { + throw new PreviewDataRouteError(422, 'invalid_request', 'Preview data 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 PreviewDataRouteError(422, 'invalid_request', 'Preview data 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 PreviewDataRouteError(422, 'invalid_revision', 'Data Service document revision is invalid'); + } + const revision = Number(value.slice(1, -1)); + if (!Number.isSafeInteger(revision) || revision < 1) { + throw new PreviewDataRouteError(422, 'invalid_revision', 'Data Service document revision is invalid'); + } + return revision; +} + +function parseData(value: unknown): Record { + if (!isRecord(value)) { + throw new PreviewDataRouteError(422, 'invalid_request', 'Preview data request is invalid'); + } + return value; +} + +function requireJsonContentType(req: IncomingMessage): void { + const value = req.headers['content-type']; + if (typeof value !== 'string' || value.split(';', 1)[0].trim().toLowerCase() !== 'application/json') { + throw new PreviewDataRouteError(415, 'invalid_content_type', 'Preview data requests require application/json'); + } +} + +function sendOptions(res: ServerResponse): void { + res.statusCode = 204; + res.end(); +} + +function operationsFor(ctx: HostApiContext) { + return ctx.codingProducts?.dataService; +} + +export async function handlePreviewDataRoutes( + req: IncomingMessage, + res: ServerResponse, + url: URL, + ctx: HostApiContext, +): Promise { + if (!isPreviewDataPath(url.pathname)) return false; + + const manager = managerFor(ctx); + applyCors(res, manager, req); + res.setHeader('Cache-Control', 'private, no-store'); + if (!manager) { + sendError(res, unavailable()); + return true; + } + + const authorization = manager.authorizeRequest(req); + if (!authorization.ok) { + sendError(res, routeFailure( + authorization.status, + authorization.code, + authorization.message, + authorization.retryable, + authorization.retryAfterSeconds === undefined + ? {} + : { retry_after_seconds: authorization.retryAfterSeconds }, + )); + return true; + } + + const operations = operationsFor(ctx); + if (!operations && authorization.method !== 'OPTIONS') { + sendError(res, unavailable()); + return true; + } + + const runOperation = async (operation: () => Promise>): Promise => { + if (!manager.isCurrentSession(authorization.session)) { + sendError(res, unavailable()); + return; + } + const result = await operation(); + if (!manager.isCurrentSession(authorization.session)) { + sendError(res, unavailable()); + return; + } + sendResult(res, result); + }; + + try { + const method = authorization.method; + const collectionPath = url.pathname.match( + new RegExp(`^${PREVIEW_DATA_ROUTE_ROOT}/collections/([^/]+)/documents$`), + ); + if (collectionPath) { + const collection = parseCollection(collectionPath[1]); + requireQueryKeys(url, ['limit', 'cursor']); + if (method === 'OPTIONS') { + sendOptions(res); + } else if (method === 'GET') { + await runOperation(() => operations!.listDocuments({ + collection, + limit: parseLimit(url), + cursor: parseCursor(url), + }, authorization.session.projectPath)); + } else { + sendError(res, routeFailure(405, 'method_not_allowed', 'Preview data method is not allowed')); + } + return true; + } + + const documentPath = url.pathname.match( + new RegExp(`^${PREVIEW_DATA_ROUTE_ROOT}/collections/([^/]+)/documents/([^/]+)$`), + ); + if (documentPath) { + const collection = parseCollection(documentPath[1]); + const documentId = parseDocumentId(documentPath[2]); + requireQueryKeys(url, []); + if (method === 'OPTIONS') { + sendOptions(res); + } else if (method === 'GET') { + await runOperation(() => operations!.getDocument( + { collection, document_id: documentId }, + authorization.session.projectPath, + )); + } else if (method === 'PUT') { + requireJsonContentType(req); + const body = await readBoundedJson(req); + requireExactKeys(body, ['data']); + const ifRevision = parseIfMatch(req); + await runOperation(() => operations!.putDocument({ + collection, + document_id: documentId, + data: parseData(body.data), + ...(ifRevision === undefined ? {} : { if_revision: ifRevision }), + }, authorization.session.projectPath)); + } else if (method === 'DELETE') { + const ifRevision = parseIfMatch(req); + await runOperation(() => operations!.deleteDocument({ + collection, + document_id: documentId, + ...(ifRevision === undefined ? {} : { if_revision: ifRevision }), + confirmed: true, + }, authorization.session.projectPath)); + } else { + sendError(res, routeFailure(405, 'method_not_allowed', 'Preview data method is not allowed')); + } + return true; + } + + sendError(res, routeFailure(404, 'route_not_found', 'Preview data route was not found')); + return true; + } catch (error) { + if (error instanceof PreviewDataRouteError) { + sendError(res, routeFailure(error.status, error.code, error.message, error.retryable)); + return true; + } + sendError(res, unavailable()); + return true; + } +} + +export const isPreviewDataRoute = isPreviewDataPath; diff --git a/electron/api/server.ts b/electron/api/server.ts index d467a2d..19b1368 100644 --- a/electron/api/server.ts +++ b/electron/api/server.ts @@ -6,6 +6,7 @@ import type { HostApiContext } from './context'; import { sendJson, setCorsHeaders, requireJsonContentType } from './route-utils'; import { rotateRendererCapability } from './renderer-capability'; import { hostApiRouteHandlers } from './route-handlers'; +import { handlePreviewDataRoutes, isPreviewDataRoute } from './routes/runtime-data'; /** * Per-session secret token used to authenticate Host API requests. @@ -29,6 +30,16 @@ export function startHostApiServer(ctx: HostApiContext, port = getPort('NIANCODE const server = createServer(async (req, res) => { try { const requestUrl = new URL(req.url || '/', `http://127.0.0.1:${port}`); + + // Preview data is a separate, capability-scoped data plane. It must not + // pass through the broad Host token, Renderer CORS, preflight, or JSON + // gates below: preview pages only have their exact Origin and ephemeral + // data bearer. Keep this branch out of the shared in-process dispatcher. + if (isPreviewDataRoute(requestUrl.pathname)) { + await handlePreviewDataRoutes(req, res, requestUrl, ctx); + return; + } + // ── CORS headers ───────────────────────────────────────── // Set origin-aware CORS headers early so every response // (including error responses) carries them consistently. diff --git a/electron/main/index.ts b/electron/main/index.ts index 292c76e..ccae18f 100644 --- a/electron/main/index.ts +++ b/electron/main/index.ts @@ -589,6 +589,7 @@ async function initialize(): Promise { lifecycle: backgroundLifecycle, releaseJobs, codingProducts, + previewDataSession: codingProducts.previewDataSession, }; registerIpcHandlers(window, backgroundLifecycle, hostApiContext); @@ -784,6 +785,7 @@ if (gotTheLock) { } hostEventBus.closeAll(); + codingProducts?.previewDataSession?.invalidate('main_shutdown'); hostApiServer?.close(); projectProgressSync?.stop(); backgroundLifecycle?.dispose(); @@ -820,6 +822,7 @@ if (gotTheLock) { const emergencyRuntimeCleanup = (reason: string, error: unknown): void => { logger.error(`${reason}:`, error); projectProgressSync?.stop(); + codingProducts?.previewDataSession?.invalidate('main_shutdown'); try { void agentBrowser?.dispose().catch(() => { /* ignore */ }); } catch { diff --git a/electron/services/preview-data-session.ts b/electron/services/preview-data-session.ts new file mode 100644 index 0000000..ac04ad4 --- /dev/null +++ b/electron/services/preview-data-session.ts @@ -0,0 +1,428 @@ +import { randomBytes, timingSafeEqual } from 'node:crypto'; +import path from 'node:path'; +import type { IncomingMessage } from 'node:http'; +import type { + AgentBrowserLifecycleEvent, +} from '../agent-browser/module'; +import type { CodingProjectService } from '../coding-projects/project-service'; +import { + getWorksSquareAccountBinding, + getWorksSquareSessionSnapshot, + isCurrentWorksSquareAccountBinding, + subscribeWorksSquareSession, + type WorksSquareAccountBinding, + type WorksSquareSessionListener, +} from './works-square-session'; +import { getPort } from '../utils/config'; + +export const PREVIEW_DATA_ROUTE_ROOT = '/api/runtime/data/v1'; +export const PREVIEW_DATA_CONTRACT_VERSION = 1 as const; +export const PREVIEW_DATA_MAX_REQUEST_BYTES = 98_304; +export const PREVIEW_DATA_MAX_RESPONSE_BYTES = 1_310_720; +export const PREVIEW_DATA_RATE = 5; +export const PREVIEW_DATA_BUCKET_CAPACITY = 30; + +const PREVIEW_DATA_METHODS = new Set(['GET', 'PUT', 'DELETE', 'OPTIONS']); +const PREVIEW_DATA_HEADERS = new Set(['authorization', 'content-type', 'if-match']); +const PREVIEW_DATA_TRANSPORT_HEADERS = new Set([ + 'accept', + 'accept-encoding', + 'accept-language', + 'cache-control', + 'connection', + 'content-length', + 'host', + 'origin', + 'priority', + 'pragma', + 'referer', + 'sec-ch-ua', + 'sec-ch-ua-mobile', + 'sec-ch-ua-platform', + 'sec-fetch-dest', + 'sec-fetch-mode', + 'sec-fetch-site', + 'sec-fetch-user', + 'transfer-encoding', + 'upgrade-insecure-requests', + 'user-agent', +]); + +export type PreviewDataSessionInvalidationReason = + | 'preview_closed' + | 'project_deactivated' + | 'project_identity_changed' + | 'session_cleared' + | 'account_changed' + | 'cross_origin_navigation' + | 'browser_generation_replaced' + | 'browser_detached' + | 'browser_crashed' + | 'main_shutdown' + | 'replaced' + | 'manual'; + +export type PreviewDataSessionOpenInput = { + projectPath: string; + origin: string; + browserGeneration: number; +}; + +export type PreviewDataSessionSnapshot = Readonly<{ + projectPath: string; + projectId: string; + origin: string; + browserGeneration: number; + createdAt: number; +}>; + +export type PreviewDataInjectionValue = Readonly<{ + endpoint: string; + token: string; + contractVersion: typeof PREVIEW_DATA_CONTRACT_VERSION; +}>; + +export type PreviewDataAuthorizationSuccess = Readonly<{ + ok: true; + method: string; + session: PreviewDataSessionSnapshot; +}>; + +export type PreviewDataAuthorizationFailure = Readonly<{ + ok: false; + status: number; + code: string; + message: string; + retryable: boolean; + retryAfterSeconds?: number; +}>; + +export type PreviewDataAuthorizationResult = + | PreviewDataAuthorizationSuccess + | PreviewDataAuthorizationFailure; + +export class PreviewDataSessionError extends Error { + constructor( + readonly code: 'invalid_origin' | 'invalid_generation' | 'runtime_unavailable', + message: string, + ) { + super(message); + this.name = 'PreviewDataSessionError'; + } +} + +export type PreviewDataSessionManagerOptions = { + projects: Pick; + hostPort?: number; + now?: () => number; + randomBytes?: (size: number) => Buffer; + subscribeWorksSquareSession?: typeof subscribeWorksSquareSession; + getWorksSquareSessionSnapshot?: typeof getWorksSquareSessionSnapshot; + getWorksSquareAccountBinding?: typeof getWorksSquareAccountBinding; + isCurrentWorksSquareAccountBinding?: typeof isCurrentWorksSquareAccountBinding; +}; + +type InternalSession = { + snapshot: PreviewDataSessionSnapshot; + token: string; + tokenBytes: Buffer; + tokens: number; + lastRefillMs: number; +}; + +function normalizePath(value: string): string { + const resolved = path.resolve(value); + return process.platform === 'win32' ? resolved.toLocaleLowerCase('en-US') : resolved; +} + +function samePath(left: string, right: string): boolean { + return normalizePath(left) === normalizePath(right); +} + +function normalizeOrigin(value: string): string { + let parsed: URL; + try { + parsed = new URL(value); + } catch { + throw new PreviewDataSessionError('invalid_origin', 'Preview Origin is invalid'); + } + const hostname = parsed.hostname.toLowerCase(); + const isLoopback = hostname === 'localhost' + || hostname === '127.0.0.1' + || hostname === '[::1]'; + if ( + !isLoopback + || parsed.protocol !== 'http:' + || !parsed.port + || parsed.username + || parsed.password + || parsed.pathname !== '/' + || parsed.search + || parsed.hash + ) { + throw new PreviewDataSessionError('invalid_origin', 'Preview Origin is invalid'); + } + return parsed.origin; +} + +function normalizeGeneration(value: number): number { + if (!Number.isSafeInteger(value) || value < 1) { + throw new PreviewDataSessionError('invalid_generation', 'Browser generation is invalid'); + } + return value; +} + +function headerValue(req: IncomingMessage, name: string): string | undefined { + const value = req.headers[name]; + return typeof value === 'string' ? value : undefined; +} + +function failure( + status: number, + code: string, + message: string, + retryable = false, + retryAfterSeconds?: number, +): PreviewDataAuthorizationFailure { + return { + ok: false, + status, + code, + message, + retryable, + ...(retryAfterSeconds === undefined ? {} : { retryAfterSeconds }), + }; +} + +function requestHeadersAllowed(req: IncomingMessage): boolean { + for (const [name, value] of Object.entries(req.headers)) { + if (Array.isArray(value)) return false; + if (PREVIEW_DATA_HEADERS.has(name) || PREVIEW_DATA_TRANSPORT_HEADERS.has(name)) continue; + if (name === 'access-control-request-method') { + if (value !== undefined && !PREVIEW_DATA_METHODS.has(value.toUpperCase())) return false; + continue; + } + if (name === 'access-control-request-headers') { + if (value === undefined) continue; + const requested = value.split(',').map((item) => item.trim().toLowerCase()).filter(Boolean); + if (requested.some((item) => !PREVIEW_DATA_HEADERS.has(item))) return false; + continue; + } + return false; + } + return true; +} + +function isExactToken(candidate: string, expected: Buffer): boolean { + if (!candidate || !/^[A-Za-z0-9_-]+$/.test(candidate)) return false; + const candidateBytes = Buffer.from(candidate, 'base64url'); + return candidateBytes.length === expected.length && timingSafeEqual(candidateBytes, expected); +} + +function retryAfterSeconds(tokens: number): number { + return Math.max(1, Math.ceil((1 - tokens) / PREVIEW_DATA_RATE)); +} + +export class PreviewDataSessionManager { + private readonly projects: Pick; + private readonly hostPort: number; + private readonly now: () => number; + private readonly createRandomBytes: (size: number) => Buffer; + private readonly getSessionSnapshot: typeof getWorksSquareSessionSnapshot; + private readonly getAccountBinding: typeof getWorksSquareAccountBinding; + private readonly isCurrentAccountBinding: typeof isCurrentWorksSquareAccountBinding; + private readonly unsubscribeWorksSession: () => void; + private session: InternalSession | null = null; + private boundAccountBinding: WorksSquareAccountBinding | null; + private disposed = false; + + constructor(options: PreviewDataSessionManagerOptions) { + this.projects = options.projects; + this.hostPort = options.hostPort ?? getPort('NIANCODE_HOST_API'); + this.now = options.now ?? Date.now; + this.createRandomBytes = options.randomBytes ?? randomBytes; + this.getSessionSnapshot = options.getWorksSquareSessionSnapshot ?? getWorksSquareSessionSnapshot; + this.getAccountBinding = options.getWorksSquareAccountBinding ?? getWorksSquareAccountBinding; + this.isCurrentAccountBinding = options.isCurrentWorksSquareAccountBinding + ?? isCurrentWorksSquareAccountBinding; + // The Works observer intentionally has no initial callback. Read the + // current snapshot once, then rely on the unsubscribe-able observer for + // subsequent logout/account transitions. + const initialSession = this.getSessionSnapshot(); + this.boundAccountBinding = initialSession ? this.getAccountBinding() : null; + + const subscribe = options.subscribeWorksSquareSession ?? subscribeWorksSquareSession; + const listener: WorksSquareSessionListener = (nextSession) => { + if (!nextSession) { + this.invalidate('session_cleared'); + return; + } + if (!this.boundAccountBinding) { + this.invalidate('account_changed'); + return; + } + if (!this.isCurrentAccountBinding(this.boundAccountBinding)) { + this.invalidate('account_changed'); + } + }; + this.unsubscribeWorksSession = subscribe(listener); + } + + async open(input: PreviewDataSessionOpenInput): Promise { + if (this.disposed) { + throw new PreviewDataSessionError('runtime_unavailable', 'Preview data runtime is unavailable'); + } + const origin = normalizeOrigin(input.origin); + const browserGeneration = normalizeGeneration(input.browserGeneration); + const active = await this.projects.requireActiveRealProjectWithIdentity(input.projectPath); + if (this.disposed) { + throw new PreviewDataSessionError('runtime_unavailable', 'Preview data runtime is unavailable'); + } + + const now = this.now(); + const tokenBytes = this.createRandomBytes(32); + if (tokenBytes.length !== 32) { + throw new PreviewDataSessionError('runtime_unavailable', 'Preview data runtime is unavailable'); + } + const token = tokenBytes.toString('base64url'); + const snapshot: PreviewDataSessionSnapshot = Object.freeze({ + projectPath: active.path, + projectId: active.projectId, + origin, + browserGeneration, + createdAt: now, + }); + this.session = { + snapshot, + token, + tokenBytes, + tokens: PREVIEW_DATA_BUCKET_CAPACITY, + lastRefillMs: now, + }; + this.boundAccountBinding = this.getAccountBinding(); + return snapshot; + } + + getSnapshot(): PreviewDataSessionSnapshot | null { + return this.session?.snapshot ?? null; + } + + getInjectionValue(hostPort = this.hostPort): PreviewDataInjectionValue | null { + const session = this.session; + if (!session || !Number.isSafeInteger(hostPort) || hostPort < 1 || hostPort > 65_535) { + return null; + } + return Object.freeze({ + endpoint: `http://127.0.0.1:${hostPort}${PREVIEW_DATA_ROUTE_ROOT}`, + token: session.token, + contractVersion: PREVIEW_DATA_CONTRACT_VERSION, + }); + } + + private accountBindingIsCurrent(): boolean { + const currentSession = this.getSessionSnapshot(); + const currentBinding = this.getAccountBinding(); + if (!this.boundAccountBinding) return !currentSession && !currentBinding; + return Boolean(currentBinding && this.isCurrentAccountBinding(this.boundAccountBinding)); + } + + corsOrigin(req: IncomingMessage): string | null { + const origin = headerValue(req, 'origin'); + const session = this.session; + return session && origin === session.snapshot.origin ? origin : null; + } + + authorizeRequest(req: IncomingMessage): PreviewDataAuthorizationResult { + const session = this.session; + if (!session) return failure(503, 'runtime_unavailable', 'Preview data runtime is unavailable', true); + if (!this.accountBindingIsCurrent()) { + this.invalidate('account_changed'); + return failure(503, 'runtime_unavailable', 'Preview data runtime is unavailable', true); + } + + const origin = headerValue(req, 'origin'); + if (origin !== session.snapshot.origin) { + return failure(403, 'origin_not_allowed', 'Preview Origin is not allowed'); + } + if (!requestHeadersAllowed(req)) { + return failure(400, 'invalid_request', 'Preview request headers are not allowed'); + } + + const method = (req.method ?? '').toUpperCase(); + if (method === 'OPTIONS') { + return { ok: true, method, session: session.snapshot }; + } + + const authorization = headerValue(req, 'authorization') ?? ''; + if (!authorization.startsWith('Bearer ') || !isExactToken(authorization.slice(7), session.tokenBytes)) { + return failure(401, 'authentication_required', 'Preview data authorization is required'); + } + if (!PREVIEW_DATA_METHODS.has(method)) { + return failure(405, 'method_not_allowed', 'Preview data method is not allowed'); + } + + const now = this.now(); + const elapsed = Math.max(0, now - session.lastRefillMs); + session.tokens = Math.min( + PREVIEW_DATA_BUCKET_CAPACITY, + session.tokens + elapsed / 1_000 * PREVIEW_DATA_RATE, + ); + session.lastRefillMs = now; + if (session.tokens < 1) { + return failure( + 429, + 'rate_limited', + 'Preview data request rate limit exceeded', + true, + retryAfterSeconds(session.tokens), + ); + } + session.tokens -= 1; + return { ok: true, method, session: session.snapshot }; + } + + isCurrentSession(snapshot: PreviewDataSessionSnapshot): boolean { + return this.session?.snapshot === snapshot; + } + + handleAgentBrowserLifecycle(event: AgentBrowserLifecycleEvent): void { + const session = this.session; + if (!session || !samePath(session.snapshot.projectPath, event.projectPath)) return; + switch (event.type) { + case 'closed': + this.invalidate('preview_closed'); + break; + case 'cross-origin-navigation': + this.invalidate('cross_origin_navigation'); + break; + case 'generation-replaced': + this.invalidate('browser_generation_replaced'); + break; + case 'detached': + this.invalidate('browser_detached'); + break; + case 'crashed': + this.invalidate('browser_crashed'); + break; + default: + break; + } + } + + invalidate(_reason: PreviewDataSessionInvalidationReason = 'manual'): void { + this.session = null; + } + + dispose(): void { + if (this.disposed) return; + this.disposed = true; + this.invalidate('main_shutdown'); + this.unsubscribeWorksSession(); + } +} + +export function createPreviewDataSessionManager( + options: PreviewDataSessionManagerOptions, +): PreviewDataSessionManager { + return new PreviewDataSessionManager(options); +} diff --git a/tests/unit/preview-data-session.test.ts b/tests/unit/preview-data-session.test.ts new file mode 100644 index 0000000..8f5855d --- /dev/null +++ b/tests/unit/preview-data-session.test.ts @@ -0,0 +1,324 @@ +import { EventEmitter, once } from 'node:events'; +import { mkdtemp, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { startHostApiServer } from '@electron/api/server'; +import type { HostApiContext } from '@electron/api/context'; +import type { AgentBrowserLifecycleEvent } from '@electron/agent-browser/module'; +import { + PREVIEW_DATA_BUCKET_CAPACITY, + PREVIEW_DATA_MAX_REQUEST_BYTES, + createPreviewDataSessionManager, + type PreviewDataSessionManager, +} from '@electron/services/preview-data-session'; +import type { DataServiceOperations } from '@electron/services/data-service-client'; +import { + clearWorksSquareSession, + resetWorksSquareSessionForTests, + storeWorksSquareSession, +} from '@electron/services/works-square-session'; +import type { DataServiceDocument, DataServiceHostResult } from '../../shared/data-service'; + +const roots: string[] = []; + +function success(data: T, status = 200): DataServiceHostResult { + return { success: true, status, code: null, error: null, retryable: false, data }; +} + +function operationSet(overrides: Partial>> = {}) { + return { + configure: vi.fn().mockResolvedValue(success(null)), + inspect: vi.fn().mockResolvedValue(success(null)), + listProjects: vi.fn().mockResolvedValue(success(null)), + getDocument: vi.fn().mockResolvedValue(success(null)), + listDocuments: vi.fn().mockResolvedValue(success(null)), + putDocument: vi.fn().mockResolvedValue(success(null)), + deleteDocument: vi.fn().mockResolvedValue(success(null, 204)), + removeCollection: vi.fn().mockResolvedValue(success(null)), + reset: vi.fn().mockResolvedValue(success(null)), + removeProject: vi.fn().mockResolvedValue(success(null)), + ...overrides, + } as unknown as DataServiceOperations; +} + +function request(method: string, origin: string, token: string): import('node:http').IncomingMessage { + const req = new EventEmitter(); + Object.assign(req, { + method, + headers: { + origin, + authorization: `Bearer ${token}`, + host: '127.0.0.1', + }, + }); + return req as import('node:http').IncomingMessage; +} + +async function createManager( + operations = operationSet(), + now: () => number = () => 0, +): Promise<{ manager: PreviewDataSessionManager; operations: DataServiceOperations; projectPath: string }> { + const projectPath = await mkdtemp(path.join(tmpdir(), 'makelore-preview-data-')); + roots.push(projectPath); + const projects = { + requireActiveRealProjectWithIdentity: vi.fn(async () => ({ + project: { path: projectPath }, + path: projectPath, + projectId: '11111111-1111-4111-8111-111111111111', + })), + }; + return { + manager: createPreviewDataSessionManager({ projects, now }), + operations, + projectPath, + }; +} + +async function startServer(manager: PreviewDataSessionManager, operations: DataServiceOperations) { + const context = { + previewDataSession: manager, + codingProducts: { dataService: operations, previewDataSession: manager }, + } as unknown as HostApiContext; + const server = startHostApiServer(context, 0); + await once(server, 'listening'); + const address = server.address(); + if (!address || typeof address === 'string') throw new Error('Preview test server failed to bind'); + return { + baseUrl: `http://127.0.0.1:${address.port}`, + port: address.port, + close: async () => await new Promise((resolve, reject) => { + server.closeAllConnections(); + server.close((error) => error ? reject(error) : resolve()); + }), + }; +} + +afterEach(async () => { + resetWorksSquareSessionForTests(); + await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true }))); +}); + +describe('preview data session manager', () => { + it('creates a fresh base64url capability bound to the active project and Origin', async () => { + const { manager, projectPath } = await createManager(); + + const session = await manager.open({ + projectPath, + origin: 'http://127.0.0.1:13210', + browserGeneration: 4, + }); + const value = manager.getInjectionValue(13210); + + expect(session).toMatchObject({ + projectPath, + projectId: '11111111-1111-4111-8111-111111111111', + origin: 'http://127.0.0.1:13210', + browserGeneration: 4, + }); + expect(value).toMatchObject({ + endpoint: 'http://127.0.0.1:13210/api/runtime/data/v1', + contractVersion: 1, + }); + expect(value?.token).toMatch(/^[A-Za-z0-9_-]{43}$/); + expect(manager.getInjectionValue(0)).toBeNull(); + + manager.dispose(); + expect(manager.getSnapshot()).toBeNull(); + }); + + it('requires an HTTP loopback Origin and invalidates on browser lifecycle events', async () => { + const { manager, projectPath } = await createManager(); + await expect(manager.open({ + projectPath, + origin: 'https://example.com:443', + browserGeneration: 1, + })).rejects.toMatchObject({ code: 'invalid_origin' }); + + const lifecycleTypes: AgentBrowserLifecycleEvent['type'][] = [ + 'cross-origin-navigation', + 'generation-replaced', + 'detached', + 'crashed', + 'closed', + ]; + for (const type of lifecycleTypes) { + await manager.open({ projectPath, origin: 'http://127.0.0.1:13210', browserGeneration: 1 }); + manager.handleAgentBrowserLifecycle({ + type, + projectId: '11111111-1111-4111-8111-111111111111', + projectPath, + generation: 1, + url: 'http://127.0.0.1:13210/', + }); + expect(manager.getSnapshot()).toBeNull(); + } + }); + + it('charges authorized operations with a burst-30, five-per-second bucket while OPTIONS is free', async () => { + const { manager, projectPath } = await createManager(undefined, () => 0); + await manager.open({ projectPath, origin: 'http://127.0.0.1:13210', browserGeneration: 1 }); + const token = manager.getInjectionValue(13210)?.token; + if (!token) throw new Error('Preview token was not created'); + + const preflight = request('OPTIONS', 'http://127.0.0.1:13210', token); + for (let index = 0; index < 10; index += 1) { + expect(manager.authorizeRequest(preflight)).toMatchObject({ ok: true }); + } + for (let index = 0; index < PREVIEW_DATA_BUCKET_CAPACITY; index += 1) { + expect(manager.authorizeRequest(request('GET', 'http://127.0.0.1:13210', token))).toMatchObject({ ok: true }); + } + expect(manager.authorizeRequest(request('GET', 'http://127.0.0.1:13210', token))).toMatchObject({ + ok: false, + status: 429, + code: 'rate_limited', + }); + }); + + it('invalidates on account replacement and session clear while retaining same-account refresh', async () => { + storeWorksSquareSession({ accessToken: 'one', accountPartitionKey: 'a'.repeat(64) }); + const { manager, projectPath } = await createManager(); + await manager.open({ projectPath, origin: 'http://127.0.0.1:13210', browserGeneration: 1 }); + + storeWorksSquareSession({ accessToken: 'two', accountPartitionKey: 'a'.repeat(64) }); + expect(manager.getSnapshot()).not.toBeNull(); + storeWorksSquareSession({ accessToken: 'three', accountPartitionKey: 'b'.repeat(64) }); + expect(manager.getSnapshot()).toBeNull(); + + await manager.open({ projectPath, origin: 'http://127.0.0.1:13210', browserGeneration: 2 }); + clearWorksSquareSession(); + expect(manager.getSnapshot()).toBeNull(); + }); +}); + +describe('preview data loopback routes', () => { + const document: DataServiceDocument = { + id: 'todo-1', + data: { done: false }, + revision: 7, + created_at: '2026-08-26T08:00:00Z', + updated_at: '2026-08-26T08:00:00Z', + }; + + it('dispatches direct DTO data operations before the Host gates and keeps general routes isolated', async () => { + const operations = operationSet({ + getDocument: vi.fn().mockResolvedValue(success(document)), + listDocuments: vi.fn().mockResolvedValue(success({ items: [document], next_cursor: null, limit: 50 })), + putDocument: vi.fn().mockResolvedValue(success({ ...document, revision: 8 })), + deleteDocument: vi.fn().mockResolvedValue(success(null, 204)), + }); + const { manager, projectPath } = await createManager(operations); + const server = await startServer(manager, operations); + const origin = server.baseUrl; + await manager.open({ projectPath, origin, browserGeneration: 1 }); + const value = manager.getInjectionValue(server.port); + if (!value) throw new Error('Preview token was not created'); + const headers = { Origin: origin, Authorization: `Bearer ${value.token}` }; + try { + const get = await fetch(`${origin}/api/runtime/data/v1/collections/todos/documents/todo-1`, { headers }); + expect(get.status).toBe(200); + expect(get.headers.get('access-control-allow-origin')).toBe(origin); + expect(get.headers.get('access-control-allow-credentials')).toBeNull(); + expect(get.headers.get('etag')).toBe('"7"'); + await expect(get.json()).resolves.toEqual(document); + + const list = await fetch(`${origin}/api/runtime/data/v1/collections/todos/documents?limit=50`, { headers }); + expect(list.status).toBe(200); + await expect(list.json()).resolves.toEqual({ items: [document], next_cursor: null, limit: 50 }); + + const put = await fetch(`${origin}/api/runtime/data/v1/collections/todos/documents/todo-1`, { + method: 'PUT', + headers: { ...headers, 'Content-Type': 'application/json', 'If-Match': '"7"' }, + body: JSON.stringify({ data: { done: true } }), + }); + expect(put.status).toBe(200); + expect(operations.putDocument).toHaveBeenCalledWith({ + collection: 'todos', document_id: 'todo-1', data: { done: true }, if_revision: 7, + }, projectPath); + + const deleted = await fetch(`${origin}/api/runtime/data/v1/collections/todos/documents/todo-1`, { + method: 'DELETE', headers, + }); + expect(deleted.status).toBe(204); + expect(operations.deleteDocument).toHaveBeenCalledWith({ + collection: 'todos', document_id: 'todo-1', confirmed: true, + }, projectPath); + + const options = await fetch(`${origin}/api/runtime/data/v1/collections/todos/documents/todo-1`, { + method: 'OPTIONS', + headers: { + Origin: origin, + 'Access-Control-Request-Method': 'PUT', + 'Access-Control-Request-Headers': 'authorization, content-type, if-match', + }, + }); + expect(options.status).toBe(204); + expect(options.headers.get('access-control-allow-methods')).toBe('GET, PUT, DELETE, OPTIONS'); + expect(options.headers.get('access-control-allow-headers')).toBe('Authorization, Content-Type, If-Match'); + + const general = await fetch(`${origin}/api/works/data-service/projects`, { headers }); + expect(general.status).toBe(401); + + const projectIdInLocalPath = await fetch( + `${origin}/api/runtime/data/v1/projects/11111111-1111-4111-8111-111111111111`, + { headers }, + ); + expect(projectIdInLocalPath.status).toBe(404); + } finally { + manager.dispose(); + await server.close(); + } + }); + + it('rejects wrong Origin, malformed headers, oversized bodies, and oversized responses locally', async () => { + const oversized = { data: { payload: 'x'.repeat(1_400_000) } }; + const operations = operationSet({ + getDocument: vi.fn().mockResolvedValue(success({ ...document, ...oversized })), + }); + const { manager, projectPath } = await createManager(operations); + const server = await startServer(manager, operations); + const origin = server.baseUrl; + await manager.open({ projectPath, origin, browserGeneration: 1 }); + const token = manager.getInjectionValue(server.port)?.token; + if (!token) throw new Error('Preview token was not created'); + try { + const wrongOrigin = await fetch(`${origin}/api/runtime/data/v1/collections/todos/documents/todo-1`, { + headers: { Origin: 'http://127.0.0.1:9999', Authorization: `Bearer ${token}` }, + }); + expect(wrongOrigin.status).toBe(403); + expect(wrongOrigin.headers.get('access-control-allow-origin')).toBeNull(); + + const malformedHeader = await fetch(`${origin}/api/runtime/data/v1/collections/todos/documents/todo-1`, { + headers: { + Origin: origin, + Authorization: `Bearer ${token}`, + 'X-Not-Allowed': '1', + }, + }); + expect(malformedHeader.status).toBe(400); + expect(operations.getDocument).not.toHaveBeenCalled(); + + const tooLarge = await fetch(`${origin}/api/runtime/data/v1/collections/todos/documents/todo-1`, { + method: 'PUT', + headers: { + Origin: origin, + Authorization: `Bearer ${token}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ data: { payload: 'x'.repeat(PREVIEW_DATA_MAX_REQUEST_BYTES) } }), + }); + expect(tooLarge.status).toBe(413); + + const responseTooLarge = await fetch(`${origin}/api/runtime/data/v1/collections/todos/documents/todo-1`, { + headers: { Origin: origin, Authorization: `Bearer ${token}` }, + }); + expect(responseTooLarge.status).toBe(502); + await expect(responseTooLarge.json()).resolves.toMatchObject({ + detail: { code: 'upstream_invalid_response' }, + }); + } finally { + manager.dispose(); + await server.close(); + } + }); +});