feat: inspect document upload journal from 0pt #415
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user