From 8a19909716c1f3f109d776c663eacab5866079ab Mon Sep 17 00:00:00 2001 From: Codex Bot Date: Sat, 7 Nov 2026 22:53:00 +0800 Subject: [PATCH] Connect durable canvas runtime to canonical Foundry protocol --- package.json | 3 +- src/app/api/foundry/[...path]/route.ts | 14 +- src/lib/canvas-core/document.ts | 13 +- src/lib/canvas-core/editor.ts | 13 ++ src/lib/canvas-core/values.ts | 2 +- src/lib/canvas-runtime/engine.ts | 2 +- src/lib/canvas-runtime/ledger.ts | 7 +- src/lib/canvas-runtime/scheduler.ts | 6 +- src/lib/canvas-service/foundry.ts | 146 ++++++++++++++++++ src/lib/canvas-service/index.ts | 1 + tests/headless/browser-storage-cases.mjs | 29 ++++ tests/headless/core.test.mjs | 15 ++ .../fixtures/foundry-model-operation-v2.json | 26 ++++ tests/headless/proxy.integration.mjs | 51 ++++++ tests/headless/scheduler.test.mjs | 7 + tests/headless/service.test.mjs | 90 +++++++++++ 16 files changed, 406 insertions(+), 19 deletions(-) create mode 100644 src/lib/canvas-service/foundry.ts create mode 100644 src/lib/canvas-service/index.ts create mode 100644 tests/headless/fixtures/foundry-model-operation-v2.json create mode 100644 tests/headless/proxy.integration.mjs create mode 100644 tests/headless/service.test.mjs diff --git a/package.json b/package.json index 26349d1..d61efcc 100644 --- a/package.json +++ b/package.json @@ -8,7 +8,8 @@ "start": "next start --hostname localhost --port 17321", "typecheck": "tsc --noEmit", "test:headless": "node --test tests/headless/*.test.mjs", - "test:headless:browser": "node tests/headless/browser-storage.mjs" + "test:headless:browser": "node tests/headless/browser-storage.mjs", + "test:headless:proxy": "node tests/headless/proxy.integration.mjs" }, "dependencies": { "@radix-ui/react-dialog": "1.3.1", diff --git a/src/app/api/foundry/[...path]/route.ts b/src/app/api/foundry/[...path]/route.ts index 414a495..6ac0e45 100644 --- a/src/app/api/foundry/[...path]/route.ts +++ b/src/app/api/foundry/[...path]/route.ts @@ -4,7 +4,7 @@ import { NextRequest, NextResponse } from "next/server"; const allowed = /^(profile|auth\/(login|register|verify-email|exchange|me|logout)|workspaces(?:\/[a-zA-Z0-9_-]+(?:\/(?:materials(?:\/[a-zA-Z0-9_-]+(?:\/canvas)?)?|search))?)?)$/; -const canvasRoute = /^workspaces\/[a-zA-Z0-9_-]+\/materials\/[a-zA-Z0-9_-]+\/canvas(?:\/(?:connections|capabilities|subgraphs(?:\/(?:migrate|[a-zA-Z0-9_-]+\/(?:members|dissolve)))?|transactions\/[a-zA-Z0-9_-]+\/undo|ai\/(?:models|runs(?:\/[a-zA-Z0-9_-]+(?:\/(?:cancel|apply))?)?)))?$/; +const canvasRoute = /^workspaces\/[a-zA-Z0-9_-]+\/materials\/[a-zA-Z0-9_-]+\/canvas(?:\/(?:model(?:\/(?:migrate|operations(?:\/[a-zA-Z0-9_-]+)?))?|operations(?:\/[a-zA-Z0-9_-]+)?|connections|capabilities|subgraphs(?:\/(?:migrate|[a-zA-Z0-9_-]+\/(?:members|dissolve)))?|transactions\/[a-zA-Z0-9_-]+\/undo|ai\/(?:models|runs(?:\/[a-zA-Z0-9_-]+(?:\/(?:cancel|apply))?)?)))?$/; const assetRoute = /^workspaces\/[a-zA-Z0-9_-]+\/assets(?:\/[a-zA-Z0-9_-]+)?$/; const bodyLimit = 1024 * 1024; @@ -55,10 +55,11 @@ async function handler( if (!allowed.test(path) && !canvasRoute.test(path) && !assetRoute.test(path)) return json({ error: "not_found" }, 404); const asset = assetRoute.test(path); const canvas = canvasRoute.test(path); + const canonical = canvas && /\/canvas\/model(?:\/|$)/.test(path); let requestBody: unknown; if (req.method !== "GET") { try { - requestBody = await readBody(req, asset ? 21 * 1024 * 1024 : path.endsWith("/canvas") ? 5 * 1024 * 1024 : bodyLimit, asset); + requestBody = await readBody(req, asset ? 21 * 1024 * 1024 : canonical || path.endsWith("/canvas") || path.endsWith("/canvas/operations") ? 5 * 1024 * 1024 : bodyLimit, asset || canonical); } catch (error) { const tooLarge = error instanceof Error && error.message === "body_too_large"; return json({ error: tooLarge ? "body_too_large" : "invalid_json" }, tooLarge ? 413 : 400); @@ -71,7 +72,7 @@ async function handler( if (canvas) headers.Accept = "application/vnd.foundry.canvas+json;version=2"; const session = req.cookies.get("foundry_session")?.value; if (session) headers.Authorization = "Bearer " + session; - for (const h of ["x-foundry-code", "x-foundry-verifier", "if-match", "idempotency-key"]) { + for (const h of ["x-foundry-code", "x-foundry-verifier", "if-match", "idempotency-key", "x-expected-account"]) { const value = req.headers.get(h); if (value !== null) headers[h] = value; } @@ -81,11 +82,12 @@ async function handler( if (values.length === 1 && values[0].length < 300) query.set(key, values[0]); } const encodingStarted = performance.now(); - const outboundBody = req.method === "GET" ? undefined : asset ? requestBody as BodyInit : JSON.stringify(requestBody || {}); + // Frozen canonical requests cross the proxy byte-for-byte; do not rebuild their JSON. + const outboundBody = req.method === "GET" ? undefined : asset || canonical ? requestBody as BodyInit : JSON.stringify(requestBody || {}); const encodeMs = performance.now() - encodingStarted; const started = performance.now(); try { - const snapshotRead = canvas && path.endsWith("/canvas") && req.method === "GET"; + const snapshotRead = canvas && (path.endsWith("/canvas") || canonical) && req.method === "GET"; const streamedRead = snapshotRead || asset && req.method === "GET"; const snapshotWrite = canvas && path.endsWith("/canvas") && req.method === "PATCH"; const traceId = streamedRead || snapshotWrite ? crypto.randomUUID() : ""; @@ -128,7 +130,7 @@ async function handler( const headersReceived = performance.now(); // Snapshots contain no login exchange payload. Forward them without buffering // and parsing/re-encoding multi-megabyte inline image JSON in the proxy. - if (canvas && path.endsWith("/canvas") && req.method === "GET" && upstream.ok) { + if (canonical || canvas && path.endsWith("/canvas") && req.method === "GET" && upstream.ok) { responseHeaders.set("Content-Type", "application/json; charset=utf-8"); responseHeaders.set("Server-Timing", `upstream;dur=${(headersReceived - started).toFixed(1)}, proxy_headers;dur=${(performance.now() - headersReceived).toFixed(1)}`); return new NextResponse(upstream.body, { status: upstream.status, headers: responseHeaders }); diff --git a/src/lib/canvas-core/document.ts b/src/lib/canvas-core/document.ts index 35d1a6a..98f3a4f 100644 --- a/src/lib/canvas-core/document.ts +++ b/src/lib/canvas-core/document.ts @@ -2,12 +2,13 @@ import type { CanvasRecord, Change, Document, ElementRecord, Geometry, Id, Regis import { clone, equal, fault, freeze, readMap, validJson } from './values.ts'; export function createDocument(projectId: Id, canvases: readonly CanvasRecord[], elements: readonly ElementRecord[] = [], tombstones: ReadonlyMap = new Map()): Document { - for (const element of elements) if (!validJson(element.data)) throw fault('invalid_element', [element.id]); + for (const element of elements) { const id = element.id; if (!validJson(element)) throw fault('invalid_element', [id]); } + for (const canvas of canvases) if (!validJson(canvas)) throw fault('invalid_canvas'); if (!projectId || new Set(canvases.map(item => item.id)).size !== canvases.length || new Set(elements.map(item => item.id)).size !== elements.length) throw fault('duplicate_identity'); return Object.freeze({ projectId, canvases: readMap(new Map(canvases.map(item => [item.id, freeze(clone(item))]))), elements: readMap(new Map(elements.map(item => [item.id, freeze(clone(item))]))), tombstones: readMap(tombstones) }); } export function geometryValid(value: Geometry): boolean { - return [value.x, value.y, value.width, value.height, value.rotation].every(Number.isFinite) && value.width > 0 && value.height > 0 && value.rotation >= 0 && value.rotation < 360; + return !!value && typeof value === 'object' && [value.x, value.y, value.width, value.height, value.rotation].every(Number.isFinite) && value.width > 0 && value.height > 0 && value.rotation >= 0 && value.rotation < 360; } export function targetElement(document: Document, target: Target): ElementRecord { const element = document.elements.get(target.id); @@ -39,7 +40,7 @@ export function validateDocument(document: Document, registry: Registry): void { const entries = new Set(); let roots = 0; for (const canvas of document.canvases.values()) { - if (!canvas.id) throw fault('invalid_canvas'); + if (typeof canvas.id !== 'string' || !canvas.id || !Object.hasOwn(canvas,'parent') || canvas.parent !== null && (!canvas.parent || typeof canvas.parent.canvasId !== 'string' || typeof canvas.parent.entryElementId !== 'string')) throw fault('invalid_canvas'); ancestors(document, canvas.id); if (!canvas.parent) { roots++; continue; } const entry = document.elements.get(canvas.parent.entryElementId); @@ -48,7 +49,7 @@ export function validateDocument(document: Document, registry: Registry): void { } if (roots !== 1) throw fault('invalid_roots'); for (const element of document.elements.values()) { - if (!element.id || !document.canvases.has(element.ownerCanvasId) || !Number.isSafeInteger(element.incarnation) || element.incarnation < 1 || !Number.isSafeInteger(element.dataVersion) || element.dataVersion < 1 || !validJson(element.data)) throw fault('invalid_element', [element.id]); + if (typeof element.id !== 'string' || !element.id || typeof element.componentId !== 'string' || !element.componentId || !['node','connection'].includes(element.structure) || !document.canvases.has(element.ownerCanvasId) || !Number.isSafeInteger(element.incarnation) || element.incarnation < 1 || !Number.isSafeInteger(element.dataVersion) || element.dataVersion < 1 || !element.data || typeof element.data !== 'object' || Array.isArray(element.data) || !validJson(element.data)) throw fault('invalid_element', [element.id]); const dead = document.tombstones.get(element.id); if (dead !== undefined && element.incarnation <= dead) throw fault('stale_incarnation', [element.id]); const definition = registry.components.get(element.componentId); @@ -56,6 +57,7 @@ export function validateDocument(document: Document, registry: Registry): void { if (element.structure === 'node') { if (!geometryValid(element.geometry)) throw fault('invalid_geometry', [element.id]); } else { + if (!element.source || !element.target || typeof element.source.elementId !== 'string' || typeof element.target.elementId !== 'string' || [element.source,element.target].some(endpoint => endpoint.portId !== undefined && (typeof endpoint.portId !== 'string' || !endpoint.portId))) throw fault('invalid_endpoint', [element.id]); const source = document.elements.get(element.source.elementId), target = document.elements.get(element.target.elementId); if (!source || !target || source.structure !== 'node' || target.structure !== 'node' || !validJson(element.path)) throw fault('invalid_endpoint', [element.id]); if (commonCanvas(document, source.ownerCanvasId, target.ownerCanvasId) !== element.ownerCanvasId) throw fault('invalid_connection_owner', [element.id]); @@ -66,6 +68,7 @@ export function applyChanges(document: Document, changes: readonly Change[], con const elements = new Map(document.elements), canvases = new Map(document.canvases), tombstones = new Map(document.tombstones); for (const change of changes) { if (change.kind === 'field') { + if (!validJson(change.after)) throw fault('invalid_change'); if (!change.field.startsWith('data.') && !['geometry', 'ownerCanvasId', 'source', 'target', 'path'].includes(change.field)) throw fault('invalid_field'); const element = elements.get(change.target.id); if (!element || element.incarnation !== change.target.incarnation) throw fault('stale_incarnation', [change.target.id]); @@ -81,6 +84,7 @@ export function applyChanges(document: Document, changes: readonly Change[], con } else if (change.kind === 'element') { if (conditional && !equal(elements.get(change.id) || null, change.before)) throw fault('element_conflict', [change.id]); if (change.after) { + if (!validJson(change.after)) throw fault('invalid_element', [change.id]); if (change.after.id !== change.id) throw fault('identity_mismatch', [change.id]); elements.set(change.id, freeze(clone(change.after))); } else { @@ -91,6 +95,7 @@ export function applyChanges(document: Document, changes: readonly Change[], con } else if (change.kind === 'canvas') { if (conditional && !equal(canvases.get(change.id) || null, change.before)) throw fault('canvas_conflict', [change.id]); if (change.after) { + if (!validJson(change.after)) throw fault('invalid_canvas', [change.id]); if (change.after.id !== change.id) throw fault('identity_mismatch', [change.id]); canvases.set(change.id, freeze(clone(change.after))); } else canvases.delete(change.id); diff --git a/src/lib/canvas-core/editor.ts b/src/lib/canvas-core/editor.ts index c4a28e4..8d7e034 100644 --- a/src/lib/canvas-core/editor.ts +++ b/src/lib/canvas-core/editor.ts @@ -123,11 +123,24 @@ export function createEditor(options: EditorOptions validJson(item, depth + 1)); - return !!value && typeof value === 'object' && Object.getPrototypeOf(value) === Object.prototype && Object.entries(value).every(([key, child]) => !['__proto__', 'prototype', 'constructor'].includes(key) && validJson(child, depth + 1)); + return !!value && typeof value === 'object' && Object.getPrototypeOf(value) === Object.prototype && Object.values(value).every(child => validJson(child, depth + 1)); } diff --git a/src/lib/canvas-runtime/engine.ts b/src/lib/canvas-runtime/engine.ts index 8c2e5db..1257e2f 100644 --- a/src/lib/canvas-runtime/engine.ts +++ b/src/lib/canvas-runtime/engine.ts @@ -41,7 +41,7 @@ export async function openProjectEngine(options: EngineOptions) { const last = new Map(); for (const record of [...previous.ledger.operations.values()].sort((a, b) => a.intent.sequence - b.intent.sequence)) if (record.phase !== 'resolved') for (const canvas of record.intent.canvasIds) last.set(canvas, record.intent.id); const dependencies = [...new Set([...intent.dependencies, ...intent.canvasIds.flatMap(canvas => last.has(canvas) ? [last.get(canvas)!] : [])])]; - const basePresence: Record = {}; + const basePresence: Record = Object.create(null); for (const change of intent.changes) if (change.kind === 'element') basePresence[change.id] = change.before ? 'exists' : 'absent'; else if (change.kind === 'field') basePresence[change.target.id] ??= 'exists'; ledger.add(freeze({ ...intent, dependencies }), basePresence); } diff --git a/src/lib/canvas-runtime/ledger.ts b/src/lib/canvas-runtime/ledger.ts index 4b0a07c..7eb6d37 100644 --- a/src/lib/canvas-runtime/ledger.ts +++ b/src/lib/canvas-runtime/ledger.ts @@ -26,6 +26,7 @@ export function compressChanges(changes: readonly Change[]): readonly Change[] { if (change.target.incarnation !== after.incarnation) throw fault('stale_incarnation', [id]); if (change.field.startsWith('data.')) { const key = change.field.slice(5), data = { ...after.data }; + if (!key || ['__proto__','prototype','constructor'].includes(key)) throw fault('invalid_field'); if (change.afterPresent === false) delete data[key]; else data[key] = clone(change.after); result[previousIndex] = freeze({ ...previous, after: { ...after, data } }); } else result[previousIndex] = freeze({ ...previous, after: { ...after, [change.field]: clone(change.after) } }) as Change; @@ -71,13 +72,13 @@ export function createLedger(initial?: LedgerSnapshot) { return !!operation && operation.phase === 'pending' && !operation.blockers.length && operation.intent.dependencies.every(dependency => operations.get(dependency)?.phase === 'resolved'); }, block(id: Id, reasons: readonly string[]) { const operation = operations.get(id); if (!operation || !['pending', 'blocked'].includes(operation.phase)) return; operations.set(id, freeze({ ...operation, phase: reasons.length ? 'blocked' : 'pending', blockers: [...reasons] })); changed(); }, - freeze(input: { id: Id; operationIds: readonly Id[]; canvasIds: readonly Id[]; expectedVersions: Readonly>; payload: Json }) { - if (!input.id || requests.has(input.id) || !input.operationIds.length || !input.canvasIds.length || !validJson(input.payload) || new Set(input.operationIds).size !== input.operationIds.length || new Set(input.canvasIds).size !== input.canvasIds.length || Object.keys(input.expectedVersions).length !== input.canvasIds.length || input.canvasIds.some(id => !id || !Number.isSafeInteger(input.expectedVersions[id]) || input.expectedVersions[id] < 1)) throw fault('invalid_frozen_request'); + freeze(input: { id: Id; protocolVersion?: number; operationIds: readonly Id[]; canvasIds: readonly Id[]; expectedVersions: Readonly>; payload: Json }) { + if (!input.id || requests.has(input.id) || !Number.isSafeInteger(input.protocolVersion ?? 1) || (input.protocolVersion ?? 1) < 1 || !input.operationIds.length || !input.canvasIds.length || !validJson(input.payload) || new Set(input.operationIds).size !== input.operationIds.length || new Set(input.canvasIds).size !== input.canvasIds.length || Object.keys(input.expectedVersions).length !== input.canvasIds.length || input.canvasIds.some(id => !id || !Number.isSafeInteger(input.expectedVersions[id]) || input.expectedVersions[id] < 0)) throw fault('invalid_frozen_request'); for (const id of input.operationIds) { const operation = operations.get(id); if (!operation || operation.phase !== 'pending' || operation.blockers.length || operation.intent.dependencies.some(dependency => operations.get(dependency)?.phase !== 'resolved' && !input.operationIds.includes(dependency))) throw fault('operation_not_ready', [id]); } - const request: FrozenRequest = freeze({ id: input.id, protocolVersion: 1, generation: generation + 1, body: JSON.stringify(input.payload), operationIds: [...input.operationIds], canvasIds: [...input.canvasIds].sort(), expectedVersions: { ...input.expectedVersions }, phase: 'frozen', outcome: 'unresolved', attempt: 0 }); + const request: FrozenRequest = freeze({ id: input.id, protocolVersion: input.protocolVersion ?? 1, generation: generation + 1, body: JSON.stringify(input.payload), operationIds: [...input.operationIds], canvasIds: [...input.canvasIds].sort(), expectedVersions: { ...input.expectedVersions }, phase: 'frozen', outcome: 'unresolved', attempt: 0 }); requests.set(request.id, request); for (const id of input.operationIds) operations.set(id, freeze({ ...operations.get(id)!, phase: 'frozen', requestId: request.id })); changed(); return request; diff --git a/src/lib/canvas-runtime/scheduler.ts b/src/lib/canvas-runtime/scheduler.ts index b784f82..87f1d71 100644 --- a/src/lib/canvas-runtime/scheduler.ts +++ b/src/lib/canvas-runtime/scheduler.ts @@ -3,8 +3,8 @@ import { fault, readMap } from '../canvas-core/values.ts'; import { createLedger, type FrozenRequest, type LedgerSnapshot, type Outcome } from './ledger.ts'; import { createDeque, createMinHeap } from './structures.ts'; -export type OperationStatus = { readonly outcome: Outcome; readonly error?: string; readonly retryAfterMs?: number; readonly canonical?: Json }; -export type CompiledRequest = { readonly payload: Json; readonly canvasIds: readonly Id[]; readonly expectedVersions: Readonly> }; +export type OperationStatus = { readonly outcome: Outcome; readonly error?: string; readonly retryAfterMs?: number; readonly canonical?: Json; readonly blocked?: boolean }; +export type CompiledRequest = { readonly protocolVersion?: number; readonly payload: Json; readonly canvasIds: readonly Id[]; readonly expectedVersions: Readonly> }; export type Clock = { now(): number; setTimer(callback: () => void, delay: number): unknown; clearTimer(handle: unknown): void }; const browserClock: Clock = { now: () => Date.now(), setTimer: (fn, delay) => setTimeout(fn, delay), clearTimer: handle => clearTimeout(handle as ReturnType) }; type Ledger = ReturnType; @@ -104,7 +104,7 @@ export function createScheduler(options: SchedulerOptions) { const completion = options.complete(request, status); const current = options.getLedger().requests.get(id)!; if (current.phase === 'resolved') { tokens.delete(id); await options.persist(); if (!stopped) release(current); } - else if (completion?.blocked) { await options.persist(); } + else if (completion?.blocked || status.blocked) { await options.persist(); } else { // A missing receipt is unknown, so the only write retry reuses this frozen request. const nextAction = status.outcome === 'accepted' || action === 'submit' ? 'query' : 'submit'; diff --git a/src/lib/canvas-service/foundry.ts b/src/lib/canvas-service/foundry.ts new file mode 100644 index 0000000..64e24f2 --- /dev/null +++ b/src/lib/canvas-service/foundry.ts @@ -0,0 +1,146 @@ +import type { CanvasRecord, Document, ElementRecord, Id, Json, OperationIntent, Registry } from '../canvas-core/types.ts'; +import { createDocument, validateDocument } from '../canvas-core/document.ts'; +import { equal, fault, freeze, readMap, validJson } from '../canvas-core/values.ts'; +import type { EngineSnapshot, PersistencePort } from '../canvas-runtime/contracts.ts'; +import type { FrozenRequest } from '../canvas-runtime/ledger.ts'; +import type { OperationStatus } from '../canvas-runtime/scheduler.ts'; + +export type ServiceRequest = { readonly path: string; readonly method: 'GET' | 'POST'; readonly requestId?: Id; readonly body?: string; readonly signal?: AbortSignal }; +export type ServiceResponse = { readonly status: number; readonly body: unknown; readonly retryAfterMs?: number }; +export type ServiceTransport = (request: ServiceRequest) => Promise; +const uuid = /^[a-f\d]{8}-[a-f\d]{4}-[a-f\d]{4}-[a-f\d]{4}-[a-f\d]{12}$/i; +const record = (value: unknown): Record => { if (!value || Array.isArray(value) || typeof value !== 'object') throw fault('invalid_canonical_response'); return value as Record; }; +const list = (value: unknown): unknown[] => { if (!Array.isArray(value)) throw fault('invalid_canonical_response'); return value; }; +const id = (value: unknown): string => { if (typeof value !== 'string' || !value) throw fault('invalid_canonical_response'); return value; }; +function versions(value: unknown, minimum = 1): ReadonlyMap { + const result = new Map(); + for (const [key, version] of Object.entries(record(value))) { if (!uuid.test(key) || typeof version !== 'number' || !Number.isSafeInteger(version) || version < minimum) throw fault('invalid_canonical_version'); result.set(key, version); } + return readMap(result); +} +const pathFor = (workspace: Id, canvas: Id) => `workspaces/${encodeURIComponent(workspace)}/materials/${encodeURIComponent(canvas)}/canvas/model`; + +/** Credentials stay in the host/session. Every write additionally binds the expected account. */ +export function createFoundryTransport(options: { accountId: Id; isCurrentIdentity(): boolean; fetch?: typeof fetch; basePath?: string }): ServiceTransport { + if (!uuid.test(options.accountId)) throw fault('invalid_account_identity'); + const requestFetch = options.fetch ?? globalThis.fetch; + return async input => { + if (!options.isCurrentIdentity()) throw fault('identity_changed'); + const response = await requestFetch(`${options.basePath ?? '/api/foundry/'}${input.path}`, { method: input.method, body: input.body, signal: input.signal, credentials: 'same-origin', cache: 'no-store', redirect: 'error', headers: { + Accept: 'application/json', 'Content-Type': 'application/json', 'X-Expected-Account': options.accountId, ...(input.requestId ? { 'Idempotency-Key': input.requestId } : {}), + } }); + if (!options.isCurrentIdentity()) { await response.body?.cancel(); throw fault('identity_changed'); } + let body: unknown; + try { body = await response.json(); } catch { throw fault('invalid_canonical_response'); } + if (!options.isCurrentIdentity()) throw fault('identity_changed'); + const retry = response.headers.get('retry-after'), seconds = retry === null ? NaN : Number(retry); + return { status: response.status, body, ...(Number.isFinite(seconds) && seconds >= 0 ? { retryAfterMs: seconds * 1000 } : {}) }; + }; +} + +type ModelChunk = { projectId: Id; complete: boolean; canvases: readonly CanvasRecord[]; elements: readonly ElementRecord[]; tombstones: ReadonlyMap; versions: ReadonlyMap; removed: readonly Id[] }; +function modelChunk(value: unknown): ModelChunk { + if (!validJson(value)) throw fault('invalid_canonical_response'); + const raw = record(JSON.parse(JSON.stringify(value))); + if (raw.documentFormatVersion !== 1) throw fault('unsupported_document_format'); + const projectId = id(raw.projectId); + if (!uuid.test(projectId) || typeof raw.complete !== 'boolean') throw fault('invalid_canonical_response'); + const canvases = list(raw.canvases) as CanvasRecord[], elements = list(raw.elements) as ElementRecord[], removed = list(raw.removedCanvasIds).map(id), serverVersions = versions(raw.versions), tombstones = new Map(); + const canvasIds = new Set(), elementIds = new Set(); + for (const canvas of canvases) { + record(canvas); + if (!uuid.test(canvas.id) || canvasIds.has(canvas.id) || !serverVersions.has(canvas.id)) throw fault('invalid_canonical_response'); + canvasIds.add(canvas.id); + } + for (const element of elements) { + record(element); + if (!element.id || elementIds.has(element.id) || !canvasIds.has(element.ownerCanvasId)) throw fault('invalid_canonical_response'); + elementIds.add(element.id); + } + for (const [key, value] of Object.entries(record(raw.tombstones))) { if (typeof value !== 'number' || !Number.isSafeInteger(value) || value < 1) throw fault('invalid_canonical_response'); tombstones.set(key, value); } + if (new Set(removed).size !== removed.length || removed.some(value => canvasIds.has(value) || !serverVersions.has(value)) || serverVersions.size !== canvasIds.size + removed.length) throw fault('invalid_canonical_response'); + return freeze({ projectId, complete: raw.complete, canvases, elements, tombstones: readMap(tombstones), versions: serverVersions, removed }); +} +export type ModelLoad = { readonly status: 'ready'; readonly document: Document; readonly versions: ReadonlyMap } | { readonly status: 'migration-required'; readonly rootId: Id; readonly sourceVersions: ReadonlyMap }; +export async function loadFoundryModel(transport: ServiceTransport, workspaceId: Id, canvasId: Id, registry: Registry, signal?: AbortSignal): Promise { + const response = await transport({ method: 'GET', path: pathFor(workspaceId, canvasId), signal }), body = record(response.body); + if (response.status === 409 && body.error === 'canonical_migration_required') { + if (body.document_format_version !== 1) throw fault('unsupported_document_format'); + const rootId = id(body.root_id); if (!uuid.test(rootId)) throw fault('invalid_canonical_response'); + return freeze({ status: 'migration-required', rootId, sourceVersions: versions(body.source_versions) }); + } + if (response.status !== 200) throw fault(typeof body.error === 'string' ? body.error : 'model_load_failed'); + const chunk = modelChunk(body.model); + if (!chunk.complete) throw fault('incomplete_initial_model'); + const document = createDocument(chunk.projectId, chunk.canvases, chunk.elements, chunk.tombstones); validateDocument(document, registry); + return Object.freeze({ status: 'ready', document, versions: chunk.versions }); +} + +function statusFor(request: FrozenRequest, response: ServiceResponse, query: boolean): OperationStatus { + const body = record(response.body), error = typeof body.error === 'string' ? body.error : undefined; + const matching = body.protocol_version === 2 && body.request_id === request.id; + if (matching && body.status === 'applied' && response.status >= 200 && response.status < 300) { + const covered = list(body.operation_ids).map(id); + if (covered.length !== request.operationIds.length || new Set(covered).size !== covered.length || covered.some(id => !request.operationIds.includes(id))) throw fault('receipt_coverage_mismatch'); + if (!validJson(body)) throw fault('invalid_canonical_response'); + return freeze({ outcome: 'applied', canonical: JSON.parse(JSON.stringify(body)) as Json }); + } + if (matching && body.status === 'accepted' && response.status >= 200 && response.status < 300) return { outcome: 'accepted', retryAfterMs: response.retryAfterMs }; + if (matching && body.status === 'not-applied' && body.terminal === true) return { outcome: 'not-applied', error: error ?? 'not_applied' }; + if (query && response.status === 404 && body.status === 'unknown' && body.request_id === request.id) return { outcome: 'unresolved', error: 'operation_receipt_unknown', retryAfterMs: response.retryAfterMs }; + if (response.status >= 400 && response.status < 500) return { outcome: 'unresolved', blocked: true, error: error ?? 'protocol_rejected' }; + return { outcome: 'unresolved', error: error ?? 'transport_unknown', retryAfterMs: response.retryAfterMs }; +} +function validateFrozen(request: FrozenRequest) { + let body: Record; + try { body = record(JSON.parse(request.body)); } catch { throw fault('invalid_frozen_request'); } + const operations = list(body.operations).map(value => id(record(value).id)); + if (request.protocolVersion !== 2 || body.protocol_version !== 2 || body.request_id !== request.id || !equal(body.expected_versions, request.expectedVersions) || !equal(Object.keys(request.expectedVersions).sort(), [...request.canvasIds].sort()) || !equal([...operations].sort(), [...request.operationIds].sort())) throw fault('frozen_request_contract_mismatch'); +} + +/** Operations/conditional fields are sent; full snapshots are only reads and ACK participant views. */ +export function createFoundryPersistence(options: { workspaceId: Id; rootId: Id; registry: Registry; transport: ServiceTransport }): PersistencePort { + if (!uuid.test(options.workspaceId) || !uuid.test(options.rootId)) throw fault('invalid_service_scope'); + const path = pathFor(options.workspaceId, options.rootId); + async function execute(request: FrozenRequest, query: boolean, signal: AbortSignal): Promise { + try { + validateFrozen(request); + const response = await options.transport({ method: query ? 'GET' : 'POST', path: `${path}/operations${query ? `/${encodeURIComponent(request.id)}` : ''}`, requestId: query ? undefined : request.id, body: query ? undefined : request.body, signal }); + return statusFor(request, response, query); + } catch (error) { + const code = (error as { code?: string }).code; + return { outcome: 'unresolved', error: code ?? 'transport_unknown', ...(code && code !== 'invalid_canonical_response' ? { blocked: true } : {}) }; + } + } + return Object.freeze({ + supportsRequest: (request: FrozenRequest) => request.protocolVersion === 2, + compile(intent: OperationIntent, requestId: Id, snapshot: EngineSnapshot) { + if (snapshot.document.projectId !== options.rootId || !uuid.test(intent.id) || !uuid.test(requestId) || intent.operationType !== 'document.change' || intent.operationSchemaVersion !== 1) throw fault('operation_interpreter_unavailable'); + const canvasIds = [...new Set(intent.canvasIds)].sort(), expectedVersions: Record = {}; + for (const canvas of canvasIds) { + const version = snapshot.runtime.versions.get(canvas); + if (version === undefined && !intent.changes.some(change => change.kind === 'canvas' && change.id === canvas && change.before === null && change.after)) throw fault('missing_server_version', [canvas]); + expectedVersions[canvas] = version ?? 0; + } + const payload = JSON.parse(JSON.stringify({ protocol_version: 2, request_id: requestId, expected_versions: expectedVersions, operations: [{ id: intent.id, changes: intent.changes }] })) as Json; + if (!validJson(payload)) throw fault('invalid_operation_payload'); + return freeze({ protocolVersion: 2, canvasIds, expectedVersions, payload }); + }, + submit: (request: FrozenRequest, signal: AbortSignal) => execute(request, false, signal), + query: (request: FrozenRequest, signal: AbortSignal) => execute(request, true, signal), + canonical(request: FrozenRequest, status: OperationStatus, snapshot: EngineSnapshot) { + const body = record(status.canonical); + if (body.request_id !== request.id || body.protocol_version !== 2) throw fault('receipt_identity_mismatch'); + const chunk = modelChunk(body.model); + if (chunk.projectId !== options.rootId || chunk.complete || chunk.versions.size !== request.canvasIds.length || request.canvasIds.some(id => !chunk.versions.has(id))) throw fault('receipt_coverage_mismatch'); + const previous = snapshot.runtime.acknowledged, canvases = new Map(previous.canvases), elements = new Map(previous.elements), tombstones = new Map(previous.tombstones), serverVersions = new Map(snapshot.runtime.versions); + for (const canvas of request.canvasIds) canvases.delete(canvas); + for (const canvas of chunk.canvases) canvases.set(canvas.id, canvas); + for (const [id, element] of elements) if (request.canvasIds.includes(element.ownerCanvasId)) elements.delete(id); + for (const element of chunk.elements) { if (elements.has(element.id)) throw fault('canonical_scope_mismatch', [element.id]); elements.set(element.id, element); } + for (const [id, generation] of chunk.tombstones) tombstones.set(id, Math.max(tombstones.get(id) ?? 0, generation)); + for (const [id, version] of chunk.versions) serverVersions.set(id, version); + const document = createDocument(previous.projectId, [...canvases.values()], [...elements.values()], tombstones); validateDocument(document, options.registry); + return Object.freeze({ document, versions: readMap(serverVersions) }); + }, + }); +} diff --git a/src/lib/canvas-service/index.ts b/src/lib/canvas-service/index.ts new file mode 100644 index 0000000..f7e3c6c --- /dev/null +++ b/src/lib/canvas-service/index.ts @@ -0,0 +1 @@ +export * from './foundry.ts'; diff --git a/tests/headless/browser-storage-cases.mjs b/tests/headless/browser-storage-cases.mjs index 5113b11..5f01e6a 100644 --- a/tests/headless/browser-storage-cases.mjs +++ b/tests/headless/browser-storage-cases.mjs @@ -1,6 +1,33 @@ import { createBrowserStorage } from '../../src/lib/canvas-runtime/storage.ts'; import { openProjectEngine } from '../../src/lib/canvas-runtime/engine.ts'; import { createDocument, createRegistry, builtinComponents, applyChanges } from '../../src/lib/canvas-core/index.ts'; +import { createFoundryPersistence } from '../../src/lib/canvas-service/foundry.ts'; + +async function protocolRecoveryCase(name,assert){ + const root='10000000-0000-4000-8000-000000000001',workspace='10000000-0000-4000-8000-000000000003',account='10000000-0000-4000-8000-000000000004'; + const registry=createRegistry(builtinComponents()),document=createDocument(root,[{id:root,parent:null}],[{id:'a',incarnation:1,ownerCanvasId:root,componentId:'text',dataVersion:1,structure:'node',geometry:{x:0,y:0,width:260,height:200,rotation:0},data:{title:'a',text:''}}]); + const calls=[],receipts=new Map();let server=document; + const transport=async input=>{ + calls.push(input); + if(input.method==='POST'){ + const batch=JSON.parse(input.body);server=applyChanges(server,batch.operations[0].changes); + receipts.set(batch.request_id,{protocol_version:2,request_id:batch.request_id,status:'applied',operation_ids:batch.operations.map(operation=>operation.id),model:{documentFormatVersion:1,projectId:root,canvases:[...server.canvases.values()],elements:[...server.elements.values()],tombstones:{},versions:{[root]:2},complete:false,removedCanvasIds:[]}}); + return{status:502,body:{error:'upstream_timeout'}}; + } + return{status:200,body:receipts.get(input.path.split('/').at(-1))}; + }; + const storage=createBrowserStorage({name:`${name}-protocol`}),persistence=createFoundryPersistence({workspaceId:workspace,rootId:root,registry,transport}); + const options={scope:{accountId:account,workspaceId:workspace,projectId:root},registry,document,versions:new Map([[root,1]]),storage,persistence};let engine; + try{ + engine=await openProjectEngine(options);engine.editor.dispatch({type:'element.data',target:{id:'a',incarnation:1},patch:{title:'durable-v2'}});await engine.settle(); + const request=[...engine.getSnapshot().runtime.ledger.requests.values()][0];assert(request.protocolVersion===2,'new protocol metadata was replaced by a legacy default');await engine.close(); + engine=await openProjectEngine({...options,clock:{now:()=>Date.now()+100000,setTimer:(callback,delay)=>setTimeout(callback,delay),clearTimer:handle=>clearTimeout(handle)}}); + await new Promise(resolve=>setTimeout(resolve,10));await engine.settle(); + assert(calls.length===2&&calls[1].method==='GET','protocol 2 recovery must query the original request'); + assert(engine.getSnapshot().document.elements.get('a').data.title==='durable-v2'&&engine.getSnapshot().history.undo.length===1,'protocol recovery changed working content or duplicated history'); + const recovered=engine.getSnapshot().runtime.ledger.requests.get(request.id);assert(recovered.body===request.body&&recovered.outcome==='applied','protocol recovery rewrote request bytes or lost the receipt'); + }finally{await engine?.close();storage.close();indexedDB.deleteDatabase(`${name}-protocol`)} +} async function engineRecoveryCase(name, assert) { const scope = { accountId: 'account', workspaceId: 'workspace', projectId: 'project' }, registry = createRegistry(builtinComponents()); @@ -96,6 +123,8 @@ export async function run() { results.push('journal gap is reported as corruption and never converted to an empty document'); await engineRecoveryCase(name, assert); results.push('project engine reopens real IndexedDB with one history unit and the original request; ACK resolves without duplicate edit'); + await protocolRecoveryCase(name,assert); + results.push('protocol 2 service adapter recovers exact frozen bytes and reconciles a lost ACK through real IndexedDB'); return results; } finally { await leader?.release(); other?.close(); storage.close(); indexedDB.deleteDatabase(name); } } diff --git a/tests/headless/core.test.mjs b/tests/headless/core.test.mjs index 9361b45..4cdd0b5 100644 --- a/tests/headless/core.test.mjs +++ b/tests/headless/core.test.mjs @@ -48,6 +48,21 @@ test('can is pure and command handlers come from the component capability table' assert.equal(ids, 0); assert.strictEqual(editor.getSnapshot(), before); }); +test('connection creation guards endpoint canvases and non-JSON envelopes reject without silent truncation',()=>{ + const document=createDocument('p',[{id:'root',parent:null},{id:'child',parent:{canvasId:'root',entryElementId:'f'}}],[node('a'),node('f','root','frame'),node('b','child')]); + const editor=createEditor({registry:createRegistry(builtinComponents()),document}); + const edge={id:'e',incarnation:1,ownerCanvasId:'root',componentId:'connection',dataVersion:1,structure:'connection',data:{},source:{elementId:'a'},target:{elementId:'b'},path:null}; + const result=editor.dispatch({type:'element.create',element:edge});assert.equal(result.status,'applied');assert.deepEqual(editor.getSnapshot().operations.get(result.operationIds[0]).canvasIds,['child','root']); + const before=editor.getSnapshot();assert.equal(editor.dispatch({type:'element.create',element:{...node('invalid'),unserializable:()=>42}}).status,'rejected');assert.strictEqual(editor.getSnapshot(),before); +}); + +test('opaque JSON keys survive unrelated edits while unsafe field setters remain rejected',()=>{ + const future={...node('opaque'),componentId:'future.plugin',dataVersion:99,data:JSON.parse('{"constructor":{"retain":true},"__proto__":{"ordinary":"json data"}}')}; + const editor=setup([node('a'),future]);editor.dispatch({type:'element.data',target:target('a'),patch:{title:'edited'}}); + assert.deepEqual(editor.getSnapshot().document.elements.get('opaque').data,future.data); + const before=editor.getSnapshot();assert.equal(editor.dispatch({type:'element.data',target:target('a'),patch:JSON.parse('{"__proto__":{"polluted":true}}')}).status,'rejected');assert.strictEqual(editor.getSnapshot(),before);assert.equal(Object.prototype.polluted,undefined); +}); + test('absent and explicit null are distinct history values; view selection follows element deletion', async () => { const editor = setup(); editor.dispatch({ type: 'element.data', target: target('a'), patch: { optional: null } }); diff --git a/tests/headless/fixtures/foundry-model-operation-v2.json b/tests/headless/fixtures/foundry-model-operation-v2.json new file mode 100644 index 0000000..165b8ea --- /dev/null +++ b/tests/headless/fixtures/foundry-model-operation-v2.json @@ -0,0 +1,26 @@ +{ + "protocol_version": 2, + "request_id": "90000000-0000-4000-8000-000000000002", + "expected_versions": { + "10000000-0000-4000-8000-000000000001": 3 + }, + "operations": [ + { + "id": "90000000-0000-4000-8000-000000000001", + "changes": [ + { + "kind": "field", + "target": { + "id": "a", + "incarnation": 1 + }, + "field": "data.title", + "before": "a", + "after": "renamed", + "beforePresent": true, + "afterPresent": true + } + ] + } + ] +} diff --git a/tests/headless/proxy.integration.mjs b/tests/headless/proxy.integration.mjs new file mode 100644 index 0000000..ce79c86 --- /dev/null +++ b/tests/headless/proxy.integration.mjs @@ -0,0 +1,51 @@ +/** Compile and exercise the actual Next route in a disposable frontend project. */ +import assert from 'node:assert/strict'; +import { createServer } from 'node:http'; +import { mkdtemp, mkdir, copyFile, writeFile, symlink, rm } from 'node:fs/promises'; +import { join, resolve } from 'node:path'; +import { tmpdir } from 'node:os'; +import { spawn } from 'node:child_process'; +import { setTimeout as pause } from 'node:timers/promises'; + +const source=resolve(import.meta.dirname,'../..'),directory=await mkdtemp(join(tmpdir(),'endoleap-proxy-check-')),received=[]; +const account='10000000-0000-4000-8000-000000000004',key='90000000-0000-4000-8000-000000000002'; +const responseBytes=' { "protocol_version":2, "request_id":"'+key+'", "status":"not-applied", "terminal":true, "error":"fixture_rejection", "extension":{"retained":true} }\n'; +const upstream=createServer(async(request,response)=>{ + const chunks=[];for await(const chunk of request)chunks.push(chunk); + received.push({path:request.url,body:Buffer.concat(chunks),headers:request.headers}); + response.writeHead(request.url.endsWith('/model/operations')?409:200,{'Content-Type':'application/json'}); + response.end(request.url.endsWith('/model/operations')?responseBytes:'{"id":"fixture"}'); +}); +await new Promise(resolve=>upstream.listen(0,'127.0.0.1',resolve)); +const reservation=createServer();await new Promise(resolve=>reservation.listen(0,'127.0.0.1',resolve));const port=reservation.address().port;await new Promise(resolve=>reservation.close(resolve)); +let next,logs='',launchError; +try{ + for(const filename of ['package.json','tsconfig.json','src/app/api/foundry/[...path]/route.ts','src/lib/canvas-response.ts','src/lib/session-cookie.ts']){ + const target=join(directory,filename);await mkdir(resolve(target,'..'),{recursive:true});await copyFile(join(source,filename),target); + } + await symlink(join(source,'node_modules'),join(directory,'node_modules')); + await writeFile(join(directory,'src/app/layout.tsx'),'export default function Layout({children}:{children:React.ReactNode}){return {children}}'); + await writeFile(join(directory,'src/app/page.tsx'),'export default function Page(){return
Proxy verification fixture
}'); + next=spawn(process.execPath,[join(source,'node_modules/next/dist/bin/next'),'dev','--webpack','--hostname','127.0.0.1','--port',String(port)],{cwd:directory,detached:true,env:{...process.env,NEXT_TELEMETRY_DISABLED:'1',FOUNDRY_API_ORIGIN:`http://127.0.0.1:${upstream.address().port}`,NEXT_PUBLIC_FOUNDRY_PROJECT:'fixture'},stdio:['ignore','pipe','pipe']}); + next.stdout.on('data',chunk=>{logs=(logs+chunk).slice(-12000)});next.stderr.on('data',chunk=>{logs=(logs+chunk).slice(-12000)});next.on('error',error=>{launchError=error}); + let ready=false; + for(let attempt=0;attempt<90;attempt++){ + if(launchError)throw launchError;if(next.exitCode!==null)throw new Error('Next exited during startup:\n'+logs); + try{const response=await fetch(`http://127.0.0.1:${port}/api/foundry/profile`,{signal:AbortSignal.timeout(1500)});await response.text();if(response.status===200){ready=true;break}}catch{} + await pause(250); + } + if(!ready)throw new Error('Next did not become ready:\n'+logs); + const path='workspaces/10000000-0000-4000-8000-000000000003/materials/10000000-0000-4000-8000-000000000001/canvas/model/operations'; + // More than the generic 1 MiB proxy cap, below the canonical 5 MiB cap. + const body=' {\n "protocol_version":2,"request_id":"'+key+'","fixture":"'+ 'x'.repeat(1100000)+'"\n} '; + const response=await fetch(`http://127.0.0.1:${port}/api/foundry/${path}`,{method:'POST',body,headers:{'Content-Type':'application/json','Idempotency-Key':key,'X-Expected-Account':account,Cookie:'foundry_session=fixture-session',Origin:`http://127.0.0.1:${port}`},signal:AbortSignal.timeout(20000)}); + assert.equal(response.status,409);assert.equal(await response.text(),responseBytes); + const request=received.find(item=>item.path.endsWith('/model/operations')); + assert.ok(request);assert.equal(request.body.toString(),body);assert.equal(request.headers['idempotency-key'],key);assert.equal(request.headers['x-expected-account'],account);assert.equal(request.headers.authorization,'Bearer fixture-session'); + const before=received.length,invalid=await fetch(`http://127.0.0.1:${port}/api/foundry/${path}/unexpected/extra`,{signal:AbortSignal.timeout(10000)});await invalid.text();assert.equal(invalid.status,404);assert.equal(received.length,before); + console.log('PASS actual Next proxy preserves frozen request bytes/key/account and raw terminal receipt above 1 MiB'); + console.log('PASS canonical route allowlist rejects unexpected paths without contacting the upstream'); +}finally{ + if(next?.pid){const exited=new Promise(resolve=>{if(next.exitCode!==null)resolve();else next.once('exit',resolve)});try{process.kill(-next.pid,'SIGTERM')}catch{}await Promise.race([exited,pause(3000)]);if(next.exitCode===null){try{process.kill(-next.pid,'SIGKILL')}catch{}}} + upstream.closeAllConnections();await new Promise(resolve=>upstream.close(resolve));await rm(directory,{recursive:true,force:true,maxRetries:5}); +} diff --git a/tests/headless/scheduler.test.mjs b/tests/headless/scheduler.test.mjs index fdceea7..6b31a8d 100644 --- a/tests/headless/scheduler.test.mjs +++ b/tests/headless/scheduler.test.mjs @@ -98,3 +98,10 @@ test('missing frozen-request interpreter preserves bytes and claims while unrela assert.equal(restored.ledger().getSnapshot().requests.get(old.id).error, 'request_interpreter_unavailable'); assert.equal(restored.scheduler.getClaims().get('canvas'), old.id); restored.scheduler.stop(); }); + +test('permission/protocol rejection keeps unknown claims without an automatic retry loop', async()=>{ + const calls=[],state=setup({submit:async request=>{calls.push(request);return{outcome:'unresolved',blocked:true,error:'identity_changed'}}}); + state.add('create');state.scheduler.start();await state.scheduler.settle(); + assert.equal(state.ledger().getSnapshot().requests.get('request-1').outcome,'unresolved');assert.equal(state.scheduler.getClaims().size,1);assert.equal(state.timerCount(),0); + await state.advance(10000);assert.equal(calls.length,1);state.scheduler.stop(); +}); diff --git a/tests/headless/service.test.mjs b/tests/headless/service.test.mjs new file mode 100644 index 0000000..8362f6e --- /dev/null +++ b/tests/headless/service.test.mjs @@ -0,0 +1,90 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { readFile } from 'node:fs/promises'; +import { createDocument, createRegistry, builtinComponents } from '../../src/lib/canvas-core/index.ts'; +import { createLedger } from '../../src/lib/canvas-runtime/ledger.ts'; +import { createFoundryPersistence, createFoundryTransport, loadFoundryModel } from '../../src/lib/canvas-service/foundry.ts'; + +const root='10000000-0000-4000-8000-000000000001',child='10000000-0000-4000-8000-000000000002',workspace='10000000-0000-4000-8000-000000000003',account='10000000-0000-4000-8000-000000000004'; +const operationId='90000000-0000-4000-8000-000000000001',requestId='90000000-0000-4000-8000-000000000002'; +const registry=createRegistry(builtinComponents()); +const node=(id,owner=root)=>({id,incarnation:1,ownerCanvasId:owner,componentId:'text',dataVersion:1,structure:'node',data:{title:id,text:'preserved'},geometry:{x:0,y:0,width:260,height:200,rotation:0}}); +const a=node('a'),f={...node('f'),componentId:'frame'},future={...node('future',child),componentId:'plugin.future',dataVersion:9,data:{custom:{keep:[1,'x']}},extension:{retained:true}}; +const canvases=[{id:root,parent:null},{id:child,parent:{canvasId:root,entryElementId:'f'}}]; +const document=createDocument(root,canvases,[a,f,future]); +const snapshot={revision:1,rootRevision:1,document,history:{undo:[],redo:[],entries:new Map()},operations:new Map(),runtime:{acknowledged:document,versions:new Map([[root,3],[child,7]]),ledger:createLedger().getSnapshot(),receipts:new Map(),conflicts:new Map()}}; +const intent={id:operationId,operationType:'document.change',operationSchemaVersion:1,sequence:1,origin:'user',canvasIds:[root],dependencies:[],changes:[{kind:'field',target:{id:'a',incarnation:1},field:'data.title',before:'a',after:'renamed',beforePresent:true,afterPresent:true}]}; +const chunk=(complete=true)=>({documentFormatVersion:1,projectId:root,canvases:complete?canvases:[canvases[0]],elements:complete?[a,f,future]:[{...a,data:{...a.data,title:'renamed'}},f],tombstones:{},versions:complete?{[root]:3,[child]:7}:{[root]:4},complete,removedCanvasIds:[]}); +function setup(transport=async()=>({status:200,body:{protocol_version:2,request_id:requestId,status:'applied',operation_ids:[operationId],model:chunk(false)}})){ + const port=createFoundryPersistence({workspaceId:workspace,rootId:root,registry,transport}); + const compiled=port.compile(intent,requestId,snapshot),ledger=createLedger();ledger.add(intent,{a:'exists'}); + const request=ledger.freeze({id:requestId,operationIds:[operationId],...compiled});return{port,request,compiled}; +} + +test('initial model read is explicit about migration/coverage and owns opaque records',async()=>{ + const calls=[]; + const required=await loadFoundryModel(async request=>{calls.push(request);return{status:409,body:{error:'canonical_migration_required',document_format_version:1,root_id:root,source_versions:{[root]:1}}}},workspace,root,registry); + assert.equal(required.status,'migration-required');assert.equal(required.sourceVersions.get(root),1);assert.equal(calls[0].method,'GET'); + const source=chunk(),loaded=await loadFoundryModel(async()=>({status:200,body:{model:source}}),workspace,child,registry); + assert.equal(loaded.document.projectId,root);assert.deepEqual(loaded.document.elements.get('future'),future); + assert.equal(Object.isFrozen(source.elements[0]),false);source.elements[0]={...a,data:{title:'caller mutation',text:''}}; + assert.equal(loaded.document.elements.get('a').data.title,'a'); + await assert.rejects(()=>loadFoundryModel(async()=>({status:200,body:{model:chunk(false)}}),workspace,root,registry),/incomplete_initial_model/); +}); + +test('compiler freezes protocol 2 conditional changes and transports the exact original bytes',async()=>{ + const calls=[],{port,request,compiled}=setup(async input=>{calls.push(input);return{status:200,body:{protocol_version:2,request_id:requestId,status:'applied',operation_ids:[operationId],model:chunk(false)}}}); + assert.equal(compiled.protocolVersion,2);assert.equal(request.protocolVersion,2); + const body=JSON.parse(request.body);assert.deepEqual(body.operations,[{id:operationId,changes:intent.changes}]);assert.equal(Object.hasOwn(body,'document'),false); + assert.deepEqual(body,JSON.parse(await readFile(new URL('./fixtures/foundry-model-operation-v2.json',import.meta.url),'utf8'))); + const originalBytes=' \n'+request.body+' ',frozen={...request,body:originalBytes}; + assert.equal((await port.submit(frozen,new AbortController().signal)).outcome,'applied'); + assert.equal(calls[0].body,originalBytes);assert.equal(calls[0].requestId,requestId); + await port.query(frozen,new AbortController().signal);assert.equal(calls[1].method,'GET');assert.equal(calls[1].body,undefined);assert.ok(calls[1].path.endsWith('/'+requestId)); +}); + +test('only explicit identity-matched terminal proof is zero-write; bare 4xx stays unresolved and blocked',async()=>{ + for(const [response,outcome,blocked]of[ + [{status:409,body:{protocol_version:2,request_id:requestId,status:'not-applied',terminal:true,error:'version_conflict'}},'not-applied',false], + [{status:409,body:{error:'idempotency_conflict'}},'unresolved',true], + [{status:403,body:{error:'forbidden'}},'unresolved',true], + [{status:404,body:{status:'unknown',request_id:requestId}},'unresolved',false], + ]){ + const{port,request}=setup(async()=>response);const status=await port.query(request,new AbortController().signal); + assert.equal(status.outcome,outcome);assert.equal(!!status.blocked,blocked); + } +}); + +test('partial canonical receipts replace participants and retain unrelated future components and versions',async()=>{ + const{port,request}=setup();const status=await port.submit(request,new AbortController().signal),proposal=port.canonical(request,status,snapshot); + assert.equal(proposal.document.elements.get('a').data.title,'renamed');assert.deepEqual(proposal.document.elements.get('future'),future);assert.equal(proposal.versions.get(child),7);assert.equal(proposal.versions.get(root),4); + const broken=structuredClone(status);broken.canonical.model.versions={[root]:4,[child]:8}; + assert.throws(()=>port.canonical(request,broken,snapshot),/invalid_canonical_response|receipt_coverage_mismatch/); + const wrong=structuredClone(status);wrong.canonical.model.elements.push({...future,ownerCanvasId:root}); + assert.throws(()=>port.canonical(request,wrong,snapshot),/canonical_scope_mismatch/); + assert.equal(snapshot.runtime.acknowledged.elements.get('a').data.title,'a'); +}); + +test('missing logical coverage and altered frozen metadata do not masquerade as successful requests',async()=>{ + const calls=[],{port,request}=setup(async input=>{calls.push(input);return{status:200,body:{protocol_version:2,request_id:requestId,status:'applied',operation_ids:[],model:chunk(false)}}}); + const incomplete=await port.submit(request,new AbortController().signal);assert.equal(incomplete.outcome,'unresolved');assert.equal(incomplete.blocked,true);assert.equal(incomplete.error,'receipt_coverage_mismatch'); + const corrupt=await port.submit({...request,expectedVersions:{[root]:99}},new AbortController().signal); + assert.equal(corrupt.blocked,true);assert.equal(calls.length,1);assert.equal(corrupt.error,'frozen_request_contract_mismatch'); +}); + +test('new canvases carry an explicit absent version without confusing format/protocol/document clocks',()=>{ + const canvas='10000000-0000-4000-8000-000000000005',{port}=setup(); + const create={...intent,canvasIds:[root,canvas],changes:[{kind:'canvas',id:canvas,before:null,after:{id:canvas,parent:{canvasId:root,entryElementId:'f'}}}]}; + const compiled=port.compile(create,requestId,snapshot);assert.equal(compiled.expectedVersions[canvas],0);assert.equal(compiled.expectedVersions[root],3);assert.equal(compiled.protocolVersion,2); + const ledger=createLedger();ledger.add(create,{});assert.equal(ledger.freeze({id:requestId,operationIds:[operationId],...compiled}).expectedVersions[canvas],0); +}); + +test('browser transport fences identity changes and never rebuilds a frozen body',async()=>{ + let current=true;const calls=[]; + const transport=createFoundryTransport({accountId:account,isCurrentIdentity:()=>current,fetch:async(url,init)=>{calls.push([url,init]);return new Response('{"status":"unknown"}',{status:404,headers:{'Content-Type':'application/json'}})}}); + await transport({path:'fixture',method:'POST',requestId,body:' {"literal":"bytes"} '}); + assert.equal(calls[0][1].headers['X-Expected-Account'],account);assert.equal(calls[0][1].body,' {"literal":"bytes"} ');assert.equal(calls[0][1].headers['Idempotency-Key'],requestId); + current=false;await assert.rejects(()=>transport({path:'fixture',method:'GET'}),/identity_changed/);assert.equal(calls.length,1); + const late=createFoundryTransport({accountId:account,isCurrentIdentity:()=>current,fetch:async()=>{current=false;return new Response('{}')}});current=true; + await assert.rejects(()=>late({path:'fixture',method:'GET'}),/identity_changed/); +});