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
267 lines
11 KiB
TypeScript
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();
|
|
}
|
|
}
|