From 6a7ef51225466434471459ead3b8d7dd86bb5b13 Mon Sep 17 00:00:00 2001 From: Jeremy Date: Sat, 18 Jul 2026 23:27:22 +0000 Subject: [PATCH 1/2] plan: fix drift on existing Grid CLI commands --- docs/plans/2026-07-18-grid-cli-drift-fixes.md | 93 +++++++++++++++++++ 1 file changed, 93 insertions(+) create mode 100644 docs/plans/2026-07-18-grid-cli-drift-fixes.md diff --git a/docs/plans/2026-07-18-grid-cli-drift-fixes.md b/docs/plans/2026-07-18-grid-cli-drift-fixes.md new file mode 100644 index 000000000..4026ebb44 --- /dev/null +++ b/docs/plans/2026-07-18-grid-cli-drift-fixes.md @@ -0,0 +1,93 @@ +# Fix drift on existing Grid CLI commands + +## Context +The hand-written Grid CLI (`cli/`) has drifted from the current OpenAPI spec (`2025-10-13`). This first pass fixes the commands the CLI **already has** so they produce valid requests against today's API — starting point requested in the originating Slack thread. New endpoints/domains (agents, cards, sca, auth, tokens, etc.) are explicitly **out of scope** here. + +A field-by-field reconciliation of every implemented command against `openapi/components/schemas/**` surfaced five commands that build **invalid requests today** (real bugs), plus a set of missing/renamed fields and stale response types. Base URL, auth, error parsing, and pagination envelope are already correct — no change needed there. + +## Approach +One PR, scoped to correctness of existing commands. Group work as: +1. **Breaking-bug fixes** — commands that currently send invalid bodies/discriminators. +2. **Field drift** — add/rename fields the current schema requires or supports on those same commands (high-value, correctness-adjacent). +3. **Stale response interfaces** — refresh the TS types the CLI declares for responses (output is JSON passthrough, so these are non-breaking but misleading and can break `table` mode / typed access). +4. **Test harness + tests** — the CLI has *no* tests today. Add `vitest` and boundary tests that mock `global.fetch` and assert the constructed request (method/path/body) for each fixed command. This is how we prove "works correctly." + +Deferred to explicit follow-ups (noted in PR, not done here): expanding external-account create to all ~37 currency variants + `SWIFT/LIGHTNING/ETHEREUM`, embedded-wallet `Grid-Wallet-Signature` signing, `AgentAuth` bearer, and net-new subcommands (quotes authorize/resend, transactions confirm, customers verify/trust/bulk, accounts get/update/export). These are "new surface," not drift on existing commands. + +### Test-boundary decision +The stable boundary for a CLI is: parse argv → build request → call HTTP. Tests drive `program.parseAsync(["node","grid", ...args])` with `global.fetch` stubbed and creds supplied via `GRID_API_TOKEN_ID`/`GRID_API_CLIENT_SECRET` env + `--base-url`, then assert on the captured `fetch(url, init)`. No internal/private functions touched — survives refactor. + +## Changes + +### 1. `cli/src/commands/customers.ts` +- **What**: + - `update`: **send `customerType`** (required oneOf discriminator — updates are rejected without it). Add `--type` option; route individual vs business fields under the correct variant. (BUG) + - `create` business: send required **`incorporatedOn`** (add `--incorporated-on`); currently every business create is rejected. (BUG) + - Add missing create/update fields: top-level `email`, `phoneNumber`, `region`, `currencies`; individual `nationality`, `idType`+`identifier`; business KYB `entityType`, `businessType`, `doingBusinessAs`, `country`, `purposeOfAccount`, `naicsCode`, `sourceOfFundsCategories`, `sourceOfFunds`, `expectedCounterpartyCountries`, `expectedMonthlyTransactionCount`, `expectedMonthlyTransactionVolume`; address `line2`. + - `list`: add `--region`, `--currency`, `--created-after/before`, `--updated-after/before`, `--include-deleted`. + - Only emit the `address` object when required members (`line1`, `postalCode`, `country`) are present. +- **Why**: `update`/`business create` are broken; the rest are schema-supported fields with no CLI surface. +- **Code sketch**: + ```ts + // update: discriminator first + const body: Record = { customerType: options.type }; + if (options.type === "INDIVIDUAL") { /* fullName, birthDate, nationality, address */ } + else if (options.type === "BUSINESS") { body.businessInfo = { legalName, taxId, incorporatedOn, ... }; } + ``` + +### 2. `cli/src/commands/quotes.ts` +- **What**: + - Move `purposeOfPayment` from `senderCustomerInfo.PURPOSE_OF_PAYMENT` to the **top-level** `purposeOfPayment` field. (BUG — currently mis-placed) + - Add `--remittance-information` (top-level, maxLength 80). + - Add `--payment-rail` on ACCOUNT destination; add `--source-customer` support on ACCOUNT source (`customerId`); add `--crypto-network` on REALTIME_FUNDING source (required for stablecoin funding). + - Refresh the `Quote` response interface to current `Quote.yaml` (`totalSendingAmount`/`totalReceivingAmount`/`feesIncluded`, currency objects, `transactionId`, `PENDING_AUTHORIZATION`). +- **Why**: purposeOfPayment silently ignored today; other fields are supported and needed for common flows (remittance, stablecoin funding, rail selection). + +### 3. `cli/src/commands/transfers.ts` +- **What**: add `--remittance-information` and destination `--payment-rail` to `transfers out` (both on `TransferOutRequest`/`ExternalAccountDestinationReference`). `transfers in` is already correct — no change. +- **Why**: schema-supported top-level fields with no CLI surface. + +### 4. `cli/src/commands/sandbox.ts` +- **What** (BUG): `sandbox receive` — rename body fields `amount`→`receivingCurrencyAmount`, `currency`→`receivingCurrencyCode`; make `senderUmaAddress` **required** (schema requires it); add `--customer-id` as an alternative to `--uma-address` (`receiverUmaAddress`). `sandbox send`/`fund` are correct — no change. +- **Why**: `sandbox receive` currently sends two fields that don't exist in the schema and omits a required one — it cannot produce a valid request. + +### 5. `cli/src/commands/config.ts` +- **What**: `config update` — add `--supported-currencies` (JSON array of `PlatformCurrencyConfig`) and `--embedded-wallet-config` (JSON object) inputs (accept JSON strings; validate parse). Refresh the `PlatformConfig` interface to match `PlatformConfig.yaml`. +- **Why**: the two core mutable config surfaces (per-currency limits/txn types, embedded-wallet branding/OTP) have no CLI flag today. + +### 6. `cli/src/commands/accounts.ts` +- **What**: + - `external create` (BUG): replace legacy `--account-type` discriminator values with the current currency-suffixed enum — `US_ACCOUNT→USD_ACCOUNT`, `CLABE→MXN_ACCOUNT`, `PIX→BRL_ACCOUNT`, `IBAN→EUR_ACCOUNT`, `UPI→INR_ACCOUNT` (wallet types already correct). Drop the nonexistent NGN `purposeOfPayment` field; fix US `accountCategory`→`bankAccountType` (`CHECKING`/`SAVINGS`). Make `beneficiary` **required** for fiat create; add beneficiary `email`/`phoneNumber`/`countryOfResidence`, business `registrationNumber`/`taxId`, and address `line2`. Add `--swift-code` (EUR), INR non-UPI (`accountNumber`/`ifsc`/`rail`/`bankName`), and top-level `--platform-account-id`/`--default-uma-deposit-account`. + - `internal list`: add `--type` (`InternalAccountType`); for `--platform`, send only `currency`+`type` (stop sending `limit`/`cursor`/`customerId`, unsupported there). + - `external list`: for `--platform`, stop sending `customerId` (unsupported). + - Refresh `InternalAccount`/`ExternalAccount` interfaces to current schema. +- **Why**: 5 of the advertised fiat account types are outright rejected today (dead discriminators); fiat create omits a required `beneficiary`; platform list sends unsupported params. + +### 7. `cli/src/commands/transactions.ts` + `cli/src/commands/receiver.ts` +- **What**: + - `transactions list`: add `--account-identifier`. `approve`: add optional `--receiver-customer-info` (JSON). Refresh the `Transaction` interface (drop `amount`/`currency`/`sender…`/`receiver…`/`reference`; model `source`/`destination` incl. `onChainTransaction`, `receivedAmount`/`sentAmount`, `direction`, `CARD` variant). + - `receiver lookup-account`: add `--sender-uma`/`--customer-id` query params. Refresh `ReceiverLookup` interface (`lookupId`, `receiverUmaAddress`/`accountId`, `supportedCurrencies` with nested `currency`+`estimatedExchangeRate`+`min`/`max`, `requiredPayerDataFields`). +- **Why**: missing supported params; response interfaces are badly stale (wrong field names + currency shape), which misleads and drops fields in `table` mode. + +### 8. `cli/src/validation.ts` +- **What**: remove the hardcoded 13-currency `VALID_CURRENCIES` allowlist (rejects ~24 currencies the API now supports); let the API validate currency codes, keeping only format checks. Remove now-dead `validateCustomerType` if unused after wiring, else keep. +- **Why**: client-side allowlist silently rejects valid, currently-supported currencies — a drift trap. + +### 9. `cli/src/client.ts` +- **What**: map `error.details` to the schema's inner `details` object rather than the whole response body. +- **Why**: faithful mapping of the error schema (`{status, code, message, details}`). + +### 10. Test harness — `cli/package.json`, `cli/vitest.config.ts` (new), `cli/test/**` (new) +- **What**: add `vitest` devDep + `"test": "vitest run"` script. Add a `runCli(args)` helper that stubs `global.fetch`, sets creds via env, runs `program.parseAsync`, and returns the captured request. One test file per fixed command asserting method/path/body — explicitly covering each BUG fix (customerType on update, incorporatedOn, purposeOfPayment placement, sandbox-receive field names, account-type discriminators, beneficiary required). +- **Why**: no tests exist; TDD requires a failing test first, and these lock the drift fixes. + +## Verification +- [ ] `cd cli && npm install && npm run build` — clean TypeScript compile (strict mode). +- [ ] `npm test` — all new boundary tests pass; each BUG fix has a red→green test. +- [ ] Spot-check a captured request body for `quotes create`, `customers update`, `sandbox receive`, `accounts external create` matches the corresponding `openapi/components/schemas/**` request schema. +- [ ] `grid --help` and per-command `--help` render (help text updated for renamed options). + +## Risks +- **Renamed options are user-facing breaking changes** (e.g. `--account-type US_ACCOUNT`→`USD_ACCOUNT`). Given these values are *already broken* against the API, changing them is a fix, not a regression — will call out in the PR/README and CLI version bump. +- Scope: exhaustive currency coverage and net-new subcommands are deferred; if the reviewer wants them folded in, that expands the PR substantially. +- `config update`/`approve` taking JSON-string inputs for nested objects is a pragmatic choice for a flag-based CLI; alternative is many granular flags. Noted for redirect. From 03644996063f8a0c06827c22c58e00cc168bdd92 Mon Sep 17 00:00:00 2001 From: Jeremy Date: Sat, 18 Jul 2026 23:48:09 +0000 Subject: [PATCH 2/2] feat(cli): fix drift on existing Grid CLI commands Reconcile every implemented command against the current 2025-10-13 schema. Bug fixes (commands that built invalid requests): - customers update: send required customerType discriminator; route individual/business fields under the correct variant - customers create (business): send required incorporatedOn - quotes create: move purposeOfPayment to the top-level field (was buried in senderCustomerInfo and ignored) - sandbox receive: use receivingCurrencyAmount/receivingCurrencyCode and require senderUmaAddress; add --customer-id - accounts external create: use currency-suffixed *_ACCOUNT discriminators, drop nonexistent NGN purposeOfPayment / US accountCategory, require beneficiary for fiat accounts Field/param drift added: - quotes create: remittanceInformation, paymentRail, cryptoNetwork, FBO customerId, scaFactor; quotes execute: scaFactor - transfers out: remittanceInformation, destination paymentRail - customers: KYB fields, contact fields, nationality, id fields, list filters - config update: supportedCurrencies, embeddedWalletConfig - accounts: internal list --type, platform-endpoint param trimming, external create swift/ifsc/rail, platformAccountId, defaultUmaDepositAccount - transactions list --account-identifier; approve receiverCustomerInfo - receiver lookup-account sender-uma/customer-id Also: relax client-side currency allowlist to a format check, map error.details to the schema's inner details object, refresh stale response interfaces, and add a vitest boundary-test harness (25 tests) covering the fixes. --- cli/README.md | 22 +- cli/package-lock.json | 1444 ++++++++++++++++- cli/package.json | 6 +- cli/src/client.ts | 8 +- cli/src/commands/accounts.ts | 286 +++- cli/src/commands/config.ts | 55 +- cli/src/commands/customers.ts | 360 +++- cli/src/commands/quotes.ts | 96 +- cli/src/commands/receiver.ts | 29 +- cli/src/commands/sandbox.ts | 29 +- cli/src/commands/transactions.ts | 43 +- cli/src/commands/transfers.ts | 9 +- cli/src/index.ts | 71 +- cli/src/parse.ts | 12 + cli/src/validation.ts | 14 +- cli/test/accounts.test.ts | 271 ++++ cli/test/client.test.ts | 23 + cli/test/customers.test.ts | 157 ++ cli/test/helpers.ts | 107 ++ cli/test/quotes.test.ts | 134 ++ cli/test/sandbox.test.ts | 70 + cli/test/transactions.test.ts | 57 + cli/test/transfers.test.ts | 24 + cli/vitest.config.ts | 8 + docs/plans/2026-07-18-grid-cli-drift-fixes.md | 93 -- 25 files changed, 3050 insertions(+), 378 deletions(-) create mode 100644 cli/src/parse.ts create mode 100644 cli/test/accounts.test.ts create mode 100644 cli/test/client.test.ts create mode 100644 cli/test/customers.test.ts create mode 100644 cli/test/helpers.ts create mode 100644 cli/test/quotes.test.ts create mode 100644 cli/test/sandbox.test.ts create mode 100644 cli/test/transactions.test.ts create mode 100644 cli/test/transfers.test.ts create mode 100644 cli/vitest.config.ts delete mode 100644 docs/plans/2026-07-18-grid-cli-drift-fixes.md diff --git a/cli/README.md b/cli/README.md index fd742cad4..7f9c27ec4 100644 --- a/cli/README.md +++ b/cli/README.md @@ -143,14 +143,16 @@ grid accounts internal list --platform # List external accounts grid accounts external list [--customer-id ] +# The --account-type value is the API discriminator (currency-suffixed *_ACCOUNT +# or a wallet type). A beneficiary is required for every fiat account type. + # Create US bank account grid accounts external create \ --customer-id \ --currency USD \ - --account-type US_ACCOUNT \ + --account-type USD_ACCOUNT \ --account-number "123456789" \ --routing-number "021000021" \ - --account-category CHECKING \ --beneficiary-type INDIVIDUAL \ --beneficiary-name "John Doe" @@ -158,7 +160,7 @@ grid accounts external create \ grid accounts external create \ --customer-id \ --currency MXN \ - --account-type CLABE \ + --account-type MXN_ACCOUNT \ --clabe "012345678901234567" \ --beneficiary-type INDIVIDUAL \ --beneficiary-name "Carlos Garcia" \ @@ -169,7 +171,7 @@ grid accounts external create \ grid accounts external create \ --customer-id \ --currency INR \ - --account-type UPI \ + --account-type INR_ACCOUNT \ --upi-id "name@okaxis" \ --beneficiary-type INDIVIDUAL \ --beneficiary-name "Rajesh Kumar" \ @@ -180,8 +182,9 @@ grid accounts external create \ grid accounts external create \ --customer-id \ --currency BRL \ - --account-type PIX \ + --account-type BRL_ACCOUNT \ --pix-key "12345678901" \ + --pix-key-type CPF \ --beneficiary-type INDIVIDUAL \ --beneficiary-name "Maria Silva" \ --beneficiary-birth-date "1990-05-10" \ @@ -194,7 +197,6 @@ grid accounts external create \ --account-type NGN_ACCOUNT \ --account-number "1234567890" \ --bank-name "First Bank" \ - --purpose GOODS_OR_SERVICES \ --beneficiary-type INDIVIDUAL \ --beneficiary-name "Chidi Okonkwo" \ --beneficiary-birth-date "1992-08-20" \ @@ -204,7 +206,7 @@ grid accounts external create \ grid accounts external create \ --customer-id \ --currency EUR \ - --account-type IBAN \ + --account-type EUR_ACCOUNT \ --iban "DE89370400440532013000" \ --beneficiary-type INDIVIDUAL \ --beneficiary-name "Hans Mueller" @@ -302,8 +304,10 @@ grid sandbox fund --amount 100000 # Simulate sending funds to a JIT quote grid sandbox send --quote-id --currency USDC -# Simulate receiving an UMA payment +# Simulate receiving an UMA payment (--sender-uma is required; identify the +# receiver with either --uma-address or --customer-id) grid sandbox receive \ + --sender-uma '$sender@sandbox.domain.com' \ --uma-address '$user@domain.com' \ --amount 1000 \ --currency USD @@ -341,7 +345,7 @@ On error: grid accounts external create \ --customer-id \ --currency MXN \ - --account-type CLABE \ + --account-type MXN_ACCOUNT \ --clabe "012345678901234567" \ --beneficiary-type INDIVIDUAL \ --beneficiary-name "Carlos Garcia" \ diff --git a/cli/package-lock.json b/cli/package-lock.json index 0ec237b50..cf29c72c4 100644 --- a/cli/package-lock.json +++ b/cli/package-lock.json @@ -17,7 +17,11 @@ "devDependencies": { "@types/node": "^20.0.0", "ts-node": "^10.9.2", - "typescript": "^5.4.0" + "typescript": "^5.4.0", + "vitest": "^2.1.9" + }, + "engines": { + "node": ">=18.0.0" } }, "node_modules/@cspotcode/source-map-support": { @@ -33,6 +37,397 @@ "node": ">=12" } }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", + "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.21.5.tgz", + "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz", + "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.21.5.tgz", + "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz", + "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz", + "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz", + "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz", + "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz", + "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz", + "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz", + "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz", + "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz", + "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz", + "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz", + "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz", + "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz", + "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz", + "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz", + "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz", + "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz", + "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz", + "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz", + "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, "node_modules/@jridgewell/resolve-uri": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", @@ -61,6 +456,395 @@ "@jridgewell/sourcemap-codec": "^1.4.10" } }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.62.2.tgz", + "integrity": "sha512-6o7ZLZK+BeenkZCFNDXqpbjw9bD6nuWonvS/lwQJp7NoVVxm6p3qE7qQ5jGuBjiFsgvqjD8mZAU5oWxTmbOeOg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.62.2.tgz", + "integrity": "sha512-BaH7BllCACHoH1LguOU56UItGfUWjujlO65kS9LAodViaN4bwIKd7oeW/ZHJ/4ljr/7MIiENnNy3HJ0zXv8Zkw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.62.2.tgz", + "integrity": "sha512-v39RCCvj4He82I9sFmk+M1VZ0PLM9sfsLVikjfx2hYBNALhrrOR2D3JjQA6AhlaSOgcR+RzrKY7e1+bT6SUO/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.62.2.tgz", + "integrity": "sha512-yl0y2vq3S3lHeuXhEdss6TWfKW8vkujImO12tn4ZkG/4oghr09LvdYm2RElVjokTQiUvDUGXLGsYeLqUMCKpGA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.62.2.tgz", + "integrity": "sha512-tT4pvt4qXD+vEoezupCWi+a1F0vvDiksiHc+PxRlYTOH1I6/X4id9jPxTP+Fg+545euaFT1jJVs4CEdHZAU1vw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.62.2.tgz", + "integrity": "sha512-6nU5F2wCW+qvCBhTn1pdIU3bzsIoF7EUwsCDRxilWGprQR6yd508YnH9+OKFCwpfS8pjZqDUmnCAr7exax0XCg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.62.2.tgz", + "integrity": "sha512-n1GJHPOvpIfhi3TmrCeh6S6URt9BFCt0KQE3qvexyGCTAKpR4Lg+eWvNZEqu7epxwus/8ElT3hacYEucm49SZg==", + "cpu": [ + "arm" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.62.2.tgz", + "integrity": "sha512-JqgflS8wEB+UXV/vS1RpRbifGBeN4D5lz8D8oOFbFZw4vedvdOgCFAjfBmIMdW3yL10XpQQ0Ambepw6MXrhOnA==", + "cpu": [ + "arm" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.62.2.tgz", + "integrity": "sha512-wnFJkogWvN4jm/hQRF2UBaeUmk20j5+DmHvoyWii2b8HJDyvz1MF2OU/6ynXt2KR63rbZLWkFpoytpdc/yBuSA==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.62.2.tgz", + "integrity": "sha512-HVu2bp0zhvJ8xHEV9+UUs7S90VadmBSY3LcIMvozbPo4AuMGDWlz3ymHLHZPX4hR67TKTt8Qp5PJ5RBg/i+RMQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.62.2.tgz", + "integrity": "sha512-mQqqAV8QaoSgr9I2fKDLY2BAVvmKjWoGiu/cSYQonsLvtqwEn1E4QYfnCOcp5zoEqNhsDYin1s6jx/VJmrxlZg==", + "cpu": [ + "loong64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.62.2.tgz", + "integrity": "sha512-IxKLoxCQ2IWi6bT2akyDUBGsOImDKB+sPp4EsTmwFQ/fMwpCKm8uLSSgP/Kx/QYUgKis6SEZ5/Nlhup0DIA0PQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.62.2.tgz", + "integrity": "sha512-Mk5ha2RQSgyFfmYYLkBpPnUk8D8FriBxesO1u9O75X0mHgXL1UQcH5Itl2lurWL2tj0RxV9b9tJgipac0hRY9A==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.62.2.tgz", + "integrity": "sha512-CjvEnqJL/0/TQ3TXX3OPIJ/kmBellrWd4heXUmHeJlTnmwjKpSJzoehLaL6Xk0ZnMHBu9dZuFADNOrtjF4v+2w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.62.2.tgz", + "integrity": "sha512-1SiZbzwdkaDURsew/tSOrooKiYy7EQGT6m8ufavAi9NEyQb/6VuIxFXAL1fqa4iZe3g4NbNk4P7J32z2tw5Mgg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.62.2.tgz", + "integrity": "sha512-nQts12zJ3NQRoE6uYljOH89v7szzLDvG2JD/vsX+vGXU8w/At1GowTZ5/7qeFQ8m7L55rpR8Okugnuo5bgjy2Q==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.62.2.tgz", + "integrity": "sha512-E9/ll019jhPIJgpzfZoIkBGhcz+kKNgVWYRY0zr9srBdPPFVpvOKW8VaJKUbeK+eZXyQF9ltME+Kk6affeaPgg==", + "cpu": [ + "s390x" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.62.2.tgz", + "integrity": "sha512-5BqxR/pshjey51iliyzTD5Xi3EN0aLmQ2lZ3lvefVV9c82BvrLo2/6OT55iifpWBufs6kdwWbuOKS841DrmK9A==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.62.2.tgz", + "integrity": "sha512-uNN83XxQrRAh/w0/pmAfibcwyb6YWt4gP+dpnQKPVJshAloQ785ii8CT8ZCIxkGg9opVsvAlGhFitSm6D1Jjpg==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.62.2.tgz", + "integrity": "sha512-srjEIxSH3LRnJN6THczDHWQplqEMFiAJrTab0msUryh9kwNpkICf3Ea6q6MN/2cZwRFUNx5w+h6Hpi4QuHS6Zg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.62.2.tgz", + "integrity": "sha512-8hOJnxgbyObnCm5AlRA3A931xX19xq80RjVTKgJOvEKWqJruP/Uf12IbAOaDjjEXYRewwHLfmF0YRIdK3OwKWA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.62.2.tgz", + "integrity": "sha512-mmF4AY1i0hG/bLWUctUq59gtmgaSIRa3cu/A3JFRp/sCNEme2bgDEiDS22P9FbnJB8NJNF4jPJiSP5RHQpUTDg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.62.2.tgz", + "integrity": "sha512-DZgkknc6jhHrk46V25vbAM0zZkyP0nSDkJB8/dRkLTxv470dOmWDqGoEJl/9A0dFfS7yE3REOwNDxpHwSLSt0Q==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.62.2.tgz", + "integrity": "sha512-T6xr6ucWSFto+VGajA8YH26LdpHRuP4YLHEKAtCWvJDOlnmWcDZVCI2Jmjr+IFHDlt2zRaTAKE4tfjTaWLgJBg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.62.2.tgz", + "integrity": "sha512-BfzEnDJOt9T8M989/lA37EcJgat01wLRnoi5dQf3QzOH7jzpqTAzdDbVfRljVr5r+jzKqpbHeyOfAaXxAd0PAA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, "node_modules/@tsconfig/node10": { "version": "1.0.12", "resolved": "https://registry.npmjs.org/@tsconfig/node10/-/node10-1.0.12.tgz", @@ -89,6 +873,13 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/node": { "version": "20.19.30", "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.30.tgz", @@ -99,6 +890,119 @@ "undici-types": "~6.21.0" } }, + "node_modules/@vitest/expect": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-2.1.9.tgz", + "integrity": "sha512-UJCIkTBenHeKT1TTlKMJWy1laZewsRIzYighyYiJKZreqtdxSos/S1t+ktRMQWu2CKqaarrkeszJx1cgC5tGZw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "2.1.9", + "@vitest/utils": "2.1.9", + "chai": "^5.1.2", + "tinyrainbow": "^1.2.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-2.1.9.tgz", + "integrity": "sha512-tVL6uJgoUdi6icpxmdrn5YNo3g3Dxv+IHJBr0GXHaEdTcw3F+cPKnsXFhli6nO+f/6SDKPHEK1UN+k+TQv0Ehg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "2.1.9", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.12" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^5.0.0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/pretty-format": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-2.1.9.tgz", + "integrity": "sha512-KhRIdGV2U9HOUzxfiHmY8IFHTdqtOhIzCpd8WRdJiE7D/HUcZVD0EgQCVjm+Q9gkUXWgBvMmTtZgIG48wq7sOQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^1.2.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-2.1.9.tgz", + "integrity": "sha512-ZXSSqTFIrzduD63btIfEyOmNcBmQvgOVsPNPe0jYtESiXkhd8u2erDLnMxmGrDCwHCCHE7hxwRDCT3pt0esT4g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "2.1.9", + "pathe": "^1.1.2" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-2.1.9.tgz", + "integrity": "sha512-oBO82rEjsxLNJincVhLhaxxZdEtV0EFHMK5Kmx5sJ6H9L183dHECjiefOAdnqpIgT5eZwT04PoggUnW88vOBNQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "2.1.9", + "magic-string": "^0.30.12", + "pathe": "^1.1.2" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-2.1.9.tgz", + "integrity": "sha512-E1B35FwzXXTs9FHNK6bDszs7mtydNi5MIfUWpceJ8Xbfb1gBMscAnwLbEu+B44ed6W3XjL9/ehLPHR1fkf1KLQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyspy": "^3.0.2" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-2.1.9.tgz", + "integrity": "sha512-v0psaMSkNJ3A2NMrUEHFRzJtDPFn+/VWZ5WxImB21T9fjucJRmS7xCS3ppEnARb9y11OAzaD+P2Ps+b+BGX5iQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "2.1.9", + "loupe": "^3.1.2", + "tinyrainbow": "^1.2.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, "node_modules/acorn": { "version": "8.15.0", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", @@ -132,6 +1036,53 @@ "dev": true, "license": "MIT" }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "node_modules/cac": { + "version": "6.7.14", + "resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz", + "integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/chai": { + "version": "5.3.3", + "resolved": "https://registry.npmjs.org/chai/-/chai-5.3.3.tgz", + "integrity": "sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "assertion-error": "^2.0.1", + "check-error": "^2.1.1", + "deep-eql": "^5.0.1", + "loupe": "^3.1.0", + "pathval": "^2.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/check-error": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/check-error/-/check-error-2.1.3.tgz", + "integrity": "sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 16" + } + }, "node_modules/commander": { "version": "12.1.0", "resolved": "https://registry.npmjs.org/commander/-/commander-12.1.0.tgz", @@ -148,6 +1099,34 @@ "dev": true, "license": "MIT" }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/deep-eql": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz", + "integrity": "sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/diff": { "version": "4.0.4", "resolved": "https://registry.npmjs.org/diff/-/diff-4.0.4.tgz", @@ -158,6 +1137,104 @@ "node": ">=0.3.1" } }, + "node_modules/es-module-lexer": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz", + "integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==", + "dev": true, + "license": "MIT" + }, + "node_modules/esbuild": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz", + "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=12" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.21.5", + "@esbuild/android-arm": "0.21.5", + "@esbuild/android-arm64": "0.21.5", + "@esbuild/android-x64": "0.21.5", + "@esbuild/darwin-arm64": "0.21.5", + "@esbuild/darwin-x64": "0.21.5", + "@esbuild/freebsd-arm64": "0.21.5", + "@esbuild/freebsd-x64": "0.21.5", + "@esbuild/linux-arm": "0.21.5", + "@esbuild/linux-arm64": "0.21.5", + "@esbuild/linux-ia32": "0.21.5", + "@esbuild/linux-loong64": "0.21.5", + "@esbuild/linux-mips64el": "0.21.5", + "@esbuild/linux-ppc64": "0.21.5", + "@esbuild/linux-riscv64": "0.21.5", + "@esbuild/linux-s390x": "0.21.5", + "@esbuild/linux-x64": "0.21.5", + "@esbuild/netbsd-x64": "0.21.5", + "@esbuild/openbsd-x64": "0.21.5", + "@esbuild/sunos-x64": "0.21.5", + "@esbuild/win32-arm64": "0.21.5", + "@esbuild/win32-ia32": "0.21.5", + "@esbuild/win32-x64": "0.21.5" + } + }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/expect-type": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz", + "integrity": "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/loupe": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/loupe/-/loupe-3.2.1.tgz", + "integrity": "sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, "node_modules/make-error": { "version": "1.3.6", "resolved": "https://registry.npmjs.org/make-error/-/make-error-1.3.6.tgz", @@ -165,6 +1242,205 @@ "dev": true, "license": "ISC" }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.16", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.16.tgz", + "integrity": "sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/pathe": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-1.1.2.tgz", + "integrity": "sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/pathval": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/pathval/-/pathval-2.0.1.tgz", + "integrity": "sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14.16" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/postcss": { + "version": "8.5.19", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.19.tgz", + "integrity": "sha512-Mz8SaolMd8nB+G13WkORcxQKHZ/NE4xXevtkJHVuG+guo9/wYKlIMTKAqGdEmYOXR2ijPjTYNHssizdaVSUNdQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.12", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/rollup": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.62.2.tgz", + "integrity": "sha512-RFnrW4lhXA3s3eqHDZvN654g8OTjzRfqpIRJYczCGB6HzphckVAi/Qh4tbPUbRuDi7s1Llv8g/NspLkttY3gTA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.9" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.62.2", + "@rollup/rollup-android-arm64": "4.62.2", + "@rollup/rollup-darwin-arm64": "4.62.2", + "@rollup/rollup-darwin-x64": "4.62.2", + "@rollup/rollup-freebsd-arm64": "4.62.2", + "@rollup/rollup-freebsd-x64": "4.62.2", + "@rollup/rollup-linux-arm-gnueabihf": "4.62.2", + "@rollup/rollup-linux-arm-musleabihf": "4.62.2", + "@rollup/rollup-linux-arm64-gnu": "4.62.2", + "@rollup/rollup-linux-arm64-musl": "4.62.2", + "@rollup/rollup-linux-loong64-gnu": "4.62.2", + "@rollup/rollup-linux-loong64-musl": "4.62.2", + "@rollup/rollup-linux-ppc64-gnu": "4.62.2", + "@rollup/rollup-linux-ppc64-musl": "4.62.2", + "@rollup/rollup-linux-riscv64-gnu": "4.62.2", + "@rollup/rollup-linux-riscv64-musl": "4.62.2", + "@rollup/rollup-linux-s390x-gnu": "4.62.2", + "@rollup/rollup-linux-x64-gnu": "4.62.2", + "@rollup/rollup-linux-x64-musl": "4.62.2", + "@rollup/rollup-openbsd-x64": "4.62.2", + "@rollup/rollup-openharmony-arm64": "4.62.2", + "@rollup/rollup-win32-arm64-msvc": "4.62.2", + "@rollup/rollup-win32-ia32-msvc": "4.62.2", + "@rollup/rollup-win32-x64-gnu": "4.62.2", + "@rollup/rollup-win32-x64-msvc": "4.62.2", + "fsevents": "~2.3.2" + } + }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/std-env": { + "version": "3.10.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz", + "integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-0.3.2.tgz", + "integrity": "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinypool": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/tinypool/-/tinypool-1.1.1.tgz", + "integrity": "sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.0.0 || >=20.0.0" + } + }, + "node_modules/tinyrainbow": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-1.2.0.tgz", + "integrity": "sha512-weEDEq7Z5eTHPDh4xjX789+fHfF+P8boiFB+0vbWzpbnbsEr/GRaohi/uMKxg8RZMXnl1ItAi/IUHWMsjDV7kQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tinyspy": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-3.0.2.tgz", + "integrity": "sha512-n1cw8k1k0x4pgA2+9XrOkFydTerNcJ1zWCO5Nn9scWHTD+5tp8dghT2x1uduQePZTZgd3Tupf+x9BxJjeJi77Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, "node_modules/ts-node": { "version": "10.9.2", "resolved": "https://registry.npmjs.org/ts-node/-/ts-node-10.9.2.tgz", @@ -237,6 +1513,172 @@ "dev": true, "license": "MIT" }, + "node_modules/vite": { + "version": "5.4.21", + "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz", + "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.21.3", + "postcss": "^8.4.43", + "rollup": "^4.20.0" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || >=20.0.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.4.0" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + } + } + }, + "node_modules/vite-node": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-2.1.9.tgz", + "integrity": "sha512-AM9aQ/IPrW/6ENLQg3AGY4K1N2TGZdR5e4gu/MmmR2xR3Ll1+dib+nook92g4TV3PXVyeyxdWwtaCAiUL0hMxA==", + "dev": true, + "license": "MIT", + "dependencies": { + "cac": "^6.7.14", + "debug": "^4.3.7", + "es-module-lexer": "^1.5.4", + "pathe": "^1.1.2", + "vite": "^5.0.0" + }, + "bin": { + "vite-node": "vite-node.mjs" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/vitest": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-2.1.9.tgz", + "integrity": "sha512-MSmPM9REYqDGBI8439mA4mWhV5sKmDlBKWIYbA3lRb2PTHACE0mgKwA8yQ2xq9vxDTuk4iPrECBAEW2aoFXY0Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/expect": "2.1.9", + "@vitest/mocker": "2.1.9", + "@vitest/pretty-format": "^2.1.9", + "@vitest/runner": "2.1.9", + "@vitest/snapshot": "2.1.9", + "@vitest/spy": "2.1.9", + "@vitest/utils": "2.1.9", + "chai": "^5.1.2", + "debug": "^4.3.7", + "expect-type": "^1.1.0", + "magic-string": "^0.30.12", + "pathe": "^1.1.2", + "std-env": "^3.8.0", + "tinybench": "^2.9.0", + "tinyexec": "^0.3.1", + "tinypool": "^1.0.1", + "tinyrainbow": "^1.2.0", + "vite": "^5.0.0", + "vite-node": "2.1.9", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@types/node": "^18.0.0 || >=20.0.0", + "@vitest/browser": "2.1.9", + "@vitest/ui": "2.1.9", + "happy-dom": "*", + "jsdom": "*" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + } + } + }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/yn": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/yn/-/yn-3.1.1.tgz", diff --git a/cli/package.json b/cli/package.json index 182a8161a..16ffbc2ab 100644 --- a/cli/package.json +++ b/cli/package.json @@ -1,6 +1,6 @@ { "name": "grid-cli", - "version": "1.0.0", + "version": "1.1.0", "description": "CLI tool for Grid API", "main": "dist/index.js", "bin": { @@ -13,6 +13,7 @@ "build": "tsc", "dev": "ts-node src/index.ts", "start": "node dist/index.js", + "test": "vitest run", "clean": "rm -rf dist" }, "dependencies": { @@ -21,7 +22,8 @@ "devDependencies": { "@types/node": "^20.0.0", "ts-node": "^10.9.2", - "typescript": "^5.4.0" + "typescript": "^5.4.0", + "vitest": "^2.1.9" }, "author": "Lightspark", "license": "Apache-2.0" diff --git a/cli/src/client.ts b/cli/src/client.ts index af06189eb..dd44785a3 100644 --- a/cli/src/client.ts +++ b/cli/src/client.ts @@ -93,7 +93,11 @@ export class GridClient { } if (!response.ok) { - const errorData = data as { code?: string; message?: string }; + const errorData = data as { + code?: string; + message?: string; + details?: unknown; + }; return { success: false, error: { @@ -101,7 +105,7 @@ export class GridClient { code: errorData?.code, message: errorData?.message || response.statusText || "Request failed", - details: data, + details: errorData?.details, }, }; } diff --git a/cli/src/commands/accounts.ts b/cli/src/commands/accounts.ts index ea1770733..d7c3b8b33 100644 --- a/cli/src/commands/accounts.ts +++ b/cli/src/commands/accounts.ts @@ -2,28 +2,41 @@ import { Command } from "commander"; import { GridClient, PaginatedResponse } from "../client"; import { outputResponse, formatError, output } from "../output"; import { GlobalOptions } from "../index"; -import { validateCurrency, validateDate, validateAll } from "../validation"; +import { + validateCurrency, + validateDate, + validateAll, + ValidationResult, +} from "../validation"; + +interface CurrencyAmount { + amount: number; + currency: { code: string; name?: string; symbol?: string; decimals?: number }; +} interface InternalAccount { id: string; customerId?: string; - currency: string; - balance: number; - availableBalance: number; + type: string; status: string; - paymentInstructions?: unknown; + balance: CurrencyAmount; + totalBalance: CurrencyAmount; + fundingPaymentInstructions?: unknown[]; + privateEnabled?: boolean; createdAt: string; updatedAt: string; } interface ExternalAccount { id: string; - customerId: string; + customerId?: string; + platformAccountId?: string; currency: string; accountInfo: { accountType: string; [key: string]: unknown; }; + defaultUmaDepositAccount?: boolean; status: string; createdAt: string; updatedAt: string; @@ -48,6 +61,7 @@ export function registerAccountsCommand( .option("--cursor ", "Pagination cursor") .option("--customer-id ", "Filter by customer ID") .option("--currency ", "Filter by currency code") + .option("--type ", "Filter by account type (EMBEDDED_WALLET, INTERNAL_FIAT, INTERNAL_CRYPTO)") .option("--platform", "List platform internal accounts instead of customer accounts") .action(async (options) => { const opts = program.opts(); @@ -63,12 +77,17 @@ export function registerAccountsCommand( } } - const params: Record = { - limit: parseInt(options.limit, 10), - cursor: options.cursor, - customerId: options.customerId, - currency: options.currency, - }; + // The platform endpoint only supports currency + type; the customer + // endpoint additionally supports pagination and customerId. + const params: Record = options.platform + ? { currency: options.currency, type: options.type } + : { + limit: parseInt(options.limit, 10), + cursor: options.cursor, + customerId: options.customerId, + currency: options.currency, + type: options.type, + }; const endpoint = options.platform ? "/platform/internal-accounts" @@ -107,12 +126,19 @@ export function registerAccountsCommand( } } - const params: Record = { - limit: parseInt(options.limit, 10), - cursor: options.cursor, - customerId: options.customerId, - currency: options.currency, - }; + // The platform endpoint does not accept a customerId filter. + const params: Record = options.platform + ? { + limit: parseInt(options.limit, 10), + cursor: options.cursor, + currency: options.currency, + } + : { + limit: parseInt(options.limit, 10), + cursor: options.cursor, + customerId: options.customerId, + currency: options.currency, + }; const endpoint = options.platform ? "/platform/external-accounts" @@ -130,24 +156,36 @@ export function registerAccountsCommand( .description("Create an external account") .requiredOption("--customer-id ", "Customer ID") .requiredOption("--currency ", "Currency code (USD, MXN, BRL, EUR, etc.)") - .requiredOption("--account-type ", "Account type (US_ACCOUNT, CLABE, PIX, IBAN, UPI, NGN_ACCOUNT, SPARK_WALLET, etc.)") - .option("--account-number ", "Account number (for US_ACCOUNT, NGN_ACCOUNT)") - .option("--routing-number ", "Routing number (for US_ACCOUNT)") - .option("--account-category ", "Account category: CHECKING or SAVINGS (for US_ACCOUNT)") - .option("--clabe ", "CLABE number (for Mexico)") - .option("--pix-key ", "PIX key (for Brazil)") - .option("--pix-key-type ", "PIX key type: CPF, CNPJ, EMAIL, PHONE, RANDOM (for Brazil)") - .option("--tax-id ", "Tax ID of the account holder (for Brazil PIX)") - .option("--iban ", "IBAN (for Europe)") - .option("--upi-id ", "UPI ID (for India)") - .option("--bank-name ", "Bank name (for NGN_ACCOUNT)") - .option("--purpose ", "Purpose of payment (for NGN_ACCOUNT): GIFT, SELF, GOODS_OR_SERVICES, EDUCATION, etc.") + .requiredOption( + "--account-type ", + "Account type discriminator (e.g. USD_ACCOUNT, MXN_ACCOUNT, BRL_ACCOUNT, EUR_ACCOUNT, INR_ACCOUNT, NGN_ACCOUNT, SPARK_WALLET)" + ) + .option("--account-number ", "Bank account number") + .option("--routing-number ", "ABA routing number (USD)") + .option("--clabe ", "CLABE number (MXN)") + .option("--pix-key ", "PIX key (BRL)") + .option("--pix-key-type ", "PIX key type: CPF, CNPJ, EMAIL, PHONE, RANDOM (BRL)") + .option("--tax-id ", "Tax ID of the account holder (BRL PIX)") + .option("--iban ", "IBAN (EUR and others)") + .option("--swift-code ", "SWIFT/BIC code (EUR and others)") + .option("--upi-id ", "UPI virtual payment address (INR UPI)") + .option("--ifsc ", "IFSC code (INR NEFT/RTGS)") + .option("--rail ", "Payment rail, e.g. NEFT or RTGS (INR)") + .option("--bank-name ", "Bank name") .option("--address ", "Wallet address (for SPARK_WALLET, SOLANA_WALLET, etc.)") + .option("--platform-account-id ", "Your platform's identifier for this account") + .option("--default-uma-deposit-account", "Set as the default UMA deposit account") .option("--beneficiary-type ", "Beneficiary type: INDIVIDUAL or BUSINESS") .option("--beneficiary-name ", "Beneficiary full name (individual) or legal name (business)") .option("--beneficiary-birth-date ", "Beneficiary birth date YYYY-MM-DD (individual)") .option("--beneficiary-nationality ", "Beneficiary nationality country code (individual)") + .option("--beneficiary-registration-number ", "Beneficiary registration number (business)") + .option("--beneficiary-tax-id ", "Beneficiary tax ID (business)") + .option("--beneficiary-email ", "Beneficiary email") + .option("--beneficiary-phone ", "Beneficiary phone number") + .option("--beneficiary-country-of-residence ", "Beneficiary country of residence") .option("--beneficiary-address-line1 ", "Beneficiary address line 1") + .option("--beneficiary-address-line2 ", "Beneficiary address line 2") .option("--beneficiary-address-city ", "Beneficiary city") .option("--beneficiary-address-state ", "Beneficiary state") .option("--beneficiary-address-postal ", "Beneficiary postal code") @@ -157,7 +195,10 @@ export function registerAccountsCommand( const client = getClient(opts); if (!client) return; - const validations = [validateCurrency(options.currency, "currency")]; + const validations = [ + validateCurrency(options.currency, "currency"), + validateBeneficiaryInput(options), + ]; if (options.beneficiaryBirthDate) { validations.push(validateDate(options.beneficiaryBirthDate, "beneficiary-birth-date")); } @@ -171,71 +212,47 @@ export function registerAccountsCommand( const accountInfo: Record = { accountType: options.accountType, }; + const setInfo = (key: string, value: string | undefined) => { + if (value !== undefined) accountInfo[key] = value; + }; + setInfo("accountNumber", options.accountNumber); + setInfo("routingNumber", options.routingNumber); + setInfo("clabeNumber", options.clabe); + setInfo("pixKey", options.pixKey); + setInfo("pixKeyType", options.pixKeyType); + setInfo("taxId", options.taxId); + setInfo("iban", options.iban); + setInfo("swiftCode", options.swiftCode); + setInfo("vpa", options.upiId); + setInfo("ifsc", options.ifsc); + setInfo("rail", options.rail); + setInfo("bankName", options.bankName); + setInfo("address", options.address); - switch (options.accountType) { - case "US_ACCOUNT": - if (options.accountNumber) accountInfo.accountNumber = options.accountNumber; - if (options.routingNumber) accountInfo.routingNumber = options.routingNumber; - if (options.accountCategory) accountInfo.accountCategory = options.accountCategory; - break; - case "CLABE": - if (options.clabe) accountInfo.clabeNumber = options.clabe; - break; - case "PIX": - if (options.pixKey) accountInfo.pixKey = options.pixKey; - if (options.pixKeyType) accountInfo.pixKeyType = options.pixKeyType; - if (options.taxId) accountInfo.taxId = options.taxId; - break; - case "IBAN": - if (options.iban) accountInfo.iban = options.iban; - break; - case "UPI": - if (options.upiId) accountInfo.vpa = options.upiId; - break; - case "NGN_ACCOUNT": - if (options.accountNumber) accountInfo.accountNumber = options.accountNumber; - if (options.bankName) accountInfo.bankName = options.bankName; - if (options.purpose) accountInfo.purposeOfPayment = options.purpose; - break; - case "SPARK_WALLET": - case "SOLANA_WALLET": - case "TRON_WALLET": - case "POLYGON_WALLET": - case "BASE_WALLET": - if (options.address) accountInfo.address = options.address; - break; - } - - if (options.beneficiaryType || options.beneficiaryName) { - const beneficiary: Record = {}; - if (options.beneficiaryType) beneficiary.beneficiaryType = options.beneficiaryType; - - if (options.beneficiaryType === "INDIVIDUAL") { - if (options.beneficiaryName) beneficiary.fullName = options.beneficiaryName; - if (options.beneficiaryBirthDate) beneficiary.birthDate = options.beneficiaryBirthDate; - if (options.beneficiaryNationality) beneficiary.nationality = options.beneficiaryNationality; - } else if (options.beneficiaryType === "BUSINESS") { - if (options.beneficiaryName) beneficiary.legalName = options.beneficiaryName; - } - - if (options.beneficiaryAddressLine1 || options.beneficiaryAddressCity) { - beneficiary.address = { - line1: options.beneficiaryAddressLine1, - city: options.beneficiaryAddressCity, - state: options.beneficiaryAddressState, - postalCode: options.beneficiaryAddressPostal, - country: options.beneficiaryAddressCountry, - }; - } + const beneficiary = buildBeneficiary(options); + if (beneficiary) accountInfo.beneficiary = beneficiary; - accountInfo.beneficiary = beneficiary; + // Every fiat account type requires a beneficiary; catch it here rather + // than letting the API reject the request. + if (isFiatAccountType(options.accountType) && !beneficiary) { + output( + formatError( + `A beneficiary is required for ${options.accountType}. Pass --beneficiary-type and --beneficiary-name.` + ) + ); + process.exitCode = 1; + return; } - const body = { + const body: Record = { customerId: options.customerId, currency: options.currency, accountInfo, }; + if (options.platformAccountId) + body.platformAccountId = options.platformAccountId; + if (options.defaultUmaDepositAccount) + body.defaultUmaDepositAccount = true; const response = await client.post( "/customers/external-accounts", @@ -244,3 +261,96 @@ export function registerAccountsCommand( outputResponse(response); }); } + +function isFiatAccountType(accountType: string): boolean { + // Fiat and SWIFT types are the *_ACCOUNT discriminators; wallets/LIGHTNING are not. + return accountType.endsWith("_ACCOUNT"); +} + +// A beneficiary needs a name for its type, and any address supplied must carry +// the schema-required line1/postalCode/country. +function validateBeneficiaryInput( + options: Record +): ValidationResult { + if (options.beneficiaryType && !options.beneficiaryName) { + return { + valid: false, + error: "--beneficiary-name is required with --beneficiary-type", + }; + } + if (options.beneficiaryName && !options.beneficiaryType) { + return { + valid: false, + error: "--beneficiary-type is required with --beneficiary-name", + }; + } + const addressProvided = + options.beneficiaryAddressLine1 || + options.beneficiaryAddressLine2 || + options.beneficiaryAddressCity || + options.beneficiaryAddressState || + options.beneficiaryAddressPostal || + options.beneficiaryAddressCountry; + if ( + addressProvided && + !( + options.beneficiaryAddressLine1 && + options.beneficiaryAddressPostal && + options.beneficiaryAddressCountry + ) + ) { + return { + valid: false, + error: + "beneficiary address requires --beneficiary-address-line1, --beneficiary-address-postal, and --beneficiary-address-country", + }; + } + return { valid: true }; +} + +function buildBeneficiary( + options: Record +): Record | undefined { + if (!options.beneficiaryType) return undefined; + + const beneficiary: Record = { + beneficiaryType: options.beneficiaryType, + }; + if (options.beneficiaryType === "INDIVIDUAL") { + if (options.beneficiaryName) beneficiary.fullName = options.beneficiaryName; + if (options.beneficiaryBirthDate) + beneficiary.birthDate = options.beneficiaryBirthDate; + if (options.beneficiaryNationality) + beneficiary.nationality = options.beneficiaryNationality; + } else if (options.beneficiaryType === "BUSINESS") { + if (options.beneficiaryName) beneficiary.legalName = options.beneficiaryName; + if (options.beneficiaryRegistrationNumber) + beneficiary.registrationNumber = options.beneficiaryRegistrationNumber; + if (options.beneficiaryTaxId) + beneficiary.taxId = options.beneficiaryTaxId; + } + if (options.beneficiaryEmail) beneficiary.email = options.beneficiaryEmail; + if (options.beneficiaryPhone) + beneficiary.phoneNumber = options.beneficiaryPhone; + if (options.beneficiaryCountryOfResidence) + beneficiary.countryOfResidence = options.beneficiaryCountryOfResidence; + + if (options.beneficiaryAddressLine1) { + const address: Record = { + line1: options.beneficiaryAddressLine1, + }; + if (options.beneficiaryAddressLine2) + address.line2 = options.beneficiaryAddressLine2; + if (options.beneficiaryAddressCity) + address.city = options.beneficiaryAddressCity; + if (options.beneficiaryAddressState) + address.state = options.beneficiaryAddressState; + if (options.beneficiaryAddressPostal) + address.postalCode = options.beneficiaryAddressPostal; + if (options.beneficiaryAddressCountry) + address.country = options.beneficiaryAddressCountry; + beneficiary.address = address; + } + + return beneficiary; +} diff --git a/cli/src/commands/config.ts b/cli/src/commands/config.ts index 8638f2071..977dac48a 100644 --- a/cli/src/commands/config.ts +++ b/cli/src/commands/config.ts @@ -1,18 +1,24 @@ import { Command } from "commander"; import { GridClient } from "../client"; -import { outputResponse } from "../output"; +import { outputResponse, formatError, output } from "../output"; import { GlobalOptions } from "../index"; interface PlatformConfig { id: string; umaDomain?: string; + proxyUmaSubdomain?: string; webhookEndpoint?: string; - supportedCurrencies: Array<{ + isRegulatedFinancialInstitution?: boolean; + supportedCurrencies?: Array<{ currencyCode: string; minAmount?: number; maxAmount?: number; enabledTransactionTypes?: string[]; + requiredCounterpartyFields?: string[]; }>; + embeddedWalletConfig?: Record; + createdAt?: string; + updatedAt?: string; } export function registerConfigCommand( @@ -40,6 +46,14 @@ export function registerConfigCommand( .description("Update platform configuration") .option("--uma-domain ", "UMA domain") .option("--webhook-endpoint ", "Webhook endpoint URL") + .option( + "--supported-currencies ", + "Supported currencies as a JSON array of currency configs" + ) + .option( + "--embedded-wallet-config ", + "Embedded wallet configuration as a JSON object" + ) .action(async (options) => { const opts = program.opts(); const client = getClient(opts); @@ -49,7 +63,44 @@ export function registerConfigCommand( if (options.umaDomain) body.umaDomain = options.umaDomain; if (options.webhookEndpoint) body.webhookEndpoint = options.webhookEndpoint; + if (options.supportedCurrencies) { + const parsed = parseJsonOption( + options.supportedCurrencies, + "supported-currencies" + ); + if ("error" in parsed) { + output(formatError(parsed.error)); + process.exitCode = 1; + return; + } + body.supportedCurrencies = parsed.value; + } + + if (options.embeddedWalletConfig) { + const parsed = parseJsonOption( + options.embeddedWalletConfig, + "embedded-wallet-config" + ); + if ("error" in parsed) { + output(formatError(parsed.error)); + process.exitCode = 1; + return; + } + body.embeddedWalletConfig = parsed.value; + } + const response = await client.patch("/config", body); outputResponse(response); }); } + +function parseJsonOption( + 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/customers.ts b/cli/src/commands/customers.ts index 46b8a1180..53ad2b65c 100644 --- a/cli/src/commands/customers.ts +++ b/cli/src/commands/customers.ts @@ -2,8 +2,14 @@ import { Command } from "commander"; import { GridClient, PaginatedResponse } from "../client"; import { outputResponse, formatError, output } from "../output"; import { GlobalOptions } from "../index"; -import { validateDate, validateCustomerType, validateAll } from "../validation"; +import { + validateDate, + validateCustomerType, + validateAll, + ValidationResult, +} from "../validation"; import { confirm } from "../prompt"; +import { parseList } from "../parse"; interface Customer { id: string; @@ -17,6 +23,151 @@ interface Customer { updatedAt: string; } +// Address requires line1/postalCode/country. Returns an error if a partial +// address was supplied so we never send a schema-invalid object. +function validateAddress( + options: Record +): ValidationResult { + const provided = + options.addressLine1 || + options.addressLine2 || + options.addressCity || + options.addressState || + options.addressPostal || + options.addressCountry; + if (!provided) return { valid: true }; + if (options.addressLine1 && options.addressPostal && options.addressCountry) { + return { valid: true }; + } + return { + valid: false, + error: + "address requires --address-line1, --address-postal, and --address-country", + }; +} + +// BusinessInfo requires legalName, taxId, and incorporatedOn on create. +function validateBusinessRequired( + options: Record +): ValidationResult { + const missing = ["legalName", "taxId", "incorporatedOn"].filter( + (key) => !options[key] + ); + if (missing.length === 0) return { valid: true }; + const flags = missing + .map((k) => "--" + k.replace(/[A-Z]/g, (c) => "-" + c.toLowerCase())) + .join(", "); + return { + valid: false, + error: `Business customers require ${flags}`, + }; +} + +function buildAddress(options: Record) { + if (!options.addressLine1) return undefined; + const address: Record = { line1: options.addressLine1 }; + if (options.addressLine2) address.line2 = options.addressLine2; + if (options.addressCity) address.city = options.addressCity; + if (options.addressState) address.state = options.addressState; + if (options.addressPostal) address.postalCode = options.addressPostal; + if (options.addressCountry) address.country = options.addressCountry; + return address; +} + +function buildBusinessInfo( + options: Record +): Record { + const info: Record = {}; + const set = (key: string, value: string | undefined) => { + if (value !== undefined) info[key] = value; + }; + set("legalName", options.legalName); + set("doingBusinessAs", options.doingBusinessAs); + set("country", options.businessCountry); + set("registrationNumber", options.registrationNumber); + set("incorporatedOn", options.incorporatedOn); + set("entityType", options.entityType); + set("taxId", options.taxId); + set("businessType", options.businessType); + set("purposeOfAccount", options.purposeOfAccount); + set("sourceOfFunds", options.sourceOfFunds); + set("naicsCode", options.naicsCode); + set( + "expectedMonthlyTransactionCount", + options.expectedMonthlyTransactionCount + ); + set( + "expectedMonthlyTransactionVolume", + options.expectedMonthlyTransactionVolume + ); + const countriesOfOperation = parseList(options.countriesOfOperation); + if (countriesOfOperation) info.countriesOfOperation = countriesOfOperation; + const sourceOfFundsCategories = parseList(options.sourceOfFundsCategories); + if (sourceOfFundsCategories) + info.sourceOfFundsCategories = sourceOfFundsCategories; + const expectedCounterpartyCountries = parseList( + options.expectedCounterpartyCountries + ); + if (expectedCounterpartyCountries) + info.expectedCounterpartyCountries = expectedCounterpartyCountries; + const expectedRecipientJurisdictions = parseList( + options.expectedRecipientJurisdictions + ); + if (expectedRecipientJurisdictions) + info.expectedRecipientJurisdictions = expectedRecipientJurisdictions; + return info; +} + +function applyTopLevelFields( + body: Record, + options: Record +): void { + if (options.umaAddress) body.umaAddress = options.umaAddress; + if (options.email) body.email = options.email; + if (options.phoneNumber) body.phoneNumber = options.phoneNumber; + const currencies = parseList(options.currencies); + if (currencies) body.currencies = currencies; +} + +function businessOptions(cmd: Command): Command { + return cmd + .option("--legal-name ", "Legal name (business)") + .option("--doing-business-as ", "Trade/DBA name (business)") + .option("--business-country ", "Country of incorporation (business)") + .option("--registration-number ", "Registration number (business)") + .option("--tax-id ", "Tax ID (business)") + .option("--incorporated-on ", "Incorporation date YYYY-MM-DD (business)") + .option("--entity-type ", "Entity type (business)") + .option("--business-type ", "Business type (business)") + .option("--purpose-of-account ", "Purpose of account (business)") + .option("--source-of-funds ", "Primary source of funds (business)") + .option( + "--source-of-funds-categories ", + "Comma-separated source-of-funds categories (business)" + ) + .option("--naics-code ", "NAICS code, 2-6 digits (business)") + .option( + "--expected-monthly-transaction-count ", + "Expected monthly transaction count bucket (business)" + ) + .option( + "--expected-monthly-transaction-volume ", + "Expected monthly transaction volume bucket (business)" + ) + .option( + "--countries-of-operation ", + "Comma-separated countries of operation (business)" + ) + .option( + "--expected-counterparty-countries ", + "Comma-separated expected counterparty countries (business)" + ) + .option( + "--expected-recipient-jurisdictions ", + "Comma-separated expected recipient jurisdictions (business)" + ); +} + export function registerCustomersCommand( program: Command, getClient: (opts: GlobalOptions) => GridClient | null @@ -33,17 +184,31 @@ export function registerCustomersCommand( .option("--platform-id ", "Filter by platform customer ID") .option("--type ", "Filter by type (INDIVIDUAL or BUSINESS)") .option("--uma-address
", "Filter by UMA address") + .option("--region ", "Filter by region (ISO 3166-1 alpha-2)") + .option("--currency ", "Filter by currency code") + .option("--created-after ", "Filter by creation date (RFC 3339)") + .option("--created-before ", "Filter by creation date (RFC 3339)") + .option("--updated-after ", "Filter by update date (RFC 3339)") + .option("--updated-before ", "Filter by update date (RFC 3339)") + .option("--include-deleted", "Include soft-deleted customers") .action(async (options) => { const opts = program.opts(); const client = getClient(opts); if (!client) return; - const params: Record = { + const params: Record = { limit: parseInt(options.limit, 10), cursor: options.cursor, platformCustomerId: options.platformId, customerType: options.type, umaAddress: options.umaAddress, + region: options.region, + currency: options.currency, + createdAfter: options.createdAfter, + createdBefore: options.createdBefore, + updatedAfter: options.updatedAfter, + updatedBefore: options.updatedBefore, + isIncludingDeleted: options.includeDeleted ? true : undefined, }; const response = await client.get>( @@ -65,115 +230,142 @@ export function registerCustomersCommand( outputResponse(response); }); - customersCmd + const createCmd = customersCmd .command("create") .description("Create a new customer") .requiredOption("--platform-id ", "Platform-specific customer ID") .option("--type ", "Customer type (INDIVIDUAL or BUSINESS)", "INDIVIDUAL") .option("--uma-address
", "UMA address (optional, generated if not provided)") - .option("--full-name ", "Full name (for individuals)") - .option("--birth-date ", "Birth date YYYY-MM-DD (for individuals)") - .option("--legal-name ", "Legal name (for businesses)") - .option("--registration-number ", "Registration number (for businesses)") - .option("--tax-id ", "Tax ID (for businesses)") + .option("--region ", "Region (ISO 3166-1 alpha-2), immutable after creation") + .option("--currencies ", "Comma-separated currency codes") + .option("--email ", "Email address") + .option("--phone-number ", "Phone number in E.164 format") + .option("--full-name ", "Full name (individual)") + .option("--birth-date ", "Birth date YYYY-MM-DD (individual)") + .option("--nationality ", "Nationality (ISO 3166-1 alpha-2, individual)") + .option("--id-type ", "Identification type (individual)") + .option("--identifier ", "Tax identifier, e.g. SSN/ITIN (individual)") .option("--address-line1 ", "Address line 1") + .option("--address-line2 ", "Address line 2") .option("--address-city ", "City") .option("--address-state ", "State/Province") .option("--address-postal ", "Postal code") - .option("--address-country ", "Country code (e.g., US)") - .action(async (options) => { - const opts = program.opts(); - const client = getClient(opts); - if (!client) return; + .option("--address-country ", "Country code (e.g., US)"); + businessOptions(createCmd).action(async (options) => { + const opts = program.opts(); + const client = getClient(opts); + if (!client) return; - const validations = [validateCustomerType(options.type)]; - if (options.birthDate) { - validations.push(validateDate(options.birthDate, "birth-date")); - } - const validation = validateAll(validations); - if (!validation.valid) { - output(formatError(validation.error!)); - process.exitCode = 1; - return; - } + const validations = [ + validateCustomerType(options.type), + validateAddress(options), + ]; + if (options.birthDate) { + validations.push(validateDate(options.birthDate, "birth-date")); + } + if (options.type === "BUSINESS") { + validations.push(validateBusinessRequired(options)); + } + const validation = validateAll(validations); + if (!validation.valid) { + output(formatError(validation.error!)); + process.exitCode = 1; + return; + } - const body: Record = { - platformCustomerId: options.platformId, - customerType: options.type, - }; + const body: Record = { + platformCustomerId: options.platformId, + customerType: options.type, + }; + if (options.region) body.region = options.region; + applyTopLevelFields(body, options); - if (options.umaAddress) body.umaAddress = options.umaAddress; - - if (options.type === "INDIVIDUAL") { - if (options.fullName) body.fullName = options.fullName; - if (options.birthDate) body.birthDate = options.birthDate; - } else if (options.type === "BUSINESS") { - const businessInfo: Record = {}; - if (options.legalName) businessInfo.legalName = options.legalName; - if (options.registrationNumber) - businessInfo.registrationNumber = options.registrationNumber; - if (options.taxId) businessInfo.taxId = options.taxId; - body.businessInfo = businessInfo; - } + if (options.type === "INDIVIDUAL") { + if (options.fullName) body.fullName = options.fullName; + if (options.birthDate) body.birthDate = options.birthDate; + if (options.nationality) body.nationality = options.nationality; + if (options.idType) body.idType = options.idType; + if (options.identifier) body.identifier = options.identifier; + } else if (options.type === "BUSINESS") { + body.businessInfo = buildBusinessInfo(options); + } - if (options.addressLine1 || options.addressCity) { - body.address = { - line1: options.addressLine1, - city: options.addressCity, - state: options.addressState, - postalCode: options.addressPostal, - country: options.addressCountry, - }; - } + const address = buildAddress(options); + if (address) body.address = address; - const response = await client.post("/customers", body); - outputResponse(response); - }); + const response = await client.post("/customers", body); + outputResponse(response); + }); - customersCmd + const updateCmd = customersCmd .command("update ") .description("Update a customer") - .option("--full-name ", "Full name") - .option("--birth-date ", "Birth date YYYY-MM-DD") + .requiredOption( + "--type ", + "Customer type (INDIVIDUAL or BUSINESS) — required discriminator" + ) + .option("--uma-address
", "UMA address") + .option("--currencies ", "Comma-separated currency codes") + .option("--email ", "Email address") + .option("--phone-number ", "Phone number in E.164 format") + .option("--full-name ", "Full name (individual)") + .option("--birth-date ", "Birth date YYYY-MM-DD (individual)") + .option("--nationality ", "Nationality (ISO 3166-1 alpha-2, individual)") .option("--address-line1 ", "Address line 1") + .option("--address-line2 ", "Address line 2") .option("--address-city ", "City") .option("--address-state ", "State/Province") .option("--address-postal ", "Postal code") - .option("--address-country ", "Country code") - .action(async (customerId: string, options) => { - const opts = program.opts(); - const client = getClient(opts); - if (!client) return; + .option("--address-country ", "Country code"); + businessOptions(updateCmd).action(async (customerId: string, options) => { + const opts = program.opts(); + const client = getClient(opts); + if (!client) return; - if (options.birthDate) { - const validation = validateDate(options.birthDate, "birth-date"); - if (!validation.valid) { - output(formatError(validation.error!)); - process.exitCode = 1; - return; - } - } + const validations = [ + validateCustomerType(options.type), + validateAddress(options), + ]; + if (options.birthDate) { + validations.push(validateDate(options.birthDate, "birth-date")); + } + // Email and phone updates must be separate PATCH calls so tied Embedded + // Wallet credentials propagate through the signed-retry flow. + if (options.email && options.phoneNumber) { + validations.push({ + valid: false, + error: + "Update --email and --phone-number in separate calls, not together", + }); + } + const validation = validateAll(validations); + if (!validation.valid) { + output(formatError(validation.error!)); + process.exitCode = 1; + return; + } + + const body: Record = { customerType: options.type }; + applyTopLevelFields(body, options); - const body: Record = {}; + if (options.type === "INDIVIDUAL") { if (options.fullName) body.fullName = options.fullName; if (options.birthDate) body.birthDate = options.birthDate; + if (options.nationality) body.nationality = options.nationality; + } else if (options.type === "BUSINESS") { + const businessInfo = buildBusinessInfo(options); + if (Object.keys(businessInfo).length) body.businessInfo = businessInfo; + } - if (options.addressLine1 || options.addressCity) { - body.address = { - line1: options.addressLine1, - city: options.addressCity, - state: options.addressState, - postalCode: options.addressPostal, - country: options.addressCountry, - }; - } + const address = buildAddress(options); + if (address) body.address = address; - const response = await client.patch( - `/customers/${customerId}`, - body - ); - outputResponse(response); - }); + const response = await client.patch( + `/customers/${customerId}`, + body + ); + outputResponse(response); + }); customersCmd .command("delete ") diff --git a/cli/src/commands/quotes.ts b/cli/src/commands/quotes.ts index 49a1a41b3..1004965a6 100644 --- a/cli/src/commands/quotes.ts +++ b/cli/src/commands/quotes.ts @@ -11,34 +11,33 @@ import { parseAmount, } from "../validation"; +interface Currency { + code: string; + name?: string; + symbol?: string; + decimals?: number; +} + interface Quote { id: string; - status: "PENDING" | "PROCESSING" | "COMPLETED" | "FAILED" | "EXPIRED"; - source: { - accountId?: string; - customerId?: string; - currency: string; - }; - destination: { - accountId?: string; - umaAddress?: string; - currency: string; - }; - lockedCurrencySide: "SENDING" | "RECEIVING"; - lockedCurrencyAmount: number; - sendingAmount: number; - sendingCurrency: string; - receivingAmount: number; - receivingCurrency: string; + status: + | "PENDING" + | "PENDING_AUTHORIZATION" + | "PROCESSING" + | "COMPLETED" + | "FAILED" + | "EXPIRED"; + source: Record; + destination: Record; + sendingCurrency: Currency; + receivingCurrency: Currency; + totalSendingAmount: number; + totalReceivingAmount: number; + feesIncluded: number; exchangeRate: number; - fees?: Array<{ - type: string; - amount: number; - currency: string; - }>; + transactionId: string; expiresAt: string; createdAt: string; - updatedAt: string; } export function registerQuotesCommand( @@ -79,6 +78,11 @@ export function registerQuotesCommand( .option("--sender-birth-date ", "Sender birth date YYYY-MM-DD (for UMA destinations)") .option("--sender-nationality ", "Sender nationality country code (for UMA destinations)") .option("--purpose-of-payment ", "Purpose of payment (GOODS_OR_SERVICES, GIFT, SELF, EDUCATION, etc.)") + .option("--remittance-information ", "Free-form remittance information (max 80 chars)") + .option("--payment-rail ", "Payment rail for an ACCOUNT destination") + .option("--crypto-network ", "Crypto network for a stablecoin realtime-funding source") + .option("--fbo-customer ", "Customer ID when funding from an FBO account (ACCOUNT source)") + .option("--sca-factor ", "Preferred SCA factor (SMS_OTP or PASSKEY)") .action(async (options) => { const opts = program.opts(); const client = getClient(opts); @@ -88,6 +92,18 @@ export function registerQuotesCommand( validateAmount(options.amount, "amount"), validateLockSide(options.lockSide), ]; + // A realtime-funding source requires a currency. + if ( + (options.sourceCustomer || options.cryptoNetwork) && + !options.sourceAccount && + !options.sourceCurrency + ) { + validations.push({ + valid: false, + error: + "--source-currency is required for a realtime-funding source (--source-customer)", + }); + } if (options.sourceCurrency) { validations.push(validateCurrency(options.sourceCurrency, "source-currency")); } @@ -110,23 +126,29 @@ export function registerQuotesCommand( }; if (options.sourceAccount) { - body.source = { + const source: Record = { sourceType: "ACCOUNT", accountId: options.sourceAccount, }; - } else if (options.sourceCustomer) { - body.source = { + if (options.fboCustomer) source.customerId = options.fboCustomer; + body.source = source; + } else if (options.sourceCustomer || options.sourceCurrency) { + const source: Record = { sourceType: "REALTIME_FUNDING", - customerId: options.sourceCustomer, currency: options.sourceCurrency, }; + if (options.sourceCustomer) source.customerId = options.sourceCustomer; + if (options.cryptoNetwork) source.cryptoNetwork = options.cryptoNetwork; + body.source = source; } if (options.destAccount) { - body.destination = { + const destination: Record = { destinationType: "ACCOUNT", accountId: options.destAccount, }; + if (options.paymentRail) destination.paymentRail = options.paymentRail; + body.destination = destination; } else if (options.destUma) { body.destination = { destinationType: "UMA_ADDRESS", @@ -138,12 +160,16 @@ export function registerQuotesCommand( if (options.description) body.description = options.description; if (options.lookupId) body.lookupId = options.lookupId; if (options.immediate) body.immediatelyExecute = true; + if (options.remittanceInformation) + body.remittanceInformation = options.remittanceInformation; + if (options.purposeOfPayment) + body.purposeOfPayment = options.purposeOfPayment; + if (options.scaFactor) body.scaFactor = options.scaFactor; const senderInfo: Record = {}; if (options.senderName) senderInfo.FULL_NAME = options.senderName; if (options.senderBirthDate) senderInfo.BIRTH_DATE = options.senderBirthDate; if (options.senderNationality) senderInfo.NATIONALITY = options.senderNationality; - if (options.purposeOfPayment) senderInfo.PURPOSE_OF_PAYMENT = options.purposeOfPayment; if (Object.keys(senderInfo).length > 0) { body.senderCustomerInfo = senderInfo; } @@ -155,12 +181,20 @@ export function registerQuotesCommand( quotesCmd .command("execute ") .description("Execute a pending quote") - .action(async (quoteId: string) => { + .option("--sca-factor ", "Preferred SCA factor (SMS_OTP or PASSKEY)") + .action(async (quoteId: string, options) => { const opts = program.opts(); const client = getClient(opts); if (!client) return; - const response = await client.post(`/quotes/${quoteId}/execute`); + const body = options.scaFactor + ? { scaFactor: options.scaFactor } + : undefined; + + const response = await client.post( + `/quotes/${quoteId}/execute`, + body + ); outputResponse(response); }); } diff --git a/cli/src/commands/receiver.ts b/cli/src/commands/receiver.ts index c820c3a83..1bf50f827 100644 --- a/cli/src/commands/receiver.ts +++ b/cli/src/commands/receiver.ts @@ -4,17 +4,16 @@ import { outputResponse } from "../output"; import { GlobalOptions } from "../index"; interface ReceiverLookup { - id: string; - umaAddress?: string; + lookupId: string; + receiverUmaAddress?: string; accountId?: string; - currencies: Array<{ - code: string; - name: string; - symbol: string; - minAmount?: number; - maxAmount?: number; + supportedCurrencies: Array<{ + currency: { code: string; name?: string; symbol?: string; decimals?: number }; + estimatedExchangeRate?: number; + min?: number; + max?: number; }>; - requiredPayerData?: Array<{ + requiredPayerDataFields?: Array<{ name: string; mandatory: boolean; }>; @@ -53,13 +52,21 @@ export function registerReceiverCommand( receiverCmd .command("lookup-account ") .description("Look up an external account to get payment capabilities") - .action(async (accountId: string) => { + .option("--customer-id ", "Sender customer ID") + .option("--sender-uma
", "Sender UMA address") + .action(async (accountId: string, options) => { const opts = program.opts(); const client = getClient(opts); if (!client) return; + const params: Record = { + customerId: options.customerId, + senderUmaAddress: options.senderUma, + }; + const response = await client.get( - `/receiver/external-account/${encodeURIComponent(accountId)}` + `/receiver/external-account/${encodeURIComponent(accountId)}`, + params ); outputResponse(response); }); diff --git a/cli/src/commands/sandbox.ts b/cli/src/commands/sandbox.ts index 7e4e039d4..52b540858 100644 --- a/cli/src/commands/sandbox.ts +++ b/cli/src/commands/sandbox.ts @@ -46,15 +46,29 @@ export function registerSandboxCommand( sandboxCmd .command("receive") .description("Simulate receiving an UMA payment in sandbox") - .requiredOption("--uma-address
", "Receiver UMA address") + .requiredOption("--sender-uma
", "Sender UMA address") .requiredOption("--amount ", "Amount in smallest unit") - .requiredOption("--currency ", "Currency code") - .option("--sender-uma
", "Sender UMA address") + .requiredOption("--currency ", "Receiving currency code") + .option("--uma-address
", "Receiver UMA address (or use --customer-id)") + .option("--customer-id ", "Receiver customer ID (or use --uma-address)") .action(async (options) => { const opts = program.opts(); const client = getClient(opts); if (!client) return; + if (!options.umaAddress && !options.customerId) { + output(formatError("Provide either --uma-address or --customer-id")); + process.exitCode = 1; + return; + } + if (options.umaAddress && options.customerId) { + output( + formatError("Provide only one of --uma-address or --customer-id") + ); + process.exitCode = 1; + return; + } + const validation = validateAll([ validateAmount(options.amount, "amount"), validateCurrency(options.currency, "currency"), @@ -66,11 +80,12 @@ export function registerSandboxCommand( } const body: Record = { - receiverUmaAddress: options.umaAddress, - amount: parseAmount(options.amount), - currency: options.currency, + senderUmaAddress: options.senderUma, + receivingCurrencyAmount: parseAmount(options.amount), + receivingCurrencyCode: options.currency, }; - if (options.senderUma) body.senderUmaAddress = options.senderUma; + if (options.umaAddress) body.receiverUmaAddress = options.umaAddress; + if (options.customerId) body.customerId = options.customerId; const response = await client.post("/sandbox/uma/receive", body); outputResponse(response); diff --git a/cli/src/commands/transactions.ts b/cli/src/commands/transactions.ts index cbd449fcd..c19e16e45 100644 --- a/cli/src/commands/transactions.ts +++ b/cli/src/commands/transactions.ts @@ -1,17 +1,24 @@ import { Command } from "commander"; import { GridClient, PaginatedResponse } from "../client"; -import { outputResponse } from "../output"; +import { outputResponse, formatError, output } from "../output"; import { GlobalOptions } from "../index"; +interface CurrencyAmount { + amount: number; + currency: { code: string; name?: string; symbol?: string; decimals?: number }; +} + interface Transaction { id: string; - type: "INCOMING" | "OUTGOING"; + type: "INCOMING" | "OUTGOING" | "CARD"; + direction?: string; status: string; - amount: number; - currency: string; - senderAccountIdentifier?: string; - receiverAccountIdentifier?: string; - reference?: string; + customerId?: string; + platformCustomerId?: string; + source?: Record; + destination?: Record; + receivedAmount?: CurrencyAmount; + sentAmount?: CurrencyAmount; description?: string; createdAt: string; updatedAt: string; @@ -34,6 +41,7 @@ export function registerTransactionsCommand( .option("--platform-customer-id ", "Filter by platform customer ID") .option("--sender ", "Filter by sender account identifier") .option("--receiver ", "Filter by receiver account identifier") + .option("--account-identifier ", "Filter by an account identifier matching either sender or receiver") .option("--status ", "Filter by status") .option("--type ", "Filter by type (INCOMING or OUTGOING)") .option("--reference ", "Filter by reference") @@ -52,6 +60,7 @@ export function registerTransactionsCommand( platformCustomerId: options.platformCustomerId, senderAccountIdentifier: options.sender, receiverAccountIdentifier: options.receiver, + accountIdentifier: options.accountIdentifier, status: options.status, type: options.type, reference: options.reference, @@ -84,13 +93,29 @@ export function registerTransactionsCommand( transactionsCmd .command("approve ") .description("Approve an incoming payment transaction") - .action(async (transactionId: string) => { + .option( + "--receiver-customer-info ", + "Requested receiver customer info as a JSON object" + ) + .action(async (transactionId: string, options) => { const opts = program.opts(); const client = getClient(opts); if (!client) return; + let body: Record | undefined; + if (options.receiverCustomerInfo) { + try { + body = { receiverCustomerInfo: JSON.parse(options.receiverCustomerInfo) }; + } catch { + output(formatError("Invalid JSON for --receiver-customer-info")); + process.exitCode = 1; + return; + } + } + const response = await client.post( - `/transactions/${transactionId}/approve` + `/transactions/${transactionId}/approve`, + body ); outputResponse(response); }); diff --git a/cli/src/commands/transfers.ts b/cli/src/commands/transfers.ts index bfd324c5a..431edfff5 100644 --- a/cli/src/commands/transfers.ts +++ b/cli/src/commands/transfers.ts @@ -61,6 +61,8 @@ export function registerTransfersCommand( .requiredOption("--source ", "Source internal account ID (InternalAccount:...)") .requiredOption("--dest ", "Destination external account ID (ExternalAccount:...)") .option("--amount ", "Amount in smallest currency unit (optional for full balance)") + .option("--payment-rail ", "Payment rail for the destination external account") + .option("--remittance-information ", "Free-form remittance information (max 80 chars)") .action(async (options) => { const opts = program.opts(); const client = getClient(opts); @@ -75,14 +77,19 @@ export function registerTransfersCommand( } } + const destination: Record = { accountId: options.dest }; + if (options.paymentRail) destination.paymentRail = options.paymentRail; + const body: Record = { source: { accountId: options.source }, - destination: { accountId: options.dest }, + destination, }; if (options.amount) { body.amount = parseAmount(options.amount); } + if (options.remittanceInformation) + body.remittanceInformation = options.remittanceInformation; const response = await client.post("/transfer-out", body); outputResponse(response); diff --git a/cli/src/index.ts b/cli/src/index.ts index e67c3b0d4..d4f40f7e9 100644 --- a/cli/src/index.ts +++ b/cli/src/index.ts @@ -19,28 +19,6 @@ const packageJson = JSON.parse( fs.readFileSync(path.join(__dirname, "..", "package.json"), "utf-8") ); -const program = new Command(); - -program - .name("grid") - .description("CLI for Grid API - manage global payments") - .version(packageJson.version, "-V, --version", "Show the version and exit") - .option("-c, --config ", "Path to credentials file") - .option( - "-u, --base-url ", - "Base URL for API (default: https://api.lightspark.com/grid/2025-10-13)" - ) - .option("-f, --format ", "Output format: json or table", "json") - .option("--no-color", "Disable colored output"); - -configureHelp(program); - -program.hook("preAction", (thisCommand) => { - const opts = thisCommand.opts(); - if (opts.format) setOutputFormat(opts.format as OutputFormat); - if (opts.color === false) setUseColors(false); - }); - function getClient(options: GlobalOptions): GridClient | null { try { const config = loadConfig({ @@ -56,9 +34,29 @@ function getClient(options: GlobalOptions): GridClient | null { } } -export { program, getClient, GridClient, GridConfig }; +export async function buildProgram(): Promise { + const program = new Command(); + + program + .name("grid") + .description("CLI for Grid API - manage global payments") + .version(packageJson.version, "-V, --version", "Show the version and exit") + .option("-c, --config ", "Path to credentials file") + .option( + "-u, --base-url ", + "Base URL for API (default: https://api.lightspark.com/grid/2025-10-13)" + ) + .option("-f, --format ", "Output format: json or table", "json") + .option("--no-color", "Disable colored output"); + + configureHelp(program); + + program.hook("preAction", (thisCommand) => { + const opts = thisCommand.opts(); + if (opts.format) setOutputFormat(opts.format as OutputFormat); + if (opts.color === false) setUseColors(false); + }); -async function main() { const { registerConfigureCommand } = await import("./commands/configure"); const { registerConfigCommand } = await import("./commands/config"); const { registerCustomersCommand } = await import("./commands/customers"); @@ -81,18 +79,29 @@ async function main() { registerSandboxCommand(program, getClient); registerReceiverCommand(program, getClient); - const customersCmd = program.commands.find(c => c.name() === "customers"); - const transactionsCmd = program.commands.find(c => c.name() === "transactions"); - const accountsCmd = program.commands.find(c => c.name() === "accounts"); + const customersCmd = program.commands.find((c) => c.name() === "customers"); + const transactionsCmd = program.commands.find( + (c) => c.name() === "transactions" + ); + const accountsCmd = program.commands.find((c) => c.name() === "accounts"); if (customersCmd) customersCmd.alias("cust"); if (transactionsCmd) transactionsCmd.alias("tx"); if (accountsCmd) accountsCmd.alias("acct"); + return program; +} + +export { getClient, GridClient, GridConfig }; + +async function main() { + const program = await buildProgram(); await program.parseAsync(process.argv); } -main().catch((err) => { - output(formatError(err.message)); - process.exitCode = 1; -}); +if (typeof require !== "undefined" && require.main === module) { + main().catch((err) => { + output(formatError(err.message)); + process.exitCode = 1; + }); +} diff --git a/cli/src/parse.ts b/cli/src/parse.ts new file mode 100644 index 000000000..6913d57cf --- /dev/null +++ b/cli/src/parse.ts @@ -0,0 +1,12 @@ +/** + * Splits a comma-separated flag value into a trimmed, non-empty string array, + * or returns undefined when nothing usable was provided. + */ +export function parseList(value: string | undefined): string[] | undefined { + if (!value) return undefined; + const items = value + .split(",") + .map((v) => v.trim()) + .filter(Boolean); + return items.length ? items : undefined; +} diff --git a/cli/src/validation.ts b/cli/src/validation.ts index c54e429eb..c4ff7aa7e 100644 --- a/cli/src/validation.ts +++ b/cli/src/validation.ts @@ -46,17 +46,17 @@ export function parseAmount(value: string): number { return amount; } -const VALID_CURRENCIES = new Set([ - "USD", "EUR", "GBP", "MXN", "BRL", "INR", "NGN", "PHP", "KES", - "BTC", "SAT", "USDC", "USDT", -]); +// The API is the source of truth for supported currencies (the set changes +// often), so only guard against obviously malformed codes here. Codes must be +// uppercase — the API rejects lowercase, so catch it client-side with a clear +// error rather than sending it verbatim. +const CURRENCY_CODE_PATTERN = /^[A-Z0-9]{2,10}$/; export function validateCurrency(value: string, fieldName: string): ValidationResult { - const upper = value.toUpperCase(); - if (!VALID_CURRENCIES.has(upper)) { + if (!CURRENCY_CODE_PATTERN.test(value)) { return { valid: false, - error: `${fieldName} "${value}" is not a recognized currency. Valid: ${Array.from(VALID_CURRENCIES).join(", ")}`, + error: `${fieldName} "${value}" is not a valid currency code (expected uppercase, e.g. USD)`, }; } return { valid: true }; diff --git a/cli/test/accounts.test.ts b/cli/test/accounts.test.ts new file mode 100644 index 000000000..c88e8b374 --- /dev/null +++ b/cli/test/accounts.test.ts @@ -0,0 +1,271 @@ +import { describe, it, expect } from "vitest"; +import { runCli } from "./helpers"; + +describe("accounts external create — beneficiary gating", () => { + it("does not require a beneficiary for a wallet account type", async () => { + const { request, calls } = await runCli([ + "accounts", + "external", + "create", + "--customer-id", + "Customer:abc", + "--currency", + "USDC", + "--account-type", + "SPARK_WALLET", + "--address", + "sp1qexampleaddress", + ]); + + expect(calls).toBe(1); + expect(request?.body).toMatchObject({ + accountInfo: { accountType: "SPARK_WALLET", address: "sp1qexampleaddress" }, + }); + }); + + it("builds a BUSINESS beneficiary with registration and tax id", async () => { + const { request } = await runCli([ + "accounts", + "external", + "create", + "--customer-id", + "Customer:abc", + "--currency", + "USD", + "--account-type", + "USD_ACCOUNT", + "--account-number", + "1234567890", + "--routing-number", + "021000021", + "--beneficiary-type", + "BUSINESS", + "--beneficiary-name", + "Acme LLC", + "--beneficiary-registration-number", + "5523041", + "--beneficiary-tax-id", + "47-1234567", + ]); + + expect(request?.body).toMatchObject({ + accountInfo: { + beneficiary: { + beneficiaryType: "BUSINESS", + legalName: "Acme LLC", + registrationNumber: "5523041", + taxId: "47-1234567", + }, + }, + }); + }); + + it("rejects a partial beneficiary address", async () => { + const { calls } = await runCli([ + "accounts", + "external", + "create", + "--customer-id", + "Customer:abc", + "--currency", + "USD", + "--account-type", + "USD_ACCOUNT", + "--account-number", + "1234567890", + "--routing-number", + "021000021", + "--beneficiary-type", + "INDIVIDUAL", + "--beneficiary-name", + "Ada Lovelace", + "--beneficiary-address-line1", + "123 Main St", + ]); + + expect(calls).toBe(0); + }); +}); + +describe("accounts external create", () => { + it("uses the currency-suffixed USD_ACCOUNT discriminator and includes the beneficiary", async () => { + const { request } = await runCli([ + "accounts", + "external", + "create", + "--customer-id", + "Customer:abc", + "--currency", + "USD", + "--account-type", + "USD_ACCOUNT", + "--account-number", + "1234567890", + "--routing-number", + "021000021", + "--beneficiary-type", + "INDIVIDUAL", + "--beneficiary-name", + "Ada Lovelace", + ]); + + expect(request?.method).toBe("POST"); + expect(request?.path).toBe("/grid/v1/customers/external-accounts"); + expect(request?.body).toMatchObject({ + customerId: "Customer:abc", + currency: "USD", + accountInfo: { + accountType: "USD_ACCOUNT", + accountNumber: "1234567890", + routingNumber: "021000021", + beneficiary: { beneficiaryType: "INDIVIDUAL", fullName: "Ada Lovelace" }, + }, + }); + }); + + it("does not send a request when a fiat account omits the required beneficiary", async () => { + const { request, calls } = await runCli([ + "accounts", + "external", + "create", + "--customer-id", + "Customer:abc", + "--currency", + "USD", + "--account-type", + "USD_ACCOUNT", + "--account-number", + "1234567890", + "--routing-number", + "021000021", + ]); + + expect(calls).toBe(0); + expect(request).toBeNull(); + }); + + it("rejects a beneficiary that has a type but no name", async () => { + const { calls } = await runCli([ + "accounts", + "external", + "create", + "--customer-id", + "Customer:abc", + "--currency", + "USD", + "--account-type", + "USD_ACCOUNT", + "--account-number", + "1234567890", + "--routing-number", + "021000021", + "--beneficiary-type", + "INDIVIDUAL", + // --beneficiary-name omitted + ]); + + expect(calls).toBe(0); + }); + + it("carries platformAccountId and defaultUmaDepositAccount at the top level", async () => { + const { request } = await runCli([ + "accounts", + "external", + "create", + "--customer-id", + "Customer:abc", + "--currency", + "USD", + "--account-type", + "USD_ACCOUNT", + "--account-number", + "1234567890", + "--routing-number", + "021000021", + "--beneficiary-type", + "INDIVIDUAL", + "--beneficiary-name", + "Ada Lovelace", + "--platform-account-id", + "ext_acc_1", + "--default-uma-deposit-account", + ]); + + expect(request?.body).toMatchObject({ + platformAccountId: "ext_acc_1", + defaultUmaDepositAccount: true, + }); + }); +}); + +describe("accounts external create currency validation", () => { + it("accepts currencies beyond the legacy allowlist (e.g. SGD)", async () => { + const { request, calls } = await runCli([ + "accounts", + "external", + "create", + "--customer-id", + "Customer:abc", + "--currency", + "SGD", + "--account-type", + "SGD_ACCOUNT", + "--account-number", + "1234567890", + "--beneficiary-type", + "INDIVIDUAL", + "--beneficiary-name", + "Ada Lovelace", + ]); + + expect(calls).toBe(1); + expect(request?.body).toMatchObject({ currency: "SGD" }); + }); +}); + +describe("accounts internal list", () => { + it("sends only currency and type to the platform endpoint", async () => { + const { request } = await runCli([ + "accounts", + "internal", + "list", + "--platform", + "--currency", + "USD", + "--type", + "INTERNAL_FIAT", + ]); + + expect(request?.path).toBe("/grid/v1/platform/internal-accounts"); + expect(request?.query).toEqual({ currency: "USD", type: "INTERNAL_FIAT" }); + }); + + it("passes the type filter to the customer endpoint", async () => { + const { request } = await runCli([ + "accounts", + "internal", + "list", + "--type", + "EMBEDDED_WALLET", + ]); + + expect(request?.path).toBe("/grid/v1/customers/internal-accounts"); + expect(request?.query).toMatchObject({ type: "EMBEDDED_WALLET" }); + }); +}); + +describe("accounts external list", () => { + it("does not send customerId to the platform endpoint", async () => { + const { request } = await runCli([ + "accounts", + "external", + "list", + "--platform", + "--customer-id", + "Customer:abc", + ]); + + expect(request?.path).toBe("/grid/v1/platform/external-accounts"); + expect(request?.query.customerId).toBeUndefined(); + }); +}); diff --git a/cli/test/client.test.ts b/cli/test/client.test.ts new file mode 100644 index 000000000..d649ff4ca --- /dev/null +++ b/cli/test/client.test.ts @@ -0,0 +1,23 @@ +import { describe, it, expect } from "vitest"; +import { runCli } from "./helpers"; + +describe("error handling", () => { + it("surfaces the API error's inner details object, not the whole error body", async () => { + const { stdout } = await runCli(["customers", "get", "cust_1"], { + status: 400, + body: { + status: 400, + code: "INVALID_REQUEST", + message: "Bad request", + details: { field: "email" }, + }, + }); + + const parsed = JSON.parse(stdout); + expect(parsed.success).toBe(false); + expect(parsed.error.code).toBe("INVALID_REQUEST"); + expect(parsed.error.details).toEqual({ field: "email" }); + // The whole error envelope must not be nested under details. + expect(parsed.error.details.code).toBeUndefined(); + }); +}); diff --git a/cli/test/customers.test.ts b/cli/test/customers.test.ts new file mode 100644 index 000000000..b79bb2da7 --- /dev/null +++ b/cli/test/customers.test.ts @@ -0,0 +1,157 @@ +import { describe, it, expect } from "vitest"; +import { runCli } from "./helpers"; + +describe("customers update", () => { + it("sends the customerType discriminator required by the update oneOf", async () => { + const { request } = await runCli([ + "customers", + "update", + "cust_123", + "--type", + "INDIVIDUAL", + "--full-name", + "Ada Lovelace", + ]); + + expect(request?.method).toBe("PATCH"); + expect(request?.path).toBe("/grid/v1/customers/cust_123"); + expect(request?.body).toMatchObject({ + customerType: "INDIVIDUAL", + fullName: "Ada Lovelace", + }); + }); + + it("routes business fields under businessInfo with the discriminator", async () => { + const { request } = await runCli([ + "customers", + "update", + "cust_456", + "--type", + "BUSINESS", + "--legal-name", + "Acme LLC", + ]); + + expect(request?.body).toMatchObject({ + customerType: "BUSINESS", + businessInfo: { legalName: "Acme LLC" }, + }); + }); +}); + +describe("customers update validation", () => { + it("rejects updating email and phoneNumber in the same call", async () => { + const { calls } = await runCli([ + "customers", + "update", + "cust_9", + "--type", + "INDIVIDUAL", + "--email", + "ada@example.com", + "--phone-number", + "+15551234567", + ]); + + expect(calls).toBe(0); + }); +}); + +describe("customers create", () => { + it("includes the required incorporatedOn for business customers", async () => { + const { request } = await runCli([ + "customers", + "create", + "--platform-id", + "p1", + "--type", + "BUSINESS", + "--legal-name", + "Acme LLC", + "--tax-id", + "12-3456789", + "--incorporated-on", + "2020-01-01", + ]); + + expect(request?.body).toMatchObject({ + customerType: "BUSINESS", + businessInfo: { + legalName: "Acme LLC", + taxId: "12-3456789", + incorporatedOn: "2020-01-01", + }, + }); + }); + + it("rejects a business create missing required businessInfo fields", async () => { + const { calls } = await runCli([ + "customers", + "create", + "--platform-id", + "p3", + "--type", + "BUSINESS", + "--legal-name", + "Acme LLC", + // taxId and incorporatedOn omitted + ]); + + expect(calls).toBe(0); + }); + + it("rejects a partial address missing postalCode/country", async () => { + const { calls } = await runCli([ + "customers", + "create", + "--platform-id", + "p4", + "--type", + "INDIVIDUAL", + "--full-name", + "Ada Lovelace", + "--address-line1", + "123 Main St", + ]); + + expect(calls).toBe(0); + }); + + it("sends top-level contact fields when provided", async () => { + const { request } = await runCli([ + "customers", + "create", + "--platform-id", + "p2", + "--type", + "INDIVIDUAL", + "--full-name", + "Ada Lovelace", + "--email", + "ada@example.com", + "--phone-number", + "+15551234567", + ]); + + expect(request?.body).toMatchObject({ + customerType: "INDIVIDUAL", + email: "ada@example.com", + phoneNumber: "+15551234567", + }); + }); +}); + +describe("customers list", () => { + it("passes new filter query params", async () => { + const { request } = await runCli([ + "customers", + "list", + "--region", + "US", + "--currency", + "USD", + ]); + + expect(request?.query).toMatchObject({ region: "US", currency: "USD" }); + }); +}); diff --git a/cli/test/helpers.ts b/cli/test/helpers.ts new file mode 100644 index 000000000..678fc9dae --- /dev/null +++ b/cli/test/helpers.ts @@ -0,0 +1,107 @@ +import { vi } from "vitest"; +import { buildProgram } from "../src/index"; + +export interface CapturedRequest { + method: string; + url: URL; + path: string; + query: Record; + body: unknown; + headers: Record; +} + +export interface RunResult { + request: CapturedRequest | null; + calls: number; + stdout: string; +} + +/** + * Drives the CLI through its real entry point (argv parsing → request building) + * with `fetch` stubbed at the network boundary, and returns the HTTP request the + * command produced. This is the stable boundary: internals can be rewritten and + * these assertions still hold as long as the emitted request is unchanged. + */ +export async function runCli( + args: string[], + response: { status?: number; body?: unknown } = {} +): Promise { + const prevTokenId = process.env.GRID_API_TOKEN_ID; + const prevClientSecret = process.env.GRID_API_CLIENT_SECRET; + const prevExitCode = process.exitCode; + process.env.GRID_API_TOKEN_ID = "test-token-id"; + process.env.GRID_API_CLIENT_SECRET = "test-client-secret"; + + let captured: CapturedRequest | null = null; + let calls = 0; + + const fetchMock = vi.fn(async (input: string | URL, init?: RequestInit) => { + calls += 1; + const url = new URL(String(input)); + const headers = normalizeHeaders(init?.headers); + captured = { + method: init?.method ?? "GET", + url, + path: url.pathname, + query: Object.fromEntries(url.searchParams.entries()), + body: init?.body ? JSON.parse(init.body as string) : undefined, + headers, + }; + return new Response(JSON.stringify(response.body ?? { ok: true }), { + status: response.status ?? 200, + headers: { "content-type": "application/json" }, + }); + }); + + const originalFetch = globalThis.fetch; + globalThis.fetch = fetchMock as unknown as typeof fetch; + + const stdout: string[] = []; + const logSpy = vi + .spyOn(console, "log") + .mockImplementation((...parts: unknown[]) => { + stdout.push(parts.map(String).join(" ")); + }); + + try { + const program = await buildProgram(); + program.exitOverride(); + // A stable base URL keeps assertions on `path`/`query` independent of the + // default production host. + await program.parseAsync([ + "node", + "grid", + "--base-url", + "https://api.test/grid/v1", + ...args, + ]); + } finally { + globalThis.fetch = originalFetch; + logSpy.mockRestore(); + restoreEnv("GRID_API_TOKEN_ID", prevTokenId); + restoreEnv("GRID_API_CLIENT_SECRET", prevClientSecret); + // Actions signal failure via process.exitCode; reset so one test's failure + // path can't bleed into the next test or the runner's exit status. + process.exitCode = prevExitCode; + } + + return { request: captured, calls, stdout: stdout.join("\n") }; +} + +function restoreEnv(key: string, value: string | undefined): void { + if (value === undefined) delete process.env[key]; + else process.env[key] = value; +} + +function normalizeHeaders( + headers: RequestInit["headers"] +): Record { + if (!headers) return {}; + if (headers instanceof Headers) { + return Object.fromEntries(headers.entries()); + } + if (Array.isArray(headers)) { + return Object.fromEntries(headers); + } + return { ...(headers as Record) }; +} diff --git a/cli/test/quotes.test.ts b/cli/test/quotes.test.ts new file mode 100644 index 000000000..69be20e88 --- /dev/null +++ b/cli/test/quotes.test.ts @@ -0,0 +1,134 @@ +import { describe, it, expect } from "vitest"; +import { runCli } from "./helpers"; + +describe("quotes create", () => { + it("sends purposeOfPayment as a top-level field, not inside senderCustomerInfo", async () => { + const { request } = await runCli([ + "quotes", + "create", + "--amount", + "1000", + "--lock-side", + "SENDING", + "--source-account", + "InternalAccount:abc", + "--dest-uma", + "$bob@uma.example.com", + "--dest-currency", + "USD", + "--purpose-of-payment", + "GOODS_OR_SERVICES", + ]); + + const body = request?.body as Record; + expect(body.purposeOfPayment).toBe("GOODS_OR_SERVICES"); + expect( + (body.senderCustomerInfo as Record | undefined) + ?.PURPOSE_OF_PAYMENT + ).toBeUndefined(); + }); + + it("sends top-level remittanceInformation", async () => { + const { request } = await runCli([ + "quotes", + "create", + "--amount", + "1000", + "--lock-side", + "SENDING", + "--source-account", + "InternalAccount:abc", + "--dest-account", + "ExternalAccount:xyz", + "--remittance-information", + "Invoice 42", + ]); + + expect((request?.body as Record).remittanceInformation).toBe( + "Invoice 42" + ); + }); + + it("sets paymentRail on an ACCOUNT destination", async () => { + const { request } = await runCli([ + "quotes", + "create", + "--amount", + "1000", + "--lock-side", + "SENDING", + "--source-account", + "InternalAccount:abc", + "--dest-account", + "ExternalAccount:xyz", + "--payment-rail", + "ACH", + ]); + + expect(request?.body).toMatchObject({ + destination: { destinationType: "ACCOUNT", paymentRail: "ACH" }, + }); + }); + + it("sets cryptoNetwork on a realtime-funding source", async () => { + const { request } = await runCli([ + "quotes", + "create", + "--amount", + "1000", + "--lock-side", + "SENDING", + "--source-customer", + "Customer:abc", + "--source-currency", + "USDC", + "--crypto-network", + "SOLANA", + "--dest-account", + "ExternalAccount:xyz", + ]); + + expect(request?.body).toMatchObject({ + source: { + sourceType: "REALTIME_FUNDING", + currency: "USDC", + cryptoNetwork: "SOLANA", + }, + }); + }); +}); + +describe("quotes create validation", () => { + it("rejects a realtime-funding source without a currency", async () => { + const { calls } = await runCli([ + "quotes", + "create", + "--amount", + "1000", + "--lock-side", + "SENDING", + "--source-customer", + "Customer:abc", + "--dest-account", + "ExternalAccount:xyz", + ]); + + expect(calls).toBe(0); + }); +}); + +describe("quotes execute", () => { + it("sends scaFactor in the body when provided", async () => { + const { request } = await runCli([ + "quotes", + "execute", + "quote_1", + "--sca-factor", + "SMS_OTP", + ]); + + expect(request?.method).toBe("POST"); + expect(request?.path).toBe("/grid/v1/quotes/quote_1/execute"); + expect(request?.body).toMatchObject({ scaFactor: "SMS_OTP" }); + }); +}); diff --git a/cli/test/sandbox.test.ts b/cli/test/sandbox.test.ts new file mode 100644 index 000000000..56bd52e8e --- /dev/null +++ b/cli/test/sandbox.test.ts @@ -0,0 +1,70 @@ +import { describe, it, expect } from "vitest"; +import { runCli } from "./helpers"; + +describe("sandbox receive", () => { + it("uses the schema field names receivingCurrencyAmount and receivingCurrencyCode", async () => { + const { request } = await runCli([ + "sandbox", + "receive", + "--uma-address", + "$receiver@uma.example.com", + "--sender-uma", + "$sender@sandbox.example.com", + "--amount", + "1000", + "--currency", + "USD", + ]); + + expect(request?.body).toMatchObject({ + receiverUmaAddress: "$receiver@uma.example.com", + senderUmaAddress: "$sender@sandbox.example.com", + receivingCurrencyAmount: 1000, + receivingCurrencyCode: "USD", + }); + const body = request?.body as Record; + expect(body.amount).toBeUndefined(); + expect(body.currency).toBeUndefined(); + }); + + it("accepts customerId as an alternative to the receiver UMA address", async () => { + const { request } = await runCli([ + "sandbox", + "receive", + "--customer-id", + "Customer:abc", + "--sender-uma", + "$sender@sandbox.example.com", + "--amount", + "500", + "--currency", + "USD", + ]); + + expect(request?.body).toMatchObject({ + customerId: "Customer:abc", + receivingCurrencyAmount: 500, + }); + }); +}); + +describe("config update", () => { + it("sends supportedCurrencies and embeddedWalletConfig from JSON inputs", async () => { + const { request } = await runCli([ + "config", + "update", + "--supported-currencies", + '[{"currencyCode":"USD","minAmount":100,"maxAmount":100000}]', + "--embedded-wallet-config", + '{"appName":"Acme","otpLength":6}', + ]); + + expect(request?.method).toBe("PATCH"); + expect(request?.body).toMatchObject({ + supportedCurrencies: [ + { currencyCode: "USD", minAmount: 100, maxAmount: 100000 }, + ], + embeddedWalletConfig: { appName: "Acme", otpLength: 6 }, + }); + }); +}); diff --git a/cli/test/transactions.test.ts b/cli/test/transactions.test.ts new file mode 100644 index 000000000..1b646499e --- /dev/null +++ b/cli/test/transactions.test.ts @@ -0,0 +1,57 @@ +import { describe, it, expect } from "vitest"; +import { runCli } from "./helpers"; + +describe("transactions list", () => { + it("passes the accountIdentifier filter", async () => { + const { request } = await runCli([ + "transactions", + "list", + "--account-identifier", + "InternalAccount:abc", + ]); + + expect(request?.query).toMatchObject({ + accountIdentifier: "InternalAccount:abc", + }); + }); +}); + +describe("transactions approve", () => { + it("sends receiverCustomerInfo from a JSON option", async () => { + const { request } = await runCli([ + "transactions", + "approve", + "txn_1", + "--receiver-customer-info", + '{"FULL_NAME":"Ada Lovelace"}', + ]); + + expect(request?.method).toBe("POST"); + expect(request?.path).toBe("/grid/v1/transactions/txn_1/approve"); + expect(request?.body).toMatchObject({ + receiverCustomerInfo: { FULL_NAME: "Ada Lovelace" }, + }); + }); +}); + +describe("receiver lookup-account", () => { + it("passes senderUmaAddress and customerId query params", async () => { + const { request } = await runCli([ + "receiver", + "lookup-account", + "ExternalAccount:abc", + "--sender-uma", + "$sender@uma.example.com", + "--customer-id", + "Customer:xyz", + ]); + + expect(request?.path).toBe( + "/grid/v1/receiver/external-account/ExternalAccount%3Aabc" + ); + expect(request?.query).toMatchObject({ + senderUmaAddress: "$sender@uma.example.com", + customerId: "Customer:xyz", + }); + }); +}); diff --git a/cli/test/transfers.test.ts b/cli/test/transfers.test.ts new file mode 100644 index 000000000..bfa084451 --- /dev/null +++ b/cli/test/transfers.test.ts @@ -0,0 +1,24 @@ +import { describe, it, expect } from "vitest"; +import { runCli } from "./helpers"; + +describe("transfers out", () => { + it("sends remittanceInformation and destination paymentRail", async () => { + const { request } = await runCli([ + "transfers", + "out", + "--source", + "InternalAccount:abc", + "--dest", + "ExternalAccount:xyz", + "--remittance-information", + "Payroll", + "--payment-rail", + "ACH", + ]); + + expect(request?.body).toMatchObject({ + remittanceInformation: "Payroll", + destination: { accountId: "ExternalAccount:xyz", paymentRail: "ACH" }, + }); + }); +}); diff --git a/cli/vitest.config.ts b/cli/vitest.config.ts new file mode 100644 index 000000000..ed8bf7739 --- /dev/null +++ b/cli/vitest.config.ts @@ -0,0 +1,8 @@ +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + environment: "node", + include: ["test/**/*.test.ts"], + }, +}); diff --git a/docs/plans/2026-07-18-grid-cli-drift-fixes.md b/docs/plans/2026-07-18-grid-cli-drift-fixes.md deleted file mode 100644 index 4026ebb44..000000000 --- a/docs/plans/2026-07-18-grid-cli-drift-fixes.md +++ /dev/null @@ -1,93 +0,0 @@ -# Fix drift on existing Grid CLI commands - -## Context -The hand-written Grid CLI (`cli/`) has drifted from the current OpenAPI spec (`2025-10-13`). This first pass fixes the commands the CLI **already has** so they produce valid requests against today's API — starting point requested in the originating Slack thread. New endpoints/domains (agents, cards, sca, auth, tokens, etc.) are explicitly **out of scope** here. - -A field-by-field reconciliation of every implemented command against `openapi/components/schemas/**` surfaced five commands that build **invalid requests today** (real bugs), plus a set of missing/renamed fields and stale response types. Base URL, auth, error parsing, and pagination envelope are already correct — no change needed there. - -## Approach -One PR, scoped to correctness of existing commands. Group work as: -1. **Breaking-bug fixes** — commands that currently send invalid bodies/discriminators. -2. **Field drift** — add/rename fields the current schema requires or supports on those same commands (high-value, correctness-adjacent). -3. **Stale response interfaces** — refresh the TS types the CLI declares for responses (output is JSON passthrough, so these are non-breaking but misleading and can break `table` mode / typed access). -4. **Test harness + tests** — the CLI has *no* tests today. Add `vitest` and boundary tests that mock `global.fetch` and assert the constructed request (method/path/body) for each fixed command. This is how we prove "works correctly." - -Deferred to explicit follow-ups (noted in PR, not done here): expanding external-account create to all ~37 currency variants + `SWIFT/LIGHTNING/ETHEREUM`, embedded-wallet `Grid-Wallet-Signature` signing, `AgentAuth` bearer, and net-new subcommands (quotes authorize/resend, transactions confirm, customers verify/trust/bulk, accounts get/update/export). These are "new surface," not drift on existing commands. - -### Test-boundary decision -The stable boundary for a CLI is: parse argv → build request → call HTTP. Tests drive `program.parseAsync(["node","grid", ...args])` with `global.fetch` stubbed and creds supplied via `GRID_API_TOKEN_ID`/`GRID_API_CLIENT_SECRET` env + `--base-url`, then assert on the captured `fetch(url, init)`. No internal/private functions touched — survives refactor. - -## Changes - -### 1. `cli/src/commands/customers.ts` -- **What**: - - `update`: **send `customerType`** (required oneOf discriminator — updates are rejected without it). Add `--type` option; route individual vs business fields under the correct variant. (BUG) - - `create` business: send required **`incorporatedOn`** (add `--incorporated-on`); currently every business create is rejected. (BUG) - - Add missing create/update fields: top-level `email`, `phoneNumber`, `region`, `currencies`; individual `nationality`, `idType`+`identifier`; business KYB `entityType`, `businessType`, `doingBusinessAs`, `country`, `purposeOfAccount`, `naicsCode`, `sourceOfFundsCategories`, `sourceOfFunds`, `expectedCounterpartyCountries`, `expectedMonthlyTransactionCount`, `expectedMonthlyTransactionVolume`; address `line2`. - - `list`: add `--region`, `--currency`, `--created-after/before`, `--updated-after/before`, `--include-deleted`. - - Only emit the `address` object when required members (`line1`, `postalCode`, `country`) are present. -- **Why**: `update`/`business create` are broken; the rest are schema-supported fields with no CLI surface. -- **Code sketch**: - ```ts - // update: discriminator first - const body: Record = { customerType: options.type }; - if (options.type === "INDIVIDUAL") { /* fullName, birthDate, nationality, address */ } - else if (options.type === "BUSINESS") { body.businessInfo = { legalName, taxId, incorporatedOn, ... }; } - ``` - -### 2. `cli/src/commands/quotes.ts` -- **What**: - - Move `purposeOfPayment` from `senderCustomerInfo.PURPOSE_OF_PAYMENT` to the **top-level** `purposeOfPayment` field. (BUG — currently mis-placed) - - Add `--remittance-information` (top-level, maxLength 80). - - Add `--payment-rail` on ACCOUNT destination; add `--source-customer` support on ACCOUNT source (`customerId`); add `--crypto-network` on REALTIME_FUNDING source (required for stablecoin funding). - - Refresh the `Quote` response interface to current `Quote.yaml` (`totalSendingAmount`/`totalReceivingAmount`/`feesIncluded`, currency objects, `transactionId`, `PENDING_AUTHORIZATION`). -- **Why**: purposeOfPayment silently ignored today; other fields are supported and needed for common flows (remittance, stablecoin funding, rail selection). - -### 3. `cli/src/commands/transfers.ts` -- **What**: add `--remittance-information` and destination `--payment-rail` to `transfers out` (both on `TransferOutRequest`/`ExternalAccountDestinationReference`). `transfers in` is already correct — no change. -- **Why**: schema-supported top-level fields with no CLI surface. - -### 4. `cli/src/commands/sandbox.ts` -- **What** (BUG): `sandbox receive` — rename body fields `amount`→`receivingCurrencyAmount`, `currency`→`receivingCurrencyCode`; make `senderUmaAddress` **required** (schema requires it); add `--customer-id` as an alternative to `--uma-address` (`receiverUmaAddress`). `sandbox send`/`fund` are correct — no change. -- **Why**: `sandbox receive` currently sends two fields that don't exist in the schema and omits a required one — it cannot produce a valid request. - -### 5. `cli/src/commands/config.ts` -- **What**: `config update` — add `--supported-currencies` (JSON array of `PlatformCurrencyConfig`) and `--embedded-wallet-config` (JSON object) inputs (accept JSON strings; validate parse). Refresh the `PlatformConfig` interface to match `PlatformConfig.yaml`. -- **Why**: the two core mutable config surfaces (per-currency limits/txn types, embedded-wallet branding/OTP) have no CLI flag today. - -### 6. `cli/src/commands/accounts.ts` -- **What**: - - `external create` (BUG): replace legacy `--account-type` discriminator values with the current currency-suffixed enum — `US_ACCOUNT→USD_ACCOUNT`, `CLABE→MXN_ACCOUNT`, `PIX→BRL_ACCOUNT`, `IBAN→EUR_ACCOUNT`, `UPI→INR_ACCOUNT` (wallet types already correct). Drop the nonexistent NGN `purposeOfPayment` field; fix US `accountCategory`→`bankAccountType` (`CHECKING`/`SAVINGS`). Make `beneficiary` **required** for fiat create; add beneficiary `email`/`phoneNumber`/`countryOfResidence`, business `registrationNumber`/`taxId`, and address `line2`. Add `--swift-code` (EUR), INR non-UPI (`accountNumber`/`ifsc`/`rail`/`bankName`), and top-level `--platform-account-id`/`--default-uma-deposit-account`. - - `internal list`: add `--type` (`InternalAccountType`); for `--platform`, send only `currency`+`type` (stop sending `limit`/`cursor`/`customerId`, unsupported there). - - `external list`: for `--platform`, stop sending `customerId` (unsupported). - - Refresh `InternalAccount`/`ExternalAccount` interfaces to current schema. -- **Why**: 5 of the advertised fiat account types are outright rejected today (dead discriminators); fiat create omits a required `beneficiary`; platform list sends unsupported params. - -### 7. `cli/src/commands/transactions.ts` + `cli/src/commands/receiver.ts` -- **What**: - - `transactions list`: add `--account-identifier`. `approve`: add optional `--receiver-customer-info` (JSON). Refresh the `Transaction` interface (drop `amount`/`currency`/`sender…`/`receiver…`/`reference`; model `source`/`destination` incl. `onChainTransaction`, `receivedAmount`/`sentAmount`, `direction`, `CARD` variant). - - `receiver lookup-account`: add `--sender-uma`/`--customer-id` query params. Refresh `ReceiverLookup` interface (`lookupId`, `receiverUmaAddress`/`accountId`, `supportedCurrencies` with nested `currency`+`estimatedExchangeRate`+`min`/`max`, `requiredPayerDataFields`). -- **Why**: missing supported params; response interfaces are badly stale (wrong field names + currency shape), which misleads and drops fields in `table` mode. - -### 8. `cli/src/validation.ts` -- **What**: remove the hardcoded 13-currency `VALID_CURRENCIES` allowlist (rejects ~24 currencies the API now supports); let the API validate currency codes, keeping only format checks. Remove now-dead `validateCustomerType` if unused after wiring, else keep. -- **Why**: client-side allowlist silently rejects valid, currently-supported currencies — a drift trap. - -### 9. `cli/src/client.ts` -- **What**: map `error.details` to the schema's inner `details` object rather than the whole response body. -- **Why**: faithful mapping of the error schema (`{status, code, message, details}`). - -### 10. Test harness — `cli/package.json`, `cli/vitest.config.ts` (new), `cli/test/**` (new) -- **What**: add `vitest` devDep + `"test": "vitest run"` script. Add a `runCli(args)` helper that stubs `global.fetch`, sets creds via env, runs `program.parseAsync`, and returns the captured request. One test file per fixed command asserting method/path/body — explicitly covering each BUG fix (customerType on update, incorporatedOn, purposeOfPayment placement, sandbox-receive field names, account-type discriminators, beneficiary required). -- **Why**: no tests exist; TDD requires a failing test first, and these lock the drift fixes. - -## Verification -- [ ] `cd cli && npm install && npm run build` — clean TypeScript compile (strict mode). -- [ ] `npm test` — all new boundary tests pass; each BUG fix has a red→green test. -- [ ] Spot-check a captured request body for `quotes create`, `customers update`, `sandbox receive`, `accounts external create` matches the corresponding `openapi/components/schemas/**` request schema. -- [ ] `grid --help` and per-command `--help` render (help text updated for renamed options). - -## Risks -- **Renamed options are user-facing breaking changes** (e.g. `--account-type US_ACCOUNT`→`USD_ACCOUNT`). Given these values are *already broken* against the API, changing them is a fix, not a regression — will call out in the PR/README and CLI version bump. -- Scope: exhaustive currency coverage and net-new subcommands are deferred; if the reviewer wants them folded in, that expands the PR substantially. -- `config update`/`approve` taking JSON-string inputs for nested objects is a pragmatic choice for a flag-based CLI; alternative is many granular flags. Noted for redirect.