From 16eafc147cfd662853e83f3529f34a4e3c9f72ae Mon Sep 17 00:00:00 2001 From: Benjamin Poile Date: Mon, 13 Jul 2026 22:55:13 -0700 Subject: [PATCH 01/13] feat(payloads): streaming payloads client (SDK) + primitive payloads push/pull (CLI) @primitivedotdev/sdk/payloads: streaming, bounded-memory push/pull for large content-addressed, end-to-end-encrypted objects. Chunk/manifest construction is byte-compatible with the server object model (64 MiB chunks, per-chunk HKDF AES-256-GCM, SHA-256 content addressing, Merkle manifest). Two-pass push keeps memory flat regardless of object size; transient 429/5xx are retried with backoff. CLI adds payloads push/pull on top. Local/unpublished: verified a 5 GiB round-trip against staging (integrity OK). --- cli-node/src/oclif/commands/payloads.ts | 165 ++++++++ cli-node/src/oclif/index.ts | 7 + sdk-node/package.json | 5 + sdk-node/src/payloads/index.ts | 486 ++++++++++++++++++++++++ sdk-node/tsdown.config.ts | 1 + 5 files changed, 664 insertions(+) create mode 100644 cli-node/src/oclif/commands/payloads.ts create mode 100644 sdk-node/src/payloads/index.ts diff --git a/cli-node/src/oclif/commands/payloads.ts b/cli-node/src/oclif/commands/payloads.ts new file mode 100644 index 00000000..f8854de9 --- /dev/null +++ b/cli-node/src/oclif/commands/payloads.ts @@ -0,0 +1,165 @@ +import { Args, Command, Flags } from "@oclif/core"; +import { + type ProgressFn, + pullFile, + pushFile, +} from "@primitivedotdev/sdk/payloads"; +import { resolveCliAuth } from "../auth.js"; + +const DEFAULT_API_BASE_URL = "https://api.primitive.dev"; + +const authFlags = { + "api-key": Flags.string({ + description: + "Primitive API key (defaults to PRIMITIVE_API_KEY or saved login credentials)", + env: "PRIMITIVE_API_KEY", + }), + "api-base-url": Flags.string({ + description: "Override the API base URL. Internal testing only.", + env: "PRIMITIVE_API_BASE_URL", + hidden: true, + }), +}; + +function resolveClient( + configDir: string, + flags: { "api-key"?: string; "api-base-url"?: string }, +): { baseUrl: string; apiKey: string } { + const auth = resolveCliAuth({ + configDir, + apiKey: flags["api-key"], + apiBaseUrl: flags["api-base-url"], + }); + if (!auth.apiKey) { + throw new Error( + "Not authenticated: set PRIMITIVE_API_KEY, pass --api-key, or run `primitive login`.", + ); + } + return { + baseUrl: auth.apiBaseUrl ?? DEFAULT_API_BASE_URL, + apiKey: auth.apiKey, + }; +} + +function progressToStderr(): ProgressFn { + let lastPct = -1; + return (phase, done, total) => { + const pct = total === 0 ? 100 : Math.floor((done / total) * 100); + if (pct !== lastPct) { + lastPct = pct; + process.stderr.write(`\r${phase}: ${done}/${total} (${pct}%) `); + if (done === total) process.stderr.write("\n"); + } + }; +} + +export class PayloadsPushCommand extends Command { + static description = + `Upload a file as a Primitive Payload — a large, content-addressed, end-to-end-encrypted object. + + The file is chunked and encrypted client-side and streamed up in bounded + memory (multi-GB files never load fully into RAM). Prints the object's content + address (merkle_root) and the hex CEK required to download it — keep the CEK + secret; without it the object cannot be decrypted.`; + + static summary = "Stream-upload a file as an encrypted payload"; + static examples = ["<%= config.bin %> payloads push ./big-video.mp4"]; + + static args = { + file: Args.string({ + required: true, + description: "Path to the file to upload", + }), + }; + + static flags = { + ...authFlags, + concurrency: Flags.integer({ + description: "Parallel chunk uploads", + default: 3, + }), + quiet: Flags.boolean({ + description: "Suppress progress output", + default: false, + }), + }; + + async run(): Promise { + const { args, flags } = await this.parse(PayloadsPushCommand); + const { baseUrl, apiKey } = resolveClient(this.config.configDir, flags); + const result = await pushFile(args.file, { + baseUrl, + apiKey, + concurrency: flags.concurrency, + onProgress: flags.quiet ? undefined : progressToStderr(), + }); + this.log( + JSON.stringify( + { + merkle_root: result.merkleRoot, + cek: result.cek, + chunk_count: result.chunkCount, + total_bytes: result.totalBytes, + }, + null, + 2, + ), + ); + } +} + +export class PayloadsPullCommand extends Command { + static description = `Download and decrypt a Primitive Payload to a file. + + Streams one chunk at a time, verifying each against its content address before + decryption, so a corrupt or substituted chunk fails loudly. Requires the hex + CEK printed by \`payloads push\`.`; + + static summary = "Stream-download and decrypt a payload to a file"; + static examples = [ + "<%= config.bin %> payloads pull --cek --out ./restored.mp4", + ]; + + static args = { + root: Args.string({ + required: true, + description: "Object content address (merkle_root)", + }), + }; + + static flags = { + ...authFlags, + out: Flags.string({ required: true, description: "Output file path" }), + cek: Flags.string({ + required: true, + description: "Hex content-encryption key from `payloads push`", + }), + quiet: Flags.boolean({ + description: "Suppress progress output", + default: false, + }), + }; + + async run(): Promise { + const { args, flags } = await this.parse(PayloadsPullCommand); + const { baseUrl, apiKey } = resolveClient(this.config.configDir, flags); + const manifest = await pullFile(args.root, flags.out, { + baseUrl, + apiKey, + cek: flags.cek, + onProgress: flags.quiet ? undefined : progressToStderr(), + }); + this.log( + JSON.stringify( + { + merkle_root: args.root, + out: flags.out, + chunk_count: manifest.chunkCount, + total_bytes: manifest.totalPlaintextSize, + }, + null, + 2, + ), + ); + } +} diff --git a/cli-node/src/oclif/index.ts b/cli-node/src/oclif/index.ts index d50e5259..7b5137e4 100644 --- a/cli-node/src/oclif/index.ts +++ b/cli-node/src/oclif/index.ts @@ -39,6 +39,7 @@ import { MemoriesSetCommand, } from "./commands/memories.js"; import OrgSecretsListCommand from "./commands/org-secrets-list.js"; +import { PayloadsPullCommand, PayloadsPushCommand } from "./commands/payloads.js"; import OrgSecretsRemoveCommand from "./commands/org-secrets-remove.js"; import OrgSecretsSetCommand from "./commands/org-secrets-set.js"; import PaymentsChallengeFromEmailCommand from "./commands/payments-challenge-from-email.js"; @@ -610,6 +611,12 @@ export const COMMANDS: Record = { "org:secrets:list": OrgSecretsListCommand, "org:secrets:set": OrgSecretsSetCommand, "org:secrets:remove": OrgSecretsRemoveCommand, + // `payloads:push` / `payloads:pull` stream large (multi-GB) content-addressed, + // end-to-end-encrypted objects up and down in bounded memory. Hand-rolled on + // the SDK's streaming payloads client; the routes are not on the generated + // client (openapi:false server-side). + "payloads:push": PayloadsPushCommand, + "payloads:pull": PayloadsPullCommand, // `functions:test-function` is hand-rolled to add --wait, --show-sends, // and --timeout on top of POST /functions/{id}/test. Without those // flags, agents had to manually thread queued-send + emails:wait + diff --git a/sdk-node/package.json b/sdk-node/package.json index 67dbe402..f87f9b7d 100644 --- a/sdk-node/package.json +++ b/sdk-node/package.json @@ -45,6 +45,11 @@ "types": "./dist/x402/index.d.ts", "import": "./dist/x402/index.js", "default": "./dist/x402/index.js" + }, + "./payloads": { + "types": "./dist/payloads/index.d.ts", + "import": "./dist/payloads/index.js", + "default": "./dist/payloads/index.js" } }, "sideEffects": false, diff --git a/sdk-node/src/payloads/index.ts b/sdk-node/src/payloads/index.ts new file mode 100644 index 00000000..c0c29cb5 --- /dev/null +++ b/sdk-node/src/payloads/index.ts @@ -0,0 +1,486 @@ +/** + * Primitive Payloads — streaming client for large, content-addressed, + * end-to-end-encrypted objects (attachments up to ~1 TB). + * + * Objects are split into fixed 64 MiB chunks; each chunk is encrypted with a + * per-chunk AES-256-GCM key (HKDF-derived from the object CEK), content-addressed + * by its ciphertext SHA-256, and committed under a Merkle root. This module + * streams: it processes one chunk at a time and never holds the whole object in + * memory, so a multi-GB file uploads/downloads in bounded memory. + * + * The chunk/manifest construction is byte-compatible with the server's object + * model (packages/payloads-core in the API monorepo). + */ +import { createWriteStream } from "node:fs"; +import { type FileHandle, open, stat } from "node:fs/promises"; + +// ── Object-model constants (must match the server's payloads-core) ── +const CHUNK_SIZE = 64 * 1024 * 1024; +const MANIFEST_VERSION = 1; +const CHUNK_KDF_INFO = "payloads-chunk"; +const OBJECT_ID_BYTES = 16; +const CEK_BYTES = 32; + +const subtle = globalThis.crypto.subtle; +const textEncoder = new TextEncoder(); + +// ── Crypto / hashing primitives (byte-identical to payloads-core) ── +function randomBytes(length: number): Uint8Array { + const out = new Uint8Array(length); + globalThis.crypto.getRandomValues(out); + return out; +} + +function toHex(bytes: Uint8Array): string { + let out = ""; + for (const b of bytes) out += b.toString(16).padStart(2, "0"); + return out; +} + +function fromHex(hex: string): Uint8Array { + if (hex.length % 2 !== 0) throw new Error("invalid hex: odd length"); + if (hex.length > 0 && !/^[0-9a-fA-F]+$/.test(hex)) { + throw new Error("invalid hex: non-hex character"); + } + const out = new Uint8Array(hex.length / 2); + for (let i = 0; i < out.length; i++) + out[i] = Number.parseInt(hex.slice(i * 2, i * 2 + 2), 16); + return out; +} + +async function contentHash(bytes: Uint8Array): Promise { + return new Uint8Array(await subtle.digest("SHA-256", bytes as BufferSource)); +} + +async function contentHashHex(bytes: Uint8Array): Promise { + return toHex(await contentHash(bytes)); +} + +/** Binary Merkle root over ordered hex leaf hashes (odd node promoted). */ +async function merkleRoot(leafHashesHex: string[]): Promise { + if (leafHashesHex.length === 0) return contentHashHex(new Uint8Array(0)); + let level = leafHashesHex.map(fromHex); + while (level.length > 1) { + const next: Uint8Array[] = []; + for (let i = 0; i < level.length; i += 2) { + const left = level[i]; + const right = i + 1 < level.length ? level[i + 1] : level[i]; + const combined = new Uint8Array(left.length + right.length); + combined.set(left, 0); + combined.set(right, left.length); + next.push(await contentHash(combined)); + } + level = next; + } + return toHex(level[0]); +} + +async function deriveChunkKey( + cek: Uint8Array, + index: number, +): Promise { + const baseKey = await subtle.importKey( + "raw", + cek as BufferSource, + "HKDF", + false, + ["deriveKey"], + ); + const info = textEncoder.encode(`${CHUNK_KDF_INFO}:${index}`); + return subtle.deriveKey( + { + name: "HKDF", + hash: "SHA-256", + salt: new Uint8Array(32) as BufferSource, + info: info as BufferSource, + }, + baseKey, + { name: "AES-GCM", length: 256 }, + false, + ["encrypt", "decrypt"], + ); +} + +function chunkNonce(index: number): Uint8Array { + const nonce = new Uint8Array(12); + new DataView(nonce.buffer).setUint32(8, index >>> 0, false); + return nonce; +} + +function chunkAad(objectId: Uint8Array, index: number): Uint8Array { + const aad = new Uint8Array(objectId.length + 4); + aad.set(objectId, 0); + new DataView(aad.buffer).setUint32(objectId.length, index >>> 0, false); + return aad; +} + +async function encryptChunk( + cek: Uint8Array, + objectId: Uint8Array, + index: number, + plaintext: Uint8Array, +): Promise { + const key = await deriveChunkKey(cek, index); + const ct = await subtle.encrypt( + { + name: "AES-GCM", + iv: chunkNonce(index) as BufferSource, + additionalData: chunkAad(objectId, index) as BufferSource, + }, + key, + plaintext as BufferSource, + ); + return new Uint8Array(ct); +} + +async function decryptChunk( + cek: Uint8Array, + objectId: Uint8Array, + index: number, + ciphertext: Uint8Array, +): Promise { + const key = await deriveChunkKey(cek, index); + const pt = await subtle.decrypt( + { + name: "AES-GCM", + iv: chunkNonce(index) as BufferSource, + additionalData: chunkAad(objectId, index) as BufferSource, + }, + key, + ciphertext as BufferSource, + ); + return new Uint8Array(pt); +} + +// ── Manifest / HTTP types ── +export interface ChunkDescriptor { + index: number; + ciphertextHash: string; + plaintextSize: number; + ciphertextSize: number; +} + +export interface PayloadManifest { + version: number; + objectId: string; + chunkSize: number; + totalPlaintextSize: number; + chunkCount: number; + chunks: ChunkDescriptor[]; + merkleRoot: string; +} + +export interface PayloadClientOptions { + /** API base URL, e.g. https://api.primitive.dev (no trailing /v1). */ + baseUrl: string; + /** Bearer API key. */ + apiKey: string; +} + +export type ProgressPhase = "encrypt" | "upload" | "download"; +export type ProgressFn = ( + phase: ProgressPhase, + done: number, + total: number, +) => void; + +export interface PushOptions extends PayloadClientOptions { + chunkSize?: number; + concurrency?: number; + onProgress?: ProgressFn; +} + +export interface PushResult { + merkleRoot: string; + /** Hex-encoded content-encryption key; required to download+decrypt. */ + cek: string; + chunkCount: number; + totalBytes: number; +} + +export interface PullOptions extends PayloadClientOptions { + /** Hex-encoded CEK from push. */ + cek: string; + concurrency?: number; + onProgress?: ProgressFn; +} + +// ── HTTP helpers ── +function authHeaders(apiKey: string): Record { + return { authorization: `Bearer ${apiKey}` }; +} + +function apiRoot(baseUrl: string): string { + return `${baseUrl.replace(/\/+$/, "")}/v1/payloads`; +} + +const DEFAULT_MAX_RETRIES = 6; +// Transient statuses worth retrying: rate limiting and gateway/overload errors. +// A multi-GB transfer is dozens–thousands of requests, so an occasional 503/429 +// from the edge is expected and must not fail the whole object. +const RETRYABLE_STATUS = new Set([429, 500, 502, 503, 504]); + +function sleep(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +/** + * fetch with exponential backoff on transient failures. Request bodies here are + * in-memory buffers/strings (never streams), so a retry can safely resend them. + */ +async function retryingFetch( + url: string, + init: RequestInit, + label: string, + maxRetries = DEFAULT_MAX_RETRIES, +): Promise { + for (let attempt = 0; ; attempt++) { + let res: Response | undefined; + let networkError: unknown; + try { + res = await fetch(url, init); + } catch (err) { + networkError = err; + } + if (res && !RETRYABLE_STATUS.has(res.status)) return res; + if (attempt >= maxRetries) { + if (res) return res; // let the caller surface the final non-ok status + throw new Error( + `${label}: network error after ${attempt} retries: ${(networkError as Error)?.message ?? networkError}`, + ); + } + if (res) await res.text().catch(() => {}); // drain so the socket can be reused + const backoffMs = + Math.min(1000 * 2 ** attempt, 15_000) + Math.floor(Math.random() * 250); + await sleep(backoffMs); + } +} + +async function initiate( + opts: PayloadClientOptions, + manifest: PayloadManifest, +): Promise { + const res = await retryingFetch( + apiRoot(opts.baseUrl), + { + method: "POST", + headers: { + ...authHeaders(opts.apiKey), + "content-type": "application/json", + }, + body: JSON.stringify({ manifest }), + }, + "initiate", + ); + if (!res.ok) + throw new Error(`initiate failed: HTTP ${res.status} ${await res.text()}`); + await res.text(); +} + +async function putChunk( + opts: PayloadClientOptions, + root: string, + d: ChunkDescriptor, + bytes: Uint8Array, +): Promise { + const res = await retryingFetch( + `${apiRoot(opts.baseUrl)}/${root}/chunks/${d.ciphertextHash}`, + { + method: "PUT", + headers: { + ...authHeaders(opts.apiKey), + "content-type": "application/octet-stream", + }, + body: bytes as BodyInit, + }, + `chunk ${d.index} upload`, + ); + if (!res.ok) + throw new Error( + `chunk ${d.index} upload failed: HTTP ${res.status} ${await res.text()}`, + ); + await res.text(); +} + +async function finalize( + opts: PayloadClientOptions, + root: string, +): Promise { + const res = await retryingFetch( + `${apiRoot(opts.baseUrl)}/${root}/finalize`, + { method: "POST", headers: authHeaders(opts.apiKey) }, + "finalize", + ); + if (!res.ok) + throw new Error(`finalize failed: HTTP ${res.status} ${await res.text()}`); + await res.text(); +} + +export async function fetchManifest( + opts: PayloadClientOptions, + root: string, +): Promise { + const res = await retryingFetch( + `${apiRoot(opts.baseUrl)}/${root}/manifest`, + { headers: authHeaders(opts.apiKey) }, + "get manifest", + ); + if (!res.ok) + throw new Error( + `get manifest failed: HTTP ${res.status} ${await res.text()}`, + ); + const body = (await res.json()) as { data: { manifest: PayloadManifest } }; + return body.data.manifest; +} + +async function getChunkBytes( + opts: PayloadClientOptions, + root: string, + d: ChunkDescriptor, +): Promise { + const res = await retryingFetch( + `${apiRoot(opts.baseUrl)}/${root}/chunks/${d.ciphertextHash}`, + { headers: authHeaders(opts.apiKey) }, + `get chunk ${d.index}`, + ); + if (!res.ok) + throw new Error(`get chunk ${d.index} failed: HTTP ${res.status}`); + return new Uint8Array(await res.arrayBuffer()); +} + +// ── Streaming file I/O ── +async function readWindow( + fh: FileHandle, + position: number, + length: number, +): Promise { + const buf = Buffer.allocUnsafe(length); + let read = 0; + while (read < length) { + const { bytesRead } = await fh.read( + buf, + read, + length - read, + position + read, + ); + if (bytesRead === 0) break; + read += bytesRead; + } + return read === length ? buf : buf.subarray(0, read); +} + +async function mapLimit( + items: T[], + limit: number, + fn: (item: T, i: number) => Promise, +): Promise { + let next = 0; + const worker = async (): Promise => { + while (next < items.length) { + const i = next++; + await fn(items[i], i); + } + }; + await Promise.all( + Array.from({ length: Math.min(limit, items.length || 1) }, () => worker()), + ); +} + +/** + * Stream a file up as a Primitive Payload. Two passes over the file (encrypt to + * build the manifest, then encrypt again to upload) keep memory bounded to a few + * chunks — the whole object is never resident. Returns the content address + * (merkleRoot) and the hex CEK needed to download it. + */ +export async function pushFile( + filePath: string, + opts: PushOptions, +): Promise { + const chunkSize = opts.chunkSize ?? CHUNK_SIZE; + const concurrency = opts.concurrency ?? 3; + const { size } = await stat(filePath); + const cek = randomBytes(CEK_BYTES); + const objectId = randomBytes(OBJECT_ID_BYTES); + const chunkCount = size === 0 ? 0 : Math.ceil(size / chunkSize); + + const fh = await open(filePath, "r"); + try { + // Pass 1: content-address every chunk to build the manifest. + const descriptors: ChunkDescriptor[] = []; + for (let index = 0; index < chunkCount; index++) { + const position = index * chunkSize; + const length = Math.min(chunkSize, size - position); + const plaintext = await readWindow(fh, position, length); + const ciphertext = await encryptChunk(cek, objectId, index, plaintext); + descriptors.push({ + index, + ciphertextHash: await contentHashHex(ciphertext), + plaintextSize: length, + ciphertextSize: ciphertext.length, + }); + opts.onProgress?.("encrypt", index + 1, chunkCount); + } + + const root = await merkleRoot(descriptors.map((d) => d.ciphertextHash)); + const manifest: PayloadManifest = { + version: MANIFEST_VERSION, + objectId: toHex(objectId), + chunkSize, + totalPlaintextSize: size, + chunkCount, + chunks: descriptors, + merkleRoot: root, + }; + + await initiate(opts, manifest); + + // Pass 2: re-encrypt (deterministic) and upload, bounded concurrency. + let uploaded = 0; + await mapLimit(descriptors, concurrency, async (d) => { + const position = d.index * chunkSize; + const plaintext = await readWindow(fh, position, d.plaintextSize); + const ciphertext = await encryptChunk(cek, objectId, d.index, plaintext); + await putChunk(opts, root, d, ciphertext); + opts.onProgress?.("upload", ++uploaded, chunkCount); + }); + + await finalize(opts, root); + return { merkleRoot: root, cek: toHex(cek), chunkCount, totalBytes: size }; + } finally { + await fh.close(); + } +} + +/** + * Stream a Primitive Payload down to a file, verifying and decrypting one chunk + * at a time (bounded memory). Every chunk is content-address-checked before + * decryption, so a corrupt or substituted chunk throws. + */ +export async function pullFile( + root: string, + outPath: string, + opts: PullOptions, +): Promise { + const manifest = await fetchManifest(opts, root); + const cek = fromHex(opts.cek); + const objectId = fromHex(manifest.objectId); + const out = createWriteStream(outPath); + try { + let done = 0; + for (const d of manifest.chunks) { + const ciphertext = await getChunkBytes(opts, root, d); + if ((await contentHashHex(ciphertext)) !== d.ciphertextHash) { + throw new Error(`chunk ${d.index} failed integrity check`); + } + const plaintext = await decryptChunk(cek, objectId, d.index, ciphertext); + if (!out.write(plaintext)) + await new Promise((r) => out.once("drain", r)); + opts.onProgress?.("download", ++done, manifest.chunkCount); + } + } finally { + await new Promise((resolve, reject) => + out.end((err?: Error | null) => (err ? reject(err) : resolve())), + ); + } + return manifest; +} diff --git a/sdk-node/tsdown.config.ts b/sdk-node/tsdown.config.ts index aa69c1d9..f39c2c08 100644 --- a/sdk-node/tsdown.config.ts +++ b/sdk-node/tsdown.config.ts @@ -10,6 +10,7 @@ export default defineConfig({ "src/parser/index.ts", "src/parser/address.ts", "src/x402/index.ts", + "src/payloads/index.ts", ], format: ["esm"], // Keep `.js` / `.d.ts` extensions so the existing `package.json` exports map From e40d91b614de9140f4d6c63c70ce99c0fff87d20 Mon Sep 17 00:00:00 2001 From: Benjamin Poile Date: Tue, 14 Jul 2026 10:50:43 -0700 Subject: [PATCH 02/13] feat(payloads): add encodeManifest (deterministic in-memory manifest) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Exposes the chunk/crypto/Merkle construction as a pure function for precomputing an object's content address, convergent encoding, and conformance-testing against the server object model. A deterministic dual-encode (same cek/objectId) against payloads-core produced a byte-identical manifest — the vendored client primitives are verified byte-compatible with the reference implementation. --- sdk-node/src/payloads/index.ts | 51 ++++++++++++++++++++++++++++++++++ 1 file changed, 51 insertions(+) diff --git a/sdk-node/src/payloads/index.ts b/sdk-node/src/payloads/index.ts index c0c29cb5..6465204d 100644 --- a/sdk-node/src/payloads/index.ts +++ b/sdk-node/src/payloads/index.ts @@ -348,6 +348,57 @@ async function getChunkBytes( return new Uint8Array(await res.arrayBuffer()); } +export interface EncodeManifestOptions { + chunkSize?: number; + /** Hex CEK for deterministic/convergent encoding (default: random). */ + cekHex?: string; + /** Hex object id for deterministic encoding (default: random). */ + objectIdHex?: string; +} + +/** + * Encode in-memory bytes into a payload manifest (no upload). Exposed for + * precomputing an object's content address and for conformance-testing the + * chunk/crypto/Merkle construction against the server object model. With a fixed + * cek/objectId the output is fully deterministic. + */ +export async function encodeManifest( + bytes: Uint8Array, + opts: EncodeManifestOptions = {}, +): Promise { + const chunkSize = opts.chunkSize ?? CHUNK_SIZE; + const cek = opts.cekHex ? fromHex(opts.cekHex) : randomBytes(CEK_BYTES); + const objectId = opts.objectIdHex + ? fromHex(opts.objectIdHex) + : randomBytes(OBJECT_ID_BYTES); + const chunkCount = + bytes.length === 0 ? 0 : Math.ceil(bytes.length / chunkSize); + const descriptors: ChunkDescriptor[] = []; + for (let index = 0; index < chunkCount; index++) { + const start = index * chunkSize; + const plaintext = bytes.subarray( + start, + Math.min(start + chunkSize, bytes.length), + ); + const ciphertext = await encryptChunk(cek, objectId, index, plaintext); + descriptors.push({ + index, + ciphertextHash: await contentHashHex(ciphertext), + plaintextSize: plaintext.length, + ciphertextSize: ciphertext.length, + }); + } + return { + version: MANIFEST_VERSION, + objectId: toHex(objectId), + chunkSize, + totalPlaintextSize: bytes.length, + chunkCount, + chunks: descriptors, + merkleRoot: await merkleRoot(descriptors.map((d) => d.ciphertextHash)), + }; +} + // ── Streaming file I/O ── async function readWindow( fh: FileHandle, From 790c8e8e586a8cfc465a5f45d7c96c8756dc5033 Mon Sep 17 00:00:00 2001 From: Benjamin Poile Date: Tue, 14 Jul 2026 12:34:26 -0700 Subject: [PATCH 03/13] test(payloads): conformance guard vs server object model MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Golden vector generated from the server reference implementation (@primitivedotdev/payloads-core in the mono-repo) pins the SDK's vendored chunk/crypto/Merkle construction to the server's. encodeManifest must reproduce the fixture manifest byte-for-byte; any drift in HKDF/AES-GCM, content hashing, or Merkle layout fails the build — catching interop/dedup breakage before it ships. Runs with the normal SDK test suite; verified it fails on a one-char KDF change. --- sdk-node/tests/payloads/conformance.test.ts | 71 +++++++++++++++++++ .../payloads/conformance-vector.json | 44 ++++++++++++ 2 files changed, 115 insertions(+) create mode 100644 sdk-node/tests/payloads/conformance.test.ts create mode 100644 test-fixtures/payloads/conformance-vector.json diff --git a/sdk-node/tests/payloads/conformance.test.ts b/sdk-node/tests/payloads/conformance.test.ts new file mode 100644 index 00000000..ff5940af --- /dev/null +++ b/sdk-node/tests/payloads/conformance.test.ts @@ -0,0 +1,71 @@ +import { readFileSync } from "node:fs"; +import { fileURLToPath } from "node:url"; +import { describe, expect, it } from "vitest"; +import { + encodeManifest, + type PayloadManifest, +} from "../../src/payloads/index.js"; + +// Conformance guard for the Primitive Payloads object model. +// +// The SDK carries its own copy of the chunk/crypto/Merkle construction so the +// public package needs no low-level dependency. This test pins that copy to the +// SERVER reference implementation via a golden vector generated from the mono-repo +// (@primitivedotdev/payloads-core). If anyone changes the client's HKDF/AES-GCM, +// content hashing, or Merkle layout — or the server's — the produced manifest +// stops matching the vector and this fails, catching drift that would otherwise +// silently break cross-decrypt and content-address dedup between client and server. +// +// Regenerate the vector from the mono-repo when the object model changes on purpose. + +interface ConformanceVector { + input: { sizeBytes: number; recipe: string }; + cekHex: string; + objectIdHex: string; + chunkSize: number; + expectedManifest: PayloadManifest; +} + +const vector = JSON.parse( + readFileSync( + fileURLToPath( + new URL( + "../../../test-fixtures/payloads/conformance-vector.json", + import.meta.url, + ), + ), + "utf8", + ), +) as ConformanceVector; + +/** Deterministic input matching the fixture recipe: byteAt(i) = (i * 31 + 7) & 0xff. */ +function buildInput(size: number): Uint8Array { + const bytes = new Uint8Array(size); + for (let i = 0; i < size; i++) bytes[i] = (i * 31 + 7) & 0xff; + return bytes; +} + +describe("payloads object-model conformance (vs server reference)", () => { + it("reproduces the server-generated manifest byte-for-byte", async () => { + const input = buildInput(vector.input.sizeBytes); + const manifest = await encodeManifest(input, { + cekHex: vector.cekHex, + objectIdHex: vector.objectIdHex, + chunkSize: vector.chunkSize, + }); + expect(manifest).toEqual(vector.expectedManifest); + }); + + it("matches per-chunk content addresses and the Merkle root", async () => { + const input = buildInput(vector.input.sizeBytes); + const manifest = await encodeManifest(input, { + cekHex: vector.cekHex, + objectIdHex: vector.objectIdHex, + chunkSize: vector.chunkSize, + }); + expect(manifest.chunks.map((c) => c.ciphertextHash)).toEqual( + vector.expectedManifest.chunks.map((c) => c.ciphertextHash), + ); + expect(manifest.merkleRoot).toBe(vector.expectedManifest.merkleRoot); + }); +}); diff --git a/test-fixtures/payloads/conformance-vector.json b/test-fixtures/payloads/conformance-vector.json new file mode 100644 index 00000000..8c08bd48 --- /dev/null +++ b/test-fixtures/payloads/conformance-vector.json @@ -0,0 +1,44 @@ +{ + "_comment": "Cross-implementation conformance vector for the Primitive Payloads object model. Generated from the SERVER reference implementation (@primitivedotdev/payloads-core in the primitive mono-repo). Any payloads client (this SDK, and future Python/Go SDKs) MUST reproduce `expectedManifest` byte-for-byte for the given input + cek + objectId. A mismatch means the client crypto / content-addressing / Merkle construction has drifted from the server and would break interop, cross-decrypt, and content-address dedup. Regenerate from the mono-repo (packages/payloads-format) whenever the object model changes.", + "input": { + "sizeBytes": 200000, + "recipe": "byteAt(i) = (i * 31 + 7) & 0xff" + }, + "cekHex": "01080f161d242b323940474e555c636a71787f868d949ba2a9b0b7bec5ccd3da", + "objectIdHex": "03101d2a3744515e6b7885929facb9c6", + "chunkSize": 65536, + "expectedManifest": { + "version": 1, + "objectId": "03101d2a3744515e6b7885929facb9c6", + "chunkSize": 65536, + "totalPlaintextSize": 200000, + "chunkCount": 4, + "chunks": [ + { + "index": 0, + "ciphertextHash": "fa778a503267e363b40ca0140fe701c5498b449195c9fa673ca1e73bcf8455f4", + "plaintextSize": 65536, + "ciphertextSize": 65552 + }, + { + "index": 1, + "ciphertextHash": "60776e6b061d9d2012954d4c2d117fc05b294a679c81f083c59be9f1fa7d4c47", + "plaintextSize": 65536, + "ciphertextSize": 65552 + }, + { + "index": 2, + "ciphertextHash": "ddee83984dbb39ae328106b3a5216f2e40f9e98ec79c3a695b0b2b8707692e8c", + "plaintextSize": 65536, + "ciphertextSize": 65552 + }, + { + "index": 3, + "ciphertextHash": "d4b069a6d54cf5a6400d591ce08e2bb4f10bc02cf4221abd21a9cf16d8924322", + "plaintextSize": 3392, + "ciphertextSize": 3408 + } + ], + "merkleRoot": "424c887d687ae7bfdd7b8882901b53913515c63531fb25da026de91ddae135ff" + } +} From 764f63160397d591f0ed8096808fb88886a2f0e6 Mon Sep 17 00:00:00 2001 From: Benjamin Poile Date: Tue, 14 Jul 2026 12:39:40 -0700 Subject: [PATCH 04/13] test(payloads): offline streaming-client tests (retry + edge cases) Fake in-memory server exercises the SDK client with no network: push/pull round-trip (multi-chunk, single-chunk, exact chunk boundary, zero-byte), the transient-503 retry path (previously unexercised), and the download integrity check (a corrupted chunk rejects). Closes the retry/edge-case coverage gaps flagged in the confidence review. --- sdk-node/tests/payloads/client.test.ts | 195 +++++++++++++++++++++++++ 1 file changed, 195 insertions(+) create mode 100644 sdk-node/tests/payloads/client.test.ts diff --git a/sdk-node/tests/payloads/client.test.ts b/sdk-node/tests/payloads/client.test.ts new file mode 100644 index 00000000..fc1ef0ee --- /dev/null +++ b/sdk-node/tests/payloads/client.test.ts @@ -0,0 +1,195 @@ +import { createHash, randomFillSync } from "node:crypto"; +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { pullFile, pushFile } from "../../src/payloads/index.js"; + +const BASE = "https://api.test.example"; +const KEY = "prim_test"; + +interface ServerOptions { + /** Return 503 for the first N chunk uploads, then serve normally (retry path). */ + flakyPuts?: number; + /** On download, corrupt the ciphertext of this chunk index (integrity path). */ + corruptChunkIndex?: number; +} + +/** In-memory Primitive Payloads server as a fetch mock. */ +function makeServer(opts: ServerOptions = {}) { + const manifests = new Map(); + const chunks = new Map(); + let flaky = opts.flakyPuts ?? 0; + + const json = (status: number, data: unknown): Response => + new Response(JSON.stringify({ success: status < 400, data }), { + status, + headers: { "content-type": "application/json" }, + }); + + const fetchMock = vi.fn( + async ( + input: string | URL | Request, + init?: RequestInit, + ): Promise => { + const url = new URL(typeof input === "string" ? input : input.toString()); + const method = (init?.method ?? "GET").toUpperCase(); + const parts = url.pathname + .replace(/^\/v1\/payloads\/?/, "") + .split("/") + .filter(Boolean); + + if (method === "POST" && parts.length === 0) { + const body = JSON.parse(init?.body as string) as { + manifest: { merkleRoot: string; chunkCount: number }; + }; + manifests.set(body.manifest.merkleRoot, body.manifest); + return json(201, { + merkle_root: body.manifest.merkleRoot, + state: "pending", + chunk_count: body.manifest.chunkCount, + }); + } + const [root, kind, hash] = parts; + if (method === "PUT" && kind === "chunks") { + if (flaky > 0) { + flaky--; + return new Response("overloaded", { status: 503 }); + } + chunks.set( + `${root}/${hash}`, + new Uint8Array( + await new Response(init?.body as BodyInit).arrayBuffer(), + ), + ); + return json(200, { merkle_root: root, chunk: hash, stored: true }); + } + if (method === "POST" && kind === "finalize") + return json(200, { merkle_root: root, state: "ready" }); + if (method === "GET" && kind === "manifest") + return json(200, { + merkle_root: root, + state: "ready", + manifest: manifests.get(root), + }); + if (method === "GET" && kind === "chunks") { + const bytes = chunks.get(`${root}/${hash}`); + if (!bytes) return new Response("not found", { status: 404 }); + const manifest = manifests.get(root) as { + chunks: { index: number; ciphertextHash: string }[]; + }; + const idx = manifest.chunks.find( + (c) => c.ciphertextHash === hash, + )?.index; + const out = new Uint8Array(bytes); + if ( + opts.corruptChunkIndex !== undefined && + idx === opts.corruptChunkIndex + ) + out[0] ^= 0xff; + return new Response(out); + } + return new Response("bad route", { status: 400 }); + }, + ); + + return { fetchMock, chunks }; +} + +let dir: string; +beforeEach(() => { + dir = mkdtempSync(join(tmpdir(), "payloads-client-")); +}); +afterEach(() => { + vi.unstubAllGlobals(); + rmSync(dir, { recursive: true, force: true }); +}); + +function writeRandom(name: string, size: number): string { + const path = join(dir, name); + const buf = Buffer.allocUnsafe(size); + if (size > 0) randomFillSync(buf); + writeFileSync(path, buf); + return path; +} + +function sha256(path: string): string { + return createHash("sha256").update(readFileSync(path)).digest("hex"); +} + +async function roundTrip( + size: number, + chunkSize: number, + server = makeServer(), +): Promise { + vi.stubGlobal("fetch", server.fetchMock); + const inPath = writeRandom("in.bin", size); + const pushed = await pushFile(inPath, { + baseUrl: BASE, + apiKey: KEY, + chunkSize, + }); + expect(pushed.totalBytes).toBe(size); + const outPath = join(dir, "out.bin"); + await pullFile(pushed.merkleRoot, outPath, { + baseUrl: BASE, + apiKey: KEY, + cek: pushed.cek, + }); + expect(sha256(outPath)).toBe(sha256(inPath)); +} + +describe("payloads streaming client (offline)", () => { + it("round-trips a multi-chunk object", async () => { + await roundTrip(5000, 1024); // 5 chunks (4 full + partial) + }); + + it("round-trips a single-chunk object", async () => { + await roundTrip(700, 1024); + }); + + it("round-trips an object sized exactly on a chunk boundary", async () => { + await roundTrip(2048, 1024); // exactly 2 chunks, no partial + }); + + it("round-trips a zero-byte object", async () => { + const server = makeServer(); + vi.stubGlobal("fetch", server.fetchMock); + const inPath = writeRandom("empty.bin", 0); + const pushed = await pushFile(inPath, { + baseUrl: BASE, + apiKey: KEY, + chunkSize: 1024, + }); + expect(pushed.chunkCount).toBe(0); + const outPath = join(dir, "out.bin"); + await pullFile(pushed.merkleRoot, outPath, { + baseUrl: BASE, + apiKey: KEY, + cek: pushed.cek, + }); + expect(readFileSync(outPath).length).toBe(0); + }); + + it("retries a transient 503 on upload and still completes", async () => { + await roundTrip(3000, 1024, makeServer({ flakyPuts: 1 })); + }); + + it("rejects a corrupted chunk on download (integrity check)", async () => { + const server = makeServer({ corruptChunkIndex: 1 }); + vi.stubGlobal("fetch", server.fetchMock); + const inPath = writeRandom("in.bin", 5000); + const pushed = await pushFile(inPath, { + baseUrl: BASE, + apiKey: KEY, + chunkSize: 1024, + }); + await expect( + pullFile(pushed.merkleRoot, join(dir, "out.bin"), { + baseUrl: BASE, + apiKey: KEY, + cek: pushed.cek, + }), + ).rejects.toThrow(/integrity check/); + }); +}); From 31b73d839033be33b473bd253b1e5a9752b00d18 Mon Sep 17 00:00:00 2001 From: Benjamin Poile Date: Tue, 14 Jul 2026 12:49:01 -0700 Subject: [PATCH 05/13] chore(payloads): lint payloads source in node-check; format cli registration --- Makefile | 2 +- cli-node/src/oclif/index.ts | 5 ++++- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/Makefile b/Makefile index 03673270..21b7df5f 100644 --- a/Makefile +++ b/Makefile @@ -19,7 +19,7 @@ node-test: pnpm --dir sdk-node test node-check: node-check-generated - if command -v biome >/dev/null 2>&1; then cd sdk-node && biome check --error-on-warnings src/index.ts src/validation.ts src/types.ts src/webhook src/contract src/parser src/api/index.ts src/openapi/index.ts tests/; else pnpm --dir sdk-node lint; fi + if command -v biome >/dev/null 2>&1; then cd sdk-node && biome check --error-on-warnings src/index.ts src/validation.ts src/types.ts src/webhook src/contract src/parser src/api/index.ts src/openapi/index.ts src/payloads/index.ts tests/; else pnpm --dir sdk-node lint; fi pnpm --dir sdk-node typecheck $(MAKE) node-test diff --git a/cli-node/src/oclif/index.ts b/cli-node/src/oclif/index.ts index 7b5137e4..cdc1acd5 100644 --- a/cli-node/src/oclif/index.ts +++ b/cli-node/src/oclif/index.ts @@ -39,9 +39,12 @@ import { MemoriesSetCommand, } from "./commands/memories.js"; import OrgSecretsListCommand from "./commands/org-secrets-list.js"; -import { PayloadsPullCommand, PayloadsPushCommand } from "./commands/payloads.js"; import OrgSecretsRemoveCommand from "./commands/org-secrets-remove.js"; import OrgSecretsSetCommand from "./commands/org-secrets-set.js"; +import { + PayloadsPullCommand, + PayloadsPushCommand, +} from "./commands/payloads.js"; import PaymentsChallengeFromEmailCommand from "./commands/payments-challenge-from-email.js"; import PaymentsChargeCommand from "./commands/payments-charge.js"; import PaymentsPayCommand from "./commands/payments-pay.js"; From 487ff7fb1c5bc72792e0a6973fbf84fd487d9a30 Mon Sep 17 00:00:00 2001 From: Benjamin Poile Date: Tue, 14 Jul 2026 13:02:19 -0700 Subject: [PATCH 06/13] fix(payloads): remove ReDoS in base-URL trim; verify manifest root + clean partial file on failed pull - apiRoot trimmed trailing slashes with /\/+$/ on caller-supplied input (CodeQL js/polynomial-redos, high). Replace with a linear scan. - pullFile now recomputes the Merkle root from the manifest's chunk hashes and rejects a manifest that doesn't match the requested content address, and removes a partial output file if the download fails mid-stream. - Tests for the manifest-root-mismatch rejection and partial-file cleanup. --- sdk-node/package.json | 2 +- sdk-node/src/payloads/index.ts | 25 +++++++++++- sdk-node/tests/payloads/client.test.ts | 55 +++++++++++++++++++++++--- 3 files changed, 74 insertions(+), 8 deletions(-) diff --git a/sdk-node/package.json b/sdk-node/package.json index f87f9b7d..19c8ca74 100644 --- a/sdk-node/package.json +++ b/sdk-node/package.json @@ -66,7 +66,7 @@ "test:coverage": "vitest run --coverage", "test:watch": "vitest", "typecheck": "pnpm generate && tsc --noEmit -p tsconfig.typecheck.json", - "lint": "biome check --error-on-warnings src/index.ts src/validation.ts src/types.ts src/webhook src/contract src/parser src/api/index.ts src/x402 src/openapi/index.ts tests/", + "lint": "biome check --error-on-warnings src/index.ts src/validation.ts src/types.ts src/webhook src/contract src/parser src/api/index.ts src/x402 src/openapi/index.ts src/payloads/index.ts tests/", "lint:fix": "biome check --write --error-on-warnings src/index.ts src/validation.ts src/types.ts src/webhook src/contract src/parser src/api/index.ts src/x402 src/openapi/index.ts tests/", "prepublishOnly": "pnpm build" }, diff --git a/sdk-node/src/payloads/index.ts b/sdk-node/src/payloads/index.ts index 6465204d..9100ecdc 100644 --- a/sdk-node/src/payloads/index.ts +++ b/sdk-node/src/payloads/index.ts @@ -12,7 +12,7 @@ * model (packages/payloads-core in the API monorepo). */ import { createWriteStream } from "node:fs"; -import { type FileHandle, open, stat } from "node:fs/promises"; +import { type FileHandle, open, rm, stat } from "node:fs/promises"; // ── Object-model constants (must match the server's payloads-core) ── const CHUNK_SIZE = 64 * 1024 * 1024; @@ -211,7 +211,11 @@ function authHeaders(apiKey: string): Record { } function apiRoot(baseUrl: string): string { - return `${baseUrl.replace(/\/+$/, "")}/v1/payloads`; + // Trim trailing slashes without a regex — a `/\/+$/` on a caller-supplied URL + // is a polynomial-ReDoS vector (many repeated '/'). + let end = baseUrl.length; + while (end > 0 && baseUrl.charCodeAt(end - 1) === 47 /* "/" */) end--; + return `${baseUrl.slice(0, end)}/v1/payloads`; } const DEFAULT_MAX_RETRIES = 6; @@ -513,9 +517,21 @@ export async function pullFile( opts: PullOptions, ): Promise { const manifest = await fetchManifest(opts, root); + // Content-address check: the manifest must actually describe the object at + // `root`. Recompute the Merkle root from the chunk hashes so a server can't + // substitute a manifest for a different object. + const computedRoot = await merkleRoot( + manifest.chunks.map((c) => c.ciphertextHash), + ); + if (computedRoot !== root) { + throw new Error( + `manifest does not match the requested content address (got ${computedRoot})`, + ); + } const cek = fromHex(opts.cek); const objectId = fromHex(manifest.objectId); const out = createWriteStream(outPath); + let failed = false; try { let done = 0; for (const d of manifest.chunks) { @@ -528,10 +544,15 @@ export async function pullFile( await new Promise((r) => out.once("drain", r)); opts.onProgress?.("download", ++done, manifest.chunkCount); } + } catch (err) { + failed = true; + throw err; } finally { await new Promise((resolve, reject) => out.end((err?: Error | null) => (err ? reject(err) : resolve())), ); + // Don't leave a partial/corrupt plaintext file behind on a failed download. + if (failed) await rm(outPath, { force: true }); } return manifest; } diff --git a/sdk-node/tests/payloads/client.test.ts b/sdk-node/tests/payloads/client.test.ts index fc1ef0ee..730f9d58 100644 --- a/sdk-node/tests/payloads/client.test.ts +++ b/sdk-node/tests/payloads/client.test.ts @@ -1,5 +1,11 @@ import { createHash, randomFillSync } from "node:crypto"; -import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { + existsSync, + mkdtempSync, + readFileSync, + rmSync, + writeFileSync, +} from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; @@ -13,6 +19,8 @@ interface ServerOptions { flakyPuts?: number; /** On download, corrupt the ciphertext of this chunk index (integrity path). */ corruptChunkIndex?: number; + /** On GET manifest, alter a chunk hash so the manifest no longer hashes to the root. */ + tamperManifestHash?: boolean; } /** In-memory Primitive Payloads server as a fetch mock. */ @@ -66,12 +74,29 @@ function makeServer(opts: ServerOptions = {}) { } if (method === "POST" && kind === "finalize") return json(200, { merkle_root: root, state: "ready" }); - if (method === "GET" && kind === "manifest") + if (method === "GET" && kind === "manifest") { + const m = manifests.get(root) as { + chunks: { ciphertextHash: string }[]; + }; + const served = opts.tamperManifestHash + ? { + ...m, + chunks: m.chunks.map((c, i) => + i === 0 + ? { + ...c, + ciphertextHash: `${c.ciphertextHash.slice(0, -1)}${c.ciphertextHash.slice(-1) === "0" ? "1" : "0"}`, + } + : c, + ), + } + : m; return json(200, { merkle_root: root, state: "ready", - manifest: manifests.get(root), + manifest: served, }); + } if (method === "GET" && kind === "chunks") { const bytes = chunks.get(`${root}/${hash}`); if (!bytes) return new Response("not found", { status: 404 }); @@ -175,7 +200,7 @@ describe("payloads streaming client (offline)", () => { await roundTrip(3000, 1024, makeServer({ flakyPuts: 1 })); }); - it("rejects a corrupted chunk on download (integrity check)", async () => { + it("rejects a corrupted chunk on download and leaves no partial file", async () => { const server = makeServer({ corruptChunkIndex: 1 }); vi.stubGlobal("fetch", server.fetchMock); const inPath = writeRandom("in.bin", 5000); @@ -184,12 +209,32 @@ describe("payloads streaming client (offline)", () => { apiKey: KEY, chunkSize: 1024, }); + const outPath = join(dir, "out.bin"); await expect( - pullFile(pushed.merkleRoot, join(dir, "out.bin"), { + pullFile(pushed.merkleRoot, outPath, { baseUrl: BASE, apiKey: KEY, cek: pushed.cek, }), ).rejects.toThrow(/integrity check/); + expect(existsSync(outPath)).toBe(false); + }); + + it("rejects a manifest whose content address does not match the request", async () => { + const server = makeServer({ tamperManifestHash: true }); + vi.stubGlobal("fetch", server.fetchMock); + const inPath = writeRandom("in.bin", 3000); + const pushed = await pushFile(inPath, { + baseUrl: BASE, + apiKey: KEY, + chunkSize: 1024, + }); + await expect( + pullFile(pushed.merkleRoot, join(dir, "out.bin"), { + baseUrl: BASE, + apiKey: KEY, + cek: pushed.cek, + }), + ).rejects.toThrow(/content address/); }); }); From bfac8de2d53ac4f117f0b6ebaf0e55b97406569a Mon Sep 17 00:00:00 2001 From: Benjamin Poile Date: Tue, 14 Jul 2026 13:12:41 -0700 Subject: [PATCH 07/13] fix(payloads): robust write-stream lifecycle in pullFile MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Attach an 'error' listener and race writes/end against it so an async disk-write failure (disk full, EIO, bad path) surfaces as a rejection instead of an uncaught error that crashes the process — and destroy the stream + remove the partial file on any failure. Adds a write-error test (invalid output path). --- sdk-node/src/payloads/index.ts | 33 ++++++++++++++++++-------- sdk-node/tests/payloads/client.test.ts | 21 ++++++++++++++++ 2 files changed, 44 insertions(+), 10 deletions(-) diff --git a/sdk-node/src/payloads/index.ts b/sdk-node/src/payloads/index.ts index 9100ecdc..1306fa41 100644 --- a/sdk-node/src/payloads/index.ts +++ b/sdk-node/src/payloads/index.ts @@ -531,7 +531,16 @@ export async function pullFile( const cek = fromHex(opts.cek); const objectId = fromHex(manifest.objectId); const out = createWriteStream(outPath); - let failed = false; + // Surface async write failures (disk full, EIO) as a rejection: without an + // 'error' listener the stream would throw an uncaught error and crash the + // process, bypassing the cleanup below. + let onWriteError: (err: Error) => void = () => {}; + const writeErrored = new Promise((_, reject) => { + onWriteError = reject; + }); + writeErrored.catch(() => {}); // never an unhandled rejection on the happy path + out.on("error", (err: Error) => onWriteError(err)); + try { let done = 0; for (const d of manifest.chunks) { @@ -540,19 +549,23 @@ export async function pullFile( throw new Error(`chunk ${d.index} failed integrity check`); } const plaintext = await decryptChunk(cek, objectId, d.index, ciphertext); - if (!out.write(plaintext)) - await new Promise((r) => out.once("drain", r)); + if (!out.write(plaintext)) { + await Promise.race([ + new Promise((resolve) => out.once("drain", resolve)), + writeErrored, + ]); + } opts.onProgress?.("download", ++done, manifest.chunkCount); } + await Promise.race([ + new Promise((resolve) => out.end(resolve)), + writeErrored, + ]); } catch (err) { - failed = true; - throw err; - } finally { - await new Promise((resolve, reject) => - out.end((err?: Error | null) => (err ? reject(err) : resolve())), - ); // Don't leave a partial/corrupt plaintext file behind on a failed download. - if (failed) await rm(outPath, { force: true }); + out.destroy(); + await rm(outPath, { force: true }); + throw err; } return manifest; } diff --git a/sdk-node/tests/payloads/client.test.ts b/sdk-node/tests/payloads/client.test.ts index 730f9d58..16e570cb 100644 --- a/sdk-node/tests/payloads/client.test.ts +++ b/sdk-node/tests/payloads/client.test.ts @@ -237,4 +237,25 @@ describe("payloads streaming client (offline)", () => { }), ).rejects.toThrow(/content address/); }); + + it("surfaces an output write error as a rejection (no uncaught crash)", async () => { + const server = makeServer(); + vi.stubGlobal("fetch", server.fetchMock); + const inPath = writeRandom("in.bin", 3000); + const pushed = await pushFile(inPath, { + baseUrl: BASE, + apiKey: KEY, + chunkSize: 1024, + }); + // Parent directory does not exist → the write stream emits an ENOENT error. + const badOut = join(dir, "missing-subdir", "out.bin"); + await expect( + pullFile(pushed.merkleRoot, badOut, { + baseUrl: BASE, + apiKey: KEY, + cek: pushed.cek, + }), + ).rejects.toThrow(); + expect(existsSync(badOut)).toBe(false); + }); }); From 9bbed5dfa0161218790a36d7ef11a295b12a81b8 Mon Sep 17 00:00:00 2001 From: Benjamin Poile Date: Wed, 15 Jul 2026 17:42:54 -0700 Subject: [PATCH 08/13] feat(payloads): send-by-reference + size-transparent sendAttachment helper MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds payload_attachments to the OpenAPI SendMailInput (+ SendMailPayloadRef schema) and regenerates api-core, so the SDK's typed send surface exposes send-by-reference. Threads payloadAttachments (and inline attachments) through PrimitiveClient.send. New PrimitiveClient.sendAttachment(): one call takes an attachment as in-memory bytes or a file path and picks the mechanism by size — inline under the threshold (default 25 MiB), otherwise upload as an E2E-encrypted content-addressed payloads object (streamed from disk via pushFile, or pushBytes for in-memory) and deliver by reference with no size ceiling. Adds payloads.pushBytes (in-memory variant of pushFile). The push CEK is hex; the reference carries base64url, converted here. Tests cover the wire mapping, inline routing, and the large-file upload-then-reference path incl. the hex->base64url CEK conversion. --- openapi/primitive-api.codegen.json | 42 ++++ openapi/primitive-api.yaml | 39 ++++ packages/api-core/src/api/index.ts | 2 +- packages/api-core/src/api/types.gen.ts | 26 +++ .../api-core/src/openapi/openapi.generated.ts | 42 ++++ .../src/openapi/operations.generated.ts | 39 ++++ sdk-node/src/api/index.ts | 199 ++++++++++++++++++ sdk-node/src/payloads/index.ts | 63 ++++++ sdk-node/tests/api/client.test.ts | 136 ++++++++++++ 9 files changed, 587 insertions(+), 1 deletion(-) diff --git a/openapi/primitive-api.codegen.json b/openapi/primitive-api.codegen.json index eb9c386c..423f1f1b 100644 --- a/openapi/primitive-api.codegen.json +++ b/openapi/primitive-api.codegen.json @@ -11235,6 +11235,40 @@ "content_base64" ] }, + "SendMailPayloadRef": { + "type": "object", + "additionalProperties": false, + "description": "A reference to an already-uploaded Primitive Payloads object, delivered as an attachment without inlining the bytes — the way to send an attachment larger than the inline cap. Upload the object via /v1/payloads (with a client-held CEK the server never sees), then reference it here.", + "properties": { + "root": { + "type": "string", + "pattern": "^[0-9a-f]{64}$", + "description": "The 64-char lowercase-hex Merkle root of a finalized payloads object." + }, + "filename": { + "type": "string", + "minLength": 1, + "maxLength": 255, + "description": "Attachment filename presented to the recipient." + }, + "content_type": { + "type": "string", + "minLength": 1, + "maxLength": 255, + "description": "Optional MIME content type." + }, + "cek": { + "type": "string", + "pattern": "^[A-Za-z0-9_-]{1,128}$", + "description": "Base64url-encoded (unpadded) content-encryption key the recipient uses to decrypt. Travels with the email; the object store only ever holds ciphertext." + } + }, + "required": [ + "root", + "filename", + "cek" + ] + }, "SendMailInput": { "type": "object", "additionalProperties": false, @@ -11291,6 +11325,14 @@ "$ref": "#/components/schemas/SendMailAttachment" } }, + "payload_attachments": { + "type": "array", + "maxItems": 1, + "description": "Deliver an already-uploaded Primitive Payloads object as an attachment by reference, without inlining the bytes — the way to send attachments larger than the inline cap. Upload the object via /v1/payloads (client-held CEK), then reference it here. v1 supports at most one.", + "items": { + "$ref": "#/components/schemas/SendMailPayloadRef" + } + }, "wait": { "type": "boolean", "description": "When true, wait for the first downstream SMTP delivery outcome before returning." diff --git a/openapi/primitive-api.yaml b/openapi/primitive-api.yaml index 5958b288..2b9c07cc 100644 --- a/openapi/primitive-api.yaml +++ b/openapi/primitive-api.yaml @@ -7896,6 +7896,39 @@ components: description: Base64-encoded attachment bytes. required: [filename, content_base64] + SendMailPayloadRef: + type: object + additionalProperties: false + description: >- + A reference to an already-uploaded Primitive Payloads object, delivered + as an attachment without inlining the bytes — the way to send an + attachment larger than the inline cap. Upload the object via + /v1/payloads (with a client-held CEK the server never sees), then + reference it here. + properties: + root: + type: string + pattern: '^[0-9a-f]{64}$' + description: The 64-char lowercase-hex Merkle root of a finalized payloads object. + filename: + type: string + minLength: 1 + maxLength: 255 + description: Attachment filename presented to the recipient. + content_type: + type: string + minLength: 1 + maxLength: 255 + description: Optional MIME content type. + cek: + type: string + pattern: '^[A-Za-z0-9_-]{1,128}$' + description: >- + Base64url-encoded (unpadded) content-encryption key the recipient + uses to decrypt. Travels with the email; the object store only ever + holds ciphertext. + required: [root, filename, cek] + SendMailInput: type: object additionalProperties: false @@ -7942,6 +7975,12 @@ components: description: Inline attachments. Send requests with attachments to https://api.primitive.dev/v1/send-mail. Combined raw decoded attachment bytes must be at most 31457280. items: $ref: '#/components/schemas/SendMailAttachment' + payload_attachments: + type: array + maxItems: 1 + description: Deliver an already-uploaded Primitive Payloads object as an attachment by reference, without inlining the bytes — the way to send attachments larger than the inline cap. Upload the object via /v1/payloads (client-held CEK), then reference it here. v1 supports at most one. + items: + $ref: '#/components/schemas/SendMailPayloadRef' wait: type: boolean description: When true, wait for the first downstream SMTP delivery outcome before returning. diff --git a/packages/api-core/src/api/index.ts b/packages/api-core/src/api/index.ts index 6aec693a..e04782cd 100644 --- a/packages/api-core/src/api/index.ts +++ b/packages/api-core/src/api/index.ts @@ -1,4 +1,4 @@ // This file is auto-generated by @hey-api/openapi-ts export { addDomain, cliLogout, createAgentAccount, createAgentClaimLink, createChallenge, createEmailChallenge, createEndpoint, createFilter, createFunction, createFunctionSecret, createOrgSecret, createRegistry, createRoute, createWakeAuthorization, createWakeSchedule, decideRegistryRequest, defineAgent, deleteDomain, deleteEmail, deleteEndpoint, deleteFilter, deleteFunction, deleteFunctionSecret, deleteMemory, deleteOrgSecret, deleteRegistry, deleteRoute, deleteWakeAuthorization, deleteWakeSchedule, discardEmailContent, downloadAttachments, downloadDomainZoneFile, downloadRawEmail, getAccount, getAgent, getChallenge, getConversation, getEmail, getFunction, getFunctionRouting, getFunctionTestRunTrace, getInboxStatus, getMemory, getOrgRoutingTopology, getRegistry, getSendPermissions, getSentEmail, getSpendPolicy, getStorageStats, getTemplate, getTemplateInstall, getThread, getWakeSchedule, getWebhookSecret, installTemplate, listDeclinedPayments, listDeliveries, listDomains, listEmails, listEndpoints, listFilters, listFunctionLogs, listFunctions, listFunctionSecrets, listOrgSecrets, listPayoutAddresses, listRegistries, listRegistryAgents, listRegistryRequests, listRoutes, listSentEmails, listTemplates, listWakeAuthorizations, listWakeDispatches, listWakeSchedules, type Options, payChallenge, pollCliLogin, publishAgent, registerPayoutAddress, reorderRoutes, replayDelivery, replayEmailWebhooks, replyToEmail, resendAgentSignupVerification, resendCliSignupVerification, resolveRegistryHandle, rotateWebhookSecret, runWakeSchedule, searchEmails, searchMemories, semanticSearch, sendEmail, setFunctionRoute, setFunctionSecret, setMemory, setOrgSecret, simulateRoute, startAgentClaim, startAgentSignup, startCliLogin, startCliSignup, testEndpoint, testFunction, unpublishAgent, unsetFunctionRoute, updateAccount, updateDomain, updateEndpoint, updateFilter, updateFunction, updateRegistry, updateRoute, updateSpendPolicy, updateWakeAuthorization, updateWakeSchedule, verifyAgentClaim, verifyAgentSignup, verifyCliSignup, verifyDomain } from './sdk.gen.js'; -export type { Account, AccountUpdated, AddDomainData, AddDomainError, AddDomainErrors, AddDomainInput, AddDomainResponse, AddDomainResponses, AgentAccountResult, AgentAccountUpgradeHint, AgentClaimLinkResult, AgentClaimResult, AgentClaimStartResult, AgentOrgRef, AgentSignupResendResult, AgentSignupStartResult, AgentSignupVerifyResult, ClientOptions, CliLoginPollResult, CliLoginStartResult, CliLogoutData, CliLogoutError, CliLogoutErrors, CliLogoutInput, CliLogoutResponse, CliLogoutResponses, CliLogoutResult, CliSignupResendResult, CliSignupStartResult, CliSignupVerifyResult, Conversation, ConversationMessage, CreateAgentAccountData, CreateAgentAccountError, CreateAgentAccountErrors, CreateAgentAccountInput, CreateAgentAccountResponse, CreateAgentAccountResponses, CreateAgentClaimLinkData, CreateAgentClaimLinkError, CreateAgentClaimLinkErrors, CreateAgentClaimLinkInput, CreateAgentClaimLinkResponse, CreateAgentClaimLinkResponses, CreateChallengeData, CreateChallengeError, CreateChallengeErrors, CreateChallengeInput, CreateChallengeResponse, CreateChallengeResponses, CreateEmailChallengeData, CreateEmailChallengeError, CreateEmailChallengeErrors, CreateEmailChallengeInput, CreateEmailChallengeResponse, CreateEmailChallengeResponses, CreateEndpointData, CreateEndpointError, CreateEndpointErrors, CreateEndpointInput, CreateEndpointResponse, CreateEndpointResponses, CreateFilterData, CreateFilterError, CreateFilterErrors, CreateFilterInput, CreateFilterResponse, CreateFilterResponses, CreateFunctionData, CreateFunctionError, CreateFunctionErrors, CreateFunctionInput, CreateFunctionResponse, CreateFunctionResponses, CreateFunctionResult, CreateFunctionSecretData, CreateFunctionSecretError, CreateFunctionSecretErrors, CreateFunctionSecretInput, CreateFunctionSecretResponse, CreateFunctionSecretResponses, CreateOrgSecretData, CreateOrgSecretError, CreateOrgSecretErrors, CreateOrgSecretInput, CreateOrgSecretResponse, CreateOrgSecretResponses, CreateRegistryData, CreateRegistryError, CreateRegistryErrors, CreateRegistryInput, CreateRegistryResponse, CreateRegistryResponses, CreateRouteData, CreateRouteError, CreateRouteErrors, CreateRouteInput, CreateRouteResponse, CreateRouteResponses, CreateWakeAuthorizationData, CreateWakeAuthorizationError, CreateWakeAuthorizationErrors, CreateWakeAuthorizationInput, CreateWakeAuthorizationResponse, CreateWakeAuthorizationResponses, CreateWakeScheduleData, CreateWakeScheduleError, CreateWakeScheduleErrors, CreateWakeScheduleInput, CreateWakeScheduleResponse, CreateWakeScheduleResponses, Cursor, DecideRegistryRequestData, DecideRegistryRequestError, DecideRegistryRequestErrors, DecideRegistryRequestInput, DecideRegistryRequestResponse, DecideRegistryRequestResponses, DefineAgentData, DefineAgentError, DefineAgentErrors, DefineAgentInput, DefineAgentResponse, DefineAgentResponses, DeleteDomainData, DeleteDomainError, DeleteDomainErrors, DeleteDomainResponse, DeleteDomainResponses, DeleteEmailData, DeleteEmailError, DeleteEmailErrors, DeleteEmailResponse, DeleteEmailResponses, DeleteEndpointData, DeleteEndpointError, DeleteEndpointErrors, DeleteEndpointResponse, DeleteEndpointResponses, DeleteFilterData, DeleteFilterError, DeleteFilterErrors, DeleteFilterResponse, DeleteFilterResponses, DeleteFunctionData, DeleteFunctionError, DeleteFunctionErrors, DeleteFunctionResponse, DeleteFunctionResponses, DeleteFunctionSecretData, DeleteFunctionSecretError, DeleteFunctionSecretErrors, DeleteFunctionSecretResponse, DeleteFunctionSecretResponses, DeleteMemoryData, DeleteMemoryError, DeleteMemoryErrors, DeleteMemoryResponse, DeleteMemoryResponses, DeleteMemoryResult, DeleteOrgSecretData, DeleteOrgSecretError, DeleteOrgSecretErrors, DeleteOrgSecretResponse, DeleteOrgSecretResponses, DeleteRegistryData, DeleteRegistryError, DeleteRegistryErrors, DeleteRegistryResponse, DeleteRegistryResponses, DeleteRouteData, DeleteRouteError, DeleteRouteErrors, DeleteRouteResponse, DeleteRouteResponses, DeleteWakeAuthorizationData, DeleteWakeAuthorizationError, DeleteWakeAuthorizationErrors, DeleteWakeAuthorizationResponse, DeleteWakeAuthorizationResponses, DeleteWakeScheduleData, DeleteWakeScheduleError, DeleteWakeScheduleErrors, DeleteWakeScheduleResponse, DeleteWakeScheduleResponses, DeliveryStatus, DeliverySummary, DiscardContentResult, DiscardEmailContentData, DiscardEmailContentError, DiscardEmailContentErrors, DiscardEmailContentResponse, DiscardEmailContentResponses, DkimSignature, Domain, DomainDnsRecord, DomainVerifyResult, DownloadAttachmentsData, DownloadAttachmentsError, DownloadAttachmentsErrors, DownloadAttachmentsResponse, DownloadAttachmentsResponses, DownloadDomainZoneFileData, DownloadDomainZoneFileError, DownloadDomainZoneFileErrors, DownloadDomainZoneFileResponse, DownloadDomainZoneFileResponses, DownloadRawEmailData, DownloadRawEmailError, DownloadRawEmailErrors, DownloadRawEmailResponse, DownloadRawEmailResponses, EmailAddress, EmailAttachment, EmailAuth, EmailDetail, EmailDetailReply, EmailSearchFacetBucket, EmailSearchFacets, EmailSearchHighlights, EmailSearchMeta, EmailSearchResult, EmailStatus, EmailSummary, EmailWebhookStatus, Endpoint, ErrorResponse, Filter, FunctionDeployStatus, FunctionDetail, FunctionListItem, FunctionLogRow, FunctionRouteBody, FunctionRouteResult, FunctionRouting, FunctionSecretListItem, FunctionSecretWriteResult, FunctionTestRun, FunctionTestRunDelivery, FunctionTestRunDeliveryEndpoint, FunctionTestRunInboundEmail, FunctionTestRunOutboundRequest, FunctionTestRunReply, FunctionTestRunSend, FunctionTestRunState, FunctionTestRunTrace, GateDenial, GateFix, GetAccountData, GetAccountError, GetAccountErrors, GetAccountResponse, GetAccountResponses, GetAgentData, GetAgentError, GetAgentErrors, GetAgentResponse, GetAgentResponses, GetChallengeData, GetChallengeError, GetChallengeErrors, GetChallengeResponse, GetChallengeResponses, GetConversationData, GetConversationError, GetConversationErrors, GetConversationResponse, GetConversationResponses, GetEmailData, GetEmailError, GetEmailErrors, GetEmailResponse, GetEmailResponses, GetFunctionData, GetFunctionError, GetFunctionErrors, GetFunctionResponse, GetFunctionResponses, GetFunctionRoutingData, GetFunctionRoutingError, GetFunctionRoutingErrors, GetFunctionRoutingResponse, GetFunctionRoutingResponses, GetFunctionTestRunTraceData, GetFunctionTestRunTraceError, GetFunctionTestRunTraceErrors, GetFunctionTestRunTraceResponse, GetFunctionTestRunTraceResponses, GetInboxStatusData, GetInboxStatusError, GetInboxStatusErrors, GetInboxStatusResponse, GetInboxStatusResponses, GetMemoryData, GetMemoryError, GetMemoryErrors, GetMemoryResponse, GetMemoryResponses, GetOrgRoutingTopologyData, GetOrgRoutingTopologyError, GetOrgRoutingTopologyErrors, GetOrgRoutingTopologyResponse, GetOrgRoutingTopologyResponses, GetRegistryData, GetRegistryError, GetRegistryErrors, GetRegistryResponse, GetRegistryResponses, GetSendPermissionsData, GetSendPermissionsError, GetSendPermissionsErrors, GetSendPermissionsResponse, GetSendPermissionsResponses, GetSentEmailData, GetSentEmailError, GetSentEmailErrors, GetSentEmailResponse, GetSentEmailResponses, GetSpendPolicyData, GetSpendPolicyError, GetSpendPolicyErrors, GetSpendPolicyResponse, GetSpendPolicyResponses, GetStorageStatsData, GetStorageStatsError, GetStorageStatsErrors, GetStorageStatsResponse, GetStorageStatsResponses, GetTemplateData, GetTemplateError, GetTemplateErrors, GetTemplateInstallData, GetTemplateInstallError, GetTemplateInstallErrors, GetTemplateInstallResponse, GetTemplateInstallResponses, GetTemplateResponse, GetTemplateResponses, GetThreadData, GetThreadError, GetThreadErrors, GetThreadResponse, GetThreadResponses, GetWakeScheduleData, GetWakeScheduleError, GetWakeScheduleErrors, GetWakeScheduleResponse, GetWakeScheduleResponses, GetWebhookSecretData, GetWebhookSecretError, GetWebhookSecretErrors, GetWebhookSecretResponse, GetWebhookSecretResponses, IdempotencyKey, InboxStatus, InboxStatusDomain, InboxStatusEndpointSummary, InboxStatusFunctionSummary, InboxStatusNextAction, InboxStatusRecentEmailSummary, InstallTemplateBody, InstallTemplateData, InstallTemplateError, InstallTemplateErrors, InstallTemplateResponse, InstallTemplateResponses, Limit, ListDeclinedPaymentsData, ListDeclinedPaymentsError, ListDeclinedPaymentsErrors, ListDeclinedPaymentsResponse, ListDeclinedPaymentsResponses, ListDeliveriesData, ListDeliveriesError, ListDeliveriesErrors, ListDeliveriesResponse, ListDeliveriesResponses, ListDomainsData, ListDomainsError, ListDomainsErrors, ListDomainsResponse, ListDomainsResponses, ListEmailsData, ListEmailsError, ListEmailsErrors, ListEmailsResponse, ListEmailsResponses, ListEndpointsData, ListEndpointsError, ListEndpointsErrors, ListEndpointsResponse, ListEndpointsResponses, ListEnvelope, ListFiltersData, ListFiltersError, ListFiltersErrors, ListFiltersResponse, ListFiltersResponses, ListFunctionLogsData, ListFunctionLogsError, ListFunctionLogsErrors, ListFunctionLogsResponse, ListFunctionLogsResponses, ListFunctionsData, ListFunctionSecretsData, ListFunctionSecretsError, ListFunctionSecretsErrors, ListFunctionSecretsResponse, ListFunctionSecretsResponses, ListFunctionsError, ListFunctionsErrors, ListFunctionsResponse, ListFunctionsResponses, ListOrgSecretsData, ListOrgSecretsError, ListOrgSecretsErrors, ListOrgSecretsResponse, ListOrgSecretsResponses, ListPayoutAddressesData, ListPayoutAddressesError, ListPayoutAddressesErrors, ListPayoutAddressesResponse, ListPayoutAddressesResponses, ListRegistriesData, ListRegistriesError, ListRegistriesErrors, ListRegistriesResponse, ListRegistriesResponses, ListRegistryAgentsData, ListRegistryAgentsResponse, ListRegistryAgentsResponses, ListRegistryRequestsData, ListRegistryRequestsError, ListRegistryRequestsErrors, ListRegistryRequestsResponse, ListRegistryRequestsResponses, ListRoutesData, ListRoutesError, ListRoutesErrors, ListRoutesResponse, ListRoutesResponses, ListSentEmailsData, ListSentEmailsError, ListSentEmailsErrors, ListSentEmailsResponse, ListSentEmailsResponses, ListTemplatesData, ListTemplatesError, ListTemplatesErrors, ListTemplatesResponse, ListTemplatesResponses, ListWakeAuthorizationsData, ListWakeAuthorizationsError, ListWakeAuthorizationsErrors, ListWakeAuthorizationsResponse, ListWakeAuthorizationsResponses, ListWakeDispatchesData, ListWakeDispatchesError, ListWakeDispatchesErrors, ListWakeDispatchesResponse, ListWakeDispatchesResponses, ListWakeSchedulesData, ListWakeSchedulesError, ListWakeSchedulesErrors, ListWakeSchedulesResponse, ListWakeSchedulesResponses, MemoryJsonValue, MemoryKeyQuery, MemoryRecord, MemoryRecordWithValue, MemoryResolvedScope, MemoryScope, MemoryScopeId, MemoryScopeQueryType, NumericString, OrgSecretListItem, OrgSecretWriteResult, PaginationMeta, ParsedEmailData, PayChallengeData, PayChallengeError, PayChallengeErrors, PayChallengeInput, PayChallengeResponse, PayChallengeResponses, PlanLimits, PollCliLoginData, PollCliLoginError, PollCliLoginErrors, PollCliLoginInput, PollCliLoginResponse, PollCliLoginResponses, PublishAgentData, PublishAgentError, PublishAgentErrors, PublishAgentInput, PublishAgentResponse, PublishAgentResponses, PublishAgentResult, PublishPolicy, RecipientRoute, RegisterPayoutAddressData, RegisterPayoutAddressError, RegisterPayoutAddressErrors, RegisterPayoutAddressInput, RegisterPayoutAddressResponse, RegisterPayoutAddressResponses, Registry, RegistryAgent, RegistryRequest, ReorderRoutesData, ReorderRoutesError, ReorderRoutesErrors, ReorderRoutesInput, ReorderRoutesResponse, ReorderRoutesResponses, ReplayDeliveryData, ReplayDeliveryError, ReplayDeliveryErrors, ReplayDeliveryResponse, ReplayDeliveryResponses, ReplayEmailWebhooksData, ReplayEmailWebhooksError, ReplayEmailWebhooksErrors, ReplayEmailWebhooksResponse, ReplayEmailWebhooksResponses, ReplayResult, ReplyInput, ReplyToEmailData, ReplyToEmailError, ReplyToEmailErrors, ReplyToEmailResponse, ReplyToEmailResponses, ResendAgentSignupVerificationData, ResendAgentSignupVerificationError, ResendAgentSignupVerificationErrors, ResendAgentSignupVerificationInput, ResendAgentSignupVerificationResponse, ResendAgentSignupVerificationResponses, ResendCliSignupVerificationData, ResendCliSignupVerificationError, ResendCliSignupVerificationErrors, ResendCliSignupVerificationInput, ResendCliSignupVerificationResponse, ResendCliSignupVerificationResponses, ResolveRegistryHandleData, ResolveRegistryHandleError, ResolveRegistryHandleErrors, ResolveRegistryHandleResponse, ResolveRegistryHandleResponses, ResourceId, RotateWebhookSecretData, RotateWebhookSecretError, RotateWebhookSecretErrors, RotateWebhookSecretResponse, RotateWebhookSecretResponses, RouteEvaluatedEntry, RoutingTopology, RunWakeScheduleData, RunWakeScheduleError, RunWakeScheduleErrors, RunWakeScheduleResponse, RunWakeScheduleResponses, SearchEmailsData, SearchEmailsError, SearchEmailsErrors, SearchEmailsResponse, SearchEmailsResponses, SearchMemoriesData, SearchMemoriesError, SearchMemoriesErrors, SearchMemoriesResponse, SearchMemoriesResponses, SemanticSearchCoverage, SemanticSearchData, SemanticSearchError, SemanticSearchErrors, SemanticSearchField, SemanticSearchInput, SemanticSearchMeta, SemanticSearchResponse, SemanticSearchResponses, SemanticSearchResult, SemanticSearchScoreBreakdown, SemanticSearchSnippet, SendEmailData, SendEmailError, SendEmailErrors, SendEmailResponse, SendEmailResponses, SendMailAttachment, SendMailInput, SendMailResult, SendPermissionAddress, SendPermissionAnyRecipient, SendPermissionManagedZone, SendPermissionRule, SendPermissionsMeta, SendPermissionYourDomain, SentEmailDetail, SentEmailStatus, SentEmailSummary, SetFunctionRouteData, SetFunctionRouteError, SetFunctionRouteErrors, SetFunctionRouteResponse, SetFunctionRouteResponses, SetFunctionSecretData, SetFunctionSecretError, SetFunctionSecretErrors, SetFunctionSecretInput, SetFunctionSecretResponse, SetFunctionSecretResponses, SetMemoryData, SetMemoryError, SetMemoryErrors, SetMemoryInput, SetMemoryResponse, SetMemoryResponses, SetOrgSecretData, SetOrgSecretError, SetOrgSecretErrors, SetOrgSecretInput, SetOrgSecretResponse, SetOrgSecretResponses, SimulateRouteData, SimulateRouteError, SimulateRouteErrors, SimulateRouteInput, SimulateRouteResponse, SimulateRouteResponses, SimulateRouteResult, StartAgentClaimData, StartAgentClaimError, StartAgentClaimErrors, StartAgentClaimInput, StartAgentClaimResponse, StartAgentClaimResponses, StartAgentSignupData, StartAgentSignupError, StartAgentSignupErrors, StartAgentSignupInput, StartAgentSignupResponse, StartAgentSignupResponses, StartCliLoginData, StartCliLoginError, StartCliLoginErrors, StartCliLoginInput, StartCliLoginResponse, StartCliLoginResponses, StartCliSignupData, StartCliSignupError, StartCliSignupErrors, StartCliSignupInput, StartCliSignupResponse, StartCliSignupResponses, StorageStats, SuccessEnvelope, TemplateAuthor, TemplateInstall, TemplateInstallState, TemplateInstallStatus, TemplateManifest, TemplateRegistryDetail, TemplateRegistryPage, TemplateRegistryStatus, TemplateRegistrySummary, TemplateSecret, TemplateSecretGroup, TemplateSetup, TemplateSource, TemplateVariable, TemplateVariableValidation, TestEndpointData, TestEndpointError, TestEndpointErrors, TestEndpointResponse, TestEndpointResponses, TestFunctionData, TestFunctionError, TestFunctionErrors, TestFunctionResponse, TestFunctionResponses, TestInvocationResult, TestResult, Thread, ThreadMessage, UnpublishAgentData, UnpublishAgentError, UnpublishAgentErrors, UnpublishAgentResponse, UnpublishAgentResponses, UnsetFunctionRouteData, UnsetFunctionRouteError, UnsetFunctionRouteErrors, UnsetFunctionRouteResponse, UnsetFunctionRouteResponses, UnverifiedDomain, UpdateAccountData, UpdateAccountError, UpdateAccountErrors, UpdateAccountInput, UpdateAccountResponse, UpdateAccountResponses, UpdateDomainData, UpdateDomainError, UpdateDomainErrors, UpdateDomainInput, UpdateDomainResponse, UpdateDomainResponses, UpdateEndpointData, UpdateEndpointError, UpdateEndpointErrors, UpdateEndpointInput, UpdateEndpointResponse, UpdateEndpointResponses, UpdateFilterData, UpdateFilterError, UpdateFilterErrors, UpdateFilterInput, UpdateFilterResponse, UpdateFilterResponses, UpdateFunctionData, UpdateFunctionError, UpdateFunctionErrors, UpdateFunctionInput, UpdateFunctionResponse, UpdateFunctionResponses, UpdateRegistryData, UpdateRegistryError, UpdateRegistryErrors, UpdateRegistryInput, UpdateRegistryResponse, UpdateRegistryResponses, UpdateRouteData, UpdateRouteError, UpdateRouteErrors, UpdateRouteInput, UpdateRouteResponse, UpdateRouteResponses, UpdateSpendPolicyData, UpdateSpendPolicyError, UpdateSpendPolicyErrors, UpdateSpendPolicyInput, UpdateSpendPolicyResponse, UpdateSpendPolicyResponses, UpdateWakeAuthorizationData, UpdateWakeAuthorizationError, UpdateWakeAuthorizationErrors, UpdateWakeAuthorizationInput, UpdateWakeAuthorizationResponse, UpdateWakeAuthorizationResponses, UpdateWakeScheduleData, UpdateWakeScheduleError, UpdateWakeScheduleErrors, UpdateWakeScheduleInput, UpdateWakeScheduleResponse, UpdateWakeScheduleResponses, VerifiedDomain, VerifyAgentClaimData, VerifyAgentClaimError, VerifyAgentClaimErrors, VerifyAgentClaimInput, VerifyAgentClaimResponse, VerifyAgentClaimResponses, VerifyAgentSignupData, VerifyAgentSignupError, VerifyAgentSignupErrors, VerifyAgentSignupInput, VerifyAgentSignupResponse, VerifyAgentSignupResponses, VerifyCliSignupData, VerifyCliSignupError, VerifyCliSignupErrors, VerifyCliSignupInput, VerifyCliSignupResponse, VerifyCliSignupResponses, VerifyDomainData, VerifyDomainError, VerifyDomainErrors, VerifyDomainResponse, VerifyDomainResponses, WakeAuthorization, WakeDispatch, WakeSchedule, WebhookSecret, X402Challenge, X402DeclinedPayment, X402EmailChallenge, X402EmailChallengeDetails, X402NonceBinding, X402PaymentPayload, X402PaymentRequirements, X402PayoutAddress, X402Receipt, X402SpendPolicy } from './types.gen.js'; +export type { Account, AccountUpdated, AddDomainData, AddDomainError, AddDomainErrors, AddDomainInput, AddDomainResponse, AddDomainResponses, AgentAccountResult, AgentAccountUpgradeHint, AgentClaimLinkResult, AgentClaimResult, AgentClaimStartResult, AgentOrgRef, AgentSignupResendResult, AgentSignupStartResult, AgentSignupVerifyResult, ClientOptions, CliLoginPollResult, CliLoginStartResult, CliLogoutData, CliLogoutError, CliLogoutErrors, CliLogoutInput, CliLogoutResponse, CliLogoutResponses, CliLogoutResult, CliSignupResendResult, CliSignupStartResult, CliSignupVerifyResult, Conversation, ConversationMessage, CreateAgentAccountData, CreateAgentAccountError, CreateAgentAccountErrors, CreateAgentAccountInput, CreateAgentAccountResponse, CreateAgentAccountResponses, CreateAgentClaimLinkData, CreateAgentClaimLinkError, CreateAgentClaimLinkErrors, CreateAgentClaimLinkInput, CreateAgentClaimLinkResponse, CreateAgentClaimLinkResponses, CreateChallengeData, CreateChallengeError, CreateChallengeErrors, CreateChallengeInput, CreateChallengeResponse, CreateChallengeResponses, CreateEmailChallengeData, CreateEmailChallengeError, CreateEmailChallengeErrors, CreateEmailChallengeInput, CreateEmailChallengeResponse, CreateEmailChallengeResponses, CreateEndpointData, CreateEndpointError, CreateEndpointErrors, CreateEndpointInput, CreateEndpointResponse, CreateEndpointResponses, CreateFilterData, CreateFilterError, CreateFilterErrors, CreateFilterInput, CreateFilterResponse, CreateFilterResponses, CreateFunctionData, CreateFunctionError, CreateFunctionErrors, CreateFunctionInput, CreateFunctionResponse, CreateFunctionResponses, CreateFunctionResult, CreateFunctionSecretData, CreateFunctionSecretError, CreateFunctionSecretErrors, CreateFunctionSecretInput, CreateFunctionSecretResponse, CreateFunctionSecretResponses, CreateOrgSecretData, CreateOrgSecretError, CreateOrgSecretErrors, CreateOrgSecretInput, CreateOrgSecretResponse, CreateOrgSecretResponses, CreateRegistryData, CreateRegistryError, CreateRegistryErrors, CreateRegistryInput, CreateRegistryResponse, CreateRegistryResponses, CreateRouteData, CreateRouteError, CreateRouteErrors, CreateRouteInput, CreateRouteResponse, CreateRouteResponses, CreateWakeAuthorizationData, CreateWakeAuthorizationError, CreateWakeAuthorizationErrors, CreateWakeAuthorizationInput, CreateWakeAuthorizationResponse, CreateWakeAuthorizationResponses, CreateWakeScheduleData, CreateWakeScheduleError, CreateWakeScheduleErrors, CreateWakeScheduleInput, CreateWakeScheduleResponse, CreateWakeScheduleResponses, Cursor, DecideRegistryRequestData, DecideRegistryRequestError, DecideRegistryRequestErrors, DecideRegistryRequestInput, DecideRegistryRequestResponse, DecideRegistryRequestResponses, DefineAgentData, DefineAgentError, DefineAgentErrors, DefineAgentInput, DefineAgentResponse, DefineAgentResponses, DeleteDomainData, DeleteDomainError, DeleteDomainErrors, DeleteDomainResponse, DeleteDomainResponses, DeleteEmailData, DeleteEmailError, DeleteEmailErrors, DeleteEmailResponse, DeleteEmailResponses, DeleteEndpointData, DeleteEndpointError, DeleteEndpointErrors, DeleteEndpointResponse, DeleteEndpointResponses, DeleteFilterData, DeleteFilterError, DeleteFilterErrors, DeleteFilterResponse, DeleteFilterResponses, DeleteFunctionData, DeleteFunctionError, DeleteFunctionErrors, DeleteFunctionResponse, DeleteFunctionResponses, DeleteFunctionSecretData, DeleteFunctionSecretError, DeleteFunctionSecretErrors, DeleteFunctionSecretResponse, DeleteFunctionSecretResponses, DeleteMemoryData, DeleteMemoryError, DeleteMemoryErrors, DeleteMemoryResponse, DeleteMemoryResponses, DeleteMemoryResult, DeleteOrgSecretData, DeleteOrgSecretError, DeleteOrgSecretErrors, DeleteOrgSecretResponse, DeleteOrgSecretResponses, DeleteRegistryData, DeleteRegistryError, DeleteRegistryErrors, DeleteRegistryResponse, DeleteRegistryResponses, DeleteRouteData, DeleteRouteError, DeleteRouteErrors, DeleteRouteResponse, DeleteRouteResponses, DeleteWakeAuthorizationData, DeleteWakeAuthorizationError, DeleteWakeAuthorizationErrors, DeleteWakeAuthorizationResponse, DeleteWakeAuthorizationResponses, DeleteWakeScheduleData, DeleteWakeScheduleError, DeleteWakeScheduleErrors, DeleteWakeScheduleResponse, DeleteWakeScheduleResponses, DeliveryStatus, DeliverySummary, DiscardContentResult, DiscardEmailContentData, DiscardEmailContentError, DiscardEmailContentErrors, DiscardEmailContentResponse, DiscardEmailContentResponses, DkimSignature, Domain, DomainDnsRecord, DomainVerifyResult, DownloadAttachmentsData, DownloadAttachmentsError, DownloadAttachmentsErrors, DownloadAttachmentsResponse, DownloadAttachmentsResponses, DownloadDomainZoneFileData, DownloadDomainZoneFileError, DownloadDomainZoneFileErrors, DownloadDomainZoneFileResponse, DownloadDomainZoneFileResponses, DownloadRawEmailData, DownloadRawEmailError, DownloadRawEmailErrors, DownloadRawEmailResponse, DownloadRawEmailResponses, EmailAddress, EmailAttachment, EmailAuth, EmailDetail, EmailDetailReply, EmailSearchFacetBucket, EmailSearchFacets, EmailSearchHighlights, EmailSearchMeta, EmailSearchResult, EmailStatus, EmailSummary, EmailWebhookStatus, Endpoint, ErrorResponse, Filter, FunctionDeployStatus, FunctionDetail, FunctionListItem, FunctionLogRow, FunctionRouteBody, FunctionRouteResult, FunctionRouting, FunctionSecretListItem, FunctionSecretWriteResult, FunctionTestRun, FunctionTestRunDelivery, FunctionTestRunDeliveryEndpoint, FunctionTestRunInboundEmail, FunctionTestRunOutboundRequest, FunctionTestRunReply, FunctionTestRunSend, FunctionTestRunState, FunctionTestRunTrace, GateDenial, GateFix, GetAccountData, GetAccountError, GetAccountErrors, GetAccountResponse, GetAccountResponses, GetAgentData, GetAgentError, GetAgentErrors, GetAgentResponse, GetAgentResponses, GetChallengeData, GetChallengeError, GetChallengeErrors, GetChallengeResponse, GetChallengeResponses, GetConversationData, GetConversationError, GetConversationErrors, GetConversationResponse, GetConversationResponses, GetEmailData, GetEmailError, GetEmailErrors, GetEmailResponse, GetEmailResponses, GetFunctionData, GetFunctionError, GetFunctionErrors, GetFunctionResponse, GetFunctionResponses, GetFunctionRoutingData, GetFunctionRoutingError, GetFunctionRoutingErrors, GetFunctionRoutingResponse, GetFunctionRoutingResponses, GetFunctionTestRunTraceData, GetFunctionTestRunTraceError, GetFunctionTestRunTraceErrors, GetFunctionTestRunTraceResponse, GetFunctionTestRunTraceResponses, GetInboxStatusData, GetInboxStatusError, GetInboxStatusErrors, GetInboxStatusResponse, GetInboxStatusResponses, GetMemoryData, GetMemoryError, GetMemoryErrors, GetMemoryResponse, GetMemoryResponses, GetOrgRoutingTopologyData, GetOrgRoutingTopologyError, GetOrgRoutingTopologyErrors, GetOrgRoutingTopologyResponse, GetOrgRoutingTopologyResponses, GetRegistryData, GetRegistryError, GetRegistryErrors, GetRegistryResponse, GetRegistryResponses, GetSendPermissionsData, GetSendPermissionsError, GetSendPermissionsErrors, GetSendPermissionsResponse, GetSendPermissionsResponses, GetSentEmailData, GetSentEmailError, GetSentEmailErrors, GetSentEmailResponse, GetSentEmailResponses, GetSpendPolicyData, GetSpendPolicyError, GetSpendPolicyErrors, GetSpendPolicyResponse, GetSpendPolicyResponses, GetStorageStatsData, GetStorageStatsError, GetStorageStatsErrors, GetStorageStatsResponse, GetStorageStatsResponses, GetTemplateData, GetTemplateError, GetTemplateErrors, GetTemplateInstallData, GetTemplateInstallError, GetTemplateInstallErrors, GetTemplateInstallResponse, GetTemplateInstallResponses, GetTemplateResponse, GetTemplateResponses, GetThreadData, GetThreadError, GetThreadErrors, GetThreadResponse, GetThreadResponses, GetWakeScheduleData, GetWakeScheduleError, GetWakeScheduleErrors, GetWakeScheduleResponse, GetWakeScheduleResponses, GetWebhookSecretData, GetWebhookSecretError, GetWebhookSecretErrors, GetWebhookSecretResponse, GetWebhookSecretResponses, IdempotencyKey, InboxStatus, InboxStatusDomain, InboxStatusEndpointSummary, InboxStatusFunctionSummary, InboxStatusNextAction, InboxStatusRecentEmailSummary, InstallTemplateBody, InstallTemplateData, InstallTemplateError, InstallTemplateErrors, InstallTemplateResponse, InstallTemplateResponses, Limit, ListDeclinedPaymentsData, ListDeclinedPaymentsError, ListDeclinedPaymentsErrors, ListDeclinedPaymentsResponse, ListDeclinedPaymentsResponses, ListDeliveriesData, ListDeliveriesError, ListDeliveriesErrors, ListDeliveriesResponse, ListDeliveriesResponses, ListDomainsData, ListDomainsError, ListDomainsErrors, ListDomainsResponse, ListDomainsResponses, ListEmailsData, ListEmailsError, ListEmailsErrors, ListEmailsResponse, ListEmailsResponses, ListEndpointsData, ListEndpointsError, ListEndpointsErrors, ListEndpointsResponse, ListEndpointsResponses, ListEnvelope, ListFiltersData, ListFiltersError, ListFiltersErrors, ListFiltersResponse, ListFiltersResponses, ListFunctionLogsData, ListFunctionLogsError, ListFunctionLogsErrors, ListFunctionLogsResponse, ListFunctionLogsResponses, ListFunctionsData, ListFunctionSecretsData, ListFunctionSecretsError, ListFunctionSecretsErrors, ListFunctionSecretsResponse, ListFunctionSecretsResponses, ListFunctionsError, ListFunctionsErrors, ListFunctionsResponse, ListFunctionsResponses, ListOrgSecretsData, ListOrgSecretsError, ListOrgSecretsErrors, ListOrgSecretsResponse, ListOrgSecretsResponses, ListPayoutAddressesData, ListPayoutAddressesError, ListPayoutAddressesErrors, ListPayoutAddressesResponse, ListPayoutAddressesResponses, ListRegistriesData, ListRegistriesError, ListRegistriesErrors, ListRegistriesResponse, ListRegistriesResponses, ListRegistryAgentsData, ListRegistryAgentsResponse, ListRegistryAgentsResponses, ListRegistryRequestsData, ListRegistryRequestsError, ListRegistryRequestsErrors, ListRegistryRequestsResponse, ListRegistryRequestsResponses, ListRoutesData, ListRoutesError, ListRoutesErrors, ListRoutesResponse, ListRoutesResponses, ListSentEmailsData, ListSentEmailsError, ListSentEmailsErrors, ListSentEmailsResponse, ListSentEmailsResponses, ListTemplatesData, ListTemplatesError, ListTemplatesErrors, ListTemplatesResponse, ListTemplatesResponses, ListWakeAuthorizationsData, ListWakeAuthorizationsError, ListWakeAuthorizationsErrors, ListWakeAuthorizationsResponse, ListWakeAuthorizationsResponses, ListWakeDispatchesData, ListWakeDispatchesError, ListWakeDispatchesErrors, ListWakeDispatchesResponse, ListWakeDispatchesResponses, ListWakeSchedulesData, ListWakeSchedulesError, ListWakeSchedulesErrors, ListWakeSchedulesResponse, ListWakeSchedulesResponses, MemoryJsonValue, MemoryKeyQuery, MemoryRecord, MemoryRecordWithValue, MemoryResolvedScope, MemoryScope, MemoryScopeId, MemoryScopeQueryType, NumericString, OrgSecretListItem, OrgSecretWriteResult, PaginationMeta, ParsedEmailData, PayChallengeData, PayChallengeError, PayChallengeErrors, PayChallengeInput, PayChallengeResponse, PayChallengeResponses, PlanLimits, PollCliLoginData, PollCliLoginError, PollCliLoginErrors, PollCliLoginInput, PollCliLoginResponse, PollCliLoginResponses, PublishAgentData, PublishAgentError, PublishAgentErrors, PublishAgentInput, PublishAgentResponse, PublishAgentResponses, PublishAgentResult, PublishPolicy, RecipientRoute, RegisterPayoutAddressData, RegisterPayoutAddressError, RegisterPayoutAddressErrors, RegisterPayoutAddressInput, RegisterPayoutAddressResponse, RegisterPayoutAddressResponses, Registry, RegistryAgent, RegistryRequest, ReorderRoutesData, ReorderRoutesError, ReorderRoutesErrors, ReorderRoutesInput, ReorderRoutesResponse, ReorderRoutesResponses, ReplayDeliveryData, ReplayDeliveryError, ReplayDeliveryErrors, ReplayDeliveryResponse, ReplayDeliveryResponses, ReplayEmailWebhooksData, ReplayEmailWebhooksError, ReplayEmailWebhooksErrors, ReplayEmailWebhooksResponse, ReplayEmailWebhooksResponses, ReplayResult, ReplyInput, ReplyToEmailData, ReplyToEmailError, ReplyToEmailErrors, ReplyToEmailResponse, ReplyToEmailResponses, ResendAgentSignupVerificationData, ResendAgentSignupVerificationError, ResendAgentSignupVerificationErrors, ResendAgentSignupVerificationInput, ResendAgentSignupVerificationResponse, ResendAgentSignupVerificationResponses, ResendCliSignupVerificationData, ResendCliSignupVerificationError, ResendCliSignupVerificationErrors, ResendCliSignupVerificationInput, ResendCliSignupVerificationResponse, ResendCliSignupVerificationResponses, ResolveRegistryHandleData, ResolveRegistryHandleError, ResolveRegistryHandleErrors, ResolveRegistryHandleResponse, ResolveRegistryHandleResponses, ResourceId, RotateWebhookSecretData, RotateWebhookSecretError, RotateWebhookSecretErrors, RotateWebhookSecretResponse, RotateWebhookSecretResponses, RouteEvaluatedEntry, RoutingTopology, RunWakeScheduleData, RunWakeScheduleError, RunWakeScheduleErrors, RunWakeScheduleResponse, RunWakeScheduleResponses, SearchEmailsData, SearchEmailsError, SearchEmailsErrors, SearchEmailsResponse, SearchEmailsResponses, SearchMemoriesData, SearchMemoriesError, SearchMemoriesErrors, SearchMemoriesResponse, SearchMemoriesResponses, SemanticSearchCoverage, SemanticSearchData, SemanticSearchError, SemanticSearchErrors, SemanticSearchField, SemanticSearchInput, SemanticSearchMeta, SemanticSearchResponse, SemanticSearchResponses, SemanticSearchResult, SemanticSearchScoreBreakdown, SemanticSearchSnippet, SendEmailData, SendEmailError, SendEmailErrors, SendEmailResponse, SendEmailResponses, SendMailAttachment, SendMailInput, SendMailPayloadRef, SendMailResult, SendPermissionAddress, SendPermissionAnyRecipient, SendPermissionManagedZone, SendPermissionRule, SendPermissionsMeta, SendPermissionYourDomain, SentEmailDetail, SentEmailStatus, SentEmailSummary, SetFunctionRouteData, SetFunctionRouteError, SetFunctionRouteErrors, SetFunctionRouteResponse, SetFunctionRouteResponses, SetFunctionSecretData, SetFunctionSecretError, SetFunctionSecretErrors, SetFunctionSecretInput, SetFunctionSecretResponse, SetFunctionSecretResponses, SetMemoryData, SetMemoryError, SetMemoryErrors, SetMemoryInput, SetMemoryResponse, SetMemoryResponses, SetOrgSecretData, SetOrgSecretError, SetOrgSecretErrors, SetOrgSecretInput, SetOrgSecretResponse, SetOrgSecretResponses, SimulateRouteData, SimulateRouteError, SimulateRouteErrors, SimulateRouteInput, SimulateRouteResponse, SimulateRouteResponses, SimulateRouteResult, StartAgentClaimData, StartAgentClaimError, StartAgentClaimErrors, StartAgentClaimInput, StartAgentClaimResponse, StartAgentClaimResponses, StartAgentSignupData, StartAgentSignupError, StartAgentSignupErrors, StartAgentSignupInput, StartAgentSignupResponse, StartAgentSignupResponses, StartCliLoginData, StartCliLoginError, StartCliLoginErrors, StartCliLoginInput, StartCliLoginResponse, StartCliLoginResponses, StartCliSignupData, StartCliSignupError, StartCliSignupErrors, StartCliSignupInput, StartCliSignupResponse, StartCliSignupResponses, StorageStats, SuccessEnvelope, TemplateAuthor, TemplateInstall, TemplateInstallState, TemplateInstallStatus, TemplateManifest, TemplateRegistryDetail, TemplateRegistryPage, TemplateRegistryStatus, TemplateRegistrySummary, TemplateSecret, TemplateSecretGroup, TemplateSetup, TemplateSource, TemplateVariable, TemplateVariableValidation, TestEndpointData, TestEndpointError, TestEndpointErrors, TestEndpointResponse, TestEndpointResponses, TestFunctionData, TestFunctionError, TestFunctionErrors, TestFunctionResponse, TestFunctionResponses, TestInvocationResult, TestResult, Thread, ThreadMessage, UnpublishAgentData, UnpublishAgentError, UnpublishAgentErrors, UnpublishAgentResponse, UnpublishAgentResponses, UnsetFunctionRouteData, UnsetFunctionRouteError, UnsetFunctionRouteErrors, UnsetFunctionRouteResponse, UnsetFunctionRouteResponses, UnverifiedDomain, UpdateAccountData, UpdateAccountError, UpdateAccountErrors, UpdateAccountInput, UpdateAccountResponse, UpdateAccountResponses, UpdateDomainData, UpdateDomainError, UpdateDomainErrors, UpdateDomainInput, UpdateDomainResponse, UpdateDomainResponses, UpdateEndpointData, UpdateEndpointError, UpdateEndpointErrors, UpdateEndpointInput, UpdateEndpointResponse, UpdateEndpointResponses, UpdateFilterData, UpdateFilterError, UpdateFilterErrors, UpdateFilterInput, UpdateFilterResponse, UpdateFilterResponses, UpdateFunctionData, UpdateFunctionError, UpdateFunctionErrors, UpdateFunctionInput, UpdateFunctionResponse, UpdateFunctionResponses, UpdateRegistryData, UpdateRegistryError, UpdateRegistryErrors, UpdateRegistryInput, UpdateRegistryResponse, UpdateRegistryResponses, UpdateRouteData, UpdateRouteError, UpdateRouteErrors, UpdateRouteInput, UpdateRouteResponse, UpdateRouteResponses, UpdateSpendPolicyData, UpdateSpendPolicyError, UpdateSpendPolicyErrors, UpdateSpendPolicyInput, UpdateSpendPolicyResponse, UpdateSpendPolicyResponses, UpdateWakeAuthorizationData, UpdateWakeAuthorizationError, UpdateWakeAuthorizationErrors, UpdateWakeAuthorizationInput, UpdateWakeAuthorizationResponse, UpdateWakeAuthorizationResponses, UpdateWakeScheduleData, UpdateWakeScheduleError, UpdateWakeScheduleErrors, UpdateWakeScheduleInput, UpdateWakeScheduleResponse, UpdateWakeScheduleResponses, VerifiedDomain, VerifyAgentClaimData, VerifyAgentClaimError, VerifyAgentClaimErrors, VerifyAgentClaimInput, VerifyAgentClaimResponse, VerifyAgentClaimResponses, VerifyAgentSignupData, VerifyAgentSignupError, VerifyAgentSignupErrors, VerifyAgentSignupInput, VerifyAgentSignupResponse, VerifyAgentSignupResponses, VerifyCliSignupData, VerifyCliSignupError, VerifyCliSignupErrors, VerifyCliSignupInput, VerifyCliSignupResponse, VerifyCliSignupResponses, VerifyDomainData, VerifyDomainError, VerifyDomainErrors, VerifyDomainResponse, VerifyDomainResponses, WakeAuthorization, WakeDispatch, WakeSchedule, WebhookSecret, X402Challenge, X402DeclinedPayment, X402EmailChallenge, X402EmailChallengeDetails, X402NonceBinding, X402PaymentPayload, X402PaymentRequirements, X402PayoutAddress, X402Receipt, X402SpendPolicy } from './types.gen.js'; diff --git a/packages/api-core/src/api/types.gen.ts b/packages/api-core/src/api/types.gen.ts index 5d5272ca..9b3b5b4d 100644 --- a/packages/api-core/src/api/types.gen.ts +++ b/packages/api-core/src/api/types.gen.ts @@ -1860,6 +1860,28 @@ export type SendMailAttachment = { content_base64: string; }; +/** + * A reference to an already-uploaded Primitive Payloads object, delivered as an attachment without inlining the bytes — the way to send an attachment larger than the inline cap. Upload the object via /v1/payloads (with a client-held CEK the server never sees), then reference it here. + */ +export type SendMailPayloadRef = { + /** + * The 64-char lowercase-hex Merkle root of a finalized payloads object. + */ + root: string; + /** + * Attachment filename presented to the recipient. + */ + filename: string; + /** + * Optional MIME content type. + */ + content_type?: string; + /** + * Base64url-encoded (unpadded) content-encryption key the recipient uses to decrypt. Travels with the email; the object store only ever holds ciphertext. + */ + cek: string; +}; + export type SendMailInput = { /** * RFC 5322 From header. The sender domain must be a verified outbound domain for your organization. @@ -1893,6 +1915,10 @@ export type SendMailInput = { * Inline attachments. Send requests with attachments to https://api.primitive.dev/v1/send-mail. Combined raw decoded attachment bytes must be at most 31457280. */ attachments?: Array; + /** + * Deliver an already-uploaded Primitive Payloads object as an attachment by reference, without inlining the bytes — the way to send attachments larger than the inline cap. Upload the object via /v1/payloads (client-held CEK), then reference it here. v1 supports at most one. + */ + payload_attachments?: Array; /** * When true, wait for the first downstream SMTP delivery outcome before returning. */ diff --git a/packages/api-core/src/openapi/openapi.generated.ts b/packages/api-core/src/openapi/openapi.generated.ts index fd4dff0e..fb726bc3 100644 --- a/packages/api-core/src/openapi/openapi.generated.ts +++ b/packages/api-core/src/openapi/openapi.generated.ts @@ -11482,6 +11482,40 @@ export const openapiDocument: Record = { "content_base64" ] }, + "SendMailPayloadRef": { + "type": "object", + "additionalProperties": false, + "description": "A reference to an already-uploaded Primitive Payloads object, delivered as an attachment without inlining the bytes — the way to send an attachment larger than the inline cap. Upload the object via /v1/payloads (with a client-held CEK the server never sees), then reference it here.", + "properties": { + "root": { + "type": "string", + "pattern": "^[0-9a-f]{64}$", + "description": "The 64-char lowercase-hex Merkle root of a finalized payloads object." + }, + "filename": { + "type": "string", + "minLength": 1, + "maxLength": 255, + "description": "Attachment filename presented to the recipient." + }, + "content_type": { + "type": "string", + "minLength": 1, + "maxLength": 255, + "description": "Optional MIME content type." + }, + "cek": { + "type": "string", + "pattern": "^[A-Za-z0-9_-]{1,128}$", + "description": "Base64url-encoded (unpadded) content-encryption key the recipient uses to decrypt. Travels with the email; the object store only ever holds ciphertext." + } + }, + "required": [ + "root", + "filename", + "cek" + ] + }, "SendMailInput": { "type": "object", "additionalProperties": false, @@ -11538,6 +11572,14 @@ export const openapiDocument: Record = { "$ref": "#/components/schemas/SendMailAttachment" } }, + "payload_attachments": { + "type": "array", + "maxItems": 1, + "description": "Deliver an already-uploaded Primitive Payloads object as an attachment by reference, without inlining the bytes — the way to send attachments larger than the inline cap. Upload the object via /v1/payloads (client-held CEK), then reference it here. v1 supports at most one.", + "items": { + "$ref": "#/components/schemas/SendMailPayloadRef" + } + }, "wait": { "type": "boolean", "description": "When true, wait for the first downstream SMTP delivery outcome before returning." diff --git a/packages/api-core/src/openapi/operations.generated.ts b/packages/api-core/src/openapi/operations.generated.ts index be702a89..e70229f1 100644 --- a/packages/api-core/src/openapi/operations.generated.ts +++ b/packages/api-core/src/openapi/operations.generated.ts @@ -11598,6 +11598,45 @@ export const operationManifest: PrimitiveOperationManifest[] = [ ] } }, + "payload_attachments": { + "type": "array", + "maxItems": 1, + "description": "Deliver an already-uploaded Primitive Payloads object as an attachment by reference, without inlining the bytes — the way to send attachments larger than the inline cap. Upload the object via /v1/payloads (client-held CEK), then reference it here. v1 supports at most one.", + "items": { + "type": "object", + "additionalProperties": false, + "description": "A reference to an already-uploaded Primitive Payloads object, delivered as an attachment without inlining the bytes — the way to send an attachment larger than the inline cap. Upload the object via /v1/payloads (with a client-held CEK the server never sees), then reference it here.", + "properties": { + "root": { + "type": "string", + "pattern": "^[0-9a-f]{64}$", + "description": "The 64-char lowercase-hex Merkle root of a finalized payloads object." + }, + "filename": { + "type": "string", + "minLength": 1, + "maxLength": 255, + "description": "Attachment filename presented to the recipient." + }, + "content_type": { + "type": "string", + "minLength": 1, + "maxLength": 255, + "description": "Optional MIME content type." + }, + "cek": { + "type": "string", + "pattern": "^[A-Za-z0-9_-]{1,128}$", + "description": "Base64url-encoded (unpadded) content-encryption key the recipient uses to decrypt. Travels with the email; the object store only ever holds ciphertext." + } + }, + "required": [ + "root", + "filename", + "cek" + ] + } + }, "wait": { "type": "boolean", "description": "When true, wait for the first downstream SMTP delivery outcome before returning." diff --git a/sdk-node/src/api/index.ts b/sdk-node/src/api/index.ts index 3d26bba8..78eec203 100644 --- a/sdk-node/src/api/index.ts +++ b/sdk-node/src/api/index.ts @@ -15,6 +15,7 @@ * here rather than in api-core. */ +import { readFile, stat } from "node:fs/promises"; import { type AgentAccountResult, type AgentClaimLinkResult, @@ -51,6 +52,7 @@ import { type StartAgentClaimInput, type VerifyAgentClaimInput, } from "@primitivedotdev/api-core"; +import { type PushResult, pushBytes, pushFile } from "../payloads/index.js"; import type { ReceivedEmail } from "../webhook/received-email.js"; import { formatAddress } from "../webhook/received-email.js"; @@ -133,6 +135,28 @@ export interface SendThreadInput { export type SendAttachment = GeneratedSendMailAttachment; +/** + * A reference to an already-uploaded Primitive Payloads object, delivered as an + * attachment without inlining the bytes — the way to send an attachment larger + * than the inline cap. Upload the object with `payloads.pushFile` / `pushBytes` + * (client-held CEK), then reference it here. Prefer {@link PrimitiveClient.sendAttachment}, + * which picks inline-vs-reference by size for you. + */ +export interface SendPayloadReference { + /** 64-char lowercase-hex Merkle root of the finalized object (`PushResult.merkleRoot`). */ + root: string; + /** Filename presented to the recipient. */ + filename: string; + /** Optional MIME content type. */ + contentType?: string; + /** + * Base64url-encoded (unpadded) content-encryption key the recipient uses to + * decrypt. Note `PushResult.cek` is hex — convert with + * `Buffer.from(cek, "hex").toString("base64url")`. + */ + cek: string; +} + export interface SendInput { from: string; to: string; @@ -140,10 +164,44 @@ export interface SendInput { bodyText?: string; bodyHtml?: string; thread?: SendThreadInput; + /** Inline attachments (base64 content). Combined raw bytes must stay under the inline cap (~30 MiB). */ + attachments?: SendAttachment[]; + /** Deliver already-uploaded payloads objects by reference (v1: at most one). No size ceiling. */ + payloadAttachments?: SendPayloadReference[]; wait?: boolean; waitTimeoutMs?: number; } +/** Attachments at or below this size are sent inline; larger ones are uploaded + referenced. */ +const DEFAULT_INLINE_ATTACHMENT_MAX = 25 * 1024 * 1024; + +/** + * Input for {@link PrimitiveClient.sendAttachment}: the normal send fields plus + * one attachment, given as in-memory `content` OR a file `path`. The SDK sends + * it inline when it's small and uploads-then-references it when it's large, so + * the caller never has to choose the mechanism. Provide exactly one of + * `content` / `path`. + */ +export type SendAttachmentInput = Omit< + SendInput, + "attachments" | "payloadAttachments" +> & { + attachment: { + filename: string; + contentType?: string; + /** In-memory bytes. Use `path` for large files that shouldn't be resident. */ + content?: Uint8Array; + /** Path to a file streamed from disk (bounded memory) — for large attachments. */ + path?: string; + }; + /** + * Size at or below which the attachment goes inline; larger ones are uploaded + * as an E2E-encrypted payloads object and delivered by reference. Defaults to + * 25 MiB (the server's inline/offload threshold). + */ + inlineThreshold?: number; +}; + export interface RequestOptions { /** Cancel the in-flight request when this signal fires. Surfaces as AbortError. */ signal?: AbortSignal; @@ -992,6 +1050,24 @@ export class PrimitiveClient extends PrimitiveApiClient { /** Durable JSON key-value state for agents and Functions. */ readonly memories: MemoriesResource = new MemoriesResource(this.client); + // Captured for sendAttachment's upload path, which talks to /v1/payloads + // directly (streaming, content-addressed) rather than through the generated + // operation client. + readonly #payloadApiKey: string | undefined; + readonly #payloadBaseUrl: string; + + constructor(options: PrimitiveClientOptions = {}) { + super(options); + this.#payloadApiKey = options.apiKey; + // The generated config's baseUrl carries a trailing "/v1"; the payloads + // client appends "/v1/payloads" itself, so strip it (string ops, not a + // regex — the payloads module avoids regex on URLs for ReDoS safety). + let base = this.getConfig().baseUrl ?? "https://api.primitive.dev/v1"; + while (base.endsWith("/")) base = base.slice(0, -1); + if (base.endsWith("/v1")) base = base.slice(0, -3); + this.#payloadBaseUrl = base; + } + async send(input: SendInput, options?: RequestOptions): Promise { validateSendInput(input); @@ -1007,6 +1083,19 @@ export class PrimitiveClient extends PrimitiveApiClient { ...(input.thread?.references?.length ? { references: input.thread.references } : {}), + ...(input.attachments?.length ? { attachments: input.attachments } : {}), + ...(input.payloadAttachments?.length + ? { + payload_attachments: input.payloadAttachments.map((ref) => ({ + root: ref.root, + filename: ref.filename, + cek: ref.cek, + ...(ref.contentType !== undefined + ? { content_type: ref.contentType } + : {}), + })), + } + : {}), ...(input.wait !== undefined ? { wait: input.wait } : {}), ...(input.waitTimeoutMs !== undefined ? { wait_timeout_ms: input.waitTimeoutMs } @@ -1022,6 +1111,116 @@ export class PrimitiveClient extends PrimitiveApiClient { return unwrapSendResult(result); } + /** + * Send an attachment of any size in one call. Small attachments are delivered + * inline; large ones are uploaded as an E2E-encrypted, content-addressed + * Primitive Payloads object and delivered by reference — unbounded size, and + * streamed from disk (bounded memory) when given a `path`. Either way the + * recipient gets a normal attachment (Primitive agents, via the DKIM-signed + * reference) or a download link (any mailbox). The SDK never sees a smaller + * ceiling than the object model itself. + * + * // in-memory + * await client.sendAttachment({ from, to, subject, bodyText, + * attachment: { filename: "note.txt", content: bytes } }); + * // large file, streamed + * await client.sendAttachment({ from, to, subject, bodyText, + * attachment: { filename: "video.mp4", path: "./video.mp4" } }); + */ + async sendAttachment( + input: SendAttachmentInput, + options?: RequestOptions, + ): Promise { + const { attachment, inlineThreshold, ...rest } = input; + const threshold = inlineThreshold ?? DEFAULT_INLINE_ATTACHMENT_MAX; + const meta = { + filename: attachment.filename, + contentType: attachment.contentType, + }; + + if (attachment.content !== undefined) { + return attachment.content.length <= threshold + ? this.#sendInline(rest, meta, attachment.content, options) + : this.#sendByReference( + rest, + meta, + await pushBytes(attachment.content, this.#pushOptions()), + options, + ); + } + if (attachment.path !== undefined) { + const { size } = await stat(attachment.path); + return size <= threshold + ? this.#sendInline(rest, meta, await readFile(attachment.path), options) + : this.#sendByReference( + rest, + meta, + await pushFile(attachment.path, this.#pushOptions()), + options, + ); + } + throw new TypeError( + "sendAttachment requires exactly one of attachment.content or attachment.path", + ); + } + + #pushOptions(): { apiKey: string; baseUrl: string } { + if (this.#payloadApiKey === undefined) { + throw new TypeError( + "sendAttachment requires an API key to upload a large attachment", + ); + } + return { apiKey: this.#payloadApiKey, baseUrl: this.#payloadBaseUrl }; + } + + #sendInline( + rest: Omit, + meta: { filename: string; contentType?: string }, + bytes: Uint8Array, + options?: RequestOptions, + ): Promise { + return this.send( + { + ...rest, + attachments: [ + { + filename: meta.filename, + content_base64: Buffer.from(bytes).toString("base64"), + ...(meta.contentType !== undefined + ? { content_type: meta.contentType } + : {}), + }, + ], + }, + options, + ); + } + + #sendByReference( + rest: Omit, + meta: { filename: string; contentType?: string }, + pushed: PushResult, + options?: RequestOptions, + ): Promise { + return this.send( + { + ...rest, + payloadAttachments: [ + { + root: pushed.merkleRoot, + filename: meta.filename, + // PushResult.cek is hex; the reference carries base64url. + cek: Buffer.from(pushed.cek, "hex").toString("base64url"), + ...(meta.contentType !== undefined + ? { contentType: meta.contentType } + : {}), + }, + ], + }, + options, + ); + } + /** * Semantic / hybrid / keyword search across received and sent mail. * diff --git a/sdk-node/src/payloads/index.ts b/sdk-node/src/payloads/index.ts index 1306fa41..0022fb34 100644 --- a/sdk-node/src/payloads/index.ts +++ b/sdk-node/src/payloads/index.ts @@ -506,6 +506,69 @@ export async function pushFile( } } +/** + * Push an in-memory buffer as a Primitive Payload. Same chunked, content- + * addressed, per-chunk-AEAD encoding as {@link pushFile}, over a Uint8Array + * (chunks are views, so no copy). Use {@link pushFile} for large objects that + * shouldn't be resident in memory. Returns the content address (merkleRoot) and + * the hex CEK. + */ +export async function pushBytes( + bytes: Uint8Array, + opts: PushOptions, +): Promise { + const chunkSize = opts.chunkSize ?? CHUNK_SIZE; + const concurrency = opts.concurrency ?? 3; + const size = bytes.length; + const cek = randomBytes(CEK_BYTES); + const objectId = randomBytes(OBJECT_ID_BYTES); + const chunkCount = size === 0 ? 0 : Math.ceil(size / chunkSize); + const chunkOf = (index: number): Uint8Array => + bytes.subarray(index * chunkSize, Math.min(size, (index + 1) * chunkSize)); + + // Pass 1: content-address every chunk to build the manifest. + const descriptors: ChunkDescriptor[] = []; + for (let index = 0; index < chunkCount; index++) { + const plaintext = chunkOf(index); + const ciphertext = await encryptChunk(cek, objectId, index, plaintext); + descriptors.push({ + index, + ciphertextHash: await contentHashHex(ciphertext), + plaintextSize: plaintext.length, + ciphertextSize: ciphertext.length, + }); + opts.onProgress?.("encrypt", index + 1, chunkCount); + } + + const root = await merkleRoot(descriptors.map((d) => d.ciphertextHash)); + const manifest: PayloadManifest = { + version: MANIFEST_VERSION, + objectId: toHex(objectId), + chunkSize, + totalPlaintextSize: size, + chunkCount, + chunks: descriptors, + merkleRoot: root, + }; + await initiate(opts, manifest); + + // Pass 2: re-encrypt (deterministic) and upload, bounded concurrency. + let uploaded = 0; + await mapLimit(descriptors, concurrency, async (d) => { + const ciphertext = await encryptChunk( + cek, + objectId, + d.index, + chunkOf(d.index), + ); + await putChunk(opts, root, d, ciphertext); + opts.onProgress?.("upload", ++uploaded, chunkCount); + }); + + await finalize(opts, root); + return { merkleRoot: root, cek: toHex(cek), chunkCount, totalBytes: size }; +} + /** * Stream a Primitive Payload down to a file, verifying and decrypting one chunk * at a time (bounded memory). Every chunk is content-address-checked before diff --git a/sdk-node/tests/api/client.test.ts b/sdk-node/tests/api/client.test.ts index f328be10..016d671d 100644 --- a/sdk-node/tests/api/client.test.ts +++ b/sdk-node/tests/api/client.test.ts @@ -1180,3 +1180,139 @@ describe("PrimitiveClient", () => { } satisfies Partial); }); }); + +describe("PrimitiveClient send-by-reference + sendAttachment", () => { + const jsonOk = (data: unknown) => + new Response(JSON.stringify({ success: true, data }), { + status: 200, + headers: { "content-type": "application/json" }, + }); + + it("threads payloadAttachments into the wire body as payload_attachments", async () => { + const fetchMock = vi.fn(async (input) => { + const request = input as Request; + expect(request.url).toBe("https://api.example.test/v1/send-mail"); + expect(await request.json()).toMatchObject({ + payload_attachments: [ + { + root: "a".repeat(64), + filename: "big.bin", + content_type: "application/octet-stream", + cek: "Zm9v", + }, + ], + }); + return jsonOk(SEND_RESULT); + }) as typeof fetch; + const client = new PrimitiveClient({ + apiKey: "prim_test", + apiBaseUrl: "https://api.example.test/v1", + fetch: fetchMock, + }); + await client.send({ + from: "a@example.com", + to: "b@example.com", + subject: "Hi", + bodyText: "x", + payloadAttachments: [ + { + root: "a".repeat(64), + filename: "big.bin", + contentType: "application/octet-stream", + cek: "Zm9v", + }, + ], + }); + expect(fetchMock).toHaveBeenCalledTimes(1); + }); + + it("sendAttachment sends small content inline", async () => { + const fetchMock = vi.fn(async (input) => { + const request = input as Request; + const body = (await request.json()) as { + attachments?: unknown[]; + payload_attachments?: unknown[]; + }; + expect(body.attachments).toEqual([ + { + filename: "note.txt", + content_base64: Buffer.from("hello").toString("base64"), + }, + ]); + expect(body.payload_attachments).toBeUndefined(); + return jsonOk(SEND_RESULT); + }) as typeof fetch; + const client = new PrimitiveClient({ + apiKey: "prim_test", + apiBaseUrl: "https://api.example.test/v1", + fetch: fetchMock, + }); + await client.sendAttachment({ + from: "a@example.com", + to: "b@example.com", + subject: "Hi", + bodyText: "x", + attachment: { + filename: "note.txt", + content: new TextEncoder().encode("hello"), + }, + }); + expect(fetchMock).toHaveBeenCalledTimes(1); + }); + + it("sendAttachment uploads large content and sends it by reference", async () => { + let uploadCalls = 0; + let sentBody: + | { + payload_attachments?: Array<{ + root: string; + filename: string; + cek: string; + }>; + } + | undefined; + const spy = vi + .spyOn(globalThis, "fetch") + .mockImplementation(async (input, init) => { + const isRequest = typeof input !== "string"; + const url = isRequest ? (input as Request).url : String(input); + if (url.endsWith("/v1/send-mail")) { + sentBody = isRequest + ? await (input as Request).json() + : JSON.parse(String(init?.body)); + return jsonOk(SEND_RESULT); + } + // initiate / chunk PUT / finalize — the push helpers only check res.ok. + uploadCalls++; + return new Response(null, { + status: url.endsWith("/v1/payloads") ? 201 : 200, + }); + }); + try { + const client = new PrimitiveClient({ + apiKey: "prim_test", + apiBaseUrl: "https://api.example.test/v1", + }); + await client.sendAttachment({ + from: "a@example.com", + to: "b@example.com", + subject: "Hi", + bodyText: "x", + inlineThreshold: 4, + attachment: { + filename: "big.bin", + content: new Uint8Array([1, 2, 3, 4, 5, 6, 7, 8]), + }, + }); + } finally { + spy.mockRestore(); + } + // The object was uploaded (initiate + chunk + finalize) before the send. + expect(uploadCalls).toBeGreaterThanOrEqual(3); + const ref = sentBody?.payload_attachments?.[0]; + expect(ref?.filename).toBe("big.bin"); + expect(ref?.root).toMatch(/^[0-9a-f]{64}$/); + // The hex push CEK is converted to base64url for the reference. + expect(ref?.cek).toMatch(/^[A-Za-z0-9_-]+$/); + }); +}); From f4991887b34b56dff2893b089bd83bf5230974ec Mon Sep 17 00:00:00 2001 From: Benjamin Poile Date: Wed, 15 Jul 2026 17:46:56 -0700 Subject: [PATCH 09/13] chore(python): regenerate api client for payload_attachments The OpenAPI SendMailInput gained payload_attachments (+ SendMailPayloadRef); regenerate the python api client (openapi-python-client 0.28.3) so all SDKs stay in sync. Backward-compatible optional field; send-payloads + shared fixture tests pass. --- .../src/primitive/api/models/__init__.py | 2 + .../primitive/api/models/send_mail_input.py | 31 +++++++ .../api/models/send_mail_payload_ref.py | 87 +++++++++++++++++++ 3 files changed, 120 insertions(+) create mode 100644 sdk-python/src/primitive/api/models/send_mail_payload_ref.py diff --git a/sdk-python/src/primitive/api/models/__init__.py b/sdk-python/src/primitive/api/models/__init__.py index 73f68838..85afe978 100644 --- a/sdk-python/src/primitive/api/models/__init__.py +++ b/sdk-python/src/primitive/api/models/__init__.py @@ -317,6 +317,7 @@ from .send_email_response_200 import SendEmailResponse200 from .send_mail_attachment import SendMailAttachment from .send_mail_input import SendMailInput +from .send_mail_payload_ref import SendMailPayloadRef from .send_mail_result import SendMailResult from .send_permission_address import SendPermissionAddress from .send_permission_address_type import SendPermissionAddressType @@ -771,6 +772,7 @@ "SendEmailResponse200", "SendMailAttachment", "SendMailInput", + "SendMailPayloadRef", "SendMailResult", "SendPermissionAddress", "SendPermissionAddressType", diff --git a/sdk-python/src/primitive/api/models/send_mail_input.py b/sdk-python/src/primitive/api/models/send_mail_input.py index 583d2c13..c899075b 100644 --- a/sdk-python/src/primitive/api/models/send_mail_input.py +++ b/sdk-python/src/primitive/api/models/send_mail_input.py @@ -12,6 +12,7 @@ if TYPE_CHECKING: from ..models.send_mail_attachment import SendMailAttachment + from ..models.send_mail_payload_ref import SendMailPayloadRef @@ -36,6 +37,9 @@ class SendMailInput: references (list[str] | Unset): Full ordered message-id chain for the thread. attachments (list[SendMailAttachment] | Unset): Inline attachments. Send requests with attachments to https://api.primitive.dev/v1/send-mail. Combined raw decoded attachment bytes must be at most 31457280. + payload_attachments (list[SendMailPayloadRef] | Unset): Deliver an already-uploaded Primitive Payloads object as + an attachment by reference, without inlining the bytes — the way to send attachments larger than the inline cap. + Upload the object via /v1/payloads (client-held CEK), then reference it here. v1 supports at most one. wait (bool | Unset): When true, wait for the first downstream SMTP delivery outcome before returning. wait_timeout_ms (int | Unset): Maximum time to wait for a delivery outcome when wait is true. Defaults to 30000. """ @@ -48,6 +52,7 @@ class SendMailInput: in_reply_to: str | Unset = UNSET references: list[str] | Unset = UNSET attachments: list[SendMailAttachment] | Unset = UNSET + payload_attachments: list[SendMailPayloadRef] | Unset = UNSET wait: bool | Unset = UNSET wait_timeout_ms: int | Unset = UNSET @@ -57,6 +62,7 @@ class SendMailInput: def to_dict(self) -> dict[str, Any]: from ..models.send_mail_attachment import SendMailAttachment + from ..models.send_mail_payload_ref import SendMailPayloadRef from_ = self.from_ to = self.to @@ -84,6 +90,15 @@ def to_dict(self) -> dict[str, Any]: + payload_attachments: list[dict[str, Any]] | Unset = UNSET + if not isinstance(self.payload_attachments, Unset): + payload_attachments = [] + for payload_attachments_item_data in self.payload_attachments: + payload_attachments_item = payload_attachments_item_data.to_dict() + payload_attachments.append(payload_attachments_item) + + + wait = self.wait wait_timeout_ms = self.wait_timeout_ms @@ -106,6 +121,8 @@ def to_dict(self) -> dict[str, Any]: field_dict["references"] = references if attachments is not UNSET: field_dict["attachments"] = attachments + if payload_attachments is not UNSET: + field_dict["payload_attachments"] = payload_attachments if wait is not UNSET: field_dict["wait"] = wait if wait_timeout_ms is not UNSET: @@ -118,6 +135,7 @@ def to_dict(self) -> dict[str, Any]: @classmethod def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: from ..models.send_mail_attachment import SendMailAttachment + from ..models.send_mail_payload_ref import SendMailPayloadRef d = dict(src_dict) from_ = d.pop("from") @@ -146,6 +164,18 @@ def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: attachments.append(attachments_item) + _payload_attachments = d.pop("payload_attachments", UNSET) + payload_attachments: list[SendMailPayloadRef] | Unset = UNSET + if _payload_attachments is not UNSET: + payload_attachments = [] + for payload_attachments_item_data in _payload_attachments: + payload_attachments_item = SendMailPayloadRef.from_dict(payload_attachments_item_data) + + + + payload_attachments.append(payload_attachments_item) + + wait = d.pop("wait", UNSET) wait_timeout_ms = d.pop("wait_timeout_ms", UNSET) @@ -159,6 +189,7 @@ def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: in_reply_to=in_reply_to, references=references, attachments=attachments, + payload_attachments=payload_attachments, wait=wait, wait_timeout_ms=wait_timeout_ms, ) diff --git a/sdk-python/src/primitive/api/models/send_mail_payload_ref.py b/sdk-python/src/primitive/api/models/send_mail_payload_ref.py new file mode 100644 index 00000000..f2a5ddfb --- /dev/null +++ b/sdk-python/src/primitive/api/models/send_mail_payload_ref.py @@ -0,0 +1,87 @@ +from __future__ import annotations + +from collections.abc import Mapping +from typing import Any, TypeVar, BinaryIO, TextIO, TYPE_CHECKING, Generator + +from attrs import define as _attrs_define +from attrs import field as _attrs_field + +from ..types import UNSET, Unset + + + + + + + +T = TypeVar("T", bound="SendMailPayloadRef") + + + +@_attrs_define +class SendMailPayloadRef: + """ A reference to an already-uploaded Primitive Payloads object, delivered as an attachment without inlining the bytes + — the way to send an attachment larger than the inline cap. Upload the object via /v1/payloads (with a client-held + CEK the server never sees), then reference it here. + + Attributes: + root (str): The 64-char lowercase-hex Merkle root of a finalized payloads object. + filename (str): Attachment filename presented to the recipient. + cek (str): Base64url-encoded (unpadded) content-encryption key the recipient uses to decrypt. Travels with the + email; the object store only ever holds ciphertext. + content_type (str | Unset): Optional MIME content type. + """ + + root: str + filename: str + cek: str + content_type: str | Unset = UNSET + + + + + + def to_dict(self) -> dict[str, Any]: + root = self.root + + filename = self.filename + + cek = self.cek + + content_type = self.content_type + + + field_dict: dict[str, Any] = {} + + field_dict.update({ + "root": root, + "filename": filename, + "cek": cek, + }) + if content_type is not UNSET: + field_dict["content_type"] = content_type + + return field_dict + + + + @classmethod + def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: + d = dict(src_dict) + root = d.pop("root") + + filename = d.pop("filename") + + cek = d.pop("cek") + + content_type = d.pop("content_type", UNSET) + + send_mail_payload_ref = cls( + root=root, + filename=filename, + cek=cek, + content_type=content_type, + ) + + return send_mail_payload_ref + From 2fdb58379c585730558f865d9ad9ae8783f86d43 Mon Sep 17 00:00:00 2001 From: Benjamin Poile Date: Wed, 15 Jul 2026 17:55:02 -0700 Subject: [PATCH 10/13] chore(go): regenerate api client for payload_attachments The OpenAPI SendMailInput gained payload_attachments (+ SendMailPayloadRef); regenerate the go ogen client so all SDKs stay in sync. Backward-compatible optional field; go build + tests pass. --- sdk-go/api/oas_cfg_gen.go | 1 + sdk-go/api/oas_json_gen.go | 197 +++++++++++++++++++++++++++++-- sdk-go/api/oas_schemas_gen.go | 70 +++++++++++ sdk-go/api/oas_validators_gen.go | 147 +++++++++++++++++++++++ 4 files changed, 404 insertions(+), 11 deletions(-) diff --git a/sdk-go/api/oas_cfg_gen.go b/sdk-go/api/oas_cfg_gen.go index fa5a2dd2..dc00e5de 100644 --- a/sdk-go/api/oas_cfg_gen.go +++ b/sdk-go/api/oas_cfg_gen.go @@ -27,6 +27,7 @@ var regexMap = map[string]ogenregex.Regexp{ "^[A-Z_][A-Z0-9_]*$": ogenregex.MustCompile("^[A-Z_][A-Z0-9_]*$"), "^[A-Za-z0-9-]+/[A-Za-z0-9_.-]+$": ogenregex.MustCompile("^[A-Za-z0-9-]+/[A-Za-z0-9_.-]+$"), "^[A-Za-z0-9][A-Za-z0-9._+-]{0,63}$": ogenregex.MustCompile("^[A-Za-z0-9][A-Za-z0-9._+-]{0,63}$"), + "^[A-Za-z0-9_-]{1,128}$": ogenregex.MustCompile("^[A-Za-z0-9_-]{1,128}$"), "^[BCDFGHJKLMNPQRSTVWXZ]{4}-[BCDFGHJKLMNPQRSTVWXZ]{4}$": ogenregex.MustCompile("^[BCDFGHJKLMNPQRSTVWXZ]{4}-[BCDFGHJKLMNPQRSTVWXZ]{4}$"), "^[\\x21-\\x7E]+$": ogenregex.MustCompile("^[\\x21-\\x7E]+$"), "^[^\\x00-\\x1F\\x7F]+$": ogenregex.MustCompile("^[^\\x00-\\x1F\\x7F]+$"), diff --git a/sdk-go/api/oas_json_gen.go b/sdk-go/api/oas_json_gen.go index aca22bf6..b23c69cd 100644 --- a/sdk-go/api/oas_json_gen.go +++ b/sdk-go/api/oas_json_gen.go @@ -48761,6 +48761,16 @@ func (s *SendMailInput) encodeFields(e *jx.Encoder) { e.ArrEnd() } } + { + if s.PayloadAttachments != nil { + e.FieldStart("payload_attachments") + e.ArrStart() + for _, elem := range s.PayloadAttachments { + elem.Encode(e) + } + e.ArrEnd() + } + } { if s.Wait.Set { e.FieldStart("wait") @@ -48775,17 +48785,18 @@ func (s *SendMailInput) encodeFields(e *jx.Encoder) { } } -var jsonFieldsNameOfSendMailInput = [10]string{ - 0: "from", - 1: "to", - 2: "subject", - 3: "body_text", - 4: "body_html", - 5: "in_reply_to", - 6: "references", - 7: "attachments", - 8: "wait", - 9: "wait_timeout_ms", +var jsonFieldsNameOfSendMailInput = [11]string{ + 0: "from", + 1: "to", + 2: "subject", + 3: "body_text", + 4: "body_html", + 5: "in_reply_to", + 6: "references", + 7: "attachments", + 8: "payload_attachments", + 9: "wait", + 10: "wait_timeout_ms", } // Decode decodes SendMailInput from json. @@ -48899,6 +48910,23 @@ func (s *SendMailInput) Decode(d *jx.Decoder) error { }(); err != nil { return errors.Wrap(err, "decode field \"attachments\"") } + case "payload_attachments": + if err := func() error { + s.PayloadAttachments = make([]SendMailPayloadRef, 0) + if err := d.Arr(func(d *jx.Decoder) error { + var elem SendMailPayloadRef + if err := elem.Decode(d); err != nil { + return err + } + s.PayloadAttachments = append(s.PayloadAttachments, elem) + return nil + }); err != nil { + return err + } + return nil + }(); err != nil { + return errors.Wrap(err, "decode field \"payload_attachments\"") + } case "wait": if err := func() error { s.Wait.Reset() @@ -48976,6 +49004,153 @@ func (s *SendMailInput) UnmarshalJSON(data []byte) error { return s.Decode(d) } +// Encode implements json.Marshaler. +func (s *SendMailPayloadRef) Encode(e *jx.Encoder) { + e.ObjStart() + s.encodeFields(e) + e.ObjEnd() +} + +// encodeFields encodes fields. +func (s *SendMailPayloadRef) encodeFields(e *jx.Encoder) { + { + e.FieldStart("root") + e.Str(s.Root) + } + { + e.FieldStart("filename") + e.Str(s.Filename) + } + { + if s.ContentType.Set { + e.FieldStart("content_type") + s.ContentType.Encode(e) + } + } + { + e.FieldStart("cek") + e.Str(s.Cek) + } +} + +var jsonFieldsNameOfSendMailPayloadRef = [4]string{ + 0: "root", + 1: "filename", + 2: "content_type", + 3: "cek", +} + +// Decode decodes SendMailPayloadRef from json. +func (s *SendMailPayloadRef) Decode(d *jx.Decoder) error { + if s == nil { + return errors.New("invalid: unable to decode SendMailPayloadRef to nil") + } + var requiredBitSet [1]uint8 + + if err := d.ObjBytes(func(d *jx.Decoder, k []byte) error { + switch string(k) { + case "root": + requiredBitSet[0] |= 1 << 0 + if err := func() error { + v, err := d.Str() + s.Root = string(v) + if err != nil { + return err + } + return nil + }(); err != nil { + return errors.Wrap(err, "decode field \"root\"") + } + case "filename": + requiredBitSet[0] |= 1 << 1 + if err := func() error { + v, err := d.Str() + s.Filename = string(v) + if err != nil { + return err + } + return nil + }(); err != nil { + return errors.Wrap(err, "decode field \"filename\"") + } + case "content_type": + if err := func() error { + s.ContentType.Reset() + if err := s.ContentType.Decode(d); err != nil { + return err + } + return nil + }(); err != nil { + return errors.Wrap(err, "decode field \"content_type\"") + } + case "cek": + requiredBitSet[0] |= 1 << 3 + if err := func() error { + v, err := d.Str() + s.Cek = string(v) + if err != nil { + return err + } + return nil + }(); err != nil { + return errors.Wrap(err, "decode field \"cek\"") + } + default: + return errors.Errorf("unexpected field %q", k) + } + return nil + }); err != nil { + return errors.Wrap(err, "decode SendMailPayloadRef") + } + // Validate required fields. + var failures []validate.FieldError + for i, mask := range [1]uint8{ + 0b00001011, + } { + if result := (requiredBitSet[i] & mask) ^ mask; result != 0 { + // Mask only required fields and check equality to mask using XOR. + // + // If XOR result is not zero, result is not equal to expected, so some fields are missed. + // Bits of fields which would be set are actually bits of missed fields. + missed := bits.OnesCount8(result) + for bitN := 0; bitN < missed; bitN++ { + bitIdx := bits.TrailingZeros8(result) + fieldIdx := i*8 + bitIdx + var name string + if fieldIdx < len(jsonFieldsNameOfSendMailPayloadRef) { + name = jsonFieldsNameOfSendMailPayloadRef[fieldIdx] + } else { + name = strconv.Itoa(fieldIdx) + } + failures = append(failures, validate.FieldError{ + Name: name, + Error: validate.ErrFieldRequired, + }) + // Reset bit. + result &^= 1 << bitIdx + } + } + } + if len(failures) > 0 { + return &validate.Error{Fields: failures} + } + + return nil +} + +// MarshalJSON implements stdjson.Marshaler. +func (s *SendMailPayloadRef) MarshalJSON() ([]byte, error) { + e := jx.Encoder{} + s.Encode(&e) + return e.Bytes(), nil +} + +// UnmarshalJSON implements stdjson.Unmarshaler. +func (s *SendMailPayloadRef) UnmarshalJSON(data []byte) error { + d := jx.DecodeBytes(data) + return s.Decode(d) +} + // Encode implements json.Marshaler. func (s *SendMailResult) Encode(e *jx.Encoder) { e.ObjStart() diff --git a/sdk-go/api/oas_schemas_gen.go b/sdk-go/api/oas_schemas_gen.go index 57f66d06..0a7f4584 100644 --- a/sdk-go/api/oas_schemas_gen.go +++ b/sdk-go/api/oas_schemas_gen.go @@ -21079,6 +21079,10 @@ type SendMailInput struct { // Inline attachments. Send requests with attachments to https://api.primitive.dev/v1/send-mail. // Combined raw decoded attachment bytes must be at most 31457280. Attachments []SendMailAttachment `json:"attachments"` + // Deliver an already-uploaded Primitive Payloads object as an attachment by reference, without + // inlining the bytes — the way to send attachments larger than the inline cap. Upload the object + // via /v1/payloads (client-held CEK), then reference it here. v1 supports at most one. + PayloadAttachments []SendMailPayloadRef `json:"payload_attachments"` // When true, wait for the first downstream SMTP delivery outcome before returning. Wait OptBool `json:"wait"` // Maximum time to wait for a delivery outcome when wait is true. Defaults to 30000. @@ -21125,6 +21129,11 @@ func (s *SendMailInput) GetAttachments() []SendMailAttachment { return s.Attachments } +// GetPayloadAttachments returns the value of PayloadAttachments. +func (s *SendMailInput) GetPayloadAttachments() []SendMailPayloadRef { + return s.PayloadAttachments +} + // GetWait returns the value of Wait. func (s *SendMailInput) GetWait() OptBool { return s.Wait @@ -21175,6 +21184,11 @@ func (s *SendMailInput) SetAttachments(val []SendMailAttachment) { s.Attachments = val } +// SetPayloadAttachments sets the value of PayloadAttachments. +func (s *SendMailInput) SetPayloadAttachments(val []SendMailPayloadRef) { + s.PayloadAttachments = val +} + // SetWait sets the value of Wait. func (s *SendMailInput) SetWait(val OptBool) { s.Wait = val @@ -21185,6 +21199,62 @@ func (s *SendMailInput) SetWaitTimeoutMs(val OptInt) { s.WaitTimeoutMs = val } +// A reference to an already-uploaded Primitive Payloads object, delivered as an attachment without +// inlining the bytes — the way to send an attachment larger than the inline cap. Upload the object +// via /v1/payloads (with a client-held CEK the server never sees), then reference it here. +// Ref: #/components/schemas/SendMailPayloadRef +type SendMailPayloadRef struct { + // The 64-char lowercase-hex Merkle root of a finalized payloads object. + Root string `json:"root"` + // Attachment filename presented to the recipient. + Filename string `json:"filename"` + // Optional MIME content type. + ContentType OptString `json:"content_type"` + // Base64url-encoded (unpadded) content-encryption key the recipient uses to decrypt. Travels with + // the email; the object store only ever holds ciphertext. + Cek string `json:"cek"` +} + +// GetRoot returns the value of Root. +func (s *SendMailPayloadRef) GetRoot() string { + return s.Root +} + +// GetFilename returns the value of Filename. +func (s *SendMailPayloadRef) GetFilename() string { + return s.Filename +} + +// GetContentType returns the value of ContentType. +func (s *SendMailPayloadRef) GetContentType() OptString { + return s.ContentType +} + +// GetCek returns the value of Cek. +func (s *SendMailPayloadRef) GetCek() string { + return s.Cek +} + +// SetRoot sets the value of Root. +func (s *SendMailPayloadRef) SetRoot(val string) { + s.Root = val +} + +// SetFilename sets the value of Filename. +func (s *SendMailPayloadRef) SetFilename(val string) { + s.Filename = val +} + +// SetContentType sets the value of ContentType. +func (s *SendMailPayloadRef) SetContentType(val OptString) { + s.ContentType = val +} + +// SetCek sets the value of Cek. +func (s *SendMailPayloadRef) SetCek(val string) { + s.Cek = val +} + // Ref: #/components/schemas/SendMailResult type SendMailResult struct { // Persisted sent-email attempt ID. diff --git a/sdk-go/api/oas_validators_gen.go b/sdk-go/api/oas_validators_gen.go index da4a7847..a7b64fde 100644 --- a/sdk-go/api/oas_validators_gen.go +++ b/sdk-go/api/oas_validators_gen.go @@ -10060,6 +10060,42 @@ func (s *SendMailInput) Validate() error { Error: err, }) } + if err := func() error { + if s.PayloadAttachments == nil { + return nil // optional + } + if err := (validate.Array{ + MinLength: 0, + MinLengthSet: false, + MaxLength: 1, + MaxLengthSet: true, + }).ValidateLength(len(s.PayloadAttachments)); err != nil { + return errors.Wrap(err, "array") + } + var failures []validate.FieldError + for i, elem := range s.PayloadAttachments { + if err := func() error { + if err := elem.Validate(); err != nil { + return err + } + return nil + }(); err != nil { + failures = append(failures, validate.FieldError{ + Name: fmt.Sprintf("[%d]", i), + Error: err, + }) + } + } + if len(failures) > 0 { + return &validate.Error{Fields: failures} + } + return nil + }(); err != nil { + failures = append(failures, validate.FieldError{ + Name: "payload_attachments", + Error: err, + }) + } if err := func() error { if value, ok := s.WaitTimeoutMs.Get(); ok { if err := func() error { @@ -10094,6 +10130,117 @@ func (s *SendMailInput) Validate() error { return nil } +func (s *SendMailPayloadRef) Validate() error { + if s == nil { + return validate.ErrNilPointer + } + + var failures []validate.FieldError + if err := func() error { + if err := (validate.String{ + MinLength: 0, + MinLengthSet: false, + MaxLength: 0, + MaxLengthSet: false, + Email: false, + Hostname: false, + Regex: regexMap["^[0-9a-f]{64}$"], + MinNumeric: 0, + MinNumericSet: false, + MaxNumeric: 0, + MaxNumericSet: false, + }).Validate(string(s.Root)); err != nil { + return errors.Wrap(err, "string") + } + return nil + }(); err != nil { + failures = append(failures, validate.FieldError{ + Name: "root", + Error: err, + }) + } + if err := func() error { + if err := (validate.String{ + MinLength: 1, + MinLengthSet: true, + MaxLength: 255, + MaxLengthSet: true, + Email: false, + Hostname: false, + Regex: nil, + MinNumeric: 0, + MinNumericSet: false, + MaxNumeric: 0, + MaxNumericSet: false, + }).Validate(string(s.Filename)); err != nil { + return errors.Wrap(err, "string") + } + return nil + }(); err != nil { + failures = append(failures, validate.FieldError{ + Name: "filename", + Error: err, + }) + } + if err := func() error { + if value, ok := s.ContentType.Get(); ok { + if err := func() error { + if err := (validate.String{ + MinLength: 1, + MinLengthSet: true, + MaxLength: 255, + MaxLengthSet: true, + Email: false, + Hostname: false, + Regex: nil, + MinNumeric: 0, + MinNumericSet: false, + MaxNumeric: 0, + MaxNumericSet: false, + }).Validate(string(value)); err != nil { + return errors.Wrap(err, "string") + } + return nil + }(); err != nil { + return err + } + } + return nil + }(); err != nil { + failures = append(failures, validate.FieldError{ + Name: "content_type", + Error: err, + }) + } + if err := func() error { + if err := (validate.String{ + MinLength: 0, + MinLengthSet: false, + MaxLength: 0, + MaxLengthSet: false, + Email: false, + Hostname: false, + Regex: regexMap["^[A-Za-z0-9_-]{1,128}$"], + MinNumeric: 0, + MinNumericSet: false, + MaxNumeric: 0, + MaxNumericSet: false, + }).Validate(string(s.Cek)); err != nil { + return errors.Wrap(err, "string") + } + return nil + }(); err != nil { + failures = append(failures, validate.FieldError{ + Name: "cek", + Error: err, + }) + } + if len(failures) > 0 { + return &validate.Error{Fields: failures} + } + return nil +} + func (s *SendMailResult) Validate() error { if s == nil { return validate.ErrNilPointer From 5ce72b80e11d6c7b6554243c155d0b3f06e789ff Mon Sep 17 00:00:00 2001 From: Benjamin Poile Date: Wed, 15 Jul 2026 19:58:38 -0700 Subject: [PATCH 11/13] fix(sdk): harden email-validation regexes against polynomial ReDoS MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CodeQL flagged the recipient email + display-name regexes (js/polynomial-redos): `[^\s@]+\.[^\s@]+` overlaps on dots and `.+<` backtracks. Rewrite the domain as dot-separated non-dot labels and use `[^<]+` before the address bracket — same accepted set, linear matching. --- sdk-node/src/api/index.ts | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/sdk-node/src/api/index.ts b/sdk-node/src/api/index.ts index 78eec203..c2cd9c29 100644 --- a/sdk-node/src/api/index.ts +++ b/sdk-node/src/api/index.ts @@ -100,7 +100,10 @@ export { type TrustReason, } from "../webhook/trust.js"; -const EMAIL_REGEX = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; +// Domain is dot-separated labels (each label excludes '.', '@', whitespace) so +// the quantifiers don't overlap — a plain `[^\s@]+\.[^\s@]+` is a polynomial +// ReDoS on inputs with many dots. +const EMAIL_REGEX = /^[^\s@]+@[^\s.@]+(?:\.[^\s.@]+)+$/; const MAX_THREAD_REFERENCES = 100; const MAX_THREAD_HEADER_BYTES = 8 * 1024; const MAX_FROM_HEADER_LENGTH = 998; @@ -122,7 +125,9 @@ function validateAddressHeader(field: "from" | "to", value: string): void { function validateEmailAddress(field: "to", value: string): void { if ( !EMAIL_REGEX.test(value) && - !/^.+<[^\s@]+@[^\s@]+\.[^\s@]+>$/.test(value) + // Display-name form: `Name `. `[^<]+` (not `.+`) before the bracket + // and the same dot-separated-domain shape keep this ReDoS-free. + !/^[^<]+<[^\s@]+@[^\s.@]+(?:\.[^\s.@]+)+>$/.test(value) ) { throw new TypeError(`${field} must be a valid email address`); } From 5966055c87f7eba74a5fdf0ffae557c8003002fc Mon Sep 17 00:00:00 2001 From: Benjamin Poile Date: Wed, 15 Jul 2026 20:11:26 -0700 Subject: [PATCH 12/13] fix(payloads): avoid unhandled rejections in mapLimit; guard sendAttachment content/path MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - mapLimit: capture the first worker error and stop pulling items instead of throwing mid-flight — a throwing worker left other in-flight workers' rejections unhandled (Node 15+ crashes on unhandled rejection) during a prolonged upload outage. Still throws the first error after all settle. - sendAttachment: reject when both attachment.content and attachment.path are provided (previously content silently won). --- sdk-node/src/api/index.ts | 5 +++++ sdk-node/src/payloads/index.ts | 15 +++++++++++++-- sdk-node/tests/api/client.test.ts | 21 +++++++++++++++++++++ 3 files changed, 39 insertions(+), 2 deletions(-) diff --git a/sdk-node/src/api/index.ts b/sdk-node/src/api/index.ts index c2cd9c29..71ff10bb 100644 --- a/sdk-node/src/api/index.ts +++ b/sdk-node/src/api/index.ts @@ -1137,6 +1137,11 @@ export class PrimitiveClient extends PrimitiveApiClient { options?: RequestOptions, ): Promise { const { attachment, inlineThreshold, ...rest } = input; + if (attachment.content !== undefined && attachment.path !== undefined) { + throw new TypeError( + "sendAttachment accepts either attachment.content or attachment.path, not both", + ); + } const threshold = inlineThreshold ?? DEFAULT_INLINE_ATTACHMENT_MAX; const meta = { filename: attachment.filename, diff --git a/sdk-node/src/payloads/index.ts b/sdk-node/src/payloads/index.ts index 0022fb34..c11a32c8 100644 --- a/sdk-node/src/payloads/index.ts +++ b/sdk-node/src/payloads/index.ts @@ -430,15 +430,26 @@ async function mapLimit( fn: (item: T, i: number) => Promise, ): Promise { let next = 0; + let firstError: unknown; const worker = async (): Promise => { - while (next < items.length) { + // Stop pulling new items once any worker has failed, and capture the error + // instead of throwing: a throwing worker would leave the OTHER in-flight + // workers' later rejections unhandled (Node 15+ crashes the process on an + // unhandled rejection) once Promise.all has already settled on the first. + while (next < items.length && firstError === undefined) { const i = next++; - await fn(items[i], i); + try { + await fn(items[i], i); + } catch (err) { + if (firstError === undefined) firstError = err; + return; + } } }; await Promise.all( Array.from({ length: Math.min(limit, items.length || 1) }, () => worker()), ); + if (firstError !== undefined) throw firstError; } /** diff --git a/sdk-node/tests/api/client.test.ts b/sdk-node/tests/api/client.test.ts index 016d671d..5fe8bb28 100644 --- a/sdk-node/tests/api/client.test.ts +++ b/sdk-node/tests/api/client.test.ts @@ -1260,6 +1260,27 @@ describe("PrimitiveClient send-by-reference + sendAttachment", () => { expect(fetchMock).toHaveBeenCalledTimes(1); }); + it("sendAttachment rejects when both content and path are given", async () => { + const client = new PrimitiveClient({ + apiKey: "prim_test", + apiBaseUrl: "https://api.example.test/v1", + fetch: vi.fn() as typeof fetch, + }); + await expect( + client.sendAttachment({ + from: "a@example.com", + to: "b@example.com", + subject: "Hi", + bodyText: "x", + attachment: { + filename: "x.bin", + content: new Uint8Array([1]), + path: "/tmp/x.bin", + }, + }), + ).rejects.toThrow(/not both/); + }); + it("sendAttachment uploads large content and sends it by reference", async () => { let uploadCalls = 0; let sentBody: From 40124cda2773a737da302d0fcc04788bcfebab04 Mon Sep 17 00:00:00 2001 From: Benjamin Poile Date: Wed, 15 Jul 2026 20:21:05 -0700 Subject: [PATCH 13/13] fix(sdk): make payloadAttachments CEK hex across the SDK, convert to base64url in send MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PushResult.cek and pull both use hex, but payloadAttachments.cek was documented base64url — a footgun for anyone hand-wiring push->email (they'd pass hex into a base64url field). Make the SDK surface hex everywhere (pass PushResult.cek verbatim) and convert to the wire's base64url inside send(). sendAttachment now forwards hex; the conversion lives in one place. --- sdk-node/src/api/index.ts | 13 +++++++------ sdk-node/tests/api/client.test.ts | 8 +++++--- 2 files changed, 12 insertions(+), 9 deletions(-) diff --git a/sdk-node/src/api/index.ts b/sdk-node/src/api/index.ts index 71ff10bb..e8e2d4f6 100644 --- a/sdk-node/src/api/index.ts +++ b/sdk-node/src/api/index.ts @@ -155,9 +155,9 @@ export interface SendPayloadReference { /** Optional MIME content type. */ contentType?: string; /** - * Base64url-encoded (unpadded) content-encryption key the recipient uses to - * decrypt. Note `PushResult.cek` is hex — convert with - * `Buffer.from(cek, "hex").toString("base64url")`. + * Hex-encoded content-encryption key the recipient needs to decrypt — pass + * `PushResult.cek` verbatim. `send` converts it to the base64url the wire + * carries, so the whole SDK surface (push/pull/reference) stays hex. */ cek: string; } @@ -1094,7 +1094,8 @@ export class PrimitiveClient extends PrimitiveApiClient { payload_attachments: input.payloadAttachments.map((ref) => ({ root: ref.root, filename: ref.filename, - cek: ref.cek, + // SDK surface is hex (matches push/pull); the wire carries base64url. + cek: Buffer.from(ref.cek, "hex").toString("base64url"), ...(ref.contentType !== undefined ? { content_type: ref.contentType } : {}), @@ -1219,8 +1220,8 @@ export class PrimitiveClient extends PrimitiveApiClient { { root: pushed.merkleRoot, filename: meta.filename, - // PushResult.cek is hex; the reference carries base64url. - cek: Buffer.from(pushed.cek, "hex").toString("base64url"), + // Hex, matching the rest of the SDK; send() converts to base64url. + cek: pushed.cek, ...(meta.contentType !== undefined ? { contentType: meta.contentType } : {}), diff --git a/sdk-node/tests/api/client.test.ts b/sdk-node/tests/api/client.test.ts index 5fe8bb28..7651d1f8 100644 --- a/sdk-node/tests/api/client.test.ts +++ b/sdk-node/tests/api/client.test.ts @@ -1188,7 +1188,9 @@ describe("PrimitiveClient send-by-reference + sendAttachment", () => { headers: { "content-type": "application/json" }, }); - it("threads payloadAttachments into the wire body as payload_attachments", async () => { + it("threads payloadAttachments into the wire body, converting the hex CEK to base64url", async () => { + const hexCek = "deadbeef"; + const wireCek = Buffer.from(hexCek, "hex").toString("base64url"); const fetchMock = vi.fn(async (input) => { const request = input as Request; expect(request.url).toBe("https://api.example.test/v1/send-mail"); @@ -1198,7 +1200,7 @@ describe("PrimitiveClient send-by-reference + sendAttachment", () => { root: "a".repeat(64), filename: "big.bin", content_type: "application/octet-stream", - cek: "Zm9v", + cek: wireCek, }, ], }); @@ -1219,7 +1221,7 @@ describe("PrimitiveClient send-by-reference + sendAttachment", () => { root: "a".repeat(64), filename: "big.bin", contentType: "application/octet-stream", - cek: "Zm9v", + cek: hexCek, }, ], });