From f79853d156fccd7e7d7ff75c5225a80a74c319e4 Mon Sep 17 00:00:00 2001 From: sax3l Date: Mon, 2 Nov 2026 03:06:56 +0100 Subject: [PATCH] feat: inspect document upload journal from 0pt --- src/document-workbench/doc0-upload-client.ts | 100 ++++++++++++++---- .../doc0-upload-client.test.ts | 37 ++++++- 2 files changed, 117 insertions(+), 20 deletions(-) diff --git a/src/document-workbench/doc0-upload-client.ts b/src/document-workbench/doc0-upload-client.ts index d90ce7b..1aa6eb8 100644 --- a/src/document-workbench/doc0-upload-client.ts +++ b/src/document-workbench/doc0-upload-client.ts @@ -29,8 +29,50 @@ export class Doc0UploadOutcomeUnknownError extends Error { } } +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 }; + const MAX_RECEIPT_BYTES = 128 * 1024; +async function readBoundedJson(response: Response): Promise { + 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 @@ -114,27 +156,9 @@ export class Doc0UploadClient { }); } catch { throw new Doc0UploadOutcomeUnknownError(); } // Even a non-2xx response does not prove the write did not commit. - if (!response.ok || !response.body) throw new Doc0UploadOutcomeUnknownError(); let decoded: unknown; - let reader: ReadableStreamDefaultReader; - try { reader = response.body.getReader(); } + try { decoded = await readBoundedJson(response); } catch { throw new Doc0UploadOutcomeUnknownError(); } - 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('receipt too large'); - } - parts.push(chunk.value); - } - decoded = JSON.parse(new TextDecoder('utf-8', { fatal: true }).decode(Buffer.concat(parts))) as unknown; - } catch { throw new Doc0UploadOutcomeUnknownError(); } - finally { reader.releaseLock(); } const data = decoded as { document?: { documentId?: unknown; @@ -179,4 +203,42 @@ export class Doc0UploadClient { 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 { + 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; + 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(); + } } diff --git a/tests/document-workbench/doc0-upload-client.test.ts b/tests/document-workbench/doc0-upload-client.test.ts index 8d978d0..ed2ed5e 100644 --- a/tests/document-workbench/doc0-upload-client.test.ts +++ b/tests/document-workbench/doc0-upload-client.test.ts @@ -1,6 +1,6 @@ import { createHash } from 'node:crypto'; import { describe, expect, it, vi } from 'vitest'; -import { Doc0UploadClient, Doc0UploadOutcomeUnknownError } from '../../src/document-workbench/doc0-upload-client'; +import { Doc0UploadClient, Doc0UploadOutcomeUnknownError, Doc0InspectionUnavailableError } from '../../src/document-workbench/doc0-upload-client'; const bytes = new TextEncoder().encode('source bytes'); const input = { @@ -104,3 +104,38 @@ describe('DOC0 upload client', () => { lock.releaseLock(); }); }); + +describe('DOC0 upload inspection', () => { + const inspection = { tenantId: 'tenant-a', projectId: 'project-a', actorId: 'actor-a', requestId: 'req-a', idempotencyKey: 'idem-a', sourceSha256: 'a'.repeat(64), sourceSizeBytes: 12, requestFingerprint: 'b'.repeat(64) }; + it('sends only scoped metadata and returns the recorded journal state without retry', async () => { + const fetchImpl = vi.fn(async (_url: string | URL | Request, init?: RequestInit) => { + expect(init?.redirect).toBe('error'); + expect(init?.cache).toBe('no-store'); + expect(new Headers(init?.headers).get('x-idempotency-key')).toBe('idem-a'); + expect(JSON.parse(String(init?.body))).toEqual({ projectId: 'project-a', sourceSha256: 'a'.repeat(64), sourceSizeBytes: 12, requestFingerprint: 'b'.repeat(64) }); + return Response.json({ status: 'completed_recorded', documentId: 'doc', versionId: 'v1' }); + }); + const client = new Doc0UploadClient({ baseUrl: 'https://doc0.test', serviceToken: 'test', maxBytes: 100, fetchImpl }); + expect(await client.inspect(inspection)).toEqual({ status: 'completed_recorded', documentId: 'doc', versionId: 'v1' }); + expect(fetchImpl).toHaveBeenCalledOnce(); + }); + it('preserves weaker states without turning them into successful uploads', async () => { + for (const status of ['not_found', 'in_progress', 'reconciliation_required'] as const) { + const client = new Doc0UploadClient({ baseUrl: 'https://doc0.test', serviceToken: 'test', maxBytes: 100, fetchImpl: async () => Response.json({ status }) }); + expect(await client.inspect(inspection)).toEqual({ status }); + } + }); + it('fails closed on unknown, malformed, excessive or unavailable responses', async () => { + const responses = [Response.json({ status: 'completed' }), Response.json({ status: 'completed_recorded', documentId: 'doc' }), Response.json({ status: 'not_found', documentId: 'unexpected' }), new Response('x'.repeat(128 * 1024 + 1)), new Response('unavailable', { status: 503 })]; + for (const response of responses) { + const client = new Doc0UploadClient({ baseUrl: 'https://doc0.test', serviceToken: 'test', maxBytes: 100, fetchImpl: async () => response }); + await expect(client.inspect(inspection)).rejects.toBeInstanceOf(Doc0InspectionUnavailableError); + } + }); + it('rejects invalid fingerprints before sending a request', async () => { + const fetchImpl = vi.fn(); + const client = new Doc0UploadClient({ baseUrl: 'https://doc0.test', serviceToken: 'test', maxBytes: 100, fetchImpl }); + await expect(client.inspect({ ...inspection, requestFingerprint: 'bad' })).rejects.toThrow(/Invalid/); + expect(fetchImpl).not.toHaveBeenCalled(); + }); +}); -- 2.54.0