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
- 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.
112 lines
6.3 KiB
TypeScript
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 };
|
|
}
|
|
}
|