Files
0pt/src/document-workbench/doc0-source-client.ts
T
admin b3239487b7
CI (SIAX Cloud) / integration (push) Skipped
CI (SIAX Cloud) / sonar (push) Skipped
masterplan-lock / check (push) Successful in 10s
CI (SIAX Cloud) / sonar (pull_request) Skipped
Security Scan (SIAX Cloud) / security (push) Successful in 13s
masterplan-lock / check (pull_request) Successful in 7s
CI (SIAX Cloud) / security (push) Successful in 26s
Security Scan (SIAX Cloud) / security (pull_request) Successful in 17s
CI (SIAX Cloud) / security (pull_request) Successful in 19s
CI (SIAX Cloud) / quality (push) Successful in 1m9s
CI (SIAX Cloud) / quality (pull_request) Successful in 2m26s
CI (SIAX Cloud) / integration (pull_request) Successful in 2m33s
feat(document-workbench): wire customer upload, structuring, credits and readiness
- POST /api/0pt/documents/upload: real multipart bytes into DOC0 through the
  durable attempt journal and the existing coordinator; verified tenant/actor
  only, project authorization fail-closed.
- POST /api/0pt/documents/structure: DOC0 segments -> INF0 -> evidence-bound
  validation -> optional JSON/CSV export, with credit reserve/settle.
- Credits: balance, server-owned price book, Stripe Checkout and idempotent
  webhook reconciliation via a Stripe re-read of session + payment intent.
- /portal/dokument and /portal/krediter: SIAX-styled workspace.
- @siax/access: universal, framework-neutral OpenFGA project relationship
  primitive reusable by DOC0/N0D/other products.
- Readiness: real Redis PING probe instead of presence-only; queue is now a
  live dependency when configured.
- Dockerfile: copy every workspace manifest so --frozen-lockfile works.
- Format/build hygiene: .prettierignore, .worktrees ignored, valid manifest YAML.
2026-11-02 14:46:10 +01:00

112 lines
6.3 KiB
TypeScript

import { createHash } from 'node:crypto';
import type { Doc0UploadReceipt } from './doc0-upload-client';
import type { TextSource } from './structured-output-validation';
export class Doc0SourceUnavailableError extends Error {
constructor() { super('DOC0 extracted source could not be verified; review or reconciliation is required'); this.name = 'Doc0SourceUnavailableError'; }
}
const MAX_RESPONSE_BYTES = 1024 * 1024;
const MAX_SEGMENTS = 100;
const MAX_SOURCE_BYTES = 192 * 1024;
async function boundedJson(response: Response): Promise<unknown> {
if (!response.ok || !response.body) throw new Error('source unavailable');
const reader = response.body.getReader();
try {
const chunks: Uint8Array[] = [];
let size = 0;
for (;;) {
const part = await reader.read();
if (part.done) break;
size += part.value.byteLength;
if (size > MAX_RESPONSE_BYTES) { void reader.cancel().catch(() => undefined); throw new Error('source too large'); }
chunks.push(part.value);
}
return JSON.parse(new TextDecoder('utf-8', { fatal: true }).decode(Buffer.concat(chunks))) as unknown;
} finally { reader.releaseLock(); }
}
/**
* Read a bounded DOC0 index for a verified upload receipt. The caller must
* independently authenticate and authorize the tenant/project/actor, and
* DOC0 must enforce the same project policy. The segment index is not proof
* of OCR accuracy, semantic correctness, or complete source extraction.
*/
export class Doc0SourceClient {
private readonly baseUrl: string;
private readonly token: string;
private readonly fetchImpl: typeof fetch;
private readonly timeoutMs: number;
constructor(options: { baseUrl: string; serviceToken: string; fetchImpl?: typeof fetch; timeoutMs?: number }) {
let url: URL;
try { url = new URL(options.baseUrl); } catch { throw new Error('Invalid DOC0 source configuration'); }
if (url.protocol !== 'https:' || url.username || url.password || url.search || url.hash || !options.serviceToken.trim()
|| (options.timeoutMs !== undefined && (!Number.isSafeInteger(options.timeoutMs) || options.timeoutMs < 1000 || options.timeoutMs > 300000))) {
throw new Error('Invalid DOC0 source configuration');
}
this.baseUrl = url.toString().replace(/\/+$/, '');
this.token = options.serviceToken;
this.fetchImpl = options.fetchImpl ?? fetch;
this.timeoutMs = options.timeoutMs ?? 120000;
}
async read(input: {
tenantId: string; projectId: string; actorId: string; requestId: string; receipt: Doc0UploadReceipt;
}): Promise<{ sources: TextSource[]; reportedTextSha256: string }> {
const snapshot = structuredClone(input);
const { tenantId, projectId, actorId, requestId, receipt } = snapshot;
if (![tenantId, projectId, actorId, requestId, receipt?.documentId, receipt?.versionId]
.every((value) => typeof value === 'string' && /^[\x21-\x7e]{1,256}$/.test(value))
|| !/^[0-9a-f]{64}$/.test(receipt?.checksumSha256 ?? '') || receipt?.pipelineStage !== 'parsed') {
throw new Doc0SourceUnavailableError();
}
let value: unknown;
try {
const response = await this.fetchImpl(`${this.baseUrl}/v1/doc0/documents/${encodeURIComponent(receipt.documentId)}/versions/${encodeURIComponent(receipt.versionId)}/segments?limit=${MAX_SEGMENTS}&offset=0`, {
method: 'GET', redirect: 'error', cache: 'no-store', signal: AbortSignal.timeout(this.timeoutMs),
headers: { authorization: `Bearer ${this.token}`, 'x-tenant-id': tenantId, 'x-actor-id': actorId, 'x-request-id': requestId },
});
value = await boundedJson(response);
} catch { throw new Doc0SourceUnavailableError(); }
if (!value || typeof value !== 'object' || Array.isArray(value)) throw new Doc0SourceUnavailableError();
const page = value as { texts?: unknown; segments?: unknown; offset?: unknown };
if (page.offset !== 0 || !Array.isArray(page.texts) || page.texts.length !== 1 || !Array.isArray(page.segments)) {
throw new Doc0SourceUnavailableError();
}
const meta = page.texts[0] as Record<string, unknown> | null;
if (!meta || meta.tenantId !== tenantId || meta.documentId !== receipt.documentId || meta.versionId !== receipt.versionId
|| typeof meta.documentTextId !== 'string' || !meta.documentTextId || typeof meta.parserRunId !== 'string' || !meta.parserRunId
|| meta.segmentsTruncated !== false || !Number.isSafeInteger(meta.segmentCount) || (meta.segmentCount as number) < 1
|| (meta.segmentCount as number) > MAX_SEGMENTS || meta.segmentCount !== page.segments.length
|| !Number.isSafeInteger(meta.charCount) || (meta.charCount as number) < 1
|| typeof meta.textSha256 !== 'string' || !/^[0-9a-f]{64}$/.test(meta.textSha256)) {
throw new Doc0SourceUnavailableError();
}
const ids = new Set<string>();
const sources: TextSource[] = [];
let priorEnd = 0;
for (const [index, value] of page.segments.entries()) {
if (!value || typeof value !== 'object' || Array.isArray(value)) throw new Doc0SourceUnavailableError();
const segment = value as Record<string, unknown>;
if (segment.tenantId !== tenantId || segment.documentId !== receipt.documentId || segment.versionId !== receipt.versionId
|| segment.documentTextId !== meta.documentTextId || segment.parserRunId !== meta.parserRunId
|| segment.sourceVersionSha256 !== receipt.checksumSha256 || segment.sequence !== index
|| typeof segment.segmentId !== 'string' || !segment.segmentId || ids.has(segment.segmentId)
|| typeof segment.text !== 'string' || !segment.text || !Number.isSafeInteger(segment.charStart)
|| !Number.isSafeInteger(segment.charEnd) || (segment.charStart as number) < priorEnd
|| (segment.charEnd as number) - (segment.charStart as number) !== segment.text.length
|| (segment.charEnd as number) > (meta.charCount as number)
|| segment.segmentSha256 !== createHash('sha256').update(segment.text, 'utf8').digest('hex')) {
throw new Doc0SourceUnavailableError();
}
ids.add(segment.segmentId);
priorEnd = segment.charEnd as number;
sources.push({ id: segment.segmentId, text: segment.text });
}
if (Buffer.byteLength(JSON.stringify(sources), 'utf8') > MAX_SOURCE_BYTES) throw new Doc0SourceUnavailableError();
return { sources, reportedTextSha256: meta.textSha256 };
}
}