diff --git a/src/lib/canvas-service/assets.ts b/src/lib/canvas-service/assets.ts index 9e36c1a..912541a 100644 --- a/src/lib/canvas-service/assets.ts +++ b/src/lib/canvas-service/assets.ts @@ -18,13 +18,16 @@ function object(value: unknown): Record { return value as Record; } /** 交叉检查冻结请求与元数据载荷:一个上传操作、无canvas写入版本、固定资源身份和原字节属性。 - * 最大原件20MiB;请求body只是可恢复元数据,原始Blob通过上传FormData单独携带。 */ + * 元数据 byte_length 必须为1字节至20MiB;body 只是固定请求声明,原 Blob 经 FormData 单独携带。 + * 不改写 request.body,不在这里检查实际 Blob;JSON.parse 等异常沿调用路径处理,不执行文件保存或任务编译。 */ function payload(request: FrozenRequest) { const raw = object(JSON.parse(request.body)); if (request.kind !== 'asset.upload' || request.protocolVersion !== 2 || raw.protocol_version !== 2 || raw.request_id !== request.id || !uuid.test(request.id) || typeof raw.resource_id !== 'string' || !uuid.test(raw.resource_id) || request.operationIds.length !== 1 || raw.operation_id !== request.operationIds[0] || request.canvasIds.length || Object.keys(request.expectedVersions).length || typeof raw.sha256 !== 'string' || !/^[a-f0-9]{64}$/.test(raw.sha256) || typeof raw.byte_length !== 'number' || !Number.isSafeInteger(raw.byte_length) || raw.byte_length < 1 || raw.byte_length > 20 * 1024 * 1024 || typeof raw.name !== 'string' || !raw.name || typeof raw.mime_type !== 'string') throw fault('invalid_asset_request'); return raw; } -/** 成功状态须匹配协议/请求身份;未知回执仍为 unresolved,普通拒绝也不能证明先前上传未提交。 */ +/** applied 只在2xx+协议/requestId 匹配时成立,捕获完整 JSON 回执,资源字段再由 accept 核对。 + * 查询404+matching unknown 是可再查的 unresolved;其它4xx 可 blocked,但不能因此制造 not-applied。 + * 状态解释不解决 ledger 或更新资源,它只返回 scheduler 所需的结果描述。 */ function status(request: FrozenRequest, response: ServiceResponse, query: boolean): OperationStatus { const body = object(response.body), matching = body.protocol_version === 2 && body.request_id === request.id; if (matching && body.status === 'applied' && response.status >= 200 && response.status < 300) { @@ -39,7 +42,9 @@ function status(request: FrozenRequest, response: ServiceResponse, query: boolea /** Immutable bytes and a preallocated resource ID survive response loss and view changes. */ /** 校验规范HTTPS源站,构造工作区资源服务与授权域。 - * 上传与查询复用固定身份;本地资源是否保存、任务如何排队由engine负责。 */ + * 上传与查询复用固定身份;本地文件保存与任务调度由 engine 负责,工厂不自建队列或重试 timer。 + * origin 只用于规范 sourceURL/authority;实际 HTTP 目的地由 transport 配置,不能据此参数推断 fetch 去向。 + * options.project/transport 等沿用宿主对象读取合同,应保持配置稳定,不能运行中改 project 破坏授权域/URL 一致性。 */ export function createFoundryAssets(options: { workspaceId: Id; transport: ServiceTransport; project?: string; origin?: string; displayURL?: (id: Id) => string }): AssetPort { if (!uuid.test(options.workspaceId)) throw fault('invalid_service_scope'); const origin = new URL(options.origin ?? 'https://api.foundry.entroia.com'); @@ -48,19 +53,23 @@ export function createFoundryAssets(options: { workspaceId: Id; transport: Servi /** 可持久化的规范原件地址;每段编码,默认项目endoleap,不与宿主代理预览URL混用。 */ const sourceURL = (id: Id) => `${origin.origin}/projects/${encodeURIComponent(options.project ?? 'endoleap')}/${path}/${encodeURIComponent(id)}`; /** 验证已应用回执和本地资源事实一致,包括操作/摘要/长度/规范URL、尺寸和允许MIME。 - * 限制正整数尺寸及4000万像素,复制并冻结结果后才能登记StoredAsset。 */ + * 限制正整数尺寸及4000万像素,捕获/冻结 asset 后返回;不在此更新 runtime、清本地原图或写共享仓库。 + * 接受服务器检测的允许图片 MIME,不要求它与原上传 mimeType 完全相同;实际像素不在此再次解码验证。 */ const accept = (request: FrozenRequest, result: OperationStatus, resource: ResourceRecord): StoredAsset => { const raw = payload(request), receipt = object(result.canonical), asset = object(receipt.asset); if (result.outcome !== 'applied' || receipt.protocol_version !== 2 || receipt.request_id !== request.id || receipt.status !== 'applied' || resource.id !== raw.resource_id || resource.operationId !== raw.operation_id || resource.digest !== raw.sha256 || resource.byteLength !== raw.byte_length || asset.id !== resource.id || asset.url !== sourceURL(resource.id) || asset.bytes !== resource.byteLength || typeof asset.width !== 'number' || typeof asset.height !== 'number' || !Number.isSafeInteger(asset.width) || !Number.isSafeInteger(asset.height) || asset.width <= 0 || asset.height <= 0 || asset.width * asset.height > 40_000_000 || !['image/png', 'image/jpeg', 'image/gif', 'image/webp'].includes(asset.mime_type as string)) throw fault('asset_receipt_mismatch'); return freeze(JSON.parse(JSON.stringify(asset)) as StoredAsset); }; - /** bytes存在走POST上传,否则GET原操作回执;执行错误保留unresolved,不擅自重置资源身份。 */ + /** 带 bytes 则 POST,否则 GET 原 requestId 回执;只做单次尝试,不换资源/操作/request 身份。 + * 参数/网络/回执异常按 Error 风格 code 转 unresolved;有明确 code(除 invalid_asset_response)才附 blocked。 + * signal 传给 transport,不能抢占这里原生 Blob.arrayBuffer/crypto.digest 的执行。 */ async function execute(request: FrozenRequest, bytes: Blob | undefined, signal: AbortSignal) { try { const raw = payload(request); let body: FormData | undefined; if (bytes) { - // 上传前再核对真实Blob长度和SHA;这里整体读取ArrayBuffer,受20MiB上传上限,不是流式摘要。 + // 元数据长度上限已校验,但实际 bytes.size 在整体 arrayBuffer+SHA 后才比对,不是本函数读取前的硬限。 + // 正常 engine 上传路径先检查实际长度;这里不做流式摘要或内存分块,调用者不能传任意超大 Blob 期待提前拒绝。 const digest = [...new Uint8Array(await crypto.subtle.digest('SHA-256', await bytes.arrayBuffer()))].map(value => value.toString(16).padStart(2, '0')).join(''); if (bytes.size !== raw.byte_length || digest !== raw.sha256) throw fault('resource_bytes_mismatch'); // FormData只携带原图和文件名;幂等请求ID及资源ID通过transport专用头发送。 @@ -75,7 +84,8 @@ export function createFoundryAssets(options: { workspaceId: Id; transport: Servi } /** 跨项目复用的服务授权端口。resolve读取版本化元数据并核对ID/规范URL, * read获取原件字节;字节SHA与授权元数据的一致性由调用它的AssetDepot继续验证。 - * scope绑定源站/项目/工作区,缓存命中不能替代一次新的领用授权。 */ + * scope绑定源站/项目/工作区,缓存命中不能替代一次新的领用授权。 + * resolve 返回冻结元数据,read 返回原字节响应;不在此重新哈希/解码,权限身份与字节预算还依赖 transport 合同。 */ const authority=Object.freeze({scope:`${origin.origin}/projects/${encodeURIComponent(options.project??'endoleap')}/${path}`,async resolve(id:Id,signal:AbortSignal){if(!uuid.test(id))throw fault('invalid_resource_id');const response=await options.transport({method:'GET',path:`${path}/metadata/${encodeURIComponent(id)}`,signal}),raw=object(response.body);if(response.status!==200)throw fault(typeof raw.error==='string'?raw.error:'asset_authorization_failed');if(raw.metadata_version!==1)throw fault('asset_metadata_interpreter_unavailable');const value={version:1 as const,authority:authority.scope,digest:raw.sha256 as string,asset:raw.asset as StoredAsset};validateSharedAsset(value);if(value.asset.id!==id||value.asset.url!==sourceURL(id))throw fault('asset_authorization_mismatch');return freeze(value);},async read(id:Id,signal:AbortSignal){if(!uuid.test(id))throw fault('invalid_resource_id');const response=await options.transport({method:'GET',path:`${path}/${encodeURIComponent(id)}`,signal,responseFormat:'asset-bytes-v1'});if(response.status!==200||!response.bytes)throw fault('asset_bytes_unavailable');return response.bytes;}}); // 默认显示地址走同源预览代理;upload/query只委托统一执行器,不复制另一套任务状态。 return Object.freeze({ authority, sourceURL, displayURL: options.displayURL ?? ((id: Id) => `/api/foundry/${path}/${encodeURIComponent(id)}?variant=preview`), accept, diff --git a/src/lib/canvas-service/index.ts b/src/lib/canvas-service/index.ts index 77cdd88..1648db6 100644 --- a/src/lib/canvas-service/index.ts +++ b/src/lib/canvas-service/index.ts @@ -1,6 +1,7 @@ /** * Foundry 服务适配层的公共入口,暴露文档协议、资源服务、生成任务和模型加载器。 * 这些模块解释服务端数据与请求,不拥有 editor 状态根、history 或调度队列。 + * 此入口只重导出工厂/类型,不自动连接服务、请求模型或创建 project;宿主负责注入 transport 和生命周期。 */ // 底层 transport 与文档增量协议;资源上传和生成各有独立适配器。 export * from './foundry.ts'; diff --git a/src/lib/canvas-service/model-loader.ts b/src/lib/canvas-service/model-loader.ts index b240d1c..d157b1c 100644 --- a/src/lib/canvas-service/model-loader.ts +++ b/src/lib/canvas-service/model-loader.ts @@ -10,13 +10,16 @@ import { loadQueryKey, type LoadPort } from '../canvas-runtime/loader.ts'; import { loadFoundryModel, type ServiceTransport } from './foundry.ts'; /** Negotiate real streaming; older servers may explicitly return complete JSON. */ -/** 绑定工作区和 registry,返回异步分块源;只接受 Foundry canonical-project 查询范围。 */ +/** 创建惰性异步生成器端口;开始迭代才请求,匹配 workspace/format/source/viewKey 后读取 scope.canvasId 的项目模型。 + * scope/options 按不可变合同借用;authEpoch/queryKey 标记来自宿主 scope,不能代替 transport 的账号/权限检查。 + * 同一次响应二选一解释 stream 或 JSON,不建立缓存/共享在途任务;这些由 runtime ScopedLoader 持有。 */ export function createFoundryModelLoader(options: { workspaceId: Id; registry: Registry; transport: ServiceTransport }): LoadPort { return async function* (scope, signal) { // query scope 是缓存/权限隔离依据,不能用某工作区的 transport 为另一种查询来源提供结果。 if (scope.workspaceId !== options.workspaceId || scope.format !== 'canonical-model-v1' || scope.source !== 'foundry' || scope.viewKey !== 'canonical-project') throw fault('invalid_model_load_scope'); const response=await options.transport({method:'GET',path:`workspaces/${encodeURIComponent(options.workspaceId)}/materials/${encodeURIComponent(scope.canvasId)}/canvas/model`,signal,responseFormat:'model-stream-v1'}); - // 流解析器控制序列、来源 token 和 EOF;适配器再补权限代次与 queryKey,供共享 loader 拒绝过期结果。 + // 若 response.stream 存在即进入流路径(须200),解析失败不再偷偷改用 body 或发第二次请求。 + // decoder 控序列/token/EOF,适配器逐块补 scope.authEpoch/queryKey 和 versions 外壳,供上层做迟到结果准入。 if(response.stream){ if(response.status!==200)throw fault('invalid_model_stream'); for await(const chunk of decodeModelStream(response.stream,scope.canvasId,options.registry,signal))yield Object.freeze({...chunk,serverVersions:Object.freeze(Object.fromEntries(chunk.versions)),permissionEpoch:scope.authEpoch,queryKey:loadQueryKey(scope)}); @@ -26,14 +29,15 @@ export function createFoundryModelLoader(options: { workspaceId: Id; registry: R const value = await loadFoundryModel(async()=>response, options.workspaceId, scope.canvasId, options.registry, signal); signal.throwIfAborted(); const projectId = value.status === 'ready' ? value.document.projectId : value.rootId; - // 对版本排序以稳定完整 JSON 回退的快照标记;这是结果身份,不是原响应字节的密码学摘要。 + // 对版本排序后用 [projectId,versions] 生成 token,不包含元素内容,不能把它当成源字节摘要/真实性证明。 const versions = Object.fromEntries([...(value.status === 'ready' ? value.versions : value.sourceVersions)].sort(([a], [b]) => a.localeCompare(b))); const token = JSON.stringify([projectId, versions]); const payload = value.status === 'ready' ? { projectId, canvases: [...value.document.canvases.values()], elements: [...value.document.elements.values()], tombstones: [...value.document.tombstones], versions } : { projectId, versions, migrationRequired: true }; // A serialized payload cost, not a claim about pre-parse network/heap limits. // byteLength 是已解析后重新序列化的 payload 成本,不能据此宣称 JSON.parse 前已有网络/堆内存硬上限。 const byteLength = new TextEncoder().encode(JSON.stringify(payload)).byteLength; - // 完整 JSON 一次给出 complete;迁移结果使用 migration 覆盖标记,不伪造已加载 canvas 内容。 + // JSON 完整结果一次 sequence=0/complete=true;迁移要求用 migration coverage 标记,仍保留其区别于 ready 的状态。 + // 本端口不把内容发布进 editor,消费者还需模型预览/加载协调以及作用域检查。 yield Object.freeze({ queryKey: loadQueryKey(scope), projectId, snapshotToken: token, serverVersions: Object.freeze(versions), permissionEpoch: scope.authEpoch, sequence: 0, coverage: Object.freeze(value.status === 'ready' ? [...value.document.canvases.keys()].map(id => `canvas:${id}`) : [`migration:${scope.canvasId}`]), complete: true, byteLength, value }); }; diff --git a/src/lib/canvas-service/model-stream.ts b/src/lib/canvas-service/model-stream.ts index 9bb8abd..c105097 100644 --- a/src/lib/canvas-service/model-stream.ts +++ b/src/lib/canvas-service/model-stream.ts @@ -18,7 +18,9 @@ const uuid=/^[a-f\d]{8}-[a-f\d]{4}-[a-f\d]{4}-[a-f\d]{4}-[a-f\d]{12}$/i; /** 外部记录必须是有限 JSON 对象,拒绝数组和非 JSON 值,不靠类型断言信任网络内容。 */ const object=(value:unknown):Record=>{if(!value||typeof value!=='object'||Array.isArray(value)||!validJson(value))throw fault('invalid_model_stream');return value as Record;}; /** Bounded NDJSON framing. EOF is required even after a terminal record. */ -/** 有界 UTF-8 NDJSON 分帧。按字节找换行,fatal 解码拒绝损坏编码,最后一条也要求换行结束。 */ +/** 按 LF 分帧,UTF8 decoder 跨 chunk 保留残字节,每行换行时 flush,最后一条也必须以 LF 结束。 + * 单行预算不计 LF(CRLF 的 CR 仍计入),输出 byteLength 则含 LF;计整 chunk 后才逐行解析。 + * 同步扫描/字符串拼接/JSON.parse 无合作调度;预算约束原始字节和行数,不代表整个进程堆内存硬限。 */ async function* lines(source:AsyncIterable,signal:AbortSignal){ const decoder=new TextDecoder('utf-8',{fatal:true}); /** 统一把解码错误转为协议错误,保持错误码不依赖浏览器具体异常文本。 */ @@ -41,14 +43,17 @@ async function* lines(source:AsyncIterable,signal:AbortSignal){ signal.throwIfAborted();pending+=decode();if(pending||lineBytes)throw fault('incomplete_model_stream'); } /** 解释一条固定快照流。sequence 从0连续增长,snapshotToken 全程相同; - * header/元素批次可先 yield,终结结果暂存到 EOF,之后多一条记录也判非法。 */ + * header/元素批次可先 yield,complete 暂存到真正 EOF,之后多一条记录也判非法。 + * receiving 只校验该阶段可确定的 ID/owner/数量等条件,完整 schema/图引用在终结时再验证。 + * 消费者须迭代到终结才能得到 ready;放弃迭代不等于源已完整,signal 也不能强制打断不响应取消的 source.next。 */ export async function* decodeModelStream(source:AsyncIterable,canvasId:Id,registry:Registry,signal:AbortSignal){ let sequence=0,token='',projectId='',expected=0,ended=false; // terminal 暂存而不立即 yield,保证 complete 之后的恶意/错误尾部仍能阻止 ready。 let terminal: {sequence:number;snapshotToken:string;projectId:string;versions:ReadonlyMap;complete:boolean;byteLength:number;coverage:string[];value:CanonicalReadUpdate}|undefined; let canvases:CanvasRecord[]=[],versions:ReadonlyMap=new Map(),tombstones:ReadonlyMap=new Map(); let elementOwners:ElementOwners|undefined;let canvasElementCounts:CanvasElementCounts|undefined;const ownerCounts=new Map(); - // 元素累积用于最终构建文档;流式显示不等于恒定内存,完整模型仍需保存在最终文档中。 + // elements 累积全部记录,ids 去重,ownerCounts 累计接收归属;这些是本次读取的私有暂存,不是已发布文档。 + // receiving 分批显示只减重复传播,不使总内存恒定;已 yield 的前缀也不会因后续报错自动回滚消费者状态。 const elements:ElementRecord[]=[],ids=new Set(),canvasIds=new Set(); for await(const line of lines(source,signal)){ const raw=object(line.raw); @@ -66,7 +71,8 @@ export async function* decodeModelStream(source:AsyncIterable,canvas if(!Array.isArray(raw.removedCanvasIds)||new Set(raw.removedCanvasIds).size!==raw.removedCanvasIds.length||raw.removedCanvasIds.some(id=>typeof id!=='string'||canvasIds.has(id)||!versionMap.has(id))||versionMap.size!==canvasIds.size+raw.removedCanvasIds.length)throw fault('invalid_model_stream'); for(const [id,value]of Object.entries(object(raw.tombstones))){if(!id||!Number.isSafeInteger(value)||(value as number)<1)throw fault('invalid_model_stream');dead.set(id,value as number);} versions=readMap(versionMap);tombstones=readMap(dead); - // 数量/所有权证据是可选协议能力;elementOwners 依赖 counts,必须与总数及各 canvas 数量匹配。 + // 可选 counts/owners 先查声明内部一致性;owners 必须配 counts,声明不代替后续实际记录的数量/归属核验。 + // header 的 canvas 记录此时尚未用完整文档校验树;预览层若要采用必须继续执行自己的阶段校验。 if(Object.hasOwn(raw,'canvasElementCounts')){validateCanvasElementCounts(raw.canvasElementCounts,[...canvasIds],expected);canvasElementCounts=freeze({...raw.canvasElementCounts});} if(Object.hasOwn(raw,'elementOwners')){if(!canvasElementCounts)throw fault('invalid_model_identity');validateElementOwners(raw.elementOwners,canvasElementCounts,expected);elementOwners=freeze({...raw.elementOwners});} const value:CanonicalReadUpdate=freeze({status:'receiving',...(elementOwners?{elementOwners}:{}),...(canvasElementCounts?{canvasElementCounts}:{}),projectId,canvases,elements:[],tombstones,versions,receivedElements:0,totalElements:expected}); @@ -78,10 +84,12 @@ export async function* decodeModelStream(source:AsyncIterable,canvas if(raw.kind==='elements'){ if(!Array.isArray(raw.elements)||!raw.elements.length||raw.elements.length>64||elements.length+raw.elements.length>expected)throw fault('invalid_model_stream'); for(const item of raw.elements){const element=object(item) as unknown as ElementRecord;if(typeof element.id!=='string'||!element.id||ids.has(element.id)||!canvasIds.has(element.ownerCanvasId))throw fault('invalid_model_stream');const count=(ownerCounts.get(element.ownerCanvasId)??0)+1;if(canvasElementCounts&&count>canvasElementCounts[element.ownerCanvasId])throw fault('invalid_model_coverage');ownerCounts.set(element.ownerCanvasId,count);if(elementOwners&&(!Object.hasOwn(elementOwners,element.id)||elementOwners[element.id]!==element.ownerCanvasId))throw fault('invalid_model_identity');ids.add(element.id);elements.push(element);} - // 后续 receiving 只交本批新增元素,不重复传播整份前缀;保留累计进度供显示。 + // receiving 只交本批新记录,canvases/墓碑留空,versions 与总数沿用 header;receivedElements 是累计接收数。 + // 冻结这个 DTO 不是整批导入提交/可靠保存/所有端点已到齐的证明,显示层继续按依赖门禁采用。 const value:CanonicalReadUpdate=freeze({status:'receiving',projectId,canvases:[],elements:raw.elements as ElementRecord[],tombstones:readMap(new Map()),versions,receivedElements:elements.length,totalElements:expected}); yield{sequence:sequence-1,snapshotToken:token,projectId,versions,complete:false,byteLength:line.byteLength,coverage:raw.elements.map(item=>`element:${(item as ElementRecord).id}`),value}; - // 总元素数一致才构建并校验完整文档;只在校验成功后设 ended,随后必须等 EOF。 + // complete 数量匹配后 createDocument 捕获全部记录、validateDocument 校树/归属/引用及已知 schema。 + // terminal 只存候选;ended 后任何记录都拒绝,直到 lines 正常 EOF 才 yield 这份 ready。 }else if(raw.kind==='complete'){ if(raw.elementCount!==expected||elements.length!==expected)throw fault('incomplete_model_stream'); const document=createDocument(projectId,canvases,elements,tombstones);validateDocument(document,registry);ended=true;