fix(upload): idempotent retry ger samma verifierade kvitto #459

Merged
admin merged 1 commits from fix/upload-retry-receipt into main 2026-11-02 23:36:58 +00:00
4 changed files with 234 additions and 46 deletions
+8
View File
@@ -0,0 +1,8 @@
-- Persist the verified DOC0 receipt on the durable upload attempt so an
-- idempotent retry returns the same 201 instead of "outcome unknown".
-- Additive and re-runnable.
ALTER TABLE opt_document_upload_attempts ADD COLUMN IF NOT EXISTS document_id text;
ALTER TABLE opt_document_upload_attempts ADD COLUMN IF NOT EXISTS version_id text;
ALTER TABLE opt_document_upload_attempts ADD COLUMN IF NOT EXISTS receipt_checksum text;
ALTER TABLE opt_document_upload_attempts ADD COLUMN IF NOT EXISTS pipeline_stage text;
ALTER TABLE opt_document_upload_attempts ADD COLUMN IF NOT EXISTS verified_at timestamptz;
+85 -8
View File
@@ -13,6 +13,12 @@ export interface UploadAttemptIdentity {
requestFingerprint: string;
}
export type UploadAttemptState = 'prepared' | 'dispatch_started';
export interface StoredUploadReceipt {
documentId: string;
versionId: string;
checksumSha256: string;
pipelineStage: string;
}
interface Row {
source_sha256: string;
source_size_bytes: string;
@@ -21,10 +27,15 @@ interface Row {
}
function validate(input: UploadAttemptIdentity): void {
if (![input.tenantId, input.projectId, input.actorId, input.attemptId].every(
(value) => typeof value === 'string' && value.trim().length > 0 && value.length <= 256,
) || !/^[0-9a-f]{64}$/.test(input.sourceSha256) || !/^[0-9a-f]{64}$/.test(input.requestFingerprint)
|| !Number.isSafeInteger(input.sourceSizeBytes) || input.sourceSizeBytes <= 0) {
if (
![input.tenantId, input.projectId, input.actorId, input.attemptId].every(
(value) => typeof value === 'string' && value.trim().length > 0 && value.length <= 256,
) ||
!/^[0-9a-f]{64}$/.test(input.sourceSha256) ||
!/^[0-9a-f]{64}$/.test(input.requestFingerprint) ||
!Number.isSafeInteger(input.sourceSizeBytes) ||
input.sourceSizeBytes <= 0
) {
throw new Error('Invalid document upload attempt');
}
}
@@ -40,12 +51,16 @@ function validate(input: UploadAttemptIdentity): void {
export class PostgresUploadAttemptStore {
constructor(private readonly sql: RepositorySql) {}
private async scoped<T>(input: UploadAttemptIdentity, action: (tx: postgres.TransactionSql) => Promise<T>): Promise<T> {
private async scoped<T>(
input: UploadAttemptIdentity,
action: (tx: postgres.TransactionSql) => Promise<T>,
): Promise<T> {
validate(input);
return this.sql.begin(async (tx) => {
const roles = await tx<{ rolsuper: boolean; rolbypassrls: boolean }[]>`
SELECT rolsuper, rolbypassrls FROM pg_roles WHERE rolname = current_user`;
if (!roles[0] || roles[0].rolsuper || roles[0].rolbypassrls) throw new Error('Upload attempts require an RLS-enforced database role');
if (!roles[0] || roles[0].rolsuper || roles[0].rolbypassrls)
throw new Error('Upload attempts require an RLS-enforced database role');
await tx`SELECT set_config('app.opt_tenant_id', ${input.tenantId}, true), set_config('app.opt_project_id', ${input.projectId}, true), set_config('app.opt_actor_id', ${input.actorId}, true)`;
return action(tx);
}) as Promise<T>;
@@ -57,11 +72,18 @@ export class PostgresUploadAttemptStore {
INSERT INTO opt_document_upload_attempts (tenant_id,project_id,actor_id,attempt_id,source_sha256,source_size_bytes,request_fingerprint,state)
VALUES (${input.tenantId},${input.projectId},${input.actorId},${input.attemptId},${input.sourceSha256},${input.sourceSizeBytes},${input.requestFingerprint},'prepared')
ON CONFLICT (tenant_id,attempt_id) DO NOTHING RETURNING source_sha256,source_size_bytes,request_fingerprint,state`;
const rows = inserted.length ? inserted : await tx<Row[]>`
const rows = inserted.length
? inserted
: await tx<Row[]>`
SELECT source_sha256,source_size_bytes,request_fingerprint,state FROM opt_document_upload_attempts
WHERE tenant_id=${input.tenantId} AND project_id=${input.projectId} AND actor_id=${input.actorId} AND attempt_id=${input.attemptId}`;
const row = rows[0];
if (!row || row.source_sha256 !== input.sourceSha256 || row.source_size_bytes !== String(input.sourceSizeBytes) || row.request_fingerprint !== input.requestFingerprint) {
if (
!row ||
row.source_sha256 !== input.sourceSha256 ||
row.source_size_bytes !== String(input.sourceSizeBytes) ||
row.request_fingerprint !== input.requestFingerprint
) {
throw new Error('Document upload attempt cannot be reused');
}
return { state: row.state, replay: inserted.length === 0 };
@@ -80,4 +102,59 @@ export class PostgresUploadAttemptStore {
return rows.length === 1;
});
}
/**
* Persist the verified DOC0 receipt so a retry with the same idempotency key
* returns the SAME 201 receipt instead of "outcome unknown". Only the fields
* DOC0 itself returned are stored; storage/extraction are not re-proven here.
*/
async recordReceipt(input: UploadAttemptIdentity, receipt: StoredUploadReceipt): Promise<void> {
if (
![receipt.documentId, receipt.versionId, receipt.pipelineStage].every(
(value) => typeof value === 'string' && value.trim().length > 0 && value.length <= 256,
) ||
!/^[0-9a-f]{64}$/.test(receipt.checksumSha256)
) {
throw new Error('Invalid upload receipt');
}
await this.scoped(input, async (tx) => {
const rows = await tx<{ attempt_id: string }[]>`
UPDATE opt_document_upload_attempts
SET document_id=${receipt.documentId}, version_id=${receipt.versionId},
receipt_checksum=${receipt.checksumSha256}, pipeline_stage=${receipt.pipelineStage},
verified_at=clock_timestamp(), updated_at=clock_timestamp()
WHERE tenant_id=${input.tenantId} AND project_id=${input.projectId} AND actor_id=${input.actorId}
AND attempt_id=${input.attemptId} AND source_sha256=${input.sourceSha256}
AND source_size_bytes=${input.sourceSizeBytes} AND request_fingerprint=${input.requestFingerprint}
RETURNING attempt_id`;
if (rows.length !== 1) throw new Error('Upload attempt receipt could not be recorded');
});
}
async getReceipt(input: UploadAttemptIdentity): Promise<StoredUploadReceipt | undefined> {
return this.scoped(input, async (tx) => {
const rows = await tx<
{
document_id: string | null;
version_id: string | null;
receipt_checksum: string | null;
pipeline_stage: string | null;
}[]
>`
SELECT document_id, version_id, receipt_checksum, pipeline_stage
FROM opt_document_upload_attempts
WHERE tenant_id=${input.tenantId} AND project_id=${input.projectId} AND actor_id=${input.actorId}
AND attempt_id=${input.attemptId} AND source_sha256=${input.sourceSha256}
AND source_size_bytes=${input.sourceSizeBytes} AND request_fingerprint=${input.requestFingerprint}`;
const row = rows[0];
if (!row?.document_id || !row.version_id || !row.receipt_checksum || !row.pipeline_stage)
return undefined;
return {
documentId: row.document_id,
versionId: row.version_id,
checksumSha256: row.receipt_checksum,
pipelineStage: row.pipeline_stage,
};
});
}
}
+40 -12
View File
@@ -6,12 +6,15 @@ import {
type Doc0UploadInput,
type Doc0UploadReceipt,
} from './doc0-upload-client';
import type { UploadAttemptIdentity, UploadAttemptState } from './upload-attempt-store';
import type { StoredUploadReceipt, UploadAttemptIdentity, UploadAttemptState } from './upload-attempt-store';
type Scope = Pick<Doc0UploadInput, 'tenantId' | 'projectId' | 'actorId'>;
type Attempts = {
prepare(input: UploadAttemptIdentity): Promise<{ state: UploadAttemptState; replay: boolean }>;
startDispatch(input: UploadAttemptIdentity): Promise<boolean>;
/** Optional so lightweight tests/implementations keep working. */
recordReceipt?(input: UploadAttemptIdentity, receipt: StoredUploadReceipt): Promise<void>;
getReceipt?(input: UploadAttemptIdentity): Promise<StoredUploadReceipt | undefined>;
};
type Doc0 = {
upload(input: Doc0UploadInput): Promise<Doc0UploadReceipt>;
@@ -28,12 +31,14 @@ export type CoordinatedUpload =
* be deployed and verified before this coordinator can serve customer data.
*/
export class DocumentUploadCoordinator {
constructor(private readonly deps: {
attempts: Attempts;
doc0: Doc0;
authorize: (scope: Scope) => Promise<boolean>;
maxBytes: number;
}) {
constructor(
private readonly deps: {
attempts: Attempts;
doc0: Doc0;
authorize: (scope: Scope) => Promise<boolean>;
maxBytes: number;
},
) {
if (!Number.isSafeInteger(deps.maxBytes) || deps.maxBytes <= 0) throw new Error('Invalid upload limit');
}
@@ -48,27 +53,50 @@ export class DocumentUploadCoordinator {
const scope = { tenantId: snapshot.tenantId, projectId: snapshot.projectId, actorId: snapshot.actorId };
if ((await this.deps.authorize(scope)) !== true) throw new Error('Document upload not authorized');
const attempt: UploadAttemptIdentity = {
...scope, attemptId: snapshot.idempotencyKey, sourceSha256: details.sourceSha256,
sourceSizeBytes: details.sourceSizeBytes, requestFingerprint: details.requestFingerprint,
...scope,
attemptId: snapshot.idempotencyKey,
sourceSha256: details.sourceSha256,
sourceSizeBytes: details.sourceSizeBytes,
requestFingerprint: details.requestFingerprint,
};
await this.deps.attempts.prepare(attempt);
// A database error here also stops the operation: the update might have
// committed and must never be followed by an unrecorded network write.
if ((await this.deps.attempts.startDispatch(attempt)) === true) {
try {
return { status: 'receipt_verified', receipt: await this.deps.doc0.upload(snapshot) };
const receipt = await this.deps.doc0.upload(snapshot);
// Persist the verified receipt so retrying with the same idempotency
// key returns this exact 201 instead of "outcome unknown". A journal
// write failure must not turn a real upload into an error response.
try {
await this.deps.attempts.recordReceipt?.(attempt, receipt);
} catch {
/* receipt stays unpersisted; a retry falls back to observation */
}
return { status: 'receipt_verified', receipt };
} catch (error) {
// The client marks every potentially committed POST as unknown. Any
// other error propagates, while the one-way dispatch remains recorded.
if (!(error instanceof Doc0UploadOutcomeUnknownError)) throw error;
}
}
// A retry of an already-dispatched attempt: if the first dispatch returned
// a verified receipt, return it again (same document, same checksum).
try {
const stored = await this.deps.attempts.getReceipt?.(attempt);
if (stored) return { status: 'receipt_verified', receipt: stored };
} catch {
/* fall through to observation */
}
// Observation only. Even not_found does not prove that a write cannot
// still arrive; completed_recorded does not prove storage or extraction.
try {
const observation = await this.deps.doc0.inspect({
...scope, requestId: snapshot.requestId, idempotencyKey: snapshot.idempotencyKey,
sourceSha256: details.sourceSha256, sourceSizeBytes: details.sourceSizeBytes,
...scope,
requestId: snapshot.requestId,
idempotencyKey: snapshot.idempotencyKey,
sourceSha256: details.sourceSha256,
sourceSizeBytes: details.sourceSizeBytes,
requestFingerprint: details.requestFingerprint,
});
return { status: 'outcome_unknown', observation };
@@ -1,15 +1,38 @@
import { describe, expect, it, vi } from 'vitest';
import { Doc0UploadOutcomeUnknownError, prepareDoc0UploadIdentity, type Doc0UploadClient } from '../../src/document-workbench/doc0-upload-client';
import {
Doc0UploadOutcomeUnknownError,
prepareDoc0UploadIdentity,
type Doc0UploadClient,
} from '../../src/document-workbench/doc0-upload-client';
import { DocumentUploadCoordinator } from '../../src/document-workbench/upload-coordinator';
import type { PostgresUploadAttemptStore } from '../../src/document-workbench/upload-attempt-store';
const input = { tenantId: 't1', projectId: 'p1', actorId: 'a1', requestId: 'r1', idempotencyKey: 'i1',
filename: 'rapport åäö.txt', mimeType: 'text/plain', classification: 'confidential' as const,
bytes: new TextEncoder().encode('original') };
const input = {
tenantId: 't1',
projectId: 'p1',
actorId: 'a1',
requestId: 'r1',
idempotencyKey: 'i1',
filename: 'rapport åäö.txt',
mimeType: 'text/plain',
classification: 'confidential' as const,
bytes: new TextEncoder().encode('original'),
};
const receipt = { documentId: 'd1', versionId: 'v1', checksumSha256: 'test', pipelineStage: 'accepted' };
function fixture() {
const attempts = { prepare: vi.fn<PostgresUploadAttemptStore['prepare']>(async () => ({ state: 'prepared' as const, replay: false })), startDispatch: vi.fn<PostgresUploadAttemptStore['startDispatch']>(async () => true) };
const doc0 = { upload: vi.fn<Doc0UploadClient['upload']>(async () => receipt), inspect: vi.fn<Doc0UploadClient['inspect']>(async () => ({ status: 'not_found' as const })) };
const attempts = {
prepare: vi.fn<PostgresUploadAttemptStore['prepare']>(async () => ({
state: 'prepared' as const,
replay: false,
})),
startDispatch: vi.fn<PostgresUploadAttemptStore['startDispatch']>(async () => true),
recordReceipt: vi.fn<PostgresUploadAttemptStore['recordReceipt']>(async () => undefined),
getReceipt: vi.fn<PostgresUploadAttemptStore['getReceipt']>(async () => undefined),
};
const doc0 = {
upload: vi.fn<Doc0UploadClient['upload']>(async () => receipt),
inspect: vi.fn<Doc0UploadClient['inspect']>(async () => ({ status: 'not_found' as const })),
};
const authorize = vi.fn(async () => true);
const coordinator = new DocumentUploadCoordinator({ attempts, doc0, authorize, maxBytes: 100 });
return { attempts, doc0, authorize, coordinator };
@@ -20,47 +43,99 @@ describe('document upload coordination', () => {
expect(await f.coordinator.execute(input)).toEqual({ status: 'receipt_verified', receipt });
expect(f.authorize).toHaveBeenCalledWith({ tenantId: 't1', projectId: 'p1', actorId: 'a1' });
const attempt = f.attempts.prepare.mock.calls[0][0];
expect(attempt).toEqual({ tenantId: 't1', projectId: 'p1', actorId: 'a1', attemptId: 'i1',
sourceSha256: prepareDoc0UploadIdentity(input).sourceSha256, sourceSizeBytes: input.bytes.length,
requestFingerprint: prepareDoc0UploadIdentity(input).requestFingerprint });
expect(attempt).toEqual({
tenantId: 't1',
projectId: 'p1',
actorId: 'a1',
attemptId: 'i1',
sourceSha256: prepareDoc0UploadIdentity(input).sourceSha256,
sourceSizeBytes: input.bytes.length,
requestFingerprint: prepareDoc0UploadIdentity(input).requestFingerprint,
});
expect(f.attempts.startDispatch).toHaveBeenCalledWith(attempt);
expect(f.attempts.prepare.mock.invocationCallOrder[0]).toBeLessThan(f.attempts.startDispatch.mock.invocationCallOrder[0]);
expect(f.attempts.startDispatch.mock.invocationCallOrder[0]).toBeLessThan(f.doc0.upload.mock.invocationCallOrder[0]);
expect(f.attempts.prepare.mock.invocationCallOrder[0]).toBeLessThan(
f.attempts.startDispatch.mock.invocationCallOrder[0],
);
expect(f.attempts.startDispatch.mock.invocationCallOrder[0]).toBeLessThan(
f.doc0.upload.mock.invocationCallOrder[0],
);
});
it('denies before persistence or network when authorization fails', async () => {
const f = fixture(); f.authorize.mockResolvedValueOnce(false);
const f = fixture();
f.authorize.mockResolvedValueOnce(false);
await expect(f.coordinator.execute(input)).rejects.toThrow(/not authorized/);
expect(f.attempts.prepare).not.toHaveBeenCalled(); expect(f.doc0.upload).not.toHaveBeenCalled();
expect(f.attempts.prepare).not.toHaveBeenCalled();
expect(f.doc0.upload).not.toHaveBeenCalled();
});
it('rejects truthy but non-boolean authorization results', async () => {
const f = fixture(); f.authorize.mockResolvedValueOnce('allow' as unknown as boolean);
const f = fixture();
f.authorize.mockResolvedValueOnce('allow' as unknown as boolean);
await expect(f.coordinator.execute(input)).rejects.toThrow(/not authorized/);
expect(f.attempts.prepare).not.toHaveBeenCalled();
});
it('does not dispatch after a failed or ambiguous database transition', async () => {
const f = fixture(); f.attempts.startDispatch.mockRejectedValueOnce(new Error('connection lost'));
const f = fixture();
f.attempts.startDispatch.mockRejectedValueOnce(new Error('connection lost'));
await expect(f.coordinator.execute(input)).rejects.toThrow(/connection lost/);
expect(f.doc0.upload).not.toHaveBeenCalled(); expect(f.doc0.inspect).not.toHaveBeenCalled();
expect(f.doc0.upload).not.toHaveBeenCalled();
expect(f.doc0.inspect).not.toHaveBeenCalled();
});
it('only observes a previously started attempt and never sends a second POST', async () => {
const f = fixture(); f.attempts.startDispatch.mockResolvedValueOnce(false);
expect(await f.coordinator.execute(input)).toEqual({ status: 'outcome_unknown', observation: { status: 'not_found' } });
expect(f.doc0.upload).not.toHaveBeenCalled(); expect(f.doc0.inspect).toHaveBeenCalledOnce();
const f = fixture();
f.attempts.startDispatch.mockResolvedValueOnce(false);
expect(await f.coordinator.execute(input)).toEqual({
status: 'outcome_unknown',
observation: { status: 'not_found' },
});
expect(f.doc0.upload).not.toHaveBeenCalled();
expect(f.doc0.inspect).toHaveBeenCalledOnce();
});
it('persists the verified receipt after a successful upload', async () => {
const f = fixture();
expect(await f.coordinator.execute(input)).toEqual({ status: 'receipt_verified', receipt });
expect(f.attempts.recordReceipt).toHaveBeenCalledWith(
expect.objectContaining({ attemptId: 'i1' }),
receipt,
);
});
it('returns the SAME verified receipt on an idempotent retry without a second POST', async () => {
const f = fixture();
f.attempts.startDispatch.mockResolvedValueOnce(false);
f.attempts.getReceipt.mockResolvedValueOnce(receipt);
expect(await f.coordinator.execute(input)).toEqual({ status: 'receipt_verified', receipt });
expect(f.doc0.upload).not.toHaveBeenCalled();
expect(f.doc0.inspect).not.toHaveBeenCalled();
});
it('keeps a journaled completion distinct from a verified receipt', async () => {
const f = fixture(); f.attempts.startDispatch.mockResolvedValueOnce(false);
f.doc0.inspect.mockResolvedValueOnce({ status: 'completed_recorded', documentId: 'doc', versionId: 'version' });
expect(await f.coordinator.execute(input)).toEqual({ status: 'outcome_unknown', observation: { status: 'completed_recorded', documentId: 'doc', versionId: 'version' } });
const f = fixture();
f.attempts.startDispatch.mockResolvedValueOnce(false);
f.doc0.inspect.mockResolvedValueOnce({
status: 'completed_recorded',
documentId: 'doc',
versionId: 'version',
});
expect(await f.coordinator.execute(input)).toEqual({
status: 'outcome_unknown',
observation: { status: 'completed_recorded', documentId: 'doc', versionId: 'version' },
});
expect(f.doc0.upload).not.toHaveBeenCalled();
});
it('keeps a possibly committed upload unknown even when inspection is unavailable', async () => {
const f = fixture(); f.doc0.upload.mockRejectedValueOnce(new Doc0UploadOutcomeUnknownError()); f.doc0.inspect.mockRejectedValueOnce(new Error('unavailable'));
const f = fixture();
f.doc0.upload.mockRejectedValueOnce(new Doc0UploadOutcomeUnknownError());
f.doc0.inspect.mockRejectedValueOnce(new Error('unavailable'));
expect(await f.coordinator.execute(input)).toEqual({ status: 'outcome_unknown' });
expect(f.doc0.upload).toHaveBeenCalledOnce(); expect(f.doc0.inspect).toHaveBeenCalledOnce();
expect(f.doc0.upload).toHaveBeenCalledOnce();
expect(f.doc0.inspect).toHaveBeenCalledOnce();
});
it('snapshots bytes and metadata before asynchronous authorization', async () => {
const f = fixture(); const mutable = { ...input, bytes: new Uint8Array(input.bytes) };
f.authorize.mockImplementationOnce(async () => { mutable.bytes.fill(0); mutable.filename = 'changed.txt'; return true; });
const f = fixture();
const mutable = { ...input, bytes: new Uint8Array(input.bytes) };
f.authorize.mockImplementationOnce(async () => {
mutable.bytes.fill(0);
mutable.filename = 'changed.txt';
return true;
});
await f.coordinator.execute(mutable);
expect(f.doc0.upload.mock.calls[0][0].filename).toBe(input.filename);
expect(new TextDecoder().decode(f.doc0.upload.mock.calls[0][0].bytes)).toBe('original');