Preserve frozen AI admission requests and expose receipt lookup

This commit is contained in:
2026-11-08 00:30:22 +08:00
parent e2987de21a
commit 69b52fd819
4 changed files with 29 additions and 6 deletions
+5 -4
View File
@@ -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(?:\/(?: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 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|operations\/[a-zA-Z0-9_-]+|runs(?:\/[a-zA-Z0-9_-]+(?:\/(?:cancel|apply))?)?)))?$/;
const assetRoute = /^workspaces\/[a-zA-Z0-9_-]+\/assets(?:\/(?:operations\/[a-zA-Z0-9_-]+|[a-zA-Z0-9_-]+))?$/;
const bodyLimit = 1024 * 1024;
@@ -57,10 +57,11 @@ async function handler(
const asset = assetRoute.test(path) && !assetReceipt;
const canvas = canvasRoute.test(path);
const canonical = canvas && /\/canvas\/model(?:\/|$)/.test(path);
const frozenAI = canvas && /\/canvas\/ai\/runs(?:\/|$)/.test(path);
let requestBody: unknown;
if (req.method !== "GET") {
try {
requestBody = await readBody(req, asset ? 21 * 1024 * 1024 : canonical || path.endsWith("/canvas") || path.endsWith("/canvas/operations") ? 5 * 1024 * 1024 : bodyLimit, asset || canonical);
requestBody = await readBody(req, asset ? 21 * 1024 * 1024 : canonical || path.endsWith("/canvas") || path.endsWith("/canvas/operations") ? 5 * 1024 * 1024 : bodyLimit, asset || canonical || frozenAI);
} catch (error) {
const tooLarge = error instanceof Error && error.message === "body_too_large";
return json({ error: tooLarge ? "body_too_large" : "invalid_json" }, tooLarge ? 413 : 400);
@@ -83,8 +84,8 @@ async function handler(
if (values.length === 1 && values[0].length < 300) query.set(key, values[0]);
}
const encodingStarted = performance.now();
// 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 || {});
// Frozen document and AI requests cross the proxy byte-for-byte.
const outboundBody = req.method === "GET" ? undefined : asset || canonical || frozenAI ? requestBody as BodyInit : JSON.stringify(requestBody || {});
const encodeMs = performance.now() - encodingStarted;
const started = performance.now();
try {
+3 -2
View File
@@ -5,7 +5,7 @@ import type { EngineSnapshot, PersistencePort } from '../canvas-runtime/contract
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 | FormData; readonly resourceId?: Id; readonly signal?: AbortSignal };
export type ServiceRequest = { readonly path: string; readonly method: 'GET' | 'POST'; readonly requestId?: Id; readonly expectedVersion?: number; readonly body?: string | FormData; readonly resourceId?: Id; readonly signal?: AbortSignal };
export type ServiceResponse = { readonly status: number; readonly body: unknown; readonly retryAfterMs?: number };
export type ServiceTransport = (request: ServiceRequest) => Promise<ServiceResponse>;
const uuid = /^[a-f\d]{8}-[a-f\d]{4}-[a-f\d]{4}-[a-f\d]{4}-[a-f\d]{12}$/i;
@@ -25,8 +25,9 @@ export function createFoundryTransport(options: { accountId: Id; isCurrentIdenti
const requestFetch = options.fetch ?? globalThis.fetch;
return async input => {
if (!options.isCurrentIdentity()) throw fault('identity_changed');
if (input.expectedVersion !== undefined && (!Number.isSafeInteger(input.expectedVersion) || input.expectedVersion < 1)) throw fault('invalid_server_version');
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', ...(input.body instanceof FormData ? {} : { 'Content-Type': 'application/json' }), ...(input.resourceId ? { 'X-Resource-Id': input.resourceId } : {}), 'X-Expected-Account': options.accountId, ...(input.requestId ? { 'Idempotency-Key': input.requestId } : {}),
Accept: 'application/json', ...(input.body instanceof FormData ? {} : { 'Content-Type': 'application/json' }), ...(input.resourceId ? { 'X-Resource-Id': input.resourceId } : {}), 'X-Expected-Account': options.accountId, ...(input.requestId ? { 'Idempotency-Key': input.requestId } : {}), ...(input.expectedVersion !== undefined ? { 'If-Match': `"${input.expectedVersion}"` } : {}),
} });
if (!options.isCurrentIdentity()) { await response.body?.cancel(); throw fault('identity_changed'); }
let body: unknown;
+11
View File
@@ -49,6 +49,17 @@ try{
const uploaded=await fetch(`http://127.0.0.1:${port}/api/foundry/${assetPath}`,{method:'POST',body:multipart,headers:{'X-Resource-Id':resourceId,'Idempotency-Key':key,'X-Expected-Account':account}});await uploaded.text();assert.equal(uploaded.status,200);
const upload=received.find(item=>item.path.endsWith('/assets'));assert.equal(upload.headers['x-resource-id'],resourceId);assert.equal(upload.headers['idempotency-key'],key);assert.match(upload.body.toString(),/immutable file/);assert.match(upload.headers['content-type'],/^multipart\/form-data; boundary=/);
console.log('PASS asset receipt JSON and multipart resource identity cross the actual Next proxy');
const aiPath=path.replace('/model/operations','/ai/runs');
const frozenAI=' {\n "protocol_version":2,"request_id":"'+key+'","prompt":"保留请求字节" } ';
const aiResponse=await fetch(`http://127.0.0.1:${port}/api/foundry/${aiPath}`,{method:'POST',body:frozenAI,headers:{'Content-Type':'application/json','Idempotency-Key':key,'If-Match':'"7"','X-Expected-Account':account,Cookie:'foundry_session=fixture-session'}});
await aiResponse.text();assert.equal(aiResponse.status,200);
const ai=received.find(item=>item.path.endsWith('/ai/runs'));
assert.equal(ai.body.toString(),frozenAI);assert.equal(ai.headers['if-match'],'"7"');assert.equal(ai.headers['idempotency-key'],key);assert.equal(ai.headers['x-expected-account'],account);assert.equal(ai.headers.authorization,'Bearer fixture-session');assert.equal(ai.headers.accept,'application/vnd.foundry.canvas+json;version=2');
const aiReceipt=await fetch(`http://127.0.0.1:${port}/api/foundry/${aiPath.replace('/runs','/operations')}/${key}`);await aiReceipt.text();assert.equal(aiReceipt.status,200);
assert.ok(received.some(item=>item.path.endsWith('/ai/operations/'+key)));
const aiEvents=await fetch(`http://127.0.0.1:${port}/api/foundry/${aiPath}/${key}?after_seq=41&limit=100`);await aiEvents.text();assert.equal(aiEvents.status,200);
assert.ok(received.some(item=>item.path.endsWith('/ai/runs/'+key+'?limit=100&after_seq=41')));
console.log('PASS frozen AI bytes, identity, version header, admission receipt and event cursor cross the actual Next proxy');
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');
+10
View File
@@ -88,3 +88,13 @@ test('browser transport fences identity changes and never rebuilds a frozen body
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/);
});
test('shared service transport binds the immutable AI admission version header',async()=>{
const calls=[];
const transport=createFoundryTransport({accountId:account,isCurrentIdentity:()=>true,fetch:async(url,input)=>{calls.push({url,input});return new Response('{}',{status:200,headers:{'Content-Type':'application/json'}})}});
await transport({method:'POST',path:'fixture',body:' {"frozen": true} ',requestId,expectedVersion:7});
assert.equal(calls[0].input.headers['If-Match'],'"7"');assert.equal(calls[0].input.headers['Idempotency-Key'],requestId);assert.equal(calls[0].input.body,' {"frozen": true} ');
for(const value of [0,-1,1.5,NaN,Number.MAX_SAFE_INTEGER+1])await assert.rejects(()=>transport({method:'POST',path:'fixture',expectedVersion:value}),/invalid_server_version/);
assert.equal(calls.length,1);
});