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:
@@ -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',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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();
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user