From 2ecb7a786c4b9eaafc1c31a8eb82384be1aa6778 Mon Sep 17 00:00:00 2001 From: Eric Viana Date: Mon, 3 Aug 2026 21:27:38 -0300 Subject: [PATCH 1/3] feat: deterministic api-sync patcher (Phase A) Replaces the AI-driven api-sync pipeline with a deterministic, state-based patcher (scripts/api-sync/), a curated spec-map.json, and an honest unmodeled.json inventory of every known gap between the spec and this SDK. Applies the two changes verified safe: QuoteIn.refund_wallet_address on CreateQuoteInput, and the missing "portage" banking_partner enum member (both physical BankingPartner declarations). Bumps to 5.1.0 (enum member added). Everything else found along the way (tracking_payment/tracking_transaction gaps, kyc_status, several bare-string enum regressions, one real string-vs-number type bug on Payin.billing_fee_amount, and more) is recorded in unmodeled.json with an owner and a specific reason, not silently applied. Adds sync/sync:check/audit:types/sync:coverage scripts, a CI job wired into main.yaml (state+map validity, a determinism proof, and a non-blocking coverage + type-audit report), and rewrites api-sync.yml to run the patcher instead of Claude, removing CLAUDE_CODE_OAUTH_TOKEN entirely. Claude-Session: https://claude.ai/code/session_01F1stiNzuNtJXoXtiW9ZCbs --- .api-sync/spec-map.json | 1863 +++++++++++++++++++++++ .api-sync/spec-snapshot.json | 162 +- .api-sync/unmodeled.json | 908 +++++++++++ .github/workflows/api-sync-check.yaml | 59 + .github/workflows/api-sync.yml | 136 +- .github/workflows/main.yaml | 2 + .gitignore | 3 + package.json | 8 +- scripts/api-sync/apply.test.ts | 130 ++ scripts/api-sync/apply.ts | 98 ++ scripts/api-sync/audit.ts | 107 ++ scripts/api-sync/coverage.ts | 42 + scripts/api-sync/diff.test.ts | 191 +++ scripts/api-sync/diff.ts | 103 ++ scripts/api-sync/index.ts | Bin 0 -> 8352 bytes scripts/api-sync/map.test.ts | 89 ++ scripts/api-sync/map.ts | 63 + scripts/api-sync/reconcile.test.ts | 455 ++++++ scripts/api-sync/reconcile.ts | 270 ++++ scripts/api-sync/sdk-scan.test.ts | 127 ++ scripts/api-sync/sdk-scan.ts | 194 +++ scripts/api-sync/spec.ts | 170 +++ scripts/api-sync/type-check.test.ts | 111 ++ scripts/api-sync/type-check.ts | 202 +++ scripts/api-sync/types.ts | 126 ++ scripts/api-sync/version.test.ts | 57 + scripts/api-sync/version.ts | 44 + src/resources/quotes/index.ts | 1 + src/resources/virtual-accounts/index.ts | 2 +- types/index.d.ts | 2 +- 30 files changed, 5662 insertions(+), 63 deletions(-) create mode 100644 .api-sync/spec-map.json create mode 100644 .api-sync/unmodeled.json create mode 100644 .github/workflows/api-sync-check.yaml create mode 100644 scripts/api-sync/apply.test.ts create mode 100644 scripts/api-sync/apply.ts create mode 100644 scripts/api-sync/audit.ts create mode 100644 scripts/api-sync/coverage.ts create mode 100644 scripts/api-sync/diff.test.ts create mode 100644 scripts/api-sync/diff.ts create mode 100644 scripts/api-sync/index.ts create mode 100644 scripts/api-sync/map.test.ts create mode 100644 scripts/api-sync/map.ts create mode 100644 scripts/api-sync/reconcile.test.ts create mode 100644 scripts/api-sync/reconcile.ts create mode 100644 scripts/api-sync/sdk-scan.test.ts create mode 100644 scripts/api-sync/sdk-scan.ts create mode 100644 scripts/api-sync/spec.ts create mode 100644 scripts/api-sync/type-check.test.ts create mode 100644 scripts/api-sync/type-check.ts create mode 100644 scripts/api-sync/types.ts create mode 100644 scripts/api-sync/version.test.ts create mode 100644 scripts/api-sync/version.ts diff --git a/.api-sync/spec-map.json b/.api-sync/spec-map.json new file mode 100644 index 0000000..c121b7c --- /dev/null +++ b/.api-sync/spec-map.json @@ -0,0 +1,1863 @@ +{ + "notes": [ + "Bootstrapped by scoring each exported SDK type's field-set against each spec schema's property-set, then every entry was verified by reading the source.", + "Scope decisions (not individually re-justified per entry): (1) path-identifier params bundled into an Input type for URL building (customer_id, id, bank_account_id, memberId, payout_id, and instance_id as a query param on UploadInput) are expected and not tracked as gaps or divergences. (2) discriminator fields injected by a factory function body (type/kyc_type on customers and bank-accounts, is_account_abstraction on blockchain wallets, the role->user_role rename on instance members) are expected to be absent from the corresponding input type. (3) nesting is curated one level deep; deeper nested sub-objects (e.g. VirtualAccountOut.us/blockchain_wallet's own inner fields, PayinTrackingTransaction's pse_instruction/transfers_instruction/ted_instruction sub-fields) are out of scope for this pass.", + "Reachability: every reachable schema (computed by transitive $ref from paths + the top-level webhooks object) is either referenced by an enums/types entry's spec.schema, or listed in ignore.schemas. Verified programmatically at bootstrap time." + ], + "enums": [ + { + "spec": { + "schema": "AvailableBankDetails", + "property": "[].key" + }, + "sdk": [ + { + "file": "src/resources/available/index.ts", + "symbol": "BankDetailKey" + } + ], + "notes": null + }, + { + "spec": { + "schema": "BankAccountWebhookOut", + "property": "account_type" + }, + "sdk": [ + { + "file": "types/index.d.ts", + "symbol": "BankAccountType" + } + ], + "notes": "CONFIRMED CORRECT: matches packages/api-contract/src/bank-account.schema.ts's accountType exactly (\"checking\"|\"saving\"). Do not touch." + }, + { + "spec": { + "schema": "BankAccountWebhookOut", + "property": "ach_cop_document_type" + }, + "sdk": [ + { + "file": "types/index.d.ts", + "symbol": "AchCopDocument" + } + ], + "notes": "Anchored on the ignored webhook schema only because it is reachable and carries the same enum as CreateBankAccountIn/BankAccountOut/PayoutOut.ach_cop_document_type." + }, + { + "spec": { + "schema": "BankAccountWebhookOut", + "property": "offramp_wallets.[].network" + }, + "sdk": [ + { + "file": "src/resources/wallets/offramp.ts", + "symbol": "OfframpWalletNetwork" + } + ], + "notes": "Nested at offramp_wallets.[].network." + }, + { + "spec": { + "schema": "BankAccountWebhookOut", + "property": "recipient_relationship" + }, + "sdk": [ + { + "file": "types/index.d.ts", + "symbol": "RecipientRelationship" + }, + { + "file": "src/resources/bank-accounts/index.ts", + "symbol": "RecipientRelationship" + } + ], + "notes": "Declared twice with different membership per the survey; both currently cover spec's 10 members." + }, + { + "spec": { + "schema": "BankAccountWebhookOut", + "property": "spei_protocol" + }, + "sdk": [ + { + "file": "src/resources/payouts/index.ts", + "symbol": "SpeiProtocol" + } + ], + "notes": "Imported into bank-accounts/index.ts from ../payouts (cross-resource type reuse)." + }, + { + "spec": { + "schema": "BankAccountWebhookOut", + "property": "type" + }, + "sdk": [ + { + "file": "types/index.d.ts", + "symbol": "Rail" + } + ], + "notes": "Missing 'sepa': the SDK already implements SEPA bank-account creation (createSepa/CreateSepaInput, hardcoded type literal \"sepa\") but the shared Rail union used for List*Input filters and Payout.type was never extended to include it." + }, + { + "spec": { + "schema": "CreateCustomerIn", + "property": "kyc_type" + }, + "sdk": [ + { + "file": "src/resources/customers/index.ts", + "symbol": "KycType" + } + ], + "notes": null + }, + { + "spec": { + "schema": "CreatePayinQuoteIn", + "property": "currency_type" + }, + "sdk": [ + { + "file": "types/index.d.ts", + "symbol": "CurrencyType" + } + ], + "notes": null + }, + { + "spec": { + "schema": "CreatePayinQuoteIn", + "property": "payment_method" + }, + "sdk": [ + { + "file": "types/index.d.ts", + "symbol": "PaymentMethod" + }, + { + "file": "types/index.d.ts", + "symbol": "PayinPaymentMethod" + } + ], + "notes": "Declared twice with identical values (PaymentMethod and PayinPaymentMethod, both in types/index.d.ts); both used across payins/transfers/bank-accounts. Verified: spec has exactly these 9 values (ach, wire, pix, spei, transfers, pse, international_swift, rtp, ted), matching both SDK unions exactly." + }, + { + "spec": { + "schema": "CreateVirtualAccountIn", + "property": "sole_proprietor_doc_type" + }, + "sdk": [ + { + "file": "types/index.d.ts", + "symbol": "SoleProprietorDocType" + }, + { + "file": "src/resources/virtual-accounts/index.ts", + "symbol": "SoleProprietorDocType" + } + ], + "notes": "Declared twice, same reasoning as BankingPartner." + }, + { + "spec": { + "schema": "CustomerLimitIncreaseIn", + "property": "supporting_document_type" + }, + "sdk": [ + { + "file": "src/resources/customers/index.ts", + "symbol": "LimitIncreaseRequestSupportingDocumentType" + } + ], + "notes": null + }, + { + "spec": { + "schema": "CustomerOut", + "property": "aml_status" + }, + "sdk": [ + { + "file": "src/resources/customers/index.ts", + "symbol": "AmlStatus" + } + ], + "notes": null + }, + { + "spec": { + "schema": "CustomerOut", + "property": "kyc_status" + }, + "sdk": [ + { + "file": "types/index.d.ts", + "symbol": "KycStatus" + } + ], + "notes": "Also appears on VirtualAccountOut.kyc_status and on the ignored *WebhookOut customer/limit-increase schemas." + }, + { + "spec": { + "schema": "LimitIncreaseUpdateWebhookOut", + "property": "status" + }, + "sdk": [ + { + "file": "src/resources/customers/index.ts", + "symbol": "LimitIncreaseRequestStatus" + } + ], + "notes": null + }, + { + "spec": { + "schema": "PayinOut", + "property": "currency" + }, + "sdk": [ + { + "file": "types/index.d.ts", + "symbol": "Currency" + } + ], + "notes": "SDK union additionally carries the 3 crypto settlement tokens (USDC, USDT, USDB), which are the separate StablecoinToken concept on the spec side, not part of this fiat-currency property." + }, + { + "spec": { + "schema": "PayinOut", + "property": "tracking_transaction.transfers_instruction.type" + }, + "sdk": [ + { + "file": "types/index.d.ts", + "symbol": "ArgentinaTransfers" + } + ], + "notes": "Nested at tracking_transaction.transfers_instruction.type." + }, + { + "spec": { + "schema": "PayinOut", + "property": "type" + }, + "sdk": [ + { + "file": "types/index.d.ts", + "symbol": "AccountClass" + } + ], + "notes": null + }, + { + "spec": { + "schema": "PayoutOut", + "property": "tracking_payment.estimated_time_of_arrival" + }, + "sdk": [ + { + "file": "types/index.d.ts", + "symbol": "EstimatedTimeOfArrival" + } + ], + "notes": "Single-quoted union declaration (unlike most others in this file, which are double-quoted); also on tracking_liquidity.estimated_time_of_arrival." + }, + { + "spec": { + "schema": "PayoutOut", + "property": "transaction_document_type" + }, + "sdk": [ + { + "file": "types/index.d.ts", + "symbol": "TransactionDocumentType" + } + ], + "notes": null + }, + { + "spec": { + "schema": "PayoutUpdateWebhookOut", + "property": "status" + }, + "sdk": [ + { + "file": "types/index.d.ts", + "symbol": "TransactionStatus" + } + ], + "notes": null + }, + { + "spec": { + "schema": "UpdateCustomerIn", + "property": "account_purpose" + }, + "sdk": [ + { + "file": "types/index.d.ts", + "symbol": "AccountPurpose" + } + ], + "notes": null + }, + { + "spec": { + "schema": "UpdateCustomerIn", + "property": "business_industry" + }, + "sdk": [ + { + "file": "types/index.d.ts", + "symbol": "BusinessIndustry" + } + ], + "notes": "SDK union additionally carries crypto/web3 business categories (dapp, exchange, gambling, gaming, infra, marketplace, neo_bank, other, saas, social, wallet) that are not NAICS codes and are not part of this spec property at all." + }, + { + "spec": { + "schema": "UpdateCustomerIn", + "property": "business_type" + }, + "sdk": [ + { + "file": "types/index.d.ts", + "symbol": "BusinessType" + } + ], + "notes": null + }, + { + "spec": { + "schema": "UpdateCustomerIn", + "property": "country" + }, + "sdk": [ + { + "file": "types/index.d.ts", + "symbol": "Country" + } + ], + "notes": null + }, + { + "spec": { + "schema": "UpdateCustomerIn", + "property": "estimated_annual_revenue" + }, + "sdk": [ + { + "file": "types/index.d.ts", + "symbol": "EstimatedAnnualRevenue" + } + ], + "notes": null + }, + { + "spec": { + "schema": "UpdateCustomerIn", + "property": "id_doc_type" + }, + "sdk": [ + { + "file": "src/resources/customers/index.ts", + "symbol": "IdentificationDocument" + } + ], + "notes": null + }, + { + "spec": { + "schema": "UpdateCustomerIn", + "property": "owners.[].role" + }, + "sdk": [ + { + "file": "src/resources/customers/index.ts", + "symbol": "OwnerRole" + } + ], + "notes": null + }, + { + "spec": { + "schema": "UpdateCustomerIn", + "property": "owners.[].tax_type" + }, + "sdk": [ + { + "file": "src/resources/customers/index.ts", + "symbol": "OwnerTaxType" + } + ], + "notes": null + }, + { + "spec": { + "schema": "UpdateCustomerIn", + "property": "proof_of_address_doc_type" + }, + "sdk": [ + { + "file": "src/resources/customers/index.ts", + "symbol": "ProofOfAddressDocType" + } + ], + "notes": null + }, + { + "spec": { + "schema": "UpdateCustomerIn", + "property": "purpose_of_transactions" + }, + "sdk": [ + { + "file": "types/index.d.ts", + "symbol": "PurposeOfTransactions" + } + ], + "notes": null + }, + { + "spec": { + "schema": "UpdateCustomerIn", + "property": "source_of_funds_doc_type" + }, + "sdk": [ + { + "file": "types/index.d.ts", + "symbol": "SourceOfFundsDocType" + } + ], + "notes": null + }, + { + "spec": { + "schema": "UpdateCustomerIn", + "property": "source_of_wealth" + }, + "sdk": [ + { + "file": "types/index.d.ts", + "symbol": "SourceOfWealth" + } + ], + "notes": null + }, + { + "spec": { + "schema": "UpdateInstanceMemberIn", + "property": "user_role" + }, + "sdk": [ + { + "file": "src/resources/instances/index.ts", + "symbol": "InstanceMemberRole" + } + ], + "notes": null + }, + { + "spec": { + "schema": "UploadIn", + "property": "bucket" + }, + "sdk": [ + { + "file": "src/resources/upload/index.ts", + "symbol": "UploadBucket" + } + ], + "notes": null + }, + { + "spec": { + "schema": "VirtualAccountOut", + "property": "banking_partner" + }, + "sdk": [ + { + "file": "types/index.d.ts", + "symbol": "BankingPartner" + }, + { + "file": "src/resources/virtual-accounts/index.ts", + "symbol": "BankingPartner" + } + ], + "notes": "Declared twice: the shared ambient type (types/index.d.ts) is dead code never imported anywhere; virtual-accounts/index.ts's local redeclaration is the one actually used by VirtualAccount/CreateVirtualAccountInput. Both must be patched together. Same values also on CreateVirtualAccountIn.banking_partner." + }, + { + "spec": { + "schema": "VirtualAccountOut", + "property": "blockchain_wallet.network" + }, + "sdk": [ + { + "file": "types/index.d.ts", + "symbol": "Network" + } + ], + "notes": "Nested at blockchain_wallet.network; canonical Network enum also used broadly across quotes/payouts/wallets/bank-accounts." + }, + { + "spec": { + "schema": "VirtualAccountOut", + "property": "token" + }, + "sdk": [ + { + "file": "types/index.d.ts", + "symbol": "StablecoinToken" + } + ], + "notes": null + }, + { + "spec": { + "schema": "WebhookEndpointIn", + "property": "events", + "itemsEnum": true + }, + "sdk": [ + { + "file": "src/resources/webhooks/index.ts", + "symbol": "WebhookEvents" + } + ], + "notes": "Array-of-enum: values live on properties.events.items.enum, not a top-level schema enum. This is also contract-check.mjs's own hard-fail anchor (regex-parsed)." + } + ], + "types": [ + { + "spec": { + "path": "/v1/instances/{id}/members", + "method": "get", + "location": "response:200" + }, + "sdk": [ + { + "file": "src/resources/instances/index.ts", + "symbol": "GetInstanceMembersResponse" + } + ], + "notes": "inline array response, not a named schema. Note: spec names this path param {id}, not {instance_id}, unlike most other instance-scoped paths." + }, + { + "spec": { + "path": "/v1/instances/{instance_id}/create-asset-trustline", + "method": "post", + "location": "response:200" + }, + "sdk": [ + { + "file": "src/resources/wallets/blockchain.ts", + "symbol": "CreateAssetTrustlineResponse" + } + ], + "notes": "inline object body, not a named schema" + }, + { + "spec": { + "path": "/v1/instances/{instance_id}/customers/{customer_id}/bank-accounts/{bank_account_id}/offramp-wallets", + "method": "post", + "location": "requestBody" + }, + "sdk": [ + { + "file": "src/resources/wallets/offramp.ts", + "symbol": "CreateOfframpWalletInput" + } + ], + "notes": "inline object body, not a named schema" + }, + { + "spec": { + "path": "/v1/instances/{instance_id}/customers/{customer_id}/bank-accounts/{bank_account_id}/offramp-wallets", + "method": "post", + "location": "response:200" + }, + "sdk": [ + { + "file": "src/resources/wallets/offramp.ts", + "symbol": "CreateOfframpWalletResponse" + } + ], + "notes": "inline object body, not a named schema" + }, + { + "spec": { + "path": "/v1/instances/{instance_id}/partner-fees", + "method": "post", + "location": "requestBody" + }, + "sdk": [ + { + "file": "src/resources/partner-fees/index.ts", + "symbol": "CreatePartnerFeeInput" + } + ], + "notes": "inline object body, not a named schema" + }, + { + "spec": { + "path": "/v1/instances/{instance_id}/partner-fees", + "method": "post", + "location": "response:200" + }, + "sdk": [ + { + "file": "src/resources/partner-fees/index.ts", + "symbol": "CreatePartnerFeeResponse" + } + ], + "notes": "inline object body, not a named schema" + }, + { + "spec": { + "path": "/v1/instances/{instance_id}/partner-fees", + "method": "get", + "location": "response:200" + }, + "sdk": [ + { + "file": "src/resources/partner-fees/index.ts", + "symbol": "ListPartnerFeesResponse" + } + ], + "notes": "inline array body, not a named schema" + }, + { + "spec": { + "path": "/v1/instances/{instance_id}/partner-fees/{id}", + "method": "get", + "location": "response:200" + }, + "sdk": [ + { + "file": "src/resources/partner-fees/index.ts", + "symbol": "GetPartnerFeeResponse" + } + ], + "notes": "inline object body, not a named schema" + }, + { + "spec": { + "path": "/v1/instances/{instance_id}/payin-quotes/fx", + "method": "post", + "location": "requestBody" + }, + "sdk": [ + { + "file": "src/resources/payins/quotes.ts", + "symbol": "GetPayinFxRateInput" + } + ], + "notes": "inline object body, not a named schema" + }, + { + "spec": { + "path": "/v1/instances/{instance_id}/payin-quotes/fx", + "method": "post", + "location": "response:200" + }, + "sdk": [ + { + "file": "src/resources/payins/quotes.ts", + "symbol": "GetPayinFxRateResponse" + } + ], + "notes": "inline object body, not a named schema" + }, + { + "spec": { + "path": "/v1/instances/{instance_id}/payouts/stellar/authorize", + "method": "post", + "location": "response:200" + }, + "sdk": [ + { + "file": "src/resources/payouts/index.ts", + "symbol": "AuthorizeStellarTokenResponse" + } + ], + "notes": "inline object body, not a named schema" + }, + { + "spec": { + "path": "/v1/instances/{instance_id}/quotes/fx", + "method": "post", + "location": "requestBody" + }, + "sdk": [ + { + "file": "src/resources/quotes/index.ts", + "symbol": "GetFxRateInput" + } + ], + "notes": "inline object body, not a named schema" + }, + { + "spec": { + "path": "/v1/instances/{instance_id}/quotes/fx", + "method": "post", + "location": "response:200" + }, + "sdk": [ + { + "file": "src/resources/quotes/index.ts", + "symbol": "GetFxRateResponse" + } + ], + "notes": "inline object body, not a named schema" + }, + { + "spec": { + "path": "/v1/instances/{instance_id}/webhook-endpoints/{id}/secret", + "method": "get", + "location": "response:200" + }, + "sdk": [ + { + "file": "src/resources/webhooks/index.ts", + "symbol": "GetWebhookEndpointSecretResponse" + } + ], + "notes": "inline object body, not a named schema" + }, + { + "spec": { + "schema": "AvailableBankDetails" + }, + "sdk": [ + { + "file": "src/resources/available/index.ts", + "symbol": "GetBankDetailsResponse" + } + ], + "notes": "array item" + }, + { + "spec": { + "schema": "AvailableNaicsList" + }, + "sdk": [ + { + "file": "src/resources/available/index.ts", + "symbol": "GetNaicsCodesResponse" + } + ], + "notes": "array item" + }, + { + "spec": { + "schema": "AvailableRails" + }, + "sdk": [ + { + "file": "src/resources/available/index.ts", + "symbol": "GetRailsResponse" + } + ], + "notes": "array item" + }, + { + "spec": { + "schema": "BankAccountOut" + }, + "sdk": [ + { + "file": "src/resources/bank-accounts/index.ts", + "symbol": "GetBankAccountResponse" + }, + { + "file": "src/resources/bank-accounts/index.ts", + "symbol": "ListBankAccountsResponse" + }, + { + "file": "src/resources/bank-accounts/index.ts", + "symbol": "CreatePixResponse" + }, + { + "file": "src/resources/bank-accounts/index.ts", + "symbol": "CreatePixSafeResponse" + }, + { + "file": "src/resources/bank-accounts/index.ts", + "symbol": "CreateArgentinaTransfersResponse" + }, + { + "file": "src/resources/bank-accounts/index.ts", + "symbol": "CreateSpeiResponse" + }, + { + "file": "src/resources/bank-accounts/index.ts", + "symbol": "CreateColombiaAchResponse" + }, + { + "file": "src/resources/bank-accounts/index.ts", + "symbol": "CreateAchResponse" + }, + { + "file": "src/resources/bank-accounts/index.ts", + "symbol": "CreateWireResponse" + }, + { + "file": "src/resources/bank-accounts/index.ts", + "symbol": "CreateInternationalSwiftResponse" + }, + { + "file": "src/resources/bank-accounts/index.ts", + "symbol": "CreateRtpResponse" + }, + { + "file": "src/resources/bank-accounts/index.ts", + "symbol": "CreateSepaResponse" + }, + { + "file": "src/resources/bank-accounts/index.ts", + "symbol": "CreateTedResponse" + } + ], + "notes": "Fan-out response side: get, list item, and 11 rail create-response types." + }, + { + "spec": { + "schema": "BlockchainWalletMessageOut" + }, + "sdk": [ + { + "file": "src/resources/wallets/blockchain.ts", + "symbol": "GetBlockchainWalletMessageResponse" + } + ], + "notes": null + }, + { + "spec": { + "schema": "BlockchainWalletOut" + }, + "sdk": [ + { + "file": "src/resources/wallets/blockchain.ts", + "symbol": "ListBlockchainWalletsResponse" + }, + { + "file": "src/resources/wallets/blockchain.ts", + "symbol": "GetBlockchainWalletResponse" + }, + { + "file": "src/resources/wallets/blockchain.ts", + "symbol": "CreateBlockchainWalletResponse" + } + ], + "notes": "array item / get / create-response, all identical shape" + }, + { + "spec": { + "schema": "CreateBankAccountIn" + }, + "sdk": [ + { + "file": "src/resources/bank-accounts/index.ts", + "symbol": "CreatePixInput" + }, + { + "file": "src/resources/bank-accounts/index.ts", + "symbol": "CreatePixSafeInput" + }, + { + "file": "src/resources/bank-accounts/index.ts", + "symbol": "CreateArgentinaTransfersInput" + }, + { + "file": "src/resources/bank-accounts/index.ts", + "symbol": "CreateSpeiInput" + }, + { + "file": "src/resources/bank-accounts/index.ts", + "symbol": "CreateColombiaAchInput" + }, + { + "file": "src/resources/bank-accounts/index.ts", + "symbol": "CreateAchInput" + }, + { + "file": "src/resources/bank-accounts/index.ts", + "symbol": "CreateWireInput" + }, + { + "file": "src/resources/bank-accounts/index.ts", + "symbol": "CreateInternationalSwiftInput" + }, + { + "file": "src/resources/bank-accounts/index.ts", + "symbol": "CreateRtpInput" + }, + { + "file": "src/resources/bank-accounts/index.ts", + "symbol": "CreateSepaInput" + }, + { + "file": "src/resources/bank-accounts/index.ts", + "symbol": "CreateTedInput" + } + ], + "notes": "Discriminator fan-out: 11 rail create input types. The `type` discriminator itself is injected by the factory function body (bank-accounts/index.ts:508-610), not carried on any input type by design." + }, + { + "spec": { + "schema": "CreateBlockchainWalletIn" + }, + "sdk": [ + { + "file": "src/resources/wallets/blockchain.ts", + "symbol": "CreateBlockchainWalletWithAddressInput" + }, + { + "file": "src/resources/wallets/blockchain.ts", + "symbol": "CreateBlockchainWalletWithHashInput" + } + ], + "notes": "Discriminator fan-out: is_account_abstraction is injected by the factory function body (blockchain.ts:109-136), not carried on either input type by design." + }, + { + "spec": { + "schema": "CreateCustomerIn" + }, + "sdk": [ + { + "file": "src/resources/customers/index.ts", + "symbol": "CreateIndividualWithStandardKYCInput" + }, + { + "file": "src/resources/customers/index.ts", + "symbol": "CreateIndividualWithEnhancedKYCInput" + }, + { + "file": "src/resources/customers/index.ts", + "symbol": "CreateBusinessWithStandardKYBInput" + } + ], + "notes": "Discriminator fan-out: the 3 KYC/KYB input types. `type` and `kyc_type` discriminators are injected by the factory function body (customers/index.ts:521-547), not carried on any input type by design. kyc_type=\"light\" has no corresponding SDK input type at all (not modeled)." + }, + { + "spec": { + "schema": "CreatePayinIn" + }, + "sdk": [ + { + "file": "src/resources/payins/index.ts", + "symbol": "CreateEvmPayinInput" + } + ], + "notes": "createEvm(payin_quote_id) posts {payin_quote_id}, matching this schema's one field exactly. CreatePayinInput (payins/index.ts:111, `{quote_id: string}`) is dead code: never referenced by the factory, and its field name does not even match the spec (payin_quote_id). Orphaned, same situation as CreatePayoutInput (payouts/index.ts:106) noted separately." + }, + { + "spec": { + "schema": "CreatePayinOut" + }, + "sdk": [ + { + "file": "src/resources/payins/index.ts", + "symbol": "CreateEvmPayinResponse" + } + ], + "notes": "CreateEvmPayinResponse is Pick & extra fields (blindpay_bank_details, customer_id, receiver_amount, payment_method, billing_fee, sender_amount); tracking_* sub-fields resolve through the same shared types as Payin." + }, + { + "spec": { + "schema": "CreatePayinQuoteIn" + }, + "sdk": [ + { + "file": "src/resources/payins/quotes.ts", + "symbol": "CreatePayinQuoteInput" + } + ], + "notes": null + }, + { + "spec": { + "schema": "CreatePayinQuoteOut" + }, + "sdk": [ + { + "file": "src/resources/payins/quotes.ts", + "symbol": "CreatePayinQuoteResponse" + } + ], + "notes": null + }, + { + "spec": { + "schema": "CreateTransferIn" + }, + "sdk": [ + { + "file": "src/resources/transfers/index.ts", + "symbol": "CreateTransferInput" + } + ], + "notes": null + }, + { + "spec": { + "schema": "CreateTransferOut" + }, + "sdk": [ + { + "file": "src/resources/transfers/index.ts", + "symbol": "CreateTransferResponse" + } + ], + "notes": null + }, + { + "spec": { + "schema": "CreateTransferQuoteIn" + }, + "sdk": [ + { + "file": "src/resources/transfers/index.ts", + "symbol": "CreateTransferQuoteInput" + } + ], + "notes": null + }, + { + "spec": { + "schema": "CreateTransferQuoteOut" + }, + "sdk": [ + { + "file": "src/resources/transfers/index.ts", + "symbol": "CreateTransferQuoteResponse" + } + ], + "notes": null + }, + { + "spec": { + "schema": "CreateVirtualAccountIn" + }, + "sdk": [ + { + "file": "src/resources/virtual-accounts/index.ts", + "symbol": "CreateVirtualAccountInput" + } + ], + "notes": null + }, + { + "spec": { + "schema": "CreateWalletIn" + }, + "sdk": [ + { + "file": "src/resources/wallets/custodial.ts", + "symbol": "CreateCustodialWalletInput" + } + ], + "notes": null + }, + { + "spec": { + "schema": "CustomerLimitIncreaseIn" + }, + "sdk": [ + { + "file": "src/resources/customers/index.ts", + "symbol": "RequestLimitIncreaseInput" + } + ], + "notes": null + }, + { + "spec": { + "schema": "CustomerLimitIncreaseOut" + }, + "sdk": [ + { + "file": "src/resources/customers/index.ts", + "symbol": "RequestLimitIncreaseResponse" + }, + { + "file": "src/resources/customers/index.ts", + "symbol": "CreateIndividualWithStandardKYCResponse" + }, + { + "file": "src/resources/customers/index.ts", + "symbol": "CreateIndividualWithEnhancedKYCResponse" + }, + { + "file": "src/resources/customers/index.ts", + "symbol": "CreateBusinessWithStandardKYBResponse" + } + ], + "notes": "1-field {id} response shape shared by several create/request endpoints." + }, + { + "spec": { + "schema": "CustomerOut" + }, + "sdk": [ + { + "file": "src/resources/customers/index.ts", + "symbol": "IndividualWithStandardKYC" + }, + { + "file": "src/resources/customers/index.ts", + "symbol": "IndividualWithEnhancedKYC" + }, + { + "file": "src/resources/customers/index.ts", + "symbol": "BusinessWithStandardKYB" + } + ], + "notes": "Discriminator fan-out: the 3 KYC/KYB response types. Same GetCustomerResponse/ListCustomersResponse union is used for get/list." + }, + { + "spec": { + "schema": "CustomerOut", + "property": "fraud_warnings" + }, + "sdk": [ + { + "file": "src/resources/customers/index.ts", + "symbol": "FraudWarning" + } + ], + "notes": "Nested at fraud_warnings.[]." + }, + { + "spec": { + "schema": "CustomerOut", + "property": "owners" + }, + "sdk": [ + { + "file": "src/resources/customers/index.ts", + "symbol": "Owner" + } + ], + "notes": "Nested at owners.[]." + }, + { + "spec": { + "schema": "Error" + }, + "sdk": [ + { + "file": "types/index.d.ts", + "symbol": "ErrorResponse" + } + ], + "notes": "This SDK's error envelope only ever surfaces `message`; it does not expose the spec's stable `code`, `description`, `errors` list, or `trace_id`." + }, + { + "spec": { + "schema": "FeeOptions" + }, + "sdk": [ + { + "file": "src/resources/fees/index.ts", + "symbol": "FeeOptions" + } + ], + "notes": null + }, + { + "spec": { + "schema": "GetCustomerLimitIncreaseOut" + }, + "sdk": [ + { + "file": "src/resources/customers/index.ts", + "symbol": "GetLimitIncreaseRequestsResponse" + } + ], + "notes": "array item" + }, + { + "spec": { + "schema": "GetCustomerLimitsOut" + }, + "sdk": [ + { + "file": "src/resources/customers/index.ts", + "symbol": "GetCustomerLimitsResponse" + } + ], + "notes": null + }, + { + "spec": { + "schema": "GetFeesOut" + }, + "sdk": [ + { + "file": "src/resources/fees/index.ts", + "symbol": "GetFeesResponse" + } + ], + "notes": null + }, + { + "spec": { + "schema": "InitiateTosIn" + }, + "sdk": [ + { + "file": "src/resources/terms-of-service/index.ts", + "symbol": "InitiateInput" + } + ], + "notes": null + }, + { + "spec": { + "schema": "InitiateTosOut" + }, + "sdk": [ + { + "file": "src/resources/terms-of-service/index.ts", + "symbol": "InitiateResponse" + } + ], + "notes": null + }, + { + "spec": { + "schema": "MigrateInstanceOwnershipIn" + }, + "sdk": [ + { + "file": "src/resources/instances/index.ts", + "symbol": "MigrateInstanceOwnershipInput" + } + ], + "notes": null + }, + { + "spec": { + "schema": "OfframpWallet" + }, + "sdk": [ + { + "file": "src/resources/wallets/offramp.ts", + "symbol": "OfframpWallet" + }, + { + "file": "src/resources/wallets/offramp.ts", + "symbol": "GetOfframpWalletResponse" + } + ], + "notes": null + }, + { + "spec": { + "schema": "PaginationMetadata" + }, + "sdk": [ + { + "file": "types/index.d.ts", + "symbol": "PaginationMetadata" + } + ], + "notes": null + }, + { + "spec": { + "schema": "PayinOut" + }, + "sdk": [ + { + "file": "src/resources/payins/index.ts", + "symbol": "Payin" + } + ], + "notes": null + }, + { + "spec": { + "schema": "PayinOut", + "property": "blindpay_bank_details" + }, + "sdk": [ + { + "file": "src/resources/payins/index.ts", + "symbol": "BlindpayBankDetails" + } + ], + "notes": "Nested inline sub-object." + }, + { + "spec": { + "schema": "PayinOut", + "property": "tracking_complete" + }, + "sdk": [ + { + "file": "types/index.d.ts", + "symbol": "PayinTrackingComplete" + } + ], + "notes": "Nested inline sub-object." + }, + { + "spec": { + "schema": "PayinOut", + "property": "tracking_payment" + }, + "sdk": [ + { + "file": "types/index.d.ts", + "symbol": "PayinTrackingPayment" + } + ], + "notes": "Nested inline sub-object. Also appears (same 6-prop shape) on CreatePayinOut and the 4 ignored payin webhook schemas." + }, + { + "spec": { + "schema": "PayinOut", + "property": "tracking_transaction" + }, + "sdk": [ + { + "file": "types/index.d.ts", + "symbol": "PayinTrackingTransaction" + } + ], + "notes": "Nested inline sub-object." + }, + { + "spec": { + "schema": "PayoutOnEvmIn" + }, + "sdk": [ + { + "file": "src/resources/payouts/index.ts", + "symbol": "CreateEvmPayoutInput" + } + ], + "notes": null + }, + { + "spec": { + "schema": "PayoutOnEvmOut" + }, + "sdk": [ + { + "file": "src/resources/payouts/index.ts", + "symbol": "CreateStellarPayoutResponse" + }, + { + "file": "src/resources/payouts/index.ts", + "symbol": "CreateEvmPayoutResponse" + }, + { + "file": "src/resources/payouts/index.ts", + "symbol": "CreateSolanaPayoutResponse" + } + ], + "notes": "Single spec response schema shared by all 3 create-payout operations (evm/solana/stellar); the SDK independently hand-types one response type per operation instead of reusing one." + }, + { + "spec": { + "schema": "PayoutOnSolanaIn" + }, + "sdk": [ + { + "file": "src/resources/payouts/index.ts", + "symbol": "CreateSolanaPayoutInput" + } + ], + "notes": null + }, + { + "spec": { + "schema": "PayoutOnStellarAuthorizeIn" + }, + "sdk": [ + { + "file": "src/resources/payouts/index.ts", + "symbol": "AuthorizeStellarTokenInput" + } + ], + "notes": null + }, + { + "spec": { + "schema": "PayoutOnStellarIn" + }, + "sdk": [ + { + "file": "src/resources/payouts/index.ts", + "symbol": "CreateStellarPayoutInput" + } + ], + "notes": null + }, + { + "spec": { + "schema": "PayoutOut" + }, + "sdk": [ + { + "file": "src/resources/payouts/index.ts", + "symbol": "Payout" + } + ], + "notes": null + }, + { + "spec": { + "schema": "PayoutOut", + "property": "tracking_complete" + }, + "sdk": [ + { + "file": "types/index.d.ts", + "symbol": "PayoutTrackingComplete" + } + ], + "notes": "Nested inline sub-object. Also on PayoutOnEvmOut." + }, + { + "spec": { + "schema": "PayoutOut", + "property": "tracking_liquidity" + }, + "sdk": [ + { + "file": "types/index.d.ts", + "symbol": "PayoutTrackingLiquidity" + } + ], + "notes": "Nested inline sub-object. Also on PayoutOnEvmOut." + }, + { + "spec": { + "schema": "PayoutOut", + "property": "tracking_payment" + }, + "sdk": [ + { + "file": "types/index.d.ts", + "symbol": "PayoutTrackingPayment" + } + ], + "notes": "Nested inline sub-object, 20 spec properties. Also on PayoutOnEvmOut and the 4 ignored payout webhook schemas (all reuse the identical shared PayoutTrackingPayment SDK symbol directly, not copies)." + }, + { + "spec": { + "schema": "PayoutOut", + "property": "tracking_transaction" + }, + "sdk": [ + { + "file": "types/index.d.ts", + "symbol": "PayoutTrackingTransaction" + } + ], + "notes": "Nested inline sub-object. Also on PayoutOnEvmOut." + }, + { + "spec": { + "schema": "PortalAccessOut" + }, + "sdk": [ + { + "file": "src/resources/webhooks/index.ts", + "symbol": "GetPortalAccessUrlResponse" + }, + { + "file": "src/resources/terms-of-service/index.ts", + "symbol": "InitiateResponse" + } + ], + "notes": "1-field {url} response shape reused across endpoints" + }, + { + "spec": { + "schema": "QuoteIn" + }, + "sdk": [ + { + "file": "src/resources/quotes/index.ts", + "symbol": "CreateQuoteInput" + } + ], + "notes": "POST /v1/instances/{instance_id}/quotes request body." + }, + { + "spec": { + "schema": "QuoteOut" + }, + "sdk": [ + { + "file": "src/resources/quotes/index.ts", + "symbol": "CreateQuoteResponse" + } + ], + "notes": null + }, + { + "spec": { + "schema": "SubmitPayoutDocumentsIn" + }, + "sdk": [ + { + "file": "src/resources/payouts/index.ts", + "symbol": "SubmitPayoutDocumentsInput" + } + ], + "notes": "Path param `id` modeled as payout_id on the input type; already allow-listed in contract-check-allowlist.json." + }, + { + "spec": { + "schema": "Success" + }, + "sdk": [ + { + "file": "src/resources/instances/index.ts", + "symbol": "MigrateInstanceOwnershipResponse" + } + ], + "notes": null + }, + { + "spec": { + "schema": "SwiftCodeItem" + }, + "sdk": [ + { + "file": "src/resources/available/index.ts", + "symbol": "GetSwiftCodeBankDetailsResponse" + } + ], + "notes": "array item" + }, + { + "spec": { + "schema": "TransferOut" + }, + "sdk": [ + { + "file": "src/resources/transfers/index.ts", + "symbol": "Transfer" + } + ], + "notes": null + }, + { + "spec": { + "schema": "TransferOut", + "property": "tracking_bridge_swap" + }, + "sdk": [ + { + "file": "src/resources/transfers/index.ts", + "symbol": "TransferTrackingStep" + } + ], + "notes": "Nested inline sub-object; identical shape also used for tracking_paymaster and tracking_complete." + }, + { + "spec": { + "schema": "TransferOut", + "property": "tracking_transaction_monitoring" + }, + "sdk": [ + { + "file": "src/resources/transfers/index.ts", + "symbol": "TransferTrackingTransactionMonitoring" + } + ], + "notes": "Nested inline sub-object." + }, + { + "spec": { + "schema": "UpdateCustomerIn" + }, + "sdk": [ + { + "file": "src/resources/customers/index.ts", + "symbol": "UpdateCustomerInput" + } + ], + "notes": null + }, + { + "spec": { + "schema": "UpdateInstanceIn" + }, + "sdk": [ + { + "file": "src/resources/instances/index.ts", + "symbol": "UpdateInstanceInput" + } + ], + "notes": null + }, + { + "spec": { + "schema": "UpdateInstanceMemberIn" + }, + "sdk": [ + { + "file": "src/resources/instances/index.ts", + "symbol": "UpdateInstanceMemberRoleInput" + } + ], + "notes": "instances/index.ts:60-65 renames the `role` param to body key `user_role` on the wire; already allow-listed in contract-check-allowlist.json for the memberId path param." + }, + { + "spec": { + "schema": "UpdateVirtualAccountIn" + }, + "sdk": [ + { + "file": "src/resources/virtual-accounts/index.ts", + "symbol": "UpdateVirtualAccountInput" + } + ], + "notes": null + }, + { + "spec": { + "schema": "UploadIn" + }, + "sdk": [ + { + "file": "src/resources/upload/index.ts", + "symbol": "UploadInput" + } + ], + "notes": "multipart/form-data request; instance_id is a query param, not part of UploadIn." + }, + { + "spec": { + "schema": "UploadOut" + }, + "sdk": [ + { + "file": "src/resources/upload/index.ts", + "symbol": "UploadResponse" + } + ], + "notes": null + }, + { + "spec": { + "schema": "VirtualAccountOut" + }, + "sdk": [ + { + "file": "src/resources/virtual-accounts/index.ts", + "symbol": "VirtualAccount" + }, + { + "file": "src/resources/virtual-accounts/index.ts", + "symbol": "CreateVirtualAccountResponse" + }, + { + "file": "src/resources/virtual-accounts/index.ts", + "symbol": "GetVirtualAccountResponse" + } + ], + "notes": null + }, + { + "spec": { + "schema": "WalletBalanceOut" + }, + "sdk": [ + { + "file": "src/resources/wallets/custodial.ts", + "symbol": "GetCustodialWalletBalanceResponse" + } + ], + "notes": null + }, + { + "spec": { + "schema": "WalletOut" + }, + "sdk": [ + { + "file": "src/resources/wallets/custodial.ts", + "symbol": "CustodialWallet" + } + ], + "notes": null + }, + { + "spec": { + "schema": "WalletTokenOut" + }, + "sdk": [ + { + "file": "src/resources/wallets/custodial.ts", + "symbol": "WalletTokenBalance" + } + ], + "notes": null + }, + { + "spec": { + "schema": "WebhookEndpoint" + }, + "sdk": [ + { + "file": "src/resources/webhooks/index.ts", + "symbol": "ListWebhookEndpointsResponse" + } + ], + "notes": "array item" + }, + { + "spec": { + "schema": "WebhookEndpointIn" + }, + "sdk": [ + { + "file": "src/resources/webhooks/index.ts", + "symbol": "CreateWebhookEndpointInput" + } + ], + "notes": null + }, + { + "spec": { + "schema": "WebhookEndpointOut" + }, + "sdk": [ + { + "file": "src/resources/webhooks/index.ts", + "symbol": "CreateWebhookEndpointResponse" + } + ], + "notes": null + } + ], + "ignore": { + "schemas": [ + { + "name": "AdditionalInfoItem", + "reason": "Nested item schema of CustomerOut.additional_info, which is not modeled by any SDK type at all (see the CustomerOut fan-out entry's unmodeled properties)." + }, + { + "name": "BankAccountWebhookOut", + "reason": "Webhook payload body for bankAccount.new. This SDK does not model webhook payload shapes as typed symbols anywhere; verifyWebhookSignature only verifies the signature and returns a boolean, consumers parse the JSON body themselves." + }, + { + "name": "BlockchainWalletWebhookOut", + "reason": "Webhook payload body for blockchainWallet.new; same reasoning as BankAccountWebhookOut." + }, + { + "name": "CreateInstanceRfiBody", + "reason": "Orphan schema, same situation as CreateRfiBody." + }, + { + "name": "CreateRfiBody", + "reason": "Orphan schema (zero $ref anywhere; the actual POST /rfi request body is inline `{type: object, additionalProperties: {}}`, not $ref'd to this schema). Grouped with the Rfi family since it is a request-body remnant for the unmodeled RFI resource." + }, + { + "name": "CustomerDeleteWebhookOut", + "reason": "Webhook payload body for customer.delete; same reasoning as BankAccountWebhookOut." + }, + { + "name": "CustomerNewWebhookOut", + "reason": "Webhook payload body for customer.new; same reasoning as BankAccountWebhookOut." + }, + { + "name": "CustomerUpdateWebhookOut", + "reason": "Webhook payload body for customer.update; same reasoning as BankAccountWebhookOut." + }, + { + "name": "InstanceRfi", + "reason": "Instance-level RFI variant; not modeled, same as Rfi." + }, + { + "name": "LedgerOperation", + "reason": "unreferenced by any public operation" + }, + { + "name": "LimitIncreaseNewWebhookOut", + "reason": "Webhook payload body for limitIncrease.new; same reasoning as BankAccountWebhookOut." + }, + { + "name": "LimitIncreaseUpdateWebhookOut", + "reason": "Webhook payload body for limitIncrease.update; same reasoning as BankAccountWebhookOut (still used above as the reachable anchor for the LimitIncreaseRequestStatus enum, which is otherwise identical to GetCustomerLimitIncreaseOut.status)." + }, + { + "name": "PayinCompleteWebhookOut", + "reason": "Webhook payload body for payin.complete; same reasoning as BankAccountWebhookOut." + }, + { + "name": "PayinNewWebhookOut", + "reason": "Webhook payload body for payin.new; same reasoning as BankAccountWebhookOut." + }, + { + "name": "PayinPartnerFeeWebhookOut", + "reason": "Webhook payload body for payin.partnerFee; same reasoning as BankAccountWebhookOut." + }, + { + "name": "PayinUpdateWebhookOut", + "reason": "Webhook payload body for payin.update; same reasoning as BankAccountWebhookOut." + }, + { + "name": "PayoutCompleteWebhookOut", + "reason": "Webhook payload body for payout.complete; same reasoning as BankAccountWebhookOut." + }, + { + "name": "PayoutNewWebhookOut", + "reason": "Webhook payload body for payout.new; same reasoning as BankAccountWebhookOut." + }, + { + "name": "PayoutPartnerFeeWebhookOut", + "reason": "Webhook payload body for payout.partnerFee; same reasoning as BankAccountWebhookOut." + }, + { + "name": "PayoutUpdateWebhookOut", + "reason": "Webhook payload body for payout.update; same reasoning as BankAccountWebhookOut (still used above as the reachable anchor for the TransactionStatus enum)." + }, + { + "name": "Rfi", + "reason": "RFI resource (GET/POST /v1/instances/{instance_id}/rfi and .../customers/{customer_id}/rfi) is not modeled by this SDK. Known coverage gap, tracked for Phase C." + }, + { + "name": "RfiField", + "reason": "Nested under Rfi.request/response; not modeled, same as Rfi." + }, + { + "name": "RfiSection", + "reason": "Nested under Rfi.request/response; not modeled, same as Rfi." + }, + { + "name": "SwiftCodeResponse", + "reason": "Array-wrapper alias for SwiftCodeItem[]; the item shape itself is mapped via GetSwiftCodeBankDetailsResponse -> SwiftCodeItem." + }, + { + "name": "TosAcceptWebhookOut", + "reason": "Webhook payload body for tos.accept; same reasoning as BankAccountWebhookOut." + }, + { + "name": "UploadAnalyzeIn", + "reason": "POST /v1/upload/analyze is not modeled by this SDK. Known coverage gap, tracked for Phase C." + }, + { + "name": "UploadAnalyzeOut", + "reason": "Response of POST /v1/upload/analyze; not modeled, same as UploadAnalyzeIn." + }, + { + "name": "WalletInboundSchemaOut", + "reason": "Webhook payload body for wallet.inbound; same reasoning as BankAccountWebhookOut." + } + ] + } +} diff --git a/.api-sync/spec-snapshot.json b/.api-sync/spec-snapshot.json index 2557f54..98de756 100644 --- a/.api-sync/spec-snapshot.json +++ b/.api-sync/spec-snapshot.json @@ -70,7 +70,7 @@ }, { "name": "Errors", - "description": "## Error responses\n\nEvery error response uses this envelope:\n\n```json\n{\n \"success\": false,\n \"code\": \"QUOTES_EXPIRED\",\n \"message\": \"quote_expired\",\n \"description\": \"This quote has expired. Request a new quote and try again.\",\n \"errors\": [],\n \"trace_id\": \"0af7651916cd43dd8448eb211c80319c\"\n}\n```\n\n- `code` is a stable identifier, safe to build programmatic handling on.\n- `message` is a legacy machine string kept for backward compatibility. Prefer `code`.\n- `description` is human-readable text, safe to show to end users.\n- `trace_id` identifies the request. Include it when contacting support.\n\nRetry semantics per code: `retryable` means the same request can be retried as is, `terminal` means do not retry, and `actionable` means the request succeeds only after you fix something (for example funding a balance or correcting bank details).\n\nThe Status column shows the HTTP status a code typically responds with; the per-endpoint error tabs list the exact status each endpoint returns.\n\n### Error codes\n\n| Code | Status | Retry | Description |\n| --- | --- | --- | --- |\n| `AUTH_EMAIL_ALREADY_REGISTERED` | 409 | terminal | This email address is already registered. |\n| `AUTH_FORBIDDEN` | 403 | terminal | You do not have permission to perform this action. |\n| `AUTH_IP_BLOCKED` | 403 | terminal | Requests from this IP address are not allowed for this API key. |\n| `AUTH_RATE_LIMITED` | 429 | retryable | Too many requests. Wait a moment and try again. |\n| `AUTH_SERVICE_UNAVAILABLE` | 503 | retryable | The authentication service is temporarily unavailable. Try again shortly. |\n| `AUTH_TOKEN_INVALID` | 401 | actionable | This link or token is invalid or has expired. Request a new one. |\n| `AUTH_UNAUTHORIZED` | 401 | actionable | Authentication failed. Check your API key or session and try again. |\n| `BANK_ACCOUNTS_INCOMPLETE` | 400 | actionable | The bank account details are incomplete for this payment method. Provide the missing fields. |\n| `BANK_ACCOUNTS_INVALID_BANK_CODE` | 400 | actionable | The bank code provided is not valid for this payment rail. The message lists where to fetch the valid codes. |\n| `BANK_ACCOUNTS_INVALID_ROUTING` | 400 | actionable | The routing number provided is not valid. Check the number and try again. |\n| `BANK_ACCOUNTS_NOT_APPROVED` | 403 | actionable | This bank account is not approved yet. |\n| `BANK_ACCOUNTS_NOT_FOUND` | 404 | actionable | The requested bank account was not found. |\n| `BANK_ACCOUNTS_PARTNER_NOT_SUPPORTED` | 400 | terminal | This banking option is not supported for this operation. |\n| `BANK_ACCOUNTS_RTP_NOT_SUPPORTED` | 400 | terminal | The provided routing number does not support instant payments. |\n| `BILLING_INVOICE_NOT_FOUND` | 404 | actionable | The requested invoice was not found. |\n| `BILLING_INVOICE_NOT_PAYABLE` | 409 | terminal | This invoice cannot be paid or collected in its current state. |\n| `BILLING_NOT_AVAILABLE` | 403 | terminal | Billing is not available for development instances. |\n| `BLOCKCHAIN_CALL_FAILED` | 400 | retryable | The blockchain call could not be completed. Check the transaction details and try again. |\n| `BLOCKCHAIN_NETWORK_NOT_SUPPORTED` | 400 | terminal | This network or token combination is not supported for this operation. |\n| `BLOCKCHAIN_TOKEN_NOT_SUPPORTED` | 400 | actionable | This token is not supported for this instance type. Development instances settle in USDB; production instances settle in USDC or USDT. |\n| `BLOCKCHAIN_TX_FAILED` | 502 | retryable | The blockchain transaction failed. Try again, and contact support if the problem persists. |\n| `COMPLIANCE_RFI_IN_PROGRESS` | 409 | actionable | An information request is already in progress. Resolve it before continuing. |\n| `COMPLIANCE_RFI_INVALID` | 400 | actionable | The information request could not be processed. Check the submitted fields. |\n| `CUSTOMERS_ALREADY_APPROVED` | 409 | terminal | This customer is already approved. |\n| `CUSTOMERS_BUSINESS_ENHANCED_NOT_AVAILABLE` | 400 | terminal | Enhanced verification is not available for business customers. Use standard verification (kyc_type: standard) for businesses. |\n| `CUSTOMERS_COUNTRY_NOT_SUPPORTED` | 400 | terminal | This country is not supported for this operation. |\n| `CUSTOMERS_ENHANCED_KYC_REQUIRED` | 400 | actionable | Individuals from high-risk countries must use enhanced verification (kyc_type: enhanced); standard verification is not available for them. See https://www.blindpay.com/docs/kb/supported-countries for country tiers. |\n| `CUSTOMERS_ID_DOCUMENT_INVALID` | 400 | actionable | The identity document is not an accepted passport or national ID. Provide a valid passport or national ID for this customer (a driver license is not accepted for high-risk countries). |\n| `CUSTOMERS_INVALID_DATA` | 422 | actionable | The customer data was rejected by the banking provider. Check the highlighted fields and try again. |\n| `CUSTOMERS_INVALID_PHONE` | 400 | actionable | The phone number provided is not valid. Check the number and try again. |\n| `CUSTOMERS_INVALID_TAX_ID` | 400 | actionable | The tax ID provided is not valid. Check the value and try again. |\n| `CUSTOMERS_KYC_NOT_APPROVED` | 403 | actionable | This customer has not completed verification yet. Complete the verification before continuing. |\n| `CUSTOMERS_NOT_FOUND` | 404 | actionable | The requested customer was not found. |\n| `CUSTOMERS_ONBOARDING_INCOMPLETE` | 400 | actionable | Onboarding is not complete yet. Finish the remaining steps before continuing. |\n| `DATA_INTEGRITY_ERROR` | 422 | terminal | This record could not be loaded because it contains invalid stored data. Contact support with the ID below. |\n| `FEES_NOT_FOUND` | 404 | actionable | The requested fee configuration was not found. |\n| `FEES_PARTNER_FEE_EXCEEDED` | 400 | actionable | The partner fee exceeds the allowed maximum for this transaction. |\n| `FILES_ANALYSIS_FAILED` | 502 | retryable | The document could not be analyzed. Try again, and contact support if the problem persists. |\n| `FILES_EMPTY` | 400 | actionable | The uploaded file is empty. Upload a valid file. |\n| `FILES_TOO_LARGE` | 400 | actionable | The file is too large. Upload a smaller file. |\n| `FILES_TYPE_NOT_ALLOWED` | 400 | actionable | This file type is not allowed. Upload a supported file type. |\n| `FILES_UNREADABLE` | 400 | actionable | We could not read the uploaded file. Check the file and try again. |\n| `INSTANCES_BLOCKED` | 403 | terminal | This account is blocked. Contact support for assistance. |\n| `INSTANCES_LIMIT_REACHED` | 400 | terminal | You reached the maximum number of instances allowed for your account. |\n| `INSTANCES_NOT_FOUND` | 404 | actionable | The requested instance was not found. |\n| `INTERNAL_ERROR` | 500 | retryable | Something went wrong on our side. Try again, and contact support with the trace ID if the problem persists. |\n| `LIMITS_AMOUNT_OUT_OF_RANGE` | 400 | actionable | The amount is outside the allowed range. The message includes the allowed range. |\n| `LIMITS_VOLUME_EXCEEDED` | 400 | actionable | You exceeded your transaction volume limit. The message includes the remaining amount available. |\n| `PAYINS_ALREADY_TERMINAL` | 409 | terminal | This payin already reached a final status and cannot be changed. |\n| `PAYINS_METHOD_NOT_SUPPORTED` | 400 | terminal | This payment method is not supported for this operation. |\n| `PAYINS_NOT_FOUND` | 404 | actionable | The requested payin was not found. |\n| `PAYINS_TAX_ID_REQUIRED` | 400 | actionable | A tax ID is required for this payment method. Provide the tax ID and try again. |\n| `PAYOUTS_ALLOWANCE_NOT_CONFIRMED` | 400 | retryable | The token approval for this payout has not been confirmed on-chain yet, or the approved amount is lower than the payout amount. Wait for the approval transaction to confirm and execute the quote again. |\n| `PAYOUTS_AMOUNT_BELOW_MINIMUM` | 400 | actionable | The amount is below the minimum for this payment rail. |\n| `PAYOUTS_DOCUMENTS_NOT_ACCEPTED` | 400 | terminal | Document upload is only available for international wire payouts. |\n| `PAYOUTS_INSUFFICIENT_BALANCE` | 400 | actionable | There is not enough balance to complete this payout. Fund your balance and try again. |\n| `PAYOUTS_NOT_FOUND` | 404 | actionable | The requested payout was not found. |\n| `QUOTES_ALREADY_USED` | 409 | actionable | This quote was already used. Request a new quote. |\n| `QUOTES_EXPIRED` | 400 | actionable | This quote has expired. Request a new quote and try again. |\n| `QUOTES_INSUFFICIENT_LIQUIDITY` | 400 | actionable | There is not enough market liquidity to fill this amount right now. Try a smaller amount or try again later. |\n| `QUOTES_INVALID_STATE` | 400 | actionable | This quote cannot be authorized in its current state. Request a new quote and try again. |\n| `QUOTES_NOT_FOUND` | 404 | actionable | The requested quote was not found. |\n| `QUOTES_OTC_NOT_SUPPORTED` | 400 | actionable | This OTC configuration is not supported. OTC requires BRL with USDT, and either the sender amount with fees not covered, or the receiver amount with fees covered. |\n| `QUOTES_RATE_UNAVAILABLE` | 502 | retryable | An exchange rate is temporarily unavailable. Try again shortly. |\n| `TOS_ALREADY_ACCEPTED` | 409 | terminal | These terms of service were already accepted. |\n| `TOS_NOT_ACCEPTED` | 400 | actionable | The terms of service must be accepted before continuing. |\n| `TOS_NOT_FOUND` | 404 | actionable | The requested terms of service record was not found. |\n| `TRANSFERS_NOT_ENABLED` | 403 | terminal | Wallets and transfers are not enabled for this account. Contact support to enable them. |\n| `VALIDATION_FAILED` | 400 | actionable | The request contains invalid fields. Check the errors list for details. |\n| `VALIDATION_INVALID_REQUEST` | 400 | actionable | The request is not valid for this operation. Check the parameters and try again. |\n| `VALIDATION_MISSING_BODY` | 400 | actionable | The request body is missing or malformed. Send valid JSON with the Content-Type: application/json header. |\n| `VALIDATION_MISSING_REQUIRED_FIELDS` | 400 | actionable | Required fields are missing. Provide the missing fields and try again. |\n| `VIRTUAL_ACCOUNTS_DISABLED` | 403 | terminal | Virtual accounts are not enabled for this account. Contact support to enable them. |\n| `VIRTUAL_ACCOUNTS_DOCUMENTS_REQUIRED` | 400 | actionable | Additional documents are required to create this virtual account. Upload the requested documents. |\n| `VIRTUAL_ACCOUNTS_NOT_APPROVED` | 403 | actionable | This virtual account is not approved yet. |\n| `VIRTUAL_ACCOUNTS_NOT_FOUND` | 404 | actionable | The requested virtual account was not found. |\n| `VIRTUAL_ACCOUNTS_PROVISION_FAILED` | 502 | retryable | We could not create the virtual account. Try again, and contact support if the problem persists. |\n| `VIRTUAL_ACCOUNTS_REGION_NOT_SUPPORTED` | 400 | terminal | Virtual accounts are not available for this country or region. |\n| `WALLETS_ATTACHED_TO_VIRTUAL_ACCOUNT` | 400 | terminal | This wallet is attached to a virtual account and cannot be deleted. Delete the virtual account first. |\n| `WALLETS_BALANCE_UNAVAILABLE` | 503 | retryable | The wallet balance is temporarily unavailable. Try again shortly. |\n| `WALLETS_NOT_FOUND` | 404 | actionable | The requested wallet was not found. |\n| `WEBHOOKS_LIMIT_REACHED` | 400 | terminal | You reached the maximum number of webhook endpoints. |\n| `WEBHOOKS_SIGNATURE_INVALID` | 401 | terminal | The webhook signature is missing or invalid. |\n| `WEBHOOKS_URL_INVALID` | 400 | actionable | The webhook URL is not valid. Use a reachable HTTPS URL. |\n" + "description": "## Error responses\n\nEvery error response uses this envelope:\n\n```json\n{\n \"success\": false,\n \"code\": \"QUOTES_EXPIRED\",\n \"message\": \"quote_expired\",\n \"description\": \"This quote has expired. Request a new quote and try again.\",\n \"errors\": [],\n \"trace_id\": \"0af7651916cd43dd8448eb211c80319c\"\n}\n```\n\n- `code` is a stable identifier, safe to build programmatic handling on.\n- `message` is a legacy machine string kept for backward compatibility. Prefer `code`.\n- `description` is human-readable text, safe to show to end users.\n- `trace_id` identifies the request. Include it when contacting support.\n\nRetry semantics per code: `retryable` means the same request can be retried as is, `terminal` means do not retry, and `actionable` means the request succeeds only after you fix something (for example funding a balance or correcting bank details).\n\nThe Status column shows the HTTP status a code typically responds with; the per-endpoint error tabs list the exact status each endpoint returns.\n\n### Error codes\n\n| Code | Status | Retry | Description |\n| --- | --- | --- | --- |\n| `AUTH_EMAIL_ALREADY_REGISTERED` | 409 | terminal | This email address is already registered. |\n| `AUTH_FORBIDDEN` | 403 | terminal | You do not have permission to perform this action. |\n| `AUTH_IP_BLOCKED` | 403 | terminal | Requests from this IP address are not allowed for this API key. |\n| `AUTH_RATE_LIMITED` | 429 | retryable | Too many requests. Wait a moment and try again. |\n| `AUTH_SERVICE_UNAVAILABLE` | 503 | retryable | The authentication service is temporarily unavailable. Try again shortly. |\n| `AUTH_TOKEN_INVALID` | 401 | actionable | This link or token is invalid or has expired. Request a new one. |\n| `AUTH_UNAUTHORIZED` | 401 | actionable | Authentication failed. Check your API key or session and try again. |\n| `BANK_ACCOUNTS_INCOMPLETE` | 400 | actionable | The bank account details are incomplete for this payment method. Provide the missing fields. |\n| `BANK_ACCOUNTS_INVALID_BANK_CODE` | 400 | actionable | The bank code provided is not valid for this payment rail. The message lists where to fetch the valid codes. |\n| `BANK_ACCOUNTS_INVALID_ROUTING` | 400 | actionable | The routing number provided is not valid. Check the number and try again. |\n| `BANK_ACCOUNTS_NOT_APPROVED` | 403 | actionable | This bank account is not approved yet. |\n| `BANK_ACCOUNTS_NOT_FOUND` | 404 | actionable | The requested bank account was not found. |\n| `BANK_ACCOUNTS_PARTNER_NOT_SUPPORTED` | 400 | terminal | This banking option is not supported for this operation. |\n| `BANK_ACCOUNTS_RTP_NOT_SUPPORTED` | 400 | terminal | The provided routing number does not support instant payments. |\n| `BILLING_INVOICE_NOT_FOUND` | 404 | actionable | The requested invoice was not found. |\n| `BILLING_INVOICE_NOT_PAYABLE` | 409 | terminal | This invoice cannot be paid or collected in its current state. |\n| `BILLING_NOT_AVAILABLE` | 403 | terminal | Billing is not available for development instances. |\n| `BLOCKCHAIN_CALL_FAILED` | 400 | retryable | The blockchain call could not be completed. Check the transaction details and try again. |\n| `BLOCKCHAIN_NETWORK_NOT_SUPPORTED` | 400 | terminal | This network or token combination is not supported for this operation. |\n| `BLOCKCHAIN_TOKEN_NOT_SUPPORTED` | 400 | actionable | This token is not supported for this instance type. Development instances settle in USDB; production instances settle in USDC or USDT. |\n| `BLOCKCHAIN_TX_FAILED` | 502 | retryable | The blockchain transaction failed. Try again, and contact support if the problem persists. |\n| `COMPLIANCE_RFI_IN_PROGRESS` | 409 | actionable | An information request is already in progress. Resolve it before continuing. |\n| `COMPLIANCE_RFI_INVALID` | 400 | actionable | The information request could not be processed. Check the submitted fields. |\n| `CUSTOMERS_ALREADY_APPROVED` | 409 | terminal | This customer is already approved. |\n| `CUSTOMERS_BUSINESS_ENHANCED_NOT_AVAILABLE` | 400 | terminal | Enhanced verification is not available for business customers. Use standard verification (kyc_type: standard) for businesses. |\n| `CUSTOMERS_COUNTRY_NOT_SUPPORTED` | 400 | terminal | This country is not supported for this operation. |\n| `CUSTOMERS_ENHANCED_KYC_REQUIRED` | 400 | actionable | Individuals from high-risk countries must use enhanced verification (kyc_type: enhanced); standard verification is not available for them. See https://www.blindpay.com/docs/kb/supported-countries for country tiers. |\n| `CUSTOMERS_ID_DOCUMENT_INVALID` | 400 | actionable | The identity document is not an accepted passport or national ID. Provide a valid passport or national ID for this customer (a driver license is not accepted for high-risk countries). |\n| `CUSTOMERS_INVALID_DATA` | 422 | actionable | The customer data was rejected by the banking provider. Check the highlighted fields and try again. |\n| `CUSTOMERS_INVALID_PHONE` | 400 | actionable | The phone number provided is not valid. Check the number and try again. |\n| `CUSTOMERS_INVALID_TAX_ID` | 400 | actionable | The tax ID provided is not valid. Check the value and try again. |\n| `CUSTOMERS_KYC_NOT_APPROVED` | 403 | actionable | This customer has not completed verification yet. Complete the verification before continuing. |\n| `CUSTOMERS_NOT_FOUND` | 404 | actionable | The requested customer was not found. |\n| `CUSTOMERS_ONBOARDING_INCOMPLETE` | 400 | actionable | Onboarding is not complete yet. Finish the remaining steps before continuing. |\n| `DATA_INTEGRITY_ERROR` | 422 | terminal | This record could not be loaded because it contains invalid stored data. Contact support with the ID below. |\n| `FEES_NOT_FOUND` | 404 | actionable | The requested fee configuration was not found. |\n| `FEES_PARTNER_FEE_EXCEEDED` | 400 | actionable | The partner fee exceeds the allowed maximum for this transaction. |\n| `FILES_ANALYSIS_FAILED` | 502 | retryable | The document could not be analyzed. Try again, and contact support if the problem persists. |\n| `FILES_EMPTY` | 400 | actionable | The uploaded file is empty. Upload a valid file. |\n| `FILES_TOO_LARGE` | 400 | actionable | The file is too large. Upload a smaller file. |\n| `FILES_TYPE_NOT_ALLOWED` | 400 | actionable | This file type is not allowed. Upload a supported file type. |\n| `FILES_UNREADABLE` | 400 | actionable | We could not read the uploaded file. Check the file and try again. |\n| `INSTANCES_BLOCKED` | 403 | terminal | This account is blocked. Contact support for assistance. |\n| `INSTANCES_LIMIT_REACHED` | 400 | terminal | You reached the maximum number of instances allowed for your account. |\n| `INSTANCES_NOT_FOUND` | 404 | actionable | The requested instance was not found. |\n| `INTERNAL_ERROR` | 500 | retryable | Something went wrong on our side. Try again, and contact support with the trace ID if the problem persists. |\n| `LIMITS_AMOUNT_OUT_OF_RANGE` | 400 | actionable | The amount is outside the allowed range. The message includes the allowed range. |\n| `LIMITS_VOLUME_EXCEEDED` | 400 | actionable | You exceeded your transaction volume limit. The message includes the remaining amount available. |\n| `PAYINS_ALREADY_TERMINAL` | 409 | terminal | This payin already reached a final status and cannot be changed. |\n| `PAYINS_METHOD_NOT_SUPPORTED` | 400 | terminal | This payment method is not supported for this operation. |\n| `PAYINS_NOT_FOUND` | 404 | actionable | The requested payin was not found. |\n| `PAYINS_TAX_ID_REQUIRED` | 400 | actionable | A tax ID is required for this payment method. Provide the tax ID and try again. |\n| `PAYOUTS_ALLOWANCE_NOT_CONFIRMED` | 400 | retryable | The token approval for this payout has not been confirmed on-chain yet, or the approved amount is lower than the payout amount. Wait for the approval transaction to confirm and execute the quote again. |\n| `PAYOUTS_AMOUNT_BELOW_MINIMUM` | 400 | actionable | The amount is below the minimum for this payment rail. |\n| `PAYOUTS_DOCUMENTS_NOT_ACCEPTED` | 400 | terminal | Document upload is only available for international wire payouts. |\n| `PAYOUTS_INSUFFICIENT_BALANCE` | 400 | actionable | There is not enough balance to complete this payout. Fund your balance and try again. |\n| `PAYOUTS_NOT_FOUND` | 404 | actionable | The requested payout was not found. |\n| `QUOTES_ALREADY_USED` | 409 | actionable | This quote was already used. Request a new quote. |\n| `QUOTES_EXPIRED` | 400 | actionable | This quote has expired. Request a new quote and try again. |\n| `QUOTES_INSUFFICIENT_LIQUIDITY` | 400 | actionable | There is not enough market liquidity to fill this amount right now. Try a smaller amount or try again later. |\n| `QUOTES_INVALID_STATE` | 400 | actionable | This quote cannot be authorized in its current state. Request a new quote and try again. |\n| `QUOTES_NOT_FOUND` | 404 | actionable | The requested quote was not found. |\n| `QUOTES_OTC_NOT_SUPPORTED` | 400 | actionable | This OTC configuration is not supported. OTC requires BRL with USDT, and either the sender amount with fees not covered, or the receiver amount with fees covered. |\n| `QUOTES_RATE_UNAVAILABLE` | 502 | retryable | An exchange rate is temporarily unavailable. Try again shortly. |\n| `REQUEST_REJECTED` | 400 | actionable | The request was rejected. Check the error message for details. |\n| `TOS_ALREADY_ACCEPTED` | 409 | terminal | These terms of service were already accepted. |\n| `TOS_NOT_ACCEPTED` | 400 | actionable | The terms of service must be accepted before continuing. |\n| `TOS_NOT_FOUND` | 404 | actionable | The requested terms of service record was not found. |\n| `TRANSFERS_NOT_ENABLED` | 403 | terminal | Wallets and transfers are not enabled for this account. Contact support to enable them. |\n| `VALIDATION_FAILED` | 400 | actionable | The request contains invalid fields. Check the errors list for details. |\n| `VALIDATION_INVALID_REQUEST` | 400 | actionable | The request is not valid for this operation. Check the parameters and try again. |\n| `VALIDATION_MISSING_BODY` | 400 | actionable | The request body is missing or malformed. Send valid JSON with the Content-Type: application/json header. |\n| `VALIDATION_MISSING_REQUIRED_FIELDS` | 400 | actionable | Required fields are missing. Provide the missing fields and try again. |\n| `VIRTUAL_ACCOUNTS_DISABLED` | 403 | terminal | Virtual accounts are not enabled for this account. Contact support to enable them. |\n| `VIRTUAL_ACCOUNTS_DOCUMENTS_REQUIRED` | 400 | actionable | Additional documents are required to create this virtual account. Upload the requested documents. |\n| `VIRTUAL_ACCOUNTS_NOT_APPROVED` | 403 | actionable | This virtual account is not approved yet. |\n| `VIRTUAL_ACCOUNTS_NOT_FOUND` | 404 | actionable | The requested virtual account was not found. |\n| `VIRTUAL_ACCOUNTS_PROVISION_FAILED` | 502 | retryable | We could not create the virtual account. Try again, and contact support if the problem persists. |\n| `VIRTUAL_ACCOUNTS_REGION_NOT_SUPPORTED` | 400 | terminal | Virtual accounts are not available for this country or region. |\n| `WALLETS_ATTACHED_TO_VIRTUAL_ACCOUNT` | 400 | terminal | This wallet is attached to a virtual account and cannot be deleted. Delete the virtual account first. |\n| `WALLETS_BALANCE_UNAVAILABLE` | 503 | retryable | The wallet balance is temporarily unavailable. Try again shortly. |\n| `WALLETS_NOT_FOUND` | 404 | actionable | The requested wallet was not found. |\n| `WEBHOOKS_LIMIT_REACHED` | 400 | terminal | You reached the maximum number of webhook endpoints. |\n| `WEBHOOKS_SIGNATURE_INVALID` | 401 | terminal | The webhook signature is missing or invalid. |\n| `WEBHOOKS_URL_INVALID` | 400 | actionable | The webhook URL is not valid. Use a reachable HTTPS URL. |\n" } ], "security": [ @@ -3250,6 +3250,11 @@ "example": "bh_swift_afl" }, "created_at": { + "type": [ + "string", + "null" + ], + "format": "date-time", "example": "2021-01-01T00:00:00Z" } }, @@ -4747,9 +4752,19 @@ "example": "re_000000000000" }, "created_at": { + "type": [ + "string", + "null" + ], + "format": "date-time", "example": "2021-01-01T00:00:00Z" }, "updated_at": { + "type": [ + "string", + "null" + ], + "format": "date-time", "example": "2021-01-01T00:00:00Z" } }, @@ -4871,9 +4886,19 @@ "example": "re_000000000000" }, "created_at": { + "type": [ + "string", + "null" + ], + "format": "date-time", "example": "2021-01-01T00:00:00Z" }, "updated_at": { + "type": [ + "string", + "null" + ], + "format": "date-time", "example": "2021-01-01T00:00:00Z" } }, @@ -10526,9 +10551,19 @@ } }, "created_at": { + "type": [ + "string", + "null" + ], + "format": "date-time", "example": "2021-01-01T00:00:00Z" }, "updated_at": { + "type": [ + "string", + "null" + ], + "format": "date-time", "example": "2021-01-01T00:00:00Z" }, "limit": { @@ -12567,9 +12602,19 @@ } }, "created_at": { + "type": [ + "string", + "null" + ], + "format": "date-time", "example": "2021-01-01T00:00:00Z" }, "updated_at": { + "type": [ + "string", + "null" + ], + "format": "date-time", "example": "2021-01-01T00:00:00Z" }, "limit": { @@ -14238,9 +14283,19 @@ "example": "f06dbc45-58a4-389f-beb8-581c3fafff3c" }, "created_at": { + "type": [ + "string", + "null" + ], + "format": "date-time", "example": "2021-01-01T00:00:00Z" }, "updated_at": { + "type": [ + "string", + "null" + ], + "format": "date-time", "example": "2021-01-01T00:00:00Z" }, "image_url": { @@ -17355,6 +17410,11 @@ "example": "abcd1234" }, "created_at": { + "type": [ + "string", + "null" + ], + "format": "date-time", "example": "2021-01-01T00:00:00Z" } }, @@ -17451,6 +17511,11 @@ ] }, "created_at": { + "type": [ + "string", + "null" + ], + "format": "date-time", "example": "2021-01-01T00:00:00Z" } }, @@ -17625,9 +17690,19 @@ "$ref": "#/components/schemas/FeeOptions" }, "created_at": { + "type": [ + "string", + "null" + ], + "format": "date-time", "example": "2021-01-01T00:00:00Z" }, "updated_at": { + "type": [ + "string", + "null" + ], + "format": "date-time", "example": "2021-01-01T00:00:00Z" } }, @@ -17944,9 +18019,19 @@ "example": "in_000000000000" }, "created_at": { + "type": [ + "string", + "null" + ], + "format": "date-time", "example": "2021-01-01T00:00:00Z" }, "updated_at": { + "type": [ + "string", + "null" + ], + "format": "date-time", "example": "2021-01-01T00:00:00Z" } }, @@ -18090,9 +18175,19 @@ "example": "in_000000000000" }, "created_at": { + "type": [ + "string", + "null" + ], + "format": "date-time", "example": "2021-01-01T00:00:00Z" }, "updated_at": { + "type": [ + "string", + "null" + ], + "format": "date-time", "example": "2021-01-01T00:00:00Z" } }, @@ -18604,9 +18699,19 @@ "example": null }, "created_at": { + "type": [ + "string", + "null" + ], + "format": "date-time", "example": "2021-01-01T00:00:00Z" }, "updated_at": { + "type": [ + "string", + "null" + ], + "format": "date-time", "example": "2021-01-01T00:00:00Z" }, "image_url": { @@ -21188,9 +21293,19 @@ ] }, "created_at": { + "type": [ + "string", + "null" + ], + "format": "date-time", "example": "2021-01-01T00:00:00Z" }, "updated_at": { + "type": [ + "string", + "null" + ], + "format": "date-time", "example": "2021-01-01T00:00:00Z" }, "image_url": { @@ -21937,9 +22052,19 @@ "example": "re_000000000000" }, "created_at": { + "type": [ + "string", + "null" + ], + "format": "date-time", "example": "2021-01-01T00:00:00Z" }, "updated_at": { + "type": [ + "string", + "null" + ], + "format": "date-time", "example": "2021-01-01T00:00:00Z" } }, @@ -22012,9 +22137,19 @@ "example": "TALJN9zTTEL9TVBb4WuTt6wLvPqJZr3hvb" }, "created_at": { + "type": [ + "string", + "null" + ], + "format": "date-time", "example": "2021-01-01T00:00:00Z" }, "updated_at": { + "type": [ + "string", + "null" + ], + "format": "date-time", "example": "2021-01-01T00:00:00Z" } }, @@ -22077,6 +22212,11 @@ "example": "polygon" }, "created_at": { + "type": [ + "string", + "null" + ], + "format": "date-time", "example": "2021-01-01T00:00:00Z" } }, @@ -25360,6 +25500,11 @@ "example": "bh_swift_afl" }, "created_at": { + "type": [ + "string", + "null" + ], + "format": "date-time", "example": "2021-01-01T00:00:00Z" } }, @@ -32561,9 +32706,19 @@ } }, "created_at": { + "type": [ + "string", + "null" + ], + "format": "date-time", "example": "2021-01-01T00:00:00Z" }, "updated_at": { + "type": [ + "string", + "null" + ], + "format": "date-time", "example": "2021-01-01T00:00:00Z" }, "limit": { @@ -35656,6 +35811,11 @@ "example": "https://example.com/image.png" }, "created_at": { + "type": [ + "string", + "null" + ], + "format": "date-time", "example": "2021-01-01T00:00:00Z" }, "has_passkey": { diff --git a/.api-sync/unmodeled.json b/.api-sync/unmodeled.json new file mode 100644 index 0000000..2ee7ec1 --- /dev/null +++ b/.api-sync/unmodeled.json @@ -0,0 +1,908 @@ +{ + "properties": [ + { + "kind": "property", + "schema": "AvailableBankDetails", + "field": "requiredWhen", + "reason": "Conditional-requirement metadata (field/operator/values, describing when a bank-detail-form field becomes required) added to each AvailableBankDetails item; GetBankDetailsResponse does not model it.", + "owner": "eric@blindpay.com" + }, + { + "kind": "property", + "schema": "BankAccountOut", + "field": "swift_ifsc_branch_code", + "reason": "See CreateBankAccountIn.swift_ifsc_branch_code: same new field missing on the response side (GetBankAccountResponse/ListBankAccountsResponse/CreateInternationalSwiftResponse).", + "owner": "eric@blindpay.com" + }, + { + "kind": "property", + "schema": "CreateBankAccountIn", + "field": "checkbook_account_id", + "reason": "Provider-internal account handle (Checkbook integration) present on the bank-account create schema; not settable through any of the 11 rail create input types, consistent with the existing aiprise_validation_key-style internal-field pattern already allow-listed elsewhere.", + "owner": "eric@blindpay.com" + }, + { + "kind": "property", + "schema": "CreateBankAccountIn", + "field": "checkbook_user_key", + "reason": "See CreateBankAccountIn.checkbook_account_id (same Checkbook-provider internal field).", + "owner": "eric@blindpay.com" + }, + { + "kind": "property", + "schema": "CreateBankAccountIn", + "field": "force_cpf_cnpj", + "reason": "New PIX-key CPF/CNPJ validation override flag; not modeled on CreatePixInput.", + "owner": "eric@blindpay.com" + }, + { + "kind": "property", + "schema": "CreateBankAccountIn", + "field": "onemoney_external_account_id", + "reason": "Provider-internal account handle (OneMoney integration); same pattern as checkbook_account_id.", + "owner": "eric@blindpay.com" + }, + { + "kind": "property", + "schema": "CreateBankAccountIn", + "field": "status", + "reason": "Bank-account status is present on the shared create schema (likely a server-computed/back-office field, not client-settable) but not modeled on any of the 11 rail create input types; needs confirmation of intended write access before modeling.", + "owner": "eric@blindpay.com" + }, + { + "kind": "property", + "schema": "CreateBankAccountIn", + "field": "swift_ifsc_branch_code", + "reason": "IFSC branch code (Indian bank routing) added alongside the existing SWIFT fields; not modeled on CreateInternationalSwiftInput.", + "owner": "eric@blindpay.com" + }, + { + "kind": "property", + "schema": "CreateBankAccountIn", + "field": "type", + "reason": "Discriminator field injected by the factory function body (bank-accounts/index.ts:508-610: `{ type: \"pix\", ...data }` etc.), never carried on any of the 11 rail create input types by design.", + "owner": "eric@blindpay.com" + }, + { + "kind": "property", + "schema": "CreateBlockchainWalletIn", + "field": "is_account_abstraction", + "reason": "Discriminator field injected by the factory function body (blockchain.ts:109-136: createWithAddress sets true, createWithHash sets false), never carried on either input type by design.", + "owner": "eric@blindpay.com" + }, + { + "kind": "property", + "schema": "CreateCustomerIn", + "field": "additional_info", + "reason": "See CustomerOut.additional_info: same new field, not modeled on any of the 3 KYC/KYB input types.", + "owner": "eric@blindpay.com" + }, + { + "kind": "property", + "schema": "CreateCustomerIn", + "field": "kyc_type", + "reason": "Discriminator field injected by the factory function body (customers/index.ts:521-547: `{ kyc_type: \"standard\"|\"enhanced\", ...data }`), never carried on any of the 3 KYC/KYB input types by design.", + "owner": "eric@blindpay.com" + }, + { + "kind": "property", + "schema": "CreateCustomerIn", + "field": "latitude", + "reason": "See CustomerOut.latitude: same new field, not modeled on any of the 3 KYC/KYB input types.", + "owner": "eric@blindpay.com" + }, + { + "kind": "property", + "schema": "CreateCustomerIn", + "field": "longitude", + "reason": "See CreateCustomerIn.latitude.", + "owner": "eric@blindpay.com" + }, + { + "kind": "property", + "schema": "CreateCustomerIn", + "field": "type", + "reason": "Discriminator field injected by the factory function body (customers/index.ts:521-547: `{ type: \"individual\"|\"business\", ...data }`), never carried on any of the 3 KYC/KYB input types by design.", + "owner": "eric@blindpay.com" + }, + { + "kind": "property", + "schema": "CreatePayinIn", + "field": "payin_quote_id", + "reason": "CreateEvmPayinInput (payins/index.ts) is `= string`, a bare scalar alias for the value the factory sends as {payin_quote_id}, not an object literal; there is no key to mechanically add a field to. Verified by reading the factory: createEvm(payin_quote_id) posts exactly {payin_quote_id}, matching this schema's one property.", + "owner": "eric@blindpay.com" + }, + { + "kind": "property", + "schema": "CreatePayinOut", + "field": "billing_fee_amount", + "reason": "billing_fee_amount is not in CreateEvmPayinResponse's Pick list and has no inline fallback; only the legacy billing_fee field is carried.", + "owner": "eric@blindpay.com" + }, + { + "kind": "property", + "schema": "CreatePayinOut", + "field": "partner_fee", + "reason": "Same partner_fee/transaction_fee_amount/billing_fee_amount gaps as PayinOut (CreateEvmPayinResponse Picks its shared fields from Payin, which does not have them either).", + "owner": "eric@blindpay.com" + }, + { + "kind": "property", + "schema": "CreatePayinOut", + "field": "transaction_fee_amount", + "reason": "See CreatePayinOut.partner_fee.", + "owner": "eric@blindpay.com" + }, + { + "kind": "property", + "schema": "CreatePayinQuoteOut", + "field": "billing_fee_amount", + "reason": "New computed billing-fee-in-cents field added to CreatePayinQuoteOut; CreatePayinQuoteResponse does not model it (mirrors the same gap on CreateQuoteResponse/QuoteOut).", + "owner": "eric@blindpay.com" + }, + { + "kind": "property", + "schema": "CustomerOut", + "field": "additional_info", + "reason": "Extra-documents/notes attachment list (label+value+AdditionalInfoItem) added to CustomerOut; not modeled on any of the 3 KYC/KYB response types.", + "owner": "eric@blindpay.com" + }, + { + "kind": "property", + "schema": "CustomerOut", + "field": "business_type", + "reason": "CreateBusinessWithStandardKYBInput accepts business_type on create, but BusinessWithStandardKYB (the response type) never surfaces it back; a response-side-only gap.", + "owner": "eric@blindpay.com" + }, + { + "kind": "property", + "schema": "CustomerOut", + "field": "customer_id", + "reason": "CustomerOut carries both `id` and a separate `customer_id` (the wave-2 rename's canonical identifier); none of the 3 KYC/KYB response types expose `customer_id`, only `id`.", + "owner": "eric@blindpay.com" + }, + { + "kind": "property", + "schema": "CustomerOut", + "field": "latitude", + "reason": "Optional geolocation pair added to CustomerOut (forwarded to the identity/fraud providers); not modeled on any of the 3 KYC/KYB response types.", + "owner": "eric@blindpay.com" + }, + { + "kind": "property", + "schema": "CustomerOut", + "field": "longitude", + "reason": "See CustomerOut.latitude.", + "owner": "eric@blindpay.com" + }, + { + "kind": "property", + "schema": "Error", + "field": "code", + "reason": "This SDK's error envelope (ErrorResponse) only ever surfaces `message`, the legacy machine string. It does not expose the newer stable `code` the spec explicitly recommends building programmatic handling on.", + "owner": "eric@blindpay.com" + }, + { + "kind": "property", + "schema": "Error", + "field": "description", + "reason": "See Error.code: human-readable, safe-to-display explanation, not modeled.", + "owner": "eric@blindpay.com" + }, + { + "kind": "property", + "schema": "Error", + "field": "errors", + "reason": "See Error.code: structured per-field validation error list, not modeled.", + "owner": "eric@blindpay.com" + }, + { + "kind": "property", + "schema": "Error", + "field": "success", + "reason": "See Error.code: the {success: false} discriminator flag on the error envelope, not modeled (this SDK signals an error by BlindpayApiResponse.error being non-null instead).", + "owner": "eric@blindpay.com" + }, + { + "kind": "property", + "schema": "Error", + "field": "trace_id", + "reason": "See Error.code: request trace ID for support correlation, not modeled.", + "owner": "eric@blindpay.com" + }, + { + "kind": "property", + "schema": "GetFeesOut", + "field": "sepa", + "reason": "SEPA fee tier added alongside the other rail fee tiers; GetFeesResponse does not model it (consistent with the SDK-wide SEPA gaps: Rail enum and CreateBankAccountIn/BankAccountOut swift_ifsc_branch_code notwithstanding, SEPA itself is only partially wired).", + "owner": "eric@blindpay.com" + }, + { + "kind": "property", + "schema": "PayinOut", + "field": "approved_risk_sources", + "reason": "See tracking_payment.review_source: same pending PR.", + "owner": "eric@blindpay.com", + "property": "tracking_payment" + }, + { + "kind": "property", + "schema": "PayinOut", + "field": "end_to_end_id", + "reason": "Payin tracking_transaction gained 6 fields (end_to_end_id, provider_name, provider_transaction_id, sender_bank_name, ted_instruction, transaction_hash) that PayinTrackingTransaction does not model; same class of gap as tracking_payment, needs its own reviewed pass.", + "owner": "eric@blindpay.com", + "property": "tracking_transaction" + }, + { + "kind": "property", + "schema": "PayinOut", + "field": "manual_concluded_at", + "reason": "Manual OTC-liquidity execution tracking (manual_execution_status plus its concluded_at/concluded_by pair) added to PayinOut; not modeled on Payin.", + "owner": "eric@blindpay.com" + }, + { + "kind": "property", + "schema": "PayinOut", + "field": "manual_concluded_by", + "reason": "See manual_concluded_at.", + "owner": "eric@blindpay.com" + }, + { + "kind": "property", + "schema": "PayinOut", + "field": "manual_execution_status", + "reason": "See manual_concluded_at.", + "owner": "eric@blindpay.com" + }, + { + "kind": "property", + "schema": "PayinOut", + "field": "partner_fee", + "reason": "New computed partner-fee-in-cents field (distinct from partner_fee_amount) added to PayinOut; not modeled on Payin.", + "owner": "eric@blindpay.com" + }, + { + "kind": "property", + "schema": "PayinOut", + "field": "provider_name", + "reason": "See tracking_transaction.end_to_end_id: same pending gap.", + "owner": "eric@blindpay.com", + "property": "tracking_transaction" + }, + { + "kind": "property", + "schema": "PayinOut", + "field": "provider_transaction_id", + "reason": "See tracking_transaction.end_to_end_id: same pending gap.", + "owner": "eric@blindpay.com", + "property": "tracking_transaction" + }, + { + "kind": "property", + "schema": "PayinOut", + "field": "review_context", + "reason": "See tracking_payment.review_source: same pending PR.", + "owner": "eric@blindpay.com", + "property": "tracking_payment" + }, + { + "kind": "property", + "schema": "PayinOut", + "field": "review_source", + "reason": "Manual-review risk-context fields added to the payin tracking_payment step (review_source, review_context, approved_risk_sources); this SDK's PayinTrackingPayment models only step/provider_name/completed_at. Additive, needs its own reviewed PR alongside the payout tracking_payment expansion above.", + "owner": "eric@blindpay.com", + "property": "tracking_payment" + }, + { + "kind": "property", + "schema": "PayinOut", + "field": "sender_bank_name", + "reason": "See tracking_transaction.end_to_end_id: same pending gap.", + "owner": "eric@blindpay.com", + "property": "tracking_transaction" + }, + { + "kind": "property", + "schema": "PayinOut", + "field": "ted_instruction", + "reason": "See tracking_transaction.end_to_end_id: same pending gap (a Brazilian TED-rail instruction sub-object, mirroring pse_instruction/transfers_instruction which are already modeled).", + "owner": "eric@blindpay.com", + "property": "tracking_transaction" + }, + { + "kind": "property", + "schema": "PayinOut", + "field": "transaction_fee_amount", + "reason": "See PayinOut.partner_fee: added alongside it, not modeled.", + "owner": "eric@blindpay.com" + }, + { + "kind": "property", + "schema": "PayinOut", + "field": "transaction_hash", + "reason": "See tracking_transaction.end_to_end_id: same pending gap.", + "owner": "eric@blindpay.com", + "property": "tracking_transaction" + }, + { + "kind": "property", + "schema": "PayoutOnEvmOut", + "field": "bank_account_id", + "reason": "PayoutOnEvmOut (the shared create-payout response for evm/solana/stellar) carries several fields Payout itself is also missing (see PayoutOut.partner_fee, .tracking_documents, .transaction_fee_amount); bank_account_id and offramp_wallet_id are additionally new here and not modeled on any of CreateStellarPayoutResponse/CreateEvmPayoutResponse/CreateSolanaPayoutResponse.", + "owner": "eric@blindpay.com" + }, + { + "kind": "property", + "schema": "PayoutOnEvmOut", + "field": "billing_fee_amount", + "reason": "See PayoutOut: same field missing on the create-response types too.", + "owner": "eric@blindpay.com" + }, + { + "kind": "property", + "schema": "PayoutOnEvmOut", + "field": "offramp_wallet_id", + "reason": "See PayoutOnEvmOut.bank_account_id.", + "owner": "eric@blindpay.com" + }, + { + "kind": "property", + "schema": "PayoutOnEvmOut", + "field": "partner_fee", + "reason": "See PayoutOut.partner_fee: same field missing on the create-response types too.", + "owner": "eric@blindpay.com" + }, + { + "kind": "property", + "schema": "PayoutOnEvmOut", + "field": "tracking_documents", + "reason": "See PayoutOut.tracking_documents: same new tracking step missing on the create-response types too.", + "owner": "eric@blindpay.com" + }, + { + "kind": "property", + "schema": "PayoutOnEvmOut", + "field": "transaction_fee_amount", + "reason": "See PayoutOut.transaction_fee_amount: same field missing on the create-response types too.", + "owner": "eric@blindpay.com" + }, + { + "kind": "property", + "schema": "PayoutOut", + "field": "coelsa_id", + "reason": "SDK's shared tracking-payment type models a 12 of 20 field subset of the payout tracking_payment object (this SDK already models the 6 recipient_* fields plus provider_name/provider_transaction_id/provider_status/estimated_time_of_arrival/completed_at); exposing the remaining 8 payout-only fields (coelsa_id, end_to_end_id, provider_clearing_system, provider_error_reason, provider_imad, provider_integration, provider_reference, provider_uetr) is a deliberate additive change that needs one reviewed PR across all SDKs, not a blind per-field add.", + "owner": "eric@blindpay.com", + "property": "tracking_payment" + }, + { + "kind": "property", + "schema": "PayoutOut", + "field": "end_to_end_id", + "reason": "See tracking_payment.coelsa_id: same pending cross-SDK PR.", + "owner": "eric@blindpay.com", + "property": "tracking_payment" + }, + { + "kind": "property", + "schema": "PayoutOut", + "field": "ledger_in_transaction_id", + "reason": "Payout tracking_transaction gained 4 ledger/provider fields (ledger_in_transaction_id, ledger_out_transaction_id, provider_error_reason, provider_transaction_id) that PayoutTrackingTransaction does not model.", + "owner": "eric@blindpay.com", + "property": "tracking_transaction" + }, + { + "kind": "property", + "schema": "PayoutOut", + "field": "ledger_out_transaction_id", + "reason": "See tracking_transaction.ledger_in_transaction_id: same pending gap.", + "owner": "eric@blindpay.com", + "property": "tracking_transaction" + }, + { + "kind": "property", + "schema": "PayoutOut", + "field": "partner_fee", + "reason": "New computed partner-fee-in-cents field (distinct from the existing partner_fee_amount) added alongside billing_fee_amount/transaction_fee_amount; none of the three are modeled on Payout.", + "owner": "eric@blindpay.com" + }, + { + "kind": "property", + "schema": "PayoutOut", + "field": "pix_safe_bank_code", + "reason": "PIX SAFE rail fields present on the shared PayoutOut/BankAccountOut shape but never copied onto Payout (whose bank-account fields are otherwise a near-complete flattening of BankAccountOut).", + "owner": "eric@blindpay.com" + }, + { + "kind": "property", + "schema": "PayoutOut", + "field": "pix_safe_branch_code", + "reason": "See pix_safe_bank_code.", + "owner": "eric@blindpay.com" + }, + { + "kind": "property", + "schema": "PayoutOut", + "field": "pix_safe_cpf_cnpj", + "reason": "See pix_safe_bank_code.", + "owner": "eric@blindpay.com" + }, + { + "kind": "property", + "schema": "PayoutOut", + "field": "provider_clearing_system", + "reason": "See tracking_payment.coelsa_id: same pending cross-SDK PR.", + "owner": "eric@blindpay.com", + "property": "tracking_payment" + }, + { + "kind": "property", + "schema": "PayoutOut", + "field": "provider_error_reason", + "reason": "See tracking_payment.coelsa_id: same pending cross-SDK PR.", + "owner": "eric@blindpay.com", + "property": "tracking_payment" + }, + { + "kind": "property", + "schema": "PayoutOut", + "field": "provider_error_reason", + "reason": "See tracking_transaction.ledger_in_transaction_id: same pending gap.", + "owner": "eric@blindpay.com", + "property": "tracking_transaction" + }, + { + "kind": "property", + "schema": "PayoutOut", + "field": "provider_imad", + "reason": "See tracking_payment.coelsa_id: same pending cross-SDK PR.", + "owner": "eric@blindpay.com", + "property": "tracking_payment" + }, + { + "kind": "property", + "schema": "PayoutOut", + "field": "provider_integration", + "reason": "See tracking_payment.coelsa_id: same pending cross-SDK PR.", + "owner": "eric@blindpay.com", + "property": "tracking_payment" + }, + { + "kind": "property", + "schema": "PayoutOut", + "field": "provider_reference", + "reason": "See tracking_payment.coelsa_id: same pending cross-SDK PR.", + "owner": "eric@blindpay.com", + "property": "tracking_payment" + }, + { + "kind": "property", + "schema": "PayoutOut", + "field": "provider_transaction_id", + "reason": "See tracking_transaction.ledger_in_transaction_id: same pending gap.", + "owner": "eric@blindpay.com", + "property": "tracking_transaction" + }, + { + "kind": "property", + "schema": "PayoutOut", + "field": "provider_transaction_id", + "reason": "Payout tracking_complete gained a refund-tracking pair (provider_transaction_id for the refund transaction, refund_reason) that PayoutTrackingComplete does not model.", + "owner": "eric@blindpay.com", + "property": "tracking_complete" + }, + { + "kind": "property", + "schema": "PayoutOut", + "field": "provider_uetr", + "reason": "See tracking_payment.coelsa_id: same pending cross-SDK PR.", + "owner": "eric@blindpay.com", + "property": "tracking_payment" + }, + { + "kind": "property", + "schema": "PayoutOut", + "field": "refund_reason", + "reason": "See tracking_complete.provider_transaction_id: same pending gap.", + "owner": "eric@blindpay.com", + "property": "tracking_complete" + }, + { + "kind": "property", + "schema": "PayoutOut", + "field": "sender_legal_name", + "reason": "New field (legal name of the sending instance, shown on the receipt) not modeled on Payout.", + "owner": "eric@blindpay.com" + }, + { + "kind": "property", + "schema": "PayoutOut", + "field": "tracking_documents", + "reason": "Entire new tracking step (step, completed_at, reviewed_by, status) added for international-wire document review; not modeled at all on Payout, or on the create-response types (PayoutOnEvmOut). Needs a new shared PayoutTrackingDocuments type plus wiring into Payout/CreateStellarPayoutResponse/CreateEvmPayoutResponse/CreateSolanaPayoutResponse.", + "owner": "eric@blindpay.com" + }, + { + "kind": "property", + "schema": "PayoutOut", + "field": "transaction_fee_amount", + "reason": "See partner_fee: added alongside it, not modeled.", + "owner": "eric@blindpay.com" + }, + { + "kind": "property", + "schema": "QuoteOut", + "field": "billing_fee_amount", + "reason": "New computed billing-fee-in-cents field added to QuoteOut; CreateQuoteResponse does not model it.", + "owner": "eric@blindpay.com" + }, + { + "kind": "property", + "schema": "TransferOut", + "field": "external_id", + "reason": "New pass-through external_id field added to TransferOut; Transfer does not model it.", + "owner": "eric@blindpay.com" + }, + { + "kind": "property", + "schema": "UpdateCustomerIn", + "field": "additional_info", + "reason": "See CustomerOut.additional_info: same new field, not modeled on UpdateCustomerInput.", + "owner": "eric@blindpay.com" + }, + { + "kind": "property", + "schema": "UpdateCustomerIn", + "field": "latitude", + "reason": "See CustomerOut.latitude: same new field, not modeled on UpdateCustomerInput.", + "owner": "eric@blindpay.com" + }, + { + "kind": "property", + "schema": "UpdateCustomerIn", + "field": "longitude", + "reason": "See UpdateCustomerIn.latitude.", + "owner": "eric@blindpay.com" + }, + { + "kind": "property", + "schema": "UpdateInstanceIn", + "field": "compliance_emails", + "reason": "New instance-level RFI notification settings (an email allow-list plus a toggle) added to UpdateInstanceIn; UpdateInstanceInput does not model either.", + "owner": "eric@blindpay.com" + }, + { + "kind": "property", + "schema": "UpdateInstanceIn", + "field": "customer_rfi_emails_enabled", + "reason": "See UpdateInstanceIn.compliance_emails.", + "owner": "eric@blindpay.com" + }, + { + "kind": "property", + "schema": "UpdateInstanceMemberIn", + "field": "user_role", + "reason": "instances/index.ts:60-65 renames the SDK's `role` field to the wire body key `user_role` inside the factory function; the input type itself is named `role`, not `user_role`. Sibling quirk to the already allow-listed UpdateInstanceMemberRoleInput.memberId path-param entry.", + "owner": "eric@blindpay.com" + }, + { + "kind": "property", + "schema": "VirtualAccountOut", + "field": "customer_id", + "reason": "New field added to VirtualAccountOut; VirtualAccount (virtual-accounts/index.ts) does not model it, even though the surrounding customer-scoped resource already threads customer_id through the URL.", + "owner": "eric@blindpay.com" + }, + { + "kind": "property", + "schema": "get /v1/instances/{id}/members", + "field": "has_passkey", + "reason": "Per-member passkey-enrollment flag added; GetInstanceMembersResponse (instances/index.ts) does not model it.", + "owner": "eric@blindpay.com" + } + ], + "knownDivergences": [ + { + "kind": "enum", + "schema": "AvailableBankDetails", + "enumProperty": "[].key", + "reason": "Spec's available-bank-details key enum has grown to 73 values; this SDK's BankDetailKey union models 48 and is missing 24 (business_industry, date_of_birth, force_cpf_cnpj, onemoney_external_account_id, phone_number, pix_safe_bank_code, pix_safe_branch_code, pix_safe_cpf_cnpj, recipient_relationship, sepa_beneficiary_address_line_1, sepa_beneficiary_address_line_2, sepa_beneficiary_bic, sepa_beneficiary_city, sepa_beneficiary_country, sepa_beneficiary_legal_name, sepa_beneficiary_postal_code, sepa_beneficiary_state_province_region, sepa_iban, status, swift_ifsc_branch_code, swift_payment_code, tax_id, ted_bank_code, ted_branch_code, ted_cpf_cnpj). This is a purely descriptive/display enum (used to label dynamic bank-detail form fields), so the gap is cosmetic rather than a functional block, but is substantial enough to need one reviewed pass rather than a blind append.", + "owner": "eric@blindpay.com" + }, + { + "kind": "type-mismatch", + "schema": "AvailableRails", + "field": "country", + "reason": "GetRailsResponse declares country as a bare string; the spec constrains it to the full ISO country-code enum (same list as the shared Country type). Read-only informational endpoint (GET /available/rails); a modeling gap, not a functional one.", + "owner": "eric@blindpay.com" + }, + { + "kind": "property", + "schema": "BankAccountOut", + "field": "bank_name", + "reason": "Pre-existing SDK-only field on GetBankAccountResponse with no matching property on BankAccountOut in the spec (same family as the already allow-listed account_holder_name/swift_code/is_primary/iban).", + "owner": "eric@blindpay.com" + }, + { + "kind": "type-mismatch", + "schema": "BankAccountOut", + "field": "business_industry", + "reason": "ListBankAccountsResponse's inline array-item literal declares business_industry as a bare `string | null`; every create*Input type in the same file (CreateAchInput, CreateWireInput, CreateInternationalSwiftInput, CreateRtpInput) correctly types the same field as the shared BusinessIndustry union. The list endpoint's response type alone was never brought in line.", + "owner": "eric@blindpay.com" + }, + { + "kind": "property", + "schema": "BankAccountOut", + "field": "customer_id", + "reason": "Pre-existing SDK-only field on GetBankAccountResponse with no matching property on BankAccountOut in the spec; same family as bank_name.", + "owner": "eric@blindpay.com" + }, + { + "kind": "property", + "schema": "BankAccountOut", + "field": "offramp_wallets.[].circle_wallet_id", + "reason": "ListBankAccountsResponse's inline offramp_wallets literal (bank-accounts/index.ts:88-93) models {address,id,network,external_id} but spec's BankAccountOut.offramp_wallets item also carries circle_wallet_id; a narrower, independently-named SDK symbol would be needed to track this sub-shape mechanically.", + "owner": "eric@blindpay.com" + }, + { + "kind": "type-mismatch", + "schema": "BankAccountOut", + "field": "spei_protocol", + "reason": "ListBankAccountsResponse's inline array-item literal declares spei_protocol as a bare `string`; CreateSpeiResponse in the same file correctly types it as SpeiProtocol. The list endpoint's response type alone was never brought in line.", + "owner": "eric@blindpay.com" + }, + { + "kind": "type-mismatch", + "schema": "BankAccountOut", + "field": "status", + "reason": "ListBankAccountsResponse's inline array-item literal declares status as a bare `string | null`; spec constrains it to a 4-value approval-status enum (verifying, approved, rejected, deprecated). Never modeled as a named SDK type at all.", + "owner": "eric@blindpay.com" + }, + { + "kind": "type-mismatch", + "schema": "BankAccountOut", + "field": "swift_payment_code", + "reason": "CreateInternationalSwiftResponse and ListBankAccountsResponse both declare swift_payment_code as a bare `string | null`; spec constrains it to a large country-specific SWIFT purpose-code enum (hundreds of values, e.g. ae_swift_str, jp_swift_101). Given the size, a named SDK union is likely the wrong shape for this field regardless; flagged for a human modeling decision, not a blind enum type.", + "owner": "eric@blindpay.com" + }, + { + "kind": "property", + "schema": "BankAccountOut", + "field": "updated_at", + "reason": "Pre-existing SDK-only field on GetBankAccountResponse with no matching property on BankAccountOut in the spec; same family as bank_name.", + "owner": "eric@blindpay.com" + }, + { + "kind": "enum", + "schema": "BankAccountWebhookOut", + "enumProperty": "type", + "reason": "Spec's Rail (bank-account type) enum has 11 values including sepa; this SDK's Rail union has 10 and is missing sepa, even though the SDK already implements SEPA bank-account creation (createSepa/CreateSepaInput). Looks like a safe, easy follow-up: add \"sepa\" to the shared Rail union.", + "owner": "eric@blindpay.com", + "member": "sepa" + }, + { + "kind": "type-mismatch", + "schema": "CreateBankAccountIn", + "field": "swift_payment_code", + "reason": "CreateInternationalSwiftInput declares swift_payment_code as a bare `string`; same large country-specific SWIFT purpose-code enum as BankAccountOut.swift_payment_code above.", + "owner": "eric@blindpay.com" + }, + { + "kind": "property", + "schema": "CreateCustomerIn", + "field": "recipient_relationship", + "reason": "Pre-existing SDK-only field on all 3 create KYC/KYB input types with no matching property on CreateCustomerIn in the spec.", + "owner": "eric@blindpay.com" + }, + { + "kind": "property", + "schema": "CreateCustomerIn", + "field": "sole_proprietor_doc_type", + "reason": "Pre-existing SDK-only field on all 3 create KYC/KYB input types with no matching property on CreateCustomerIn in the spec.", + "owner": "eric@blindpay.com" + }, + { + "kind": "property", + "schema": "CreatePartnerFeeInput", + "field": "evm_wallet_address", + "reason": "Already tracked in .api-sync/contract-check-allowlist.json; mirrored here so this repo's own reconciliation state does not depend on that file staying in sync.", + "owner": "eric@blindpay.com" + }, + { + "kind": "property", + "schema": "CreatePartnerFeeInput", + "field": "stellar_wallet_address", + "reason": "See CreatePartnerFeeInput.evm_wallet_address.", + "owner": "eric@blindpay.com" + }, + { + "kind": "property", + "schema": "CreatePartnerFeeResponse", + "field": "evm_wallet_address", + "reason": "See CreatePartnerFeeInput.evm_wallet_address.", + "owner": "eric@blindpay.com" + }, + { + "kind": "property", + "schema": "CreatePartnerFeeResponse", + "field": "stellar_wallet_address", + "reason": "See CreatePartnerFeeInput.evm_wallet_address.", + "owner": "eric@blindpay.com" + }, + { + "kind": "property", + "schema": "CreatePayinOut", + "field": "billing_fee", + "reason": "Already tracked in .api-sync/contract-check-allowlist.json as CreateEvmPayinResponse.billing_fee; mirrored here.", + "owner": "eric@blindpay.com" + }, + { + "kind": "property", + "schema": "CreateTransferOut", + "field": "tracking_partner_fee", + "reason": "Already tracked in .api-sync/contract-check-allowlist.json as CreateTransferResponse.tracking_partner_fee; mirrored here.", + "owner": "eric@blindpay.com" + }, + { + "kind": "enum", + "schema": "CustomerLimitIncreaseIn", + "enumProperty": "supporting_document_type", + "reason": "Spec's supporting_document_type enum has grown to 15 values; this SDK's LimitIncreaseRequestSupportingDocumentType models 6 and is missing 9 (business_accounts_receivable, business_contract, business_merchant_processor_statement, business_shareholder_loan, individual_blockchain_wallet_statement, individual_crypto_exchange_statement, individual_employment_letter, individual_investment_statement, individual_pay_stub).", + "owner": "eric@blindpay.com" + }, + { + "kind": "enum", + "schema": "CustomerOut", + "enumProperty": "kyc_status", + "reason": "Spec's kyc_status has 8 values (verifying, approved, rejected, deprecated, pending_review, awaiting_contract, compliance_request, approved_rfi); this SDK's KycStatus models 7, missing approved_rfi. approved_rfi is reached only through the RFI resource, which this SDK does not implement (see ignore.schemas); adding the enum member without the resource would be misleading.", + "owner": "eric@blindpay.com", + "member": "approved_rfi" + }, + { + "kind": "property", + "schema": "CustomerOut", + "field": "recipient_relationship", + "reason": "Pre-existing SDK-only field on all 3 KYC/KYB response types with no matching property on CustomerOut in the spec.", + "owner": "eric@blindpay.com" + }, + { + "kind": "property", + "schema": "CustomerOut", + "field": "sole_proprietor_doc_type", + "reason": "Pre-existing SDK-only field on all 3 KYC/KYB response types with no matching property on CustomerOut in the spec.", + "owner": "eric@blindpay.com" + }, + { + "kind": "type-mismatch", + "schema": "CustomerOut", + "field": "source_of_funds_doc_type", + "reason": "IndividualWithEnhancedKYC declares source_of_funds_doc_type as a bare `string`; the other 2 KYC/KYB response types (IndividualWithStandardKYC, BusinessWithStandardKYB) correctly type the same field as `SourceOfFundsDocType | null`. Only the enhanced-KYC variant was never brought in line.", + "owner": "eric@blindpay.com" + }, + { + "kind": "property", + "schema": "GetPartnerFeeResponse", + "field": "evm_wallet_address", + "reason": "See CreatePartnerFeeInput.evm_wallet_address.", + "owner": "eric@blindpay.com" + }, + { + "kind": "property", + "schema": "GetPartnerFeeResponse", + "field": "stellar_wallet_address", + "reason": "See CreatePartnerFeeInput.evm_wallet_address.", + "owner": "eric@blindpay.com" + }, + { + "kind": "property", + "schema": "ListPartnerFeesResponse", + "field": "evm_wallet_address", + "reason": "See CreatePartnerFeeInput.evm_wallet_address.", + "owner": "eric@blindpay.com" + }, + { + "kind": "property", + "schema": "ListPartnerFeesResponse", + "field": "stellar_wallet_address", + "reason": "See CreatePartnerFeeInput.evm_wallet_address.", + "owner": "eric@blindpay.com" + }, + { + "kind": "property", + "schema": "PayinOut", + "field": "billing_fee", + "reason": "Already tracked in .api-sync/contract-check-allowlist.json (superseded by billing_fee_amount); mirrored here for the same reason as the partner-fee entries above.", + "owner": "eric@blindpay.com" + }, + { + "kind": "type-mismatch", + "schema": "PayinOut", + "field": "billing_fee_amount", + "reason": "Real pre-existing type bug: spec declares billing_fee_amount as a number (cents), but Payin (payins/index.ts) declares `billing_fee_amount?: string | null`. Every other amount-in-cents field on Payin (billing_fee, partner_fee, total_fee_amount, etc.) is numeric; this one field is not. Flagged for a human fix, not silently corrected here since changing a public field's type is a breaking change for any consumer already coercing/parsing it as a string.", + "owner": "eric@blindpay.com" + }, + { + "kind": "enum", + "schema": "PayinOut", + "enumProperty": "currency", + "reason": "Spec's fiat currency enum added EUR; this SDK's Currency union (which also carries the 3 crypto settlement tokens as a deliberate broader union) is missing it.", + "owner": "eric@blindpay.com", + "member": "EUR" + }, + { + "kind": "property", + "schema": "PayinOut", + "field": "tracking_partner_fee", + "reason": "Already tracked in .api-sync/contract-check-allowlist.json (no longer exists on PayinOut in the spec); mirrored here.", + "owner": "eric@blindpay.com" + }, + { + "kind": "property", + "schema": "PayoutOnEvmOut", + "field": "tracking_partner_fee", + "reason": "Already tracked in .api-sync/contract-check-allowlist.json as CreateStellarPayoutResponse/CreateEvmPayoutResponse/CreateSolanaPayoutResponse.tracking_partner_fee; mirrored here.", + "owner": "eric@blindpay.com" + }, + { + "kind": "type-mismatch", + "schema": "PayoutOut", + "field": "ach_cop_document_type", + "reason": "Payout declares ach_cop_document_type as a bare `string`; BankAccountOut's own create*Input/Response types (CreateColombiaAchInput/Response) correctly type the same field as AchCopDocument. Only the flattened Payout type was never brought in line.", + "owner": "eric@blindpay.com" + }, + { + "kind": "type-mismatch", + "schema": "PayoutOut", + "field": "provider_name", + "reason": "Nested at tracking_payment.provider_name. Spec constrains it to a fixed list of human-readable bank/provider display labels (\"HSBC\", \"JPMorgan Chase\", \"Portage Bank\", etc.); PayoutTrackingPayment declares it as a bare `string | null`. Given this is a display label list that grows whenever a new banking partner is onboarded, a strict union may be the wrong shape for this field regardless; flagged for a human modeling decision, not a blind enum type.", + "owner": "eric@blindpay.com" + }, + { + "kind": "property", + "schema": "PayoutOut", + "field": "tracking_partner_fee", + "reason": "Already tracked in .api-sync/contract-check-allowlist.json (no longer exists on any Payout schema in the spec); mirrored here.", + "owner": "eric@blindpay.com" + }, + { + "kind": "enum", + "schema": "PayoutOut", + "enumProperty": "tracking_payment.estimated_time_of_arrival", + "reason": "Spec's EstimatedTimeOfArrival gained a 7th value, 5_30_min, between 5_min and 30_min; this SDK's EstimatedTimeOfArrival union still has the original 6. Bootstrap note: this enum is declared with single quotes (unlike most others in this codebase), which is exactly why it slipped past this project's own first bootstrap pass; fixed before committing.", + "owner": "eric@blindpay.com", + "member": "5_30_min" + }, + { + "kind": "property", + "schema": "TransferOut", + "field": "tracking_partner_fee", + "reason": "Already tracked in .api-sync/contract-check-allowlist.json (no longer exists on TransferOut in the spec); mirrored here.", + "owner": "eric@blindpay.com" + }, + { + "kind": "enum", + "schema": "UpdateCustomerIn", + "enumProperty": "business_industry", + "reason": "Spec's business_industry NAICS enum added code 446120 (this SDK is missing it) and does not include this SDK's non-NAICS crypto/web3 business categories (dapp, exchange, gambling, gaming, infra, marketplace, neo_bank, other, saas, social, wallet), which appear to be a deliberate SDK-side addition for wallet/exchange-type instances rather than spec drift.", + "owner": "eric@blindpay.com" + }, + { + "kind": "enum", + "schema": "UpdateCustomerIn", + "enumProperty": "estimated_annual_revenue", + "reason": "VALUE MISMATCH, not a missing member: this SDK's top tier is literally \"2500000000_plus\" (2.5 billion) but the spec's top tier is \"250000000_plus\" (250 million, one fewer zero). As written, this SDK's type currently accepts a value the API would reject, and has no literal for the value the API actually expects. Likely a typo in the SDK; needs a human decision on the fix (correcting the literal is a breaking change for any caller already depending on the wrong spelling, however unlikely).", + "owner": "eric@blindpay.com" + }, + { + "kind": "property", + "schema": "UpdateCustomerIn", + "field": "recipient_relationship", + "reason": "Pre-existing SDK-only field on UpdateCustomerInput with no matching property on UpdateCustomerIn in the spec.", + "owner": "eric@blindpay.com" + }, + { + "kind": "property", + "schema": "UpdateCustomerIn", + "field": "sole_proprietor_doc_type", + "reason": "Pre-existing SDK-only field on UpdateCustomerInput with no matching property on UpdateCustomerIn in the spec.", + "owner": "eric@blindpay.com" + } + ] +} diff --git a/.github/workflows/api-sync-check.yaml b/.github/workflows/api-sync-check.yaml new file mode 100644 index 0000000..5f75c19 --- /dev/null +++ b/.github/workflows/api-sync-check.yaml @@ -0,0 +1,59 @@ +name: API Sync Check + +on: + workflow_call: + +jobs: + api-sync-check: + name: Verify SDK sync state against the spec + runs-on: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - uses: oven-sh/setup-bun@v2 + with: + bun-version: 1.2.18 + + - name: Install dependencies + run: bun install --frozen-lockfile + + # State reconciliation against the committed snapshot (the only spec guaranteed present on + # a plain checkout): exits 1 with a precise message for any pending drift or needs-human + # finding, silent (besides a one-line OK) when clean. Also IS the map-validity check: every + # spec-map.json entry must resolve (schema/property/path locator against the snapshot, + # file + symbol against the source tree) before any drift can even be computed. + - name: sync:check (state + map validity) + run: bun scripts/api-sync/index.ts --check --spec .api-sync/spec-snapshot.json + + # Determinism: apply the same input into two independent worktrees and diff. No + # timestamps, no randomness, no map-iteration-order leaks; running twice must be + # byte-identical. Uses the committed snapshot as its own input, so this proof needs + # nothing external to run. + - name: Determinism proof + run: | + set -e + git worktree add --detach /tmp/api-sync-det-a HEAD + git worktree add --detach /tmp/api-sync-det-b HEAD + (cd /tmp/api-sync-det-a && bun install --frozen-lockfile --silent && bun scripts/api-sync/index.ts --apply --spec .api-sync/spec-snapshot.json) + (cd /tmp/api-sync-det-b && bun install --frozen-lockfile --silent && bun scripts/api-sync/index.ts --apply --spec .api-sync/spec-snapshot.json) + diff -rq /tmp/api-sync-det-a /tmp/api-sync-det-b \ + --exclude=node_modules --exclude=.git --exclude=bun.lock + git worktree remove --force /tmp/api-sync-det-a + git worktree remove --force /tmp/api-sync-det-b + + # Non-blocking, printed on every run: spec operations with no recognizable SDK endpoint + # string (known real gaps: the Rfi family, POST /v1/upload/analyze), and the full + # state-based type audit including nullability, which sync:check deliberately does not + # enforce (see reconcile.ts). Never gates CI; a human triages this list in Phase C. + - name: Coverage + type-audit report (non-blocking) + if: always() + run: | + echo "=== Coverage gaps (spec operations with no recognizable SDK endpoint string) ===" + bun run sync:coverage + echo + echo "=== Type audit (informational only; see PayinOut.billing_fee_amount etc.) ===" + bun run audit:types diff --git a/.github/workflows/api-sync.yml b/.github/workflows/api-sync.yml index 6d6a156..cb6082f 100644 --- a/.github/workflows/api-sync.yml +++ b/.github/workflows/api-sync.yml @@ -16,13 +16,15 @@ jobs: steps: - uses: actions/checkout@v4 - - name: Fetch API sync data + - uses: oven-sh/setup-bun@v2 + with: + bun-version: 1.2.18 + + - name: Fetch the delivered spec run: | - mkdir -p /tmp/api-sync git fetch origin api-sync-data - git show origin/api-sync-data:.api-sync/changelog.md > /tmp/api-sync/changelog.md - echo "=== Changelog ===" - cat /tmp/api-sync/changelog.md + mkdir -p .api-sync + git show origin/api-sync-data:.api-sync/spec-current.json > .api-sync/spec-current.json - name: Check for existing api-sync PR id: check-pr @@ -31,10 +33,10 @@ jobs: run: | PR_NUMBER=$(gh pr list --head api-sync --json number --jq '.[0].number // empty') if [ -n "$PR_NUMBER" ]; then - echo "existing_pr=$PR_NUMBER" >> $GITHUB_OUTPUT + echo "existing_pr=$PR_NUMBER" >> "$GITHUB_OUTPUT" echo "Found existing api-sync PR: #$PR_NUMBER" else - echo "existing_pr=" >> $GITHUB_OUTPUT + echo "existing_pr=" >> "$GITHUB_OUTPUT" echo "No existing api-sync PR found" fi @@ -48,70 +50,88 @@ jobs: git checkout -b api-sync fi - - name: Apply changes with Claude Code - uses: anthropics/claude-code-action@v1 - with: - claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }} - claude_args: '--model claude-opus-5 --allowedTools "Bash(*),Read,Edit,Write,Glob,Grep"' - prompt: | - You are updating this SDK to match API changes. Read CLAUDE.md for this SDK's conventions. - - The changelog is at /tmp/api-sync/changelog.md — it contains ALL the information you need. - Do NOT read openapi.json. The changelog is self-contained. - - INSTRUCTIONS: - 1. Read CLAUDE.md thoroughly for this SDK's patterns and conventions. - 2. Read /tmp/api-sync/changelog.md — it lists every enum type with every value, every field to add, and every change to make. - 3. Implement ALL changes. Go through the changelog section by section: - - Section 1 (Enum Types): For each enum listed, check if the SDK has it. If missing, create it. If it exists, add any missing values. The changelog lists ALL values — add the ones that don't exist yet. - - Section 2 (Receiver Fields): Add each listed field to the receiver input/output types. - - Section 3 (Version): Minor bump. - 4. Do not skip ANY enum or field. Every item in the changelog must be addressed. - 5. Follow CLAUDE.md patterns exactly for naming, typing, and file organization. - 6. MINOR version bump only. - 7. Run, in this order: - bun run lint:fix # auto-fix formatter issues (trailing newlines, etc.) - bun run check-types # type-check - bun run lint:check # validate (must report 0 errors) - bun run test # run the test suite - Fix any errors until all four are clean. Running only - `lint:check` will surface formatter diffs as failures — - always run `lint:fix` first. - 8. Do NOT create commits — just modify the files. + - name: Install dependencies + run: bun install --frozen-lockfile + + # The entire "what changed, and can this repo express it without a human" decision is a + # pure, deterministic script: scripts/api-sync/. It reconciles the SDK's declared surface + # against the delivered spec state (not an event diff — see reconcile.ts for why), applies + # only enum-member and optional-field additions, and hard-fails on anything else: a + # removal, a required-ness or type change, a new operation or schema, or a mapped + # symbol/anchor it can no longer find. No LLM, no best-guessing. + - name: Run the deterministic patcher + id: sync + run: | + set +e + bun scripts/api-sync/index.ts --apply --spec .api-sync/spec-current.json --report /tmp/sync-report.json + echo "exit_code=$?" >> "$GITHUB_OUTPUT" + set -e + echo "bump=$(jq -r '.bump' /tmp/sync-report.json)" >> "$GITHUB_OUTPUT" + echo "new_version=$(jq -r '.newVersion // empty' /tmp/sync-report.json)" >> "$GITHUB_OUTPUT" + echo "applied_count=$(jq -r '.applied | length' /tmp/sync-report.json)" >> "$GITHUB_OUTPUT" + + # A precise, actionable failure (removal, type change, new operation/schema, missing + # anchor) fails the run loudly instead of silently applying nothing. If the repo also has + # an alerting hook wired to failed runs of this workflow, that is what pages a human. + - name: Fail loudly on a needs-human finding + if: steps.sync.outputs.exit_code != '0' + run: | + echo "::error::api-sync found change(s) it cannot apply automatically. See needsHuman below." + jq -r '.needsHuman[] | "- " + .' /tmp/sync-report.json + exit 1 + + - name: Exit quietly when there is nothing to apply + if: steps.sync.outputs.applied_count == '0' + run: echo "No pending drift; nothing to sync." - name: Commit and push id: commit + if: steps.sync.outputs.applied_count != '0' run: | - git remote set-url origin "https://x-access-token:${{ secrets.SDK_SYNC_PAT }}@github.com/${{ github.repository }}.git" - git checkout -- .github/workflows/ 2>/dev/null || true - git add -A - git reset HEAD .github/workflows/ 2>/dev/null || true - if git diff --staged --quiet; then - echo "No changes to commit" - echo "has_changes=false" >> $GITHUB_OUTPUT - exit 0 - fi - echo "has_changes=true" >> $GITHUB_OUTPUT git config user.name "github-actions[bot]" git config user.email "github-actions[bot]@users.noreply.github.com" - git commit -m "feat: sync SDK with API changes" + git add -A + git commit -m "$(printf 'feat: sync SDK with API changes (v%s)\n\nApplied:\n%s\n' \ + "${{ steps.sync.outputs.new_version }}" \ + "$(jq -r '.applied[] | "- " + .' /tmp/sync-report.json)")" git push --force-with-lease origin api-sync - name: Create or update PR - if: steps.commit.outputs.has_changes == 'true' + if: steps.commit.outcome == 'success' env: - GH_TOKEN: ${{ secrets.SDK_SYNC_PAT }} + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | + BUMP="${{ steps.sync.outputs.bump }}" + VERSION="${{ steps.sync.outputs.new_version }}" + if [ "$BUMP" = "minor" ]; then + PREFIX="feat" + else + PREFIX="fix" + fi + TITLE="${PREFIX}: sync SDK with API changes (v${VERSION})" EXISTING_PR="${{ steps.check-pr.outputs.existing_pr }}" + { + echo "Automated, script-generated SDK update from an API spec change." + echo "No LLM was involved in producing this diff; see scripts/api-sync/ in this repo." + echo + echo "### Applied" + jq -r '.applied[] | "- " + .' /tmp/sync-report.json + echo + echo "### Known divergences (recorded, not applied)" + jq -r '.knownDivergences[] | "- " + .' /tmp/sync-report.json + echo + echo "Version bump: \`${BUMP}\` -> v${VERSION}" + } > /tmp/pr-body.md + if [ -n "$EXISTING_PR" ]; then - echo "Updating existing PR #$EXISTING_PR" - gh pr comment "$EXISTING_PR" --body "Updated with latest API changes." + gh pr edit "$EXISTING_PR" --title "$TITLE" --body-file /tmp/pr-body.md + PR_NUMBER="$EXISTING_PR" else - gh pr create \ - --title "feat: sync SDK with API changes" \ - --body "Automated SDK update from API changes." \ - --base main \ - --head api-sync \ - --label api-sync + gh pr create --title "$TITLE" --body-file /tmp/pr-body.md --base main --head api-sync --label api-sync + PR_NUMBER=$(gh pr list --head api-sync --json number --jq '.[0].number') fi + + # Enable auto-merge so the required checks (sync:check, determinism, tests, lint, + # contract-check, etc.) are the only gate; a needs-human run never reaches this step. + gh pr merge "$PR_NUMBER" --auto --squash diff --git a/.github/workflows/main.yaml b/.github/workflows/main.yaml index fa9ef14..66bcc5e 100644 --- a/.github/workflows/main.yaml +++ b/.github/workflows/main.yaml @@ -15,6 +15,8 @@ jobs: uses: ./.github/workflows/lint.yaml contract-check: uses: ./.github/workflows/contract-check.yaml + api-sync-check: + uses: ./.github/workflows/api-sync-check.yaml type-checking: uses: ./.github/workflows/type-checking.yaml needs: [lint] diff --git a/.gitignore b/.gitignore index 046807c..75cf9d6 100644 --- a/.gitignore +++ b/.gitignore @@ -61,3 +61,6 @@ dist # Snyk Security Extension - AI Rules (auto-generated) .cursor/rules/snyk_rules.mdc + +# api-sync: delivered fresh each CI run from the api-sync-data branch, never committed +.api-sync/spec-current.json diff --git a/package.json b/package.json index d8ef605..1212218 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@blindpay/node", - "version": "5.0.1", + "version": "5.1.0", "description": "Official Node.js SDK for Blindpay API - Stablecoin API for global payments", "keywords": [ "blindpay", @@ -52,7 +52,11 @@ "lint:check": "biome check .", "lint:fix": "biome check --write .", "test": "vitest run", - "test:coverage": "vitest run --coverage" + "test:coverage": "vitest run --coverage", + "sync": "bun scripts/api-sync/index.ts --apply", + "sync:check": "bun scripts/api-sync/index.ts --check", + "sync:coverage": "bun scripts/api-sync/index.ts --coverage --spec .api-sync/spec-snapshot.json", + "audit:types": "bun scripts/api-sync/index.ts --audit-types --spec .api-sync/spec-snapshot.json" }, "exports": { ".": { diff --git a/scripts/api-sync/apply.test.ts b/scripts/api-sync/apply.test.ts new file mode 100644 index 0000000..4ff3dda --- /dev/null +++ b/scripts/api-sync/apply.test.ts @@ -0,0 +1,130 @@ +import { describe, expect, it } from "vitest"; +import { insertEnumMember, insertOptionalField } from "./apply"; + +describe("insertEnumMember", () => { + it("inserts into a single-line union", () => { + const src = 'export type BankingPartner = "jpmorgan" | "citi" | "hsbc" | "cfsb";\n'; + const result = insertEnumMember(src, "BankingPartner", "portage"); + expect(result).toBe( + 'export type BankingPartner = "jpmorgan" | "citi" | "hsbc" | "cfsb" | "portage";\n' + ); + }); + + it("inserts into a one-member-per-line union, keeping the final `;` on the true last member", () => { + const src = [ + "export type WebhookEvents =", + ' | "payout.new"', + ' | "payout.update";', + "", + ].join("\n"); + const result = insertEnumMember(src, "WebhookEvents", "payout.complete"); + expect(result).toBe( + [ + "export type WebhookEvents =", + ' | "payout.new"', + ' | "payout.complete"', + ' | "payout.update";', + "", + ].join("\n") + ); + }); + + it("is idempotent: applying the same member twice makes no second change", () => { + const src = 'export type BankingPartner = "jpmorgan" | "citi";\n'; + const once = insertEnumMember(src, "BankingPartner", "portage"); + const twice = insertEnumMember(once, "BankingPartner", "portage"); + expect(twice).toBe(once); + }); + + it("never touches a same-named union declared in an unrelated later symbol", () => { + const src = [ + 'export type BankingPartner = "jpmorgan" | "citi";', + "", + 'export type OtherPartner = "jpmorgan" | "citi";', + "", + ].join("\n"); + const result = insertEnumMember(src, "BankingPartner", "portage"); + expect(result).toContain('export type BankingPartner = "jpmorgan" | "citi" | "portage";'); + expect(result).toContain('export type OtherPartner = "jpmorgan" | "citi";'); + expect(result).not.toContain('OtherPartner = "jpmorgan" | "citi" | "portage"'); + }); +}); + +describe("insertOptionalField", () => { + it("appends a new optional field before the closing brace, matching sibling indentation", () => { + const src = [ + "export type CreateQuoteInput = {", + " bank_account_id: string;", + " description?: string | null;", + " partner_fee_id?: string | null;", + "};", + "", + ].join("\n"); + const result = insertOptionalField( + src, + "CreateQuoteInput", + "refund_wallet_address", + "string | null" + ); + expect(result).toBe( + [ + "export type CreateQuoteInput = {", + " bank_account_id: string;", + " description?: string | null;", + " partner_fee_id?: string | null;", + " refund_wallet_address?: string | null;", + "};", + "", + ].join("\n") + ); + }); + + it("matches deeper sibling indentation, not a hardcoded 4 spaces", () => { + const src = [ + "export type Foo = {", + " a: string;", + " b?: number;", + " };", + "", + ].join("\n"); + const result = insertOptionalField(src, "Foo", "c", "boolean"); + const lines = result.split("\n"); + const newLine = lines.find((l) => l.includes("c?:")); + expect(newLine).toBe(" c?: boolean;"); + }); + + it("is idempotent: applying the same field twice makes no second change", () => { + const src = ["export type Foo = {", " a: string;", "};", ""].join("\n"); + const once = insertOptionalField(src, "Foo", "b", "string | null"); + const twice = insertOptionalField(once, "Foo", "b", "string | null"); + expect(twice).toBe(once); + }); + + it("handles a nested inline sub-object field without disturbing it", () => { + const src = [ + "export type PayoutTrackingLiquidity = {", + " step: TrackingStatus", + " provider_transaction_id?: string | null", + " estimated_time_of_arrival?: EstimatedTimeOfArrival | null;", + "};", + "", + ].join("\n"); + const result = insertOptionalField( + src, + "PayoutTrackingLiquidity", + "provider_status", + "string | null" + ); + expect(result).toContain( + "estimated_time_of_arrival?: EstimatedTimeOfArrival | null;\n provider_status?: string | null;\n};" + ); + }); + + it("never reformats untouched lines (surgical insertion only)", () => { + const src = ["export type Foo = {", " a: string;", " b?: number;", "};", ""].join( + "\n" + ); + const result = insertOptionalField(src, "Foo", "c", "boolean"); + expect(result).toContain("a: string;"); + }); +}); diff --git a/scripts/api-sync/apply.ts b/scripts/api-sync/apply.ts new file mode 100644 index 0000000..cb10ae0 --- /dev/null +++ b/scripts/api-sync/apply.ts @@ -0,0 +1,98 @@ +import { findDeclarationSpan } from "./sdk-scan"; + +function leadingWhitespace(line: string): string { + return /^(\s*)/.exec(line)?.[1] ?? ""; +} + +/** + * Inserts a new string-literal member into a scoped `export type X = ...;` union, matching + * whichever style the declaration already uses: + * - single-line: `export type X = "a" | "b";` -> appended before the closing `;` on that line. + * - one-member-per-line (leading `|`): a new `| "member"` line is inserted just before the + * final member's line, so that line keeps its own trailing `;` untouched. + * Idempotent: returns `content` unchanged if `member` is already present in the union. + */ +export function insertEnumMember(content: string, symbol: string, member: string): string { + const span = findDeclarationSpan(content, symbol); + if (new RegExp(`"${member.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}"`).test(span.text)) { + return content; + } + + const lines = span.text.split("\n"); + const declLineIdx = lines.findIndex((l) => l.includes(`export type ${symbol}`)); + if (declLineIdx === -1) + throw new Error(`apply: could not locate declaration line for "${symbol}"`); + + if (lines[declLineIdx].includes(";")) { + // single-line union: `export type X = "a" | "b" | "c";` + const semiIdx = lines[declLineIdx].lastIndexOf(";"); + lines[declLineIdx] = + `${lines[declLineIdx].slice(0, semiIdx)} | "${member}"${lines[declLineIdx].slice(semiIdx)}`; + } else { + // one-member-per-line union with a leading `|`; find the last such line (it carries the `;`). + let lastMemberIdx = -1; + for (let i = lines.length - 1; i >= 0; i--) { + if (/^\s*\|\s*".*"\s*;\s*$/.test(lines[i])) { + lastMemberIdx = i; + break; + } + } + if (lastMemberIdx === -1) { + throw new Error(`apply: could not locate the last union member line for "${symbol}"`); + } + const indent = leadingWhitespace(lines[lastMemberIdx]); + lines.splice(lastMemberIdx, 0, `${indent}| "${member}"`); + } + + const patched = lines.join("\n"); + return content.slice(0, span.start) + patched + content.slice(span.end); +} + +/** + * Inserts `?: ;` as the last property of a scoped `export type X = { ... }` + * (or `Array<{ ... }>`) declaration, matching the indentation of its sibling properties. + * Idempotent: returns `content` unchanged if `field` is already declared on this type. + */ +export function insertOptionalField( + content: string, + symbol: string, + field: string, + tsType: string +): string { + const span = findDeclarationSpan(content, symbol); + if (new RegExp(`^\\s*(?:readonly\\s+)?${field}\\??\\s*:`, "m").test(span.text)) { + return content; + } + + const lines = span.text.split("\n"); + let depth = 0; + let closingIdx = -1; + for (let i = 0; i < lines.length; i++) { + const before = depth; + for (const ch of lines[i]) { + if (ch === "{") depth++; + else if (ch === "}") depth--; + } + if (before === 1 && depth === 0) { + closingIdx = i; + break; + } + } + if (closingIdx === -1) { + throw new Error( + `apply: could not locate the closing brace of "${symbol}" to anchor the new field before` + ); + } + + let indent = " "; + for (let i = closingIdx - 1; i >= 0; i--) { + if (lines[i].trim() !== "") { + indent = leadingWhitespace(lines[i]); + break; + } + } + + lines.splice(closingIdx, 0, `${indent}${field}?: ${tsType};`); + const patched = lines.join("\n"); + return content.slice(0, span.start) + patched + content.slice(span.end); +} diff --git a/scripts/api-sync/audit.ts b/scripts/api-sync/audit.ts new file mode 100644 index 0000000..9d897ec --- /dev/null +++ b/scripts/api-sync/audit.ts @@ -0,0 +1,107 @@ +import { extractTopLevelFields, findDeclarationSpan, readSource } from "./sdk-scan"; +import { resolveLocator, resolveProperties, type Spec } from "./spec"; +import { classifySdkType, compareTypeSignatures, specTypeSignature } from "./type-check"; +import { isPathLocator, type SpecLocator, type SpecMap } from "./types"; + +export type AuditFinding = { + locator: string; + field: string; + site: string; + category: "type-kind-mismatch" | "nullability" | "enum-narrowed-or-dropped"; + detail: string; +}; + +function locatorLabel(loc: SpecLocator): string { + if (isPathLocator(loc)) return `${loc.method} ${loc.path}`; + return loc.property ? `${loc.schema}.${loc.property}` : loc.schema; +} + +function isArrayNode(node: unknown): node is { items: unknown } { + // biome-ignore lint/suspicious/noExplicitAny: raw OpenAPI schema node + const n = node as any; + const t = n?.type; + const isArray = Array.isArray(t) ? t.includes("array") : t === "array"; + return isArray && n?.items !== undefined; +} + +function resolveFieldNode(spec: Spec, loc: SpecLocator, field: string): unknown { + // biome-ignore lint/suspicious/noExplicitAny: raw OpenAPI schema node + let node: any; + try { + node = resolveLocator(spec, loc); + } catch { + return undefined; + } + if (isArrayNode(node)) { + // biome-ignore lint/suspicious/noExplicitAny: raw OpenAPI schema node + const items = node.items as any; + node = items?.$ref ? spec.components.schemas[items.$ref.split("/").pop()] : items; + } + return node?.properties?.[field]; +} + +function categorize(problem: string): AuditFinding["category"] { + if (problem.startsWith("declared type changed")) return "type-kind-mismatch"; + if (problem.startsWith("spec allows null")) return "nullability"; + return "enum-narrowed-or-dropped"; +} + +/** + * Full STATE-based comparison of every mapped property's spec type against its declared + * TypeScript type, including nullability, which the blocking `--check`/`--apply` path + * deliberately does not enforce (see reconcile.ts: this codebase does not mirror JSON + * nullability into `| null` on response fields as a matter of pervasive, pre-existing house + * style, not oversight; enforcing it retroactively is a separate, wholesale, human-reviewed + * pass, not a Phase A concern). Always non-blocking: this function only ever reports, it never + * throws or exits non-zero, so it is safe to run unconditionally in CI. Its purpose is exactly + * the one state-based reconciliation exists for elsewhere in this tool: make sure the SDK's + * CURRENT surface disagreeing with the CURRENT spec is never invisible, even when the disagreement + * predates this run and nothing "changed." + */ +export function auditTypes(spec: Spec, map: SpecMap, repoRoot: string): AuditFinding[] { + const findings: AuditFinding[] = []; + + for (const entry of map.types) { + const label = locatorLabel(entry.spec); + let specProps: Set; + try { + specProps = resolveProperties(spec, entry.spec); + } catch { + continue; // surfaced by the blocking path already + } + + for (const site of entry.sdk) { + const siteKey = `${site.file}:${site.symbol}`; + let fields: Map; + try { + const content = readSource(repoRoot, site.file); + const span = findDeclarationSpan(content, site.symbol); + if (!span.text.includes("{")) continue; + fields = extractTopLevelFields(span.text); + } catch { + continue; + } + + for (const field of specProps) { + const info = fields.get(field); + if (!info) continue; + const specSig = specTypeSignature(resolveFieldNode(spec, entry.spec, field)); + const sdkSig = classifySdkType(info.typeExpr, repoRoot, site.file); + const problems = compareTypeSignatures(specSig, sdkSig, { skipEnumValues: false }); + for (const problem of problems) { + findings.push({ + locator: label, + field, + site: siteKey, + category: categorize(problem), + detail: problem, + }); + } + } + } + } + + return findings.sort((a, b) => + a.locator + a.field + a.site < b.locator + b.field + b.site ? -1 : 1 + ); +} diff --git a/scripts/api-sync/coverage.ts b/scripts/api-sync/coverage.ts new file mode 100644 index 0000000..dd32b18 --- /dev/null +++ b/scripts/api-sync/coverage.ts @@ -0,0 +1,42 @@ +import { readdirSync, readFileSync } from "node:fs"; +import { join } from "node:path"; +import { operationKeys, type Spec } from "./spec"; + +function allResourceSource(repoRoot: string): string { + const dir = join(repoRoot, "src/resources"); + const parts: string[] = []; + const walk = (d: string) => { + for (const entry of readdirSync(d, { withFileTypes: true })) { + const full = join(d, entry.name); + if (entry.isDirectory()) walk(full); + else if (entry.name.endsWith(".ts") && !entry.name.endsWith(".test.ts")) { + parts.push(readFileSync(full, "utf8")); + } + } + }; + walk(dir); + return parts.join("\n"); +} + +/** + * Non-blocking heuristic: an operation "has SDK coverage" if the last literal (non-`{param}`) + * path segment appears anywhere in the resource sources' request-building template strings. + * Approximate by design (this is a printed report, not a gate), its job is to make gaps like + * the RFI resource and POST /v1/upload/analyze show up on every run instead of silently. + */ +export function computeCoverageGaps(spec: Spec, repoRoot: string): string[] { + const source = allResourceSource(repoRoot); + const gaps: string[] = []; + for (const key of operationKeys(spec)) { + const [method, path] = key.split(" "); + const segments = path + .replace(/^\/v1\//, "") + .split("/") + .filter((seg) => seg.length > 0 && !seg.startsWith("{")); + const needle = segments[segments.length - 1]; + if (!needle || !source.includes(needle)) { + gaps.push(`${method.toUpperCase()} ${path}`); + } + } + return gaps.sort(); +} diff --git a/scripts/api-sync/diff.test.ts b/scripts/api-sync/diff.test.ts new file mode 100644 index 0000000..8ad866d --- /dev/null +++ b/scripts/api-sync/diff.test.ts @@ -0,0 +1,191 @@ +import { describe, expect, it } from "vitest"; +import { detectNewOperations, detectNewSchemas, detectRemovals } from "./diff"; +import type { Spec } from "./spec"; +import type { SpecMap } from "./types"; + +function emptyMap(overrides: Partial = {}): SpecMap { + return { enums: [], types: [], ignore: { schemas: [] }, ...overrides }; +} + +describe("detectRemovals", () => { + it("hard-fails when a mapped enum loses a member between the snapshot and the new spec", () => { + const oldSpec: Spec = { + paths: {}, + components: { + schemas: { + S: { type: "object", properties: { f: { type: "string", enum: ["a", "b"] } } }, + }, + }, + }; + const newSpec: Spec = { + paths: {}, + components: { + schemas: { + S: { type: "object", properties: { f: { type: "string", enum: ["a"] } } }, + }, + }, + }; + const map = emptyMap({ + enums: [{ spec: { schema: "S", property: "f" }, sdk: [{ file: "x.ts", symbol: "X" }] }], + }); + const removed = detectRemovals(oldSpec, newSpec, map); + expect(removed.length).toBe(1); + expect(removed[0]).toContain("b"); + expect(removed[0]).toContain("major version bump"); + }); + + it("hard-fails when a mapped schema loses a property", () => { + const oldSpec: Spec = { + paths: {}, + components: { + schemas: { + S: { + type: "object", + properties: { a: { type: "string" }, b: { type: "string" } }, + }, + }, + }, + }; + const newSpec: Spec = { + paths: {}, + components: { + schemas: { S: { type: "object", properties: { a: { type: "string" } } } }, + }, + }; + const map = emptyMap({ + types: [{ spec: { schema: "S" }, sdk: [{ file: "x.ts", symbol: "X" }] }], + }); + const removed = detectRemovals(oldSpec, newSpec, map); + expect(removed.length).toBe(1); + expect(removed[0]).toContain("b"); + }); + + it("reports nothing when nothing was removed", () => { + const spec: Spec = { + paths: {}, + components: { + schemas: { S: { type: "object", properties: { a: { type: "string" } } } }, + }, + }; + const map = emptyMap({ + types: [{ spec: { schema: "S" }, sdk: [{ file: "x.ts", symbol: "X" }] }], + }); + expect(detectRemovals(spec, spec, map)).toEqual([]); + }); +}); + +describe("detectNewSchemas", () => { + it("flags a newly reachable schema that is neither mapped nor ignored", () => { + const oldSpec: Spec = { paths: {}, components: { schemas: {} } }; + const newSpec: Spec = { + paths: { + "/v1/widgets": { + get: { + responses: { + "200": { + content: { + "application/json": { + schema: { $ref: "#/components/schemas/Widget" }, + }, + }, + }, + }, + }, + }, + }, + components: { + schemas: { Widget: { type: "object", properties: { id: { type: "string" } } } }, + }, + }; + const messages = detectNewSchemas(oldSpec, newSpec, emptyMap()); + expect(messages.length).toBe(1); + expect(messages[0]).toContain("Widget"); + }); + + it("orphan-schema skipping: a schema unreachable by any path or webhook never generates work", () => { + const oldSpec: Spec = { paths: {}, components: { schemas: {} } }; + const newSpec: Spec = { + paths: {}, + components: { + schemas: { + NeverReferenced: { type: "object", properties: { id: { type: "string" } } }, + }, + }, + }; + expect(detectNewSchemas(oldSpec, newSpec, emptyMap())).toEqual([]); + }); + + it("does not flag a newly reachable schema that is already mapped", () => { + const oldSpec: Spec = { paths: {}, components: { schemas: {} } }; + const newSpec: Spec = { + paths: { + "/v1/widgets": { + get: { + responses: { + "200": { + content: { + "application/json": { + schema: { $ref: "#/components/schemas/Widget" }, + }, + }, + }, + }, + }, + }, + }, + components: { + schemas: { Widget: { type: "object", properties: { id: { type: "string" } } } }, + }, + }; + const map = emptyMap({ + types: [{ spec: { schema: "Widget" }, sdk: [{ file: "x.ts", symbol: "X" }] }], + }); + expect(detectNewSchemas(oldSpec, newSpec, map)).toEqual([]); + }); + + it("does not flag a newly reachable schema that is explicitly ignored", () => { + const oldSpec: Spec = { paths: {}, components: { schemas: {} } }; + const newSpec: Spec = { + paths: { + "/v1/widgets": { + get: { + responses: { + "200": { + content: { + "application/json": { + schema: { $ref: "#/components/schemas/Widget" }, + }, + }, + }, + }, + }, + }, + }, + components: { + schemas: { Widget: { type: "object", properties: { id: { type: "string" } } } }, + }, + }; + const map = emptyMap({ + ignore: { schemas: [{ name: "Widget", reason: "not modeled yet" }] }, + }); + expect(detectNewSchemas(oldSpec, newSpec, map)).toEqual([]); + }); +}); + +describe("detectNewOperations", () => { + it("flags a new method+path combination", () => { + const oldSpec: Spec = { paths: { "/v1/x": { get: {} } }, components: { schemas: {} } }; + const newSpec: Spec = { + paths: { "/v1/x": { get: {}, post: {} } }, + components: { schemas: {} }, + }; + const messages = detectNewOperations(oldSpec, newSpec); + expect(messages.length).toBe(1); + expect(messages[0]).toContain("post /v1/x"); + }); + + it("reports nothing when no operations changed", () => { + const spec: Spec = { paths: { "/v1/x": { get: {} } }, components: { schemas: {} } }; + expect(detectNewOperations(spec, spec)).toEqual([]); + }); +}); diff --git a/scripts/api-sync/diff.ts b/scripts/api-sync/diff.ts new file mode 100644 index 0000000..4efdea4 --- /dev/null +++ b/scripts/api-sync/diff.ts @@ -0,0 +1,103 @@ +import { + operationKeys, + reachableSchemas, + resolveEnumMembers, + resolveProperties, + type Spec, +} from "./spec"; +import { isPathLocator, type SpecMap } from "./types"; + +function locatorLabel(loc: { + schema?: string; + property?: string; + path?: string; + method?: string; +}): string { + if (loc.path) return `${loc.method} ${loc.path}`; + return loc.property ? `${loc.schema}.${loc.property}` : (loc.schema as string); +} + +/** + * The only place old-vs-new spec diffing is used: catching something the state-based pass + * cannot, by definition, ever see, a member/property that existed in the committed snapshot + * and is simply gone from the newly delivered spec. Always a hard fail; removals are breaking + * and must be a deliberate major version bump, never automatic. + */ +export function detectRemovals(oldSpec: Spec, newSpec: Spec, map: SpecMap): string[] { + const messages: string[] = []; + + for (const entry of map.enums) { + let oldMembers: Set; + try { + oldMembers = resolveEnumMembers(oldSpec, entry.spec); + } catch { + continue; // wasn't resolvable in the old snapshot either; nothing to compare + } + let newMembers: Set; + try { + newMembers = resolveEnumMembers(newSpec, entry.spec); + } catch { + continue; // surfaced separately by reconcile() as "no longer resolves" + } + const removed = [...oldMembers].filter((m) => !newMembers.has(m)).sort(); + if (removed.length > 0) { + messages.push( + `enum "${locatorLabel(entry.spec)}" lost member(s) [${removed.join(", ")}] present in the committed snapshot; a removal needs a deliberate major version bump, not this patcher.` + ); + } + } + + for (const entry of map.types) { + let oldProps: Set; + try { + oldProps = resolveProperties(oldSpec, entry.spec); + } catch { + continue; + } + let newProps: Set; + try { + newProps = resolveProperties(newSpec, entry.spec); + } catch { + continue; + } + const removed = [...oldProps].filter((p) => !newProps.has(p)).sort(); + if (removed.length > 0) { + messages.push( + `schema "${locatorLabel(entry.spec)}" lost propert${removed.length === 1 ? "y" : "ies"} [${removed.join(", ")}] present in the committed snapshot; a removal needs a deliberate major version bump, not this patcher.` + ); + } + } + + return messages; +} + +/** A schema that becomes reachable for the first time and is neither mapped nor ignored. */ +export function detectNewSchemas(oldSpec: Spec, newSpec: Spec, map: SpecMap): string[] { + const oldReachable = reachableSchemas(oldSpec); + const newReachable = reachableSchemas(newSpec); + + const mappedNames = new Set(); + for (const entry of [...map.enums, ...map.types]) { + if (!isPathLocator(entry.spec)) mappedNames.add(entry.spec.schema); + } + const ignoredNames = new Set(map.ignore.schemas.map((e) => e.name)); + + const newlyReachable = [...newReachable].filter((n) => !oldReachable.has(n)).sort(); + return newlyReachable + .filter((n) => !mappedNames.has(n) && !ignoredNames.has(n)) + .map( + (n) => + `new schema "${n}" is now reachable from the spec and is neither mapped nor in ignore.schemas; needs a human to decide how (or whether) to model it.` + ); +} + +/** A "METHOD path" combination present in the new spec but absent from the old snapshot. */ +export function detectNewOperations(oldSpec: Spec, newSpec: Spec): string[] { + const oldOps = operationKeys(oldSpec); + const newOps = operationKeys(newSpec); + const added = [...newOps].filter((o) => !oldOps.has(o)).sort(); + return added.map( + (o) => + `new operation "${o}" needs a human to decide the SDK surface (new method, or a new resource entirely).` + ); +} diff --git a/scripts/api-sync/index.ts b/scripts/api-sync/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..885c2c1028686001756b921a73e50a7c6a3cef1b GIT binary patch literal 8352 zcmcIpTXWks7Vfiu1)|-Vk`swa^03p9>twQN+@@*bjP3Tt@x%}bO00Psg0!lp+TXt4 z0YDI-Wjoo69}tOj#pjmEQ<|ZOz-YyE?_u zzn=bbUVXp^e`(j`?#ir`qG?~3UGYv7OVLc3esfZ{Syl4`^@`@*CwTR#GddleG*Rp_`bKy!?@Y>zeh`1_a=+Y6*zVjD?c-1wr zyHY0CGa2*JUD$Ks5R_nQSkgk1z|TvlIg9x+7q8a5T*2;yo>QUV)x#v3(`pwc2MmA9 zuBgvM0ZmWUbDVQ2vvm7Uyw`64mw5ew$Ja2$&$23i6ODv|^+y8ezHh^+piIlb^a1oE zTlVRbfhRj+=2zfBFwl+>6{6)45EzDsX6xze4=0~aPfpIU)j&J2kL-g!`jI=~*C+}D=**00ZI#l!; zrD69yww7InDf!?|Vj) zM7bze?HVs0KMswGiE@@QCFwCJDl+(&WYC$JwQ^%Y9;Ldrd-s zpL|=z>%kKqAiPs%C*wRKY`GRA$EK zCgP8%l`JqO{%bZ%8I*qcymh0Rqa;4c2~Y!i6HYV zO$zuQ3&xX#5%Wwl&L$+95n{sHidj*WP<2yfiNTce z!eKu-Gyz$M6|c;5PMH!$em*;SA8C9dOPjA#l^v$+S-AUHUcp1$^i%Mx4E*DDB1R0C z2*yKt>K#o5cZrfh1kWh>QY_G0$a7H>jRMa-nFlqJmvuCmuCJ!Ph>%^qhmtH&9ImTz zD7=TGe#ahS^-$#8!t5GqvsGLy>2~>ky)^sI2hLQjU0bQ=MO@`*9tl$Z8wJNheshnkJ zKfFcUN$Ls|+yhL24n(wySi2VNG|gDbi!9$x*@u&}b2h(uI`3){e$LUEZ2uHt#NN~S zY{gsjAFtj-mZC=Giu#NrApKVNp%gb|7FCWJXe*`8yjuB*)3t1`Ckj|}im9A-BP9ef zv}$lA@AFYmI(iMGAwoL*sAA2?!>Sa*)}ZQgdBxa_`0&tVjq3&_KM*M>4xjf$BRcF> zEZPg$Cx&GNsyndd)DL5L9izl%XXh`^U(c3Y0$F4--ejn5sUwYZlpf_u#YDc$@~qt+ zGM;bvRsvuHGFc#nVCo#yFyR;uDbhX25G01S)gS?`^+F;W5t?nD<-5oYQMyL+>^tBmwBAPxz8Y%1K>rckw13#VVnUzo@dNusMQ<+w zntbC%%RZx+#Y!%H!VnaxD%LIff;D_I)SSAFCKZ%IB=V=O;HI^vcS(kTwGt&7hM%%l zuifEnJ6CY=Xt}-(P0h~01V~&&QRH!Q8A%kyLBOYM8M342);@m4%tN$){wpkh!DdfU zxII;y;M_ zHnfk9{U}GId|`N=zqfWGEm|!>o%<|ETJ{w%SX^xx$u|CK`RbY03yiUP=2ZB0Su?SO zJ!el`mv_t8Tk(8AD?v|zMI;M$aXIir9|kEGh^me{{AJf=wJCoX@Vgj+S)(oVH_b=H%jng0QEpAt~#w`%0(f!b{8d6?rygJN1~FY`F$k zi)`$<0~N&U!-Cf+#3-#MO_sK*PTgWAWMIMq6m`0IF(ZI*qzpR5Q3z^3D8zDhhc?Dd zZRlx-Nbc1TeHfBYFWS8&8V$=9YD~pW-Xf_~%$FAptRIYRwpKuQz)fkJ;&YRrGT|uX z<#krqBDo_|yPcn@hIH7YPFkK<9hkz{0l3>R)bQWy(igE(4I-ZhUZ}KAtG`}g00;lo z3CvZ#nt_ZhF!=w#0QT!xKg=~WHBzx&wiKX@h5$9jz;Lt@t(xj0HN1d2`iXa7Yv`$3 zKXN_bDaGKH9eIvzICxp3*PozfX9y5P360w)8QGGQ0qy`UoSgjeyGFB~r z$88bLfL>5cOT{7S1-8cTO!oA1+@^FWTEHEWnK}V}x5YwmfpE9RKN43pF literal 0 HcmV?d00001 diff --git a/scripts/api-sync/map.test.ts b/scripts/api-sync/map.test.ts new file mode 100644 index 0000000..1568194 --- /dev/null +++ b/scripts/api-sync/map.test.ts @@ -0,0 +1,89 @@ +import { resolve } from "node:path"; +import { describe, expect, it } from "vitest"; +import { isKnownEnumDivergence, isUnmodeledProperty, loadSpecMap, loadUnmodeled } from "./map"; +import { findDeclarationSpan, readSource } from "./sdk-scan"; +import { + loadSpec, + reachableSchemas, + resolveEnumMembers, + resolveProperties, + type Spec, +} from "./spec"; +import { isPathLocator } from "./types"; + +const REPO_ROOT = resolve(import.meta.dirname, "../.."); +const map = loadSpecMap(resolve(REPO_ROOT, ".api-sync/spec-map.json")); +const unmodeled = loadUnmodeled(resolve(REPO_ROOT, ".api-sync/unmodeled.json")); +const spec: Spec = loadSpec(resolve(REPO_ROOT, ".api-sync/spec-snapshot.json")); + +describe("spec-map.json validity", () => { + it("every enums[] locator resolves against the committed spec snapshot", () => { + for (const entry of map.enums) { + expect( + () => resolveEnumMembers(spec, entry.spec), + JSON.stringify(entry.spec) + ).not.toThrow(); + } + }); + + it("every types[] locator resolves against the committed spec snapshot", () => { + for (const entry of map.types) { + expect( + () => resolveProperties(spec, entry.spec), + JSON.stringify(entry.spec) + ).not.toThrow(); + } + }); + + it("every sdk site's file exists and declares the named symbol", () => { + for (const entry of [...map.enums, ...map.types]) { + for (const site of entry.sdk) { + const content = readSource(REPO_ROOT, site.file); + expect( + () => findDeclarationSpan(content, site.symbol), + `${site.file}:${site.symbol} (mapped from ${JSON.stringify(entry.spec)})` + ).not.toThrow(); + } + } + }); + + it("every ignore.schemas name exists in the spec's components.schemas", () => { + for (const entry of map.ignore.schemas) { + expect(spec.components.schemas[entry.name], entry.name).toBeDefined(); + } + }); + + it("every reachable schema is either mapped or explicitly ignored", () => { + const reachable = reachableSchemas(spec); + const mappedNames = new Set( + [...map.enums, ...map.types] + .filter((e) => !isPathLocator(e.spec)) + .map((e) => (e.spec as { schema: string }).schema) + ); + const ignoredNames = new Set(map.ignore.schemas.map((e) => e.name)); + const uncovered = [...reachable].filter((n) => !mappedNames.has(n) && !ignoredNames.has(n)); + expect(uncovered).toEqual([]); + }); +}); + +describe("unmodeled.json validity", () => { + it("every property entry resolves as a real, currently-genuine gap helper", () => { + for (const entry of unmodeled.properties) { + expect(isUnmodeledProperty(unmodeled, entry.schema, entry.field)).toBe(true); + } + }); + + it("every enum knownDivergence is internally consistent (has schema, enumProperty, reason, owner)", () => { + for (const entry of unmodeled.knownDivergences) { + if (entry.kind !== "enum") continue; + expect( + isKnownEnumDivergence( + unmodeled, + entry.schema, + entry.enumProperty, + entry.member ?? "anything" + ) + ).toBe(true); + } + }); +}); diff --git a/scripts/api-sync/map.ts b/scripts/api-sync/map.ts new file mode 100644 index 0000000..6944ed3 --- /dev/null +++ b/scripts/api-sync/map.ts @@ -0,0 +1,63 @@ +import { existsSync, readFileSync } from "node:fs"; +import { join } from "node:path"; +import type { KnownDivergenceEntry, SpecMap, UnmodeledFile } from "./types"; + +export function loadSpecMap(path: string): SpecMap { + const raw = JSON.parse(readFileSync(path, "utf8")); + if ( + !Array.isArray(raw.enums) || + !Array.isArray(raw.types) || + !Array.isArray(raw.ignore?.schemas) + ) { + throw new Error(`${path}: expected {enums: [], types: [], ignore: {schemas: []}}`); + } + return raw; +} + +export function loadUnmodeled(path: string): UnmodeledFile { + const raw = JSON.parse(readFileSync(path, "utf8")); + if (!Array.isArray(raw.properties) || !Array.isArray(raw.knownDivergences)) { + throw new Error(`${path}: expected {properties: [], knownDivergences: []}`); + } + return raw; +} + +export function isUnmodeledProperty( + unmodeled: UnmodeledFile, + schema: string, + field: string +): boolean { + return unmodeled.properties.some((e) => e.schema === schema && e.field === field); +} + +/** + * True if the given (schema, enumProperty, member) is covered by a knownDivergences entry. + * An entry with a specific `member` covers only that member; an entry with no `member` at all + * is a blanket note covering the whole enum's current gap state for that (schema, enumProperty). + */ +export function isKnownEnumDivergence( + unmodeled: UnmodeledFile, + schema: string, + enumProperty: string, + member: string +): boolean { + return unmodeled.knownDivergences.some((e: KnownDivergenceEntry) => { + if (e.kind !== "enum" || e.schema !== schema || e.enumProperty !== enumProperty) + return false; + return e.member === undefined || e.member === member; + }); +} + +export function isKnownTypeMismatchDivergence( + unmodeled: UnmodeledFile, + schema: string, + field: string +): boolean { + return unmodeled.knownDivergences.some( + (e) => e.kind === "type-mismatch" && e.schema === schema && e.field === field + ); +} + +export function findSdkFileExists(repoRoot: string, file: string): boolean { + return existsSync(join(repoRoot, file)); +} diff --git a/scripts/api-sync/reconcile.test.ts b/scripts/api-sync/reconcile.test.ts new file mode 100644 index 0000000..17a0734 --- /dev/null +++ b/scripts/api-sync/reconcile.test.ts @@ -0,0 +1,455 @@ +import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; +import { reconcile } from "./reconcile"; +import { clearSourceCache } from "./sdk-scan"; +import type { Spec } from "./spec"; +import type { SpecMap, UnmodeledFile } from "./types"; + +let dir: string | null = null; + +function makeRepo(files: Record): string { + dir = mkdtempSync(join(tmpdir(), "api-sync-reconcile-")); + for (const [rel, content] of Object.entries(files)) { + writeFileSync(join(dir, rel), content); + } + return dir; +} + +afterEach(() => { + clearSourceCache(); + if (dir) rmSync(dir, { recursive: true, force: true }); + dir = null; +}); + +function emptyUnmodeled(): UnmodeledFile { + return { properties: [], knownDivergences: [] }; +} + +describe("enum reconciliation", () => { + it("classifies a missing enum member as applicable when no divergence covers it", () => { + const repo = makeRepo({ + "types.ts": 'export type BankingPartner = "jpmorgan" | "citi";\n', + }); + const spec: Spec = { + paths: {}, + components: { + schemas: { + VirtualAccountOut: { + type: "object", + properties: { + banking_partner: { + type: "string", + enum: ["jpmorgan", "citi", "portage"], + }, + }, + }, + }, + }, + }; + const map: SpecMap = { + enums: [ + { + spec: { schema: "VirtualAccountOut", property: "banking_partner" }, + sdk: [{ file: "types.ts", symbol: "BankingPartner" }], + }, + ], + types: [], + ignore: { schemas: [] }, + }; + const { applicable, needsHuman } = reconcile(spec, map, emptyUnmodeled(), repo); + expect(needsHuman).toEqual([]); + expect(applicable).toEqual([ + { + kind: "enum-insert", + schema: "VirtualAccountOut", + property: "banking_partner", + member: "portage", + site: { file: "types.ts", symbol: "BankingPartner" }, + }, + ]); + }); + + it("records a known enum divergence instead of applying it", () => { + const repo = makeRepo({ "types.ts": 'export type KycStatus = "approved" | "rejected";\n' }); + const spec: Spec = { + paths: {}, + components: { + schemas: { + CustomerOut: { + type: "object", + properties: { + kyc_status: { + type: "string", + enum: ["approved", "rejected", "approved_rfi"], + }, + }, + }, + }, + }, + }; + const map: SpecMap = { + enums: [ + { + spec: { schema: "CustomerOut", property: "kyc_status" }, + sdk: [{ file: "types.ts", symbol: "KycStatus" }], + }, + ], + types: [], + ignore: { schemas: [] }, + }; + const unmodeled: UnmodeledFile = { + properties: [], + knownDivergences: [ + { + kind: "enum", + schema: "CustomerOut", + enumProperty: "kyc_status", + member: "approved_rfi", + reason: "RFI not implemented", + owner: "eric@blindpay.com", + }, + ], + }; + const { applicable, needsHuman, divergencesHit } = reconcile(spec, map, unmodeled, repo); + expect(needsHuman).toEqual([]); + expect(applicable).toEqual([]); + expect(divergencesHit.length).toBe(1); + expect(divergencesHit[0]).toContain("approved_rfi"); + }); +}); + +describe("field-insertion (property reconciliation)", () => { + it("classifies a missing optional property as applicable, inferring the TS type from the spec node", () => { + const repo = makeRepo({ + "quotes.ts": [ + "export type CreateQuoteInput = {", + " bank_account_id: string;", + "};", + "", + ].join("\n"), + }); + const spec: Spec = { + paths: {}, + components: { + schemas: { + QuoteIn: { + type: "object", + properties: { + bank_account_id: { type: "string" }, + refund_wallet_address: { type: ["string", "null"] }, + }, + }, + }, + }, + }; + const map: SpecMap = { + enums: [], + types: [ + { + spec: { schema: "QuoteIn" }, + sdk: [{ file: "quotes.ts", symbol: "CreateQuoteInput" }], + }, + ], + ignore: { schemas: [] }, + }; + const { applicable, needsHuman } = reconcile(spec, map, emptyUnmodeled(), repo); + expect(needsHuman).toEqual([]); + expect(applicable).toEqual([ + { + kind: "field-insert", + schema: "QuoteIn", + property: undefined, + field: "refund_wallet_address", + tsType: "string | null", + site: { file: "quotes.ts", symbol: "CreateQuoteInput" }, + }, + ]); + }); + + it("honors unmodeled.json: a recorded gap is a known divergence, not applicable", () => { + const repo = makeRepo({ + "payouts.ts": ["export type Payout = {", " id: string;", "};", ""].join("\n"), + }); + const spec: Spec = { + paths: {}, + components: { + schemas: { + PayoutOut: { + type: "object", + properties: { id: { type: "string" }, partner_fee: { type: "integer" } }, + }, + }, + }, + }; + const map: SpecMap = { + enums: [], + types: [ + { spec: { schema: "PayoutOut" }, sdk: [{ file: "payouts.ts", symbol: "Payout" }] }, + ], + ignore: { schemas: [] }, + }; + const unmodeled: UnmodeledFile = { + properties: [ + { + kind: "property", + schema: "PayoutOut", + field: "partner_fee", + reason: "not modeled yet", + owner: "eric@blindpay.com", + }, + ], + knownDivergences: [], + }; + const { applicable, needsHuman, divergencesHit } = reconcile(spec, map, unmodeled, repo); + expect(needsHuman).toEqual([]); + expect(applicable).toEqual([]); + expect(divergencesHit.some((d) => d.includes("partner_fee"))).toBe(true); + }); + + it("needs-human when a missing property's every mapped site is a bare scalar alias (`= string`) it will not edit", () => { + // Mirrors CreatePayinIn -> CreateEvmPayinInput in the real codebase: `export type + // CreateEvmPayinInput = string;` is a bare alias for the one property's value, not an + // object literal, so there is no key to mechanically insert a second field into. + const repo = makeRepo({ + "payins.ts": ["export type CreateEvmPayinInput = string;", ""].join("\n"), + }); + const spec: Spec = { + paths: {}, + components: { + schemas: { + CreatePayinIn: { + type: "object", + properties: { payin_quote_id: { type: "string" } }, + }, + }, + }, + }; + const map: SpecMap = { + enums: [], + types: [ + { + spec: { schema: "CreatePayinIn" }, + sdk: [{ file: "payins.ts", symbol: "CreateEvmPayinInput" }], + }, + ], + ignore: { schemas: [] }, + }; + const { needsHuman } = reconcile(spec, map, emptyUnmodeled(), repo); + expect(needsHuman.length).toBe(1); + expect(needsHuman[0]).toContain("payin_quote_id"); + expect(needsHuman[0]).toContain("derived type expression"); + }); + + it("resolves Pick & {...} keys normally (not opaque) when Other is a real object type", () => { + // Mirrors CreatePayinOut -> CreateEvmPayinResponse: a Pick<> plus an inline literal is + // not a bare alias, so genuinely-missing fields are ordinary "missing", not blocked. + const repo = makeRepo({ + "payins.ts": [ + "export type Payin = {", + " id: string;", + " status: string;", + "};", + "", + 'export type CreateEvmPayinResponse = Pick & {', + " extra?: string;", + "};", + "", + ].join("\n"), + }); + const spec: Spec = { + paths: {}, + components: { + schemas: { + CreatePayinOut: { + type: "object", + properties: { + id: { type: "string" }, + status: { type: "string" }, + billing_fee_amount: { type: "number" }, + }, + }, + }, + }, + }; + const map: SpecMap = { + enums: [], + types: [ + { + spec: { schema: "CreatePayinOut" }, + sdk: [{ file: "payins.ts", symbol: "CreateEvmPayinResponse" }], + }, + ], + ignore: { schemas: [] }, + }; + const unmodeled: UnmodeledFile = { + properties: [ + { + kind: "property", + schema: "CreatePayinOut", + field: "billing_fee_amount", + reason: "not modeled", + owner: "eric@blindpay.com", + }, + ], + knownDivergences: [], + }; + const { needsHuman, applicable, divergencesHit } = reconcile(spec, map, unmodeled, repo); + expect(needsHuman).toEqual([]); + expect(applicable).toEqual([]); + expect(divergencesHit.some((d) => d.includes("billing_fee_amount"))).toBe(true); + }); +}); + +describe("needs-human classifications", () => { + it("a mapped schema locator that no longer resolves in the delivered spec", () => { + const repo = makeRepo({ "x.ts": "export type X = { a: string };\n" }); + const spec: Spec = { paths: {}, components: { schemas: {} } }; + const map: SpecMap = { + enums: [], + types: [{ spec: { schema: "GoneSchema" }, sdk: [{ file: "x.ts", symbol: "X" }] }], + ignore: { schemas: [] }, + }; + const { needsHuman } = reconcile(spec, map, emptyUnmodeled(), repo); + expect(needsHuman.length).toBe(1); + expect(needsHuman[0]).toContain("GoneSchema"); + expect(needsHuman[0]).toContain("no longer resolves"); + }); + + it("a mapped SDK symbol that is not found (anchor drift)", () => { + const repo = makeRepo({ "x.ts": "export type X = { a: string };\n" }); + const spec: Spec = { + paths: {}, + components: { + schemas: { S: { type: "object", properties: { a: { type: "string" } } } }, + }, + }; + const map: SpecMap = { + enums: [], + types: [ + { spec: { schema: "S" }, sdk: [{ file: "x.ts", symbol: "RenamedSomewhereElse" }] }, + ], + ignore: { schemas: [] }, + }; + const { needsHuman } = reconcile(spec, map, emptyUnmodeled(), repo); + expect(needsHuman.length).toBe(1); + expect(needsHuman[0]).toContain("RenamedSomewhereElse"); + expect(needsHuman[0]).toContain("not found"); + }); + + it("a type-kind mismatch on a modeled property, not covered by a divergence", () => { + const repo = makeRepo({ + "payins.ts": [ + "export type Payin = {", + " billing_fee_amount?: string | null;", + "};", + "", + ].join("\n"), + }); + const spec: Spec = { + paths: {}, + components: { + schemas: { + PayinOut: { + type: "object", + properties: { billing_fee_amount: { type: ["number", "null"] } }, + }, + }, + }, + }; + const map: SpecMap = { + enums: [], + types: [ + { spec: { schema: "PayinOut" }, sdk: [{ file: "payins.ts", symbol: "Payin" }] }, + ], + ignore: { schemas: [] }, + }; + const { needsHuman } = reconcile(spec, map, emptyUnmodeled(), repo); + expect(needsHuman.length).toBe(1); + expect(needsHuman[0]).toContain("billing_fee_amount"); + expect(needsHuman[0]).toContain('spec is "number", SDK declares "string"'); + }); + + it("records a type-mismatch known divergence instead of failing", () => { + const repo = makeRepo({ + "payins.ts": [ + "export type Payin = {", + " billing_fee_amount?: string | null;", + "};", + "", + ].join("\n"), + }); + const spec: Spec = { + paths: {}, + components: { + schemas: { + PayinOut: { + type: "object", + properties: { billing_fee_amount: { type: ["number", "null"] } }, + }, + }, + }, + }; + const map: SpecMap = { + enums: [], + types: [ + { spec: { schema: "PayinOut" }, sdk: [{ file: "payins.ts", symbol: "Payin" }] }, + ], + ignore: { schemas: [] }, + }; + const unmodeled: UnmodeledFile = { + properties: [], + knownDivergences: [ + { + kind: "type-mismatch", + schema: "PayinOut", + field: "billing_fee_amount", + reason: "pre-existing bug", + owner: "eric@blindpay.com", + }, + ], + }; + const { needsHuman, divergencesHit } = reconcile(spec, map, unmodeled, repo); + expect(needsHuman).toEqual([]); + expect(divergencesHit.some((d) => d.includes("billing_fee_amount"))).toBe(true); + }); +}); + +describe("idempotency", () => { + it("reconciling twice after applying leaves nothing applicable", () => { + const repo = makeRepo({ + "types.ts": 'export type BankingPartner = "jpmorgan" | "citi" | "portage";\n', + }); + const spec: Spec = { + paths: {}, + components: { + schemas: { + VirtualAccountOut: { + type: "object", + properties: { + banking_partner: { + type: "string", + enum: ["jpmorgan", "citi", "portage"], + }, + }, + }, + }, + }, + }; + const map: SpecMap = { + enums: [ + { + spec: { schema: "VirtualAccountOut", property: "banking_partner" }, + sdk: [{ file: "types.ts", symbol: "BankingPartner" }], + }, + ], + types: [], + ignore: { schemas: [] }, + }; + const first = reconcile(spec, map, emptyUnmodeled(), repo); + expect(first.applicable).toEqual([]); + const second = reconcile(spec, map, emptyUnmodeled(), repo); + expect(second.applicable).toEqual([]); + }); +}); diff --git a/scripts/api-sync/reconcile.ts b/scripts/api-sync/reconcile.ts new file mode 100644 index 0000000..ccedf96 --- /dev/null +++ b/scripts/api-sync/reconcile.ts @@ -0,0 +1,270 @@ +import { isKnownEnumDivergence, isKnownTypeMismatchDivergence, isUnmodeledProperty } from "./map"; +import { + extractEnumMembers, + extractTopLevelFields, + extractTopLevelKeys, + type FieldInfo, + findDeclarationSpan, + makeLookup, + readSource, +} from "./sdk-scan"; +import { resolveEnumMembers, resolveLocator, resolveProperties, type Spec } from "./spec"; +import { classifySdkType, compareTypeSignatures, specTypeSignature } from "./type-check"; +import { + type ApplicableChange, + isPathLocator, + type SpecLocator, + type SpecMap, + type UnmodeledFile, +} from "./types"; + +function locatorLabel(loc: SpecLocator): string { + if (isPathLocator(loc)) return `${loc.method} ${loc.path}`; + return loc.property ? `${loc.schema}.${loc.property}` : loc.schema; +} + +function isArrayNode(node: unknown): node is { items: unknown } { + // biome-ignore lint/suspicious/noExplicitAny: raw OpenAPI schema node + const n = node as any; + const t = n?.type; + const isArray = Array.isArray(t) ? t.includes("array") : t === "array"; + return isArray && n?.items !== undefined; +} + +/** Resolves the raw property schema for `field` on whatever a locator points at (unwrapping one array level). */ +function resolveFieldNode(spec: Spec, loc: SpecLocator, field: string): unknown { + // biome-ignore lint/suspicious/noExplicitAny: raw OpenAPI schema node + let node: any; + try { + node = resolveLocator(spec, loc); + } catch { + return undefined; + } + if (isArrayNode(node)) { + // biome-ignore lint/suspicious/noExplicitAny: raw OpenAPI schema node + const items = node.items as any; + node = items?.$ref ? spec.components.schemas[items.$ref.split("/").pop()] : items; + } + return node?.properties?.[field]; +} + +function inferTsType(node: unknown): string { + // biome-ignore lint/suspicious/noExplicitAny: raw OpenAPI schema node + const n = node as any; + const rawType = n?.type; + const types: string[] = Array.isArray(rawType) ? rawType : rawType ? [rawType] : []; + const nullable = types.includes("null"); + let base = "string"; + if (types.includes("integer") || types.includes("number")) base = "number"; + else if (types.includes("boolean")) base = "boolean"; + else if (types.includes("object")) base = "Record"; + else if (types.includes("array")) base = "unknown[]"; + return nullable ? `${base} | null` : base; +} + +/** + * Field names a factory function body injects itself (discriminators, is_account_abstraction), + * never carried on the corresponding input/response type by design. Excluded from type-checking + * entirely: each fan-out variant deliberately narrows to its own single literal, which is not a + * "missing enum value" against the schema's full discriminator range, it is the discriminator + * working as intended. + */ +const DISCRIMINATOR_FIELDS = new Set(["type", "kyc_type", "is_account_abstraction"]); + +export type ReconcileResult = { + applicable: ApplicableChange[]; + needsHuman: string[]; + divergencesHit: string[]; +}; + +export function reconcile( + spec: Spec, + map: SpecMap, + unmodeled: UnmodeledFile, + repoRoot: string +): ReconcileResult { + const applicable: ApplicableChange[] = []; + const needsHuman: string[] = []; + const divergencesHit: string[] = []; + + // (schema, property) pairs already covered by a dedicated enums[] mapping, so the type-check + // pass below never double-reports an enum-member gap the enum-reconciliation pass already owns. + const enumCovered = new Set(map.enums.map((e) => `${e.spec.schema} ${e.spec.property ?? ""}`)); + // Shared union symbols covered by a dedicated enums[] mapping (at ANY schema.property site, + // not just the one canonical locator an entry happens to be anchored to): the same symbol + // (e.g. Currency, Rail, BusinessIndustry) is routinely reused across many other schemas' + // properties, and each of those reuses is this same drift, not a new one. + const enumSymbolsCovered = new Set(map.enums.flatMap((e) => e.sdk.map((s) => s.symbol))); + + for (const entry of map.enums) { + const label = locatorLabel(entry.spec); + let specMembers: Set; + try { + specMembers = resolveEnumMembers(spec, entry.spec); + } catch (err) { + needsHuman.push( + `enum mapping "${label}" no longer resolves in the delivered spec: ${(err as Error).message}` + ); + continue; + } + + for (const site of entry.sdk) { + let sdkMembers: Set; + try { + const content = readSource(repoRoot, site.file); + const span = findDeclarationSpan(content, site.symbol); + const extracted = extractEnumMembers(span.text); + if (extracted === null) { + needsHuman.push( + `enum mapping "${label}" -> ${site.file}:${site.symbol} does not look like a string-literal union; anchor may have drifted.` + ); + continue; + } + sdkMembers = extracted; + } catch (err) { + needsHuman.push( + `enum mapping "${label}" -> ${site.file}:${site.symbol} not found: ${(err as Error).message}` + ); + continue; + } + + const missing = [...specMembers].filter((m) => !sdkMembers.has(m)).sort(); + for (const member of missing) { + const enumProperty = entry.spec.property ?? ""; + if (isKnownEnumDivergence(unmodeled, entry.spec.schema, enumProperty, member)) { + divergencesHit.push( + `${label} is missing enum member "${member}" on ${site.file}:${site.symbol} (known divergence, not applied)` + ); + continue; + } + applicable.push({ + kind: "enum-insert", + schema: entry.spec.schema, + property: enumProperty, + member, + site, + }); + } + } + } + + for (const entry of map.types) { + const label = locatorLabel(entry.spec); + const schemaName = isPathLocator(entry.spec) ? label : entry.spec.schema; + const propertyName = isPathLocator(entry.spec) ? undefined : entry.spec.property; + + let specProps: Set; + try { + specProps = resolveProperties(spec, entry.spec); + } catch (err) { + needsHuman.push( + `type mapping "${label}" no longer resolves in the delivered spec: ${(err as Error).message}` + ); + continue; + } + + const union = new Set(); + const opaqueSites = new Set(); + const fieldsBySite = new Map>(); + for (const site of entry.sdk) { + const siteKey = `${site.file}:${site.symbol}`; + try { + const content = readSource(repoRoot, site.file); + const span = findDeclarationSpan(content, site.symbol); + if (!span.text.includes("{")) { + opaqueSites.add(siteKey); + continue; + } + const keys = extractTopLevelKeys(span.text, makeLookup(repoRoot, site.file)); + for (const k of keys) union.add(k); + fieldsBySite.set(siteKey, extractTopLevelFields(span.text)); + } catch (err) { + needsHuman.push( + `type mapping "${label}" -> ${siteKey} not found: ${(err as Error).message}` + ); + } + } + + const missing = [...specProps].filter((p) => !union.has(p)).sort(); + const missingSet = new Set(missing); + for (const field of missing) { + if (isUnmodeledProperty(unmodeled, schemaName, field)) { + divergencesHit.push( + `${label} is missing property "${field}" (recorded in unmodeled.json, not applied)` + ); + continue; + } + const nonOpaqueSites = entry.sdk.filter( + (s) => !opaqueSites.has(`${s.file}:${s.symbol}`) + ); + if (nonOpaqueSites.length === 0) { + needsHuman.push( + `type mapping "${label}" is missing property "${field}" and every mapped SDK site (${entry.sdk + .map((s) => `${s.file}:${s.symbol}`) + .join( + ", " + )}) is a derived type expression this patcher will not edit automatically. Add it by hand and record it in unmodeled.json.` + ); + continue; + } + // Added to every mapped SDK site (not just one): the design's rule for an additive + // property is "add it in each mapped type", matching how enum members are broadcast + // to every declared union site above. Always optional (`field?: T`), matching spec: an + // added property is never required on an already-shipped type, and emitting it as + // non-optional would force every existing caller to start passing it. + const tsType = inferTsType(resolveFieldNode(spec, entry.spec, field)); + for (const target of nonOpaqueSites) { + applicable.push({ + kind: "field-insert", + schema: schemaName, + property: propertyName, + field, + tsType, + site: target, + }); + } + } + + // Type-change detection: for every spec property the SDK actually models (present, not + // in `missing` above), compare the spec's declared type against each site's declared + // TypeScript type. A mismatch is never auto-applicable: silently rewriting a + // consumer-facing type is exactly the kind of change that needs a human's eyes, so every + // finding here is either a needs-human failure or (if pre-recorded) a known divergence. + // + // Scoped narrower than the full comparison `auditTypes` runs (see audit.ts): + // - nullability is not enforced here. This codebase does not mirror JSON nullability + // into `| null` on response fields as a matter of pervasive, pre-existing house + // style (hundreds of fields), not oversight; retrofitting that is a separate, + // wholesale, human-reviewed pass, not a Phase A concern. `bun run audit:types` + // reports it in full, non-blocking, for exactly that follow-up. + // - discriminator fields (DISCRIMINATOR_FIELDS) are skipped entirely; see its comment. + for (const field of specProps) { + if (missingSet.has(field) || DISCRIMINATOR_FIELDS.has(field)) continue; + const specSig = specTypeSignature(resolveFieldNode(spec, entry.spec, field)); + for (const [siteKey, fields] of fieldsBySite) { + const info = fields.get(field); + if (!info) continue; // this particular site doesn't carry the field (a fan-out variant) + const [siteFile] = siteKey.split(":"); + const sdkSig = classifySdkType(info.typeExpr, repoRoot, siteFile); + const skipEnumValues = + enumCovered.has(`${schemaName} ${field}`) || + (!!sdkSig.namedAs && enumSymbolsCovered.has(sdkSig.namedAs)); + const problems = compareTypeSignatures(specSig, sdkSig, { + skipEnumValues, + skipNullability: true, + }); + for (const problem of problems) { + if (isKnownTypeMismatchDivergence(unmodeled, schemaName, field)) { + divergencesHit.push( + `${label}.${field} on ${siteKey}: ${problem} (known divergence, recorded).` + ); + continue; + } + needsHuman.push(`type mapping "${label}.${field}" on ${siteKey}: ${problem}.`); + } + } + } + } + + return { applicable, needsHuman, divergencesHit }; +} diff --git a/scripts/api-sync/sdk-scan.test.ts b/scripts/api-sync/sdk-scan.test.ts new file mode 100644 index 0000000..0fee6aa --- /dev/null +++ b/scripts/api-sync/sdk-scan.test.ts @@ -0,0 +1,127 @@ +import { describe, expect, it } from "vitest"; +import { + extractEnumMembers, + extractTopLevelFields, + extractTopLevelKeys, + findDeclarationSpan, +} from "./sdk-scan"; + +describe("findDeclarationSpan", () => { + it("scopes the search to one declaration, stopping before the next top-level export", () => { + const src = [ + "export type A = { x: string };", + "", + "export type B = { y: string };", + "", + ].join("\n"); + const span = findDeclarationSpan(src, "A"); + expect(span.text).toContain("A = { x: string }"); + expect(span.text).not.toContain("B = { y: string }"); + }); + + it("throws when the symbol is not found", () => { + expect(() => findDeclarationSpan("export type A = {};", "Missing")).toThrow(); + }); +}); + +describe("extractTopLevelFields", () => { + it("does not let a nested inline sub-object's own fields leak into the parent's key set", () => { + const decl = [ + "export type VirtualAccount = {", + " id: string;", + " blockchain_wallet: { network: Network; address: string } | null;", + " token: StablecoinToken;", + "};", + ].join("\n"); + const fields = extractTopLevelFields(decl); + expect([...fields.keys()]).toEqual(["id", "blockchain_wallet", "token"]); + expect(fields.get("blockchain_wallet")?.typeExpr).toBe( + "{ network: Network; address: string } | null" + ); + }); + + it("handles a multi-line nested sub-object whose inner fields use semicolons", () => { + const decl = [ + "export type PayinTrackingTransaction = {", + " step: TrackingStatus", + " pse_instruction?: {", + " payment_link: string;", + " fid: string;", + " bank_code?: string | null;", + " } | null", + " transfers_instruction?: {", + " account: string;", + " tax_id?: string | null;", + " } | null", + " };", + ].join("\n"); + const fields = extractTopLevelFields(decl); + expect([...fields.keys()]).toEqual(["step", "pse_instruction", "transfers_instruction"]); + expect(fields.get("pse_instruction")?.typeExpr).toContain("payment_link: string"); + expect(fields.get("pse_instruction")?.typeExpr).toContain("} | null"); + expect(fields.get("transfers_instruction")?.typeExpr).toContain("account: string"); + }); + + it("terminates a field at a newline when there is no trailing semicolon (ASI-like object-type members)", () => { + const decl = [ + "export type X = {", + " step: TrackingStatus", + " provider_name?: string | null", + "};", + ].join("\n"); + const fields = extractTopLevelFields(decl); + expect(fields.get("step")?.typeExpr).toBe("TrackingStatus"); + expect(fields.get("provider_name")?.typeExpr).toBe("string | null"); + }); + + it("keeps consuming a dangling union across lines", () => { + const decl = [ + "export type X = {", + " status?:", + ' | "failed"', + ' | "completed";', + "};", + ].join("\n"); + const fields = extractTopLevelFields(decl); + expect(fields.get("status")?.typeExpr.replace(/\s+/g, " ")).toContain('"failed"'); + expect(fields.get("status")?.typeExpr.replace(/\s+/g, " ")).toContain('"completed"'); + }); +}); + +describe("extractTopLevelKeys", () => { + it("resolves Pick keys via the lookup callback, spanning multiple lines with a leading pipe", () => { + const decl = [ + "export type CreateEvmPayinResponse = Pick<", + " Payin,", + ' | "id"', + ' | "status"', + "> & {", + " customer_id?: string | null;", + "};", + ].join("\n"); + const lookup = (symbol: string) => + symbol === "Payin" ? new Set(["id", "status", "pix_code"]) : null; + const keys = extractTopLevelKeys(decl, lookup); + expect(keys.has("id")).toBe(true); + expect(keys.has("status")).toBe(true); + expect(keys.has("pix_code")).toBe(false); + expect(keys.has("customer_id")).toBe(true); + }); +}); + +describe("extractEnumMembers", () => { + it("extracts a double-quoted union", () => { + const decl = 'export type X = "a" | "b" | "c";'; + expect(extractEnumMembers(decl)).toEqual(new Set(["a", "b", "c"])); + }); + + it("extracts a single-quoted union (this codebase mixes styles)", () => { + const decl = "export type EstimatedTimeOfArrival = '5_min' | '30_min' | '2_hours';"; + expect(extractEnumMembers(decl)).toEqual(new Set(["5_min", "30_min", "2_hours"])); + }); + + it("returns null for an object-literal (not a string union)", () => { + const decl = "export type X = { a: string };"; + expect(extractEnumMembers(decl)).toBeNull(); + }); +}); diff --git a/scripts/api-sync/sdk-scan.ts b/scripts/api-sync/sdk-scan.ts new file mode 100644 index 0000000..3dc8637 --- /dev/null +++ b/scripts/api-sync/sdk-scan.ts @@ -0,0 +1,194 @@ +import { existsSync, readFileSync } from "node:fs"; +import { join } from "node:path"; + +function escapeRegExp(s: string): string { + return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); +} + +/** Matches a single- or double-quoted string literal; this codebase uses both inconsistently + * (e.g. types/index.d.ts's TrackingStatus/EstimatedTimeOfArrival are single-quoted while most + * other unions are double-quoted). Group 1 or group 2 holds the literal's contents. */ +export const QUOTED_STRING_SOURCE = `"([^"]*)"|'([^']*)'`; + +/** + * A `|`-separated list of quoted string literals, allowing the pipe on either side of each + * item (both `"a" | "b"` and the one-member-per-line `| "a"\n| "b"` style used for some unions + * in this codebase are valid TypeScript and both appear here). + */ +export const QUOTED_UNION_LIST_SOURCE = `(?:\\|?\\s*(?:${QUOTED_STRING_SOURCE})\\s*)+`; + +export function matchAllQuoted(text: string): string[] { + return [...text.matchAll(new RegExp(QUOTED_STRING_SOURCE, "g"))].map((m) => m[1] ?? m[2]); +} + +/** + * Bounds a search to one top-level `export type = ...;` declaration: from its + * `export type` line up to (but not including) the next top-level `export ` declaration, + * or EOF. Every scan/apply below is scoped this way so a match can never leak into an + * unrelated symbol declared later in the file (mirrors blindpay-cli's sliceToNextExport). + */ +export function findDeclarationSpan( + content: string, + symbol: string +): { start: number; end: number; text: string } { + const declRe = new RegExp(`^export type ${escapeRegExp(symbol)}\\b`, "m"); + const match = declRe.exec(content); + if (!match) { + throw new Error(`type "${symbol}" not found`); + } + const start = match.index; + const rest = content.slice(start + 1); + const nextExport = rest.search(/^export /m); + const end = nextExport === -1 ? content.length : start + 1 + nextExport; + return { start, end, text: content.slice(start, end) }; +} + +export type FieldInfo = { optional: boolean; typeExpr: string }; + +/** + * Direct (depth-1) fields of an `export type X = { ... }` (or `Array<{ ... }>`, or + * `Pick & { ... }`) declaration, keyed by name, each with its optionality and full + * type-expression text. Tracks a combined `{ ( < [` / `} ) > ]` depth so a field's own type , + * including a single-line nested object literal with its own internal `;` separators, is + * captured whole and never truncated at the first semicolon encountered, and so nothing at + * depth 0 (e.g. a `Pick` reference before `& {`) is misread as a field. + */ +export function extractTopLevelFields(declText: string): Map { + const fields = new Map(); + let depth = 0; + let i = 0; + const n = declText.length; + const fieldStartRe = /^(?:readonly\s+)?([A-Za-z_][A-Za-z0-9_]*)(\??)\s*:\s*/; + + while (i < n) { + if (depth === 1) { + const rest = declText.slice(i); + const m = fieldStartRe.exec(rest); + if (m) { + const name = m[1]; + const optional = m[2] === "?"; + let j = i + m[0].length; + let localDepth = 0; + let lastNonSpace = ""; + const typeStart = j; + while (j < n) { + const ch = declText[j]; + if (ch === "{" || ch === "(" || ch === "<" || ch === "[") { + localDepth++; + lastNonSpace = ch; + } else if (ch === "}" || ch === ")" || ch === ">" || ch === "]") { + if (localDepth === 0) break; + localDepth--; + lastNonSpace = ch; + } else if (ch === ";" && localDepth === 0) { + break; + } else if (ch === "\n" && localDepth === 0) { + // A newline is itself a valid TS object-type member separator (members + // may be delimited by `;`, `,`, or just a line break): treat it as an + // implicit terminator too, unless the statement clearly continues onto + // the next line, either forward (a dangling `|`, `&`, `,` just before this + // newline) or backward (the next non-blank content starts with `|` or + // `&`, the one-member-per-line union style used elsewhere in this codebase). + if ("|&,".includes(lastNonSpace)) { + // falls through to advance j below + } else { + let k = j + 1; + while (k < n && /\s/.test(declText[k])) k++; + if (k < n && (declText[k] === "|" || declText[k] === "&")) { + // leading-continuation style; keep consuming + } else { + break; + } + } + } else if (!/\s/.test(ch)) { + lastNonSpace = ch; + } + j++; + } + const typeText = declText.slice(typeStart, j); + fields.set(name, { optional, typeExpr: typeText.trim() }); + for (const ch of typeText) { + if (ch === "{") depth++; + else if (ch === "}") depth--; + } + i = j < n && (declText[j] === ";" || declText[j] === "\n") ? j + 1 : j; + continue; + } + } + const ch = declText[i]; + if (ch === "{") depth++; + else if (ch === "}") depth--; + i++; + } + + return fields; +} + +/** + * Top-level property keys of a declaration (see extractTopLevelFields). Also resolves one + * level of `Pick` by looking up Other's own top-level keys (via + * `lookupOther`) and intersecting with the picked literal names. + */ +export function extractTopLevelKeys( + declText: string, + lookupOther?: (symbol: string) => Set | null +): Set { + const keys = new Set(extractTopLevelFields(declText).keys()); + + const pickMatch = new RegExp( + `Pick<\\s*([A-Za-z_][A-Za-z0-9_]*)\\s*,\\s*(${QUOTED_UNION_LIST_SOURCE})>` + ).exec(declText); + if (pickMatch && lookupOther) { + const otherKeys = lookupOther(pickMatch[1]); + if (otherKeys) { + const picked = matchAllQuoted(pickMatch[2]); + for (const p of picked) { + if (otherKeys.has(p)) keys.add(p); + } + } + } + + return keys; +} + +/** String-literal union members of an `export type X = "a" | "b" | ...;` declaration, or null. */ +export function extractEnumMembers(declText: string): Set | null { + const eqIdx = declText.indexOf("="); + if (eqIdx === -1) return null; + const semiIdx = declText.indexOf(";"); + const body = declText.slice(eqIdx + 1, semiIdx === -1 ? undefined : semiIdx); + // Reject anything that looks like an object/array/generic shape, not a bare string union. + if (/[{<]/.test(body)) return null; + const quoted = matchAllQuoted(body); + if (quoted.length === 0) return null; + return new Set(quoted); +} + +const fileCache = new Map(); + +export function readSource(repoRoot: string, file: string): string { + const key = join(repoRoot, file); + const cached = fileCache.get(key); + if (cached !== undefined) return cached; + if (!existsSync(key)) throw new Error(`file "${file}" not found`); + const content = readFileSync(key, "utf8"); + fileCache.set(key, content); + return content; +} + +export function clearSourceCache(): void { + fileCache.clear(); +} + +/** Builds a same-file symbol lookup for extractTopLevelKeys' Pick<> resolution. */ +export function makeLookup(repoRoot: string, file: string): (symbol: string) => Set | null { + return (symbol: string) => { + try { + const content = readSource(repoRoot, file); + const span = findDeclarationSpan(content, symbol); + return extractTopLevelKeys(span.text); + } catch { + return null; + } + }; +} diff --git a/scripts/api-sync/spec.ts b/scripts/api-sync/spec.ts new file mode 100644 index 0000000..1b72efd --- /dev/null +++ b/scripts/api-sync/spec.ts @@ -0,0 +1,170 @@ +import { readFileSync } from "node:fs"; +import { isPathLocator, type SpecLocator } from "./types"; + +// biome-ignore lint/suspicious/noExplicitAny: OpenAPI documents are arbitrarily shaped JSON. +export type JsonValue = any; + +export type Spec = { + paths: Record>; + webhooks?: Record; + components: { schemas: Record }; +}; + +export function loadSpec(path: string): Spec { + return JSON.parse(readFileSync(path, "utf8")); +} + +function collectRefs(node: JsonValue, out: Set): void { + if (Array.isArray(node)) { + for (const item of node) collectRefs(item, out); + return; + } + if (node && typeof node === "object") { + if (typeof node.$ref === "string") { + out.add(node.$ref.split("/").pop() as string); + } + for (const value of Object.values(node)) collectRefs(value, out); + } +} + +/** Transitive $ref closure from every path operation plus the top-level webhooks object. */ +export function reachableSchemas(spec: Spec): Set { + const allNames = new Set(Object.keys(spec.components.schemas)); + const seed = new Set(); + collectRefs(spec.paths, seed); + if (spec.webhooks) collectRefs(spec.webhooks, seed); + + const reachable = new Set(); + let frontier = new Set([...seed].filter((n) => allNames.has(n))); + while (frontier.size > 0) { + for (const name of frontier) reachable.add(name); + const next = new Set(); + for (const name of frontier) { + const refs = new Set(); + collectRefs(spec.components.schemas[name], refs); + for (const r of refs) { + if (allNames.has(r) && !reachable.has(r)) next.add(r); + } + } + frontier = next; + } + return reachable; +} + +/** Every "METHOD path" tuple declared in spec.paths, as a stable string key. */ +export function operationKeys(spec: Spec): Set { + const out = new Set(); + for (const [path, methods] of Object.entries(spec.paths)) { + for (const method of Object.keys(methods)) { + if (["get", "post", "put", "patch", "delete"].includes(method)) { + out.add(`${method} ${path}`); + } + } + } + return out; +} + +function isArrayType(node: JsonValue): boolean { + const t = node?.type; + if (Array.isArray(t)) return t.includes("array"); + return t === "array"; +} + +function resolveRef(spec: Spec, node: JsonValue): JsonValue { + if (node && typeof node === "object" && typeof node.$ref === "string") { + const name = node.$ref.split("/").pop() as string; + return spec.components.schemas[name]; + } + return node; +} + +/** Walks schema.property.property... (dotted, with "[]" for array item) down to a schema node. */ +function resolveSchemaNode(spec: Spec, schemaName: string, propertyPath?: string): JsonValue { + let node = spec.components.schemas[schemaName]; + if (node === undefined) { + throw new Error(`schema "${schemaName}" not found in spec`); + } + if (!propertyPath) return node; + for (const part of propertyPath.split(".")) { + if (part === "[]") { + node = node?.items; + if (node === undefined) throw new Error(`"${schemaName}.${propertyPath}" has no items`); + node = resolveRef(spec, node); + } else { + node = node?.properties?.[part]; + if (node === undefined) { + throw new Error( + `property "${part}" not found while resolving "${schemaName}.${propertyPath}"` + ); + } + node = resolveRef(spec, node); + } + } + return node; +} + +function propsOf(spec: Spec, node: JsonValue): Set { + if (isArrayType(node) && node.items) { + const item = resolveRef(spec, node.items); + return new Set(Object.keys(item?.properties ?? {})); + } + return new Set(Object.keys(node?.properties ?? {})); +} + +function resolveOperationNode( + spec: Spec, + loc: { path: string; method: string; location: string } +): JsonValue { + const op = spec.paths[loc.path]?.[loc.method]; + if (!op) throw new Error(`operation "${loc.method} ${loc.path}" not found in spec`); + if (loc.location === "requestBody") { + const content = op.requestBody?.content ?? {}; + const body = content["application/json"] ?? content["multipart/form-data"]; + if (!body) throw new Error(`no requestBody content for "${loc.method} ${loc.path}"`); + return body.schema; + } + if (loc.location.startsWith("response:")) { + const code = loc.location.split(":")[1]; + const content = op.responses?.[code]?.content?.["application/json"]; + if (!content) + throw new Error(`no response:${code} content for "${loc.method} ${loc.path}"`); + return content.schema; + } + throw new Error(`unsupported path locator location "${loc.location}"`); +} + +/** Resolves a spec locator to a JSON schema node (following $ref once at the leaf). */ +export function resolveLocator(spec: Spec, loc: SpecLocator): JsonValue { + if (isPathLocator(loc)) { + return resolveRef(spec, resolveOperationNode(spec, loc)); + } + return resolveSchemaNode(spec, loc.schema, loc.property); +} + +/** Property-name set for a locator, unwrapping one array level if the resolved node is an array. */ +export function resolveProperties(spec: Spec, loc: SpecLocator): Set { + const node = resolveLocator(spec, loc); + return propsOf(spec, node); +} + +/** Enum member set for a locator. If `itemsEnum` is set, reads properties..items.enum instead. */ +export function resolveEnumMembers(spec: Spec, loc: SchemaLocatorWithEnum): Set { + if (loc.itemsEnum) { + const node = resolveSchemaNode(spec, loc.schema, loc.property); + const values = node?.items?.enum; + if (!Array.isArray(values)) { + throw new Error(`no items.enum found for "${loc.schema}.${loc.property}"`); + } + return new Set(values); + } + const node = resolveSchemaNode(spec, loc.schema, loc.property); + const values = node?.enum; + if (!Array.isArray(values)) { + throw new Error( + `no enum found for "${loc.schema}${loc.property ? `.${loc.property}` : ""}"` + ); + } + return new Set(values); +} + +type SchemaLocatorWithEnum = { schema: string; property?: string; itemsEnum?: boolean }; diff --git a/scripts/api-sync/type-check.test.ts b/scripts/api-sync/type-check.test.ts new file mode 100644 index 0000000..c1145d1 --- /dev/null +++ b/scripts/api-sync/type-check.test.ts @@ -0,0 +1,111 @@ +import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; +import { classifySdkType, compareTypeSignatures, specTypeSignature } from "./type-check"; + +// classifySdkType resolves named unions (bare references and Extract/Exclude bases) by reading +// files on disk, so these tests use a small throwaway repo directory rather than mocking fs. +let dir: string | null = null; + +function makeRepo(files: Record): string { + dir = mkdtempSync(join(tmpdir(), "api-sync-type-check-")); + for (const [rel, content] of Object.entries(files)) { + writeFileSync(join(dir, rel), content); + } + return dir; +} + +afterEach(() => { + if (dir) rmSync(dir, { recursive: true, force: true }); + dir = null; +}); + +describe("string to integer (base-kind mismatch)", () => { + it("flags a field whose spec type changed from string to integer", () => { + const spec = specTypeSignature({ type: "integer" }); + const sdk = classifySdkType("string", "/unused", "unused.ts"); + const problems = compareTypeSignatures(spec, sdk, { skipEnumValues: false }); + expect(problems).toEqual([ + 'declared type changed: spec is "number", SDK declares "string"', + ]); + }); +}); + +describe("nullable to non-nullable / non-nullable to nullable", () => { + it("flags when spec allows null but the SDK type has no | null (nullability checked)", () => { + const spec = specTypeSignature({ type: ["string", "null"] }); + const sdk = classifySdkType("string", "/unused", "unused.ts"); + const problems = compareTypeSignatures(spec, sdk, { + skipEnumValues: false, + skipNullability: false, + }); + expect(problems).toEqual(['spec allows null but the SDK type has no "| null"']); + }); + + it("does not flag nullability when the blocking pass explicitly skips it (this repo's own house style)", () => { + const spec = specTypeSignature({ type: ["string", "null"] }); + const sdk = classifySdkType("string", "/unused", "unused.ts"); + const problems = compareTypeSignatures(spec, sdk, { + skipEnumValues: false, + skipNullability: true, + }); + expect(problems).toEqual([]); + }); +}); + +describe("enum-typed property degrading to a bare string", () => { + it("flags when spec constrains the field but the SDK declares a bare string", () => { + const spec = specTypeSignature({ + type: "string", + enum: ["clabe", "debitcard", "phonenum"], + }); + const sdk = classifySdkType("string", "/unused", "unused.ts"); + const problems = compareTypeSignatures(spec, sdk, { skipEnumValues: false }); + expect(problems).toEqual([ + "spec constrains this field to a fixed set of values, but the SDK declares it as a bare string (enum constraint dropped)", + ]); + }); +}); + +describe("Extract-narrowed union", () => { + it("resolves the narrowed member set and reports only genuinely missing values", () => { + const repo = makeRepo({ + "types.ts": 'export type Currency = "USD" | "BRL" | "EUR" | "USDC";\n', + }); + const spec = specTypeSignature({ type: "string", enum: ["USD", "BRL", "EUR"] }); + const sdk = classifySdkType('Extract', repo, "types.ts"); + const problems = compareTypeSignatures(spec, sdk, { skipEnumValues: false }); + expect(problems).toEqual([ + "spec enum value(s) [EUR] not present in the SDK's type (Extract)", + ]); + }); + + it("treats an Exclude-narrowed union as compatible when the exclusion already matches spec", () => { + const repo = makeRepo({ + "types.ts": 'export type Currency = "USD" | "BRL" | "USDC" | "USDT" | "USDB";\n', + }); + const spec = specTypeSignature({ type: "string", enum: ["USD", "BRL"] }); + const sdk = classifySdkType( + 'Exclude', + repo, + "types.ts" + ); + const problems = compareTypeSignatures(spec, sdk, { skipEnumValues: false }); + expect(problems).toEqual([]); + }); +}); + +describe("a deliberately compatible case", () => { + it("does not flag a spec integer modeled as a TS number", () => { + const spec = specTypeSignature({ type: "integer" }); + const sdk = classifySdkType("number", "/unused", "unused.ts"); + expect(compareTypeSignatures(spec, sdk, { skipEnumValues: false })).toEqual([]); + }); + + it("does not flag a nullable spec string modeled with both `?` and `| null`", () => { + const spec = specTypeSignature({ type: ["string", "null"] }); + const sdk = classifySdkType("string | null", "/unused", "unused.ts"); + expect(compareTypeSignatures(spec, sdk, { skipEnumValues: false })).toEqual([]); + }); +}); diff --git a/scripts/api-sync/type-check.ts b/scripts/api-sync/type-check.ts new file mode 100644 index 0000000..f415bff --- /dev/null +++ b/scripts/api-sync/type-check.ts @@ -0,0 +1,202 @@ +import { readdirSync } from "node:fs"; +import { join, relative } from "node:path"; +import { + extractEnumMembers, + findDeclarationSpan, + matchAllQuoted, + QUOTED_UNION_LIST_SOURCE, + readSource, +} from "./sdk-scan"; + +export { extractTopLevelFields, type FieldInfo } from "./sdk-scan"; + +export type TypeSignature = { + baseKind: "string" | "number" | "boolean" | "array" | "object" | "unknown"; + nullable: boolean; + enumValues: string[] | null; + narrowedFrom?: { op: "Extract" | "Exclude"; base: string }; + /** The shared union symbol this type resolves to (bare reference or Extract/Exclude base), if any. */ + namedAs?: string; +}; + +export function specTypeSignature(node: unknown): TypeSignature { + // biome-ignore lint/suspicious/noExplicitAny: raw OpenAPI schema node + const n = node as any; + const raw = n?.type; + const types: string[] = Array.isArray(raw) ? raw : raw ? [raw] : []; + const nullable = types.includes("null"); + const nonNull = types.filter((t) => t !== "null"); + let baseKind: TypeSignature["baseKind"] = "unknown"; + if (nonNull.includes("string")) baseKind = "string"; + else if (nonNull.includes("integer") || nonNull.includes("number")) baseKind = "number"; + else if (nonNull.includes("boolean")) baseKind = "boolean"; + else if (nonNull.includes("array")) baseKind = "array"; + else if (nonNull.includes("object")) baseKind = "object"; + const enumValues: string[] | null = Array.isArray(n?.enum) ? n.enum : (n?.items?.enum ?? null); + return { baseKind, nullable, enumValues }; +} + +/** Resolves `name` as a string-literal union: same file first, then types/index.d.ts, then any resource file. */ +export function resolveNamedUnion( + repoRoot: string, + currentFile: string, + name: string +): Set | null { + const candidates = [currentFile, "types/index.d.ts"]; + for (const file of candidates) { + try { + const content = readSource(repoRoot, file); + const span = findDeclarationSpan(content, name); + const values = extractEnumMembers(span.text); + if (values) return values; + } catch { + // not in this file; try the next candidate + } + } + try { + const resourcesDir = join(repoRoot, "src/resources"); + for (const file of listTsFiles(resourcesDir)) { + const rel = relative(repoRoot, file); + if (candidates.includes(rel)) continue; + try { + const content = readSource(repoRoot, rel); + const span = findDeclarationSpan(content, name); + const values = extractEnumMembers(span.text); + if (values) return values; + } catch { + // not here either + } + } + } catch { + // resources dir missing (shouldn't happen) + } + return null; +} + +function listTsFiles(dir: string, out: string[] = []): string[] { + for (const entry of readdirSync(dir, { withFileTypes: true })) { + if (entry.name.startsWith(".")) continue; + const full = join(dir, entry.name); + if (entry.isDirectory()) listTsFiles(full, out); + else if (entry.name.endsWith(".ts") && !entry.name.endsWith(".test.ts")) out.push(full); + } + return out; +} + +/** Classifies a TypeScript type expression (the text after `field?:` up to its terminating `;`). */ +export function classifySdkType( + typeExpr: string, + repoRoot: string, + currentFile: string +): TypeSignature { + const nullable = /\|\s*null\b/.test(typeExpr) || /\bnull\s*\|/.test(typeExpr); + const withoutNull = typeExpr + .replace(/\s*\|\s*null\b/g, "") + .replace(/\bnull\s*\|\s*/g, "") + .trim(); + + const narrowMatch = new RegExp( + `^(Extract|Exclude)<\\s*([A-Za-z_][A-Za-z0-9_]*)\\s*,\\s*(${QUOTED_UNION_LIST_SOURCE})>$` + ).exec(withoutNull); + if (narrowMatch) { + const op = narrowMatch[1] as "Extract" | "Exclude"; + const base = narrowMatch[2]; + const list = matchAllQuoted(narrowMatch[3]); + const baseSet = resolveNamedUnion(repoRoot, currentFile, base); + let enumValues: string[] | null = null; + if (baseSet) { + enumValues = + op === "Extract" + ? list.filter((v) => baseSet.has(v)) + : [...baseSet].filter((v) => !list.includes(v)); + } else { + enumValues = list; + } + return { + baseKind: "string", + nullable, + enumValues, + narrowedFrom: { op, base }, + namedAs: base, + }; + } + + if (new RegExp(`^${QUOTED_UNION_LIST_SOURCE}$`).test(withoutNull)) { + const values = matchAllQuoted(withoutNull); + return { baseKind: "string", nullable, enumValues: values }; + } + + if (/^Array$/.test(withoutNull) || /^string\[\]$/.test(withoutNull)) { + return { baseKind: "array", nullable, enumValues: null }; + } + if (withoutNull === "string") return { baseKind: "string", nullable, enumValues: null }; + if (withoutNull === "number") return { baseKind: "number", nullable, enumValues: null }; + if (withoutNull === "boolean") return { baseKind: "boolean", nullable, enumValues: null }; + if (withoutNull.startsWith("{") || withoutNull.startsWith("Record<")) { + return { baseKind: "object", nullable, enumValues: null }; + } + if (withoutNull.endsWith("[]") || /^Array !(sdk.enumValues as string[]).includes(v) + ); + if (missing.length > 0) { + const narrowDesc = sdk.narrowedFrom + ? ` (${sdk.narrowedFrom.op}<${sdk.narrowedFrom.base}, ...>)` + : ""; + problems.push( + `spec enum value(s) [${missing.join(", ")}] not present in the SDK's type${narrowDesc}` + ); + } + } else if (sdk.baseKind === "string") { + problems.push( + "spec constrains this field to a fixed set of values, but the SDK declares it as a bare string (enum constraint dropped)" + ); + } + } + + return problems; +} diff --git a/scripts/api-sync/types.ts b/scripts/api-sync/types.ts new file mode 100644 index 0000000..d2a5e24 --- /dev/null +++ b/scripts/api-sync/types.ts @@ -0,0 +1,126 @@ +export type SchemaLocator = { + schema: string; + property?: string; + itemsEnum?: boolean; +}; + +export type PathLocator = { + path: string; + method: string; + location: "requestBody" | "response:200" | "parameters"; +}; + +export type SpecLocator = SchemaLocator | PathLocator; + +export function isPathLocator(loc: SpecLocator): loc is PathLocator { + return "path" in loc; +} + +export type SdkSite = { + file: string; + symbol: string; +}; + +export type EnumMapEntry = { + spec: SchemaLocator; + sdk: SdkSite[]; + notes?: string | null; +}; + +export type TypeMapEntry = { + spec: SpecLocator; + sdk: SdkSite[]; + notes?: string | null; +}; + +export type IgnoreEntry = { + name: string; + reason: string; +}; + +export type SpecMap = { + notes?: string[]; + enums: EnumMapEntry[]; + types: TypeMapEntry[]; + ignore: { + schemas: IgnoreEntry[]; + }; +}; + +export type UnmodeledPropertyEntry = { + kind: "property"; + schema: string; + field: string; + property?: string; + reason: string; + owner: string; +}; + +export type KnownDivergenceEnumEntry = { + kind: "enum"; + schema: string; + enumProperty: string; + member?: string; + reason: string; + owner: string; +}; + +export type KnownDivergencePropertyEntry = { + kind: "property"; + schema: string; + field: string; + property?: string; + reason: string; + owner: string; +}; + +export type KnownDivergenceTypeMismatchEntry = { + kind: "type-mismatch"; + schema: string; + field: string; + reason: string; + owner: string; +}; + +export type KnownDivergenceEntry = + | KnownDivergenceEnumEntry + | KnownDivergencePropertyEntry + | KnownDivergenceTypeMismatchEntry; + +export type UnmodeledFile = { + properties: UnmodeledPropertyEntry[]; + knownDivergences: KnownDivergenceEntry[]; +}; + +export type ApplicableChange = + | { + kind: "enum-insert"; + schema: string; + property: string; + member: string; + site: SdkSite; + } + | { + kind: "field-insert"; + schema: string; + property?: string; + field: string; + tsType: string; + site: SdkSite; + }; + +export type NeedsHumanIssue = { + message: string; +}; + +export type BumpType = "minor" | "patch" | "none"; + +export type SyncReport = { + applied: string[]; + needsHuman: string[]; + knownDivergences: string[]; + bump: BumpType; + previousVersion: string | null; + newVersion: string | null; + coverageGaps: string[]; +}; diff --git a/scripts/api-sync/version.test.ts b/scripts/api-sync/version.test.ts new file mode 100644 index 0000000..9ab068a --- /dev/null +++ b/scripts/api-sync/version.test.ts @@ -0,0 +1,57 @@ +import { describe, expect, it } from "vitest"; +import { + bumpSemver, + classifyBump, + patchPackageJsonVersion, + readPackageJsonVersion, +} from "./version"; + +describe("classifyBump", () => { + it("is minor when any enum member was inserted", () => { + expect(classifyBump(true, false, false)).toBe("minor"); + expect(classifyBump(true, true, false)).toBe("minor"); + }); + + it("is patch when only field additions were inserted", () => { + expect(classifyBump(false, true, false)).toBe("patch"); + }); + + it("is none when nothing was applied", () => { + expect(classifyBump(false, false, false)).toBe("none"); + }); +}); + +describe("bumpSemver", () => { + it("bumps minor and resets patch to 0", () => { + expect(bumpSemver("5.0.1", "minor")).toBe("5.1.0"); + }); + + it("bumps patch only", () => { + expect(bumpSemver("5.0.1", "patch")).toBe("5.0.2"); + }); + + it("leaves the version unchanged for none", () => { + expect(bumpSemver("5.0.1", "none")).toBe("5.0.1"); + }); + + it("throws on an unparseable version", () => { + expect(() => bumpSemver("5.0", "minor")).toThrow(); + }); +}); + +describe("patchPackageJsonVersion", () => { + it("replaces only the version field, byte-for-byte elsewhere", () => { + const raw = + '{\n "name": "@blindpay/node",\n "version": "5.0.1",\n "other": true\n}\n'; + const patched = patchPackageJsonVersion(raw, "5.1.0"); + expect(patched).toBe( + '{\n "name": "@blindpay/node",\n "version": "5.1.0",\n "other": true\n}\n' + ); + }); + + it("round-trips through readPackageJsonVersion", () => { + const raw = '{"version": "1.2.3"}'; + expect(readPackageJsonVersion(raw)).toBe("1.2.3"); + expect(readPackageJsonVersion(patchPackageJsonVersion(raw, "1.3.0"))).toBe("1.3.0"); + }); +}); diff --git a/scripts/api-sync/version.ts b/scripts/api-sync/version.ts new file mode 100644 index 0000000..0aed6b7 --- /dev/null +++ b/scripts/api-sync/version.ts @@ -0,0 +1,44 @@ +import type { BumpType } from "./types"; + +/** + * minor: any enum member was inserted this run, or (never reachable in practice, since a new + * operation always hard-fails as needs-human before a bump is computed, kept only so the + * rule stated by the design is total, not because it can currently fire) an operation changed. + * patch: only field additions were inserted. + * none: nothing was applied. + * major is never automatic: removals hard-fail long before this point. + */ +export function classifyBump( + hasEnumInsert: boolean, + hasFieldInsert: boolean, + hasOperationChange: boolean +): BumpType { + if (hasOperationChange || hasEnumInsert) return "minor"; + if (hasFieldInsert) return "patch"; + return "none"; +} + +export function bumpSemver(version: string, bump: BumpType): string { + const match = /^(\d+)\.(\d+)\.(\d+)$/.exec(version); + if (!match) throw new Error(`cannot parse semver "${version}"`); + const major = Number(match[1]); + const minor = Number(match[2]); + const patch = Number(match[3]); + if (bump === "minor") return `${major}.${minor + 1}.0`; + if (bump === "patch") return `${major}.${minor}.${patch + 1}`; + return version; +} + +/** Targeted regex replace, never a JSON.parse/stringify round trip: preserves every other byte of package.json. */ +export function patchPackageJsonVersion(raw: string, newVersion: string): string { + const re = /"version":\s*"\d+\.\d+\.\d+"/; + if (!re.test(raw)) + throw new Error('could not find a plain-semver "version" field in package.json'); + return raw.replace(re, `"version": "${newVersion}"`); +} + +export function readPackageJsonVersion(raw: string): string { + const match = /"version":\s*"(\d+\.\d+\.\d+)"/.exec(raw); + if (!match) throw new Error('could not find a plain-semver "version" field in package.json'); + return match[1]; +} diff --git a/src/resources/quotes/index.ts b/src/resources/quotes/index.ts index e0563e0..1f0ba2a 100644 --- a/src/resources/quotes/index.ts +++ b/src/resources/quotes/index.ts @@ -16,6 +16,7 @@ export type CreateQuoteInput = { token: StablecoinToken; description?: string | null; partner_fee_id?: string | null; + refund_wallet_address?: string | null; }; export type CreateQuoteResponse = { id: string; diff --git a/src/resources/virtual-accounts/index.ts b/src/resources/virtual-accounts/index.ts index cd77136..60d8a15 100644 --- a/src/resources/virtual-accounts/index.ts +++ b/src/resources/virtual-accounts/index.ts @@ -1,7 +1,7 @@ import type { BlindpayApiResponse, KycStatus, Network, StablecoinToken } from "../../../types"; import type { InternalApiClient } from "../../internal/api-client"; -export type BankingPartner = "jpmorgan" | "citi" | "hsbc" | "cfsb"; +export type BankingPartner = "jpmorgan" | "citi" | "hsbc" | "cfsb" | "portage"; export type SoleProprietorDocType = "master_service_agreement" | "salary_slip" | "bank_statement"; diff --git a/types/index.d.ts b/types/index.d.ts index 2561d41..4d711fb 100644 --- a/types/index.d.ts +++ b/types/index.d.ts @@ -551,7 +551,7 @@ export type SourceOfWealth = | "inheritance" | "affiliate_or_royalty_income"; -export type BankingPartner = "jpmorgan" | "citi" | "hsbc" | "cfsb"; +export type BankingPartner = "jpmorgan" | "citi" | "hsbc" | "cfsb" | "portage"; export type SoleProprietorDocType = "master_service_agreement" | "salary_slip" | "bank_statement"; From 9dbf026796c2ddbb9b61105d7ebafc3541e02273 Mon Sep 17 00:00:00 2001 From: Eric Viana Date: Mon, 3 Aug 2026 21:30:10 -0300 Subject: [PATCH 2/3] fix(api-sync): guard snapshot refresh against copying a file onto itself copyFileSync(src, dest) with src === dest (e.g. running --apply --spec .api-sync/spec-snapshot.json, as the determinism-proof CI step does) truncated the destination before reading the identical source, deleting the snapshot. Extracted the refresh into its own module with a same-path no-op guard, and added tests. Claude-Session: https://claude.ai/code/session_01F1stiNzuNtJXoXtiW9ZCbs --- scripts/api-sync/index.ts | Bin 8352 -> 7952 bytes scripts/api-sync/snapshot.test.ts | 53 ++++++++++++++++++++++++++++++ scripts/api-sync/snapshot.ts | 18 ++++++++++ 3 files changed, 71 insertions(+) create mode 100644 scripts/api-sync/snapshot.test.ts create mode 100644 scripts/api-sync/snapshot.ts diff --git a/scripts/api-sync/index.ts b/scripts/api-sync/index.ts index 885c2c1028686001756b921a73e50a7c6a3cef1b..b9d27f997dd2b48634a6db7623d2f246c5ff7385 100644 GIT binary patch delta 71 zcmZ4BIKgg$%tkFPCZ(d(w4&7FjNrV)g5r$)5`|iYw4(f61tmTGVyMt&4W<(k{AlVn Jub27A3;>yq8leCH delta 485 zcmYk3F-`+95JjcXf#{Kf#P29bHWCFw6iA_K*Tp>r`3Meg? z;s(s_Y9(A)p3!{0Ki~WNulfG`R65^0YD3G$%E|ZmWOR5N{haM)Gb|_%6icL%5UudB zbO~FX`i>%_Hxx)AdXkqB^dV%zs!0^@5J;&0J;65UL_=YTMaSU7gs0`pbLK^egsZ-Y zDQH{heAi@{Be>csaLcm^E{YO@=ipXrVpgEsg-WE@SmTU*Y;`8?A9Y$M%(s>SN2SmG1gS<|4 z;Fvf>ye?RvU`T9QaT}j>Ri#sE!y1CL4#qJlbjeJ$qTU3OV_y#<4S*F>l^4x)bB$PP bA0c9w`Bc^+uqs { + if (dir) rmSync(dir, { recursive: true, force: true }); + dir = null; +}); + +describe("refreshSnapshot", () => { + it("leaves the snapshot byte-identical to the source spec file", () => { + dir = mkdtempSync(join(tmpdir(), "api-sync-snapshot-")); + const specPath = join(dir, "spec-current.json"); + const snapshotPath = join(dir, "spec-snapshot.json"); + const content = '{\n "openapi": "3.1.0",\n "info": {\n "title": "x"\n }\n}\n'; + writeFileSync(specPath, content); + writeFileSync(snapshotPath, '{"stale": true}'); + + refreshSnapshot(specPath, snapshotPath); + + expect(readFileSync(snapshotPath, "utf8")).toBe(content); + expect(readFileSync(snapshotPath)).toEqual(readFileSync(specPath)); + }); + + it("is a safe no-op when --spec already points at the snapshot itself", () => { + dir = mkdtempSync(join(tmpdir(), "api-sync-snapshot-")); + const snapshotPath = join(dir, "spec-snapshot.json"); + const content = '{"a": 1}\n'; + writeFileSync(snapshotPath, content); + + expect(() => refreshSnapshot(snapshotPath, snapshotPath)).not.toThrow(); + expect(readFileSync(snapshotPath, "utf8")).toBe(content); + }); + + it("does not reformat: byte-for-byte, not a JSON.parse + JSON.stringify round trip", () => { + dir = mkdtempSync(join(tmpdir(), "api-sync-snapshot-")); + const specPath = join(dir, "spec-current.json"); + const snapshotPath = join(dir, "spec-snapshot.json"); + // Deliberately unusual (but valid) JSON formatting: a JSON.stringify round trip would + // normalize this away, so a byte-for-byte copy is the only way to preserve it. + const content = '{"a":1, "b": 2,\r\n"c":3}'; + writeFileSync(specPath, content); + writeFileSync(snapshotPath, "{}"); + + refreshSnapshot(specPath, snapshotPath); + + expect(readFileSync(snapshotPath, "utf8")).toBe(content); + }); +}); diff --git a/scripts/api-sync/snapshot.ts b/scripts/api-sync/snapshot.ts new file mode 100644 index 0000000..4f93ed4 --- /dev/null +++ b/scripts/api-sync/snapshot.ts @@ -0,0 +1,18 @@ +import { copyFileSync } from "node:fs"; +import { resolve } from "node:path"; + +/** + * Refreshes the committed snapshot with the delivered spec's exact bytes: read bytes, write + * bytes, never a JSON.parse + JSON.stringify round trip (that reformats the file in this + * language's own JSON style even when semantically unchanged, producing a huge diff on every + * future sync PR and leaving the committed snapshot no longer byte-identical to what + * blindpay-v2 ships as spec-current.json). + * + * A no-op when `specPath` already IS `snapshotPath` (e.g. a determinism proof run against the + * committed baseline itself): copying a file onto itself is never useful, and some copyfile() + * implementations truncate the destination before reading the identical source, destroying it. + */ +export function refreshSnapshot(specPath: string, snapshotPath: string): void { + if (resolve(specPath) === resolve(snapshotPath)) return; + copyFileSync(specPath, snapshotPath); +} From 5ebed1c3a815c5c6a82eb3cf42dfc06b21b9311f Mon Sep 17 00:00:00 2001 From: Eric Viana Date: Mon, 3 Aug 2026 21:36:26 -0300 Subject: [PATCH 3/3] fix(api-sync): default non-apply modes to the committed snapshot, merge with the PAT sync:check as documented in package.json failed for anyone running it locally: it defaulted to spec-current.json, which only exists on the api-sync-data branch, while CI passed --spec explicitly. Check, coverage and audit assert against committed state, so the snapshot is the correct default. A sync PR merged with GITHUB_TOKEN produces a push that starts no workflow, so the merge would land on main and never publish to npm. Claude-Session: https://claude.ai/code/session_01F1stiNzuNtJXoXtiW9ZCbs --- .github/workflows/api-sync.yml | 5 ++++- package.json | 4 ++-- scripts/api-sync/index.ts | Bin 7952 -> 8250 bytes 3 files changed, 6 insertions(+), 3 deletions(-) diff --git a/.github/workflows/api-sync.yml b/.github/workflows/api-sync.yml index cb6082f..0fda453 100644 --- a/.github/workflows/api-sync.yml +++ b/.github/workflows/api-sync.yml @@ -96,10 +96,13 @@ jobs: "$(jq -r '.applied[] | "- " + .' /tmp/sync-report.json)")" git push --force-with-lease origin api-sync + # The PAT, not GITHUB_TOKEN: a merge performed with GITHUB_TOKEN produces a + # push that starts no workflow, so the sync would land on main and never + # publish to npm. - name: Create or update PR if: steps.commit.outcome == 'success' env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + GH_TOKEN: ${{ secrets.SDK_SYNC_PAT }} run: | BUMP="${{ steps.sync.outputs.bump }}" VERSION="${{ steps.sync.outputs.new_version }}" diff --git a/package.json b/package.json index 1212218..1753c4a 100644 --- a/package.json +++ b/package.json @@ -55,8 +55,8 @@ "test:coverage": "vitest run --coverage", "sync": "bun scripts/api-sync/index.ts --apply", "sync:check": "bun scripts/api-sync/index.ts --check", - "sync:coverage": "bun scripts/api-sync/index.ts --coverage --spec .api-sync/spec-snapshot.json", - "audit:types": "bun scripts/api-sync/index.ts --audit-types --spec .api-sync/spec-snapshot.json" + "sync:coverage": "bun scripts/api-sync/index.ts --coverage", + "audit:types": "bun scripts/api-sync/index.ts --audit-types" }, "exports": { ".": { diff --git a/scripts/api-sync/index.ts b/scripts/api-sync/index.ts index b9d27f997dd2b48634a6db7623d2f246c5ff7385..41d73c392ad5dfe156bc4e1a5706f3246fa96159 100644 GIT binary patch delta 286 zcmYk0u}Z{16h(1aSWvOC5G3ejJHhPsD={|qf`zTm$(R{QX2!g?YAOp`1tE=}p=@XG zhxjK>qJ^(}7w);|_4xDT{eIkCCduk!{55)<&vAOEn^uf;RK|mo?Er?k+!l06@ zV}bH@ZAEke77~33_7J1NjshDmu)L0V5!uI$Esdbi*R-r;0S;QFYS}c}$?S%`gLhSX z5hpFjk@)u|3O41|sk9&=Ts7pBnGICsdxt;Vd4wSw@_w=Qg+`WTm=5Ms!tzUyP5`#QP_r1Hl`44umdYL@!`~a1kV0!=n delta 31 ncmdnxFu`s^Im_e}9>vL4972qmn|oPKGfgh$5Z}C=qgfmPt