From 4dbd9eecb91fa42e6d031f8cab21295bcad4551d Mon Sep 17 00:00:00 2001 From: weichen Date: Sun, 13 Sep 2026 17:08:35 +0000 Subject: [PATCH] feat(web): fail-closed controlled save without hydrate ETag Reject revision-state network/5xx and missing ledger on controlled hydrate; never PATCH 0:0 unless the document is freeform. Co-authored-by: Cursor --- web/src/lib/controlledDocHydrate.test.ts | 48 +++++++++++++++++++++++- web/src/lib/controlledDocHydrate.ts | 19 +++++++--- web/src/lib/revisionEtag.test.ts | 25 +++++++++--- web/src/lib/revisionEtag.ts | 28 ++++++++------ 4 files changed, 97 insertions(+), 23 deletions(-) diff --git a/web/src/lib/controlledDocHydrate.test.ts b/web/src/lib/controlledDocHydrate.test.ts index 966b148f..81d3db16 100644 --- a/web/src/lib/controlledDocHydrate.test.ts +++ b/web/src/lib/controlledDocHydrate.test.ts @@ -60,8 +60,54 @@ describe('controlledDocHydrate', () => { assert.ok(ast); assert.equal(ast?.revisionEtag, '2:7'); const latestGet = { etag: '2:8' }; - const patchEtag = resolvePatchEtag({ revisionEtag: ast?.revisionEtag }); + const patchEtag = resolvePatchEtag({ + revisionEtag: ast?.revisionEtag, + governanceMode: 'controlled', + }); assert.equal(patchEtag, '2:7'); assert.notEqual(patchEtag, latestGet.etag); }); + + it('rejects hydrate when revision-state network or 5xx fails', async () => { + await assert.rejects( + () => + hydrateControlledDocumentContent('doc_proj56_intake', base, { + fetchContent: async () => ({ + doc_id: 'doc_proj56_intake', + version: 1, + content_sha256: 'abc', + markdown: '# 标题\n\n正文', + is_template_only: false, + }), + fetchRevisionState: async () => { + const err = new Error('revision-state unavailable') as Error & { status?: number }; + err.status = 502; + throw err; + }, + }), + (err: Error & { status?: number }) => err.status === 502, + ); + }); + + it('rejects controlled hydrate on NOT_FOUND or NEEDS_CONTENT', async () => { + const missingLedger = async () => { + const err = new Error('needs content') as Error & { code?: string }; + err.code = 'DOCUMENT_REVISION_NEEDS_CONTENT'; + throw err; + }; + await assert.rejects( + () => + hydrateControlledDocumentContent('doc_proj56_intake', base, { + fetchContent: async () => ({ + doc_id: 'doc_proj56_intake', + version: 1, + content_sha256: 'abc', + markdown: '# 标题\n\n正文', + is_template_only: false, + }), + fetchRevisionState: missingLedger, + }), + (err: Error & { code?: string }) => err.code === 'DOCUMENT_REVISION_NEEDS_CONTENT', + ); + }); }); diff --git a/web/src/lib/controlledDocHydrate.ts b/web/src/lib/controlledDocHydrate.ts index e8379d05..a2524053 100644 --- a/web/src/lib/controlledDocHydrate.ts +++ b/web/src/lib/controlledDocHydrate.ts @@ -37,6 +37,7 @@ export function buildControlledLoadErrorSkeleton(doc: DeliverableItem): Document reviewerId: doc.reviewerId, approver: doc.approver, approverId: doc.approverId, + dutiesAuthoritative: doc.dutiesAuthoritative ?? true, }); ast.governanceMode = 'controlled'; ast.editLease = doc.editLease; @@ -61,6 +62,7 @@ export function buildControlledLoadingSkeleton(doc: DeliverableItem): DocumentAS reviewerId: doc.reviewerId, approver: doc.approver, approverId: doc.approverId, + dutiesAuthoritative: doc.dutiesAuthoritative ?? true, }); ast.governanceMode = 'controlled'; ast.editLease = doc.editLease; @@ -89,9 +91,11 @@ export function buildControlledDocumentAST( reviewerId: doc.reviewerId, approver: doc.approver, approverId: doc.approverId, + dutiesAuthoritative: doc.dutiesAuthoritative ?? true, }); fullAST.governanceMode = 'controlled'; fullAST.editLease = doc.editLease; + fullAST.dutiesAuthoritative = doc.dutiesAuthoritative ?? true; return fullAST; } @@ -113,13 +117,16 @@ export async function hydrateControlledDocumentContent( ), ]); if (!detail?.markdown) return null; - const ast = buildControlledDocumentAST(detail.markdown, doc, detail.doc_id, detail.version); - if (rev.ok) { - ast.revisionEtag = revisionEtagFromHydrate(rev.st); - } else { - const err = rev.err as { code?: string }; - ast.revisionEtag = revisionEtagFromHydrate(null, err); + if (!rev.ok) { + throw rev.err instanceof Error ? rev.err : new Error('revision-state hydrate failed'); } + const etag = revisionEtagFromHydrate(rev.st); + if (!etag) { + throw new Error('受控文档打开时未取得修订基线,请重试'); + } + const ast = buildControlledDocumentAST(detail.markdown, doc, detail.doc_id, detail.version); + ast.revisionEtag = etag; + ast.dutiesAuthoritative = doc.dutiesAuthoritative ?? true; return ast; } diff --git a/web/src/lib/revisionEtag.test.ts b/web/src/lib/revisionEtag.test.ts index d0485098..5f82ab29 100644 --- a/web/src/lib/revisionEtag.test.ts +++ b/web/src/lib/revisionEtag.test.ts @@ -14,16 +14,31 @@ describe('revisionEtag', () => { assert.notEqual(patchEtag, latestFromGet.etag); }); - it('keeps 0:0 bootstrap when ledger is missing', () => { + it('does not invent 0:0 when controlled hydrate misses etag or errors', () => { + assert.equal(revisionEtagFromHydrate(null), undefined); assert.equal( revisionEtagFromHydrate(null, { code: 'DOCUMENT_REVISION_NEEDS_CONTENT' }), - '0:0', + undefined, ); assert.equal( - revisionEtagFromHydrate(null, { code: 'DOCUMENT_REVISION_NOT_FOUND' }), - '0:0', + revisionEtagFromHydrate(null, { code: 'DOCUMENT_REVISION_NOT_FOUND', status: 404 }), + undefined, ); + assert.equal(revisionEtagFromHydrate(null, { status: 502 }), undefined); + }); + + it('refuses controlled save without a hydrate ETag', () => { + assert.throws( + () => resolvePatchEtag({ governanceMode: 'controlled' }), + (err: Error & { code?: string }) => + err.code === 'DOCUMENT_REVISION_ETAG_MISSING' && + /打开时|基线|ETag/.test(err.message), + ); + assert.throws(() => resolvePatchEtag({ governanceMode: 'controlled', revisionEtag: '' })); + }); + + it('allows freeform bootstrap 0:0 only', () => { assert.equal(resolvePatchEtag({ governanceMode: 'freeform' }), '0:0'); - assert.equal(resolvePatchEtag({}), '0:0'); + assert.throws(() => resolvePatchEtag({})); }); }); diff --git a/web/src/lib/revisionEtag.ts b/web/src/lib/revisionEtag.ts index 3c29efb0..f8fe0f71 100644 --- a/web/src/lib/revisionEtag.ts +++ b/web/src/lib/revisionEtag.ts @@ -1,28 +1,34 @@ -export type HydrateRevisionError = { code?: string }; +export type HydrateRevisionError = { code?: string; status?: number }; -/** 打开文档时从 revision-state 取得的基线 ETag;无账本时兼容 0:0。 */ +export const DOCUMENT_REVISION_ETAG_MISSING = 'DOCUMENT_REVISION_ETAG_MISSING'; + +export class MissingRevisionEtagError extends Error { + code = DOCUMENT_REVISION_ETAG_MISSING; + constructor(message = '缺少打开时修订基线,无法保存受控文档') { + super(message); + this.name = 'MissingRevisionEtagError'; + } +} + +/** 打开文档时从 revision-state 取得的基线 ETag。受控路径不把异常映射成 0:0。 */ export function revisionEtagFromHydrate( state: { etag?: string } | null | undefined, - err?: HydrateRevisionError | null, + _err?: HydrateRevisionError | null, ): string | undefined { if (state?.etag) return state.etag; - if ( - err?.code === 'DOCUMENT_REVISION_NEEDS_CONTENT' || - err?.code === 'DOCUMENT_REVISION_NOT_FOUND' - ) { - return '0:0'; - } return undefined; } /** * 人工 PATCH 必须使用打开时缓存的基线 ETag。 - * 禁止在点击保存时重新 GET 最新 ETag。 + * 受控文档缺少基线时 fail-closed,绝不 PATCH 0:0。 + * 仅 freeform 允许无账本 bootstrap 0:0。 */ export function resolvePatchEtag(input: { revisionEtag?: string; governanceMode?: 'controlled' | 'freeform'; }): string { if (input.revisionEtag) return input.revisionEtag; - return '0:0'; + if (input.governanceMode === 'freeform') return '0:0'; + throw new MissingRevisionEtagError(); }