Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
165 changes: 165 additions & 0 deletions cli-node/src/oclif/commands/payloads.ts
Original file line number Diff line number Diff line change
@@ -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<void> {
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 <merkle_root> --cek <hex> --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<void> {
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,
),
);
}
}
10 changes: 10 additions & 0 deletions cli-node/src/oclif/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -610,6 +614,12 @@ export const COMMANDS: Record<string, typeof Command> = {
"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 +
Expand Down
42 changes: 42 additions & 0 deletions openapi/primitive-api.codegen.json
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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."
Expand Down
39 changes: 39 additions & 0 deletions openapi/primitive-api.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand Down
2 changes: 1 addition & 1 deletion packages/api-core/src/api/index.ts

Large diffs are not rendered by default.

26 changes: 26 additions & 0 deletions packages/api-core/src/api/types.gen.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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<SendMailAttachment>;
/**
* 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<SendMailPayloadRef>;
/**
* When true, wait for the first downstream SMTP delivery outcome before returning.
*/
Expand Down
Loading