From bd2631f9b7b598a87cdc9fcdcc41beea5d944330 Mon Sep 17 00:00:00 2001 From: Jeremy Date: Sun, 19 Jul 2026 00:10:10 +0000 Subject: [PATCH 1/2] plan: add cards and auth domains to the Grid CLI --- docs/plans/2026-07-19-grid-cli-cards-auth.md | 70 ++++++++++++++++++++ 1 file changed, 70 insertions(+) create mode 100644 docs/plans/2026-07-19-grid-cli-cards-auth.md diff --git a/docs/plans/2026-07-19-grid-cli-cards-auth.md b/docs/plans/2026-07-19-grid-cli-cards-auth.md new file mode 100644 index 000000000..ecfa9beb1 --- /dev/null +++ b/docs/plans/2026-07-19-grid-cli-cards-auth.md @@ -0,0 +1,70 @@ +# Add cards and auth domains to the Grid CLI + +## Context +Follow-up to the CLI drift work (#702) and contact verification (#703). The requester asked to "tackle cards and auth next" — two of the whole-domain gaps from the audit. This adds `grid cards …` and `grid auth …` commands, stacked on the existing branches (shared test harness, no index.ts conflicts). + +## Approach +Two stacked PRs on top of #703: +1. **cards** (`cli/src/commands/cards.ts`) — full surface (5 commands). +2. **auth** (`cli/src/commands/auth.ts`) — a **read-focused MVP** (6 commands). + +Both register in `cli/src/index.ts` and follow the exact existing command pattern (`registerXCommand`, commander options, `client.get/post/patch/delete`, `outputResponse`) with vitest boundary tests (stub `fetch`, assert the request) like the rest of the suite. + +### Key scope decision — auth +Every mutating auth operation **except two** is a signed-retry flow: the first unsigned call returns `202` with a `payloadToSign`, and completion requires a `Grid-Wallet-Signature` header (a Turnkey API-key stamp built from a client-held P-256 key) + `Request-Id`. The CLI can neither build those stamps (that lives in `scripts/embedded-wallet-sign.js` via `@turnkey/*` + HPKE) nor send the headers (`client.ts` has no per-call header support). Passkey flows additionally need WebAuthn. So the auth MVP ships only what genuinely completes from a flag CLI: + +**Auth MVP (SUPPORT):** `credentials list`, `credentials challenge ` (OTP resend — plain unsigned POST), `delegated-keys list`, `delegated-keys get `, `delegated-keys revoke ` (the one revoke Grid signs with its own custodied key → single unsigned DELETE), `sessions list`. + +**Deferred to a Phase 2 (OUT OF SCOPE here):** `credentials create/revoke/verify`, `delegated-keys create`, `sessions revoke/refresh`. These need (a) `client.ts` extended to send `Grid-Wallet-Signature`/`Request-Id` headers and (b) a Turnkey/HPKE signing helper. Called out in the PR; not built now. + +Cards has one analogous limitation: `cards update` uses the same 202 signed-retry pattern, so the unsigned PATCH surfaces the challenge JSON (same behavior as the existing `quotes execute` / `accounts export`). We still ship `cards update` (freeze/unfreeze/replace-sources/close all express through `state`/`fundingSources`) and note the gap. + +## Changes + +### PR 1 — cards + +#### 1. `cli/src/commands/cards.ts` (new) +- **What**: `registerCardsCommand(program, getClient)` with: + - `cards list` → GET `/cards`; filters `--cardholder-id`, `--account-id`, `--platform-card-id`, `--state`, `-l/--limit`, `--cursor`, `--sort` (→ `sortOrder`); `PaginatedResponse`. + - `cards get ` → GET `/cards/{id}`. + - `cards create` → POST `/cards`; required `--cardholder-id`, `--funding-sources` (comma list → `fundingSources[]`); `--form` (default `VIRTUAL`), `--platform-card-id`. + - `cards update ` → PATCH `/cards/{id}`; `--state` (ACTIVE|FROZEN|CLOSED), `--funding-sources`. Validate ≥1 provided and reject `--state CLOSED` + `--funding-sources` together. + - `cards reveal ` → POST `/cards/{id}/reveal` (no body); prints the short-lived `panEmbedUrl` (description notes it's a bearer secret to render in an iframe, not stored). No `delete` subcommand (close = `update --state CLOSED`). +- **Why**: cards is a full new product area with no CLI coverage. + +#### 2. `cli/src/index.ts` +- **What**: dynamic-import + register `registerCardsCommand` alongside the others. + +#### 3. `cli/test/cards.test.ts` (new) +- Boundary tests: create body shape, list query params, update variants (freeze/replace/close), update validation guard, reveal path. + +### PR 2 — auth (stacked on cards) + +#### 4. `cli/src/commands/auth.ts` (new) +- **What**: `registerAuthCommand` with nested groups: + - `auth credentials list` → GET `/auth/credentials`, required `--account-id`. + - `auth credentials challenge ` → POST `/auth/credentials/{id}/challenge` (empty body; OTP resend). + - `auth delegated-keys list` → GET `/auth/delegated-keys`; `--account-id` / `--funding-source-id` (≥1 required). + - `auth delegated-keys get ` → GET `/auth/delegated-keys/{id}`. + - `auth delegated-keys revoke ` → DELETE `/auth/delegated-keys/{id}` with `-y/--yes` + confirm. + - `auth sessions list` → GET `/auth/sessions`, required `--account-id`. + - List responses are `{ data: T[] }` (not paginated) — declare a small `AuthListResponse`. +- **Why**: gives the full auth read surface + the two genuinely-completable mutations. + +#### 5. `cli/src/index.ts` +- **What**: register `registerAuthCommand`. + +#### 6. `cli/test/auth.test.ts` (new) +- Boundary tests for each of the 6 commands (method/path/query/body). + +#### 7. `cli/README.md` +- **What**: document the new `cards` and `auth` command groups, incl. the reveal/update and auth-MVP limitations. + +## Verification +- [ ] `cd cli && npm run build` — clean strict compile. +- [ ] `npm test` — new cards + auth boundary tests pass alongside the existing 34. +- [ ] Spot-check a built request body against the corresponding `openapi/components/schemas/{cards,auth}/**` schema. + +## Risks +- Auth MVP intentionally omits the signed-retry/crypto commands. If the requester wants those, it's a larger Phase 2 (client header support + Turnkey/HPKE signing) — flagged, not built. +- `cards update` / signed ops surface the 202 challenge rather than the final object — consistent with existing CLI behavior; documented. From b0aab72a72b2109d6cc4e5977e6286f783440f6f Mon Sep 17 00:00:00 2001 From: Jeremy Date: Sun, 19 Jul 2026 00:29:59 +0000 Subject: [PATCH 2/2] feat(cli): add cards and auth domains + signed-request header support New cards commands (list/get/create/update/reveal) and auth commands (credentials, delegated-keys, sessions). Extend GridClient so get/post/patch/ delete accept optional headers, and expose --wallet-signature/--request-id on the signed-retry operations (the CLI forwards a caller-supplied Turnkey stamp; it does not compute one). vitest boundary tests cover both domains. --- cli/README.md | 70 ++++ cli/src/client.ts | 35 +- cli/src/commands/auth.ts | 394 +++++++++++++++++++ cli/src/commands/cards.ts | 158 ++++++++ cli/src/index.ts | 4 + cli/src/signed.ts | 29 ++ cli/test/auth.test.ts | 228 +++++++++++ cli/test/cards.test.ts | 103 +++++ docs/plans/2026-07-19-grid-cli-cards-auth.md | 70 ---- 9 files changed, 1013 insertions(+), 78 deletions(-) create mode 100644 cli/src/commands/auth.ts create mode 100644 cli/src/commands/cards.ts create mode 100644 cli/src/signed.ts create mode 100644 cli/test/auth.test.ts create mode 100644 cli/test/cards.test.ts delete mode 100644 docs/plans/2026-07-19-grid-cli-cards-auth.md diff --git a/cli/README.md b/cli/README.md index 012302b8e..e4cf47a62 100644 --- a/cli/README.md +++ b/cli/README.md @@ -301,6 +301,76 @@ grid receiver lookup-uma '$user@domain.com' grid receiver lookup-account ``` +### Cards + +```bash +# List cards +grid cards list [--cardholder-id ] [--state ACTIVE] + +# Get a card +grid cards get + +# Issue a virtual card +grid cards create \ + --cardholder-id \ + --funding-sources "InternalAccount:1,InternalAccount:2" + +# Freeze / unfreeze / close, or replace funding sources +grid cards update --state FROZEN +grid cards update --state ACTIVE +grid cards update --state CLOSED +grid cards update --funding-sources "InternalAccount:3" + +# Reveal card details — prints a short-lived panEmbedUrl to render in an iframe. +# Do not store or log it. +grid cards reveal +``` + +`cards update` (and the signed `auth` operations below) use Grid's signed-retry +flow: the first call returns a `202` challenge with a `payloadToSign`. Sign it +with your embedded-wallet key (e.g. via `scripts/embedded-wallet-sign.js`) and +re-run the command with `--wallet-signature --request-id ` to +complete. The CLI forwards those as the `Grid-Wallet-Signature` / `Request-Id` +headers — it does not compute the stamp for you. + +### Auth + +```bash +# Credentials +grid auth credentials list --account-id +grid auth credentials create --type OAUTH --account-id --oidc-token +grid auth credentials challenge # e.g. resend an OTP +grid auth credentials verify --type EMAIL_OTP \ + --encrypted-otp-bundle '' \ + --wallet-signature --request-id +grid auth credentials revoke --wallet-signature --request-id + +# Delegated signing keys +grid auth delegated-keys list --account-id +grid auth delegated-keys get +grid auth delegated-keys create \ + --card-id --internal-account-id --nickname "Card key" \ + --spending-limit USD:5000 --spending-limit EUR:4000 \ + --wallet-signature --request-id +grid auth delegated-keys revoke # no signature needed + +# Sessions +grid auth sessions list --account-id +grid auth sessions refresh --client-public-key \ + --wallet-signature --request-id +grid auth sessions revoke --wallet-signature --request-id +``` + +Passkey (WebAuthn) create/verify accept the attestation/assertion as JSON +(`--attestation` / `--assertion`) that you produce client-side; the CLI cannot +run WebAuthn itself. + +Some operations need more than one signed retry — notably `auth delegated-keys +create` has two successive signed legs (a single `--wallet-signature` retry +stops at the second `202` and leaves the key `PENDING`). Run the command once +per signed leg, supplying the next `--wallet-signature` / `--request-id` each +time, until it returns the created key. + ### Sandbox Testing ```bash diff --git a/cli/src/client.ts b/cli/src/client.ts index dd44785a3..253a1ecc1 100644 --- a/cli/src/client.ts +++ b/cli/src/client.ts @@ -57,6 +57,7 @@ export class GridClient { options?: { params?: Record; body?: unknown; + headers?: Record; } ): Promise> { const url = this.buildUrl(path, options?.params); @@ -76,6 +77,12 @@ export class GridClient { fetchOptions.body = JSON.stringify(options.body); } + if (options?.headers) { + for (const [key, value] of Object.entries(options.headers)) { + if (value !== undefined) headers[key] = value; + } + } + try { const controller = new AbortController(); const timeoutId = setTimeout(() => controller.abort(), this.timeoutMs); @@ -134,20 +141,32 @@ export class GridClient { async get( path: string, - params?: Record + params?: Record, + headers?: Record ): Promise> { - return this.request("GET", path, { params }); + return this.request("GET", path, { params, headers }); } - async post(path: string, body?: unknown): Promise> { - return this.request("POST", path, { body }); + async post( + path: string, + body?: unknown, + headers?: Record + ): Promise> { + return this.request("POST", path, { body, headers }); } - async patch(path: string, body?: unknown): Promise> { - return this.request("PATCH", path, { body }); + async patch( + path: string, + body?: unknown, + headers?: Record + ): Promise> { + return this.request("PATCH", path, { body, headers }); } - async delete(path: string): Promise> { - return this.request("DELETE", path); + async delete( + path: string, + headers?: Record + ): Promise> { + return this.request("DELETE", path, { headers }); } } diff --git a/cli/src/commands/auth.ts b/cli/src/commands/auth.ts new file mode 100644 index 000000000..d682f0421 --- /dev/null +++ b/cli/src/commands/auth.ts @@ -0,0 +1,394 @@ +import { Command, InvalidArgumentError } from "commander"; +import { GridClient } from "../client"; +import { outputResponse, formatError, output } from "../output"; +import { GlobalOptions } from "../index"; +import { confirm } from "../prompt"; +import { addSignedOptions, signedHeaders } from "../signed"; + +interface AuthListResponse { + data: T[]; +} + +interface AuthMethod { + id: string; + accountId: string; + type: string; + nickname?: string; + createdAt: string; + updatedAt: string; +} + +interface DelegatedKey { + id: string; + cardId: string; + accountId: string; + nickname: string; + status: string; + createdAt: string; + updatedAt: string; +} + +interface AuthSession { + id: string; + accountId: string; + type: string; + createdAt: string; + expiresAt: string; +} + +// Collects a repeated "CURRENCY:amount" flag into spending-limit objects. +// Amounts are integers in the smallest currency unit; a malformed or non-integer +// value is rejected up front rather than silently sent as null. +function collectSpendingLimit( + value: string, + previous: Array<{ currencyCode: string; maxPerTransaction: number }> = [] +): Array<{ currencyCode: string; maxPerTransaction: number }> { + const match = /^([A-Z0-9]{3,16}):(\d+)$/.exec(value); + if (!match) { + throw new InvalidArgumentError( + `expected CURRENCY:amount with an uppercase code and an integer amount, e.g. USD:5000 (got "${value}")` + ); + } + const maxPerTransaction = Number(match[2]); + if (!Number.isSafeInteger(maxPerTransaction) || maxPerTransaction < 1) { + throw new InvalidArgumentError( + `spending limit amount must be a positive integer within the safe range (got "${value}")` + ); + } + return [...previous, { currencyCode: match[1], maxPerTransaction }]; +} + +export function registerAuthCommand( + program: Command, + getClient: (opts: GlobalOptions) => GridClient | null +): void { + const authCmd = program + .command("auth") + .description("Authentication commands (credentials, delegated keys, sessions)"); + + const credentialsCmd = authCmd + .command("credentials") + .description("Authentication credential commands"); + + credentialsCmd + .command("list") + .description("List authentication credentials for an internal account") + .requiredOption("--account-id ", "Internal account ID") + .action(async (options) => { + const opts = program.opts(); + const client = getClient(opts); + if (!client) return; + + const response = await client.get>( + "/auth/credentials", + { accountId: options.accountId } + ); + outputResponse(response); + }); + + addSignedOptions( + credentialsCmd + .command("create") + .description("Create an authentication credential") + .requiredOption("--type ", "Credential type: EMAIL_OTP, SMS_OTP, OAUTH, PASSKEY") + .requiredOption("--account-id ", "Internal account ID") + .option("--oidc-token ", "OIDC ID token (OAUTH)") + .option("--nickname ", "Credential nickname (PASSKEY)") + .option("--challenge ", "Registration challenge (PASSKEY)") + .option("--attestation ", "WebAuthn attestation as JSON (PASSKEY)") + ).action(async (options) => { + const opts = program.opts(); + const client = getClient(opts); + if (!client) return; + + const body: Record = { + type: options.type, + accountId: options.accountId, + }; + if (options.oidcToken) body.oidcToken = options.oidcToken; + if (options.nickname) body.nickname = options.nickname; + if (options.challenge) body.challenge = options.challenge; + if (options.attestation) { + const parsed = parseJson(options.attestation, "attestation"); + if ("error" in parsed) { + output(formatError(parsed.error)); + process.exitCode = 1; + return; + } + body.attestation = parsed.value; + } + + const response = await client.post( + "/auth/credentials", + body, + signedHeaders(options) + ); + outputResponse(response); + }); + + credentialsCmd + .command("challenge ") + .description("Re-issue a credential challenge (e.g. resend an OTP)") + .option( + "--client-public-key ", + "Client P-256 public key — required to re-challenge a PASSKEY credential" + ) + .action(async (credentialId: string, options) => { + const opts = program.opts(); + const client = getClient(opts); + if (!client) return; + + const body = options.clientPublicKey + ? { clientPublicKey: options.clientPublicKey } + : undefined; + + const response = await client.post( + `/auth/credentials/${credentialId}/challenge`, + body + ); + outputResponse(response); + }); + + addSignedOptions( + credentialsCmd + .command("verify ") + .description("Verify an authentication credential") + .requiredOption("--type ", "Credential type: EMAIL_OTP, SMS_OTP, OAUTH, PASSKEY") + .option("--encrypted-otp-bundle ", "HPKE-sealed OTP bundle (EMAIL_OTP/SMS_OTP)") + .option("--oidc-token ", "OIDC ID token (OAUTH)") + .option("--client-public-key ", "Client P-256 public key (OAUTH)") + .option("--assertion ", "WebAuthn assertion as JSON (PASSKEY)") + ).action(async (credentialId: string, options) => { + const opts = program.opts(); + const client = getClient(opts); + if (!client) return; + + const body: Record = { type: options.type }; + if (options.encryptedOtpBundle) + body.encryptedOtpBundle = options.encryptedOtpBundle; + if (options.oidcToken) body.oidcToken = options.oidcToken; + if (options.clientPublicKey) body.clientPublicKey = options.clientPublicKey; + if (options.assertion) { + const parsed = parseJson(options.assertion, "assertion"); + if ("error" in parsed) { + output(formatError(parsed.error)); + process.exitCode = 1; + return; + } + body.assertion = parsed.value; + } + + const response = await client.post( + `/auth/credentials/${credentialId}/verify`, + body, + signedHeaders(options) + ); + outputResponse(response); + }); + + addSignedOptions( + credentialsCmd + .command("revoke ") + .description("Revoke an authentication credential") + .option("-y, --yes", "Skip confirmation prompt") + ).action(async (credentialId: string, options) => { + const opts = program.opts(); + const client = getClient(opts); + if (!client) return; + + if (!options.yes) { + const confirmed = await confirm( + `Revoke credential ${credentialId}? This cannot be undone.` + ); + if (!confirmed) { + console.log("Aborted."); + return; + } + } + + const response = await client.delete( + `/auth/credentials/${credentialId}`, + signedHeaders(options) + ); + outputResponse(response); + }); + + const delegatedKeysCmd = authCmd + .command("delegated-keys") + .description("Delegated signing key commands"); + + delegatedKeysCmd + .command("list") + .description("List delegated signing keys") + .option("--account-id ", "Internal account ID") + .option("--funding-source-id ", "Funding source ID") + .action(async (options) => { + const opts = program.opts(); + const client = getClient(opts); + if (!client) return; + + if (!options.accountId && !options.fundingSourceId) { + output( + formatError("Provide --account-id or --funding-source-id") + ); + process.exitCode = 1; + return; + } + + const response = await client.get>( + "/auth/delegated-keys", + { + accountId: options.accountId, + fundingSourceId: options.fundingSourceId, + } + ); + outputResponse(response); + }); + + delegatedKeysCmd + .command("get ") + .description("Get a delegated signing key") + .action(async (delegatedKeyId: string) => { + const opts = program.opts(); + const client = getClient(opts); + if (!client) return; + + const response = await client.get( + `/auth/delegated-keys/${delegatedKeyId}` + ); + outputResponse(response); + }); + + addSignedOptions( + delegatedKeysCmd + .command("create") + .description("Create a delegated signing key") + .requiredOption("--card-id ", "Card ID") + .requiredOption("--internal-account-id ", "Embedded Wallet internal account ID") + .requiredOption("--nickname ", "Human-readable label for the key") + .option( + "--spending-limit ", + "Per-transaction limit, e.g. USD:5000 (repeatable)", + collectSpendingLimit + ) + ).action(async (options) => { + const opts = program.opts(); + const client = getClient(opts); + if (!client) return; + + const body: Record = { + cardId: options.cardId, + internalAccountId: options.internalAccountId, + nickname: options.nickname, + }; + if (options.spendingLimit) body.spendingLimits = options.spendingLimit; + + const response = await client.post( + "/auth/delegated-keys", + body, + signedHeaders(options) + ); + outputResponse(response); + }); + + delegatedKeysCmd + .command("revoke ") + .description("Revoke a delegated signing key") + .option("-y, --yes", "Skip confirmation prompt") + .action(async (delegatedKeyId: string, options) => { + const opts = program.opts(); + const client = getClient(opts); + if (!client) return; + + if (!options.yes) { + const confirmed = await confirm( + `Revoke delegated key ${delegatedKeyId}? This cannot be undone.` + ); + if (!confirmed) { + console.log("Aborted."); + return; + } + } + + const response = await client.delete( + `/auth/delegated-keys/${delegatedKeyId}` + ); + outputResponse(response); + }); + + const sessionsCmd = authCmd + .command("sessions") + .description("Authentication session commands"); + + sessionsCmd + .command("list") + .description("List active authentication sessions") + .requiredOption("--account-id ", "Internal account ID") + .action(async (options) => { + const opts = program.opts(); + const client = getClient(opts); + if (!client) return; + + const response = await client.get>( + "/auth/sessions", + { accountId: options.accountId } + ); + outputResponse(response); + }); + + addSignedOptions( + sessionsCmd + .command("revoke ") + .description("Revoke an authentication session") + .option("-y, --yes", "Skip confirmation prompt") + ).action(async (sessionId: string, options) => { + const opts = program.opts(); + const client = getClient(opts); + if (!client) return; + + if (!options.yes) { + const confirmed = await confirm( + `Revoke session ${sessionId}? This cannot be undone.` + ); + if (!confirmed) { + console.log("Aborted."); + return; + } + } + + const response = await client.delete( + `/auth/sessions/${sessionId}`, + signedHeaders(options) + ); + outputResponse(response); + }); + + addSignedOptions( + sessionsCmd + .command("refresh ") + .description("Refresh an authentication session") + .requiredOption("--client-public-key ", "Client-generated P-256 public key (uncompressed SEC1 hex)") + ).action(async (sessionId: string, options) => { + const opts = program.opts(); + const client = getClient(opts); + if (!client) return; + + const response = await client.post( + `/auth/sessions/${sessionId}/refresh`, + { clientPublicKey: options.clientPublicKey }, + signedHeaders(options) + ); + outputResponse(response); + }); +} + +function parseJson( + value: string, + flag: string +): { value: unknown } | { error: string } { + try { + return { value: JSON.parse(value) }; + } catch { + return { error: `Invalid JSON for --${flag}` }; + } +} diff --git a/cli/src/commands/cards.ts b/cli/src/commands/cards.ts new file mode 100644 index 000000000..a0ddc537e --- /dev/null +++ b/cli/src/commands/cards.ts @@ -0,0 +1,158 @@ +import { Command } from "commander"; +import { GridClient, PaginatedResponse } from "../client"; +import { outputResponse, formatError, output } from "../output"; +import { GlobalOptions } from "../index"; +import { addSignedOptions, signedHeaders } from "../signed"; +import { parseList } from "../parse"; + +interface Card { + id: string; + cardholderId: string; + platformCardId?: string; + state: "PENDING_KYC" | "PROCESSING" | "ACTIVE" | "FROZEN" | "CLOSED"; + form: "VIRTUAL"; + last4?: string; + fundingSources: string[]; + currency?: string; + createdAt: string; + updatedAt: string; +} + +interface CardRevealResponse { + panEmbedUrl: string; + expiresAt: string; +} + +export function registerCardsCommand( + program: Command, + getClient: (opts: GlobalOptions) => GridClient | null +): void { + const cardsCmd = program.command("cards").description("Card management commands"); + + cardsCmd + .command("list") + .description("List cards") + .option("--cardholder-id ", "Filter by cardholder (customer) ID") + .option("--account-id ", "Filter by a bound funding-source account ID") + .option("--platform-card-id ", "Filter by platform card ID") + .option("--state ", "Filter by state (PENDING_KYC, PROCESSING, ACTIVE, FROZEN, CLOSED)") + .option("-l, --limit ", "Maximum results (default 20, max 100)", "20") + .option("--cursor ", "Pagination cursor") + .option("--sort ", "Sort order: asc or desc") + .action(async (options) => { + const opts = program.opts(); + const client = getClient(opts); + if (!client) return; + + const limit = parseInt(options.limit, 10); + if (Number.isNaN(limit)) { + output(formatError("--limit must be a number")); + process.exitCode = 1; + return; + } + + const params: Record = { + cardholderId: options.cardholderId, + accountId: options.accountId, + platformCardId: options.platformCardId, + state: options.state, + limit, + cursor: options.cursor, + sortOrder: options.sort, + }; + + const response = await client.get>("/cards", params); + outputResponse(response); + }); + + cardsCmd + .command("get ") + .description("Get card details") + .action(async (cardId: string) => { + const opts = program.opts(); + const client = getClient(opts); + if (!client) return; + + const response = await client.get(`/cards/${cardId}`); + outputResponse(response); + }); + + cardsCmd + .command("create") + .description("Issue a card") + .requiredOption("--cardholder-id ", "Cardholder (customer) ID") + .requiredOption("--funding-sources ", "Comma-separated internal account IDs, in priority order") + .option("--form
", "Card form (VIRTUAL)", "VIRTUAL") + .option("--platform-card-id ", "Your platform's identifier for the card") + .action(async (options) => { + const opts = program.opts(); + const client = getClient(opts); + if (!client) return; + + const fundingSources = parseList(options.fundingSources); + if (!fundingSources) { + output(formatError("--funding-sources must list at least one internal account ID")); + process.exitCode = 1; + return; + } + + const body: Record = { + cardholderId: options.cardholderId, + form: options.form, + fundingSources, + }; + if (options.platformCardId) body.platformCardId = options.platformCardId; + + const response = await client.post("/cards", body); + outputResponse(response); + }); + + addSignedOptions( + cardsCmd + .command("update ") + .description("Update a card (freeze/unfreeze, replace funding sources, or close)") + .option("--state ", "Target state: ACTIVE, FROZEN, or CLOSED") + .option("--funding-sources ", "Comma-separated internal account IDs (fully replaces the binding)") + ).action(async (cardId: string, options) => { + const opts = program.opts(); + const client = getClient(opts); + if (!client) return; + + const fundingSources = parseList(options.fundingSources); + if (!options.state && options.fundingSources === undefined) { + output(formatError("Provide --state and/or --funding-sources")); + process.exitCode = 1; + return; + } + if (options.state === "CLOSED" && options.fundingSources !== undefined) { + output(formatError("--state CLOSED cannot be combined with --funding-sources")); + process.exitCode = 1; + return; + } + + const body: Record = {}; + if (options.state) body.state = options.state; + if (fundingSources) body.fundingSources = fundingSources; + + const response = await client.patch( + `/cards/${cardId}`, + body, + signedHeaders(options) + ); + outputResponse(response); + }); + + cardsCmd + .command("reveal ") + .description("Reveal card details — prints a short-lived panEmbedUrl to render in an iframe (do not store it)") + .action(async (cardId: string) => { + const opts = program.opts(); + const client = getClient(opts); + if (!client) return; + + const response = await client.post( + `/cards/${cardId}/reveal` + ); + outputResponse(response); + }); +} diff --git a/cli/src/index.ts b/cli/src/index.ts index d4f40f7e9..8e3d90578 100644 --- a/cli/src/index.ts +++ b/cli/src/index.ts @@ -68,6 +68,8 @@ export async function buildProgram(): Promise { const { registerTransfersCommand } = await import("./commands/transfers"); const { registerSandboxCommand } = await import("./commands/sandbox"); const { registerReceiverCommand } = await import("./commands/receiver"); + const { registerCardsCommand } = await import("./commands/cards"); + const { registerAuthCommand } = await import("./commands/auth"); registerConfigureCommand(program); registerConfigCommand(program, getClient); @@ -78,6 +80,8 @@ export async function buildProgram(): Promise { registerTransfersCommand(program, getClient); registerSandboxCommand(program, getClient); registerReceiverCommand(program, getClient); + registerCardsCommand(program, getClient); + registerAuthCommand(program, getClient); const customersCmd = program.commands.find((c) => c.name() === "customers"); const transactionsCmd = program.commands.find( diff --git a/cli/src/signed.ts b/cli/src/signed.ts new file mode 100644 index 000000000..3aaecffff --- /dev/null +++ b/cli/src/signed.ts @@ -0,0 +1,29 @@ +import { Command } from "commander"; + +/** + * Options for the signed-retry leg of a mutating flow. The CLI does not compute + * the Turnkey/HPKE stamp itself — the caller supplies one (e.g. via + * scripts/embedded-wallet-sign.js) and it is forwarded verbatim as the + * `Grid-Wallet-Signature` / `Request-Id` headers. + */ +export function addSignedOptions(cmd: Command): Command { + return cmd + .option( + "--wallet-signature ", + "Grid-Wallet-Signature header for the signed retry of this operation" + ) + .option( + "--request-id ", + "Request-Id header echoing the challenge's requestId" + ); +} + +export function signedHeaders(options: { + walletSignature?: string; + requestId?: string; +}): Record { + return { + "Grid-Wallet-Signature": options.walletSignature, + "Request-Id": options.requestId, + }; +} diff --git a/cli/test/auth.test.ts b/cli/test/auth.test.ts new file mode 100644 index 000000000..497c8f023 --- /dev/null +++ b/cli/test/auth.test.ts @@ -0,0 +1,228 @@ +import { describe, it, expect } from "vitest"; +import { runCli } from "./helpers"; + +describe("auth credentials", () => { + it("list passes accountId", async () => { + const { request } = await runCli([ + "auth", + "credentials", + "list", + "--account-id", + "InternalAccount:1", + ]); + expect(request?.path).toBe("/grid/v1/auth/credentials"); + expect(request?.query).toMatchObject({ accountId: "InternalAccount:1" }); + }); + + it("create builds an OAuth credential body", async () => { + const { request } = await runCli([ + "auth", + "credentials", + "create", + "--type", + "OAUTH", + "--account-id", + "InternalAccount:1", + "--oidc-token", + "tok123", + ]); + expect(request?.method).toBe("POST"); + expect(request?.path).toBe("/grid/v1/auth/credentials"); + expect(request?.body).toMatchObject({ + type: "OAUTH", + accountId: "InternalAccount:1", + oidcToken: "tok123", + }); + }); + + it("challenge posts an empty body (OTP resend)", async () => { + const { request } = await runCli([ + "auth", + "credentials", + "challenge", + "Credential:1", + ]); + expect(request?.method).toBe("POST"); + expect(request?.path).toBe("/grid/v1/auth/credentials/Credential:1/challenge"); + expect(request?.body).toBeUndefined(); + }); + + it("challenge sends clientPublicKey when provided (passkey re-challenge)", async () => { + const { request } = await runCli([ + "auth", + "credentials", + "challenge", + "Credential:1", + "--client-public-key", + "04abcd", + ]); + expect(request?.body).toMatchObject({ clientPublicKey: "04abcd" }); + }); + + it("verify sends the OTP bundle plus signed-retry headers", async () => { + const { request } = await runCli([ + "auth", + "credentials", + "verify", + "Credential:1", + "--type", + "EMAIL_OTP", + "--encrypted-otp-bundle", + '{"encappedPublic":"04ab","ciphertext":"1fa1"}', + "--wallet-signature", + "stamp", + "--request-id", + "req-1", + ]); + expect(request?.body).toMatchObject({ + type: "EMAIL_OTP", + encryptedOtpBundle: '{"encappedPublic":"04ab","ciphertext":"1fa1"}', + }); + expect(request?.headers["Grid-Wallet-Signature"]).toBe("stamp"); + expect(request?.headers["Request-Id"]).toBe("req-1"); + }); + + it("revoke issues a DELETE with the signed-retry headers", async () => { + const { request } = await runCli([ + "auth", + "credentials", + "revoke", + "Credential:1", + "--yes", + "--wallet-signature", + "stamp", + "--request-id", + "req-1", + ]); + expect(request?.method).toBe("DELETE"); + expect(request?.path).toBe("/grid/v1/auth/credentials/Credential:1"); + expect(request?.headers["Grid-Wallet-Signature"]).toBe("stamp"); + }); +}); + +describe("auth delegated-keys", () => { + it("list requires and passes accountId/fundingSourceId", async () => { + const { request } = await runCli([ + "auth", + "delegated-keys", + "list", + "--account-id", + "InternalAccount:1", + ]); + expect(request?.path).toBe("/grid/v1/auth/delegated-keys"); + expect(request?.query).toMatchObject({ accountId: "InternalAccount:1" }); + }); + + it("list without any filter does not call the API", async () => { + const { calls } = await runCli(["auth", "delegated-keys", "list"]); + expect(calls).toBe(0); + }); + + it("create builds the body with parsed spending limits", async () => { + const { request } = await runCli([ + "auth", + "delegated-keys", + "create", + "--card-id", + "Card:1", + "--internal-account-id", + "InternalAccount:1", + "--nickname", + "Card key", + "--spending-limit", + "USD:5000", + "--spending-limit", + "EUR:4000", + ]); + expect(request?.body).toMatchObject({ + cardId: "Card:1", + internalAccountId: "InternalAccount:1", + nickname: "Card key", + spendingLimits: [ + { currencyCode: "USD", maxPerTransaction: 5000 }, + { currencyCode: "EUR", maxPerTransaction: 4000 }, + ], + }); + }); + + it("revoke issues a plain DELETE (no signature needed)", async () => { + const { request } = await runCli([ + "auth", + "delegated-keys", + "revoke", + "DelegatedKey:1", + "--yes", + ]); + expect(request?.method).toBe("DELETE"); + expect(request?.path).toBe("/grid/v1/auth/delegated-keys/DelegatedKey:1"); + }); + + it("rejects a malformed spending limit before sending", async () => { + await expect( + runCli([ + "auth", + "delegated-keys", + "create", + "--card-id", + "Card:1", + "--internal-account-id", + "InternalAccount:1", + "--nickname", + "Card key", + "--spending-limit", + "USD5000", + ]) + ).rejects.toThrow(); + }); +}); + +describe("auth sessions", () => { + it("list passes accountId", async () => { + const { request } = await runCli([ + "auth", + "sessions", + "list", + "--account-id", + "InternalAccount:1", + ]); + expect(request?.path).toBe("/grid/v1/auth/sessions"); + expect(request?.query).toMatchObject({ accountId: "InternalAccount:1" }); + }); + + it("revoke issues a signed DELETE with confirmation skipped", async () => { + const { request } = await runCli([ + "auth", + "sessions", + "revoke", + "Session:1", + "--yes", + "--wallet-signature", + "stamp", + "--request-id", + "req-1", + ]); + expect(request?.method).toBe("DELETE"); + expect(request?.path).toBe("/grid/v1/auth/sessions/Session:1"); + expect(request?.headers["Grid-Wallet-Signature"]).toBe("stamp"); + expect(request?.headers["Request-Id"]).toBe("req-1"); + }); + + it("refresh sends clientPublicKey and signed-retry headers", async () => { + const { request } = await runCli([ + "auth", + "sessions", + "refresh", + "Session:1", + "--client-public-key", + "04abcd", + "--wallet-signature", + "stamp", + "--request-id", + "req-1", + ]); + expect(request?.method).toBe("POST"); + expect(request?.path).toBe("/grid/v1/auth/sessions/Session:1/refresh"); + expect(request?.body).toMatchObject({ clientPublicKey: "04abcd" }); + expect(request?.headers["Request-Id"]).toBe("req-1"); + }); +}); diff --git a/cli/test/cards.test.ts b/cli/test/cards.test.ts new file mode 100644 index 000000000..114e9435c --- /dev/null +++ b/cli/test/cards.test.ts @@ -0,0 +1,103 @@ +import { describe, it, expect } from "vitest"; +import { runCli } from "./helpers"; + +describe("cards list", () => { + it("passes filters as query params", async () => { + const { request } = await runCli([ + "cards", + "list", + "--cardholder-id", + "Customer:abc", + "--state", + "ACTIVE", + ]); + + expect(request?.path).toBe("/grid/v1/cards"); + expect(request?.query).toMatchObject({ + cardholderId: "Customer:abc", + state: "ACTIVE", + }); + }); +}); + +describe("cards create", () => { + it("builds the create body with funding sources and default form", async () => { + const { request } = await runCli([ + "cards", + "create", + "--cardholder-id", + "Customer:abc", + "--funding-sources", + "InternalAccount:1,InternalAccount:2", + ]); + + expect(request?.method).toBe("POST"); + expect(request?.path).toBe("/grid/v1/cards"); + expect(request?.body).toMatchObject({ + cardholderId: "Customer:abc", + form: "VIRTUAL", + fundingSources: ["InternalAccount:1", "InternalAccount:2"], + }); + }); +}); + +describe("cards update", () => { + it("freezes a card via state", async () => { + const { request } = await runCli([ + "cards", + "update", + "Card:1", + "--state", + "FROZEN", + ]); + + expect(request?.method).toBe("PATCH"); + expect(request?.path).toBe("/grid/v1/cards/Card:1"); + expect(request?.body).toMatchObject({ state: "FROZEN" }); + }); + + it("forwards a supplied wallet signature and request id as headers", async () => { + const { request } = await runCli([ + "cards", + "update", + "Card:1", + "--state", + "CLOSED", + "--wallet-signature", + "stamp123", + "--request-id", + "req-1", + ]); + + expect(request?.headers["Grid-Wallet-Signature"]).toBe("stamp123"); + expect(request?.headers["Request-Id"]).toBe("req-1"); + }); + + it("rejects an update with no state or funding sources", async () => { + const { calls } = await runCli(["cards", "update", "Card:1"]); + expect(calls).toBe(0); + }); + + it("rejects CLOSED combined with funding sources", async () => { + const { calls } = await runCli([ + "cards", + "update", + "Card:1", + "--state", + "CLOSED", + "--funding-sources", + "InternalAccount:1", + ]); + expect(calls).toBe(0); + }); +}); + +describe("cards reveal", () => { + it("posts to the reveal endpoint with no body", async () => { + const { request } = await runCli(["cards", "reveal", "Card:1"]); + + expect(request?.method).toBe("POST"); + expect(request?.path).toBe("/grid/v1/cards/Card:1/reveal"); + expect(request?.body).toBeUndefined(); + }); +}); diff --git a/docs/plans/2026-07-19-grid-cli-cards-auth.md b/docs/plans/2026-07-19-grid-cli-cards-auth.md deleted file mode 100644 index ecfa9beb1..000000000 --- a/docs/plans/2026-07-19-grid-cli-cards-auth.md +++ /dev/null @@ -1,70 +0,0 @@ -# Add cards and auth domains to the Grid CLI - -## Context -Follow-up to the CLI drift work (#702) and contact verification (#703). The requester asked to "tackle cards and auth next" — two of the whole-domain gaps from the audit. This adds `grid cards …` and `grid auth …` commands, stacked on the existing branches (shared test harness, no index.ts conflicts). - -## Approach -Two stacked PRs on top of #703: -1. **cards** (`cli/src/commands/cards.ts`) — full surface (5 commands). -2. **auth** (`cli/src/commands/auth.ts`) — a **read-focused MVP** (6 commands). - -Both register in `cli/src/index.ts` and follow the exact existing command pattern (`registerXCommand`, commander options, `client.get/post/patch/delete`, `outputResponse`) with vitest boundary tests (stub `fetch`, assert the request) like the rest of the suite. - -### Key scope decision — auth -Every mutating auth operation **except two** is a signed-retry flow: the first unsigned call returns `202` with a `payloadToSign`, and completion requires a `Grid-Wallet-Signature` header (a Turnkey API-key stamp built from a client-held P-256 key) + `Request-Id`. The CLI can neither build those stamps (that lives in `scripts/embedded-wallet-sign.js` via `@turnkey/*` + HPKE) nor send the headers (`client.ts` has no per-call header support). Passkey flows additionally need WebAuthn. So the auth MVP ships only what genuinely completes from a flag CLI: - -**Auth MVP (SUPPORT):** `credentials list`, `credentials challenge ` (OTP resend — plain unsigned POST), `delegated-keys list`, `delegated-keys get `, `delegated-keys revoke ` (the one revoke Grid signs with its own custodied key → single unsigned DELETE), `sessions list`. - -**Deferred to a Phase 2 (OUT OF SCOPE here):** `credentials create/revoke/verify`, `delegated-keys create`, `sessions revoke/refresh`. These need (a) `client.ts` extended to send `Grid-Wallet-Signature`/`Request-Id` headers and (b) a Turnkey/HPKE signing helper. Called out in the PR; not built now. - -Cards has one analogous limitation: `cards update` uses the same 202 signed-retry pattern, so the unsigned PATCH surfaces the challenge JSON (same behavior as the existing `quotes execute` / `accounts export`). We still ship `cards update` (freeze/unfreeze/replace-sources/close all express through `state`/`fundingSources`) and note the gap. - -## Changes - -### PR 1 — cards - -#### 1. `cli/src/commands/cards.ts` (new) -- **What**: `registerCardsCommand(program, getClient)` with: - - `cards list` → GET `/cards`; filters `--cardholder-id`, `--account-id`, `--platform-card-id`, `--state`, `-l/--limit`, `--cursor`, `--sort` (→ `sortOrder`); `PaginatedResponse`. - - `cards get ` → GET `/cards/{id}`. - - `cards create` → POST `/cards`; required `--cardholder-id`, `--funding-sources` (comma list → `fundingSources[]`); `--form` (default `VIRTUAL`), `--platform-card-id`. - - `cards update ` → PATCH `/cards/{id}`; `--state` (ACTIVE|FROZEN|CLOSED), `--funding-sources`. Validate ≥1 provided and reject `--state CLOSED` + `--funding-sources` together. - - `cards reveal ` → POST `/cards/{id}/reveal` (no body); prints the short-lived `panEmbedUrl` (description notes it's a bearer secret to render in an iframe, not stored). No `delete` subcommand (close = `update --state CLOSED`). -- **Why**: cards is a full new product area with no CLI coverage. - -#### 2. `cli/src/index.ts` -- **What**: dynamic-import + register `registerCardsCommand` alongside the others. - -#### 3. `cli/test/cards.test.ts` (new) -- Boundary tests: create body shape, list query params, update variants (freeze/replace/close), update validation guard, reveal path. - -### PR 2 — auth (stacked on cards) - -#### 4. `cli/src/commands/auth.ts` (new) -- **What**: `registerAuthCommand` with nested groups: - - `auth credentials list` → GET `/auth/credentials`, required `--account-id`. - - `auth credentials challenge ` → POST `/auth/credentials/{id}/challenge` (empty body; OTP resend). - - `auth delegated-keys list` → GET `/auth/delegated-keys`; `--account-id` / `--funding-source-id` (≥1 required). - - `auth delegated-keys get ` → GET `/auth/delegated-keys/{id}`. - - `auth delegated-keys revoke ` → DELETE `/auth/delegated-keys/{id}` with `-y/--yes` + confirm. - - `auth sessions list` → GET `/auth/sessions`, required `--account-id`. - - List responses are `{ data: T[] }` (not paginated) — declare a small `AuthListResponse`. -- **Why**: gives the full auth read surface + the two genuinely-completable mutations. - -#### 5. `cli/src/index.ts` -- **What**: register `registerAuthCommand`. - -#### 6. `cli/test/auth.test.ts` (new) -- Boundary tests for each of the 6 commands (method/path/query/body). - -#### 7. `cli/README.md` -- **What**: document the new `cards` and `auth` command groups, incl. the reveal/update and auth-MVP limitations. - -## Verification -- [ ] `cd cli && npm run build` — clean strict compile. -- [ ] `npm test` — new cards + auth boundary tests pass alongside the existing 34. -- [ ] Spot-check a built request body against the corresponding `openapi/components/schemas/{cards,auth}/**` schema. - -## Risks -- Auth MVP intentionally omits the signed-retry/crypto commands. If the requester wants those, it's a larger Phase 2 (client header support + Turnkey/HPKE signing) — flagged, not built. -- `cards update` / signed ops surface the 202 challenge rather than the final object — consistent with existing CLI behavior; documented.