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 <cursoragent@cursor.com>
This commit is contained in:
weichen
2026-09-13 17:08:35 +00:00
co-authored by Cursor
parent 0a34daf4b5
commit 4dbd9eecb9
4 changed files with 97 additions and 23 deletions
+47 -1
View File
@@ -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',
);
});
});
+13 -6
View File
@@ -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;
}
+20 -5
View File
@@ -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({}));
});
});
+17 -11
View File
@@ -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();
}