fix: validate confirmed publication receipts before durable writes

This commit is contained in:
a1chzt
2026-09-22 14:08:46 +08:00
parent 4fee014a56
commit 1944d4c982
3 changed files with 34 additions and 5 deletions
@@ -17,7 +17,8 @@ export interface ShareBindingOutbox {
complete(task: ShareBindingTask): void;
}
function confirmedReceipt(value: SharePublishReceipt): SharePublishReceipt {
/** Validate and copy only the public, confirmed immutable publication fields. */
export function confirmedReceipt(value: SharePublishReceipt): SharePublishReceipt {
if (!value || [value.filePath, value.slug, value.versionId, value.entryPath].some(v => typeof v !== 'string' || !v.trim())
|| !Number.isSafeInteger(value.version) || value.version < 1
|| !Number.isSafeInteger(value.publishedAt) || value.publishedAt < 0) throw new Error('SHARE_RECEIPT_REQUIRED');
@@ -2,8 +2,8 @@ import type Database from 'better-sqlite3';
import type { SharePublishResult } from '@open-design/contracts';
import type { PublicFilePublication, PublicFilePublicationScope } from './public-file-publication-store.js';
import type { RecordPublicFilePublication } from './public-file-publication-recording.js';
import type { ShareBindingOutbox } from './share-binding-outbox.js';
import type { ShareFileMapping } from './share-file-mapping.js';
import { confirmedReceipt, type ShareBindingOutbox } from './share-binding-outbox.js';
import { publishedPathForSource, type ShareFileMapping } from './share-file-mapping.js';
export interface SharePublicationCompletionInput {
scope: PublicFilePublicationScope;
@@ -34,8 +34,12 @@ export function createSharePublicationCompletion(
...(input.result.binding.code ? { code: input.result.binding.code } : {}) } };
});
return (input: SharePublicationCompletionInput): SharePublishResult => {
if (input.result.receipt.filePath !== input.scope.filePath
|| input.result.receipt.slug !== input.publication.slug) throw new Error('SHARE_PUBLICATION_RECEIPT_MISMATCH');
const receipt = confirmedReceipt(input.result.receipt);
if (receipt.filePath !== input.scope.filePath || receipt.slug !== input.publication.slug
|| receipt.entryPath !== publishedPathForSource(input.mapping, input.scope.filePath)) {
throw new Error('SHARE_PUBLICATION_RECEIPT_MISMATCH');
}
input = { ...input, result: { ...input.result, receipt } };
try { return commit(input); }
catch (error) {
if (input.result.status === 'published') throw error;
@@ -10,6 +10,30 @@ const scope = { resourceTeamId: 'w', ownerMemberId: 'o', projectId: 'p', filePat
const publication = { slug: 'stable', url: 'https://example.test/artifact/p/stable', fileName: scope.filePath };
const receipt = { filePath: scope.filePath, slug: 'stable', version: 2, versionId: 'immutable', publishedAt: 1234, entryPath: 'index.html' };
const mapping = createShareFileMapping([{ sourcePath: scope.filePath, file: 'index.html' }]);
it.each([
{ ...receipt, version: 0 }, { ...receipt, version: 1.5 },
{ ...receipt, publishedAt: -1 }, { ...receipt, versionId: '' },
{ ...receipt, entryPath: 'wrong.html' },
])('rejects invalid confirmed publication before any local write: %j', badReceipt => {
const db = new Database(':memory:');
try {
migratePublicFilePublications(db);
const store = createSqlitePublicFilePublicationStore(db);
store.set(scope, { ...publication, slug: 'previous' });
const previous = store.getRevision(scope);
const outbox = createShareBindingOutbox(db);
let enqueues = 0;
const record = createPublicFilePublicationRecorder(db, store, () => { enqueues++; return { enqueued: 0, skippedInbound: 0 }; });
const complete = createSharePublicationCompletion(db, record, outbox, true);
for (const status of ['published', 'binding_pending'] as const) {
const result: SharePublishResult = status === 'published' ? { status, receipt: badReceipt }
: { status, receipt: badReceipt, binding: { retrying: false } };
expect(() => complete({ scope, publication, mapping, resourceId: 'r', result })).toThrow();
expect(store.getRevision(scope)).toEqual(previous); expect(outbox.list()).toEqual([]); expect(enqueues).toBe(0);
}
} finally { db.close(); }
});
it.each(['published', 'pending', 'no-worker', 'binding-write-fails', 'comment-write-fails', 'mismatch'] as const)('atomically completes local publication state: %s', mode => {
const db = new Database(':memory:');
try {