feat(idempotens): operationsscope på uppladdningsidentitet #488

Merged
admin merged 1 commits from feat/idempotency-operation-scope into main 2026-11-06 09:44:37 +00:00
2 changed files with 205 additions and 39 deletions
+159 -39
View File
@@ -24,7 +24,9 @@ export interface Doc0UploadReceipt {
/** The POST may have committed even if its response could not be verified. */
export class Doc0UploadOutcomeUnknownError extends Error {
constructor() {
super('DOC0 upload outcome or receipt could not be verified; reconcile using the original idempotency key');
super(
'DOC0 upload outcome or receipt could not be verified; reconcile using the original idempotency key',
);
this.name = 'Doc0UploadOutcomeUnknownError';
}
}
@@ -60,6 +62,44 @@ export type Doc0Inspection =
* Internal contract only: callers must retain this identity before dispatch,
* authorize its scope, and keep the same input bytes/metadata until dispatch.
*/
/**
* One upload identity per operation — not per file.
*
* "Identical file, different instruction" is a DIFFERENT operation, so the
* fingerprint must cover every result-affecting input. DOC0's fingerprint
* accepts arbitrary string tuples; we version the domain explicitly.
*
* Boundary kept: this code stays the 0pt-owned fingerprint of an internal
* interface; the SHA-256 of the bytes remains the same regardless, and DOC0
* still receives the same `x-idempotency-key` (it owns its own idempotency).
*/
export function bindOperationToUploadIdentity(
input: Doc0UploadInput,
operation: {
instruction: string;
fields: ReadonlyArray<{ name: string; type: string; required?: boolean }>;
modelId: string;
dataClass: string;
format: 'json' | 'csv';
},
): Doc0UploadInput {
const payloadVersion = createHash('sha256')
.update(
JSON.stringify([
'0pt-structure-operation-v1',
operation.instruction.length,
operation.instruction,
operation.fields.map((f) => [f.name, f.type, f.required === true]),
operation.modelId,
operation.dataClass,
operation.format,
]),
)
.digest('hex')
.slice(0, 16);
return { ...input, filename: `${input.filename}#${payloadVersion}` };
}
export function prepareDoc0UploadIdentity(input: Doc0UploadInput): {
sourceSha256: string;
sourceSizeBytes: number;
@@ -68,13 +108,25 @@ export function prepareDoc0UploadIdentity(input: Doc0UploadInput): {
wireMimeType: string;
} {
if (
![input.tenantId, input.projectId, input.actorId, input.requestId, input.idempotencyKey, input.filename, input.mimeType]
.every((value) => typeof value === 'string' && value.trim()) ||
![input.tenantId, input.actorId, input.requestId, input.idempotencyKey]
.every((value) => /^[\x21-\x7e]{1,256}$/.test(value)) ||
!['public', 'internal', 'confidential', 'restricted', 'personal-data', 'financial', 'legal'].includes(input.classification) ||
!(input.bytes instanceof Uint8Array) || input.bytes.byteLength === 0
) throw new Error('Invalid DOC0 upload input');
![
input.tenantId,
input.projectId,
input.actorId,
input.requestId,
input.idempotencyKey,
input.filename,
input.mimeType,
].every((value) => typeof value === 'string' && value.trim()) ||
![input.tenantId, input.actorId, input.requestId, input.idempotencyKey].every((value) =>
/^[\x21-\x7e]{1,256}$/.test(value),
) ||
!['public', 'internal', 'confidential', 'restricted', 'personal-data', 'financial', 'legal'].includes(
input.classification,
) ||
!(input.bytes instanceof Uint8Array) ||
input.bytes.byteLength === 0
)
throw new Error('Invalid DOC0 upload input');
const wireMimeType = input.mimeType.toLowerCase();
if (!/^[a-z0-9][a-z0-9!#$&^_.+-]*\/[a-z0-9][a-z0-9!#$&^_.+-]*$/.test(wireMimeType)) {
throw new Error('Invalid DOC0 upload MIME type');
@@ -83,10 +135,25 @@ export function prepareDoc0UploadIdentity(input: Doc0UploadInput): {
const wireFilename = `source.${extension}`;
const sourceSha256 = createHash('sha256').update(input.bytes).digest('hex');
const sourceSizeBytes = input.bytes.byteLength;
const requestFingerprint = createHash('sha256').update(JSON.stringify([
'doc0-upload-v1', input.tenantId, input.projectId, input.actorId, sourceSha256,
sourceSizeBytes, wireFilename, wireMimeType, input.filename, 'unknown', input.classification, [], null,
])).digest('hex');
const requestFingerprint = createHash('sha256')
.update(
JSON.stringify([
'doc0-upload-v1',
input.tenantId,
input.projectId,
input.actorId,
sourceSha256,
sourceSizeBytes,
wireFilename,
wireMimeType,
input.filename,
'unknown',
input.classification,
[],
null,
]),
)
.digest('hex');
return { sourceSha256, sourceSizeBytes, requestFingerprint, wireFilename, wireMimeType };
}
@@ -109,7 +176,9 @@ async function readBoundedJson(response: Response): Promise<unknown> {
parts.push(chunk.value);
}
return JSON.parse(new TextDecoder('utf-8', { fatal: true }).decode(Buffer.concat(parts))) as unknown;
} finally { reader.releaseLock(); }
} finally {
reader.releaseLock();
}
}
/**
@@ -131,16 +200,28 @@ export class Doc0UploadClient {
},
) {
let url: URL;
try { url = new URL(options.baseUrl); }
catch { throw new Error('A trusted HTTPS DOC0 endpoint is required'); }
try {
url = new URL(options.baseUrl);
} catch {
throw new Error('A trusted HTTPS DOC0 endpoint is required');
}
if (
url.protocol !== 'https:' || url.username || url.password || url.search || url.hash ||
url.protocol !== 'https:' ||
url.username ||
url.password ||
url.search ||
url.hash ||
!options.serviceToken.trim() ||
!Number.isSafeInteger(options.maxBytes) ||
options.maxBytes <= 0 ||
(options.timeoutMs !== undefined && (!Number.isSafeInteger(options.timeoutMs) || options.timeoutMs < 1_000 || options.timeoutMs > 300_000))
(options.timeoutMs !== undefined &&
(!Number.isSafeInteger(options.timeoutMs) ||
options.timeoutMs < 1_000 ||
options.timeoutMs > 300_000))
) {
throw new Error('A trusted HTTPS DOC0 endpoint, service credential and valid upload limits are required');
throw new Error(
'A trusted HTTPS DOC0 endpoint, service credential and valid upload limits are required',
);
}
// Keep the validated values even if the caller later changes its options object.
this.options = { ...options };
@@ -167,7 +248,11 @@ export class Doc0UploadClient {
classification: input.classification,
}),
);
body.set('file', new Blob([new Uint8Array(input.bytes)], { type: identity.wireMimeType }), identity.wireFilename);
body.set(
'file',
new Blob([new Uint8Array(input.bytes)], { type: identity.wireMimeType }),
identity.wireFilename,
);
let response: Response;
try {
response = await this.fetchImpl(`${this.baseUrl}/v1/doc0/documents`, {
@@ -183,11 +268,16 @@ export class Doc0UploadClient {
body,
signal: AbortSignal.timeout(this.options.timeoutMs ?? 120_000),
});
} catch { throw new Doc0UploadOutcomeUnknownError(); }
} catch {
throw new Doc0UploadOutcomeUnknownError();
}
// Even a non-2xx response does not prove the write did not commit.
let decoded: unknown;
try { decoded = await readBoundedJson(response); }
catch { throw new Doc0UploadOutcomeUnknownError(); }
try {
decoded = await readBoundedJson(response);
} catch {
throw new Doc0UploadOutcomeUnknownError();
}
const data = decoded as {
document?: {
documentId?: unknown;
@@ -239,33 +329,63 @@ export class Doc0UploadClient {
* is complete. No automatic retry, settlement or customer route is wired.
*/
async inspect(input: Doc0InspectionInput): Promise<Doc0Inspection> {
if (![input.tenantId, input.projectId, input.actorId, input.requestId, input.idempotencyKey]
.every((value) => typeof value === 'string' && value.trim()) ||
input.projectId.length > 256 || input.idempotencyKey.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.requestId, input.idempotencyKey].every(
(value) => typeof value === 'string' && value.trim(),
) ||
input.projectId.length > 256 ||
input.idempotencyKey.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 DOC0 inspection input');
}
let result: unknown;
try {
const response = await this.fetchImpl(`${this.baseUrl}/v1/doc0/upload-intents/inspect`, {
method: 'POST', redirect: 'error', cache: 'no-store', signal: AbortSignal.timeout(this.options.timeoutMs ?? 120_000),
headers: { authorization: `Bearer ${this.options.serviceToken}`, 'content-type': 'application/json',
'x-tenant-id': input.tenantId, 'x-actor-id': input.actorId, 'x-request-id': input.requestId, 'x-idempotency-key': input.idempotencyKey },
body: JSON.stringify({ projectId: input.projectId, sourceSha256: input.sourceSha256,
sourceSizeBytes: input.sourceSizeBytes, requestFingerprint: input.requestFingerprint }),
method: 'POST',
redirect: 'error',
cache: 'no-store',
signal: AbortSignal.timeout(this.options.timeoutMs ?? 120_000),
headers: {
authorization: `Bearer ${this.options.serviceToken}`,
'content-type': 'application/json',
'x-tenant-id': input.tenantId,
'x-actor-id': input.actorId,
'x-request-id': input.requestId,
'x-idempotency-key': input.idempotencyKey,
},
body: JSON.stringify({
projectId: input.projectId,
sourceSha256: input.sourceSha256,
sourceSizeBytes: input.sourceSizeBytes,
requestFingerprint: input.requestFingerprint,
}),
});
result = await readBoundedJson(response);
} catch { throw new Doc0InspectionUnavailableError(); }
if (!result || typeof result !== 'object' || Array.isArray(result)) throw new Doc0InspectionUnavailableError();
} catch {
throw new Doc0InspectionUnavailableError();
}
if (!result || typeof result !== 'object' || Array.isArray(result))
throw new Doc0InspectionUnavailableError();
const record = result as Record<string, unknown>;
if (typeof record.status === 'string' && ['not_found', 'in_progress', 'reconciliation_required'].includes(record.status) &&
Object.keys(record).length === 1) {
if (
typeof record.status === 'string' &&
['not_found', 'in_progress', 'reconciliation_required'].includes(record.status) &&
Object.keys(record).length === 1
) {
return { status: record.status as 'not_found' | 'in_progress' | 'reconciliation_required' };
}
if (record.status === 'completed_recorded' && Object.keys(record).length === 3 &&
typeof record.documentId === 'string' && record.documentId.trim() &&
typeof record.versionId === 'string' && record.versionId.trim()) {
if (
record.status === 'completed_recorded' &&
Object.keys(record).length === 3 &&
typeof record.documentId === 'string' &&
record.documentId.trim() &&
typeof record.versionId === 'string' &&
record.versionId.trim()
) {
return { status: 'completed_recorded', documentId: record.documentId, versionId: record.versionId };
}
throw new Doc0InspectionUnavailableError();
@@ -0,0 +1,46 @@
import { describe, expect, it } from 'vitest';
import {
bindOperationToUploadIdentity,
prepareDoc0UploadIdentity,
type Doc0UploadInput,
} from '../../src/document-workbench/doc0-upload-client';
const input: Doc0UploadInput = {
tenantId: 't1', projectId: 'p1', actorId: 'a1', requestId: 'r1', idempotencyKey: 'k1',
filename: 'rapport.txt', mimeType: 'text/plain', classification: 'internal',
bytes: new TextEncoder().encode('Innehall'),
};
const opA = {
instruction: 'Extrahera leverantor', fields: [{ name: 'supplier', type: 'string' }],
modelId: 'm1', dataClass: 'low', format: 'json' as const,
};
const opB = { ...opA, instruction: 'Extrahera belopp' };
describe('operation-scoped upload identity', () => {
it('same file + same operation binds to one identity', () => {
expect(prepareDoc0UploadIdentity(bindOperationToUploadIdentity(input, opA)))
.toEqual(prepareDoc0UploadIdentity(bindOperationToUploadIdentity(input, opA)));
});
it('same file + DIFFERENT instruction = DIFFERENT identity (not the same operation)', () => {
const a = prepareDoc0UploadIdentity(bindOperationToUploadIdentity(input, opA));
const b = prepareDoc0UploadIdentity(bindOperationToUploadIdentity(input, opB));
expect(a.requestFingerprint).not.toBe(b.requestFingerprint);
});
it('changed model or data class also changes the operation identity', () => {
const c = prepareDoc0UploadIdentity(bindOperationToUploadIdentity(input, { ...opA, modelId: 'm2' }));
const d = prepareDoc0UploadIdentity(bindOperationToUploadIdentity(input, { ...opA, dataClass: 'high' }));
const e = prepareDoc0UploadIdentity(bindOperationToUploadIdentity(input, { ...opA, format: 'csv' }));
expect(c.requestFingerprint).not.toBe(prepareDoc0UploadIdentity(bindOperationToUploadIdentity(input, opA)).requestFingerprint);
expect(d.requestFingerprint).not.toBe(prepareDoc0UploadIdentity(bindOperationToUploadIdentity(input, opA)).requestFingerprint);
expect(e.requestFingerprint).not.toBe(prepareDoc0UploadIdentity(bindOperationToUploadIdentity(input, opA)).requestFingerprint);
});
it('raw bytes checksum stays stable across operations', () => {
const a = prepareDoc0UploadIdentity(bindOperationToUploadIdentity(input, opA));
const b = prepareDoc0UploadIdentity(bindOperationToUploadIdentity(input, opB));
expect(a.sourceSha256).toBe(b.sourceSha256);
});
it('prefixes the operation marker so odd filenames cannot collide', () => {
const bound = bindOperationToUploadIdentity(input, opA);
expect(bound.filename.startsWith('rapport.txt#')).toBe(true);
});
});