From d5807aa2988369982fa4fe394dd33990e32b9806 Mon Sep 17 00:00:00 2001 From: Codex Bot Date: Thu, 29 Oct 2026 16:47:21 +0800 Subject: [PATCH] Add persistent author-owned guidance publishing and immutable revisions --- .../db/015_guidance_documents.sql | 7 +++++ apps/waterfall-wiki/node_modules | 1 + apps/waterfall-wiki/package.json | 2 +- apps/waterfall-wiki/src/lib/guidance-input.ts | 19 ++++++++++++++ apps/waterfall-wiki/src/lib/guidance-store.ts | 15 +++++++++++ .../src/pages/api/documents/[[...path]].ts | 26 +++++++++++++++++++ .../src/pages/documents/[id].tsx | 6 +++++ .../src/pages/documents/index.tsx | 3 +++ apps/waterfall-wiki/src/pages/write.tsx | 10 +++++++ apps/waterfall-wiki/src/proxy.ts | 2 +- .../src/styles/documents.module.css | 1 + .../tests/guidance-integration.ts | 15 +++++++++++ .../tests/guidance-publishing.test.ts | 6 +++++ apps/waterfall-wiki/tests/integration.test.ts | 2 ++ docs/linked-guidance/publishing.md | 12 +++++++++ 15 files changed, 125 insertions(+), 2 deletions(-) create mode 100644 apps/waterfall-wiki/db/015_guidance_documents.sql create mode 120000 apps/waterfall-wiki/node_modules create mode 100644 apps/waterfall-wiki/src/lib/guidance-input.ts create mode 100644 apps/waterfall-wiki/src/lib/guidance-store.ts create mode 100644 apps/waterfall-wiki/src/pages/api/documents/[[...path]].ts create mode 100644 apps/waterfall-wiki/src/pages/documents/[id].tsx create mode 100644 apps/waterfall-wiki/src/pages/documents/index.tsx create mode 100644 apps/waterfall-wiki/src/pages/write.tsx create mode 100644 apps/waterfall-wiki/src/styles/documents.module.css create mode 100644 apps/waterfall-wiki/tests/guidance-integration.ts create mode 100644 apps/waterfall-wiki/tests/guidance-publishing.test.ts create mode 100644 docs/linked-guidance/publishing.md diff --git a/apps/waterfall-wiki/db/015_guidance_documents.sql b/apps/waterfall-wiki/db/015_guidance_documents.sql new file mode 100644 index 0000000..e20ce10 --- /dev/null +++ b/apps/waterfall-wiki/db/015_guidance_documents.sql @@ -0,0 +1,7 @@ +BEGIN; +CREATE TABLE guidance_documents(id text PRIMARY KEY CHECK(id ~ '^user-[a-z0-9-]{1,74}$'), owner_id uuid NOT NULL REFERENCES account_users(id), latest integer NOT NULL DEFAULT 0, deleted_at timestamptz); +CREATE TABLE guidance_revisions(document_id text NOT NULL REFERENCES guidance_documents(id), revision integer NOT NULL CHECK(revision>0), content jsonb NOT NULL, created_at timestamptz NOT NULL DEFAULT now(), PRIMARY KEY(document_id,revision)); +CREATE FUNCTION guidance_immutable() RETURNS trigger LANGUAGE plpgsql AS $$ BEGIN RAISE EXCEPTION 'Published revisions are immutable'; END $$; +CREATE TRIGGER guidance_immutable BEFORE UPDATE OR DELETE ON guidance_revisions FOR EACH ROW EXECUTE FUNCTION guidance_immutable(); +INSERT INTO schema_migrations(version) VALUES('015_guidance_documents'); +COMMIT; diff --git a/apps/waterfall-wiki/node_modules b/apps/waterfall-wiki/node_modules new file mode 120000 index 0000000..473f58b --- /dev/null +++ b/apps/waterfall-wiki/node_modules @@ -0,0 +1 @@ +/Users/zzyang/ProjectMedia/probably-rendering-one-more-pixel-tonight/apps/waterfall-wiki/node_modules \ No newline at end of file diff --git a/apps/waterfall-wiki/package.json b/apps/waterfall-wiki/package.json index 80f258c..71b593c 100644 --- a/apps/waterfall-wiki/package.json +++ b/apps/waterfall-wiki/package.json @@ -10,7 +10,7 @@ "build": "next build && esbuild src/worker.ts --bundle --platform=node --format=cjs --external:pg-native --external:playwright-core --outfile=dist/worker.cjs && esbuild scripts/bootstrap.ts --bundle --platform=node --format=cjs --external:pg-native --external:playwright-core --outfile=dist/bootstrap.cjs && esbuild scripts/publish-learning-series.ts --bundle --platform=node --format=cjs --external:pg-native --external:playwright-core --outfile=dist/publish-learning-series.cjs && esbuild scripts/auth-cli.ts --bundle --platform=node --format=cjs --external:pg-native --outfile=dist/auth-cli.cjs && node scripts/prepare-standalone.mjs", "start": "next start", "typecheck": "next typegen && tsc --noEmit", - "test": "tsx --test tests/api-documentation.test.ts tests/health.test.ts tests/model.test.ts tests/learning-series.test.ts tests/skill-docs.test.ts tests/wiki.test.ts tests/explore.test.ts tests/preview.test.ts tests/note-markdown.test.tsx tests/api-publication.test.ts tests/discovery.test.ts tests/auth.test.ts tests/account.test.ts tests/guidance.test.ts", + "test": "tsx --test tests/api-documentation.test.ts tests/health.test.ts tests/model.test.ts tests/learning-series.test.ts tests/skill-docs.test.ts tests/wiki.test.ts tests/explore.test.ts tests/preview.test.ts tests/note-markdown.test.tsx tests/api-publication.test.ts tests/discovery.test.ts tests/auth.test.ts tests/account.test.ts tests/guidance.test.ts tests/guidance-publishing.test.ts", "test:smoke": "node scripts/smoke.mjs", "storage:prepare": "tsx scripts/bootstrap.ts", "content:publish-learning": "tsx scripts/publish-learning-series.ts", diff --git a/apps/waterfall-wiki/src/lib/guidance-input.ts b/apps/waterfall-wiki/src/lib/guidance-input.ts new file mode 100644 index 0000000..7c320cc --- /dev/null +++ b/apps/waterfall-wiki/src/lib/guidance-input.ts @@ -0,0 +1,19 @@ +import { z } from 'zod'; +const kind=z.enum(['guide','context','evidence']); +const text=z.string().trim().min(1).max(20000).refine(s=>!/[\u0000-\u0008\u000b\u000c\u000e-\u001f\ufffe\uffff]/.test(s),'非法 XML 字符'); +export const documentInput=z.object({id:z.string().regex(/^user-[a-z0-9-]{1,74}$/),revision:z.number().int().min(1).max(2147483647),kind,title:text.max(200),summary:text.max(1000),applies:text,body:text,steps:z.array(text).max(100),checks:z.array(text).max(100),links:z.array(z.object({target:z.string().regex(/^[a-z0-9-]{1,79}$/),revision:z.number().int().min(1).max(2147483647),relation:kind,when:text.max(1000),label:text.max(200)}).strict()).max(100),tags:z.array(text.max(80)).max(20)}).strict(); +// Deliberately narrow XML 1.0 grammar: no DTD, comments, CDATA, nested markup or arbitrary entities. +export function parseDocumentXml(source:string){ + if(Buffer.byteLength(source)>131072)throw Error('XML 过大'); + let s=source.replace(/^<\?xml version="1.0" encoding="UTF-8"\?>\s*/, ''); + if(/<\?|{if(/&(?!amp;|lt;|gt;|quot;|apos;)/.test(v))throw Error('非法实体');return v.replace(/&(amp|lt|gt|quot|apos);/g,(_,k)=>({amp:'&',lt:'<',gt:'>',quot:'"',apos:"'"}[k] as string));}; + const attrs=(v:string)=>{const result:Record={};while(v.trim()){const m=/^\s+([a-z]+)="([^"<]*)"/.exec(v);if(!m||m[1] in result)throw Error('非法或重复属性');result[m[1]]=decode(m[2]);v=v.slice(m[0].length);}return result;}; + const root=/^]*)>([\s\S]*)<\/guidance>\s*$/.exec(s);if(!root)throw Error('非法根元素');const a=attrs(root[1]); + if(Object.keys(a).sort().join(',')!=='id,kind,lang,revision,xmlns'||a.xmlns!=='urn:linked-guidance:0.1'||a.lang!=='zh-CN'||! /^[1-9][0-9]*$/.test(a.revision))throw Error('不支持的协议或属性'); + const result:Record={id:a.id,kind:a.kind,revision:Number(a.revision),steps:[],checks:[],links:[],tags:[]};s=root[2]; + while(s.trim()){const m=/^\s*<(title|summary|applies|body|step|check|link)([^>]*)>([^<]*)<\/\1>/.exec(s);if(!m)throw Error('非法子元素');const [,tag,raw,value]=m;const at=attrs(raw);const v=decode(value); + if(tag==='link'){if(Object.keys(at).sort().join(',')!=='relation,revision,target,when'||! /^[1-9][0-9]*$/.test(at.revision))throw Error('非法链接');(result.links as unknown[]).push({...at,revision:Number(at.revision),label:v});} + else {if(Object.keys(at).length)throw Error('非法属性');if(tag==='step'||tag==='check')(result[tag==='step'?'steps':'checks'] as string[]).push(v);else{if(tag in result)throw Error('重复元素');result[tag]=v;}}s=s.slice(m[0].length);} + return documentInput.parse(result); +} diff --git a/apps/waterfall-wiki/src/lib/guidance-store.ts b/apps/waterfall-wiki/src/lib/guidance-store.ts new file mode 100644 index 0000000..a792f6d --- /dev/null +++ b/apps/waterfall-wiki/src/lib/guidance-store.ts @@ -0,0 +1,15 @@ +import { db,transaction } from './db'; +import { documentInput } from './guidance-input'; +import { findGuide,type Guidance } from '../content/guides/catalog'; +export async function resolveDocument(id:string,revision?:number):Promise{ + if(!id.startsWith('user-'))return findGuide(id,revision)||null; + const r=await db().query(`SELECT r.content FROM guidance_documents d JOIN guidance_revisions r ON r.document_id=d.id AND r.revision=COALESCE($2,d.latest) WHERE d.id=$1 AND ($2::integer IS NOT NULL OR d.deleted_at IS NULL)`,[id,revision??null]);return r.rows[0]?.content||null; +} +export async function publishDocument(owner:string,value:unknown){const content=documentInput.parse(value);return transaction(async c=>{ + await c.query('INSERT INTO guidance_documents(id,owner_id) VALUES($1,$2) ON CONFLICT DO NOTHING',[content.id,owner]); + const d=(await c.query('SELECT * FROM guidance_documents WHERE id=$1 FOR UPDATE',[content.id])).rows[0]; + if(d.owner_id!==owner)throw Error('FORBIDDEN');if(d.deleted_at||content.revision!==d.latest+1)throw Error('CONFLICT'); + await c.query('INSERT INTO guidance_revisions(document_id,revision,content) VALUES($1,$2,$3)',[content.id,content.revision,content]);await c.query('UPDATE guidance_documents SET latest=$2 WHERE id=$1',[content.id,content.revision]);return content; + });} +export async function withdrawDocument(owner:string,id:string){const r=await db().query('UPDATE guidance_documents SET deleted_at=COALESCE(deleted_at,now()) WHERE id=$1 AND owner_id=$2 RETURNING id',[id,owner]);return !!r.rowCount;} +export async function discoverDocuments(){const r=await db().query(`SELECT d.id,d.latest AS revision,r.content->>'title' AS title,r.content->>'summary' AS summary,r.content->>'kind' AS kind,d.owner_id AS author FROM guidance_documents d JOIN guidance_revisions r ON r.document_id=d.id AND r.revision=d.latest WHERE d.deleted_at IS NULL ORDER BY r.created_at DESC LIMIT 100`);return r.rows;} diff --git a/apps/waterfall-wiki/src/pages/api/documents/[[...path]].ts b/apps/waterfall-wiki/src/pages/api/documents/[[...path]].ts new file mode 100644 index 0000000..4dcb7f1 --- /dev/null +++ b/apps/waterfall-wiki/src/pages/api/documents/[[...path]].ts @@ -0,0 +1,26 @@ +import type { NextApiRequest,NextApiResponse } from 'next'; +import { currentAccount } from '@/lib/account'; +import { trustedOrigin } from '@/lib/account-crypto'; +import { documentInput,parseDocumentXml } from '@/lib/guidance-input'; +import { discoverDocuments,publishDocument,resolveDocument,withdrawDocument } from '@/lib/guidance-store'; +import { guidanceXml } from '@/content/guides/catalog'; +export default async function handler(req:NextApiRequest,res:NextApiResponse){ + res.setHeader('Cache-Control','no-store');const path=req.query.path||[];if(!Array.isArray(path)||path.length>1)return res.status(404).end();const id=path[0]; + try{ + if(req.method==='GET'){ + if(!id)return res.json({documents:await discoverDocuments()}); + const revision=req.query.revision;if(revision!==undefined&&(typeof revision!=='string'||! /^[1-9][0-9]{0,9}$/.test(revision)||Number(revision)>2147483647))return res.status(400).json({error:'版本无效'}); + const doc=await resolveDocument(id,revision===undefined?undefined:Number(revision));if(!doc)return res.status(404).json({error:'文档或精确版本不存在'}); + if(req.query.format==='xml'){res.setHeader('Content-Type','application/xml; charset=utf-8');return res.send(guidanceXml(doc));}return res.json({document:doc});} + if(!['POST','DELETE'].includes(req.method||'')){res.setHeader('Allow','GET, POST, DELETE');return res.status(405).end();} + const request=new Request(new URL(req.url||'/',process.env.ACCOUNT_PUBLIC_ORIGIN||'http://'+req.headers.host),{headers:new Headers(Object.entries(req.headers).filter(([,v])=>typeof v==='string') as [string,string][])}); + if(!trustedOrigin(request))return res.status(403).json({error:'请求来源不受信任'}); + const user=await currentAccount(request);if(!user)return res.status(401).json({error:'请先登录已验证邮箱账户'}); + if(req.method==='DELETE')return id&&await withdrawDocument(user.id,id)?res.json({ok:true}):res.status(404).json({error:'无权删除或不存在'}); + const input=typeof req.body==='string'?parseDocumentXml(req.body):documentInput.parse(req.body); + if(id==='preview'){const links=await Promise.all(input.links.map(async l=>({...l,resolved:!!await resolveDocument(l.target,l.revision)})));return res.json({document:input,xml:guidanceXml(input),links});} + if(id&&id!==input.id)return res.status(400).json({error:'文档 ID 不匹配'}); + return res.status(201).json({document:await publishDocument(user.id,input)}); + }catch(e){const message=e instanceof Error?e.message:'';if(message==='FORBIDDEN')return res.status(403).json({error:'仅作者可编辑'});if(message==='CONFLICT'||(e as {code?:string}).code==='23505')return res.status(409).json({error:'版本冲突:请加载最新版本,已发布版本不可修改'});if(e instanceof Error&&(e.name==='ZodError'||/XML|实体|元素|属性|协议|链接/.test(message)))return res.status(400).json({error:'输入不符合协议'});return res.status(503).json({error:'文档服务暂不可用'});} +} +export const config={api:{bodyParser:{sizeLimit:'128kb'}}}; diff --git a/apps/waterfall-wiki/src/pages/documents/[id].tsx b/apps/waterfall-wiki/src/pages/documents/[id].tsx new file mode 100644 index 0000000..76c38cb --- /dev/null +++ b/apps/waterfall-wiki/src/pages/documents/[id].tsx @@ -0,0 +1,6 @@ +import type { GetServerSideProps } from 'next'; +import type { Guidance } from '@/content/guides/catalog'; +import styles from '@/styles/documents.module.css'; +type Props={doc:Guidance;links:(Guidance['links'][number]&{resolved:boolean})[]}; +export default function Read({doc,links}:Props){return

{doc.title}

{doc.kind} · {doc.id} · 固定版本 {doc.revision}

{doc.summary}

适用条件

{doc.applies}

{doc.body}

    {doc.steps.map((s,i)=>
  1. {s}
  2. )}

条件链接

{links.map((l,i)=>)}

检查项

    {doc.checks.map((s,i)=>
  • {s}
  • )}
读取规范 XML

;} +export const getServerSideProps:GetServerSideProps=async({params,query,res})=>{res.setHeader('Cache-Control','no-store');const revision=query.revision;if(revision!==undefined&&(typeof revision!=='string'||! /^[1-9][0-9]{0,9}$/.test(revision)||Number(revision)>2147483647))return {notFound:true};const {resolveDocument}=await import('@/lib/guidance-store');const doc=await resolveDocument(String(params?.id),revision===undefined?undefined:Number(revision));if(!doc)return {notFound:true};const links=await Promise.all(doc.links.map(async l=>({...l,resolved:!!await resolveDocument(l.target,l.revision)})));return {props:{doc,links}};}; diff --git a/apps/waterfall-wiki/src/pages/documents/index.tsx b/apps/waterfall-wiki/src/pages/documents/index.tsx new file mode 100644 index 0000000..53e3008 --- /dev/null +++ b/apps/waterfall-wiki/src/pages/documents/index.tsx @@ -0,0 +1,3 @@ +import { useEffect,useState } from 'react'; +import styles from '@/styles/documents.module.css'; +export default function Documents(){const [docs,setDocs]=useState<{id:string;revision:number;title:string;summary:string;kind:string}[]>([]),[error,setError]=useState('');useEffect(()=>{fetch('/api/documents').then(async r=>{const b=await r.json();if(!r.ok)throw Error(b.error);setDocs(b.documents);}).catch(e=>setError(e.message));},[]);return

用户发布的文档

{error}

{docs.map(d=>

{d.title}

{d.kind} · v{d.revision}

{d.summary}

)}
;} diff --git a/apps/waterfall-wiki/src/pages/write.tsx b/apps/waterfall-wiki/src/pages/write.tsx new file mode 100644 index 0000000..49668f5 --- /dev/null +++ b/apps/waterfall-wiki/src/pages/write.tsx @@ -0,0 +1,10 @@ +import { useState } from 'react'; +import type { Guidance } from '@/content/guides/catalog'; +import styles from '@/styles/documents.module.css'; +const blank:Guidance={id:'user-',revision:1,kind:'guide',title:'',summary:'',applies:'',body:'',steps:[],checks:[],links:[],tags:[]}; +export default function Write(){ + const [doc,setDoc]=useState(blank),[xml,setXml]=useState(''),[mode,setMode]=useState(false),[message,setMessage]=useState(''),[preview,setPreview]=useState<{document:Guidance;xml:string;links:{label:string;when:string;resolved:boolean}[]}|null>(null); + async function load(){const r=await fetch('/api/documents/'+encodeURIComponent(doc.id));const b=await r.json();if(r.ok){setDoc({...b.document,revision:b.document.revision+1});setPreview(null);setMessage('已加载,保存将创建新版本');}else setMessage(b.error);} + async function submit(action:string){setMessage('处理中…');const r=await fetch('/api/documents'+(action==='preview'?'/preview':''),{method:'POST',headers:{'Content-Type':mode?'application/xml':'application/json'},body:mode?xml:JSON.stringify(doc)});const b=await r.json();if(!r.ok)return setMessage(b.error);if(action==='preview'){setPreview(b);setMessage('预览成功,尚未发布');}else{setDoc({...b.document,revision:b.document.revision+1});setMessage('已发布版本 '+b.document.revision);setPreview(null);}} + return

发布独立文档

需要已验证邮箱。每次发布创建不可变版本;只有原作者可以更新或撤下。

{mode?