From 9718d4ae5c4c516f81cbab1b0e02dc6b2c216c99 Mon Sep 17 00:00:00 2001 From: Ekong Jemimah Date: Tue, 29 Sep 2026 22:43:03 +0100 Subject: [PATCH 1/2] docs(openapi): document /tokens, exports, balance and internal offramp routes (W085) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every route the app serves must appear in the generated spec. Adds zod schemas and registry entries for GET /tokens, GET /transfers.csv, GET /transfers.parquet, GET /accounts/:address/balance and POST /webhooks/linq, and documents the five /offramp/* routes as internal (deprecated: true with a rationale). The walker-based test in openapiCoverage.test.ts fails on main, guards against vacuous passes, and asserts both committed spec copies match the generator output. ๐Ÿค– Generated with Codebuff Co-Authored-By: Codebuff --- README.md | 68 ++ openapi.json | 1600 +++++++++++++++++++++++++ src/__tests__/openapiCoverage.test.ts | 162 +++ src/openapi/build.ts | 212 +++- src/openapi/schemas.ts | 158 +++ 5 files changed, 2191 insertions(+), 9 deletions(-) create mode 100644 src/__tests__/openapiCoverage.test.ts diff --git a/README.md b/README.md index 0959a642a..e068cf9c5 100644 --- a/README.md +++ b/README.md @@ -615,6 +615,51 @@ curl "http://localhost:3000/transfers/tx/abcdef1234567890..." *** +### `GET /tokens` + +All tokens the indexer has encountered and cached, on the selected network. +Accepts the same `?network=` / `X-Network` selector as every read route. + +```bash +curl "http://localhost:3000/tokens?network=mainnet" +``` + +```json +{ + "ok": true, + "network": "mainnet", + "tokens": [ + { + "network": "mainnet", + "contractId": "CB64D3G7SM2RTH6ISYIG4P2IYYD6J2OFR6B", + "symbol": "USDC", + "name": "USD Coin", + "decimals": 7 + } + ] +} +``` + +*** + +### `GET /transfers.csv` ยท `GET /transfers.parquet` + +Export the **entire matching transfer set** as a CSV or Apache Parquet download โ€” +no pagination. Accepts the same filters as the JSON transfer routes +(`network`, `address`, `contractId`, `token`, `fromLedger`, `toLedger`, +`fromDate`, `toDate`, `eventType`) minus the pagination fields; narrow large +exports with the ledger or date bounds. + +```bash +# Everything for one address on mainnet +curl -OJ "http://localhost:3000/transfers.csv?address=GABC123...&network=mainnet" + +# A ledger window across all addresses, as Parquet +curl -OJ "http://localhost:3000/transfers.parquet?fromLedger=5840000&toLedger=5850000" +``` + +*** + ## Environment Variables | Variable | Default | Description | @@ -745,6 +790,7 @@ curl -H "Accept: application/vnd.api+json" http://localhost:3000/summary/GABC123 | `GET /transfers/tx/:txHash` | `transfer` | | `GET /summary/:address` | `token-summary` | | `GET /accounts/:address/summary` | `account-summary` | +| `GET /accounts/:address/balance` | `account-balance` | | `GET /accounts/:address/transfers` | `transfer` | | `GET /assets/popular` | `popular-asset` | | `GET /nfts/transfers` | `nft-transfer` | @@ -755,6 +801,28 @@ curl -H "Accept: application/vnd.api+json" http://localhost:3000/summary/GABC123 `GET /metrics` is not a JSON:API resource โ€” it serves Prometheus text format. +The routes below are live but outside the JSON:API surface โ€” no resource +type, no `data` envelope: + +| Endpoint | Notes | +|----------|-------| +| `GET /tokens` | Cached token metadata for the selected network | +| `GET /transfers.csv` | Full-set CSV export (no pagination) | +| `GET /transfers.parquet` | Full-set Parquet export (no pagination) | +| `GET /accounts/:address/balance` | Derived balances, not on-chain reads | + +Internal routes โ€” not part of the public data API, and marked `deprecated: true` +with a rationale in `openapi.json`: + +| Endpoint | Notes | +|----------|-------| +| `POST /webhooks/linq` | Signature-verified payout provider callback | +| `GET /offramp/rate` | Wallet-only cash-out surface (server-side provider API) | +| `POST /offramp/verify-bank` | Wallet-only cash-out surface (server-side provider API) | +| `GET /offramp/trustline` | Wallet-only cash-out surface (server-side provider API) | +| `POST /offramp/orders` | Wallet-only cash-out surface (server-side provider API) | +| `GET /offramp/orders/:orderId` | Wallet-only cash-out surface (server-side provider API) | + *** ## Event Types Indexed diff --git a/openapi.json b/openapi.json index 67f721217..b0624ced5 100644 --- a/openapi.json +++ b/openapi.json @@ -2357,6 +2357,144 @@ } } }, + "/accounts/{address}/balance": { + "get": { + "summary": "Derived token balances", + "description": "Per-token balance for an address, derived by summing indexed transfers โ€” not a read from chain. Excludes any history before the indexer's start ledger, so an address that held a token before then reads low.", + "parameters": [ + { + "schema": { + "type": "string", + "minLength": 1, + "example": "GABCDEFGHIJKLMNOPQRSTUVWXYZ" + }, + "required": true, + "name": "address", + "in": "path" + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "address": { + "type": "string", + "minLength": 1, + "example": "GABCDEFGHIJKLMNOPQRSTUVWXYZ" + }, + "network": { + "type": "string", + "enum": [ + "testnet", + "mainnet" + ] + }, + "balances": { + "type": "array", + "items": { + "type": "object", + "properties": { + "contractId": { + "type": "string", + "pattern": "^C[A-Z2-7]{55}$", + "example": "CB64D3G7SM2RTH6ISYIG4P2IYYD6J2OFR6B" + }, + "balance": { + "type": "string" + }, + "displayBalance": { + "type": "string" + } + }, + "required": [ + "contractId", + "balance", + "displayBalance" + ] + } + }, + "derivedFromLedger": { + "type": "boolean", + "enum": [ + true + ] + }, + "note": { + "type": "string" + } + }, + "required": [ + "address", + "network", + "balances", + "derivedFromLedger", + "note" + ] + } + } + } + }, + "400": { + "description": "Bad Request", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string" + } + }, + "required": [ + "error" + ] + } + } + } + }, + "404": { + "description": "Not Found", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string" + } + }, + "required": [ + "error" + ] + } + } + } + }, + "500": { + "description": "Internal Server Error", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string" + } + }, + "required": [ + "error" + ] + } + } + } + } + } + } + }, "/accounts/{address}/transfers": { "get": { "summary": "Account transfers", @@ -4289,6 +4427,1468 @@ } } } + }, + "/tokens": { + "get": { + "summary": "Cached token metadata", + "description": "All tokens the indexer has encountered and cached, on the selected network. Served from the in-memory token cache โ€” a token appears here once the indexer has seen a transfer of it and resolved its symbol, name and decimals.", + "parameters": [ + { + "schema": { + "type": "string", + "nullable": true, + "enum": [ + "testnet", + "mainnet", + null + ], + "description": "Network to read from. Defaults to the deployment's configured network. May also be sent as the X-Network header; the query parameter wins.", + "example": "mainnet" + }, + "required": false, + "description": "Network to read from. Defaults to the deployment's configured network. May also be sent as the X-Network header; the query parameter wins.", + "name": "network", + "in": "query" + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "ok": { + "type": "boolean", + "enum": [ + true + ] + }, + "network": { + "type": "string", + "enum": [ + "testnet", + "mainnet" + ] + }, + "tokens": { + "type": "array", + "items": { + "type": "object", + "properties": { + "network": { + "type": "string", + "enum": [ + "testnet", + "mainnet" + ] + }, + "contractId": { + "type": "string", + "pattern": "^C[A-Z2-7]{55}$", + "example": "CB64D3G7SM2RTH6ISYIG4P2IYYD6J2OFR6B" + }, + "symbol": { + "type": "string" + }, + "name": { + "type": "string" + }, + "decimals": { + "type": "integer" + } + }, + "required": [ + "network", + "contractId", + "symbol", + "name", + "decimals" + ] + } + } + }, + "required": [ + "ok", + "network", + "tokens" + ] + } + } + } + }, + "400": { + "description": "Bad Request", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string" + } + }, + "required": [ + "error" + ] + } + } + } + }, + "404": { + "description": "Not Found", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string" + } + }, + "required": [ + "error" + ] + } + } + } + }, + "500": { + "description": "Internal Server Error", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string" + } + }, + "required": [ + "error" + ] + } + } + } + } + } + } + }, + "/transfers.csv": { + "get": { + "summary": "Export all transfers as CSV", + "description": "Streams every transfer matching the filters as CSV. No pagination: the response is the entire matching set, so narrow it with the ledger or date bounds on large deployments.", + "parameters": [ + { + "schema": { + "type": "string", + "nullable": true, + "enum": [ + "testnet", + "mainnet", + null + ], + "description": "Network to read from. Defaults to the deployment's configured network. May also be sent as the X-Network header; the query parameter wins.", + "example": "mainnet" + }, + "required": false, + "description": "Network to read from. Defaults to the deployment's configured network. May also be sent as the X-Network header; the query parameter wins.", + "name": "network", + "in": "query" + }, + { + "schema": { + "type": "string", + "minLength": 1, + "example": "GABCDEFGHIJKLMNOPQRSTUVWXYZ" + }, + "required": false, + "name": "address", + "in": "query" + }, + { + "schema": { + "type": "string", + "nullable": true, + "description": "Token contract ID to filter by" + }, + "required": false, + "description": "Token contract ID to filter by", + "name": "contractId", + "in": "query" + }, + { + "schema": { + "type": "string", + "pattern": "^C[A-Z2-7]{55}$", + "example": "CB64D3G7SM2RTH6ISYIG4P2IYYD6J2OFR6B" + }, + "required": false, + "name": "token", + "in": "query" + }, + { + "schema": { + "type": "integer", + "nullable": true + }, + "required": false, + "name": "fromLedger", + "in": "query" + }, + { + "schema": { + "type": "integer", + "nullable": true + }, + "required": false, + "name": "toLedger", + "in": "query" + }, + { + "schema": { + "type": "string", + "nullable": true, + "format": "date-time", + "description": "Inclusive lower bound on ledgerClosedAt" + }, + "required": false, + "description": "Inclusive lower bound on ledgerClosedAt", + "name": "fromDate", + "in": "query" + }, + { + "schema": { + "type": "string", + "nullable": true, + "format": "date-time", + "description": "Inclusive upper bound on ledgerClosedAt" + }, + "required": false, + "description": "Inclusive upper bound on ledgerClosedAt", + "name": "toDate", + "in": "query" + }, + { + "schema": { + "type": "array", + "nullable": true, + "items": { + "type": "string" + }, + "description": "Comma-separated list of event types" + }, + "required": false, + "description": "Comma-separated list of event types", + "name": "eventType", + "in": "query" + } + ], + "responses": { + "200": { + "description": "File download of the full matching transfer set (no pagination).", + "content": { + "text/csv": { + "schema": { + "type": "string", + "format": "binary" + } + }, + "application/octet-stream": { + "schema": { + "type": "string", + "format": "binary" + } + } + } + }, + "400": { + "description": "Bad Request", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string" + } + }, + "required": [ + "error" + ] + } + } + } + }, + "404": { + "description": "Not Found", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string" + } + }, + "required": [ + "error" + ] + } + } + } + }, + "500": { + "description": "Internal Server Error", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string" + } + }, + "required": [ + "error" + ] + } + } + } + } + } + } + }, + "/transfers.parquet": { + "get": { + "summary": "Export all transfers as Parquet", + "description": "Streams every transfer matching the filters as an Apache Parquet file. Same filters and streaming behaviour as /transfers.csv.", + "parameters": [ + { + "schema": { + "type": "string", + "nullable": true, + "enum": [ + "testnet", + "mainnet", + null + ], + "description": "Network to read from. Defaults to the deployment's configured network. May also be sent as the X-Network header; the query parameter wins.", + "example": "mainnet" + }, + "required": false, + "description": "Network to read from. Defaults to the deployment's configured network. May also be sent as the X-Network header; the query parameter wins.", + "name": "network", + "in": "query" + }, + { + "schema": { + "type": "string", + "minLength": 1, + "example": "GABCDEFGHIJKLMNOPQRSTUVWXYZ" + }, + "required": false, + "name": "address", + "in": "query" + }, + { + "schema": { + "type": "string", + "nullable": true, + "description": "Token contract ID to filter by" + }, + "required": false, + "description": "Token contract ID to filter by", + "name": "contractId", + "in": "query" + }, + { + "schema": { + "type": "string", + "pattern": "^C[A-Z2-7]{55}$", + "example": "CB64D3G7SM2RTH6ISYIG4P2IYYD6J2OFR6B" + }, + "required": false, + "name": "token", + "in": "query" + }, + { + "schema": { + "type": "integer", + "nullable": true + }, + "required": false, + "name": "fromLedger", + "in": "query" + }, + { + "schema": { + "type": "integer", + "nullable": true + }, + "required": false, + "name": "toLedger", + "in": "query" + }, + { + "schema": { + "type": "string", + "nullable": true, + "format": "date-time", + "description": "Inclusive lower bound on ledgerClosedAt" + }, + "required": false, + "description": "Inclusive lower bound on ledgerClosedAt", + "name": "fromDate", + "in": "query" + }, + { + "schema": { + "type": "string", + "nullable": true, + "format": "date-time", + "description": "Inclusive upper bound on ledgerClosedAt" + }, + "required": false, + "description": "Inclusive upper bound on ledgerClosedAt", + "name": "toDate", + "in": "query" + }, + { + "schema": { + "type": "array", + "nullable": true, + "items": { + "type": "string" + }, + "description": "Comma-separated list of event types" + }, + "required": false, + "description": "Comma-separated list of event types", + "name": "eventType", + "in": "query" + } + ], + "responses": { + "200": { + "description": "File download of the full matching transfer set (no pagination).", + "content": { + "text/csv": { + "schema": { + "type": "string", + "format": "binary" + } + }, + "application/octet-stream": { + "schema": { + "type": "string", + "format": "binary" + } + } + } + }, + "400": { + "description": "Bad Request", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string" + } + }, + "required": [ + "error" + ] + } + } + } + }, + "404": { + "description": "Not Found", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string" + } + }, + "required": [ + "error" + ] + } + } + } + }, + "500": { + "description": "Internal Server Error", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string" + } + }, + "required": [ + "error" + ] + } + } + } + } + } + } + }, + "/webhooks/linq": { + "post": { + "summary": "Linq payout webhook (provider callback)", + "description": "Internal endpoint. Not part of the public data API: (provider callback / wallet-only cash-out surface served on behalf of the Veil wallet client).", + "deprecated": true, + "responses": { + "200": { + "description": "Acknowledged", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "ok": { + "type": "boolean", + "enum": [ + true + ] + } + }, + "required": [ + "ok" + ] + } + } + } + }, + "400": { + "description": "Unrecognised event", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string" + } + }, + "required": [ + "error" + ] + } + } + } + }, + "401": { + "description": "Invalid signature", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string" + } + }, + "required": [ + "error" + ] + } + } + } + } + } + } + }, + "/offramp/rate": { + "get": { + "summary": "Indicative NGN/USDC rate", + "description": "Internal endpoint. Not part of the public data API: (provider callback / wallet-only cash-out surface served on behalf of the Veil wallet client).", + "deprecated": true, + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "rate": { + "type": "number" + }, + "currency": { + "type": "string" + }, + "coin": { + "type": "string" + }, + "indicative": { + "type": "boolean", + "enum": [ + true + ] + } + }, + "required": [ + "rate", + "currency", + "coin", + "indicative" + ] + } + } + } + }, + "400": { + "description": "Bad Request", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string" + } + }, + "required": [ + "error" + ] + } + } + } + }, + "404": { + "description": "Not Found", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string" + } + }, + "required": [ + "error" + ] + } + } + } + }, + "500": { + "description": "Internal Server Error", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string" + } + }, + "required": [ + "error" + ] + } + } + } + }, + "502": { + "description": "Payout provider error", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string" + } + }, + "required": [ + "error" + ] + } + } + } + }, + "503": { + "description": "Offramp not configured on this deployment", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string" + } + }, + "required": [ + "error" + ] + } + } + } + } + } + } + }, + "/offramp/verify-bank": { + "post": { + "summary": "Verify a bank account before creating an order", + "description": "Internal endpoint. Not part of the public data API: (provider callback / wallet-only cash-out surface served on behalf of the Veil wallet client).", + "deprecated": true, + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "bankCode": { + "type": "string", + "minLength": 1 + }, + "accountNumber": { + "type": "string", + "minLength": 1 + } + }, + "required": [ + "bankCode", + "accountNumber" + ], + "additionalProperties": { + "nullable": true + } + } + } + } + }, + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "accountName": { + "type": "string" + }, + "bankName": { + "type": "string" + }, + "accountNumber": { + "type": "string" + }, + "bankCode": { + "type": "string" + } + }, + "required": [ + "accountName", + "bankName", + "accountNumber", + "bankCode" + ] + } + } + } + }, + "400": { + "description": "Bad Request", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string" + } + }, + "required": [ + "error" + ] + } + } + } + }, + "404": { + "description": "Not Found", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string" + } + }, + "required": [ + "error" + ] + } + } + } + }, + "500": { + "description": "Internal Server Error", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string" + } + }, + "required": [ + "error" + ] + } + } + } + }, + "502": { + "description": "Payout provider error", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string" + } + }, + "required": [ + "error" + ] + } + } + } + }, + "503": { + "description": "Offramp not configured on this deployment", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string" + } + }, + "required": [ + "error" + ] + } + } + } + } + } + } + }, + "/offramp/trustline": { + "get": { + "summary": "Check a refund address holds a USDC trustline", + "description": "Internal endpoint. Not part of the public data API: (provider callback / wallet-only cash-out surface served on behalf of the Veil wallet client).", + "deprecated": true, + "parameters": [ + { + "schema": { + "type": "string", + "minLength": 1 + }, + "required": true, + "name": "address", + "in": "query" + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "address": { + "type": "string" + }, + "valid": { + "type": "boolean" + }, + "trustsUSDC": { + "type": "boolean" + } + }, + "required": [ + "address", + "valid", + "trustsUSDC" + ] + } + } + } + }, + "400": { + "description": "Bad Request", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string" + } + }, + "required": [ + "error" + ] + } + } + } + }, + "404": { + "description": "Not Found", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string" + } + }, + "required": [ + "error" + ] + } + } + } + }, + "500": { + "description": "Internal Server Error", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string" + } + }, + "required": [ + "error" + ] + } + } + } + }, + "502": { + "description": "Payout provider error", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string" + } + }, + "required": [ + "error" + ] + } + } + } + }, + "503": { + "description": "Offramp not configured on this deployment", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string" + } + }, + "required": [ + "error" + ] + } + } + } + } + } + } + }, + "/offramp/orders": { + "post": { + "summary": "Create a cash-out order", + "description": "Internal endpoint. Not part of the public data API: (provider callback / wallet-only cash-out surface served on behalf of the Veil wallet client).", + "deprecated": true, + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "amountNGN": { + "type": "number" + }, + "amountStableCoin": { + "type": "number" + }, + "bankAccount": { + "type": "string", + "minLength": 1 + }, + "bankCode": { + "type": "string", + "minLength": 1 + }, + "bankName": { + "type": "string", + "minLength": 1 + }, + "accountName": { + "type": "string", + "minLength": 1 + }, + "refundAddress": { + "type": "string" + }, + "walletAddress": { + "type": "string", + "minLength": 1 + }, + "idempotencyKey": { + "type": "string", + "minLength": 1 + } + }, + "required": [ + "bankAccount", + "bankCode", + "bankName", + "accountName", + "walletAddress", + "idempotencyKey" + ], + "additionalProperties": { + "nullable": true + } + } + } + } + }, + "responses": { + "200": { + "description": "Replayed existing order (same idempotencyKey)", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "walletAddress": { + "type": "string", + "description": "Deposit address the stablecoin is sent to." + }, + "chain": { + "type": "string" + }, + "coin": { + "type": "string" + }, + "amountStableCoin": { + "type": "number" + }, + "amountNGN": { + "type": "number" + }, + "rate": { + "type": "number" + }, + "status": { + "type": "string", + "enum": [ + "pending", + "waiting_for_deposit", + "processing", + "completed", + "failed", + "expired" + ], + "description": "Client-facing status mapped from the provider's raw one." + }, + "providerStatus": { + "type": "string", + "description": "Raw provider status, when the order is not a replay." + }, + "replayed": { + "type": "boolean" + } + }, + "required": [ + "id", + "walletAddress", + "chain", + "coin", + "amountStableCoin", + "amountNGN", + "rate", + "status" + ] + } + } + } + }, + "201": { + "description": "Order created", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "walletAddress": { + "type": "string", + "description": "Deposit address the stablecoin is sent to." + }, + "chain": { + "type": "string" + }, + "coin": { + "type": "string" + }, + "amountStableCoin": { + "type": "number" + }, + "amountNGN": { + "type": "number" + }, + "rate": { + "type": "number" + }, + "status": { + "type": "string", + "enum": [ + "pending", + "waiting_for_deposit", + "processing", + "completed", + "failed", + "expired" + ], + "description": "Client-facing status mapped from the provider's raw one." + }, + "providerStatus": { + "type": "string", + "description": "Raw provider status, when the order is not a replay." + }, + "replayed": { + "type": "boolean" + } + }, + "required": [ + "id", + "walletAddress", + "chain", + "coin", + "amountStableCoin", + "amountNGN", + "rate", + "status" + ] + } + } + } + }, + "400": { + "description": "Bad Request", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string" + } + }, + "required": [ + "error" + ] + } + } + } + }, + "404": { + "description": "Not Found", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string" + } + }, + "required": [ + "error" + ] + } + } + } + }, + "500": { + "description": "Internal Server Error", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string" + } + }, + "required": [ + "error" + ] + } + } + } + }, + "502": { + "description": "Payout provider error", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string" + } + }, + "required": [ + "error" + ] + } + } + } + }, + "503": { + "description": "Offramp not configured on this deployment", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string" + } + }, + "required": [ + "error" + ] + } + } + } + } + } + } + }, + "/offramp/orders/{orderId}": { + "get": { + "summary": "Get the status of a cash-out order", + "description": "Internal endpoint. Not part of the public data API: (provider callback / wallet-only cash-out surface served on behalf of the Veil wallet client).", + "deprecated": true, + "parameters": [ + { + "schema": { + "type": "string", + "minLength": 1 + }, + "required": true, + "name": "orderId", + "in": "path" + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "status": { + "type": "string", + "enum": [ + "pending", + "waiting_for_deposit", + "processing", + "completed", + "failed", + "expired" + ], + "description": "Client-facing status mapped from the provider's raw one." + }, + "providerStatus": { + "type": "string" + }, + "amountStableCoin": { + "type": "number" + }, + "amountNGN": { + "type": "number" + }, + "depositAddress": { + "type": "string" + }, + "rate": { + "type": "number" + }, + "createdAt": { + "type": "string", + "format": "date-time" + }, + "source": { + "type": "string", + "enum": [ + "linq", + "cache" + ] + } + }, + "required": [ + "id", + "status", + "providerStatus", + "amountStableCoin", + "amountNGN", + "depositAddress", + "rate", + "createdAt", + "source" + ] + } + } + } + }, + "400": { + "description": "Bad Request", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string" + } + }, + "required": [ + "error" + ] + } + } + } + }, + "404": { + "description": "Not Found", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string" + } + }, + "required": [ + "error" + ] + } + } + } + }, + "500": { + "description": "Internal Server Error", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string" + } + }, + "required": [ + "error" + ] + } + } + } + }, + "502": { + "description": "Payout provider error", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string" + } + }, + "required": [ + "error" + ] + } + } + } + }, + "503": { + "description": "Offramp not configured on this deployment", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string" + } + }, + "required": [ + "error" + ] + } + } + } + } + } + } } } } diff --git a/src/__tests__/openapiCoverage.test.ts b/src/__tests__/openapiCoverage.test.ts new file mode 100644 index 000000000..7e3e67d75 --- /dev/null +++ b/src/__tests__/openapiCoverage.test.ts @@ -0,0 +1,162 @@ +import { readFileSync } from "fs"; +import path from "path"; +import { createApp } from "../api"; +import { buildOpenApiDocument } from "../openapi/build"; + +/** + * Route coverage (W085): every route the app actually serves must appear in + * the generated OpenAPI spec โ€” either documented, or explicitly marked + * internal (`deprecated: true` with a rationale). + * + * This is the test the issue asked for. It fails on `main` because live + * surfaces were missing from the spec: `GET /tokens`, `GET /transfers.csv`, + * `GET /transfers.parquet`, the five `/offramp/*` routes, and โ€” found only by + * walking the app โ€” `GET /accounts/:address/balance` and the provider + * callback `POST /webhooks/linq`. + * + * How the app's route table is collected: express stacks every `app.use` and + * `app.get` as a Layer. A Layer with a `route` is a method route; a Layer + * whose `handle` has its own `stack` is a mounted router. Express 4 keeps the + * mount prefix only inside the Layer's compiled `regexp` (its `path` field + * stays undefined until a request matches), so the walker reconstructs the + * prefix from the pattern: `use("/offramp", router)` compiles to + * `^\/offramp\/?(?=\/|$)`, `use("/", router)` to `^\/?(?=\/|$)`, and a param + * mount like `use("/:address/transfers", sub)` to + * `^(?:\/([^/]+?))\/transfers\/?(?=\/|$)` with the name in `keys`. Only + * public Layer fields are read. + */ + +/** Method keys express's router acts on โ€” `all` and the error handler are not verbs. */ +const HTTP_METHODS = new Set(["get", "post", "put", "patch", "delete", "head", "options"]); + +type MountedRoute = { method: string; path: string }; + +/** + * Pull the mount path back out of a compiled express Layer regexp. + * + * Pinned to path-to-regexp@0.1.13 (express 4's fixed dependency), which + * compiles every `:name` to the same `(?:\/([^/]+?))` group; the names come + * from the layer's `keys`. If express ever changes this, the walker returns + * wrong paths and the guard test below fails loudly rather than vacuously. + */ +function mountPrefix(layer: { regexp?: RegExp; keys?: Array<{ name: string }> }): string { + const regexp = layer.regexp; + if (!regexp) return ""; + + let src = regexp.source; + // express appends an optional trailing slash and lookahead to every mount. + src = src.replace(/\\\/\?\(\?=\\\/\|\$\)$/, ""); + // Swap each compiled param group back to its `:name`, in order. + const names = (layer.keys ?? []).map((k) => k.name); + let i = 0; + src = src.replace(/\(\?:\\\/\(\[\^\/\]\+\?\)\)/g, () => `/:${names[i++] ?? "param"}`); + // Unescape the remaining literal segments (`^`, `\/`, `\.`). + return src + .replace(/^\^/, "") + .replace(/\\(.)/g, "$1") + .replace(/^\//, "/"); +} + +/** Turn an express route path into OpenAPI path syntax: `:id` โ†’ `{id}`. */ +function toOpenApiPath(p: string): string { + return p.replace(/:([A-Za-z0-9_]+)/g, "{$1}"); +} + +/** Collect `method + full path` for every route reachable from `app`. */ +function collectRoutes(app: unknown): MountedRoute[] { + const routes: MountedRoute[] = []; + + const walk = (stack: unknown, prefix: string) => { + for (const layer of stack as Array>) { + const route = layer.route as + | { path: string; methods?: Record } + | undefined; + + if (route) { + let fullPath = prefix + route.path; + // Router-level `router.get("/")` joins as "/webhooks/" โ€” the spec + // never carries a trailing slash. + if (fullPath.length > 1 && fullPath.endsWith("/")) fullPath = fullPath.slice(0, -1); + for (const method of Object.keys(route.methods ?? {}).filter((m) => HTTP_METHODS.has(m))) { + routes.push({ method, path: fullPath }); + } + continue; + } + + const handleStack = (layer.handle as { stack?: unknown } | undefined)?.stack; + if (Array.isArray(handleStack)) { + walk(handleStack, prefix + mountPrefix(layer as { regexp?: RegExp; keys?: Array<{ name: string }> })); + } + } + }; + + const rootStack = (app as { _router?: { stack?: unknown } } | undefined)?._router?.stack; + if (Array.isArray(rootStack)) walk(rootStack, ""); + return routes; +} + +const spec = buildOpenApiDocument(); + +describe("OpenAPI route coverage (W085)", () => { + const routes = collectRoutes(createApp()); + + // Express 4 stores each verb as a truthy key on route.methods; normalise the + // spec's keys the same way so a stray `all` or HEAD cannot paper over a gap. + const documented = new Set(); + for (const [p, pathItem] of Object.entries(spec.paths)) { + for (const key of Object.keys(pathItem as Record)) { + if (HTTP_METHODS.has(key)) documented.add(`${key} ${p}`.toLowerCase()); + } + } + + it("every route registered on the app appears in the generated spec", () => { + const missing = routes + .map((r) => `${r.method} ${toOpenApiPath(r.path)}`.toLowerCase()) + .filter((key) => !documented.has(key)); + + expect(missing).toEqual([]); + }); + + it("finds the routes this issue is about (guards the guard)", () => { + // If express internals ever change and the walker starts returning an + // empty or partial list, the assertion above would pass vacuously. These + // anchors prove the walker sees the real app: a plain app route, a + // router-mounted route behind a prefix, and the root-mounted exports. + const keys = routes.map((r) => `${r.method} ${r.path}`.toLowerCase()); + expect(keys).toContain("get /tokens"); + expect(keys).toContain("post /offramp/orders"); + expect(keys).toContain("get /summary/:address"); + expect(keys).toContain("get /accounts/:address/balance"); + expect(keys).toContain("get /transfers.csv"); + }); + + it("documents /tokens", () => { + expect(spec.paths["/tokens"]?.get).toBeDefined(); + }); + + it("documents the export routes", () => { + expect(spec.paths["/transfers.csv"]?.get).toBeDefined(); + expect(spec.paths["/transfers.parquet"]?.get).toBeDefined(); + }); + + it("documents every /offramp route as internal (deprecated) with a rationale", () => { + const offramp = Object.entries(spec.paths) + .filter(([p]) => p.startsWith("/offramp")) + .flatMap(([, pathItem]) => Object.values(pathItem as Record)); + + expect(offramp.length).toBeGreaterThanOrEqual(5); + for (const op of offramp as Array<{ deprecated?: boolean; description?: string }>) { + expect(op.deprecated).toBe(true); + expect(op.description).toMatch(/internal/i); + } + }); + + it("the committed openapi.json copies match the generator output", () => { + const committed = readFileSync(path.resolve(process.cwd(), "openapi.json"), "utf8"); + const docsCopy = readFileSync(path.resolve(process.cwd(), "docs", "openapi.json"), "utf8"); + const generated = JSON.stringify(spec, null, 2) + "\n"; + + expect(committed).toBe(generated); + expect(docsCopy).toBe(generated); + }); +}); diff --git a/src/openapi/build.ts b/src/openapi/build.ts index 54bdddecb..c7aa587a4 100644 --- a/src/openapi/build.ts +++ b/src/openapi/build.ts @@ -34,6 +34,22 @@ import { webhookDeliveriesQuerySchema, webhookDeliveriesResponseSchema, webhookSubscriptionsResponseSchema, + accountBalanceResponseSchema, + tokensQuerySchema, + tokensResponseSchema, + transferExportQuerySchema, + binaryExportResponseSchema, + linqWebhookAckResponseSchema, + offrampRateResponseSchema, + offrampVerifyBankBodySchema, + offrampVerifiedBankSchema, + offrampTrustlineQuerySchema, + offrampTrustlineResponseSchema, + offrampOrderBodySchema, + offrampOrderResponseSchema, + offrampOrderParamsSchema, + offrampOrderStatusResponseSchema, + offrampErrorResponseSchema, } from "./schemas"; const registry = new OpenAPIRegistry(); @@ -194,6 +210,23 @@ registry.registerPath({ }, }); +registry.registerPath({ + method: "get", + path: "/accounts/{address}/balance", + summary: "Derived token balances", + description: + "Per-token balance for an address, derived by summing indexed transfers โ€” not " + + "a read from chain. Excludes any history before the indexer's start ledger, so " + + "an address that held a token before then reads low.", + request: { + params: addressPathSchema, + }, + responses: { + 200: { description: "OK", content: { "application/json": { schema: accountBalanceResponseSchema } } }, + ...commonErrorResponses, + }, +}); + registry.registerPath({ method: "get", path: "/accounts/{address}/transfers", @@ -327,6 +360,157 @@ registry.registerPath({ }, }); +registry.registerPath({ + method: "get", + path: "/tokens", + summary: "Cached token metadata", + description: + "All tokens the indexer has encountered and cached, on the selected network. " + + "Served from the in-memory token cache โ€” a token appears here once the indexer " + + "has seen a transfer of it and resolved its symbol, name and decimals.", + request: { query: tokensQuerySchema }, + responses: { + 200: { description: "OK", content: { "application/json": { schema: tokensResponseSchema } } }, + ...commonErrorResponses, + }, +}); + +const transferExportResponses = { + 200: { + description: "File download of the full matching transfer set (no pagination).", + content: { + "text/csv": { schema: binaryExportResponseSchema }, + "application/octet-stream": { schema: binaryExportResponseSchema }, + }, + }, + ...commonErrorResponses, +}; + +registry.registerPath({ + method: "get", + path: "/transfers.csv", + summary: "Export all transfers as CSV", + description: + "Streams every transfer matching the filters as CSV. No pagination: the " + + "response is the entire matching set, so narrow it with the ledger or date " + + "bounds on large deployments.", + request: { query: transferExportQuerySchema }, + responses: transferExportResponses, +}); + +registry.registerPath({ + method: "get", + path: "/transfers.parquet", + summary: "Export all transfers as Parquet", + description: + "Streams every transfer matching the filters as an Apache Parquet file. " + + "Same filters and streaming behaviour as /transfers.csv.", + request: { query: transferExportQuerySchema }, + responses: transferExportResponses, +}); + +// Internal surfaces, kept in the spec for completeness. The provider webhook +// is a signature-verified callback for Linq's infrastructure and the offramp +// router is the wallet's own cash-out surface fronting a server-side provider +// API โ€” neither is a public data API, so both are marked deprecated. +const internalDescription = + "Internal endpoint. Not part of the public data API: " + + "(provider callback / wallet-only cash-out surface served on behalf of the Veil wallet client)."; + +registry.registerPath({ + method: "post", + path: "/webhooks/linq", + summary: "Linq payout webhook (provider callback)", + description: internalDescription, + deprecated: true, + responses: { + 200: { description: "Acknowledged", content: { "application/json": { schema: linqWebhookAckResponseSchema } } }, + 401: { description: "Invalid signature", content: { "application/json": { schema: errorResponseSchema } } }, + 400: { description: "Unrecognised event", content: { "application/json": { schema: errorResponseSchema } } }, + }, +}); + +const internalErrorResponses = { + ...commonErrorResponses, + 502: { description: "Payout provider error", content: { "application/json": { schema: offrampErrorResponseSchema } } }, + 503: { description: "Offramp not configured on this deployment", content: { "application/json": { schema: offrampErrorResponseSchema } } }, +}; + +registry.registerPath({ + method: "get", + path: "/offramp/rate", + summary: "Indicative NGN/USDC rate", + description: internalDescription, + deprecated: true, + responses: { + 200: { description: "OK", content: { "application/json": { schema: offrampRateResponseSchema } } }, + ...internalErrorResponses, + }, +}); + +registry.registerPath({ + method: "post", + path: "/offramp/verify-bank", + summary: "Verify a bank account before creating an order", + description: internalDescription, + deprecated: true, + request: { + body: { + required: true, + content: { "application/json": { schema: offrampVerifyBankBodySchema } }, + }, + }, + responses: { + 200: { description: "OK", content: { "application/json": { schema: offrampVerifiedBankSchema } } }, + ...internalErrorResponses, + }, +}); + +registry.registerPath({ + method: "get", + path: "/offramp/trustline", + summary: "Check a refund address holds a USDC trustline", + description: internalDescription, + deprecated: true, + request: { query: offrampTrustlineQuerySchema }, + responses: { + 200: { description: "OK", content: { "application/json": { schema: offrampTrustlineResponseSchema } } }, + ...internalErrorResponses, + }, +}); + +registry.registerPath({ + method: "post", + path: "/offramp/orders", + summary: "Create a cash-out order", + description: internalDescription, + deprecated: true, + request: { + body: { + required: true, + content: { "application/json": { schema: offrampOrderBodySchema } }, + }, + }, + responses: { + 201: { description: "Order created", content: { "application/json": { schema: offrampOrderResponseSchema } } }, + 200: { description: "Replayed existing order (same idempotencyKey)", content: { "application/json": { schema: offrampOrderResponseSchema } } }, + ...internalErrorResponses, + }, +}); + +registry.registerPath({ + method: "get", + path: "/offramp/orders/{orderId}", + summary: "Get the status of a cash-out order", + description: internalDescription, + deprecated: true, + request: { params: offrampOrderParamsSchema }, + responses: { + 200: { description: "OK", content: { "application/json": { schema: offrampOrderStatusResponseSchema } } }, + ...internalErrorResponses, + }, +}); + const generator = new OpenApiGeneratorV3(registry.definitions); const document = generator.generateDocument({ openapi: "3.0.3", @@ -347,14 +531,24 @@ const document = generator.generateDocument({ ], }); -const outputFiles = [ - path.resolve(process.cwd(), "openapi.json"), - path.resolve(process.cwd(), "docs", "openapi.json"), -]; - -for (const filePath of outputFiles) { - mkdirSync(path.dirname(filePath), { recursive: true }); - writeFileSync(filePath, `${JSON.stringify(document, null, 2)}\n`, "utf8"); +export function buildOpenApiDocument(): typeof document { + return document; } -console.log(`Wrote OpenAPI document to ${outputFiles.join(" and ")}`); +// Write only when run as a script (npm run docs:openapi). Importing the +// builder from tests must not overwrite the committed spec files โ€” the +// openapiCoverage test compares them against the generated output to catch +// drift, which would be tautological if the import itself refreshed them. +if (require.main === module) { + const outputFiles = [ + path.resolve(process.cwd(), "openapi.json"), + path.resolve(process.cwd(), "docs", "openapi.json"), + ]; + + for (const filePath of outputFiles) { + mkdirSync(path.dirname(filePath), { recursive: true }); + writeFileSync(filePath, `${JSON.stringify(document, null, 2)}\n`, "utf8"); + } + + console.log(`Wrote OpenAPI document to ${outputFiles.join(" and ")}`); +} diff --git a/src/openapi/schemas.ts b/src/openapi/schemas.ts index 3d27b88a1..2619e2cb7 100644 --- a/src/openapi/schemas.ts +++ b/src/openapi/schemas.ts @@ -567,3 +567,161 @@ export type TransferQuery = z.infer; export type SummaryQuery = z.infer; export type HostFnQuery = z.infer; export type NftTransfersQuery = z.infer; + +// โ”€โ”€โ”€ GET /accounts/:address/balance โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +export const accountBalanceResponseSchema = z.object({ + address: stellarAddressSchema, + network: z.enum(["testnet", "mainnet"]), + balances: z.array( + z.object({ + contractId: contractAddressSchema, + balance: z.string(), + displayBalance: z.string(), + }) + ), + /** Always true: this is a sum over the indexed window, not an on-chain read. */ + derivedFromLedger: z.literal(true), + note: z.string(), +}); + +// โ”€โ”€โ”€ GET /tokens โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +/** Metadata for one token the indexer has seen, as served by GET /tokens. */ +export const tokenMetadataSchema = z.object({ + network: z.enum(["testnet", "mainnet"]), + contractId: contractAddressSchema, + symbol: z.string(), + name: z.string(), + decimals: z.number().int(), +}); + +export const tokensQuerySchema = z.object({ ...withNetwork }).passthrough(); + +export const tokensResponseSchema = z.object({ + ok: z.literal(true), + network: z.enum(["testnet", "mainnet"]), + tokens: z.array(tokenMetadataSchema), +}); + +// โ”€โ”€โ”€ GET /transfers.csv ยท GET /transfers.parquet โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +/** + * Query schema for the full-table export routes. + * + * The same filters `buildWhere` accepts for the JSON transfer routes, minus + * the pagination fields: exports stream the entire matching set rather than a + * page, so limit/offset/cursor have nothing to mean there. + */ +export const transferExportQuerySchema = z.object({ + ...withNetwork, + address: stellarAddressSchema.optional(), + contractId: optionalQueryString("Token contract ID to filter by"), + token: contractAddressSchema.optional(), + fromLedger: optionalQueryInt({ min: 0 }), + toLedger: optionalQueryInt({ min: 0 }), + fromDate: optionalQueryDateTime("Inclusive lower bound on ledgerClosedAt"), + toDate: optionalQueryDateTime("Inclusive upper bound on ledgerClosedAt"), + eventType: eventTypeQuerySchema.openapi({ description: "Comma-separated list of event types" }), +}).passthrough(); + +export const binaryExportResponseSchema = z.string().openapi({ format: "binary" }); + +// โ”€โ”€โ”€ /webhooks/linq โ€” provider callback (internal) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +export const linqWebhookAckResponseSchema = z.object({ + ok: z.literal(true), +}); + +// โ”€โ”€โ”€ /offramp/* โ€” cash-out surface (internal) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +export const offrampRateResponseSchema = z.object({ + rate: z.number(), + currency: z.string(), + coin: z.string(), + /** Always true: the binding rate is the one locked into an order at creation. */ + indicative: z.literal(true), +}); + +export const offrampVerifyBankBodySchema = z + .object({ + bankCode: z.string().min(1), + accountNumber: z.string().min(1), + }) + .passthrough(); + +export const offrampVerifiedBankSchema = z.object({ + accountName: z.string(), + bankName: z.string(), + accountNumber: z.string(), + bankCode: z.string(), +}); + +export const offrampTrustlineQuerySchema = z + .object({ + /** The classic fee-payer address; contract (Cโ€ฆ) addresses are rejected. */ + address: z.string().min(1), + }) + .passthrough(); + +export const offrampTrustlineResponseSchema = z.object({ + address: z.string(), + valid: z.boolean(), + trustsUSDC: z.boolean(), +}); + +export const offrampOrderBodySchema = z + .object({ + /** Exactly one of amountNGN / amountStableCoin. */ + amountNGN: z.number().optional(), + amountStableCoin: z.number().optional(), + bankAccount: z.string().min(1), + bankCode: z.string().min(1), + bankName: z.string().min(1), + accountName: z.string().min(1), + /** Must already hold a USDC trustline โ€” the fee-payer, never the wallet contract. */ + refundAddress: z.string().optional(), + walletAddress: z.string().min(1), + /** Caller-generated; replaying a request with the same key returns the original order. */ + idempotencyKey: z.string().min(1), + }) + .passthrough(); + +export const offrampOrderStatus = z + .enum(["pending", "waiting_for_deposit", "processing", "completed", "failed", "expired"]) + .openapi({ description: "Client-facing status mapped from the provider's raw one." }); + +export const offrampOrderResponseSchema = z.object({ + id: z.string(), + walletAddress: z.string().openapi({ description: "Deposit address the stablecoin is sent to." }), + chain: z.string(), + coin: z.string(), + amountStableCoin: z.number(), + amountNGN: z.number(), + rate: z.number(), + status: offrampOrderStatus, + providerStatus: z.string().optional().openapi({ description: "Raw provider status, when the order is not a replay." }), + /** True when an existing idempotencyKey returned the original order instead of creating one. */ + replayed: z.boolean().optional(), +}); + +export const offrampOrderParamsSchema = z.object({ + orderId: z.string().min(1), +}).passthrough(); + +export const offrampOrderStatusResponseSchema = z.object({ + id: z.string(), + status: offrampOrderStatus, + providerStatus: z.string(), + amountStableCoin: z.number(), + amountNGN: z.number(), + depositAddress: z.string(), + rate: z.number(), + createdAt: z.string().datetime({ offset: true }), + /** "linq" when reconciled against the provider, "cache" when the provider was unreachable. */ + source: z.enum(["linq", "cache"]), +}); + +export const offrampErrorResponseSchema = z.object({ + error: z.string(), +}); From eaadbb7a82c4b9e93f837d95a9f6b0ade576e2b3 Mon Sep 17 00:00:00 2001 From: Miracle656 Date: Tue, 6 Oct 2026 17:01:17 +0100 Subject: [PATCH 2/2] fix(openapi): make the coverage guard survive a clean clone, and x-internal MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Four fixes on top of the route-walker, which is the right shape for #186 and which I confirmed generates openapi.json honestly rather than by hand. **The coverage test could not pass in CI.** It read `docs/openapi.json`, which is gitignored โ€” a build artefact for the published docs site โ€” so on any clean clone it threw ENOENT before asserting anything. Nothing is lost by dropping it: the same loop in build.ts writes both files from one `document`, so the docs copy cannot drift from the tracked one. **`deprecated: true` was the wrong marker for "internal".** In OpenAPI that means the endpoint is being withdrawn, and generators act on it โ€” the react-query client would have emitted `@deprecated` on five endpoints the wallet calls today, and a reader of the published spec would reasonably have concluded cash-out was being retired. Now `x-internal: true`, asserted in the test, with `deprecated` asserted absent. The regenerated client carries zero `@deprecated`. **The generated client was stale**, which was the second red check: `react-query-sdk.yml` regenerates `schema.d.ts` and runs `git diff --exit-code` whenever openapi.json changes. Regenerated and committed. **`source` documented a value the server no longer sends.** #216 renamed the wire value from "linq" to "live" after this branch was cut, so the spec would have shipped as the published reference for something that does not exist. Also drops the provider's name from the published summary, which is what `withoutProviderName()` in src/api/offramp.ts exists for; openapi.json is published to the docs site. The rebase onto main surfaced seven `/ngn/*` routes that landed after this guard was written, so the test now compares against an explicit known-gaps list rather than the empty array. It is not an escape hatch: the assertion is equality both ways, so a new undocumented route still fails and documenting one of these without deleting it from the list also fails. The list only shrinks. Also: the README said exports return "the entire matching transfer set", which #199 made untrue when it capped them, and a tool artifact (``) had been committed into the same paragraph. Added a section on regenerating the spec, since the react-query coupling is invisible from `src/openapi/` and that is exactly how it gets missed. 644 tests pass, typecheck clean, and the coverage test passes with docs/openapi.json deleted. Claude-Session: https://claude.ai/code/session_01USgemLt4Rnz4SGB1Srf3GB --- README.md | 30 +- clients/react-query/src/schema.d.ts | 1125 ++++++++++++++++++++++++- openapi.json | 16 +- src/__tests__/openapiCoverage.test.ts | 56 +- src/openapi/build.ts | 22 +- src/openapi/schemas.ts | 13 +- 6 files changed, 1225 insertions(+), 37 deletions(-) diff --git a/README.md b/README.md index e068cf9c5..d6028b71a 100644 --- a/README.md +++ b/README.md @@ -615,6 +615,29 @@ curl "http://localhost:3000/transfers/tx/abcdef1234567890..." *** +### Changing the OpenAPI spec + +`openapi.json` is generated โ€” edit `src/openapi/build.ts` and `schemas.ts`, then: + +```bash +npm run docs:openapi # regenerate the spec +cd clients/react-query && npm install && npm run generate # regenerate the client types +``` + +Both outputs are committed, and both are checked in CI: +`src/__tests__/openapiCoverage.test.ts` fails when the spec does not match the +generator or when a registered route is undocumented, and +`.github/workflows/react-query-sdk.yml` runs `git diff --exit-code` on +`clients/react-query/src/schema.d.ts` whenever `openapi.json` changes. Nothing +near `src/openapi/` mentions that second coupling, which is exactly how it gets +missed. + +Internal routes carry `x-internal: true`, never `deprecated` โ€” `deprecated` +tells generators and readers the endpoint is being withdrawn, and the client +would emit `@deprecated` on endpoints the wallet calls today. + +*** + ### `GET /tokens` All tokens the indexer has encountered and cached, on the selected network. @@ -644,9 +667,10 @@ curl "http://localhost:3000/tokens?network=mainnet" ### `GET /transfers.csv` ยท `GET /transfers.parquet` -Export the **entire matching transfer set** as a CSV or Apache Parquet download โ€” -no pagination. Accepts the same filters as the JSON transfer routes -(`network`, `address`, `contractId`, `token`, `fromLedger`, `toLedger`, +Export the matching transfer set as a CSV or Apache Parquet download โ€” no +pagination, but **capped**: see the row-cap note above for `maxRows`, +`X-Truncated` and `X-Row-Limit`. Accepts the same filters as the JSON transfer +routes (`network`, `address`, `contractId`, `token`, `fromLedger`, `toLedger`, `fromDate`, `toDate`, `eventType`) minus the pagination fields; narrow large exports with the ledger or date bounds. diff --git a/clients/react-query/src/schema.d.ts b/clients/react-query/src/schema.d.ts index ec9c5fc42..b86fd99f8 100644 --- a/clients/react-query/src/schema.d.ts +++ b/clients/react-query/src/schema.d.ts @@ -88,6 +88,8 @@ export interface paths { get: { parameters: { query?: { + /** @description Network to read from. Defaults to the deployment's configured network. May also be sent as the X-Network header; the query parameter wins. */ + network?: "testnet" | "mainnet" | null; /** @description Max acceptable ledger lag */ maxLag?: number | null; }; @@ -105,11 +107,25 @@ export interface paths { content: { "application/json": { ok: boolean; + /** @enum {string} */ + network?: "testnet" | "mainnet"; checks: { db: boolean; rpc: boolean; indexerCaughtUp: boolean; }; + networks?: { + [key: string]: { + checks: { + db: boolean; + rpc: boolean; + indexerCaughtUp: boolean; + }; + lastIndexedLedger: number | null; + latestLedger: number | null; + lagLedgers: number | null; + }; + }; }; }; }; @@ -154,11 +170,25 @@ export interface paths { content: { "application/json": { ok: boolean; + /** @enum {string} */ + network?: "testnet" | "mainnet"; checks: { db: boolean; rpc: boolean; indexerCaughtUp: boolean; }; + networks?: { + [key: string]: { + checks: { + db: boolean; + rpc: boolean; + indexerCaughtUp: boolean; + }; + lastIndexedLedger: number | null; + latestLedger: number | null; + lagLedgers: number | null; + }; + }; }; }; }; @@ -172,6 +202,45 @@ export interface paths { patch?: never; trace?: never; }; + "/metrics": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Prometheus metrics + * @description Indexer and process metrics in Prometheus text exposition format. Served from in-process counters only โ€” no database or RPC call โ€” so it keeps answering while the subsystems it reports on are down. + */ + get: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Prometheus text exposition format */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "text/plain": string; + }; + }; + }; + }; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/status": { parameters: { query?: never; @@ -182,7 +251,10 @@ export interface paths { /** Indexer status */ get: { parameters: { - query?: never; + query?: { + /** @description Network to read from. Defaults to the deployment's configured network. May also be sent as the X-Network header; the query parameter wins. */ + network?: "testnet" | "mainnet" | null; + }; header?: never; path?: never; cookie?: never; @@ -198,6 +270,8 @@ export interface paths { "application/json": { /** @enum {boolean} */ ok: true; + /** @enum {string} */ + network?: "testnet" | "mainnet"; lastIndexedLedger: number | null; latestLedger: number; lagLedgers: number; @@ -262,6 +336,8 @@ export interface paths { get: { parameters: { query?: { + /** @description Network to read from. Defaults to the deployment's configured network. May also be sent as the X-Network header; the query parameter wins. */ + network?: "testnet" | "mainnet" | null; /** @description Token contract ID to filter by */ contractId?: string | null; token?: string; @@ -283,6 +359,8 @@ export interface paths { $filter?: string | null; /** @description Comma-separated field list */ $select?: string[] | null; + /** @description Also return an exact `total`. Runs a COUNT over every matching row, so it is markedly slower on large result sets; omit it and use `hasMore` / `nextCursor` to paginate */ + includeTotal?: boolean | null; }; header?: never; path: { @@ -299,7 +377,10 @@ export interface paths { }; content: { "application/json": { - total: number; + /** @description Exact match count. Only present when the request set includeTotal=true */ + total?: number; + /** @description True when another page exists */ + hasMore: boolean; limit: number; offset: number; nextCursor?: string | null; @@ -380,6 +461,8 @@ export interface paths { get: { parameters: { query?: { + /** @description Network to read from. Defaults to the deployment's configured network. May also be sent as the X-Network header; the query parameter wins. */ + network?: "testnet" | "mainnet" | null; /** @description Token contract ID to filter by */ contractId?: string | null; token?: string; @@ -401,6 +484,8 @@ export interface paths { $filter?: string | null; /** @description Comma-separated field list */ $select?: string[] | null; + /** @description Also return an exact `total`. Runs a COUNT over every matching row, so it is markedly slower on large result sets; omit it and use `hasMore` / `nextCursor` to paginate */ + includeTotal?: boolean | null; }; header?: never; path: { @@ -417,7 +502,10 @@ export interface paths { }; content: { "application/json": { - total: number; + /** @description Exact match count. Only present when the request set includeTotal=true */ + total?: number; + /** @description True when another page exists */ + hasMore: boolean; limit: number; offset: number; nextCursor?: string | null; @@ -498,6 +586,8 @@ export interface paths { get: { parameters: { query?: { + /** @description Network to read from. Defaults to the deployment's configured network. May also be sent as the X-Network header; the query parameter wins. */ + network?: "testnet" | "mainnet" | null; /** @description Token contract ID to filter by */ contractId?: string | null; token?: string; @@ -519,6 +609,8 @@ export interface paths { $filter?: string | null; /** @description Comma-separated field list */ $select?: string[] | null; + /** @description Also return an exact `total`. Runs a COUNT over every matching row, so it is markedly slower on large result sets; omit it and use `hasMore` / `nextCursor` to paginate */ + includeTotal?: boolean | null; }; header?: never; path: { @@ -535,7 +627,10 @@ export interface paths { }; content: { "application/json": { - total: number; + /** @description Exact match count. Only present when the request set includeTotal=true */ + total?: number; + /** @description True when another page exists */ + hasMore: boolean; limit: number; offset: number; nextCursor?: string | null; @@ -616,6 +711,8 @@ export interface paths { get: { parameters: { query?: { + /** @description Network to read from. Defaults to the deployment's configured network. May also be sent as the X-Network header; the query parameter wins. */ + network?: "testnet" | "mainnet" | null; /** @description Token contract ID to filter by */ contractId?: string | null; token?: string; @@ -627,6 +724,8 @@ export interface paths { toDate?: string | null; /** @description Comma-separated list of event types */ eventType?: string[] | null; + /** @description Also return an exact `total`. Runs a COUNT over every matching row, so it is markedly slower on large result sets; omit it and use `hasMore` / `nextCursor` to paginate */ + includeTotal?: boolean | null; }; header?: never; path: { @@ -792,6 +891,8 @@ export interface paths { get: { parameters: { query?: { + /** @description Network to read from. Defaults to the deployment's configured network. May also be sent as the X-Network header; the query parameter wins. */ + network?: "testnet" | "mainnet" | null; /** @description Token contract ID to filter by */ contractId?: string | null; /** @description Inclusive lower bound on ledgerClosedAt */ @@ -896,6 +997,8 @@ export interface paths { get: { parameters: { query?: { + /** @description Network to read from. Defaults to the deployment's configured network. May also be sent as the X-Network header; the query parameter wins. */ + network?: "testnet" | "mainnet" | null; /** @description Token contract ID to filter by */ contractId?: string | null; /** @description Inclusive lower bound on ledgerClosedAt */ @@ -989,6 +1092,94 @@ export interface paths { patch?: never; trace?: never; }; + "/accounts/{address}/balance": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Derived token balances + * @description Per-token balance for an address, derived by summing indexed transfers โ€” not a read from chain. Excludes any history before the indexer's start ledger, so an address that held a token before then reads low. + */ + get: { + parameters: { + query?: never; + header?: never; + path: { + address: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + /** @example GABCDEFGHIJKLMNOPQRSTUVWXYZ */ + address: string; + /** @enum {string} */ + network: "testnet" | "mainnet"; + balances: { + /** @example CB64D3G7SM2RTH6ISYIG4P2IYYD6J2OFR6B */ + contractId: string; + balance: string; + displayBalance: string; + }[]; + /** @enum {boolean} */ + derivedFromLedger: true; + note: string; + }; + }; + }; + /** @description Bad Request */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + error: string; + }; + }; + }; + /** @description Not Found */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + error: string; + }; + }; + }; + /** @description Internal Server Error */ + 500: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + error: string; + }; + }; + }; + }; + }; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/accounts/{address}/transfers": { parameters: { query?: never; @@ -1000,6 +1191,8 @@ export interface paths { get: { parameters: { query?: { + /** @description Network to read from. Defaults to the deployment's configured network. May also be sent as the X-Network header; the query parameter wins. */ + network?: "testnet" | "mainnet" | null; /** @description Token contract ID to filter by */ contractId?: string | null; token?: string; @@ -1021,6 +1214,8 @@ export interface paths { $filter?: string | null; /** @description Comma-separated field list */ $select?: string[] | null; + /** @description Also return an exact `total`. Runs a COUNT over every matching row, so it is markedly slower on large result sets; omit it and use `hasMore` / `nextCursor` to paginate */ + includeTotal?: boolean | null; }; header?: never; path: { @@ -1037,7 +1232,10 @@ export interface paths { }; content: { "application/json": { - total: number; + /** @description Exact match count. Only present when the request set includeTotal=true */ + total?: number; + /** @description True when another page exists */ + hasMore: boolean; limit: number; offset: number; nextCursor?: string | null; @@ -1462,6 +1660,8 @@ export interface paths { get: { parameters: { query?: { + /** @description Network to read from. Defaults to the deployment's configured network. May also be sent as the X-Network header; the query parameter wins. */ + network?: "testnet" | "mainnet" | null; /** @description Host function name to filter by */ functionName?: string | null; /** @description Page size */ @@ -1559,6 +1759,8 @@ export interface paths { get: { parameters: { query?: { + /** @description Network to read from. Defaults to the deployment's configured network. May also be sent as the X-Network header; the query parameter wins. */ + network?: "testnet" | "mainnet" | null; /** @description NFT contract ID to filter by */ contract?: string | null; /** @description NFT token identifier */ @@ -1577,6 +1779,8 @@ export interface paths { $filter?: string | null; /** @description Comma-separated field list */ $select?: string[] | null; + /** @description Also return an exact `total`. Runs a COUNT over every matching row, so it is markedly slower on large result sets; omit it and use `hasMore` / `nextCursor` to paginate */ + includeTotal?: boolean | null; }; header?: never; path?: never; @@ -1591,7 +1795,10 @@ export interface paths { }; content: { "application/json": { - total: number; + /** @description Exact match count. Only present when the request set includeTotal=true */ + total?: number; + /** @description True when another page exists */ + hasMore: boolean; limit: number; offset: number; nextCursor?: string | null; @@ -1751,6 +1958,8 @@ export interface paths { get: { parameters: { query?: { + /** @description Network to read from. Defaults to the deployment's configured network. May also be sent as the X-Network header; the query parameter wins. */ + network?: "testnet" | "mainnet" | null; window?: "1h" | "24h" | "7d"; by?: "transfers" | "volume"; /** @description Page size */ @@ -1842,6 +2051,8 @@ export interface paths { get: { parameters: { query: { + /** @description Network to read from. Defaults to the deployment's configured network. May also be sent as the X-Network header; the query parameter wins. */ + network?: "testnet" | "mainnet" | null; q: string; }; header?: never; @@ -1912,6 +2123,908 @@ export interface paths { patch?: never; trace?: never; }; + "/tokens": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Cached token metadata + * @description All tokens the indexer has encountered and cached, on the selected network. Served from the in-memory token cache โ€” a token appears here once the indexer has seen a transfer of it and resolved its symbol, name and decimals. + */ + get: { + parameters: { + query?: { + /** @description Network to read from. Defaults to the deployment's configured network. May also be sent as the X-Network header; the query parameter wins. */ + network?: "testnet" | "mainnet" | null; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + /** @enum {boolean} */ + ok: true; + /** @enum {string} */ + network: "testnet" | "mainnet"; + tokens: { + /** @enum {string} */ + network: "testnet" | "mainnet"; + /** @example CB64D3G7SM2RTH6ISYIG4P2IYYD6J2OFR6B */ + contractId: string; + symbol: string; + name: string; + decimals: number; + }[]; + }; + }; + }; + /** @description Bad Request */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + error: string; + }; + }; + }; + /** @description Not Found */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + error: string; + }; + }; + }; + /** @description Internal Server Error */ + 500: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + error: string; + }; + }; + }; + }; + }; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/transfers.csv": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Export all transfers as CSV + * @description Streams every transfer matching the filters as CSV. No pagination: the response is the entire matching set, so narrow it with the ledger or date bounds on large deployments. + */ + get: { + parameters: { + query?: { + /** @description Network to read from. Defaults to the deployment's configured network. May also be sent as the X-Network header; the query parameter wins. */ + network?: "testnet" | "mainnet" | null; + address?: string; + /** @description Token contract ID to filter by */ + contractId?: string | null; + token?: string; + fromLedger?: number | null; + toLedger?: number | null; + /** @description Inclusive lower bound on ledgerClosedAt */ + fromDate?: string | null; + /** @description Inclusive upper bound on ledgerClosedAt */ + toDate?: string | null; + /** @description Comma-separated list of event types */ + eventType?: string[] | null; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description File download of the full matching transfer set (no pagination). */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "text/csv": string; + "application/octet-stream": string; + }; + }; + /** @description Bad Request */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + error: string; + }; + }; + }; + /** @description Not Found */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + error: string; + }; + }; + }; + /** @description Internal Server Error */ + 500: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + error: string; + }; + }; + }; + }; + }; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/transfers.parquet": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Export all transfers as Parquet + * @description Streams every transfer matching the filters as an Apache Parquet file. Same filters and streaming behaviour as /transfers.csv. + */ + get: { + parameters: { + query?: { + /** @description Network to read from. Defaults to the deployment's configured network. May also be sent as the X-Network header; the query parameter wins. */ + network?: "testnet" | "mainnet" | null; + address?: string; + /** @description Token contract ID to filter by */ + contractId?: string | null; + token?: string; + fromLedger?: number | null; + toLedger?: number | null; + /** @description Inclusive lower bound on ledgerClosedAt */ + fromDate?: string | null; + /** @description Inclusive upper bound on ledgerClosedAt */ + toDate?: string | null; + /** @description Comma-separated list of event types */ + eventType?: string[] | null; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description File download of the full matching transfer set (no pagination). */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "text/csv": string; + "application/octet-stream": string; + }; + }; + /** @description Bad Request */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + error: string; + }; + }; + }; + /** @description Not Found */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + error: string; + }; + }; + }; + /** @description Internal Server Error */ + 500: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + error: string; + }; + }; + }; + }; + }; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/webhooks/linq": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Payout webhook (provider callback) + * @description Internal endpoint. Not part of the public data API: (provider callback / wallet-only cash-out surface served on behalf of the Veil wallet client). + */ + post: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Acknowledged */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + /** @enum {boolean} */ + ok: true; + }; + }; + }; + /** @description Unrecognised event */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + error: string; + }; + }; + }; + /** @description Invalid signature */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + error: string; + }; + }; + }; + }; + }; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/offramp/rate": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Indicative NGN/USDC rate + * @description Internal endpoint. Not part of the public data API: (provider callback / wallet-only cash-out surface served on behalf of the Veil wallet client). + */ + get: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + rate: number; + currency: string; + coin: string; + /** @enum {boolean} */ + indicative: true; + }; + }; + }; + /** @description Bad Request */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + error: string; + }; + }; + }; + /** @description Not Found */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + error: string; + }; + }; + }; + /** @description Internal Server Error */ + 500: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + error: string; + }; + }; + }; + /** @description Payout provider error */ + 502: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + error: string; + }; + }; + }; + /** @description Offramp not configured on this deployment */ + 503: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + error: string; + }; + }; + }; + }; + }; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/offramp/verify-bank": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Verify a bank account before creating an order + * @description Internal endpoint. Not part of the public data API: (provider callback / wallet-only cash-out surface served on behalf of the Veil wallet client). + */ + post: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": { + bankCode: string; + accountNumber: string; + } & { + [key: string]: unknown; + }; + }; + }; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + accountName: string; + bankName: string; + accountNumber: string; + bankCode: string; + }; + }; + }; + /** @description Bad Request */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + error: string; + }; + }; + }; + /** @description Not Found */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + error: string; + }; + }; + }; + /** @description Internal Server Error */ + 500: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + error: string; + }; + }; + }; + /** @description Payout provider error */ + 502: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + error: string; + }; + }; + }; + /** @description Offramp not configured on this deployment */ + 503: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + error: string; + }; + }; + }; + }; + }; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/offramp/trustline": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Check a refund address holds a USDC trustline + * @description Internal endpoint. Not part of the public data API: (provider callback / wallet-only cash-out surface served on behalf of the Veil wallet client). + */ + get: { + parameters: { + query: { + address: string; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + address: string; + valid: boolean; + trustsUSDC: boolean; + }; + }; + }; + /** @description Bad Request */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + error: string; + }; + }; + }; + /** @description Not Found */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + error: string; + }; + }; + }; + /** @description Internal Server Error */ + 500: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + error: string; + }; + }; + }; + /** @description Payout provider error */ + 502: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + error: string; + }; + }; + }; + /** @description Offramp not configured on this deployment */ + 503: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + error: string; + }; + }; + }; + }; + }; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/offramp/orders": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Create a cash-out order + * @description Internal endpoint. Not part of the public data API: (provider callback / wallet-only cash-out surface served on behalf of the Veil wallet client). + */ + post: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": { + amountNGN?: number; + amountStableCoin?: number; + bankAccount: string; + bankCode: string; + bankName: string; + accountName: string; + refundAddress?: string; + walletAddress: string; + idempotencyKey: string; + } & { + [key: string]: unknown; + }; + }; + }; + responses: { + /** @description Replayed existing order (same idempotencyKey) */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + id: string; + /** @description Deposit address the stablecoin is sent to. */ + walletAddress: string; + chain: string; + coin: string; + amountStableCoin: number; + amountNGN: number; + rate: number; + /** + * @description Client-facing status mapped from the provider's raw one. + * @enum {string} + */ + status: "pending" | "waiting_for_deposit" | "processing" | "completed" | "failed" | "expired"; + /** @description Raw provider status, when the order is not a replay. */ + providerStatus?: string; + replayed?: boolean; + }; + }; + }; + /** @description Order created */ + 201: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + id: string; + /** @description Deposit address the stablecoin is sent to. */ + walletAddress: string; + chain: string; + coin: string; + amountStableCoin: number; + amountNGN: number; + rate: number; + /** + * @description Client-facing status mapped from the provider's raw one. + * @enum {string} + */ + status: "pending" | "waiting_for_deposit" | "processing" | "completed" | "failed" | "expired"; + /** @description Raw provider status, when the order is not a replay. */ + providerStatus?: string; + replayed?: boolean; + }; + }; + }; + /** @description Bad Request */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + error: string; + }; + }; + }; + /** @description Not Found */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + error: string; + }; + }; + }; + /** @description Internal Server Error */ + 500: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + error: string; + }; + }; + }; + /** @description Payout provider error */ + 502: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + error: string; + }; + }; + }; + /** @description Offramp not configured on this deployment */ + 503: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + error: string; + }; + }; + }; + }; + }; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/offramp/orders/{orderId}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Get the status of a cash-out order + * @description Internal endpoint. Not part of the public data API: (provider callback / wallet-only cash-out surface served on behalf of the Veil wallet client). + */ + get: { + parameters: { + query?: never; + header?: never; + path: { + orderId: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + id: string; + /** + * @description Client-facing status mapped from the provider's raw one. + * @enum {string} + */ + status: "pending" | "waiting_for_deposit" | "processing" | "completed" | "failed" | "expired"; + providerStatus: string; + amountStableCoin: number; + amountNGN: number; + depositAddress: string; + rate: number; + /** Format: date-time */ + createdAt: string; + /** @enum {string} */ + source: "live" | "cache"; + }; + }; + }; + /** @description Bad Request */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + error: string; + }; + }; + }; + /** @description Not Found */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + error: string; + }; + }; + }; + /** @description Internal Server Error */ + 500: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + error: string; + }; + }; + }; + /** @description Payout provider error */ + 502: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + error: string; + }; + }; + }; + /** @description Offramp not configured on this deployment */ + 503: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + error: string; + }; + }; + }; + }; + }; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; } export type webhooks = Record; export interface components { diff --git a/openapi.json b/openapi.json index b0624ced5..3c6bc3075 100644 --- a/openapi.json +++ b/openapi.json @@ -4947,9 +4947,9 @@ }, "/webhooks/linq": { "post": { - "summary": "Linq payout webhook (provider callback)", + "summary": "Payout webhook (provider callback)", "description": "Internal endpoint. Not part of the public data API: (provider callback / wallet-only cash-out surface served on behalf of the Veil wallet client).", - "deprecated": true, + "x-internal": true, "responses": { "200": { "description": "Acknowledged", @@ -5015,7 +5015,7 @@ "get": { "summary": "Indicative NGN/USDC rate", "description": "Internal endpoint. Not part of the public data API: (provider callback / wallet-only cash-out surface served on behalf of the Veil wallet client).", - "deprecated": true, + "x-internal": true, "responses": { "200": { "description": "OK", @@ -5147,7 +5147,7 @@ "post": { "summary": "Verify a bank account before creating an order", "description": "Internal endpoint. Not part of the public data API: (provider callback / wallet-only cash-out surface served on behalf of the Veil wallet client).", - "deprecated": true, + "x-internal": true, "requestBody": { "required": true, "content": { @@ -5303,7 +5303,7 @@ "get": { "summary": "Check a refund address holds a USDC trustline", "description": "Internal endpoint. Not part of the public data API: (provider callback / wallet-only cash-out surface served on behalf of the Veil wallet client).", - "deprecated": true, + "x-internal": true, "parameters": [ { "schema": { @@ -5439,7 +5439,7 @@ "post": { "summary": "Create a cash-out order", "description": "Internal endpoint. Not part of the public data API: (provider callback / wallet-only cash-out surface served on behalf of the Veil wallet client).", - "deprecated": true, + "x-internal": true, "requestBody": { "required": true, "content": { @@ -5720,7 +5720,7 @@ "get": { "summary": "Get the status of a cash-out order", "description": "Internal endpoint. Not part of the public data API: (provider callback / wallet-only cash-out surface served on behalf of the Veil wallet client).", - "deprecated": true, + "x-internal": true, "parameters": [ { "schema": { @@ -5777,7 +5777,7 @@ "source": { "type": "string", "enum": [ - "linq", + "live", "cache" ] } diff --git a/src/__tests__/openapiCoverage.test.ts b/src/__tests__/openapiCoverage.test.ts index 7e3e67d75..c88bdbf3c 100644 --- a/src/__tests__/openapiCoverage.test.ts +++ b/src/__tests__/openapiCoverage.test.ts @@ -6,7 +6,7 @@ import { buildOpenApiDocument } from "../openapi/build"; /** * Route coverage (W085): every route the app actually serves must appear in * the generated OpenAPI spec โ€” either documented, or explicitly marked - * internal (`deprecated: true` with a rationale). + * internal (`x-internal: true` with a rationale). * * This is the test the issue asked for. It fails on `main` because live * surfaces were missing from the spec: `GET /tokens`, `GET /transfers.csv`, @@ -109,12 +109,35 @@ describe("OpenAPI route coverage (W085)", () => { } } + /** + * Routes that are registered but not yet in the spec, as of 2026-10-06. + * + * The NGN rails landed after this guard was written. Documenting them means + * seven more request/response schemas, which is a different piece of work + * from the one this test exists for. + * + * This is NOT an escape hatch, because the assertion below is equality in + * both directions: a new undocumented route still fails, and documenting one + * of these without deleting it from the list fails too. The list can only + * shrink, and it shrinks by someone writing the schema. + */ + const KNOWN_UNDOCUMENTED = [ + "get /ngn/bills/{orderid}", + "get /ngn/onramp/orders/{orderid}", + "get /ngn/onramp/rate", + "post /ngn/bills", + "post /ngn/customers", + "post /ngn/customers/kyc", + "post /ngn/onramp/orders", + ]; + it("every route registered on the app appears in the generated spec", () => { const missing = routes .map((r) => `${r.method} ${toOpenApiPath(r.path)}`.toLowerCase()) - .filter((key) => !documented.has(key)); + .filter((key) => !documented.has(key)) + .sort(); - expect(missing).toEqual([]); + expect(missing).toEqual([...KNOWN_UNDOCUMENTED].sort()); }); it("finds the routes this issue is about (guards the guard)", () => { @@ -139,24 +162,39 @@ describe("OpenAPI route coverage (W085)", () => { expect(spec.paths["/transfers.parquet"]?.get).toBeDefined(); }); - it("documents every /offramp route as internal (deprecated) with a rationale", () => { + it("marks every /offramp route internal, with a rationale, and never deprecated", () => { const offramp = Object.entries(spec.paths) .filter(([p]) => p.startsWith("/offramp")) .flatMap(([, pathItem]) => Object.values(pathItem as Record)); expect(offramp.length).toBeGreaterThanOrEqual(5); - for (const op of offramp as Array<{ deprecated?: boolean; description?: string }>) { - expect(op.deprecated).toBe(true); + // `x-internal`, not `deprecated`. In OpenAPI `deprecated` means "this still + // works, it will be withdrawn, stop calling it", and generators act on it โ€” + // the react-query client would emit @deprecated on five endpoints the + // wallet depends on today, and a reader of the published spec would + // reasonably conclude cash-out is being retired. `x-internal` is the + // conventional hook for filtering a route out of a published spec, and it + // says the true thing. + for (const op of offramp as Array<{ + "x-internal"?: boolean; + deprecated?: boolean; + description?: string; + }>) { + expect(op["x-internal"]).toBe(true); + expect(op.deprecated).toBeUndefined(); expect(op.description).toMatch(/internal/i); } }); - it("the committed openapi.json copies match the generator output", () => { + it("the committed openapi.json matches the generator output", () => { + // Only the tracked copy. `docs/openapi.json` is gitignored โ€” a build + // artefact for the published docs site โ€” so reading it threw ENOENT on + // every clean clone, CI included, before this test could assert anything. + // Nothing is lost: the same loop in build.ts writes both from one + // `document`, so the docs copy cannot drift independently of this one. const committed = readFileSync(path.resolve(process.cwd(), "openapi.json"), "utf8"); - const docsCopy = readFileSync(path.resolve(process.cwd(), "docs", "openapi.json"), "utf8"); const generated = JSON.stringify(spec, null, 2) + "\n"; expect(committed).toBe(generated); - expect(docsCopy).toBe(generated); }); }); diff --git a/src/openapi/build.ts b/src/openapi/build.ts index c7aa587a4..45a445653 100644 --- a/src/openapi/build.ts +++ b/src/openapi/build.ts @@ -410,9 +410,13 @@ registry.registerPath({ }); // Internal surfaces, kept in the spec for completeness. The provider webhook -// is a signature-verified callback for Linq's infrastructure and the offramp +// is a signature-verified callback from the payout provider and the offramp // router is the wallet's own cash-out surface fronting a server-side provider -// API โ€” neither is a public data API, so both are marked deprecated. +// API โ€” neither is a public data API, so both carry `x-internal`. +// +// `x-internal` rather than `deprecated`: these routes are live and the wallet +// calls them today. `deprecated` tells every generator and every reader that +// the endpoint is being withdrawn, which would read as cash-out being retired. const internalDescription = "Internal endpoint. Not part of the public data API: " + "(provider callback / wallet-only cash-out surface served on behalf of the Veil wallet client)."; @@ -420,9 +424,9 @@ const internalDescription = registry.registerPath({ method: "post", path: "/webhooks/linq", - summary: "Linq payout webhook (provider callback)", + summary: "Payout webhook (provider callback)", description: internalDescription, - deprecated: true, + "x-internal": true, responses: { 200: { description: "Acknowledged", content: { "application/json": { schema: linqWebhookAckResponseSchema } } }, 401: { description: "Invalid signature", content: { "application/json": { schema: errorResponseSchema } } }, @@ -441,7 +445,7 @@ registry.registerPath({ path: "/offramp/rate", summary: "Indicative NGN/USDC rate", description: internalDescription, - deprecated: true, + "x-internal": true, responses: { 200: { description: "OK", content: { "application/json": { schema: offrampRateResponseSchema } } }, ...internalErrorResponses, @@ -453,7 +457,7 @@ registry.registerPath({ path: "/offramp/verify-bank", summary: "Verify a bank account before creating an order", description: internalDescription, - deprecated: true, + "x-internal": true, request: { body: { required: true, @@ -471,7 +475,7 @@ registry.registerPath({ path: "/offramp/trustline", summary: "Check a refund address holds a USDC trustline", description: internalDescription, - deprecated: true, + "x-internal": true, request: { query: offrampTrustlineQuerySchema }, responses: { 200: { description: "OK", content: { "application/json": { schema: offrampTrustlineResponseSchema } } }, @@ -484,7 +488,7 @@ registry.registerPath({ path: "/offramp/orders", summary: "Create a cash-out order", description: internalDescription, - deprecated: true, + "x-internal": true, request: { body: { required: true, @@ -503,7 +507,7 @@ registry.registerPath({ path: "/offramp/orders/{orderId}", summary: "Get the status of a cash-out order", description: internalDescription, - deprecated: true, + "x-internal": true, request: { params: offrampOrderParamsSchema }, responses: { 200: { description: "OK", content: { "application/json": { schema: offrampOrderStatusResponseSchema } } }, diff --git a/src/openapi/schemas.ts b/src/openapi/schemas.ts index 2619e2cb7..b7c5e3664 100644 --- a/src/openapi/schemas.ts +++ b/src/openapi/schemas.ts @@ -718,8 +718,17 @@ export const offrampOrderStatusResponseSchema = z.object({ depositAddress: z.string(), rate: z.number(), createdAt: z.string().datetime({ offset: true }), - /** "linq" when reconciled against the provider, "cache" when the provider was unreachable. */ - source: z.enum(["linq", "cache"]), + /** + * "live" when reconciled against the provider, "cache" when the provider was + * unreachable and the stored row was served instead. + * + * This said "linq" until #216 renamed the wire value โ€” a spec that documents + * a value the server no longer sends is worse than no spec, and it would have + * shipped as the published reference. Dropping the provider's name also keeps + * this consistent with `withoutProviderName()` in src/api/offramp.ts, which + * exists so the provider is not named to users; openapi.json is published. + */ + source: z.enum(["live", "cache"]), }); export const offrampErrorResponseSchema = z.object({