feat(idempotens): operationsscope på uppladdningsidentitet #488
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user