Add persistent author-owned guidance publishing and immutable revisions
This commit is contained in:
@@ -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;
|
||||
Symlink
+1
@@ -0,0 +1 @@
|
||||
/Users/zzyang/ProjectMedia/probably-rendering-one-more-pixel-tonight/apps/waterfall-wiki/node_modules
|
||||
@@ -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",
|
||||
|
||||
@@ -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(/<\?|<!/.test(s))throw Error('不支持 XML 声明或嵌入');
|
||||
const decode=(v:string)=>{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<string,string>={};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=/^<guidance(\s[^>]*)>([\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<string,unknown>={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);
|
||||
}
|
||||
@@ -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<Guidance|null>{
|
||||
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;}
|
||||
@@ -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'}}};
|
||||
@@ -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 <main className={styles.page}><nav><a href="/documents">用户文档</a> · <a href="/write">编辑 / 发布</a></nav><h1>{doc.title}</h1><p>{doc.kind} · {doc.id} · 固定版本 {doc.revision}</p><p>{doc.summary}</p><h2>适用条件</h2><p>{doc.applies}</p><p className={styles.body}>{doc.body}</p><ol>{doc.steps.map((s,i)=><li key={i}>{s}</li>)}</ol><h2>条件链接</h2>{links.map((l,i)=><article key={i}><p>条件:{l.when} · {l.relation}</p>{l.resolved?<a href={(l.target.startsWith('user-')?'/documents/':'/guide/')+l.target+'?revision='+l.revision}>{l.label} · v{l.revision}</a>:<p>{l.label}:精确版本缺失,请跳过</p>}</article>)}<h2>检查项</h2><ul>{doc.checks.map((s,i)=><li key={i}>{s}</li>)}</ul><a href={'/api/documents/'+doc.id+'?revision='+doc.revision+'&format=xml'}>读取规范 XML</a><p><button onClick={async()=>{if(!confirm('撤下后仍保留固定历史版本,确定撤下?'))return;const r=await fetch('/api/documents/'+doc.id,{method:'DELETE'});alert(r.ok?'已撤下,历史版本仍可访问':(await r.json()).error);}}>作者撤下文档</button></p></main>;}
|
||||
export const getServerSideProps:GetServerSideProps<Props>=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}};};
|
||||
@@ -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 <main className={styles.page}><nav><a href="/">首页</a> · <a href="/write">发布文档</a></nav><h1>用户发布的文档</h1><p>{error}</p>{docs.map(d=><article key={d.id}><h2><a href={'/documents/'+d.id+'?revision='+d.revision}>{d.title}</a></h2><p>{d.kind} · v{d.revision}</p><p>{d.summary}</p></article>)}</main>;}
|
||||
@@ -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 <main className={styles.page}><nav><a href="/">首页</a> · <a href="/documents">用户文档</a> · <a href="/account">邮箱账户</a></nav><h1>发布独立文档</h1><p>需要已验证邮箱。每次发布创建不可变版本;只有原作者可以更新或撤下。</p><label><input type="checkbox" checked={mode} onChange={e=>{setMode(e.target.checked);setPreview(null);}}/> 使用协议 XML(zh-CN)</label>{mode?<label>XML 输入<textarea rows={22} value={xml} onChange={e=>{setXml(e.target.value);setPreview(null);}}/></label>:<><label>文档 ID<input value={doc.id} onChange={e=>setDoc({...doc,id:e.target.value})}/></label><button onClick={load}>加载最新版本以编辑</button><label>待发布版本<input type="number" min={1} value={doc.revision} onChange={e=>setDoc({...doc,revision:Number(e.target.value)})}/></label><label>种类<select value={doc.kind} onChange={e=>setDoc({...doc,kind:e.target.value as Guidance['kind']})}><option>guide</option><option>context</option><option>evidence</option></select></label>{(['title','summary','applies','body'] as const).map(k=><label key={k}>{{title:'标题',summary:'摘要',applies:'适用条件',body:'正文'}[k]}<textarea value={doc[k]} onChange={e=>{setDoc({...doc,[k]:e.target.value});setPreview(null);}}/></label>)}{(['steps','checks'] as const).map(k=><label key={k}>{k==='steps'?'步骤':'检查项'}(每行一项)<textarea value={doc[k].join('\n')} onChange={e=>setDoc({...doc,[k]:e.target.value.split('\n').filter(Boolean)})}/></label>)}<h2>条件链接</h2>{doc.links.map((l,i)=><fieldset key={i}>{(['target','revision','relation','when','label'] as const).map(k=><label key={k}>{k}<input value={l[k]} onChange={e=>setDoc({...doc,links:doc.links.map((v,j)=>j===i?{...v,[k]:k==='revision'?Number(e.target.value):e.target.value}:v)})}/></label>)}<button onClick={()=>setDoc({...doc,links:doc.links.filter((_,j)=>j!==i)})}>移除链接</button></fieldset>)}<button onClick={()=>setDoc({...doc,links:[...doc.links,{target:'',revision:1,relation:'context',when:'',label:''}]})}>添加精确版本链接</button></>}<p><button onClick={()=>submit('preview')}>校验并预览</button> <button onClick={()=>submit('publish')}>发布新版本</button></p><p role="status">{message}</p>{preview&&<article><h2>{preview.document.title}</h2><p>{preview.document.summary}</p><p>适用:{preview.document.applies}</p><p className={styles.body}>{preview.document.body}</p>{preview.document.steps.map((s,i)=><p key={i}>{i+1}. {s}</p>)}{preview.links.map((l,i)=><p key={i}>{l.label} · 条件:{l.when} · {l.resolved?'精确版本可用':'目标缺失,读取时需跳过'}</p>)}<pre>{preview.xml}</pre></article>}</main>;
|
||||
}
|
||||
@@ -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}); }
|
||||
|
||||
@@ -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}
|
||||
@@ -0,0 +1,15 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import {randomUUID} from 'node:crypto';
|
||||
import {db} from '../src/lib/db';
|
||||
import {publishDocument,resolveDocument,withdrawDocument,discoverDocuments} from '../src/lib/guidance-store';
|
||||
export async function verifyGuidancePublishing(){
|
||||
const owner=randomUUID(),other=randomUUID(),id='user-'+randomUUID();
|
||||
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 publishDocument(owner,doc);assert.equal((await resolveDocument(id,1))?.title,doc.title);
|
||||
await assert.rejects(()=>publishDocument(other,{...doc,revision:2}),/FORBIDDEN/);
|
||||
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));
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
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('<title>','<title extra="1">'),xml.replace('</body>','</body><title>duplicate</title>'),xml.replace('urn:linked-guidance:0.1','urn:linked-guidance:0.2'),xml.replace('&','&unknown;'),xml.replace('<guidance','<!DOCTYPE guidance><guidance'),xml.replace('revision="1"','revision="1" revision="2"'),xml.replace('<body>','<body><script>')])assert.throws(()=>parseDocumentXml(bad));assert.throws(()=>documentInput.parse({...doc,admin:true}));assert.throws(()=>documentInput.parse({...doc,id:'review-changes'}));});
|
||||
@@ -12,6 +12,7 @@ import sharp from 'sharp';
|
||||
import {db} from '../src/lib/db';
|
||||
import {probe,run} from '../src/lib/probe';
|
||||
import seriesExample from '../fixtures/autumn-journal.json';
|
||||
import {verifyGuidancePublishing} from './guidance-integration';
|
||||
import {verifyEmailAccounts} from './account-integration';
|
||||
import {verifyFlatNoteMigration} from './flat-note-migration';
|
||||
import {staticEntries} from '../src/lib/explore';
|
||||
@@ -29,6 +30,7 @@ test('real PostgreSQL + S3 notes, flat tags, resource sharing and video processi
|
||||
let ready=false;for(let i=0;i<60;i++){try{if((await fetch(base+'/api/health')).ok){ready=true;break;}}catch{}await setTimeout(250);}assert.ok(ready);
|
||||
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();
|
||||
await verifyFlatNoteMigration();
|
||||
const root=await request('/api/tags',{name:'线条',slug:'lines'});const child=await request('/api/tags',{name:'白色',slug:'white'});
|
||||
const sameTag=await request('/api/tags',{name:'#线条'});assert.equal(sameTag.id,root.id,'Hashtag creation reuses the globally unique tag');
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
# 真实文档发布
|
||||
|
||||
本分支依赖 linked-guidance #7(ee69749)和 wiki-email-auth #6 后续安全修复 c27fa4f。先合并账户及协议依赖,再合并本 PR。使用现有 PostgreSQL、014 账户迁移后执行 015;无需 SMTP 变更。
|
||||
|
||||
- `/write`:已验证邮箱账户创建、加载并发布下一版本,支持结构化表单和受限 XML;预览通过 `/api/documents/preview` 校验并解析条件链接,不写数据库。
|
||||
- `/documents` 与 `/documents/:id?revision=N`:真实用户文档列表与固定版本阅读。主页可链接这两个入口,现有 `/guide` 可对 `user-` ID 委托 `resolveDocument` 或链接 `/documents/:id`。本 PR 未修改首页与原型阅读页,避免覆盖并行 UI 工作。
|
||||
- `/api/documents` GET 列表,POST 创建/发布;`/api/documents/:id` GET 单篇、POST 下一版、DELETE 作者撤下;`?format=xml&revision=N` 返回规范 XML。未指定版本为发现用途,实际采用需固定 revision。
|
||||
- `user-` 前缀独立于静态 catalog。条件链接可引用静态样例或真实文档;缺失版本明确展示,不自动请求外站、不静默换最新版。撤下后列表及 latest 不再展示,历史固定版本仍然公开可读。
|
||||
- 邮箱会话仅由新接口校验,不授予管理员 API 权限。发布是公开行为;目前没有草稿服务、私有文档、恢复撤下或账户删除功能。tags 是 JSON 元数据,XML 0.1 没有 tags 字段,XML 导入为无标签文档。
|
||||
- 数据库通过行锁分配下一版本并拒绝过时编辑,触发器禁止修改/删除已发布内容。数据库所有者仍可执行数据库维护,触发器不是对数据库管理员的安全隔离。
|
||||
|
||||
验证由 ces-control PR CI 执行:严格 XML 单元测试;PostgreSQL 真实发布、所有权、并发版本冲突、不可变内容和撤下保留历史的集成测试。PR 不自动部署,服务器视觉验收须主 chat 人工合并后的部署成功再执行。
|
||||
Reference in New Issue
Block a user