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..ea23d9d
--- /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, CHECK(id LIKE 'user-' || owner_id::text || '-%'));
+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/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/components/GuidanceShell.tsx b/apps/waterfall-wiki/src/components/GuidanceShell.tsx
index 9f88625..bcc5985 100644
--- a/apps/waterfall-wiki/src/components/GuidanceShell.tsx
+++ b/apps/waterfall-wiki/src/components/GuidanceShell.tsx
@@ -1,4 +1,4 @@
import Link from 'next/link';
import type {ReactNode} from 'react';
import s from '@/styles/guidance.module.css';
-export default function GuidanceShell({children}:{children:ReactNode}){return
↗ 织页 实验版 探索文档 协议草案 登录 / 注册 ↗ {children}织页 · Linked Guidance · 名称与协议仍在探索中 让每一页都能成为起点 ↗
}
+export default function GuidanceShell({children}:{children:ReactNode}){return ↗ 织页 实验版 探索文档 用户文档 发布文档 协议草案 登录 / 注册 ↗ {children}织页 · Linked Guidance · 名称与协议仍在探索中 让每一页都能成为起点 ↗
}
diff --git a/apps/waterfall-wiki/src/content/guides/catalog.ts b/apps/waterfall-wiki/src/content/guides/catalog.ts
index c172aee..f58e68b 100644
--- a/apps/waterfall-wiki/src/content/guides/catalog.ts
+++ b/apps/waterfall-wiki/src/content/guides/catalog.ts
@@ -7,4 +7,4 @@ export const guides:Guidance[]=[
];
export function findGuide(id:string,revision?:number){return guides.find(g=>g.id===id&&(revision===undefined||g.revision===revision));}
const esc=(s:string)=>s.replace(/[<>&"']/g,c=>({'<':'<','>':'>','&':'&','"':'"',"'":'''}[c]!));
-export function guidanceXml(g:Guidance){const t=(tag:string,s:string)=>` <${tag}>${esc(s)}${tag}>`;return `\n\n${[t('title',g.title),t('summary',g.summary),t('applies',g.applies),t('body',g.body),...g.steps.map(s=>t('step',s)),...g.links.map(l=>` ${esc(l.label)}`),...g.checks.map(s=>t('check',s))].join('\n')}\n \n`;}
+export function guidanceXml(g:Guidance){const t=(tag:string,s:string)=>` <${tag}>${esc(s)}${tag}>`;return `\n\n${[t('title',g.title),t('summary',g.summary),t('applies',g.applies),t('body',g.body),...g.steps.map(s=>t('step',s)),...g.links.map(l=>` ${esc(l.label)}`),...g.checks.map(s=>t('check',s)),...g.tags.map(s=>t('tag',s))].join('\n')}\n \n`;}
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..fbadc6c
--- /dev/null
+++ b/apps/waterfall-wiki/src/lib/guidance-input.ts
@@ -0,0 +1,22 @@
+import { z } from 'zod';
+const kind=z.enum(['guide','context','evidence']);
+export const validXmlText=(value:string)=>!/[\u0000-\u0008\u000b\u000c\u000e-\u001f\ufffe\uffff]/.test(value)&&!/[\ud800-\udfff]/u.test(value);
+const text=z.string().refine(validXmlText,'非法 XML 字符').trim().min(1).max(20000);
+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(!validXmlText(source)||source.includes(']]>'))throw Error('非法 XML 字符');
+ if(Buffer.byteLength(source)>131072)throw Error('XML 过大');
+ const trimXml=(v:string)=>v.replace(/^[ \t\r\n]+|[ \t\r\n]+$/g,'');
+ let s=source.replace(/^<\?xml version="1.0" encoding="UTF-8"\?>[ \t\r\n]*/, '');
+ 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 'amp'|'lt'|'gt'|'quot'|'apos'] as string));};
+ const attrs=(v:string)=>{const result:Record={};while(trimXml(v)){const m=/^[ \t\r\n]+([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>[ \t\r\n]*$/.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(trimXml(s)){const m=/^[ \t\r\n]*<(title|summary|applies|body|step|check|tag|link)((?:[ \t\r\n]+[a-z]+="[^"<]*")*[ \t\r\n]*)>([^<]*)<\/\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'||tag==='tag')(result[tag==='step'?'steps':tag==='tag'?'tags':'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..590d37a
--- /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);if(!content.id.startsWith('user-'+owner+'-'))throw Error('INVALID_ID');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..26e03b7
--- /dev/null
+++ b/apps/waterfall-wiki/src/pages/api/documents/[[...path]].ts
@@ -0,0 +1,27 @@
+import { ZodError } from 'zod';
+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){if(e instanceof ZodError)return res.status(400).json({error:'输入不符合协议,请检查以下字段',fields:e.issues.slice(0,12).map(i=>i.path.join('.')||'文档')});const message=e instanceof Error?e.message:'';if(message==='INVALID_ID')return res.status(400).json({error:'文档 ID 必须以 user-你的账户ID- 开头,请使用编辑器生成的前缀'});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..8fa2407
--- /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={canEdit:boolean;versions:number[];doc:Guidance;links:(Guidance['links'][number]&{resolved:boolean})[]};
+export default function Read({doc,links,canEdit,versions}:Props){return 用户文档 · {canEdit?'编辑我的文档':'发布文档'} {doc.title} {doc.kind} · {doc.id} · 固定版本 {doc.revision}
版本:{versions.map(v=> v{v} )}
{doc.summary}
适用条件 {doc.applies}
{doc.body}
{doc.steps.map((s,i)=>{s} )} 条件链接 {links.map((l,i)=>条件:{l.when} · {l.relation}
{l.resolved?{l.label} · v{l.revision} :{l.label}:精确版本缺失,请跳过
} )}检查项 {doc.checks.map((s,i)=>{s} )} 读取规范 XML {canEdit&&{if(!confirm('撤下后仍保留固定历史版本,确定撤下?'))return;const r=await fetch('/api/documents/'+doc.id,{method:'DELETE'});alert(r.ok?'已撤下,历史版本仍可访问':(await r.json()).error);}}>作者撤下文档
} ;}
+export const getServerSideProps:GetServerSideProps=async({params,query,res,req})=>{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)})));const {db}=await import('@/lib/db');const {currentAccount}=await import('@/lib/account');const user=await currentAccount(new Request('http://internal',{headers:{cookie:req.headers.cookie||''}}));const owner=await db().query('SELECT owner_id FROM guidance_documents WHERE id=$1',[doc.id]);const rows=await db().query('SELECT revision FROM guidance_revisions WHERE document_id=$1 ORDER BY revision DESC',[doc.id]);return {props:{doc,links,canEdit:!!user&&owner.rows[0]?.owner_id===user.id,versions:rows.rows.map(r=>r.revision)}};};
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.kind} · v{d.revision}
{d.summary}
)} ;}
diff --git a/apps/waterfall-wiki/src/pages/index.tsx b/apps/waterfall-wiki/src/pages/index.tsx
index a0943b7..200d64f 100644
--- a/apps/waterfall-wiki/src/pages/index.tsx
+++ b/apps/waterfall-wiki/src/pages/index.tsx
@@ -1,7 +1,7 @@
import Head from 'next/head';
import Link from 'next/link';
-import {useState} from 'react';
+import {useEffect,useState} from 'react';
import GuidanceShell from '@/components/GuidanceShell';
import {guides} from '@/content/guides/catalog';
import s from '@/styles/guidance.module.css';
-export default function Home(){const[q,setQ]=useState(''),[kind,setKind]=useState('all');const items=guides.filter(g=>(kind==='all'||g.kind===kind)&&`${g.title} ${g.summary} ${g.tags.join(' ')}`.toLowerCase().includes(q.toLowerCase()));return 织页 · 为 Agent 找到下一页
从这里开始 {items.length} 篇 协议样例 · v0.1 {items.map(g=>
{g.kind==='guide'?'↗':'≋'}
织页 / protocol-examples · r{g.revision}
{g.title}{g.summary}
{g.tags.map(t=>{t} )}↗ {g.links.length} 个关联
)}{!items.length&&
没有匹配文档,请尝试其他关键词或分类。
}
DOCUMENTS, CONNECTED. 知识不必 打包带走。 读一页,解决眼前的问题。 需要更多时,再打开下一页。
了解文档协议 ↗
为两种 Agent 而写 Coding agent 的工作方法,Personal agent 的日常指导,都可以从一篇文档开始。
这是一个早期原型 当前展示协议样例。发布、收藏与社区协作将在真实账户和版本系统上逐步开放。
}
+export default function Home(){const [published,setPublished]=useState<{id:string;revision:number;title:string;summary:string;kind:string}[]>([]),[loadError,setLoadError]=useState('');useEffect(()=>{fetch('/api/documents').then(async r=>{const b=await r.json();if(!r.ok)throw Error(b.error);setPublished(b.documents);}).catch(e=>setLoadError(e.message));},[]);const[q,setQ]=useState(''),[kind,setKind]=useState('all');const items=guides.filter(g=>(kind==='all'||g.kind===kind)&&`${g.title} ${g.summary} ${g.tags.join(' ')}`.toLowerCase().includes(q.toLowerCase()));return 织页 · 为 Agent 找到下一页
用户发布 发布一篇文档 ↗{loadError&&{loadError}
}{published.filter(g=>(kind==='all'||g.kind===kind)&&`${g.title} ${g.summary}`.toLowerCase().includes(q.toLowerCase())).map(g=>
↗
{g.id} · {g.kind} · v{g.revision}
{g.title}{g.summary}
)}
从这里开始 {items.length} 篇 协议样例 · v0.1 {items.map(g=>
{g.kind==='guide'?'↗':'≋'}
织页 / protocol-examples · r{g.revision}
{g.title}{g.summary}
{g.tags.map(t=>{t} )}↗ {g.links.length} 个关联
)}{!items.length&&
没有匹配文档,请尝试其他关键词或分类。
}
DOCUMENTS, CONNECTED. 知识不必 打包带走。 读一页,解决眼前的问题。 需要更多时,再打开下一页。
了解文档协议 ↗
为两种 Agent 而写 Coding agent 的工作方法,Personal agent 的日常指导,都可以从一篇文档开始。
这是一个早期原型 已验证邮箱用户可以发布独立文档,更新会创建不可变版本。下方协议样例与真实用户文档分别展示。
}
diff --git a/apps/waterfall-wiki/src/pages/write.tsx b/apps/waterfall-wiki/src/pages/write.tsx
new file mode 100644
index 0000000..e42290f
--- /dev/null
+++ b/apps/waterfall-wiki/src/pages/write.tsx
@@ -0,0 +1,14 @@
+import { useEffect,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 [busy,setBusy]=useState(false),[published,setPublished]=useState<{id:string;revision:number}|null>(null);
+ 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);
+ useEffect(()=>{setPreview(null);},[doc,xml,mode]);
+ useEffect(()=>{fetch('/api/account/session').then(r=>r.json()).then(b=>{if(b.user){const id=new URLSearchParams(window.location.search).get('id');if(id)fetch('/api/documents/'+encodeURIComponent(id)).then(responseBody).then(b=>setDoc({...b.document,revision:b.document.revision+1})).catch(e=>setMessage(e.message));else setDoc(d=>({...d,id:'user-'+b.user.id+'-'}));}else setMessage('请先登录并验证邮箱,再发布文档。');}).catch(()=>setMessage('账户服务暂不可用,请稍后重试。'));},[]);
+ async function responseBody(r:Response){const b=await r.json().catch(()=>({error:'服务返回异常,请稍后重试'}));if(!r.ok)throw Error((b.error||'请求失败')+(b.fields?.length?':'+b.fields.join('、'):''));return b;}
+ async function load(){setBusy(true);setPreview(null);try{const b=await responseBody(await fetch('/api/documents/'+encodeURIComponent(doc.id)));setDoc({...b.document,revision:b.document.revision+1});setMessage('已加载,保存将创建新版本');}catch(e){setMessage(e instanceof Error?e.message:'加载失败');}finally{setBusy(false);}}
+ async function submit(action:string){setBusy(true);setMessage('处理中…');setPreview(null);setPublished(null);try{const b=await responseBody(await fetch('/api/documents'+(action==='preview'?'/preview':''),{method:'POST',headers:{'Content-Type':mode?'application/xml':'application/json'},body:mode?xml:JSON.stringify(doc)}));if(action==='preview'){setPreview(b);setMessage('预览成功,尚未发布');}else{setDoc({...b.document,revision:b.document.revision+1});setPublished({id:b.document.id,revision:b.document.revision});if(mode)setXml(xml.replace(/revision="[1-9][0-9]*"/,`revision="${b.document.revision+1}"`));setMessage('已发布版本 '+b.document.revision);}}catch(e){setMessage(e instanceof Error?e.message:'请求失败,请稍后重试');}finally{setBusy(false);}}
+ return 首页 · 用户文档 · 邮箱账户 发布独立文档 需要已验证邮箱。每次发布创建不可变版本;只有原作者可以更新或撤下。
{setMode(e.target.checked);setPreview(null);}}/> 使用协议 XML(zh-CN) {mode?XML 输入 :<>文档 ID(保留账户前缀,在末尾填写短名) setDoc({...doc,id:e.target.value})}/> 加载最新版本以编辑 待发布版本 setDoc({...doc,revision:Number(e.target.value)})}/> 种类setDoc({...doc,kind:e.target.value as Guidance['kind']})}>guide context evidence {(['title','summary','applies','body'] as const).map(k=>{{title:'标题',summary:'摘要',applies:'适用条件',body:'正文'}[k]} )}{(['steps','checks'] as const).map(k=>{k==='steps'?'步骤':'检查项'}(每行一项) )}条件链接 {doc.links.map((l,i)=>{(['target','revision','relation','when','label'] as const).map(k=>{k} setDoc({...doc,links:doc.links.map((v,j)=>j===i?{...v,[k]:k==='revision'?Number(e.target.value):e.target.value}:v)})}/> )}setDoc({...doc,links:doc.links.filter((_,j)=>j!==i)})}>移除链接 )}setDoc({...doc,links:[...doc.links,{target:'',revision:1,relation:'context',when:'',label:''}]})}>添加精确版本链接 >}submit('preview')}>校验并预览 submit('publish')}>发布新版本
{message}
{published&&打开已发布文档 v{published.revision} ↗
}{preview&&{preview.document.title} {preview.document.summary}
适用:{preview.document.applies}
{preview.document.body}
{preview.document.steps.map((s,i)=>{i+1}. {s}
)}{preview.links.map((l,i)=>{l.label} · 条件:{l.when} · {l.resolved?'精确版本可用':'目标缺失,读取时需跳过'}
)}{preview.xml} } ;
+}
diff --git a/apps/waterfall-wiki/src/proxy.ts b/apps/waterfall-wiki/src/proxy.ts
index 65450ad..52e9b70 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 === '/' || path === '/protocol' || path.startsWith('/guide/') || path.startsWith('/api/guides/'));
- if (publicRead || path === '/account' || path.startsWith('/api/account/')) return NextResponse.next({headers:{...responseHeaders,'Referrer-Policy':'no-referrer'}});
+ 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 === '/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/src/styles/documents.module.css b/apps/waterfall-wiki/src/styles/documents.module.css
new file mode 100644
index 0000000..626814a
--- /dev/null
+++ b/apps/waterfall-wiki/src/styles/documents.module.css
@@ -0,0 +1 @@
+.page{max-width:850px;margin:0 auto;padding:40px 24px 100px;color:#202326;font-family:system-ui,sans-serif;line-height:1.7}.page nav{margin-bottom:32px}.page a{color:#276750}.page h1{font-size:36px;letter-spacing:-1px}.page label{display:block;margin:18px 0}.page input:not([type=checkbox]),.page textarea,.page select{display:block;width:100%;padding:12px;border:1px solid #d8ded8;border-radius:8px;background:#fff;color:inherit;font:inherit;box-sizing:border-box}.page button{padding:10px 18px;background:#264e40;color:#fff;border:0;border-radius:7px;cursor:pointer}.page article{border-top:1px solid #d8ded8;padding:22px 0}.page pre{white-space:pre-wrap;overflow-wrap:anywhere;background:#f1f4ef;padding:18px}.body{white-space:pre-wrap}.page fieldset{border:1px solid #d8ded8;border-radius:8px;margin:14px 0}
diff --git a/apps/waterfall-wiki/tests/auth.test.ts b/apps/waterfall-wiki/tests/auth.test.ts
index a3b8114..ef5e33d 100644
--- a/apps/waterfall-wiki/tests/auth.test.ts
+++ b/apps/waterfall-wiki/tests/auth.test.ts
@@ -36,7 +36,7 @@ test('proxy protects APIs, data routes, static content and Studio; fails closed'
const config=newAuthConfig(); process.env.AUTH_CONFIG_FILE=join(temp,'config.json'); writeFileSync(process.env.AUTH_CONFIG_FILE,JSON.stringify(config));
for(const path of ['/library/import','/skills/example.png','/_next/data/build/index.json','/studio']) assert.equal(proxy(new NextRequest('http://wiki.test'+path)).status,307,path);
for(const path of ['/api/upload','/studio/api/export','/api/feed']) assert.equal(proxy(new NextRequest('http://wiki.test'+path)).status,401,path);
- for(const path of ['/','/guide/welcome','/protocol','/api/guides/welcome','/account','/api/account/session','/login','/brand-mark.svg','/_next/static/chunk.js','/api/health','/api/ready']) assert.equal(proxy(new NextRequest('http://wiki.test'+path)).status,200,path);
+ for(const path of ['/','/write','/documents','/api/documents','/api/documents/preview','/guide/welcome','/protocol','/api/guides/welcome','/account','/api/account/session','/login','/brand-mark.svg','/_next/static/chunk.js','/api/health','/api/ready']) assert.equal(proxy(new NextRequest('http://wiki.test'+path)).status,200,path);
const headers={cookie:'prompt_session='+signSession(config),host:'wiki.test'};
assert.equal(proxy(new NextRequest('http://wiki.test/api/feed',{headers})).status,200);
assert.equal(proxy(new NextRequest('http://wiki.test/api/posts',{method:'POST',headers:{...headers,origin:'http://evil.test'}})).status,403);
diff --git a/apps/waterfall-wiki/tests/guidance-integration.ts b/apps/waterfall-wiki/tests/guidance-integration.ts
new file mode 100644
index 0000000..b7bf9e6
--- /dev/null
+++ b/apps/waterfall-wiki/tests/guidance-integration.ts
@@ -0,0 +1,48 @@
+import assert from 'node:assert/strict';
+import {chromium} from 'playwright-core';
+import {randomUUID} from 'node:crypto';
+import {newToken,tokenDigest,ACCOUNT_COOKIE} from '../src/lib/account-crypto';
+import {guidanceXml} from '../src/content/guides/catalog';
+import {db} from '../src/lib/db';
+import {publishDocument,resolveDocument,withdrawDocument,discoverDocuments} from '../src/lib/guidance-store';
+export async function verifyGuidancePublishing(base:string){
+ const owner=randomUUID(),other=randomUUID(),id='user-'+owner+'-document';
+ for(const uid of [owner,other])await db().query("INSERT INTO account_users(id,email,password_hash,verified_at) VALUES($1,$2,'test',now())",[uid,uid+'@example.com']);
+ const doc={id,revision:1,kind:'context',title:'实际数据库文档',summary:'摘要',applies:'条件',body:'正文',steps:[],checks:[],links:[],tags:[]};
+ await assert.rejects(()=>publishDocument(other,doc),/INVALID_ID/);
+ await publishDocument(other,{...doc,id:'user-'+other+'-document'});
+ await publishDocument(owner,doc);assert.equal((await resolveDocument(id,1))?.title,doc.title);
+ await assert.rejects(()=>publishDocument(other,{...doc,revision:2}),/INVALID_ID/);
+ const concurrent=await Promise.allSettled([publishDocument(owner,{...doc,revision:2,body:'新正文'}),publishDocument(owner,{...doc,revision:2})]);assert.equal(concurrent.filter(r=>r.status==='fulfilled').length,1);
+ assert.equal((await resolveDocument(id,1))?.body,'正文');assert.equal(await resolveDocument(id,999),null);
+ await assert.rejects(()=>db().query('UPDATE guidance_revisions SET content=$2 WHERE document_id=$1',[id,{}]),/immutable/);
+ assert.equal(await withdrawDocument(other,id),false);assert.equal(await withdrawDocument(owner,id),true);assert.equal(await resolveDocument(id),null);assert.ok(await resolveDocument(id,1));assert.ok(!(await discoverDocuments()).some(d=>d.id===id));
+ const token=newToken();await db().query("INSERT INTO account_tokens(digest,user_id,kind,expires_at) VALUES($1,$2,'session',now()+interval '1 hour')",[tokenDigest(token),owner]);
+ const headers={'Content-Type':'application/json',origin:process.env.ACCOUNT_PUBLIC_ORIGIN||base,cookie:ACCOUNT_COOKIE+'='+token};
+ const httpDoc={...doc,id:'user-'+owner+'-http',tags:['往返标签']};
+ const request=(path:string,method:string,body?:unknown,extra:Record={})=>fetch(base+'/api/documents'+path,{method,headers:{...headers,...extra},...(body?{body:typeof body==='string'?body:JSON.stringify(body)}:{})});
+ assert.equal((await request('','POST',httpDoc,{cookie:''})).status,401);
+ assert.equal((await request('','POST',httpDoc,{origin:'https://untrusted.example'})).status,403);
+ const preview=await request('/preview','POST',httpDoc);assert.equal(preview.status,200,await preview.clone().text());assert.equal(await resolveDocument(httpDoc.id,1),null);
+ const created=await request('','POST',guidanceXml(httpDoc),{'Content-Type':'application/xml'});assert.equal(created.status,201,await created.clone().text());
+ assert.equal((await request('','POST',httpDoc)).status,409);
+ assert.equal((await request('','POST',{...httpDoc,id:'user-'+other+'-http'})).status,400);
+ const xml=await fetch(base+'/api/documents/'+httpDoc.id+'?revision=1&format=xml');assert.equal(xml.status,200);assert.match(await xml.text(),/往返标签<\/tag>/);
+ const page=await fetch(base+'/documents/'+httpDoc.id+'?revision=1');assert.equal(page.status,200);assert.ok((await page.text()).includes('实际数据库文档'));
+ // The homepage and discovery list hydrate from the real API, so verify them in Chromium on the CI runner.
+ const browser=await chromium.launch({executablePath:process.env.CHROMIUM_PATH||'/usr/bin/chromium',headless:true,args:['--no-sandbox','--disable-dev-shm-usage']});
+ try{
+ const page=await browser.newPage();
+ const link=`a[href="/documents/${httpDoc.id}?revision=1"]`;
+ await page.goto(base+'/',{waitUntil:'domcontentloaded'});
+ await page.locator(link).waitFor({state:'visible'});assert.equal(await page.locator(link).textContent(),httpDoc.title);
+ await page.locator('header a[href="/documents"]').click();await page.locator(link).waitFor({state:'visible'});
+ await page.locator(link).click();await page.locator('h1').waitFor();assert.equal(await page.locator('h1').textContent(),httpDoc.title);
+ await page.goto(base+'/write',{waitUntil:'domcontentloaded'});assert.equal(await page.locator('h1').textContent(),'发布独立文档');
+ }finally{await browser.close();}
+ assert.equal((await request('/'+httpDoc.id,'DELETE',undefined,{cookie:''})).status,401);
+ assert.equal((await request('/'+httpDoc.id,'DELETE')).status,200);
+ assert.equal((await fetch(base+'/api/documents/'+httpDoc.id)).status,404);
+ assert.equal((await fetch(base+'/api/documents/'+httpDoc.id+'?revision=1')).status,200);
+
+}
diff --git a/apps/waterfall-wiki/tests/guidance-publishing.test.ts b/apps/waterfall-wiki/tests/guidance-publishing.test.ts
new file mode 100644
index 0000000..6661976
--- /dev/null
+++ b/apps/waterfall-wiki/tests/guidance-publishing.test.ts
@@ -0,0 +1,10 @@
+import {test} from 'node:test';
+import assert from 'node:assert/strict';
+import {parseDocumentXml,documentInput} from '../src/lib/guidance-input';
+import {guidanceXml} from '../src/content/guides/catalog';
+const doc={id:'user-test',revision:1,kind:'guide' as const,title:'A & B',summary:'摘要',applies:'适用',body:'<正文>',steps:['一步'],checks:[],links:[],tags:[]};
+test('XML canonical round trip and strict rejection',()=>{const xml=guidanceXml(doc);assert.deepEqual(parseDocumentXml(xml),doc);for(const bad of [xml.replace('',''),xml.replace('