Files
0pt/src/document-workbench/doc0-upload-client.ts
T
admin 47e0264571
CI (SIAX Cloud) / integration (push) Skipped
CI (SIAX Cloud) / sonar (push) Skipped
masterplan-lock / check (push) Successful in 5s
CI (SIAX Cloud) / sonar (pull_request) Skipped
Security Scan (SIAX Cloud) / security (push) Successful in 14s
CI (SIAX Cloud) / security (push) Successful in 17s
CI (SIAX Cloud) / security (pull_request) Successful in 19s
Security Scan (SIAX Cloud) / security (pull_request) Successful in 19s
CI (SIAX Cloud) / integration (pull_request) Successful in 28s
masterplan-lock / check (pull_request) Successful in 32s
CI (SIAX Cloud) / quality (push) Successful in 51s
CI (SIAX Cloud) / quality (pull_request) Successful in 50s
fix: stabilize DOC0 upload fingerprint metadata
2026-11-02 06:52:50 +01:00

267 lines
11 KiB
TypeScript

import { createHash } from 'node:crypto';
export type DocumentClassification =
'public' | 'internal' | 'confidential' | 'restricted' | 'personal-data' | 'financial' | 'legal';
export interface Doc0UploadInput {
/** Must be verified and authorized by the caller, never sourced from unchecked headers. */
tenantId: string;
projectId: string;
actorId: string;
requestId: string;
idempotencyKey: string;
filename: string;
mimeType: string;
classification: DocumentClassification;
bytes: Uint8Array;
}
export interface Doc0UploadReceipt {
documentId: string;
versionId: string;
checksumSha256: string;
pipelineStage: string;
}
/** 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');
this.name = 'Doc0UploadOutcomeUnknownError';
}
}
export class Doc0InspectionUnavailableError extends Error {
constructor() {
super('DOC0 upload inspection could not be verified');
this.name = 'Doc0InspectionUnavailableError';
}
}
export interface Doc0InspectionInput {
tenantId: string;
projectId: string;
actorId: string;
requestId: string;
idempotencyKey: string;
sourceSha256: string;
sourceSizeBytes: number;
/** Original server-compatible fingerprint from a trusted, durable attempt manifest. */
requestFingerprint: string;
}
export type Doc0Inspection =
| { status: 'not_found' | 'in_progress' | 'reconciliation_required' }
| { status: 'completed_recorded'; documentId: string; versionId: string };
/**
* DOC0 fingerprints the multipart parser's filename and MIME, not the values
* supplied to FormData. Use an ASCII wire name and a restricted, normalized
* MIME so their parser interpretation is deterministic. The original name is
* preserved separately as DOC0's displayName.
*
* Internal contract only: callers must retain this identity before dispatch,
* authorize its scope, and keep the same input bytes/metadata until dispatch.
*/
export function prepareDoc0UploadIdentity(input: Doc0UploadInput): {
sourceSha256: string;
sourceSizeBytes: number;
requestFingerprint: string;
wireFilename: string;
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');
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');
}
const extension = /\.([a-z0-9]{1,16})$/i.exec(input.filename)?.[1]?.toLowerCase() ?? 'bin';
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');
return { sourceSha256, sourceSizeBytes, requestFingerprint, wireFilename, wireMimeType };
}
const MAX_RECEIPT_BYTES = 128 * 1024;
async function readBoundedJson(response: Response): Promise<unknown> {
if (!response.ok || !response.body) throw new Error('response unavailable');
const reader = response.body.getReader();
try {
const parts: Uint8Array[] = [];
let length = 0;
for (;;) {
const chunk = await reader.read();
if (chunk.done) break;
length += chunk.value.byteLength;
if (length > MAX_RECEIPT_BYTES) {
void reader.cancel().catch(() => undefined);
throw new Error('response too large');
}
parts.push(chunk.value);
}
return JSON.parse(new TextDecoder('utf-8', { fatal: true }).decode(Buffer.concat(parts))) as unknown;
} finally { reader.releaseLock(); }
}
/**
* Internal client only. The caller must enforce verified tenant/project/actor
* permissions and data handling policy. The coordinated DOC0/N0D/ST0RE
* sensitivity handling must be deployed and verified end to end before this
* is wired to customer uploads.
*/
export class Doc0UploadClient {
private readonly fetchImpl: typeof fetch;
private readonly baseUrl: string;
constructor(
private readonly options: {
baseUrl: string;
serviceToken: string;
maxBytes: number;
timeoutMs?: number;
fetchImpl?: typeof fetch;
},
) {
let url: URL;
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 ||
!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))
) {
throw new Error('A trusted HTTPS DOC0 endpoint, service credential and valid upload limits are required');
}
this.baseUrl = url.toString().replace(/\/+$/, '');
this.fetchImpl = options.fetchImpl ?? fetch;
}
async upload(input: Doc0UploadInput): Promise<Doc0UploadReceipt> {
const identity = prepareDoc0UploadIdentity(input);
if (identity.sourceSizeBytes > this.options.maxBytes) throw new Error('Invalid DOC0 upload input');
const expectedHash = identity.sourceSha256;
const body = new FormData();
body.set(
'input',
JSON.stringify({
projectId: input.projectId,
displayName: input.filename,
documentType: 'unknown',
classification: input.classification,
}),
);
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`, {
method: 'POST',
redirect: 'error',
headers: {
authorization: `Bearer ${this.options.serviceToken}`,
'x-tenant-id': input.tenantId,
'x-actor-id': input.actorId,
'x-request-id': input.requestId,
'x-idempotency-key': input.idempotencyKey,
},
body,
signal: AbortSignal.timeout(this.options.timeoutMs ?? 120_000),
});
} 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(); }
const data = decoded as {
document?: {
documentId?: unknown;
tenantId?: unknown;
projectId?: unknown;
currentVersionId?: unknown;
};
currentVersion?: {
versionId?: unknown;
tenantId?: unknown;
documentId?: unknown;
checksumSha256?: unknown;
sizeBytes?: unknown;
pipelineStage?: unknown;
};
};
const doc = data?.document;
const version = data?.currentVersion;
if (
!doc ||
!version ||
typeof doc.documentId !== 'string' ||
!doc.documentId ||
typeof version.versionId !== 'string' ||
!version.versionId ||
doc.tenantId !== input.tenantId ||
version.tenantId !== input.tenantId ||
doc.projectId !== input.projectId ||
version.documentId !== doc.documentId ||
doc.currentVersionId !== version.versionId ||
version.checksumSha256 !== expectedHash ||
version.sizeBytes !== input.bytes.byteLength ||
typeof version.pipelineStage !== 'string' ||
!version.pipelineStage
) {
throw new Doc0UploadOutcomeUnknownError();
}
return {
documentId: doc.documentId,
versionId: version.versionId,
checksumSha256: expectedHash,
pipelineStage: version.pipelineStage,
};
}
/**
* Observe only: neither `not_found` nor `completed_recorded` proves that
* an external write is absent, that storage is intact, or that extraction
* 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) {
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 }),
});
result = await readBoundedJson(response);
} 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) {
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()) {
return { status: 'completed_recorded', documentId: record.documentId, versionId: record.versionId };
}
throw new Doc0InspectionUnavailableError();
}
}