From dc4a7eae8569b4b4e24f10fb223be116d763da57 Mon Sep 17 00:00:00 2001 From: ytkimirti Date: Mon, 31 Aug 2026 08:44:09 +0200 Subject: [PATCH 1/2] DX-2969: add Blob commands --- PLAN.md | 253 ++++++++++++++++++++++ README.md | 7 +- src/cli.ts | 2 + src/commands/blob/create.ts | 43 ++++ src/commands/blob/credentials.ts | 160 ++++++++++++++ src/commands/blob/delete.ts | 21 ++ src/commands/blob/get.ts | 23 ++ src/commands/blob/index.ts | 16 ++ src/commands/blob/list.ts | 16 ++ src/types.ts | 38 ++++ tests/helpers/program.ts | 7 + tests/integration/blob.test.ts | 120 +++++++++++ tests/unit/blob.test.ts | 351 +++++++++++++++++++++++++++++++ 13 files changed, 1056 insertions(+), 1 deletion(-) create mode 100644 PLAN.md create mode 100644 src/commands/blob/create.ts create mode 100644 src/commands/blob/credentials.ts create mode 100644 src/commands/blob/delete.ts create mode 100644 src/commands/blob/get.ts create mode 100644 src/commands/blob/index.ts create mode 100644 src/commands/blob/list.ts create mode 100644 tests/integration/blob.test.ts create mode 100644 tests/unit/blob.test.ts diff --git a/PLAN.md b/PLAN.md new file mode 100644 index 0000000..a1e3a5f --- /dev/null +++ b/PLAN.md @@ -0,0 +1,253 @@ +# DX-2969 — Add Upstash Blob to `@upstash/cli` + +Linear: https://linear.app/upstash/issue/DX-2969/cli-blob + +## Objective + +Add a small, agent-friendly Blob surface to the existing CLI. The CLI manages Blob buckets through the Upstash Developer API and provides one bridge from a bucket token to temporary, bucket-scoped S3 credentials. It does not reimplement S3 object operations: agents should use AWS CLI, rclone, or an S3 SDK once credentials are returned. + +## Product and API facts + +The control-plane source of truth is `upstash/upstash-cloud`; the current console implementation is in `upstash/upstash-console-v2`; credential behavior is defined by `upstash/blob-store` and `upstash/blob-js`. + +Control-plane routes use the CLI's existing Developer API Basic authentication: + +- `POST /v2/blob/bucket` — create a bucket +- `GET /v2/blob/bucket` — list buckets; list responses do not contain tokens +- `GET /v2/blob/bucket/:id` — get a bucket; may contain `token` and `token_next` +- `DELETE /v2/blob/bucket/:id` — delete an empty bucket + +Create accepts: + +```json +{ + "name": "bucket-name", + "visibility": "private | public", + "cors": ["https://example.com"] +} +``` + +`visibility` and `cors` are optional. The backend defaults visibility to `private`. Do not invent regions or plans: Blob buckets currently expose neither. + +Temporary S3 credentials come from: + +```text +POST https://blob.upstash.io/v1/credentials +Authorization: Bearer +``` + +The successful response is: + +```json +{ + "accessKeyId": "...", + "secretAccessKey": "...", + "sessionToken": "...", + "endpoint": "https://.r2.cloudflarestorage.com", + "bucket": "", + "region": "auto", + "expiresAt": 1234567890 +} +``` + +Credentials are temporary, bucket-scoped, and may have less than ten minutes remaining. `expiresAt` is authoritative. + +## Command surface + +Register a new top-level `blob` command described as `Manage Blob buckets`. + +### `upstash blob create` + +Options: + +- required `--name ` +- optional `--visibility `; accepted values are `private` and `public`; default to `private` +- optional variadic `--cors ` + +Send only the documented fields. Print the returned bucket as JSON, including the initial tokens returned by create. + +### `upstash blob list` + +Call the list endpoint and print the returned array as JSON. Do not synthesize or request credentials. + +### `upstash blob get` + +Options: + +- required `--bucket-id ` +- optional `--hide-credentials` + +Call the get endpoint. By default, preserve `token` and `token_next`, matching the existing Redis `get` behavior. With `--hide-credentials`, remove both fields from a copied response before printing; do not mutate shared data and do not rely on an unsupported backend query parameter. + +### `upstash blob delete` + +Options: + +- required `--bucket-id ` +- optional `--dry-run` + +Dry-run must not resolve authentication or make a request. It prints: + +```json +{ "action": "delete", "bucket_id": "...", "dry_run": true } +``` + +A real delete calls the API and prints: + +```json +{ "deleted": true, "bucket_id": "..." } +``` + +Do not add recursive object deletion. The Developer API intentionally refuses deletion of a non-empty bucket. + +### `upstash blob credentials` + +Options: + +- optional `--bucket-id ` + +Token resolution: + +1. When `--bucket-id` is present, resolve normal Developer API auth, fetch that bucket with `GET /v2/blob/bucket/:id`, and use its current `token`. This explicit mode wins even when `UPSTASH_BLOB_TOKEN` is set. +2. Without `--bucket-id`, read `UPSTASH_BLOB_TOKEN`. +3. If neither source supplies a non-empty token, fail with a clear error explaining both supported forms. +4. Do not add `--token`; command-line secrets leak into shell history and process listings. + +Exchange the token at the Blob credential endpoint and print the successful response unchanged as JSON. Do not print the long-lived bucket token, cache credentials, write them to config, or add an env/shell output format. + +Credential request behavior: + +- Send `POST` with a Bearer authorization header and no body. +- Treat `401` as a rejected bucket token. +- Retry `429` and `503` up to three retries, honoring a positive numeric `Retry-After`; use 2 seconds when absent/invalid and cap a single wait at 10 seconds. +- After retries, surface the response error through the CLI's normal JSON error handling. +- Validate the success payload before printing. Required fields are non-empty strings except `expiresAt`, which must be a finite number. +- Parse `endpoint` as a URL and require HTTPS plus a hostname ending in `.r2.cloudflarestorage.com`. Reject an unexpected endpoint so downstream agents are not instructed to send data or credentials to an arbitrary host. + +Keep this implementation dependency-free. Use the platform `fetch`; do not add `@upstash/blob`, an AWS SDK, or a signing package. + +## Types + +Extend `src/types.ts` with narrowly scoped Blob types: + +- `BLOB_VISIBILITIES` and `BlobVisibility` +- `BlobBucketEvent` +- `BlobBucket` +- `BlobS3Credentials` + +`BlobBucket` should model the current external response, including: + +- `customer_id`, `id`, `name`, `hash_for_domain` +- `visibility`, `endpoint`, `pw_version`, `creation_time` +- optional `cors`, `created_by`, `events`, `token`, `token_next` + +Do not include coordinator-only encrypted passwords or internal provisioning fields. + +## File layout + +Follow the existing one-command-per-file structure: + +```text +src/commands/blob/ + index.ts + create.ts + list.ts + get.ts + delete.ts + credentials.ts +``` + +Register `registerBlob(program)` in `src/cli.ts` beside the other product registrations. A small credential-response parser/retry helper may remain in `credentials.ts` unless extracting it clearly improves testing; avoid broad refactoring of `src/client.ts` just for one Bearer-authenticated endpoint. + +## Error and output conventions + +- Successful account commands emit pretty JSON via `printJSON`. +- Errors flow to the existing top-level `handleError`, producing `{ "error": "..." }` on stderr and exit code 1. +- Do not log Authorization headers, bucket tokens, or temporary secrets. +- Keep Commander descriptions precise enough for an agent to discover the workflow from `--help`. +- The credentials help text should state that the result is temporary S3 credentials and that `expiresAt` is the expiry. + +## Tests + +### Unit tests + +Add a Blob program factory to `tests/helpers/program.ts`. + +Add focused tests with mocked `global.fetch` covering: + +1. Command registration and expected method/path/body for create, list, get, and delete. +2. Create defaults to private and passes CORS origins correctly. +3. `get --hide-credentials` removes both token fields while normal get preserves them. +4. Delete dry-run makes no HTTP request and produces the established preview shape. +5. Credentials by bucket id performs the Developer API GET first, then the Bearer POST using the fetched current token. +6. Credentials without bucket id uses `UPSTASH_BLOB_TOKEN` and does not attempt Developer API auth. +7. Explicit bucket id wins over an ambient `UPSTASH_BLOB_TOKEN`. +8. Missing token source fails clearly. +9. Credential success is printed unchanged. +10. Invalid/missing credential fields and a non-R2 or non-HTTPS endpoint are rejected. +11. `401` is reported as rejected authentication. +12. `429`/`503` retry behavior, fallback delay, retry limit, and eventual success/failure. Use fake timers or inject sleep so tests do not actually wait. + +Restore environment variables, fetch, timers, console methods, and any other globals after each test so the existing serial suite remains isolated. + +### Integration coverage + +If the existing credentials can access Blob, add an opt-in integration lifecycle test guarded by `RUN_BLOB_INTEGRATION=1`: + +- create a uniquely named private empty bucket +- verify list and get +- fetch temporary S3 credentials and validate bucket/endpoint/expiry +- delete the bucket in `finally` + +Blob provisioning is asynchronous. Poll credential readiness and cleanup with a bounded timeout rather than assuming immediate readiness. Never upload an object in this test. Keep it opt-in so ordinary unit runs do not consume Blob quota or become flaky on the provisioning cron. + +## README + +Add concise examples to the existing Quick examples section: + +```bash +upstash blob create --name my-bucket --visibility private +upstash blob list +upstash blob credentials --bucket-id $BUCKET_ID +``` + +Explain in one sentence that `credentials` returns temporary S3 credentials for use with AWS CLI, rclone, or an S3 SDK. Do not document object-operation commands because none are being added. + +## Verification + +Run from the worktree: + +```bash +npm run build +npm run typecheck +npm test +node dist/cli.js blob --help +node dist/cli.js blob credentials --help +``` + +If Blob integration credentials and permission are available: + +```bash +RUN_BLOB_INTEGRATION=1 npm test -- tests/integration/blob.test.ts +``` + +Inspect the final diff for accidental secrets and confirm no generated `dist` output is included unless it was already intentionally tracked by this repository. + +## Non-goals + +- No object list/get/put/delete/copy commands. +- No SigV4 implementation. +- No AWS SDK or `@upstash/blob` dependency. +- No persistent storage of bucket tokens or temporary S3 credentials. +- No advanced bucket operations such as rename, token rotation, visibility updates, CORS updates, usage stats, or transfer in this first pass. +- No changes to login/config precedence. + +## Acceptance criteria + +- `upstash --help` exposes the Blob group. +- Basic bucket create/list/get/delete works through the Developer API with existing auth. +- `credentials --bucket-id` turns account access into a validated temporary S3 credential bundle. +- `credentials` also works from `UPSTASH_BLOB_TOKEN` without Developer API auth. +- Destructive behavior retains dry-run support and never recursively deletes objects. +- Credentials and tokens are never persisted or logged. +- Build, typecheck, and unit tests pass with no new runtime dependency. diff --git a/README.md b/README.md index 4da8779..101b3da 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/src/cli.ts b/src/cli.ts index 0358e86..0f04720 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -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"; @@ -46,5 +47,6 @@ registerTeam(program); registerVector(program); registerSearch(program); registerQStash(program); +registerBlob(program); program.parseAsync().catch(handleError); diff --git a/src/commands/blob/create.ts b/src/commands/blob/create.ts new file mode 100644 index 0000000..8e36afb --- /dev/null +++ b/src/commands/blob/create.ts @@ -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 ", "Bucket name") + .option( + "--visibility ", + `Bucket visibility. Available: ${BLOB_VISIBILITIES.join(", ")}`, + parseVisibility, + "private", + ) + .option("--cors ", "Allowed CORS origins (space-separated)") + .action( + async ( + flags: { name: string; visibility: BlobVisibility; cors?: string[] }, + command: Command, + ) => { + const auth = resolveAuth(command); + const bucket = await request(auth, "POST", "/v2/blob/bucket", { + name: flags.name, + visibility: flags.visibility, + cors: flags.cors, + }); + printJSON(bucket); + }, + ); +} diff --git a/src/commands/blob/credentials.ts b/src/commands/blob/credentials.ts new file mode 100644 index 0000000..4a40b2f --- /dev/null +++ b/src/commands/blob/credentials.ts @@ -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; + +function sleep(ms: number): Promise { + 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; + 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 { + 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 { + if (flags.bucketId) { + const auth = resolveAuth(command); + return request(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 ", "Blob bucket ID") + .action(async (flags: { bucketId?: string }, command: Command) => { + const token = await resolveBucketToken(flags, command); + const credentials = await fetchBlobCredentials(token); + printJSON(credentials); + }); +} diff --git a/src/commands/blob/delete.ts b/src/commands/blob/delete.ts new file mode 100644 index 0000000..7f749b7 --- /dev/null +++ b/src/commands/blob/delete.ts @@ -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 ", "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 }); + }); +} diff --git a/src/commands/blob/get.ts b/src/commands/blob/get.ts new file mode 100644 index 0000000..c560201 --- /dev/null +++ b/src/commands/blob/get.ts @@ -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 ", "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(auth, "GET", `/v2/blob/bucket/${flags.bucketId}`); + if (!flags.hideCredentials) { + printJSON(bucket); + return; + } + const { token: _token, token_next: _tokenNext, ...safeBucket } = bucket; + printJSON(safeBucket); + }); +} diff --git a/src/commands/blob/index.ts b/src/commands/blob/index.ts new file mode 100644 index 0000000..8652c91 --- /dev/null +++ b/src/commands/blob/index.ts @@ -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); +} diff --git a/src/commands/blob/list.ts b/src/commands/blob/list.ts new file mode 100644 index 0000000..562641f --- /dev/null +++ b/src/commands/blob/list.ts @@ -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, command: Command) => { + const auth = resolveAuth(command); + const buckets = await request(auth, "GET", "/v2/blob/bucket"); + printJSON(buckets); + }); +} diff --git a/src/types.ts b/src/types.ts index fec1d95..37ef0d6 100644 --- a/src/types.ts +++ b/src/types.ts @@ -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; +} diff --git a/tests/helpers/program.ts b/tests/helpers/program.ts index a13a010..1c52236 100644 --- a/tests/helpers/program.ts +++ b/tests/helpers/program.ts @@ -28,6 +28,13 @@ export async function createQStashProgram(): Promise { return p; } +export async function createBlobProgram(): Promise { + const { registerBlob } = await import("../../src/commands/blob/index.js"); + const p = new Command().exitOverride(); + registerBlob(p); + return p; +} + export async function createTeamProgram(): Promise { const { registerTeam } = await import("../../src/commands/team/index.js"); const p = new Command().exitOverride(); diff --git a/tests/integration/blob.test.ts b/tests/integration/blob.test.ts new file mode 100644 index 0000000..1e47bcd --- /dev/null +++ b/tests/integration/blob.test.ts @@ -0,0 +1,120 @@ +import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import { HttpError } from "../../src/client.js"; +import { createBlobProgram, runCommand } from "../helpers/program.js"; +import type { BlobBucket, BlobS3Credentials } from "../../src/types.js"; + +const runIntegration = process.env.RUN_BLOB_INTEGRATION === "1"; +const describeBlob = runIntegration ? describe : describe.skip; +const TEST_NAME = `cli-blob-${Date.now()}`; +const DEADLINE_MS = 120000; +const POLL_INTERVAL_MS = 5000; +const TEST_TIMEOUT_MS = DEADLINE_MS + 30000; +const CLEANUP_TIMEOUT_MS = DEADLINE_MS + 30000; + +let bucketId: string | undefined; + +async function sleep(ms: number): Promise { + await new Promise((resolve) => setTimeout(resolve, ms)); +} + +async function waitForCredentials(id: string): Promise { + const deadline = Date.now() + DEADLINE_MS; + let lastError: unknown; + + while (Date.now() < deadline) { + try { + const program = await createBlobProgram(); + return await runCommand(program, ["blob", "credentials", "--bucket-id", id]) as BlobS3Credentials; + } catch (error) { + // A fresh coordinator record can exist before the Blob worker has created + // its matching bucket row. The worker returns 401 during that window, so + // it is transient only in this bounded create-then-poll integration flow. + if (error instanceof HttpError && [401, 429, 503].includes(error.status)) { + lastError = error; + await sleep(POLL_INTERVAL_MS); + continue; + } + + throw error; + } + } + + throw lastError instanceof Error + ? lastError + : new Error("Timed out waiting for Blob credentials to become ready"); +} + +async function cleanupBucket(id: string): Promise { + const deadline = Date.now() + DEADLINE_MS; + let lastError: unknown; + + while (Date.now() < deadline) { + try { + const program = await createBlobProgram(); + await runCommand(program, ["blob", "delete", "--bucket-id", id]); + return; + } catch (error) { + if (error instanceof HttpError) { + if (error.status === 404) { + return; + } + + if (error.status >= 500 && error.status < 600) { + lastError = error; + await sleep(POLL_INTERVAL_MS); + continue; + } + } + + throw error; + } + } + + throw lastError instanceof Error + ? lastError + : new Error(`Timed out deleting Blob bucket ${id}`); +} + +beforeAll(async () => { + if (!runIntegration) return; + const program = await createBlobProgram(); + const bucket = await runCommand(program, [ + "blob", + "create", + "--name", + TEST_NAME, + "--visibility", + "private", + ]) as BlobBucket; + + expect(bucket.id).toBeDefined(); + bucketId = bucket.id; +}); + +afterAll(async () => { + if (!bucketId) return; + await cleanupBucket(bucketId); +}, CLEANUP_TIMEOUT_MS); + +describeBlob("blob integration lifecycle", () => { + it("lists and gets the created bucket", async () => { + const listProgram = await createBlobProgram(); + const buckets = await runCommand(listProgram, ["blob", "list"]) as BlobBucket[]; + expect(buckets.some((bucket) => bucket.id === bucketId)).toBe(true); + + const getProgram = await createBlobProgram(); + const bucket = await runCommand(getProgram, ["blob", "get", "--bucket-id", bucketId!]) as BlobBucket; + expect(bucket.id).toBe(bucketId); + expect(bucket.name).toBe(TEST_NAME); + expect(bucket.visibility).toBe("private"); + }); + + it("returns temporary S3 credentials once provisioning is ready", async () => { + const credentials = await waitForCredentials(bucketId!); + expect(credentials.bucket).toBe(bucketId); + expect(credentials.region).toBe("auto"); + expect(credentials.endpoint.startsWith("https://")).toBe(true); + expect(credentials.endpoint.includes(".r2.cloudflarestorage.com")).toBe(true); + expect(credentials.expiresAt).toBeGreaterThan(Date.now() / 1000); + }, TEST_TIMEOUT_MS); +}); diff --git a/tests/unit/blob.test.ts b/tests/unit/blob.test.ts new file mode 100644 index 0000000..38bcdde --- /dev/null +++ b/tests/unit/blob.test.ts @@ -0,0 +1,351 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { createBlobProgram, runCommand } from "../helpers/program.js"; +import { fetchBlobCredentials } from "../../src/commands/blob/credentials.js"; +import type { BlobBucket, BlobS3Credentials } from "../../src/types.js"; + +const originalEnv = { ...process.env }; + +function makeBucket(overrides: Partial = {}): BlobBucket { + return { + customer_id: "cust_123", + id: "bucket_123", + name: "my-bucket", + hash_for_domain: "hash_123", + visibility: "private", + endpoint: "https://bucket_123.example.com", + pw_version: 1, + creation_time: 123, + token: "token_current", + token_next: "token_next", + ...overrides, + }; +} + +function makeCredentials(overrides: Partial = {}): BlobS3Credentials { + return { + accessKeyId: "access", + secretAccessKey: "secret", + sessionToken: "session", + endpoint: "https://account.r2.cloudflarestorage.com", + bucket: "bucket_123", + region: "auto", + expiresAt: 1234567890, + ...overrides, + }; +} + +beforeEach(() => { + process.env = { + ...originalEnv, + UPSTASH_EMAIL: "user@example.com", + UPSTASH_API_KEY: "api-key", + }; + delete process.env.UPSTASH_BLOB_TOKEN; +}); + +afterEach(() => { + vi.restoreAllMocks(); + vi.useRealTimers(); + process.env = { ...originalEnv }; +}); + +describe("blob command registration", () => { + it("registers the expected blob subcommands", async () => { + const program = await createBlobProgram(); + const blob = program.commands.find((command) => command.name() === "blob"); + + expect(blob).toBeDefined(); + expect(blob?.description()).toBe("Manage Blob buckets"); + expect(blob?.commands.map((command) => command.name())).toEqual([ + "create", + "list", + "get", + "delete", + "credentials", + ]); + }); +}); + +describe("blob CRUD commands", () => { + it("create defaults to private visibility", async () => { + const bucket = makeBucket(); + const fetchSpy = vi.spyOn(globalThis, "fetch").mockResolvedValue( + new Response(JSON.stringify(bucket), { status: 200 }), + ); + + const program = await createBlobProgram(); + const result = await runCommand(program, ["blob", "create", "--name", "my-bucket"]); + + expect(result).toEqual(bucket); + expect(fetchSpy).toHaveBeenCalledTimes(1); + const [url, init] = fetchSpy.mock.calls[0] as [string, RequestInit]; + expect(url).toBe("https://api.upstash.com/v2/blob/bucket"); + expect(init.method).toBe("POST"); + expect(JSON.parse(init.body as string)).toEqual({ + name: "my-bucket", + visibility: "private", + }); + }); + + it("create passes CORS origins correctly", async () => { + const bucket = makeBucket({ cors: ["https://a.example.com", "https://b.example.com"] }); + const fetchSpy = vi.spyOn(globalThis, "fetch").mockResolvedValue( + new Response(JSON.stringify(bucket), { status: 200 }), + ); + + const program = await createBlobProgram(); + await runCommand(program, [ + "blob", + "create", + "--name", + "my-bucket", + "--cors", + "https://a.example.com", + "https://b.example.com", + ]); + + const [, init] = fetchSpy.mock.calls[0] as [string, RequestInit]; + expect(JSON.parse(init.body as string)).toEqual({ + name: "my-bucket", + visibility: "private", + cors: ["https://a.example.com", "https://b.example.com"], + }); + }); + + it("list uses the expected method and path", async () => { + const buckets = [makeBucket({ token: undefined, token_next: undefined })]; + const fetchSpy = vi.spyOn(globalThis, "fetch").mockResolvedValue( + new Response(JSON.stringify(buckets), { status: 200 }), + ); + + const program = await createBlobProgram(); + const result = await runCommand(program, ["blob", "list"]); + + expect(result).toEqual(buckets); + const [url, init] = fetchSpy.mock.calls[0] as [string, RequestInit]; + expect(url).toBe("https://api.upstash.com/v2/blob/bucket"); + expect(init.method).toBe("GET"); + }); + + it("get preserves credentials by default", async () => { + const bucket = makeBucket(); + const fetchSpy = vi.spyOn(globalThis, "fetch").mockResolvedValue( + new Response(JSON.stringify(bucket), { status: 200 }), + ); + + const program = await createBlobProgram(); + const result = await runCommand(program, ["blob", "get", "--bucket-id", "bucket_123"]); + + expect(result).toEqual(bucket); + const [url, init] = fetchSpy.mock.calls[0] as [string, RequestInit]; + expect(url).toBe("https://api.upstash.com/v2/blob/bucket/bucket_123"); + expect(init.method).toBe("GET"); + }); + + it("get --hide-credentials removes both token fields without mutating the response", async () => { + const bucket = makeBucket(); + vi.spyOn(globalThis, "fetch").mockResolvedValue( + new Response(JSON.stringify(bucket), { status: 200 }), + ); + + const program = await createBlobProgram(); + const result = await runCommand(program, [ + "blob", + "get", + "--bucket-id", + "bucket_123", + "--hide-credentials", + ]); + + expect(result).toEqual({ + customer_id: "cust_123", + id: "bucket_123", + name: "my-bucket", + hash_for_domain: "hash_123", + visibility: "private", + endpoint: "https://bucket_123.example.com", + pw_version: 1, + creation_time: 123, + }); + expect(bucket.token).toBe("token_current"); + expect(bucket.token_next).toBe("token_next"); + }); + + it("delete dry-run makes no HTTP request and returns the preview shape", async () => { + const fetchSpy = vi.spyOn(globalThis, "fetch"); + + const program = await createBlobProgram(); + const result = await runCommand(program, [ + "blob", + "delete", + "--bucket-id", + "bucket_123", + "--dry-run", + ]); + + expect(result).toEqual({ action: "delete", bucket_id: "bucket_123", dry_run: true }); + expect(fetchSpy).not.toHaveBeenCalled(); + }); + + it("delete uses the expected method and path", async () => { + const fetchSpy = vi.spyOn(globalThis, "fetch").mockResolvedValue( + new Response('"OK"', { status: 200 }), + ); + + const program = await createBlobProgram(); + const result = await runCommand(program, ["blob", "delete", "--bucket-id", "bucket_123"]); + + expect(result).toEqual({ deleted: true, bucket_id: "bucket_123" }); + const [url, init] = fetchSpy.mock.calls[0] as [string, RequestInit]; + expect(url).toBe("https://api.upstash.com/v2/blob/bucket/bucket_123"); + expect(init.method).toBe("DELETE"); + }); +}); + +describe("blob credentials command", () => { + it("by bucket id fetches the bucket first, then exchanges its token", async () => { + const bucket = makeBucket({ id: "bucket_456", token: "bucket-token" }); + const credentials = makeCredentials({ bucket: "bucket_456" }); + const fetchSpy = vi + .spyOn(globalThis, "fetch") + .mockResolvedValueOnce(new Response(JSON.stringify(bucket), { status: 200 })) + .mockResolvedValueOnce(new Response(JSON.stringify(credentials), { status: 200 })); + + const program = await createBlobProgram(); + const result = await runCommand(program, [ + "blob", + "credentials", + "--bucket-id", + "bucket_456", + ]); + + expect(result).toEqual(credentials); + expect(fetchSpy).toHaveBeenCalledTimes(2); + expect(fetchSpy.mock.calls[0]?.[0]).toBe("https://api.upstash.com/v2/blob/bucket/bucket_456"); + expect(fetchSpy.mock.calls[1]?.[0]).toBe("https://blob.upstash.io/v1/credentials"); + expect((fetchSpy.mock.calls[1]?.[1] as RequestInit).headers).toEqual({ + Authorization: "Bearer bucket-token", + }); + }); + + it("without bucket id uses UPSTASH_BLOB_TOKEN and skips Developer API auth", async () => { + process.env.UPSTASH_BLOB_TOKEN = "env-bucket-token"; + const credentials = makeCredentials(); + const fetchSpy = vi.spyOn(globalThis, "fetch").mockResolvedValue( + new Response(JSON.stringify(credentials), { status: 200 }), + ); + + const program = await createBlobProgram(); + const result = await runCommand(program, ["blob", "credentials"]); + + expect(result).toEqual(credentials); + expect(fetchSpy).toHaveBeenCalledTimes(1); + expect(fetchSpy.mock.calls[0]?.[0]).toBe("https://blob.upstash.io/v1/credentials"); + }); + + it("explicit bucket id wins over an ambient UPSTASH_BLOB_TOKEN", async () => { + process.env.UPSTASH_BLOB_TOKEN = "ambient-token"; + const bucket = makeBucket({ id: "bucket_789", token: "fresh-token" }); + const credentials = makeCredentials({ bucket: "bucket_789" }); + const fetchSpy = vi + .spyOn(globalThis, "fetch") + .mockResolvedValueOnce(new Response(JSON.stringify(bucket), { status: 200 })) + .mockResolvedValueOnce(new Response(JSON.stringify(credentials), { status: 200 })); + + const program = await createBlobProgram(); + await runCommand(program, ["blob", "credentials", "--bucket-id", "bucket_789"]); + + expect((fetchSpy.mock.calls[1]?.[1] as RequestInit).headers).toEqual({ + Authorization: "Bearer fresh-token", + }); + }); + + it("fails clearly when no bucket token source is available", async () => { + delete process.env.UPSTASH_BLOB_TOKEN; + delete process.env.UPSTASH_EMAIL; + delete process.env.UPSTASH_API_KEY; + + const program = await createBlobProgram(); + + await expect(runCommand(program, ["blob", "credentials"])) + .rejects.toThrow(/either --bucket-id.*UPSTASH_BLOB_TOKEN/); + }); + + it("prints successful credential responses unchanged", async () => { + process.env.UPSTASH_BLOB_TOKEN = "env-bucket-token"; + const credentials = { + ...makeCredentials(), + extra: "preserved", + }; + vi.spyOn(globalThis, "fetch").mockResolvedValue( + new Response(JSON.stringify(credentials), { status: 200 }), + ); + + const program = await createBlobProgram(); + const result = await runCommand(program, ["blob", "credentials"]); + + expect(result).toEqual(credentials); + }); + + it("rejects invalid credential payloads and unexpected endpoints", async () => { + const invalidPayloads = [ + makeCredentials({ accessKeyId: "" }), + { ...makeCredentials(), expiresAt: Number.NaN }, + makeCredentials({ endpoint: "http://account.r2.cloudflarestorage.com" }), + makeCredentials({ endpoint: "https://example.com" }), + ]; + + const fetchSpy = vi.spyOn(globalThis, "fetch"); + for (const payload of invalidPayloads) { + process.env.UPSTASH_BLOB_TOKEN = "env-bucket-token"; + fetchSpy.mockResolvedValueOnce(new Response(JSON.stringify(payload), { status: 200 })); + const program = await createBlobProgram(); + await expect(runCommand(program, ["blob", "credentials"])) + .rejects.toThrow(/Blob credentials response|Blob credentials endpoint/); + } + }); + + it("reports 401 as rejected authentication", async () => { + vi.spyOn(globalThis, "fetch").mockResolvedValue( + new Response('{"error":"unauthorized"}', { status: 401 }), + ); + + await expect( + fetchBlobCredentials("bad-token", async () => { + throw new Error("should not sleep"); + }), + ).rejects.toThrow(/rejected/); + }); + + it("retries 429 and 503 with retry-after or fallback delays, then succeeds", async () => { + const credentials = makeCredentials(); + const delays: number[] = []; + vi.spyOn(globalThis, "fetch") + .mockResolvedValueOnce(new Response('{"error":"slow down"}', { status: 429, headers: { "Retry-After": "1" } })) + .mockResolvedValueOnce(new Response('{"error":"unavailable"}', { status: 503 })) + .mockResolvedValueOnce(new Response(JSON.stringify(credentials), { status: 200 })); + + const result = await fetchBlobCredentials("bucket-token", async (ms) => { + delays.push(ms); + }); + + expect(result).toEqual(credentials); + expect(delays).toEqual([1000, 2000]); + }); + + it("caps retries and surfaces the final response error", async () => { + const delays: number[] = []; + vi.spyOn(globalThis, "fetch") + .mockResolvedValueOnce(new Response('{"error":"busy-1"}', { status: 429, headers: { "Retry-After": "999" } })) + .mockResolvedValueOnce(new Response('{"error":"busy-2"}', { status: 503, headers: { "Retry-After": "nope" } })) + .mockResolvedValueOnce(new Response('{"error":"busy-3"}', { status: 429, headers: { "Retry-After": "3" } })) + .mockResolvedValueOnce(new Response('{"error":"still busy"}', { status: 503 })); + + await expect( + fetchBlobCredentials("bucket-token", async (ms) => { + delays.push(ms); + }), + ).rejects.toThrow(/still busy/); + expect(delays).toEqual([10000, 2000, 3000]); + }); +}); From eca8af0be360988b11c04aee3703485830d8b844 Mon Sep 17 00:00:00 2001 From: alitariksahin Date: Wed, 2 Sep 2026 16:40:41 +0300 Subject: [PATCH 2/2] DX-2969: drop PLAN.md from the repo root Internal design doc with Linear links and control-plane internals. files: ["dist"] keeps it out of the npm tarball but not off GitHub; it belongs on the ticket. --- PLAN.md | 253 -------------------------------------------------------- 1 file changed, 253 deletions(-) delete mode 100644 PLAN.md diff --git a/PLAN.md b/PLAN.md deleted file mode 100644 index a1e3a5f..0000000 --- a/PLAN.md +++ /dev/null @@ -1,253 +0,0 @@ -# DX-2969 — Add Upstash Blob to `@upstash/cli` - -Linear: https://linear.app/upstash/issue/DX-2969/cli-blob - -## Objective - -Add a small, agent-friendly Blob surface to the existing CLI. The CLI manages Blob buckets through the Upstash Developer API and provides one bridge from a bucket token to temporary, bucket-scoped S3 credentials. It does not reimplement S3 object operations: agents should use AWS CLI, rclone, or an S3 SDK once credentials are returned. - -## Product and API facts - -The control-plane source of truth is `upstash/upstash-cloud`; the current console implementation is in `upstash/upstash-console-v2`; credential behavior is defined by `upstash/blob-store` and `upstash/blob-js`. - -Control-plane routes use the CLI's existing Developer API Basic authentication: - -- `POST /v2/blob/bucket` — create a bucket -- `GET /v2/blob/bucket` — list buckets; list responses do not contain tokens -- `GET /v2/blob/bucket/:id` — get a bucket; may contain `token` and `token_next` -- `DELETE /v2/blob/bucket/:id` — delete an empty bucket - -Create accepts: - -```json -{ - "name": "bucket-name", - "visibility": "private | public", - "cors": ["https://example.com"] -} -``` - -`visibility` and `cors` are optional. The backend defaults visibility to `private`. Do not invent regions or plans: Blob buckets currently expose neither. - -Temporary S3 credentials come from: - -```text -POST https://blob.upstash.io/v1/credentials -Authorization: Bearer -``` - -The successful response is: - -```json -{ - "accessKeyId": "...", - "secretAccessKey": "...", - "sessionToken": "...", - "endpoint": "https://.r2.cloudflarestorage.com", - "bucket": "", - "region": "auto", - "expiresAt": 1234567890 -} -``` - -Credentials are temporary, bucket-scoped, and may have less than ten minutes remaining. `expiresAt` is authoritative. - -## Command surface - -Register a new top-level `blob` command described as `Manage Blob buckets`. - -### `upstash blob create` - -Options: - -- required `--name ` -- optional `--visibility `; accepted values are `private` and `public`; default to `private` -- optional variadic `--cors ` - -Send only the documented fields. Print the returned bucket as JSON, including the initial tokens returned by create. - -### `upstash blob list` - -Call the list endpoint and print the returned array as JSON. Do not synthesize or request credentials. - -### `upstash blob get` - -Options: - -- required `--bucket-id ` -- optional `--hide-credentials` - -Call the get endpoint. By default, preserve `token` and `token_next`, matching the existing Redis `get` behavior. With `--hide-credentials`, remove both fields from a copied response before printing; do not mutate shared data and do not rely on an unsupported backend query parameter. - -### `upstash blob delete` - -Options: - -- required `--bucket-id ` -- optional `--dry-run` - -Dry-run must not resolve authentication or make a request. It prints: - -```json -{ "action": "delete", "bucket_id": "...", "dry_run": true } -``` - -A real delete calls the API and prints: - -```json -{ "deleted": true, "bucket_id": "..." } -``` - -Do not add recursive object deletion. The Developer API intentionally refuses deletion of a non-empty bucket. - -### `upstash blob credentials` - -Options: - -- optional `--bucket-id ` - -Token resolution: - -1. When `--bucket-id` is present, resolve normal Developer API auth, fetch that bucket with `GET /v2/blob/bucket/:id`, and use its current `token`. This explicit mode wins even when `UPSTASH_BLOB_TOKEN` is set. -2. Without `--bucket-id`, read `UPSTASH_BLOB_TOKEN`. -3. If neither source supplies a non-empty token, fail with a clear error explaining both supported forms. -4. Do not add `--token`; command-line secrets leak into shell history and process listings. - -Exchange the token at the Blob credential endpoint and print the successful response unchanged as JSON. Do not print the long-lived bucket token, cache credentials, write them to config, or add an env/shell output format. - -Credential request behavior: - -- Send `POST` with a Bearer authorization header and no body. -- Treat `401` as a rejected bucket token. -- Retry `429` and `503` up to three retries, honoring a positive numeric `Retry-After`; use 2 seconds when absent/invalid and cap a single wait at 10 seconds. -- After retries, surface the response error through the CLI's normal JSON error handling. -- Validate the success payload before printing. Required fields are non-empty strings except `expiresAt`, which must be a finite number. -- Parse `endpoint` as a URL and require HTTPS plus a hostname ending in `.r2.cloudflarestorage.com`. Reject an unexpected endpoint so downstream agents are not instructed to send data or credentials to an arbitrary host. - -Keep this implementation dependency-free. Use the platform `fetch`; do not add `@upstash/blob`, an AWS SDK, or a signing package. - -## Types - -Extend `src/types.ts` with narrowly scoped Blob types: - -- `BLOB_VISIBILITIES` and `BlobVisibility` -- `BlobBucketEvent` -- `BlobBucket` -- `BlobS3Credentials` - -`BlobBucket` should model the current external response, including: - -- `customer_id`, `id`, `name`, `hash_for_domain` -- `visibility`, `endpoint`, `pw_version`, `creation_time` -- optional `cors`, `created_by`, `events`, `token`, `token_next` - -Do not include coordinator-only encrypted passwords or internal provisioning fields. - -## File layout - -Follow the existing one-command-per-file structure: - -```text -src/commands/blob/ - index.ts - create.ts - list.ts - get.ts - delete.ts - credentials.ts -``` - -Register `registerBlob(program)` in `src/cli.ts` beside the other product registrations. A small credential-response parser/retry helper may remain in `credentials.ts` unless extracting it clearly improves testing; avoid broad refactoring of `src/client.ts` just for one Bearer-authenticated endpoint. - -## Error and output conventions - -- Successful account commands emit pretty JSON via `printJSON`. -- Errors flow to the existing top-level `handleError`, producing `{ "error": "..." }` on stderr and exit code 1. -- Do not log Authorization headers, bucket tokens, or temporary secrets. -- Keep Commander descriptions precise enough for an agent to discover the workflow from `--help`. -- The credentials help text should state that the result is temporary S3 credentials and that `expiresAt` is the expiry. - -## Tests - -### Unit tests - -Add a Blob program factory to `tests/helpers/program.ts`. - -Add focused tests with mocked `global.fetch` covering: - -1. Command registration and expected method/path/body for create, list, get, and delete. -2. Create defaults to private and passes CORS origins correctly. -3. `get --hide-credentials` removes both token fields while normal get preserves them. -4. Delete dry-run makes no HTTP request and produces the established preview shape. -5. Credentials by bucket id performs the Developer API GET first, then the Bearer POST using the fetched current token. -6. Credentials without bucket id uses `UPSTASH_BLOB_TOKEN` and does not attempt Developer API auth. -7. Explicit bucket id wins over an ambient `UPSTASH_BLOB_TOKEN`. -8. Missing token source fails clearly. -9. Credential success is printed unchanged. -10. Invalid/missing credential fields and a non-R2 or non-HTTPS endpoint are rejected. -11. `401` is reported as rejected authentication. -12. `429`/`503` retry behavior, fallback delay, retry limit, and eventual success/failure. Use fake timers or inject sleep so tests do not actually wait. - -Restore environment variables, fetch, timers, console methods, and any other globals after each test so the existing serial suite remains isolated. - -### Integration coverage - -If the existing credentials can access Blob, add an opt-in integration lifecycle test guarded by `RUN_BLOB_INTEGRATION=1`: - -- create a uniquely named private empty bucket -- verify list and get -- fetch temporary S3 credentials and validate bucket/endpoint/expiry -- delete the bucket in `finally` - -Blob provisioning is asynchronous. Poll credential readiness and cleanup with a bounded timeout rather than assuming immediate readiness. Never upload an object in this test. Keep it opt-in so ordinary unit runs do not consume Blob quota or become flaky on the provisioning cron. - -## README - -Add concise examples to the existing Quick examples section: - -```bash -upstash blob create --name my-bucket --visibility private -upstash blob list -upstash blob credentials --bucket-id $BUCKET_ID -``` - -Explain in one sentence that `credentials` returns temporary S3 credentials for use with AWS CLI, rclone, or an S3 SDK. Do not document object-operation commands because none are being added. - -## Verification - -Run from the worktree: - -```bash -npm run build -npm run typecheck -npm test -node dist/cli.js blob --help -node dist/cli.js blob credentials --help -``` - -If Blob integration credentials and permission are available: - -```bash -RUN_BLOB_INTEGRATION=1 npm test -- tests/integration/blob.test.ts -``` - -Inspect the final diff for accidental secrets and confirm no generated `dist` output is included unless it was already intentionally tracked by this repository. - -## Non-goals - -- No object list/get/put/delete/copy commands. -- No SigV4 implementation. -- No AWS SDK or `@upstash/blob` dependency. -- No persistent storage of bucket tokens or temporary S3 credentials. -- No advanced bucket operations such as rename, token rotation, visibility updates, CORS updates, usage stats, or transfer in this first pass. -- No changes to login/config precedence. - -## Acceptance criteria - -- `upstash --help` exposes the Blob group. -- Basic bucket create/list/get/delete works through the Developer API with existing auth. -- `credentials --bucket-id` turns account access into a validated temporary S3 credential bundle. -- `credentials` also works from `UPSTASH_BLOB_TOKEN` without Developer API auth. -- Destructive behavior retains dry-run support and never recursively deletes objects. -- Credentials and tokens are never persisted or logged. -- Build, typecheck, and unit tests pass with no new runtime dependency.