diff --git a/src/api/cryptify.ts b/src/api/cryptify.ts index 7172f426..a2b6c1b4 100644 --- a/src/api/cryptify.ts +++ b/src/api/cryptify.ts @@ -459,12 +459,14 @@ export function createUploadStream( onProgress?: (uploaded: number, last: boolean) => void; abortSignal?: AbortSignal; retry?: RetryOptions; + onUploadInit?: (info: { uuid: string; recoveryToken: string }) => void; } ): UploadStream { let state: FileState = { token: '', uuid: '', recoveryToken: '' }; let processed = 0; const signal = options.abortSignal; const onProgress = options.onProgress; + const onUploadInit = options.onUploadInit; const retry = resolveRetryOptions(options.retry); const queuingStrategy = new CountQueuingStrategy({ highWaterMark: 1 }); @@ -473,6 +475,7 @@ export function createUploadStream( async start(c) { try { state = await initUpload(cryptifyUrl, { ...options, signal }); + onUploadInit?.({ uuid: state.uuid, recoveryToken: state.recoveryToken }); onProgress?.(processed, false); if (signal?.aborted) throw new Error('Abort signaled during initFile.'); } catch (e) { diff --git a/src/crypto/encrypt.ts b/src/crypto/encrypt.ts index 6fbb832b..fb08f567 100644 --- a/src/crypto/encrypt.ts +++ b/src/crypto/encrypt.ts @@ -34,6 +34,9 @@ export interface EncryptPipelineOptions { signingKeys?: SigningKeys; /** Retry behaviour for chunk uploads. See PostGuardConfig.retry. */ retry?: RetryOptions; + /** Fires once, after `upload_init` succeeds and before any chunk PUT, + * with `{uuid, recoveryToken}`. See `UploadOptions.onUploadInit`. */ + onUploadInit?: (info: { uuid: string; recoveryToken: string }) => void; } /** Full encryption pipeline: sign -> policy -> ZIP -> seal -> upload */ @@ -97,6 +100,7 @@ export async function encryptPipeline(options: EncryptPipelineOptions): Promise< apiKey: cryptifyApiKey, abortSignal: effectiveSignal, retry: options.retry, + onUploadInit: options.onUploadInit, onProgress: (uploaded, last) => { if (onProgress) { const pct = totalSize > 0 ? Math.min(100, Math.round((uploaded / totalSize) * 100)) : 0; diff --git a/src/email/envelope.ts b/src/email/envelope.ts index 26ead691..d5275103 100644 --- a/src/email/envelope.ts +++ b/src/email/envelope.ts @@ -33,7 +33,7 @@ const DEFAULT_WEBSITE_URL = 'https://postguard.eu'; * Outlook's 1 M-char setAsync limit and was redundant with the * attachment / fragment link. */ export async function createEnvelope(options: CreateEnvelopeOptions): Promise { - const { sealed, from, unencryptedMessage, senderAttributes, notify } = options; + const { sealed, from, unencryptedMessage, senderAttributes, notify, onUploadInit } = options; const websiteUrl = options.websiteUrl ?? DEFAULT_WEBSITE_URL; const uploadToCryptify = options.uploadToCryptify ?? true; const logoUrl = `${websiteUrl}/pg_logo.png`; @@ -59,7 +59,8 @@ export async function createEnvelope(options: CreateEnvelopeOptions): Promise void; } // --- New API: Open/decrypt input --- @@ -236,6 +242,12 @@ export interface CreateEnvelopeOptions { * Has no effect on Tier 1 (no upload happens) or when * `uploadToCryptify: false` already skipped the upload. */ notify?: UploadOptions['notify']; + /** Forwarded to the underlying `Sealed.upload` call. Called once after + * `upload_init` succeeds and before any chunk PUT, with the Cryptify + * `uuid` and `recoveryToken`. Persist this pair to enable cross-restart + * resume via `resumeUpload`. No-op when no upload happens (tier 1, or + * `uploadToCryptify: false`). */ + onUploadInit?: UploadOptions['onUploadInit']; } /** Which tier the envelope falls into based on encrypted payload size. diff --git a/tests/api.test.ts b/tests/api.test.ts index 654781f8..a70b687a 100644 --- a/tests/api.test.ts +++ b/tests/api.test.ts @@ -9,6 +9,7 @@ import { finalizeUpload, downloadFile, downloadFileWithRetry, + createUploadStream, } from '../src/api/cryptify.js'; import { resolveRetryOptions } from '../src/util/retry.js'; @@ -971,4 +972,62 @@ describe('Cryptify API', () => { expect(mockFetch).toHaveBeenCalledTimes(1); }); }); + + describe('createUploadStream onUploadInit', () => { + it('fires once with {uuid, recoveryToken} after upload_init, before any chunk PUT', async () => { + mockFetch.mockImplementation((_url: string, init: RequestInit) => { + if (init?.method === 'PUT') { + return Promise.resolve({ + ok: true, + status: 200, + text: () => Promise.resolve(''), + headers: new Headers({ cryptifytoken: 'tok-next' }), + }); + } + // POST: init or finalize + return Promise.resolve({ + ok: true, + status: 200, + json: () => Promise.resolve({ uuid: 'u-1', recovery_token: 'rec-abc' }), + text: () => Promise.resolve(''), + headers: new Headers({ cryptifytoken: 'tok-0' }), + }); + }); + + const calls: Array<{ uuid: string; recoveryToken: string }> = []; + let initSeenBeforeFirstPut = false; + + const stream = createUploadStream('https://cryptify.example.com', { + recipient: 'a@b.com', + onUploadInit: (info) => { + calls.push(info); + // No PUT should have happened yet — init is the only fetch. + initSeenBeforeFirstPut = + mockFetch.mock.calls.length === 1 && + mockFetch.mock.calls[0][0].endsWith('/fileupload/init'); + }, + }); + + const writer = stream.writable.getWriter(); + await writer.write(new Uint8Array([1, 2, 3])); + await writer.close(); + + expect(calls).toEqual([{ uuid: 'u-1', recoveryToken: 'rec-abc' }]); + expect(initSeenBeforeFirstPut).toBe(true); + }); + + it('is not called when upload_init fails', async () => { + mockFetch.mockResolvedValueOnce(errorResponse(400, 'bad')); + + const onUploadInit = vi.fn(); + const stream = createUploadStream('https://cryptify.example.com', { + recipient: 'a@b.com', + onUploadInit, + }); + + const writer = stream.writable.getWriter(); + await expect(writer.write(new Uint8Array([1]))).rejects.toBeDefined(); + expect(onUploadInit).not.toHaveBeenCalled(); + }); + }); });