feat: inspect document upload journal from 0pt #415

Merged
admin merged 1 commits from codex/doc0-inspection-client into main 2026-11-02 02:12:17 +00:00
2 changed files with 117 additions and 20 deletions
+81 -19
View File
@@ -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<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
@@ -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<Uint8Array>;
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<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();
}
}
@@ -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();
});
});