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
7 changes: 6 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -66,12 +66,17 @@ upstash search create --name my-search --region us-central1 --type DENSE
upstash qstash list
upstash qstash stats --qstash-id $QSTASH_ID --period 7d

# Blob
upstash blob create --name my-bucket --visibility private
upstash blob list
upstash blob credentials --bucket-id $BUCKET_ID

# Team
upstash team list
upstash team add-member --team-id $TEAM_ID --member-email you@example.com --role dev
```

Run `upstash --help` (or `--help` on any subcommand) to discover everything else, and check the [full docs](https://upstash.com/docs/agent-resources/cli) for the complete catalog.
Run `upstash --help` (or `--help` on any subcommand) to discover everything else, and check the [full docs](https://upstash.com/docs/agent-resources/cli) for the complete catalog. `upstash blob credentials` returns temporary S3 credentials for use with AWS CLI, rclone, or an S3 SDK.

## Contributing

Expand Down
2 changes: 2 additions & 0 deletions src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { registerTeam } from "./commands/team/index.js";
import { registerVector } from "./commands/vector/index.js";
import { registerSearch } from "./commands/search/index.js";
import { registerQStash } from "./commands/qstash/index.js";
import { registerBlob } from "./commands/blob/index.js";
import { registerLogin } from "./commands/login.js";
import { registerLogout } from "./commands/logout.js";
import { registerStartRedis } from "./commands/start-redis.js";
Expand Down Expand Up @@ -46,5 +47,6 @@ registerTeam(program);
registerVector(program);
registerSearch(program);
registerQStash(program);
registerBlob(program);

program.parseAsync().catch(handleError);
43 changes: 43 additions & 0 deletions src/commands/blob/create.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import { Command, InvalidArgumentError } from "commander";
import { resolveAuth } from "../../auth.js";
import { request } from "../../client.js";
import { printJSON } from "../../output.js";
import { BLOB_VISIBILITIES } from "../../types.js";
import type { BlobBucket, BlobVisibility } from "../../types.js";

function parseVisibility(value: string): BlobVisibility {
if ((BLOB_VISIBILITIES as readonly string[]).includes(value)) {
return value as BlobVisibility;
}
throw new InvalidArgumentError(
`--visibility must be one of: ${BLOB_VISIBILITIES.join(", ")}; got "${value}"`,
);
}

export function registerBlobCreate(blob: Command): void {
blob
.command("create")
.description("Create a Blob bucket")
.requiredOption("--name <name>", "Bucket name")
.option(
"--visibility <visibility>",
`Bucket visibility. Available: ${BLOB_VISIBILITIES.join(", ")}`,
parseVisibility,
"private",
)
.option("--cors <origins...>", "Allowed CORS origins (space-separated)")
.action(
async (
flags: { name: string; visibility: BlobVisibility; cors?: string[] },
command: Command,
) => {
const auth = resolveAuth(command);
const bucket = await request<BlobBucket>(auth, "POST", "/v2/blob/bucket", {
name: flags.name,
visibility: flags.visibility,
cors: flags.cors,
});
printJSON(bucket);
},
);
}
160 changes: 160 additions & 0 deletions src/commands/blob/credentials.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,160 @@
import { Command } from "commander";
import { resolveAuth } from "../../auth.js";
import { HttpError, request } from "../../client.js";
import { printJSON } from "../../output.js";
import type { BlobBucket, BlobS3Credentials } from "../../types.js";

const BLOB_CREDENTIALS_URL = "https://blob.upstash.io/v1/credentials";
const RETRYABLE_STATUSES = new Set([429, 503]);
const DEFAULT_RETRY_DELAY_MS = 2000;
const MAX_RETRY_DELAY_MS = 10000;
const MAX_RETRIES = 3;

type Sleep = (ms: number) => Promise<void>;

function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}

function parseErrorMessage(text: string, status: number): string {
let message = text || `HTTP ${status}`;
try {
const parsed = JSON.parse(text) as { error?: unknown; message?: unknown };
const candidate = parsed.error ?? parsed.message;
if (typeof candidate === "string" && candidate.length > 0) {
message = candidate;
}
} catch {
// keep original message
}
return message;
}

function getRetryDelayMs(retryAfter: string | null): number {
const parsed = Number(retryAfter);
if (!Number.isFinite(parsed) || parsed <= 0) {
return DEFAULT_RETRY_DELAY_MS;
}
return Math.min(parsed * 1000, MAX_RETRY_DELAY_MS);
}

function validateCredentials(data: unknown): BlobS3Credentials {
if (!data || typeof data !== "object") {
throw new Error("Blob credentials response must be a JSON object");
}

const credentials = data as Record<string, unknown>;
const requiredStrings = [
"accessKeyId",
"secretAccessKey",
"sessionToken",
"endpoint",
"bucket",
"region",
] as const;

for (const field of requiredStrings) {
if (typeof credentials[field] !== "string" || credentials[field].length === 0) {
throw new Error(`Blob credentials response is missing a valid ${field}`);
}
}

if (typeof credentials.expiresAt !== "number" || !Number.isFinite(credentials.expiresAt)) {
throw new Error("Blob credentials response is missing a valid expiresAt");
}

let endpoint: URL;
try {
endpoint = new URL(credentials.endpoint as string);
} catch {
throw new Error("Blob credentials response has an invalid endpoint URL");
}

if (endpoint.protocol !== "https:") {
throw new Error("Blob credentials endpoint must use HTTPS");
}

if (!endpoint.hostname.endsWith(".r2.cloudflarestorage.com")) {
throw new Error(
"Blob credentials endpoint must target a .r2.cloudflarestorage.com hostname",
);
}

return credentials as unknown as BlobS3Credentials;
}

export async function fetchBlobCredentials(
token: string,
pause: Sleep = sleep,
): Promise<BlobS3Credentials> {
for (let attempt = 0; attempt <= MAX_RETRIES; attempt += 1) {
const response = await fetch(BLOB_CREDENTIALS_URL, {
method: "POST",
headers: {
Authorization: `Bearer ${token}`,
},
});

const text = await response.text();

if (response.ok) {
let parsed: unknown;
try {
parsed = JSON.parse(text) as unknown;
} catch {
throw new Error("Blob credentials response must be valid JSON");
}
return validateCredentials(parsed);
}

if (response.status === 401) {
throw new HttpError("Blob bucket token was rejected", response.status);
}

if (RETRYABLE_STATUSES.has(response.status) && attempt < MAX_RETRIES) {
await pause(getRetryDelayMs(response.headers.get("Retry-After")));
continue;
}

throw new HttpError(parseErrorMessage(text, response.status), response.status);
}

throw new Error("Blob credentials request failed after retries");
}

function resolveBucketToken(flags: { bucketId?: string }, command: Command): Promise<string> {
if (flags.bucketId) {
const auth = resolveAuth(command);
return request<BlobBucket>(auth, "GET", `/v2/blob/bucket/${flags.bucketId}`).then((bucket) => {
if (typeof bucket.token === "string" && bucket.token.length > 0) {
return bucket.token;
}
throw new Error(`Blob bucket ${flags.bucketId} did not return a current token`);
});
}

const token = process.env.UPSTASH_BLOB_TOKEN;
if (typeof token === "string" && token.length > 0) {
return Promise.resolve(token);
}

return Promise.reject(
new Error(
"Blob credentials require either --bucket-id with Upstash account authentication or a non-empty UPSTASH_BLOB_TOKEN environment variable",
),
);
}

export function registerBlobCredentials(blob: Command): void {
blob
.command("credentials")
.description(
"Get temporary S3 credentials for a Blob bucket; expiresAt is the credential expiry",
)
.option("--bucket-id <id>", "Blob bucket ID")
.action(async (flags: { bucketId?: string }, command: Command) => {
const token = await resolveBucketToken(flags, command);
const credentials = await fetchBlobCredentials(token);
printJSON(credentials);
});
}
21 changes: 21 additions & 0 deletions src/commands/blob/delete.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import { Command } from "commander";
import { resolveAuth } from "../../auth.js";
import { request } from "../../client.js";
import { printJSON } from "../../output.js";

export function registerBlobDelete(blob: Command): void {
blob
.command("delete")
.description("Delete a Blob bucket")
.requiredOption("--bucket-id <id>", "Blob bucket ID")
.option("--dry-run", "Preview the action without executing it")
.action(async (flags: { bucketId: string; dryRun?: boolean }, command: Command) => {
if (flags.dryRun) {
printJSON({ action: "delete", bucket_id: flags.bucketId, dry_run: true });
return;
}
const auth = resolveAuth(command);
await request(auth, "DELETE", `/v2/blob/bucket/${flags.bucketId}`);
printJSON({ deleted: true, bucket_id: flags.bucketId });
});
}
23 changes: 23 additions & 0 deletions src/commands/blob/get.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import { Command } from "commander";
import { resolveAuth } from "../../auth.js";
import { request } from "../../client.js";
import { printJSON } from "../../output.js";
import type { BlobBucket } from "../../types.js";

export function registerBlobGet(blob: Command): void {
blob
.command("get")
.description("Get details of a Blob bucket")
.requiredOption("--bucket-id <id>", "Blob bucket ID")
.option("--hide-credentials", "Omit bucket tokens from output")
.action(async (flags: { bucketId: string; hideCredentials?: boolean }, command: Command) => {
const auth = resolveAuth(command);
const bucket = await request<BlobBucket>(auth, "GET", `/v2/blob/bucket/${flags.bucketId}`);
if (!flags.hideCredentials) {
printJSON(bucket);
return;
}
const { token: _token, token_next: _tokenNext, ...safeBucket } = bucket;
printJSON(safeBucket);
});
}
16 changes: 16 additions & 0 deletions src/commands/blob/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import { Command } from "commander";
import { registerBlobCreate } from "./create.js";
import { registerBlobList } from "./list.js";
import { registerBlobGet } from "./get.js";
import { registerBlobDelete } from "./delete.js";
import { registerBlobCredentials } from "./credentials.js";

export function registerBlob(program: Command): void {
const blob = program.command("blob").description("Manage Blob buckets");

registerBlobCreate(blob);
registerBlobList(blob);
registerBlobGet(blob);
registerBlobDelete(blob);
registerBlobCredentials(blob);
}
16 changes: 16 additions & 0 deletions src/commands/blob/list.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import { Command } from "commander";
import { resolveAuth } from "../../auth.js";
import { request } from "../../client.js";
import { printJSON } from "../../output.js";
import type { BlobBucket } from "../../types.js";

export function registerBlobList(blob: Command): void {
blob
.command("list")
.description("List Blob buckets")
.action(async (flags: Record<string, never>, command: Command) => {
const auth = resolveAuth(command);
const buckets = await request<BlobBucket[]>(auth, "GET", "/v2/blob/bucket");
printJSON(buckets);
});
}
38 changes: 38 additions & 0 deletions src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -171,3 +171,41 @@ export interface QStashUser {
timeout?: number;
creation_time?: number;
}

// ── Blob ─────────────────────────────────────────────────────────────────────

export const BLOB_VISIBILITIES = ["private", "public"] as const;
export type BlobVisibility = (typeof BLOB_VISIBILITIES)[number];

export interface BlobBucketEvent {
type: string;
message: string;
observed_at: number;
[key: string]: unknown;
}

export interface BlobBucket {
customer_id: string;
id: string;
name: string;
hash_for_domain: string;
visibility: BlobVisibility;
endpoint: string;
pw_version: number;
creation_time: number;
cors?: string[];
created_by?: string;
events?: BlobBucketEvent[];
token?: string;
token_next?: string;
}

export interface BlobS3Credentials {
accessKeyId: string;
secretAccessKey: string;
sessionToken: string;
endpoint: string;
bucket: string;
region: string;
expiresAt: number;
}
7 changes: 7 additions & 0 deletions tests/helpers/program.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,13 @@ export async function createQStashProgram(): Promise<Command> {
return p;
}

export async function createBlobProgram(): Promise<Command> {
const { registerBlob } = await import("../../src/commands/blob/index.js");
const p = new Command().exitOverride();
registerBlob(p);
return p;
}

export async function createTeamProgram(): Promise<Command> {
const { registerTeam } = await import("../../src/commands/team/index.js");
const p = new Command().exitOverride();
Expand Down
Loading
Loading