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/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..cdc1acd5 100644 --- a/cli-node/src/oclif/index.ts +++ b/cli-node/src/oclif/index.ts @@ -41,6 +41,10 @@ import { import OrgSecretsListCommand from "./commands/org-secrets-list.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"; @@ -610,6 +614,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/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-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 diff --git a/sdk-node/package.json b/sdk-node/package.json index 67dbe402..19c8ca74 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, @@ -61,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/api/index.ts b/sdk-node/src/api/index.ts index 3d26bba8..e8e2d4f6 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"; @@ -98,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; @@ -120,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`); } @@ -133,6 +140,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; + /** + * 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; +} + export interface SendInput { from: string; to: string; @@ -140,10 +169,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 +1055,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 +1088,20 @@ 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, + // 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 } + : {}), + })), + } + : {}), ...(input.wait !== undefined ? { wait: input.wait } : {}), ...(input.waitTimeoutMs !== undefined ? { wait_timeout_ms: input.waitTimeoutMs } @@ -1022,6 +1117,121 @@ 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; + 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, + 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, + // Hex, matching the rest of the SDK; send() converts to base64url. + cek: pushed.cek, + ...(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 new file mode 100644 index 00000000..c11a32c8 --- /dev/null +++ b/sdk-node/src/payloads/index.ts @@ -0,0 +1,645 @@ +/** + * 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, rm, 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 { + // 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; +// 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()); +} + +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, + 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; + let firstError: unknown; + const worker = async (): Promise => { + // 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++; + 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; +} + +/** + * 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(); + } +} + +/** + * 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 + * 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); + // 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); + // 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) { + 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 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) { + // Don't leave a partial/corrupt plaintext file behind on a failed download. + out.destroy(); + await rm(outPath, { force: true }); + throw err; + } + return manifest; +} diff --git a/sdk-node/tests/api/client.test.ts b/sdk-node/tests/api/client.test.ts index f328be10..7651d1f8 100644 --- a/sdk-node/tests/api/client.test.ts +++ b/sdk-node/tests/api/client.test.ts @@ -1180,3 +1180,162 @@ 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, 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"); + expect(await request.json()).toMatchObject({ + payload_attachments: [ + { + root: "a".repeat(64), + filename: "big.bin", + content_type: "application/octet-stream", + cek: wireCek, + }, + ], + }); + 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: hexCek, + }, + ], + }); + 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 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: + | { + 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_-]+$/); + }); +}); diff --git a/sdk-node/tests/payloads/client.test.ts b/sdk-node/tests/payloads/client.test.ts new file mode 100644 index 00000000..16e570cb --- /dev/null +++ b/sdk-node/tests/payloads/client.test.ts @@ -0,0 +1,261 @@ +import { createHash, randomFillSync } from "node:crypto"; +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"; +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; + /** 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. */ +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") { + 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: served, + }); + } + 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 and leaves no partial file", 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, + }); + const outPath = join(dir, "out.bin"); + await expect( + 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/); + }); + + 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); + }); +}); 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/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 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 + 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" + } +}