diff --git a/apps/waterfall-wiki/db/016_canvas_workspace.sql b/apps/waterfall-wiki/db/016_canvas_workspace.sql new file mode 100644 index 0000000..76c13d2 --- /dev/null +++ b/apps/waterfall-wiki/db/016_canvas_workspace.sql @@ -0,0 +1,23 @@ +BEGIN; +CREATE TABLE canvas_documents ( + id uuid PRIMARY KEY, owner_id uuid NOT NULL REFERENCES account_users(id) ON DELETE CASCADE, + name text NOT NULL DEFAULT '未命名画布' CHECK(length(name) BETWEEN 1 AND 200), + revision integer NOT NULL DEFAULT 1 CHECK(revision>0), document_key text NOT NULL, + updated_at timestamptz NOT NULL DEFAULT now() +); +CREATE INDEX canvas_owner_updated ON canvas_documents(owner_id,updated_at DESC); +CREATE TABLE canvas_agent_links ( + digest text PRIMARY KEY, canvas_id uuid NOT NULL REFERENCES canvas_documents(id) ON DELETE CASCADE, + expires_at timestamptz NOT NULL, last_seen_at timestamptz +); +CREATE INDEX canvas_link_expiry ON canvas_agent_links(canvas_id,expires_at); +CREATE TABLE canvas_tasks ( + id uuid PRIMARY KEY, canvas_id uuid NOT NULL REFERENCES canvas_documents(id) ON DELETE CASCADE, + target_id text NOT NULL, source_revision integer NOT NULL, + status text NOT NULL DEFAULT 'queued' CHECK(status IN ('queued','running','succeeded','failed')), + request_key text NOT NULL, result_digest text, error text, + created_at timestamptz NOT NULL DEFAULT now(), updated_at timestamptz NOT NULL DEFAULT now() +); +CREATE UNIQUE INDEX canvas_target_active_task ON canvas_tasks(canvas_id,target_id) WHERE status IN ('queued','running'); +INSERT INTO schema_migrations(version) VALUES ('016_canvas_workspace'); +COMMIT; diff --git a/apps/waterfall-wiki/src/lib/api-documentation.ts b/apps/waterfall-wiki/src/lib/api-documentation.ts index 342bf7e..5f6943d 100644 --- a/apps/waterfall-wiki/src/lib/api-documentation.ts +++ b/apps/waterfall-wiki/src/lib/api-documentation.ts @@ -2,6 +2,14 @@ import type {NextApiRequest,NextApiResponse} from 'next'; // Read documentation before business validation or storage access. Mutations never enter this branch. export const apiDocuments:Record={ + "/api/canvases/{...path}": [ + "在线画布与 coding agent", "GET, POST, PUT, PATCH, DELETE", + "参数:POST /api/canvases {name?,document} 创建本人画布;GET 列出本人画布。GET /api/canvases/{id} 返回 document、revision 和 tasks;PUT {revision,document} 更新,冲突返回 409。所有者使用已验证邮箱会话,写入要求同源 Origin。", + "连接:所有者 POST /api/canvases/{id}/link 创建仅限此画布、有效一小时的 token(只返回一次,旧 token 同时撤销)。Agent 使用 Authorization: Bearer 。DELETE 同一路径并发送 {} 撤销;GET 读取 expiresAt/lastSeenAt。不要将 token 放入 URL、日志或作品中。", + "任务:POST /api/canvases/{id}/tasks {revision,targetId} 保存当前引用和指令快照;GET /api/canvases/{id}/tasks/{taskId} 读取 request。PATCH 先发送 {status:running} 领取任务,执行实际生成后发送 {status:succeeded,result:{text?,image?}},或 {status:failed,error:原因}。image 仅允许嵌入式 PNG/JPEG/WebP data URL(小于 3MB);正文最多 10000 字符。结果只写回该任务的目标节点,不能改写节点标识和引用。", + "返回:任务状态 queued/running/succeeded/failed,画布版本为正整数。任务期间画布变动则结果回写返回 409,先读取新画布并重新派发任务;相同成功结果重复提交幂等。请求快照和画布内容保存在 S3;访问凭据仅在数据库保存摘要。", + "401 未登录;403 来源或权限不足;404 画布/任务不存在或不可访问;409 版本/任务冲突;413 文件过大;503 服务不可用。" + ], "/api/posts/{id}/archive": [ "归档或恢复笔记", "POST", "路径参数 id 为存储 UUID 或24位 publicId。JSON 参数:{archived:true} 归档,{archived:false} 恢复。重复调用幂等,不删除 post、正文、媒体、标签或页面记录。", diff --git a/apps/waterfall-wiki/src/lib/canvas-document.ts b/apps/waterfall-wiki/src/lib/canvas-document.ts new file mode 100644 index 0000000..08f7699 --- /dev/null +++ b/apps/waterfall-wiki/src/lib/canvas-document.ts @@ -0,0 +1,67 @@ +export type NodeKind = 'text' | 'image' | 'prompt' | 'script'; +export type TextStyle = {bold?:boolean;italic?:boolean;color?:string;size?:number}; +export type CanvasNode = { + id:string;kind:NodeKind;title:string;text:string;x:number;y:number; + image?:string;width?:number;height?:number;style?:TextStyle; + instruction?:string; + operation?:'generate'|'upscale'|'script'; +}; +export type CanvasDocument = {version:1;cards:CanvasNode[];links:[string,string][]}; +const kinds:NodeKind[]=['text','image','prompt','script']; +const object=(v:unknown):v is Record=>!!v&&typeof v==='object'&&!Array.isArray(v); +const bounded=(v:unknown,min:number,max:number)=>typeof v==='number'&&Number.isFinite(v)&&v>=min&&v<=max; +const text=(v:unknown,max:number)=>typeof v==='string'&&v.length<=max; +const image=(v:unknown)=>typeof v==='string'&&v.length<3000000&&/^data:image\/(png|jpeg|webp);base64,[A-Za-z0-9+/=]+$/.test(v); + +export function parseCanvasDocument(value:unknown):CanvasDocument { + const d=typeof value==='string'?JSON.parse(value):value; + if(!object(d)||d.version!==1||!Array.isArray(d.cards)||d.cards.length>80||!Array.isArray(d.links)||d.links.length>200)throw Error('画布格式不正确'); + const ids=new Set(); + const cards:CanvasNode[]=d.cards.map((n:unknown)=>{ + if(!object(n)||!text(n.id,100)||!n.id||ids.has(n.id as string)||!kinds.includes(n.kind as NodeKind)||!text(n.title,200)||!text(n.text,10000)||!bounded(n.x,-100000,100000)||!bounded(n.y,-100000,100000))throw Error('节点格式不正确'); + if(n.width!==undefined&&!bounded(n.width,180,1200)||n.height!==undefined&&!bounded(n.height,160,1200)||n.image!==undefined&&!image(n.image))throw Error('节点尺寸或图片不正确'); + if(n.instruction!==undefined&&!text(n.instruction,10000))throw Error('创作要求格式不正确'); + if(n.operation!==undefined&&!['generate','upscale','script'].includes(n.operation as string))throw Error('生成任务类型不正确'); + let style:TextStyle|undefined; + if(n.style!==undefined){ + if(!object(n.style)||n.style.bold!==undefined&&typeof n.style.bold!=='boolean'||n.style.italic!==undefined&&typeof n.style.italic!=='boolean'||n.style.color!==undefined&&(typeof n.style.color!=='string'||!/^#[a-f\d]{6}$/i.test(n.style.color))||n.style.size!==undefined&&!bounded(n.style.size,10,72))throw Error('文字样式不正确'); + style={...(n.style.bold===undefined?{}:{bold:n.style.bold as boolean}),...(n.style.italic===undefined?{}:{italic:n.style.italic as boolean}),...(n.style.color===undefined?{}:{color:n.style.color as string}),...(n.style.size===undefined?{}:{size:n.style.size as number})}; + } + ids.add(n.id as string); + return {id:n.id as string,kind:n.kind as NodeKind,title:n.title as string,text:n.text as string,x:n.x as number,y:n.y as number,...(n.width===undefined?{}:{width:n.width as number}),...(n.height===undefined?{}:{height:n.height as number}),...(n.image===undefined?{}:{image:n.image as string}),...(style?{style}:{}),...(n.instruction===undefined?{}:{instruction:n.instruction as string}),...(n.operation===undefined?{}:{operation:n.operation as CanvasNode['operation']})}; + }); + const pairs=new Set(); + const links:[string,string][]=d.links.map((l:unknown)=>{ + if(!Array.isArray(l)||l.length!==2||l[0]===l[1]||!l.every(id=>typeof id==='string'&&ids.has(id)))throw Error('引用节点不存在'); + const key=JSON.stringify(l);if(pairs.has(key))throw Error('引用重复');pairs.add(key);return [l[0],l[1]]; + }); + return {version:1,cards,links}; +} + +export function referencedNodes(doc:CanvasDocument,target:string):CanvasNode[]{ + return doc.links.filter(([,to])=>to===target).map(([from])=>doc.cards.find(n=>n.id===from)!).filter(Boolean); +} +export function connectNodes(doc:CanvasDocument,from:string,to:string):CanvasDocument { + if(from===to||![from,to].every(id=>doc.cards.some(n=>n.id===id)))throw Error('请选择不同的有效节点'); + if(doc.links.some(([a,b])=>a===from&&b===to))return doc; + if(doc.links.length>=200)throw Error('最多 200 条引用'); + const seen=new Set();const pending=[to]; + while(pending.length){const id=pending.pop()!;if(id===from)throw Error('引用不能形成循环');if(seen.has(id))continue;seen.add(id);pending.push(...doc.links.filter(([a])=>a===id).map(([,b])=>b));} + return {...doc,links:[...doc.links,[from,to]]}; +} +export function removeCanvasNode(doc:CanvasDocument,id:string):CanvasDocument { + return {...doc,cards:doc.cards.filter(n=>n.id!==id),links:doc.links.filter(l=>!l.includes(id))}; +} +export function deriveNode(doc:CanvasDocument,source:string,node:CanvasNode):CanvasDocument { + if(doc.cards.length>=80||doc.cards.some(n=>n.id===node.id))throw Error('节点数量或标识不正确'); + const result=connectNodes({...doc,cards:[...doc.cards,node]},source,node.id); + return parseCanvasDocument(result); +} +export function agentTask(doc:CanvasDocument,id:string){ + const node=doc.cards.find(n=>n.id===id);if(!node)throw Error('目标节点不存在'); + const instruction=node.instruction??node.text; + if(!instruction.trim()&&node.operation!=='upscale')throw Error('请先填写创作要求'); + const references=referencedNodes(doc,id); + if(node.operation==='upscale'&&!references.some(n=>n.kind==='image'&&n.image))throw Error('请先引用一张图片'); + return {schema:'studio.canvas-task/1',target:{id:node.id,kind:node.kind,title:node.title},operation:node.operation||'generate',instruction,references:references.map(n=>({id:n.id,kind:n.kind,title:n.title,text:n.text,...(n.image?{image:n.image}:{}),...(n.style?{style:n.style}:{})})),result:{targetId:node.id,allowedFields:node.kind==='image'?['image','text']:['text']}}; +} diff --git a/apps/waterfall-wiki/src/lib/canvas-store.ts b/apps/waterfall-wiki/src/lib/canvas-store.ts new file mode 100644 index 0000000..2166dc8 --- /dev/null +++ b/apps/waterfall-wiki/src/lib/canvas-store.ts @@ -0,0 +1,80 @@ +import {randomUUID,createHash} from 'node:crypto'; +import {PutObjectCommand} from '@aws-sdk/client-s3'; +import {db,transaction} from './db'; +import {storage,bucket,getObject,removeObject} from './storage'; +import {newToken,tokenDigest} from './account-crypto'; +import {parseCanvasDocument,agentTask,type CanvasDocument} from './canvas-document'; +export class CanvasError extends Error { constructor(public status:number,message:string){super(message)} } +export const uuid=(v:unknown):v is string=>typeof v==='string'&&/^[a-f\d]{8}-[a-f\d]{4}-[a-f\d]{4}-[a-f\d]{4}-[a-f\d]{12}$/i.test(v); +export const revision=(v:unknown):v is number=>typeof v==='number'&&Number.isSafeInteger(v)&&v>0&&v<2147483647; +export async function readCanvasBody(key:string):Promise{const o=await getObject(key);return parseCanvasDocument(await o.Body!.transformToString())} +async function putJson(canvas:string,value:unknown){const key=`canvases/${canvas}/${randomUUID()}.json`,body=JSON.stringify(value);if(Buffer.byteLength(body)>15000000)throw new CanvasError(413,'画布不能超过 15MB');await storage().send(new PutObjectCommand({Bucket:bucket(),Key:key,Body:body,ContentType:'application/json'}));return key} +export async function authorizeCanvas(id:string,owner:string|null,bearer:string|null){ + const record=await db().query('SELECT * FROM canvas_documents WHERE id=$1',[id]);const doc=record.rows[0]; + if(!doc)throw new CanvasError(404,'画布不存在'); + if(owner===doc.owner_id)return doc; + if(bearer&&/^[a-f\d]{64}$/.test(bearer)){ + const auth=await db().query('UPDATE canvas_agent_links SET last_seen_at=now() WHERE canvas_id=$1 AND digest=$2 AND expires_at>now() RETURNING canvas_id',[id,tokenDigest(bearer)]); + if(auth.rowCount)return doc; + } + throw new CanvasError(404,'画布不存在或无访问权限'); +} +export async function canvasState(record:{id:string;revision:number;name:string;document_key:string}){ + const tasks=await db().query('SELECT id,target_id AS "targetId",status,error,source_revision AS "sourceRevision",created_at AS "createdAt" FROM canvas_tasks WHERE canvas_id=$1 ORDER BY created_at DESC LIMIT 80',[record.id]); + return {id:record.id,name:record.name,revision:record.revision,document:await readCanvasBody(record.document_key),tasks:tasks.rows}; +} +export async function createCanvas(owner:string,input:unknown,name='未命名画布'){ + if(typeof name!=='string'||!name.trim()||name.length>200)throw new CanvasError(400,'画布名称无效'); + const document=parseCanvasDocument(input),id=randomUUID(),key=await putJson(id,document); + try{const r=await db().query('INSERT INTO canvas_documents(id,owner_id,name,document_key) VALUES($1,$2,$3,$4) RETURNING *',[id,owner,name.trim(),key]);return canvasState(r.rows[0])}catch(e){await removeObject(key).catch(()=>{});throw e} +} +export async function saveCanvas(id:string,expected:number,input:unknown){ + if(!revision(expected))throw new CanvasError(400,'版本无效');const document=parseCanvasDocument(input),key=await putJson(id,document); + try{return await transaction(async client=>{const r=await client.query('UPDATE canvas_documents SET document_key=$3,revision=revision+1,updated_at=now() WHERE id=$1 AND revision=$2 RETURNING *',[id,expected,key]);if(!r.rowCount)throw new CanvasError(409,'在线画布已有更新,请先读取新版本');return r.rows[0]})}catch(e){await removeObject(key).catch(()=>{});throw e} +} +export async function createCanvasLink(id:string){ + const token=newToken(),expiresAt=new Date(Date.now()+3600000).toISOString(); + await transaction(async client=>{await client.query('DELETE FROM canvas_agent_links WHERE canvas_id=$1',[id]);await client.query('INSERT INTO canvas_agent_links(digest,canvas_id,expires_at) VALUES($1,$2,$3)',[tokenDigest(token),id,expiresAt])}); + return {token,expiresAt}; +} +export async function queueCanvasTask(id:string,expected:number,target:string){ + if(!revision(expected)||typeof target!=='string'||target.length>100)throw new CanvasError(400,'任务参数无效'); + let key:string|undefined; + try{return await transaction(async client=>{ + const row=(await client.query('SELECT * FROM canvas_documents WHERE id=$1 FOR UPDATE',[id])).rows[0];if(!row)throw new CanvasError(404,'画布不存在');if(row.revision!==expected)throw new CanvasError(409,'画布已有更新,请先同步'); + const body=await readCanvasBody(row.document_key),request=agentTask(body,target);key=await putJson(id,request); + const result=await client.query('INSERT INTO canvas_tasks(id,canvas_id,target_id,source_revision,request_key) VALUES($1,$2,$3,$4,$5) RETURNING id,status,target_id AS "targetId"',[randomUUID(),id,target,expected,key]);return result.rows[0]; + })}catch(e){if(key)await removeObject(key).catch(()=>{});if((e as {code?:string}).code==='23505')throw new CanvasError(409,'该节点已有待处理任务');throw e} +} +export async function readCanvasTask(id:string,task:string){ + const r=await db().query('SELECT * FROM canvas_tasks WHERE canvas_id=$1 AND id=$2',[id,task]);if(!r.rowCount)throw new CanvasError(404,'任务不存在');const row=r.rows[0],o=await getObject(row.request_key);return {id:row.id,targetId:row.target_id,status:row.status,sourceRevision:row.source_revision,error:row.error,request:JSON.parse(await o.Body!.transformToString())}; +} +export async function updateCanvasTask(id:string,task:string,input:{status?:unknown;result?:unknown;error?:unknown}){ + let key:string|undefined; + try{return await transaction(async client=>{ + const record=(await client.query('SELECT * FROM canvas_documents WHERE id=$1 FOR UPDATE',[id])).rows[0]; + const row=(await client.query('SELECT * FROM canvas_tasks WHERE canvas_id=$1 AND id=$2 FOR UPDATE',[id,task])).rows[0]; + if(!record||!row)throw new CanvasError(404,'任务不存在'); + if(input.status==='running'){ + if(!['queued','running'].includes(row.status))throw new CanvasError(409,'任务已经结束'); + await client.query("UPDATE canvas_tasks SET status='running',updated_at=now() WHERE id=$1",[task]);return {id:task,status:'running',revision:record.revision}; + } + if(input.status==='failed'){ + if(!['queued','running'].includes(row.status))throw new CanvasError(409,'任务已经结束'); + if(typeof input.error!=='string'||!input.error.trim()||input.error.length>1000)throw new CanvasError(400,'请提供任务失败原因'); + await client.query("UPDATE canvas_tasks SET status='failed',error=$2,updated_at=now() WHERE id=$1",[task,input.error]);return {id:task,status:'failed',revision:record.revision}; + } + if(input.status!=='succeeded'||!input.result||typeof input.result!=='object'||Array.isArray(input.result))throw new CanvasError(400,'任务更新格式不正确'); + const result=input.result as {text?:unknown;image?:unknown}; + if(result.text===undefined&&result.image===undefined)throw new CanvasError(400,'没有返回结果'); + const digest=createHash('sha256').update(JSON.stringify({text:result.text,image:result.image})).digest('hex'); + if(row.status==='succeeded'&&row.result_digest===digest)return {id:task,status:'succeeded',revision:record.revision}; + if(row.status!=='running')throw new CanvasError(409,'请先领取任务,已结束的任务不能改写'); + if(record.revision!==row.source_revision)throw new CanvasError(409,'画布在任务创建后已修改,请重新派发任务'); + const doc=await readCanvasBody(record.document_key),node=doc.cards.find(n=>n.id===row.target_id);if(!node)throw new CanvasError(409,'目标节点已移除'); + if(result.image!==undefined&&node.kind!=='image')throw new CanvasError(400,'该节点不接收图片结果'); + const next=parseCanvasDocument({...doc,cards:doc.cards.map(n=>n.id===node.id?{...n,...(result.text===undefined?{}:{text:result.text}),...(result.image===undefined?{}:{image:result.image})}:n)}); + key=await putJson(id,next);await client.query('UPDATE canvas_documents SET document_key=$2,revision=revision+1,updated_at=now() WHERE id=$1',[id,key]); + await client.query("UPDATE canvas_tasks SET status='succeeded',result_digest=$2,error=NULL,updated_at=now() WHERE id=$1",[task,digest]);return {id:task,status:'succeeded',revision:record.revision+1}; + })}catch(e){if(key)await removeObject(key).catch(()=>{});throw e} +} diff --git a/apps/waterfall-wiki/src/pages/api/canvases/[[...path]].ts b/apps/waterfall-wiki/src/pages/api/canvases/[[...path]].ts new file mode 100644 index 0000000..a72b687 --- /dev/null +++ b/apps/waterfall-wiki/src/pages/api/canvases/[[...path]].ts @@ -0,0 +1,55 @@ +import type {NextApiRequest,NextApiResponse} from 'next'; +import {serveApiDocumentation} from '@/lib/api-documentation'; +import {currentAccount} from '@/lib/account'; +import {trustedOrigin} from '@/lib/account-crypto'; +import {db} from '@/lib/db'; +import {CanvasError,uuid,authorizeCanvas,canvasState,createCanvas,saveCanvas,createCanvasLink,queueCanvasTask,readCanvasTask,updateCanvasTask} from '@/lib/canvas-store'; +export const config={api:{bodyParser:{sizeLimit:'15mb'}}}; +export default async function handler(req:NextApiRequest,res:NextApiResponse){ + if(serveApiDocumentation(req,res,'/api/canvases/{...path}'))return; + res.setHeader('Cache-Control','private, no-store');res.setHeader('Referrer-Policy','no-referrer'); + const path=req.query.path||[];if(!Array.isArray(path)||path.length>3)return res.status(404).end(); + const [id,section,taskId]=path; + if(id&&!uuid(id)||taskId&&!uuid(taskId))return res.status(404).end(); + if(!['GET','POST','PUT','PATCH','DELETE'].includes(req.method||'')){res.setHeader('Allow','GET, POST, PUT, PATCH, DELETE');return res.status(405).end()} + const input=req.body; + if(req.method!=='GET'&&(!input||typeof input!=='object'||Array.isArray(input)))return res.status(400).json({error:'请发送 JSON 对象'}); + try{ + const headers=new Headers(Object.entries(req.headers).filter(([,v])=>typeof v==='string') as [string,string][]); + const request=new Request(new URL(req.url||'/',process.env.ACCOUNT_PUBLIC_ORIGIN||'http://'+req.headers.host),{headers}); + const bearer=req.headers.authorization?.startsWith('Bearer ')?req.headers.authorization.slice(7):null; + if(bearer&&req.headers['sec-fetch-site']==='cross-site')throw new CanvasError(403,'请求来源不受信任'); + if(!bearer&&req.method!=='GET'&&!trustedOrigin(request))throw new CanvasError(403,'请求来源不受信任'); + const user=bearer?null:await currentAccount(request); + if(!id){ + if(!user)throw new CanvasError(401,'请先登录邮箱账户'); + if(req.method==='GET'){const rows=await db().query('SELECT id,name,revision,updated_at AS "updatedAt" FROM canvas_documents WHERE owner_id=$1 ORDER BY updated_at DESC LIMIT 100',[user.id]);return res.json({canvases:rows.rows})} + if(req.method==='POST')return res.status(201).json(await createCanvas(user.id,input.document,input.name)); + return res.status(405).end(); + } + const record=await authorizeCanvas(id,user?.id||null,bearer); + if(!section){ + if(req.method==='GET')return res.json(await canvasState(record)); + if(req.method==='PUT')return res.json(await canvasState(await saveCanvas(id,input.revision,input.document))); + return res.status(405).end(); + } + if(section==='link'&&!taskId){ + if(!user||user.id!==record.owner_id)throw new CanvasError(403,'只有画布所有者可以管理 Agent 连接'); + if(req.method==='POST')return res.status(201).json({...await createCanvasLink(id),canvasId:id,documentation:`/api/canvases/${id}?agent_mode=True`}); + if(req.method==='DELETE'){await db().query('DELETE FROM canvas_agent_links WHERE canvas_id=$1',[id]);return res.json({revoked:true})} + if(req.method==='GET'){const r=await db().query('SELECT expires_at AS "expiresAt",last_seen_at AS "lastSeenAt" FROM canvas_agent_links WHERE canvas_id=$1 AND expires_at>now() ORDER BY expires_at DESC LIMIT 1',[id]);return res.json({link:r.rows[0]||null})} + return res.status(405).end(); + } + if(section==='tasks'){ + if(!taskId&&req.method==='POST')return res.status(201).json(await queueCanvasTask(id,input.revision,input.targetId)); + if(taskId&&req.method==='GET')return res.json(await readCanvasTask(id,taskId)); + if(taskId&&req.method==='PATCH')return res.json(await updateCanvasTask(id,taskId,input)); + return res.status(405).end(); + } + return res.status(404).end(); + }catch(e){ + if(e instanceof CanvasError)return res.status(e.status).json({error:e.message}); + if(e instanceof SyntaxError||e instanceof Error&&/格式|节点|引用|创作要求|图片|画布不能/.test(e.message))return res.status(400).json({error:'画布或任务参数无效'}); + return res.status(503).json({error:'在线画布暂时不可用'}); + } +} diff --git a/apps/waterfall-wiki/src/proxy.ts b/apps/waterfall-wiki/src/proxy.ts index 1dea998..113de46 100644 --- a/apps/waterfall-wiki/src/proxy.ts +++ b/apps/waterfall-wiki/src/proxy.ts @@ -5,7 +5,7 @@ export function proxy(request: NextRequest) { const responseHeaders = {'Cache-Control':'private, no-store', 'Referrer-Policy':'same-origin'}; // Public knowledge pages are read-only. Account handlers enforce their own origin and session checks. const publicRead = ['GET','HEAD'].includes(request.method) && (path === '/' || ['/workspace','/graphic','/infinite_canvas','/studio','/studio/graphic','/studio/infinite_canvas','/studio/api'].includes(path) || path.startsWith('/studio/_next/') || path === '/protocol' || ['/llms.txt','/index.md','/agent','/agent/index.md','/api/agent-index','/api/guides'].includes(path) || path.startsWith('/guide/') || path.startsWith('/api/guides/')); - if (path === '/write' || path === '/documents' || path.startsWith('/documents/') || path === '/api/documents' || path.startsWith('/api/documents/') || publicRead || path === '/account' || path.startsWith('/api/account/')) return NextResponse.next({headers:{...responseHeaders,'Referrer-Policy':'no-referrer'}}); + if (path === '/api/canvases' || path.startsWith('/api/canvases/') || path === '/write' || path === '/documents' || path.startsWith('/documents/') || path === '/api/documents' || path.startsWith('/api/documents/') || publicRead || path === '/account' || path.startsWith('/api/account/')) return NextResponse.next({headers:{...responseHeaders,'Referrer-Policy':'no-referrer'}}); if (path === '/brand-mark.svg' || path === '/login' || path === '/api/auth/login' || path === '/api/auth/logout' || path === '/api/health' || path === '/api/ready' || path.startsWith('/_next/static/')) return NextResponse.next({headers:responseHeaders}); let config; try { config = authConfig(); } catch { return NextResponse.json({error:'登录服务配置不可用'}, {status:503,headers:responseHeaders}); } diff --git a/apps/waterfall-wiki/tests/api-documentation.test.ts b/apps/waterfall-wiki/tests/api-documentation.test.ts index c0c12ca..6183ec4 100644 --- a/apps/waterfall-wiki/tests/api-documentation.test.ts +++ b/apps/waterfall-wiki/tests/api-documentation.test.ts @@ -13,7 +13,7 @@ const request=(method:string,agent_mode:unknown)=>({method,query:{agent_mode}} a test('every business handler serves documentation before validating parameters or accessing dependencies',async()=>{ const routes:Record={ - '/api/explore-feed':'explore-feed','/api/feed':'feed','/api/notes/{id}':'notes/[id]', + '/api/canvases/{...path}':'canvases/[[...path]]','/api/explore-feed':'explore-feed','/api/feed':'feed','/api/notes/{id}':'notes/[id]', '/api/posts/{id}/archive':'posts/[id]/archive','/api/posts':'posts/index','/api/tags':'tags','/api/upload':'upload', '/api/media/{id}':'media/[id]','/api/preview':'preview', '/api/skills/street-people':'skills/street-people','/api/health':'health','/api/ready':'ready', diff --git a/apps/waterfall-wiki/tests/canvas-integration.ts b/apps/waterfall-wiki/tests/canvas-integration.ts new file mode 100644 index 0000000..6374502 --- /dev/null +++ b/apps/waterfall-wiki/tests/canvas-integration.ts @@ -0,0 +1,39 @@ +import assert from 'node:assert/strict'; +import {randomUUID} from 'node:crypto'; +import {db} from '../src/lib/db'; +import {newToken,tokenDigest,ACCOUNT_COOKIE} from '../src/lib/account-crypto'; +export async function verifyCanvasWorkspace(base:string){ + const identities=await Promise.all([0,1].map(async()=>{const id=randomUUID(),token=newToken();await db().query("INSERT INTO account_users(id,email,password_hash,verified_at) VALUES($1,$2,'test',now())",[id,id+'@example.com']);await db().query("INSERT INTO account_tokens(digest,user_id,kind,expires_at) VALUES($1,$2,'session',now()+interval '1 hour')",[tokenDigest(token),id]);return {[`cookie`]:ACCOUNT_COOKIE+'='+token}})); + const origin=process.env.ACCOUNT_PUBLIC_ORIGIN||base; + const call=(path:string,method='GET',body?:unknown,headers:Record={})=>fetch(base+'/api/canvases'+path,{method,headers:{'Content-Type':'application/json',origin,...headers},...(body===undefined?{}:{body:JSON.stringify(body)})}); + const doc={version:1,cards:[{id:'note',kind:'text',title:'简报',text:'自然光与城市清晨',x:0,y:0},{id:'script',kind:'script',title:'脚本',text:'',instruction:'写六个分镜',operation:'script',x:500,y:0}],links:[['note','script']]}; + assert.equal((await call('','POST',{document:doc})).status,401); + const create=await call('','POST',{document:doc},identities[0]);assert.equal(create.status,201,await create.clone().text());const canvas=await create.json();assert.equal(canvas.revision,1); + const root='/'+canvas.id; + assert.equal((await call(root,'GET',undefined,identities[1])).status,404); + assert.equal((await call(root,'PUT',{revision:1,document:doc},{...identities[0],origin:'https://evil.example'})).status,403); + const linked=await call(root+'/link','POST',{},identities[0]);assert.equal(linked.status,201);const credentials=await linked.json();const agent={authorization:'Bearer '+credentials.token}; + assert.equal((await call('','GET',undefined,agent)).status,401); + const other=await (await call('','POST',{document:doc},identities[0])).json();assert.equal((await call('/'+other.id,'GET',undefined,agent)).status,404,'A link cannot read another canvas'); + assert.equal((await call(root,'PUT',{revision:1,document:doc},identities[0])).status,200); + assert.equal((await call(root,'PUT',{revision:1,document:doc},identities[0])).status,409); + async function queue(rev:number){const r=await call(root+'/tasks','POST',{revision:rev,targetId:'script'},identities[0]);assert.equal(r.status,201,await r.clone().text());return (await r.json()).id as string} + const first=await queue(2); + assert.equal((await call(root+'/tasks','POST',{revision:2,targetId:'script'},identities[0])).status,409,'Only one live task per target'); + const recipe=await (await call(root+'/tasks/'+first,'GET',undefined,agent)).json();assert.equal(recipe.request.references[0].text,doc.cards[0].text);assert.equal(recipe.request.instruction,'写六个分镜'); + const result={status:'succeeded',result:{text:'第一镜:城市醒来。'}}; + assert.equal((await call(root+'/tasks/'+first,'PATCH',result,agent)).status,409,'Result cannot arrive before claim'); + assert.equal((await call(root+'/tasks/'+first,'PATCH',{status:'running'},agent)).status,200); + assert.equal((await call(root,'PUT',{revision:2,document:{...doc,cards:doc.cards.map(n=>({...n,x:n.x+10}))}},identities[0])).status,200); + assert.equal((await call(root+'/tasks/'+first,'PATCH',result,agent)).status,409,'Stale result must not overwrite newer user edits'); + assert.equal((await call(root+'/tasks/'+first,'PATCH',{status:'failed',error:'依据新版本重新派发'},identities[0])).status,200); + const second=await queue(3);assert.equal((await call(root+'/tasks/'+second,'PATCH',{status:'running'},agent)).status,200); + assert.equal((await call(root+'/tasks/'+second,'PATCH',result,agent)).status,200); + assert.equal((await call(root+'/tasks/'+second,'PATCH',result,agent)).status,200,'Identical completion is idempotent'); + assert.equal((await call(root+'/tasks/'+second,'PATCH',{status:'succeeded',result:{text:'覆盖'}},agent)).status,409); + const final=await (await call(root,'GET',undefined,identities[0])).json();assert.equal(final.revision,4);assert.equal(final.document.cards.find((n:{id:string})=>n.id==='script').text,'第一镜:城市醒来。');assert.deepEqual(final.document.links,doc.links); + assert.equal((await call(root+'/link','POST',{},agent)).status,403,'A delegated agent cannot mint links'); + assert.equal((await call(root+'/link','DELETE',{},identities[0])).status,200); + assert.equal((await call(root,'GET',undefined,agent)).status,404,'Revocation takes effect on the next request'); + const docs=await call(root+'?agent_mode=True');assert.equal(docs.status,200);assert.match(await docs.text(),/Authorization/); +} diff --git a/apps/waterfall-wiki/tests/integration.test.ts b/apps/waterfall-wiki/tests/integration.test.ts index 256b88a..70a3083 100644 --- a/apps/waterfall-wiki/tests/integration.test.ts +++ b/apps/waterfall-wiki/tests/integration.test.ts @@ -1,3 +1,4 @@ +import {verifyCanvasWorkspace} from './canvas-integration'; import assert from 'node:assert/strict'; import {test} from 'node:test'; import {spawn} from 'node:child_process'; @@ -33,6 +34,7 @@ test('real PostgreSQL + S3 notes, flat tags, resource sharing and video processi for(const route of ['/','/images','/videos','/audio','/articles','/motion','/library/import','/tools/doodle','/tools/skills','/tools/skills/street-people','/tools/skills/shadow-puppet'])assert.equal((await fetch(base+route)).status,200,route); await verifyEmailAccounts(); await verifyGuidancePublishing(base); + await verifyCanvasWorkspace(base); const workspaceUI=spawn(process.execPath,['--import','tsx','--test','tests/guidance-workspace.test.ts'],{env:{...process.env,WORKSPACE_TEST_URL:base},timeout:90000,stdio:'inherit'}); const [workspaceExit]=await once(workspaceUI,'exit');assert.equal(workspaceExit,0,'SaaS workspace UI regression checks'); await verifyFlatNoteMigration(); diff --git a/scripts/check-studio-contracts.mjs b/scripts/check-studio-contracts.mjs index 73e75fb..e39543f 100644 --- a/scripts/check-studio-contracts.mjs +++ b/scripts/check-studio-contracts.mjs @@ -2,7 +2,7 @@ import {readFile} from 'node:fs/promises'; import assert from 'node:assert/strict'; const root=new URL('../',import.meta.url),read=path=>readFile(new URL(path,root),'utf8'); -for(const file of ['layout-parameters.ts','series.ts'])assert.equal(await read('apps/studio/src/lib/'+file),await read('apps/waterfall-wiki/src/lib/'+file),file+' contract drift'); +for(const file of ['layout-parameters.ts','series.ts','canvas-document.ts'])assert.equal(await read('apps/studio/src/lib/'+file),await read('apps/waterfall-wiki/src/lib/'+file),file+' contract drift'); const canonical=await read('content/studio-examples/autumn-journal.json');for(const app of ['studio','waterfall-wiki'])assert.equal(canonical,await read('apps/'+app+'/fixtures/autumn-journal.json'),'Example fixture drift'); const doc=await read('apps/waterfall-wiki/src/lib/studio-design-documentation.ts'),encoded=doc.match(/export const designDocs=(.*);\n/); assert.ok(encoded);assert.equal(JSON.parse(encoded[1]),(await read('content/studio-tools/layouts-api.md')).replace('{{LAYOUTS}}','读取 /studio/api/layouts 获取全部27套构图。'),'Wiki/agent documentation drift');