@kashdao/cli 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (85) hide show
  1. package/CHANGELOG.md +78 -0
  2. package/CONTRIBUTING.md +159 -0
  3. package/LICENSE +21 -0
  4. package/README.md +868 -0
  5. package/SECURITY.md +110 -0
  6. package/dist/account-AVFLEM5D.js +90 -0
  7. package/dist/account-AVFLEM5D.js.map +1 -0
  8. package/dist/auth-GUJCVKTD.js +239 -0
  9. package/dist/auth-GUJCVKTD.js.map +1 -0
  10. package/dist/chunk-BN2CUM42.js +9 -0
  11. package/dist/chunk-BN2CUM42.js.map +1 -0
  12. package/dist/chunk-BRK7KJ4O.js +154 -0
  13. package/dist/chunk-BRK7KJ4O.js.map +1 -0
  14. package/dist/chunk-KMBMQIZ7.js +393 -0
  15. package/dist/chunk-KMBMQIZ7.js.map +1 -0
  16. package/dist/chunk-LBIRQHX5.js +133 -0
  17. package/dist/chunk-LBIRQHX5.js.map +1 -0
  18. package/dist/chunk-MIXOZU2S.js +366 -0
  19. package/dist/chunk-MIXOZU2S.js.map +1 -0
  20. package/dist/chunk-QJMF73M5.js +129 -0
  21. package/dist/chunk-QJMF73M5.js.map +1 -0
  22. package/dist/chunk-UZNSYATZ.js +680 -0
  23. package/dist/chunk-UZNSYATZ.js.map +1 -0
  24. package/dist/chunk-VIADBYFY.js +94 -0
  25. package/dist/chunk-VIADBYFY.js.map +1 -0
  26. package/dist/chunk-YHCG2SUC.js +159 -0
  27. package/dist/chunk-YHCG2SUC.js.map +1 -0
  28. package/dist/chunk-YJX3JJ4M.js +174 -0
  29. package/dist/chunk-YJX3JJ4M.js.map +1 -0
  30. package/dist/client-IOM55ZCS.js +14 -0
  31. package/dist/client-IOM55ZCS.js.map +1 -0
  32. package/dist/completion-ZZGX5BQC.js +100 -0
  33. package/dist/completion-ZZGX5BQC.js.map +1 -0
  34. package/dist/config-XJL5TSYK.js +690 -0
  35. package/dist/config-XJL5TSYK.js.map +1 -0
  36. package/dist/config-store-3ZSYGXMQ.js +44 -0
  37. package/dist/config-store-3ZSYGXMQ.js.map +1 -0
  38. package/dist/docs-MDKKSJDC.js +102 -0
  39. package/dist/docs-MDKKSJDC.js.map +1 -0
  40. package/dist/eoa-IQ72EIHR.js +551 -0
  41. package/dist/eoa-IQ72EIHR.js.map +1 -0
  42. package/dist/errors-WMZIEGQI.js +18 -0
  43. package/dist/errors-WMZIEGQI.js.map +1 -0
  44. package/dist/explain-H4EH3KH3.js +165 -0
  45. package/dist/explain-H4EH3KH3.js.map +1 -0
  46. package/dist/global-options-XOLJUPTT.js +18 -0
  47. package/dist/global-options-XOLJUPTT.js.map +1 -0
  48. package/dist/health-VZEIII74.js +98 -0
  49. package/dist/health-VZEIII74.js.map +1 -0
  50. package/dist/help-footer-GTANVDNP.js +43 -0
  51. package/dist/help-footer-GTANVDNP.js.map +1 -0
  52. package/dist/index.d.ts +2 -0
  53. package/dist/index.js +251 -0
  54. package/dist/index.js.map +1 -0
  55. package/dist/intro-PQQTYWR7.js +41 -0
  56. package/dist/intro-PQQTYWR7.js.map +1 -0
  57. package/dist/markets-C37JZDPE.js +295 -0
  58. package/dist/markets-C37JZDPE.js.map +1 -0
  59. package/dist/output-VCBZ3FM7.js +22 -0
  60. package/dist/output-VCBZ3FM7.js.map +1 -0
  61. package/dist/portfolio-2AEPIJIG.js +115 -0
  62. package/dist/portfolio-2AEPIJIG.js.map +1 -0
  63. package/dist/protocol-XEQXF2GW.js +1572 -0
  64. package/dist/protocol-XEQXF2GW.js.map +1 -0
  65. package/dist/quote-I4DVJ7ZB.js +144 -0
  66. package/dist/quote-I4DVJ7ZB.js.map +1 -0
  67. package/dist/schema-PIFQ65TS.js +410 -0
  68. package/dist/schema-PIFQ65TS.js.map +1 -0
  69. package/dist/setup-USZ6IODD.js +260 -0
  70. package/dist/setup-USZ6IODD.js.map +1 -0
  71. package/dist/stdin-YW2CEQXU.js +28 -0
  72. package/dist/stdin-YW2CEQXU.js.map +1 -0
  73. package/dist/trace-IZBYTUFO.js +103 -0
  74. package/dist/trace-IZBYTUFO.js.map +1 -0
  75. package/dist/trade-IIZSEXEI.js +586 -0
  76. package/dist/trade-IIZSEXEI.js.map +1 -0
  77. package/dist/version-JDK3PEP5.js +117 -0
  78. package/dist/version-JDK3PEP5.js.map +1 -0
  79. package/dist/version-check-TDCION37.js +138 -0
  80. package/dist/version-check-TDCION37.js.map +1 -0
  81. package/dist/webhooks-PTVRKICZ.js +680 -0
  82. package/dist/webhooks-PTVRKICZ.js.map +1 -0
  83. package/dist/with-retry-4FIZG3A7.js +223 -0
  84. package/dist/with-retry-4FIZG3A7.js.map +1 -0
  85. package/package.json +99 -0
@@ -0,0 +1,18 @@
1
+ #!/usr/bin/env node
2
+ import {
3
+ CliConfigurationError,
4
+ CliError,
5
+ CliValidationError,
6
+ EXIT_CODES,
7
+ isCommanderError,
8
+ toCliError
9
+ } from "./chunk-UZNSYATZ.js";
10
+ export {
11
+ CliConfigurationError,
12
+ CliError,
13
+ CliValidationError,
14
+ EXIT_CODES,
15
+ isCommanderError,
16
+ toCliError
17
+ };
18
+ //# sourceMappingURL=errors-WMZIEGQI.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":[],"sourcesContent":[],"mappings":"","names":[]}
@@ -0,0 +1,165 @@
1
+ #!/usr/bin/env node
2
+ import {
3
+ createTable,
4
+ truncate
5
+ } from "./chunk-BRK7KJ4O.js";
6
+ import {
7
+ readGlobals
8
+ } from "./chunk-LBIRQHX5.js";
9
+ import {
10
+ print,
11
+ printJson,
12
+ style
13
+ } from "./chunk-VIADBYFY.js";
14
+ import "./chunk-MIXOZU2S.js";
15
+ import {
16
+ CliError,
17
+ ERROR_CATALOG,
18
+ lookupErrorCode
19
+ } from "./chunk-UZNSYATZ.js";
20
+
21
+ // src/commands/explain.ts
22
+ import { Command } from "commander";
23
+
24
+ // src/api-error-bundle.generated.ts
25
+ var API_ERROR_DOCS = Object.freeze({
26
+ "ACTIVE_KEY_LIMIT_REACHED": '# `ACTIVE_KEY_LIMIT_REACHED`\n\n**HTTP status:** 409 \xB7 **Title:** "Active key limit reached"\n\n## When it fires\n\nThe user has hit `MAX_ACTIVE_KEYS_PER_USER` (5) and `POST /api/account/api-keys` in the webapp (or `kash-admin api-keys issue`) was called to issue another. The cap is enforced both at the application layer and via the `trg_api_keys_enforce_max_active_per_user` BEFORE INSERT trigger (which closes a TOCTOU window that the app-level check alone leaves open).\n\n## Why it happens\n\n- A genuine ceiling \u2014 most users only need 2-3 keys (one per environment, maybe a CI key). The cap exists to prevent runaway issuance from compromising audit hygiene.\n- A bot that issues a fresh key per run instead of reusing one (anti-pattern).\n\n## How to fix\n\n- Revoke an existing key first \u2014 Settings \u2192 API Keys \u2192 Revoke (or `kash-admin api-keys revoke <id>`).\n- Reuse keys across environments where appropriate: one `kash_live_\u2026` for production code, one `kash_test_\u2026` for everything else.\n- For mm/enterprise tiers, the cap is higher \u2014 contact support if your usage genuinely needs more.\n\n## Related codes\n\n- [`API_KEY_REVOKED`](./API_KEY_REVOKED.md) \u2014 what happens after revocation if you keep using the old key\n',
27
+ "AMOUNT_TOO_LARGE": '# `AMOUNT_TOO_LARGE`\n\n**HTTP status:** 400 \xB7 **Title:** "Amount too large"\n\n## When it fires\n\n`amount` exceeds `MAX_USDC_PER_TRADE`. This is a defence-in-depth check distinct from your key\'s per-trade and daily spending limits \u2014 it catches obvious mistakes regardless of your tier.\n\n## Why it happens\n\nThe most common cause is a **decimal-place mistake**: passing `1000000000000000000` ("1 USDC in 18-decimal atomic units") instead of `1` ("1 USDC in human units"). The public API takes amounts in **human units** (the same string a person would type), not 18-decimal atomic units.\n\n## How to fix\n\n- Pass `amount` as a string of the human-unit USDC value: `"100"`, `"0.50"`, `"2500"`.\n- If you have an atomic value, divide by 10^6 (USDC is 6 decimals on-chain, but our API normalises to "USDC the user thinks about").\n- For sustained high-value flows above `MAX_USDC_PER_TRADE`, request a tier upgrade \u2014 your key\'s `per_trade_limit_usdc` can be raised, but the absolute cap is a platform-level safety net.\n\n## Related codes\n\n- [`SPENDING_LIMIT_EXCEEDED`](./SPENDING_LIMIT_EXCEEDED.md) \u2014 your key\'s per-trade or daily limit, separate from the platform cap\n- [`VALIDATION_FAILED`](./VALIDATION_FAILED.md) \u2014 generic validation failure\n',
28
+ "API_KEY_EXPIRED": '# `API_KEY_EXPIRED`\n\n**HTTP status:** 401 \xB7 **Title:** "API key expired"\n\n## When it fires\n\nThe key has a non-null `expires_at` and the current time is past it.\n\n## Why it happens\n\n- You requested a time-limited key at issuance (recommended for CI keys, contractor access, demos).\n- An operator set an expiry on a long-lived key during a security review.\n- The key is unchanged; only the wall clock moved.\n\n## How to fix\n\n- Issue a new key via Settings \u2192 API Keys (self-service, free/developer tiers) or `kash-admin api-keys issue` (mm/enterprise tiers).\n- For automated rotation, schedule a rotation job that issues + activates the new key 24 h before the old one expires.\n- The new key will have a different `id` and prefix; update every consumer.\n\n## Related codes\n\n- [`API_KEY_REVOKED`](./API_KEY_REVOKED.md) \u2014 explicit operator action, distinct from time-based expiry\n',
29
+ "API_KEY_INVALID": '# `API_KEY_INVALID`\n\n**HTTP status:** 401 \xB7 **Title:** "Authentication required"\n\n## When it fires\n\nThe `X-API-Key` header is well-formed but no row in the API key store matches its prefix or HMAC.\n\n## Why it happens\n\n- The key was deleted (not just revoked \u2014 see [`API_KEY_REVOKED`](./API_KEY_REVOKED.md) for that distinct case).\n- A character was modified in transit or storage (e.g., copy-paste truncation that still happened to land on the right length).\n- Wrong environment \u2014 a `kash_test_\u2026` key sent to production, or vice versa. Production never accepts test keys.\n- The HMAC pepper has been rotated and the rotation grace period has elapsed for an old issuance.\n\n## How to fix\n\n- Compare the stored value to what the webapp Settings \u2192 API Keys page shows for the prefix (`kash_live_xxxx\u2026`).\n- If the prefix doesn\'t match anything in your account, the key was never yours or has been deleted \u2014 issue a new one.\n- If the prefix matches but auth still fails, contact support with the prefix and your `X-Request-ID` from a recent failed call.\n\n## Related codes\n\n- [`API_KEY_MISSING`](./API_KEY_MISSING.md), [`API_KEY_MALFORMED`](./API_KEY_MALFORMED.md) \u2014 earlier failure points\n- [`API_KEY_REVOKED`](./API_KEY_REVOKED.md) \u2014 key existed but was explicitly revoked\n- [`API_KEY_EXPIRED`](./API_KEY_EXPIRED.md) \u2014 key existed but is past `expires_at`\n',
30
+ "API_KEY_MALFORMED": '# `API_KEY_MALFORMED`\n\n**HTTP status:** 401 \xB7 **Title:** "Authentication required"\n\n## When it fires\n\nThe `X-API-Key` header is present but does not match the expected `kash_(live|test)_<32 alphanumeric>` shape.\n\n## Why it happens\n\n- A leading or trailing whitespace character (e.g., from a copy-paste with a trailing newline).\n- The wrong env var was substituted (e.g., a Privy token or a bare UUID instead of a Kash API key).\n- Truncation by a logging or proxy layer that capped header values.\n\n## How to fix\n\n- The format is `kash_<env>_<32 alphanumeric>` (base62 \u2014 `A-Z`, `a-z`, `0-9`, no dashes or underscores after the prefix). The `<env>` segment is `live` or `test`.\n- `live` keys hit production; `test` keys hit staging. Don\'t mix.\n- If you stored the key in an env var, double-check the substitution: `echo $KASH_API_KEY | head -c 16` should print `kash_live_\u2026` or `kash_test_\u2026`.\n\n## Related codes\n\n- [`API_KEY_MISSING`](./API_KEY_MISSING.md) \u2014 no header at all\n- [`API_KEY_INVALID`](./API_KEY_INVALID.md) \u2014 well-formed but unknown\n',
31
+ "API_KEY_MISSING": "# `API_KEY_MISSING`\n\n**HTTP status:** 401 \xB7 **Title:** \"Authentication required\"\n\n## When it fires\n\nA route that requires authentication received a request with no `X-API-Key` header.\n\n## Why it happens\n\n- The request was sent without the header (most common \u2014 first integration).\n- A reverse proxy or HTTP client stripped the header.\n- The wrong base URL \u2014 the documented endpoints live under `/v1/*`; some other route may not require auth.\n\n## How to fix\n\n```http\nGET /v1/portfolio HTTP/1.1\nHost: api.kash.bot\nX-API-Key: kash_live_<your-key-here>\n```\n\n- Pass the key on every authenticated request. Keys are issued via the webapp Settings \u2192 API Keys page or `kash-admin api-keys issue`.\n- The plaintext is shown once at issuance. If you've lost it, revoke and re-issue.\n\n## Related codes\n\n- [`API_KEY_MALFORMED`](./API_KEY_MALFORMED.md) \u2014 header present but doesn't match the `kash_(live|test)_*` shape\n- [`API_KEY_INVALID`](./API_KEY_INVALID.md) \u2014 header well-formed but no row matches\n",
32
+ "API_KEY_REVOKED": '# `API_KEY_REVOKED`\n\n**HTTP status:** 401 \xB7 **Title:** "API key revoked"\n\n## When it fires\n\nThe key is well-formed and recognised, but its `revoked_at` column is set. The auth middleware fails closed.\n\n## Why it happens\n\n- A user (you or a teammate) explicitly revoked the key via Settings \u2192 API Keys \u2192 Revoke.\n- An operator revoked the key via `kash-admin api-keys revoke <id>` (e.g., suspected leak).\n- A security automation revoked it (e.g., per-key velocity anomaly tripped a kill switch).\n\n## How to fix\n\n- If revocation was unintentional, ask the operator who ran it (the audit log records who/when/why).\n- If revocation was intentional and you need to keep working, **issue a new key**. Revoked keys cannot be un-revoked \u2014 that\'s the security property.\n- Update every place the old key was stored (env vars, secret stores, CI configs) before the new key goes live.\n\n## Related codes\n\n- [`API_KEY_INVALID`](./API_KEY_INVALID.md) \u2014 key was deleted, not revoked\n- [`API_KEY_EXPIRED`](./API_KEY_EXPIRED.md) \u2014 key reached its `expires_at` (no operator action)\n',
33
+ "API_TRADE_PROCESSING_HALTED": '# `API_TRADE_PROCESSING_HALTED`\n\n**HTTP status:** 503 \xB7 **Title:** "Service unavailable" \xB7 **Retry-After:** 300s\n\n## When it fires\n\nThe coarse `API_TRADE_PROCESSING` kill switch is active. Ops uses this to halt all trade-write traffic platform-wide during incidents (e.g., RPC outage, oracle failure, security investigation).\n\nRead endpoints (markets, portfolio, trade lookup) stay available throughout \u2014 only the write path is blocked.\n\n## Why it happens\n\n- Active incident under operator response.\n- Pre-deploy lockdown for a high-risk migration.\n- A security-driven freeze in response to anomaly detection.\n\n## How to fix\n\n- Honour `Retry-After: 300` and back off. Do NOT poll faster \u2014 the kill switch is binary, polling won\'t unblock you sooner.\n- Subscribe to the status page or our incident webhook to know when service resumes.\n- In the meantime, your read paths still work \u2014 surface a "trading temporarily paused" banner in your UI rather than failing silently.\n\n## Related codes\n\n- [`ROUTE_DISABLED`](./ROUTE_DISABLED.md) \u2014 single-route kill switch (more granular than the coarse halt)\n- [`DEPENDENCY_UNAVAILABLE`](./DEPENDENCY_UNAVAILABLE.md) \u2014 specific dependency rather than platform-wide\n',
34
+ "API_VERSION_UNSUPPORTED": '# `API_VERSION_UNSUPPORTED`\n\n**HTTP status:** 410 \xB7 **Title:** "API version unsupported"\n\n## When it fires\n\nYour request sent an `X-Kash-Api-Version` header whose value is not in\n`SUPPORTED_PUBLIC_API_VERSIONS`. The runtime accepts only\ndate-string values that the API has actively published as a supported\ncontract version. Unrecognized values \u2014 typos, future-dated guesses,\nversions long past their sunset window \u2014 return this error.\n\n## Why 410?\n\n`410 Gone` is the right semantic: the version was either once\naccepted (an old date past its 12-month deprecation window) or never\nhas been (a typo, a future date). Either way, sending the request\nagain with the same value will not succeed. `400` would imply the\nclient could fix syntax and retry without changing the value \u2014 which\nis not the case here. `406 Not Acceptable` is closer but is reserved\nfor content-type negotiation.\n\n## Why it happens\n\n- **Typo in the pin** \u2014 sent `2026-04-30` when the canonical version\n is `2026-04-29`.\n- **Pinned to a sunsetted version** \u2014 the date you pinned against\n passed its 12-month `Sunset` window and was removed from the\n supported set.\n- **Pinned to a future version** \u2014 your client was upgraded with a\n version pin that the production API hasn\'t shipped yet (canary\n drift between client release and server deploy).\n- **Multi-valued header confusion** \u2014 a load balancer or proxy\n appended a second `X-Kash-Api-Version` value (`,`-joined) and the\n runtime correctly refuses to silently pick one.\n\n## How to recover\n\nThe response body\'s `metadata.supported` field carries the full list\nof accepted versions, in canonical-default-first order:\n\n```json\n{\n "type": "https://docs.kash.bot/developer-docs/api-errors/API_VERSION_UNSUPPORTED",\n "title": "API version unsupported",\n "status": 410,\n "code": "API_VERSION_UNSUPPORTED",\n "detail": "Requested API version \'2099-01-01\' is not supported. Send no header to use the current default (2026-04-29), or pick a value from the \'supported\' list.",\n "instance": "/v1/markets",\n "requestId": "01HQGY8K2N3X4Z5C6D7E8F9G0H",\n "requested": "2099-01-01",\n "supported": ["2026-04-29"],\n "current": "2026-04-29"\n}\n```\n\nPick a value from `supported` and resend the request. The simplest\nrecovery is to **drop the header** entirely and let the runtime\ndefault to `current` \u2014 that\'s the recommended consumer mode unless\nyou have a specific reason to pin.\n\n## Migration policy\n\nWhen the API ships a new version, the previous version stays in\n`SUPPORTED_PUBLIC_API_VERSIONS` for **12 months** (per AD-15 / RFC\n8594). During that window, every response from the deprecated\nversion carries `Sunset: <date>` and `Deprecation: true` headers so\nyour monitoring can flag the impending expiry well before it hits.\n\nIf you\'re seeing this error on a date that is older than 12 months\nsince its `Sunset` was advertised, that\'s working as intended \u2014 it\'s\ntime to update the pin.\n\n## Best practice\n\nDon\'t pin if you don\'t have to. The default contract (no header)\nruns against the current `PUBLIC_API_VERSION`, and we promise\nadditive, non-breaking changes within a date version. Pin only when\nyou\'re locking down a release for compliance or contract-test\nreasons.\n\nIf you do pin, follow the registry: read\n`https://api.kash.bot/v1/openapi.json` (or the live OpenAPI route)\nand let your client library auto-select the highest version it\nrecognises.\n\n## See also\n\n- `apps/public-api/src/plugins/api-version.ts` \u2014 the negotiation\n plugin source.\n- `packages/constants/src/public-api-version.ts` \u2014 the canonical\n registry.\n- `apps/public-api/openapi/index.json` \u2014 the snapshot index, listing\n every published version.\n- `docs/runbooks/hmac-algorithm-rotation.md` \u2014 example of a version\n bump driven by a security event.\n',
35
+ "CLIENT_REQUEST_ID_CONFLICT": '# `CLIENT_REQUEST_ID_CONFLICT`\n\n**HTTP status:** 409 \xB7 **Title:** "Client request id conflict"\n\n## When it fires\n\nThe same `(user_id, clientRequestId)` body pair was used for an earlier trade with a **different body**. The trade row is unique on `(user_id, clientRequestId)` once you opt in.\n\n## Why it happens\n\n- Power-user mistake: `clientRequestId` is a body field with stricter conflict semantics than the HTTP `Idempotency-Key` header. Most callers should use the header; opt into `clientRequestId` only if you specifically want trade-row-scoped dedup.\n- A bug regenerated the body but kept the `clientRequestId` constant.\n\n## How to fix\n\n- For most use cases, drop `clientRequestId` and use only `Idempotency-Key`. See `apps/public-api/README.md` \xA7 Idempotency.\n- If you need `clientRequestId`\'s row-scoped dedup, generate a fresh value per logical operation.\n- The conflicting trade row is referenced in the `detail` field \u2014 fetch it via `GET /v1/trades/:id` to see what was previously submitted.\n\n## Related codes\n\n- [`IDEMPOTENCY_KEY_CONFLICT`](./IDEMPOTENCY_KEY_CONFLICT.md) \u2014 same idea, header-level\n',
36
+ "CONFIRMATION_EXPIRED": '# `CONFIRMATION_EXPIRED`\n\n**HTTP status:** 409 \xB7 **Title:** "Confirmation expired"\n\n## When it fires\n\nThe confirmation token is past its `confirmation_expires_at`. High-value trades have a short confirmation window (60 seconds by default; configurable per-tier).\n\n## Why it happens\n\n- A human-in-the-loop confirmation flow took longer than the window allows.\n- A queue/worker latency between `POST /v1/trades` returning the token and the consumer calling confirm.\n\n## How to fix\n\n- Tighten the consumer\'s confirm call \u2014 ideally fire it immediately after receiving the token.\n- For human-in-the-loop flows that need longer, request a per-key window extension (mm tier customers who pipeline trades through human review get longer windows).\n- The trade is automatically cancelled when the window expires \u2014 re-create it to retry.\n\n## Related codes\n\n- [`TRADE_NOT_AWAITING_CONFIRMATION`](./TRADE_NOT_AWAITING_CONFIRMATION.md), [`CONFIRMATION_TOKEN_INVALID`](./CONFIRMATION_TOKEN_INVALID.md)\n',
37
+ "CONFIRMATION_TOKEN_INVALID": "# `CONFIRMATION_TOKEN_INVALID`\n\n**HTTP status:** 409 \xB7 **Title:** \"Confirmation token invalid\"\n\n## When it fires\n\nThe token you sent in `POST /v1/trades/:id/confirm` doesn't match the stored hash for that trade.\n\n## Why it happens\n\n- Wrong trade id \u2014 the token is one-time and tied to a specific trade.\n- Token was truncated or modified in transit/storage.\n- Replay against a different trade than the one that issued the token.\n\nThe token plaintext is **never persisted server-side** \u2014 only the HMAC. We compare your candidate via `timingSafeEqual` to prevent per-byte timing attacks.\n\n## How to fix\n\n- Re-confirm that the trade id in the URL matches the trade id from the create response.\n- Capture the token verbatim from `confirmation.token` in the create response \u2014 the SDK does this automatically; raw HTTP callers must preserve case and length (43 chars, base64url).\n- If you've lost the token (only shown once in the create response), the trade can't be confirmed \u2014 wait for it to auto-expire and re-create.\n\n## Related codes\n\n- [`CONFIRMATION_EXPIRED`](./CONFIRMATION_EXPIRED.md), [`CONFIRMATION_TOKEN_USED`](./CONFIRMATION_TOKEN_USED.md), [`TRADE_NOT_AWAITING_CONFIRMATION`](./TRADE_NOT_AWAITING_CONFIRMATION.md)\n",
38
+ "CONFIRMATION_TOKEN_USED": "# `CONFIRMATION_TOKEN_USED`\n\n**HTTP status:** 409 \xB7 **Title:** \"Confirmation token used\"\n\n## When it fires\n\nThe token has already been redeemed. Confirmation tokens are **one-time use** \u2014 the first successful confirm clears the stored hash so a replay can't re-fire the trade.\n\n## Why it happens\n\n- A retry layer that doesn't check the trade's status before re-confirming (most common).\n- Two parallel confirmation attempts from the same consumer (race condition in the caller).\n- A double-click in a human-in-the-loop UI.\n\n## How to fix\n\n- Check the trade's status via `GET /v1/trades/:id` \u2014 if it's anything past `pending_confirmation`, the confirm already succeeded.\n- Make your retry loop idempotent: poll trade status first, only confirm if `status === 'pending_confirmation'`.\n- For UI flows: disable the confirm button immediately on click to prevent double-submission.\n\n## Related codes\n\n- [`TRADE_NOT_AWAITING_CONFIRMATION`](./TRADE_NOT_AWAITING_CONFIRMATION.md) \u2014 same outcome via different state-check path\n",
39
+ "DEPENDENCY_UNAVAILABLE": '# `DEPENDENCY_UNAVAILABLE`\n\n**HTTP status:** 503 \xB7 **Title:** "Service unavailable" \xB7 **Retry-After:** 30s\n\n## When it fires\n\nA required external dependency (Postgres, Secrets Manager, Redis, blockchain RPC) is unreachable, and the route can\'t degrade further.\n\nDistinct from [`INTERNAL_ERROR`](./INTERNAL_ERROR.md) (which is "we don\'t know what happened") and [`ROUTE_DISABLED`](./ROUTE_DISABLED.md) (which is "ops disabled this on purpose").\n\n## Why it happens\n\n- Transient infrastructure blip (most common \u2014 clears in seconds).\n- Sustained dependency outage under operator response.\n- A rare cascade where one dependency degraded and another can\'t compensate.\n\n## How to fix\n\n- Honour `Retry-After: 30` and back off with jitter. Most occurrences resolve within one retry.\n- If retries keep failing past 5 minutes, check the status page.\n- The TS SDK handles this automatically (`KashServerError`, retried with backoff up to its retry budget).\n\n## Related codes\n\n- [`API_TRADE_PROCESSING_HALTED`](./API_TRADE_PROCESSING_HALTED.md) \u2014 ops-driven, not infra-driven\n- [`REQUEST_TIMEOUT`](./REQUEST_TIMEOUT.md), [`INTERNAL_ERROR`](./INTERNAL_ERROR.md)\n',
40
+ "IDEMPOTENCY_KEY_CONFLICT": '# `IDEMPOTENCY_KEY_CONFLICT`\n\n**HTTP status:** 409 \xB7 **Title:** "Idempotency key conflict"\n\n## When it fires\n\nThe same `Idempotency-Key` was used for two requests with **different bodies**. The server refuses to either re-execute (would violate idempotency) or replay the cached response (would mislead the caller about which request "won").\n\n## Why it happens\n\n- Most common: a retry layer regenerated the body on each attempt instead of capturing it once and replaying.\n- A bug in the caller that mutates the body in-place between the request being prepared and the request being sent.\n- Two distinct logical operations accidentally sharing a key (e.g., a global key generator that didn\'t account for concurrency).\n\n## How to fix\n\n- Pin the request body before generating the idempotency key. Either:\n - Capture body + key together in your retry wrapper and re-send the exact pair on each attempt.\n - Generate a fresh key per logical operation (one trade = one key).\n- For genuinely retryable bodies that legitimately change between attempts (e.g., updated timestamps), generate a fresh key per attempt \u2014 that\'s the contract.\n\n## Related codes\n\n- [`CLIENT_REQUEST_ID_CONFLICT`](./CLIENT_REQUEST_ID_CONFLICT.md) \u2014 analogous conflict, but on the body-level `clientRequestId` field rather than the HTTP header\n- [`IDEMPOTENCY_KEY_EXPIRED`](./IDEMPOTENCY_KEY_EXPIRED.md) \u2014 key valid earlier, now past TTL\n',
41
+ "IDEMPOTENCY_KEY_EXPIRED": '# `IDEMPOTENCY_KEY_EXPIRED`\n\n**HTTP status:** 410 \xB7 **Title:** "Idempotency key expired"\n\n## When it fires\n\nThe `Idempotency-Key` you sent matches a row in the idempotency store but that row is past its 24-hour TTL.\n\n## Why it happens\n\n- A retry that took longer than 24 hours to fire (e.g., a job that stalled in a queue and retried days later).\n- An offline-first client that buffered the request locally and only got online after the TTL.\n\nThe 410 (rather than re-executing or returning the cached response) is intentional: by 24 h, the original response is gone and the system gives you a clear "this key is no longer valid" signal so you can decide whether the operation is still desired.\n\n## How to fix\n\n- Generate a fresh `Idempotency-Key` for the retry.\n- Before retrying, check whether the original operation actually completed \u2014 query `GET /v1/trades/:id` (using a `clientRequestId` you stored, if applicable) so you don\'t double-execute.\n- For long-tail retries, prefer the body-level `clientRequestId` field over the header \u2014 it has the same conflict semantics but no TTL.\n\n## Related codes\n\n- [`IDEMPOTENCY_KEY_CONFLICT`](./IDEMPOTENCY_KEY_CONFLICT.md), [`CLIENT_REQUEST_ID_CONFLICT`](./CLIENT_REQUEST_ID_CONFLICT.md)\n',
42
+ "IDEMPOTENCY_KEY_FORMAT_INVALID": "# `IDEMPOTENCY_KEY_FORMAT_INVALID`\n\n**HTTP status:** 400 \xB7 **Title:** \"Idempotency key format invalid\"\n\n## When it fires\n\n`Idempotency-Key` header contains a character outside the allowed set: `[A-Za-z0-9_\\-:.]`.\n\n## Why it happens\n\n- The key contained whitespace, a slash, or a non-ASCII character.\n- Control characters (newlines, NULs) sneaked in from a copy-paste.\n- An emoji or Unicode separator accidentally landed in the key.\n\nThe strict allowlist exists because the value flows through structured logs, Redis keys, and PG rows \u2014 non-printable / control characters could poison log shippers or downstream systems that key on the value.\n\n## How to fix\n\n- Stick to UUIDs (`crypto.randomUUID()` in Node, `uuidgen` in shell) \u2014 they're always conforming.\n- If you must derive the key from external input, sanitise: `key.replace(/[^A-Za-z0-9_\\-:.]/g, '')` then check it's still unique.\n\n## Related codes\n\n- [`IDEMPOTENCY_KEY_TOO_LONG`](./IDEMPOTENCY_KEY_TOO_LONG.md), [`IDEMPOTENCY_KEY_CONFLICT`](./IDEMPOTENCY_KEY_CONFLICT.md)\n",
43
+ "IDEMPOTENCY_KEY_TOO_LONG": '# `IDEMPOTENCY_KEY_TOO_LONG`\n\n**HTTP status:** 400 \xB7 **Title:** "Idempotency key too long"\n\n## When it fires\n\n`Idempotency-Key` header value exceeds the 255-character cap.\n\n## Why it happens\n\n- You concatenated several identifiers into the key (user id + market id + timestamp + nonce) and the result blew past 255 chars.\n- A library generated an unusually long token (e.g., a JWT instead of a UUID).\n\n## How to fix\n\n- Use a UUID v4 (`uuidgen`, `crypto.randomUUID()`) or a ULID \u2014 both are 26\u201336 chars and unique enough, well under the 255 cap.\n- If you need to embed semantic information, hash it: `sha256(your-blob).slice(0, 32)` instead of the raw concatenation.\n- Acceptable character set: `[A-Za-z0-9_\\-:.]` \u2014 see [`IDEMPOTENCY_KEY_FORMAT_INVALID`](./IDEMPOTENCY_KEY_FORMAT_INVALID.md).\n\n## Related codes\n\n- [`IDEMPOTENCY_KEY_FORMAT_INVALID`](./IDEMPOTENCY_KEY_FORMAT_INVALID.md) \u2014 disallowed characters\n- [`IDEMPOTENCY_KEY_CONFLICT`](./IDEMPOTENCY_KEY_CONFLICT.md) \u2014 same key, different body\n',
44
+ "INSUFFICIENT_BALANCE": "# `INSUFFICIENT_BALANCE`\n\n**HTTP status:** 409 \xB7 **Title:** \"Insufficient balance\"\n\n## When it fires\n\nThe actor's smart-account USDC balance is below the requested `amount`.\n\n## Why it happens\n\n- You haven't funded the account yet.\n- A previous trade consumed more than expected (gas + slippage) and left a sub-threshold balance.\n- The auto-funding pipeline is backed up \u2014 common during very high-throughput windows.\n- Org account: trade is being charged to the user but the org policy expected the org to pay (or vice versa). Check `feePayerType`.\n\n## How to fix\n\n- Check the current balance via `GET /v1/portfolio` \u2014 `usdcBalance` field.\n- For users: fund via the webapp's onramp flow (or transfer USDC into the smart account directly).\n- For orgs: the org admin tops up the org wallet.\n- For staging/sandbox: use the testnet faucet flow.\n\n## Related codes\n\n- [`SMART_ACCOUNT_NOT_PROVISIONED`](./SMART_ACCOUNT_NOT_PROVISIONED.md), [`SPENDING_LIMIT_EXCEEDED`](./SPENDING_LIMIT_EXCEEDED.md)\n",
45
+ "INSUFFICIENT_SCOPE": "# `INSUFFICIENT_SCOPE`\n\n**HTTP status:** 403 \xB7 **Title:** \"Insufficient scope\"\n\n## When it fires\n\nThe key authenticated successfully but lacks one of the scopes the route requires.\n\n## Why it happens\n\n- The key was issued with a narrower scope set than the route needs (e.g., a `markets:read` key calling `POST /v1/trades` which requires `trades:write`).\n- A new endpoint was added that requires a scope your existing key doesn't carry.\n\n## How to fix\n\n- Look up the route's required scopes in `apps/public-api/README.md` \u2192 Authentication \u2192 Scopes table.\n- Issue a new key with the needed scopes (or revoke + re-issue with a broader scope set).\n- Principle of least privilege: don't add scopes you don't actually need \u2014 narrow keys reduce blast radius if leaked.\n\n| Scope | Routes |\n| ----------------- | ----------------------------------------------------- |\n| `markets:read` | `GET /v1/markets*`, `GET /v1/markets/:id/predictions` |\n| `markets:quote` | `GET /v1/markets/:id/quote` |\n| `trades:read` | `GET /v1/trades(/:id)` |\n| `trades:write` | `POST /v1/trades`, `POST /v1/trades/:id/confirm` |\n| `portfolio:read` | `GET /v1/portfolio*` |\n| `webhooks:manage` | webhook URL/secret rotation, replay endpoint |\n| `auth:manage` | self-service key CRUD |\n\n## Related codes\n\n- [`IP_NOT_ALLOWED`](./IP_NOT_ALLOWED.md) \u2014 also 403, but driven by IP allowlist rather than scope\n",
46
+ "INTERNAL_ERROR": "# `INTERNAL_ERROR`\n\n**HTTP status:** 500 \xB7 **Title:** \"Internal Server Error\"\n\n## When it fires\n\nThe error handler caught something it didn't recognise. The actual cause is logged + Sentry-captured server-side; nothing leaks to the caller.\n\nThis is the catch-all \u2014 any other code in this catalogue is a structured, anticipated failure. `INTERNAL_ERROR` means we hit something we hadn't categorised.\n\n## Why it happens\n\n- A genuinely unexpected condition (the most common reason this fires).\n- A new code path that hasn't been instrumented with a typed error yet \u2014 please report it so we can add a proper code.\n\n## How to fix\n\n- **Always include `requestId` in any support ticket.** It's our handle into the trace, the logs, and the Sentry capture.\n- Retry once \u2014 if it was a transient bug, you may get past it.\n- If it's reproducible from a specific request, tell us the exact request shape (with secrets redacted) and we'll trace it.\n- The TS SDK exposes `requestId` on `KashServerError`; raw HTTP callers should pull it from the `requestId` field on the problem response.\n\n## Related codes\n\nEvery other code in this catalogue is preferable \u2014 if you see `INTERNAL_ERROR` consistently for one operation, the right fix is for us to add a new typed code, not for you to work around it.\n",
47
+ "IP_NOT_ALLOWED": "# `IP_NOT_ALLOWED`\n\n**HTTP status:** 403 \xB7 **Title:** \"IP not allowed\"\n\n## When it fires\n\nThe key has a non-empty `ip_allowlist` configured AND the request's source IP isn't in it.\n\nEmpty allowlist (the default) means any IP is allowed \u2014 this code only fires when an allowlist is configured AND the caller's IP doesn't match.\n\n## Why it happens\n\n- The key is locked to a specific egress IP (common for MM and enterprise tier keys) and the call originated from elsewhere \u2014 your laptop, a CI runner, a proxy with a different egress.\n- The egress IP changed (cloud provider rotated NAT gateway, your home ISP's dynamic IP rolled).\n\n## How to fix\n\n- Find the request's IP in the `requestId`'s Loki logs \u2014 search `requestId=\"<value>\"`, the structured log carries the source IP.\n- If the new IP is legitimate, add it to the key's allowlist via Settings \u2192 API Keys \u2192 Edit (or `kash-admin api-keys` ops command).\n- If it's not, this code is a successful detection of an exfiltration attempt \u2014 investigate.\n\n## Related codes\n\n- [`INSUFFICIENT_SCOPE`](./INSUFFICIENT_SCOPE.md) \u2014 also 403, scope rather than IP\n",
48
+ "MARKET_NOT_FOUND": "# `MARKET_NOT_FOUND`\n\n**HTTP status:** 404 \xB7 **Title:** \"Market not found\"\n\n## When it fires\n\nThe `marketId` (UUID) you passed doesn't match any market.\n\n## Why it happens\n\n- Typo in the market id.\n- Cross-environment confusion: a staging market id sent to production, or vice versa. Markets do not exist across environments.\n- The market was never created \u2014 you may have a placeholder id from documentation rather than a real one.\n\n## How to fix\n\n- Look up real market ids via `GET /v1/markets?status=active` (filterable by status).\n- Cache the id once you've found it; markets are immutable in their identity.\n- If you suspect the market should exist (e.g., you saw it in the webapp), confirm you're hitting the same environment.\n\n## Related codes\n\n- [`MARKET_NOT_TRADEABLE`](./MARKET_NOT_TRADEABLE.md) \u2014 market exists but is FROZEN/RESOLVED\n",
49
+ "MARKET_NOT_TRADEABLE": "# `MARKET_NOT_TRADEABLE`\n\n**HTTP status:** 409 \xB7 **Title:** \"Market not tradeable\"\n\n## When it fires\n\nThe market exists but is in a state that doesn't accept trades \u2014 typically `FROZEN` (admin paused trading) or `RESOLVED` (outcome decided, only redemptions allowed).\n\n## Why it happens\n\n- Market reached its `resolve_time` and froze automatically.\n- An admin froze the market manually (e.g., pending an oracle dispute).\n- The market was resolved between your quote call and your trade call.\n\n## How to fix\n\n- Check the market's current `status` via `GET /v1/markets/:id` before retrying.\n- If the market was just resolved, your existing positions are still redeemable (handled automatically by the payout pipeline) \u2014 you don't need to do anything.\n- For freeze-then-resume scenarios, retry once the market reopens; subscribe to the `market.resumed` webhook (when streaming lands) to know exactly when.\n\n## Related codes\n\n- [`MARKET_NOT_FOUND`](./MARKET_NOT_FOUND.md), [`OUTCOME_INDEX_INVALID`](./OUTCOME_INDEX_INVALID.md)\n",
50
+ "OUTCOME_INDEX_INVALID": '# `OUTCOME_INDEX_INVALID`\n\n**HTTP status:** 400 \xB7 **Title:** "Outcome index invalid"\n\n## When it fires\n\n`outcomeIndex` is greater than or equal to the market\'s outcome count, or negative.\n\n## Why it happens\n\n- You hard-coded `outcomeIndex: 1` (assuming a binary market) and pointed at a multi-outcome market.\n- Off-by-one: the field is **zero-indexed** \u2014 first outcome is `0`, not `1`.\n- A bug computed the index from a label without bounds-checking.\n\n## How to fix\n\n- Look up the market via `GET /v1/markets/:id` and check `outcomes.length` \u2014 valid range is `[0, length - 1]`.\n- Map outcome labels to indices once when you fetch the market, then use the index throughout your code.\n\n## Related codes\n\n- [`MARKET_NOT_FOUND`](./MARKET_NOT_FOUND.md), [`MARKET_NOT_TRADEABLE`](./MARKET_NOT_TRADEABLE.md), [`VALIDATION_FAILED`](./VALIDATION_FAILED.md)\n',
51
+ "RATE_LIMIT_EXCEEDED": "# `RATE_LIMIT_EXCEEDED`\n\n**HTTP status:** 429 \xB7 **Title:** \"Too Many Requests\"\n\n## When it fires\n\nPer-user (authenticated) or per-IP (anonymous reads) rate limit was exceeded. Limits are enforced as a sliding window in Redis.\n\nFor authenticated requests, the limit is **tier-differentiated** \u2014 60 req/min on `free`, 300 req/min on `developer`, custom (admin-set) on `enterprise` and `mm`. See [`docs/api-tiers.md`](../api-tiers.md) for the full matrix.\n\n## Response headers\n\nEvery 429 response carries:\n\n- `Retry-After: <seconds>` \u2014 wait at least this long before the next request\n- `X-RateLimit-Limit: <n>` \u2014 the limit that applied\n- `X-RateLimit-Remaining: 0` \u2014 confirms you're capped\n- `X-RateLimit-Reset: <unix-timestamp>` \u2014 when the window resets\n\n## Why it happens\n\n- Burst traffic spike (e.g., a polling loop without backoff).\n- A retry storm \u2014 every consumer retried at the same instant after a transient failure.\n- Genuine sustained traffic above your tier's quota.\n\n## How to fix\n\n- Honour `Retry-After`. The TS SDK does this automatically (`KashRateLimitError.retryAfterSeconds`).\n- Add jitter to retry timing so multiple consumers don't synchronise.\n- For polling, switch to webhooks \u2014 every poll is a wasted request.\n- If you consistently bump against the limit during normal operation, upgrade your tier \u2014 see [`docs/api-tiers.md`](../api-tiers.md) for the upgrade paths. The 60 \u2192 300 req/min jump from `free` \u2192 `developer` is 5\xD7, and `enterprise`/`mm` have no application-level cap.\n\n## Related codes\n\n- [`RATE_LIMIT_UNAVAILABLE`](./RATE_LIMIT_UNAVAILABLE.md) \u2014 503; the rate-limit subsystem itself is temporarily unavailable. Distinct from `EXCEEDED`: `UNAVAILABLE` means we couldn't check your quota; `EXCEEDED` means we checked and you're over.\n- [`WEBHOOK_REPLAY_LIMIT_REACHED`](./WEBHOOK_REPLAY_LIMIT_REACHED.md) \u2014 also 429, but specific to the webhook replay endpoint's amplification cap\n",
52
+ "RATE_LIMIT_UNAVAILABLE": "# `RATE_LIMIT_UNAVAILABLE`\n\n**HTTP status:** 503 \xB7 **Title:** \"Rate limit subsystem unavailable\" \xB7 **Retry-After:** 1s\n\n## When it fires\n\nThe rate-limit subsystem (Redis-backed token bucket / sliding window) is temporarily unavailable, and the per-task circuit breaker is still in its CLOSED state \u2014 i.e., this is a transient Redis blip, not a sustained outage.\n\nThe API fails CLOSED here because your rate limit IS your per-tier quota (a paid product feature). Serving traffic without enforcing the cap during a blip would silently leak quota to free-tier callers. The safe-for-business default is to refuse the request, surface a typed retryable error, and let your client retry.\n\nThis is the **transient blip** counterpart to the sustained-outage [`DEPENDENCY_UNAVAILABLE`](./DEPENDENCY_UNAVAILABLE.md) \u2014 same family, different policy: a transient blip in the limiter is fail-CLOSED (we know the limit must apply but can't check), while a sustained outage causes the circuit breaker to open and the API to fail-OPEN (serve traffic without quota enforcement, paged to ops).\n\n## Why it happens\n\n- A momentary ElastiCache Redis network blip \u2014 typically clears in `<100ms`.\n- Pool acquisition contention under burst \u2014 a Lua call queued for a free connection past the per-call timeout.\n- A momentary failover during a Redis cluster maintenance window.\n\nIf Redis stays unhealthy past the circuit breaker's failure threshold (10 consecutive failures), the breaker OPENs and requests start passing through fail-OPEN instead of returning 503. Ops gets paged on `kash_public_api_rate_limit_redis_circuit_state == 2`.\n\n## How to fix\n\n- **Honour `Retry-After: 1`.** The TS SDK's default retry policy already does this \u2014 it auto-retries 5xx responses with exponential backoff, so most blips are invisible to your application code. If you've disabled SDK retries (`maxRetries: 0`), wire your own backoff loop.\n- **Don't treat this as quota exhaustion.** Distinct from [`RATE_LIMIT_EXCEEDED`](./RATE_LIMIT_EXCEEDED.md) (429): `EXCEEDED` is \"you're over your tier's cap\"; `UNAVAILABLE` is \"we couldn't check, so we won't risk leaking your cap.\" Your dashboards / billing UI should NOT surface `RATE_LIMIT_UNAVAILABLE` as a quota event.\n- If you see a sustained rate of these in your client logs (>1/min over several minutes), that's the threshold where Kash ops should already be paged \u2014 file a bug at https://github.com/KashDAO/sdk-typescript/issues with the requestId if you want a status check.\n\n## Response headers\n\n- `Retry-After: 1` \u2014 Stripe-style short retry; SDK respects it automatically.\n- `X-API-Version`, `X-Request-Id` \u2014 standard cross-cutting headers.\n- The `X-RateLimit-*` family is NOT emitted on a 503 (we don't have a quota answer to attach).\n\n## Related codes\n\n- [`RATE_LIMIT_EXCEEDED`](./RATE_LIMIT_EXCEEDED.md) \u2014 429, you went over your quota.\n- [`DEPENDENCY_UNAVAILABLE`](./DEPENDENCY_UNAVAILABLE.md) \u2014 503, infrastructure-level dependency outage outside the rate-limit path.\n",
53
+ "REQUEST_SIGNATURE_INVALID": '# `REQUEST_SIGNATURE_INVALID`\n\n**HTTP status:** 401 \xB7 **Title:** "Request signature invalid"\n\n## When it fires\n\n`X-Kash-Signature` was present but did not verify against the API key plaintext over the canonical signing input `${ts}.${method}.${path}.${body}`.\n\nSigning is **opt-in**: requests without `X-Kash-Signature` skip this check entirely. Once present, it MUST verify.\n\n## Why it happens\n\n- The body was modified in transit (a transparent proxy that re-encoded JSON, an HTTP client that renormalised whitespace).\n- The timestamp is outside the \xB15 min tolerance (clock skew).\n- The signature was computed over the wrong input \u2014 common mistake: signing the URL instead of the path-only, or omitting the leading slash on `path`.\n- The wrong secret was used (e.g., the `webhook_secret` instead of the API key plaintext).\n\n## How to fix\n\n- Canonical input format: `${unixMillis}.${UPPERCASE_METHOD}.${pathWithLeadingSlash}.${rawBody}` (raw body bytes, not re-serialised JSON).\n- Key the HMAC with the API key plaintext (the same value as `X-API-Key`).\n- Header format: `X-Kash-Signature: t=<unixMillis>,v1=<hexHmacSha256>`.\n- Sync your clock \u2014 NTP. If you\'re consistently 6+ minutes off, fix the host clock.\n- Check the `signatureReason` extension field on the problem response \u2014 it pinpoints which check failed (`stale`, `mismatch`, `malformed`).\n\nSee `apps/public-api/README.md` \xA7 Optional request body signing for the full reference.\n\n## Related codes\n\n- [`API_KEY_MISSING`](./API_KEY_MISSING.md) / [`API_KEY_INVALID`](./API_KEY_INVALID.md) \u2014 earlier failure points\n',
54
+ "REQUEST_TIMEOUT": '# `REQUEST_TIMEOUT`\n\n**HTTP status:** 504 \xB7 **Title:** "Gateway timeout" \xB7 **Retry-After:** 5s\n\n## When it fires\n\nThe per-request server timeout fired (default 30 s). Bounded by `bodyLimit` + Fastify timeout config to keep slow upstreams from blocking healthy traffic.\n\n## Why it happens\n\n- An unusually slow downstream call (Postgres lock contention, RPC latency spike, etc.).\n- A request that does too much work in one call \u2014 most reads complete in <100 ms.\n- Network path degradation between you and our edge (rare but possible).\n\n## How to fix\n\n- Retry once with backoff. Most timeouts are transient.\n- If you\'re consistently timing out on the same endpoint, narrow the request \u2014 pass `limit` parameters, fetch one resource at a time.\n- For long-running operations, use webhooks instead of waiting on the response.\n\n## Related codes\n\n- [`DEPENDENCY_UNAVAILABLE`](./DEPENDENCY_UNAVAILABLE.md), [`INTERNAL_ERROR`](./INTERNAL_ERROR.md)\n',
55
+ "RESOURCE_NOT_FOUND": "# `RESOURCE_NOT_FOUND`\n\n**HTTP status:** 404 \xB7 **Title:** \"Not Found\"\n\n## When it fires\n\nA request for a specific resource (trade, key, webhook event, etc.) didn't find a matching row that the caller is authorised to see.\n\nThis is the generic catch-all 404. Specific resource types may have their own dedicated codes (e.g., [`MARKET_NOT_FOUND`](./MARKET_NOT_FOUND.md)) where there's value in distinguishing the resource type to the caller.\n\n## Why it happens\n\n- The id is wrong (typo, cross-environment confusion).\n- The resource exists but belongs to a different user \u2014 we return 404 (not 403) deliberately, so attackers can't probe for the existence of resources they don't own.\n- The resource was deleted.\n\n## How to fix\n\n- Re-check the id (capitalisation, hyphens, environment).\n- If the resource should exist but you can't see it, confirm you're authenticated as the owner.\n- For webhook events: events are retained for 90 days; older events return 404.\n\n## Related codes\n\n- [`MARKET_NOT_FOUND`](./MARKET_NOT_FOUND.md) \u2014 specific case for markets\n",
56
+ "ROUTE_DISABLED": '# `ROUTE_DISABLED`\n\n**HTTP status:** 503 \xB7 **Title:** "Route disabled" \xB7 **Retry-After:** 60s\n\n## When it fires\n\nA single route is disabled by ops via a per-route kill switch. Distinct from the coarse [`API_TRADE_PROCESSING_HALTED`](./API_TRADE_PROCESSING_HALTED.md) so SDK consumers can branch on "my entire trading capability is offline" vs "just one endpoint."\n\nThe Problem detail\'s `extensions.flag` field carries the kill-switch name (e.g., `"api-quotes-read"`) so you can tell exactly which route is gated.\n\n## Why it happens\n\n- Targeted rollback after a route-specific bug shipped.\n- Pre-deploy lockdown of one endpoint while the rest of the API stays live.\n- Capacity-driven shedding (rare \u2014 usually we\'d raise rate limits first).\n\n## How to fix\n\n- Honour `Retry-After`. The flag is binary; polling won\'t help.\n- Read the `extensions.flag` field \u2014 if it\'s an endpoint you don\'t actually need, ignore it; other endpoints are fine.\n- For mission-critical endpoints, contact support to understand the ETA.\n\n## Related codes\n\n- [`API_TRADE_PROCESSING_HALTED`](./API_TRADE_PROCESSING_HALTED.md) \u2014 coarser kill switch covering all trade writes\n- [`DEPENDENCY_UNAVAILABLE`](./DEPENDENCY_UNAVAILABLE.md) \u2014 distinct: this is "ops chose to disable", that\'s "infra is broken"\n',
57
+ "SMART_ACCOUNT_NOT_PROVISIONED": "# `SMART_ACCOUNT_NOT_PROVISIONED`\n\n**HTTP status:** 409 \xB7 **Title:** \"Smart account not provisioned\"\n\n## When it fires\n\nThe actor (user or organization) doesn't have a smart account row yet, so trades can't execute on-chain.\n\n## Why it happens\n\n- New account that hasn't completed the wallet-creation step in the webapp.\n- An organization that's enabled the API but hasn't provisioned its own org-level smart account.\n- The smart-account worker is backlogged \u2014 provisioning is async.\n\n## How to fix\n\n- For users: complete the wallet setup flow in the webapp (Settings \u2192 Wallet). Smart account provisioning is async \u2014 typically a few seconds.\n- For orgs: an admin needs to provision the org smart account via the org settings page.\n- If you provisioned recently and still see this error, retry after 30 s. If it persists past a minute, contact support with your `requestId`.\n\n## Related codes\n\n- [`WALLET_DELEGATION_NOT_ENABLED`](./WALLET_DELEGATION_NOT_ENABLED.md) \u2014 smart account exists but Privy delegation is off\n- [`INSUFFICIENT_BALANCE`](./INSUFFICIENT_BALANCE.md) \u2014 smart account provisioned but no USDC\n",
58
+ "SPENDING_LIMIT_EXCEEDED": "# `SPENDING_LIMIT_EXCEEDED`\n\n**HTTP status:** 409 \xB7 **Title:** \"Spending limit exceeded\"\n\n## When it fires\n\nThe trade would exceed one of the API key's spending caps:\n\n- `per_trade_limit_usdc` \u2014 single-trade maximum\n- `daily_spend_limit_usdc` \u2014 rolling 24-hour cumulative maximum across all trades\n\nThe `detail` field specifies which cap fired and by how much.\n\n## Why it happens\n\n- A bot iteration that computed a larger order than expected (e.g., signal scaling factor was off).\n- Cumulative drift over a busy day finally pushed a moderate trade over the daily cap.\n- The cap was lowered (by you or an admin) since you last designed your sizing logic.\n\n## How to fix\n\n- Inspect your key's caps in Settings \u2192 API Keys \u2192 Edit.\n- For per-trade overruns: split the trade into smaller chunks under the cap.\n- For daily overruns: wait for the rolling window to clear, or upgrade your tier (see \"How to raise the cap\" below).\n- Programmatically: every key's caps are returned in the issuance response \u2014 cache them so your client knows the headroom.\n\n## How to raise the cap\n\nThe cap is set by your API key's tier. See [`docs/api-tiers.md`](../api-tiers.md) for the full tier matrix and rationale, then:\n\n- **`free` \u2192 `developer`** (10\xD7 headroom \u2014 $1k \u2192 $10k daily, $500 \u2192 $2.5k per-trade): email [support@kash.bot](mailto:support@kash.bot) for early access. Self-serve via Stripe is planned ([`docs/todo/public-surface/PUBLIC_API_TIER_BILLING.md`](../todo/PUBLIC_API_TIER_BILLING.md)).\n- **`developer` \u2192 `enterprise`** ($100k daily, $25k per-trade, custom rate limit): [contact sales](mailto:sales@kash.bot) \u2014 pricing is contractual.\n- **MM-tier counterparty terms** ($1M daily, $50k per-trade): [contact partnerships](mailto:partnerships@kash.bot) \u2014 KYB and ToS attestation required; this is a relationship, not a price tier.\n\nThe caps are not punitive \u2014 they exist for blast-radius and AMM-stability reasons documented in [`docs/api-tiers.md`](../api-tiers.md). When you outgrow your tier, the cap is the signal to upgrade, not a problem to work around.\n\n## Related codes\n\n- [`AMOUNT_TOO_LARGE`](./AMOUNT_TOO_LARGE.md) \u2014 platform-wide cap, separate from your key's caps\n- [`INSUFFICIENT_BALANCE`](./INSUFFICIENT_BALANCE.md) \u2014 wallet balance too low (different failure surface)\n",
59
+ "TRADE_NOT_AWAITING_CONFIRMATION": "# `TRADE_NOT_AWAITING_CONFIRMATION`\n\n**HTTP status:** 409 \xB7 **Title:** \"Trade not awaiting confirmation\"\n\n## When it fires\n\n`POST /v1/trades/:id/confirm` was called for a trade that's not in `pending_confirmation` status.\n\n## Why it happens\n\n- The trade was already confirmed (you'll see this if you accidentally call confirm twice) \u2014 see also [`CONFIRMATION_TOKEN_USED`](./CONFIRMATION_TOKEN_USED.md).\n- The trade was below your tier's high-value threshold and never required confirmation in the first place.\n- The trade has progressed past confirmation into `pending` / `executing` / a terminal state.\n- The confirmation window expired and the trade was auto-cancelled \u2014 see [`CONFIRMATION_EXPIRED`](./CONFIRMATION_EXPIRED.md).\n\n## How to fix\n\n- Check the trade's current `status` via `GET /v1/trades/:id`. The lifecycle is: `pending_confirmation` \u2192 `pending` \u2192 `executing` \u2192 `completed`/`failed`.\n- If the trade is `completed`, there's nothing more to do.\n- If the trade is `cancelled`, re-create it and confirm within the new window.\n\n## Related codes\n\n- [`CONFIRMATION_EXPIRED`](./CONFIRMATION_EXPIRED.md), [`CONFIRMATION_TOKEN_INVALID`](./CONFIRMATION_TOKEN_INVALID.md), [`CONFIRMATION_TOKEN_USED`](./CONFIRMATION_TOKEN_USED.md)\n",
60
+ "VALIDATION_FAILED": '# `VALIDATION_FAILED`\n\n**HTTP status:** 400 \xB7 **Title:** "Validation failed"\n\n## When it fires\n\nThe request body failed Zod schema parse. One or more fields are missing, malformed, or out of range.\n\n## Why it happens\n\n- Required field is missing or `null`.\n- Field has the wrong type (e.g., `amount` sent as a number instead of a string \u2014 we use string for arbitrary-precision USDC amounts).\n- Field violates a constraint (UUID format, enum value, min/max length).\n- An unknown extra field was sent \u2014 we accept extras (`.passthrough()`) so this is rarely the cause; if it is, you\'ll see it called out in `detail`.\n\n## How to fix\n\n- The `detail` field carries the path of the first offending field (e.g., `body.outcomeIndex must be a non-negative integer`).\n- Cross-reference with the OpenAPI spec at `https://api.kash.bot/v1/openapi.json` \u2014 every endpoint\'s request body is fully typed there.\n- If you\'re using the TS SDK, you\'d have caught this client-side via `KashValidationError` before the request went out.\n\n## Example\n\n```json\n{\n "type": "https://docs.kash.bot/developer-docs/api-errors/VALIDATION_FAILED",\n "title": "Validation failed",\n "status": 400,\n "code": "VALIDATION_FAILED",\n "detail": "body.amount must be a string matching ^[0-9]+(\\\\.[0-9]+)?$",\n "instance": "/v1/trades",\n "requestId": "01HX-..."\n}\n```\n\n## Related codes\n\n- [`AMOUNT_TOO_LARGE`](./AMOUNT_TOO_LARGE.md) \u2014 specific case for `amount` exceeding the per-trade cap\n- [`OUTCOME_INDEX_INVALID`](./OUTCOME_INDEX_INVALID.md) \u2014 specific case for invalid `outcomeIndex`\n',
61
+ "WALLET_DELEGATION_NOT_ENABLED": "# `WALLET_DELEGATION_NOT_ENABLED`\n\n**HTTP status:** 409 \xB7 **Title:** \"Wallet delegation not enabled\"\n\n## When it fires\n\nA smart account exists for the actor, but Privy server-side delegation is disabled. The platform can't sign user-operations on behalf of the user, so trades can't execute.\n\nWe surface this at the API layer as a preflight check rather than failing at execution \u2014 better UX for the consumer.\n\n## Why it happens\n\n- The user revoked delegation in Privy after onboarding.\n- A Privy app config change disabled delegation for a class of users.\n- An incomplete onboarding flow that skipped the delegation grant step.\n\n## How to fix\n\n- The user must re-grant delegation via the webapp (Settings \u2192 Wallet \u2192 Re-enable). One-click; uses the existing Privy session.\n- For orgs, the org admin re-grants on behalf of the org wallet.\n- If the issue is platform-wide (multiple users), check the Privy app config or the status page.\n\n## Related codes\n\n- [`SMART_ACCOUNT_NOT_PROVISIONED`](./SMART_ACCOUNT_NOT_PROVISIONED.md) \u2014 earlier failure point\n",
62
+ "WEBHOOK_REPLAY_LIMIT_REACHED": '# `WEBHOOK_REPLAY_LIMIT_REACHED`\n\n**HTTP status:** 429 \xB7 **Title:** "Replay limit reached"\n\n## When it fires\n\n`POST /v1/webhooks/events/{id}/redeliver` was called for an event that has already been replayed up to its cap (default 5).\n\n## Why it happens\n\n- A bug in the consumer kept calling the redeliver endpoint in a loop instead of fixing the underlying delivery problem.\n- An automation re-replayed without checking past attempts.\n- A genuinely persistent need to replay an event many times \u2014 uncommon and usually signals an upstream issue.\n\nThe cap exists to stop a compromised key from looping the replay endpoint to flood its own customer endpoint or our delivery worker.\n\n## How to fix\n\n- Stop calling the redeliver endpoint for this event id. The cap is per-event, so other events still replay normally.\n- Fix the underlying delivery failure first \u2014 check the event\'s delivery status via `GET /v1/trades/:id` (the `webhookDelivery` field) or via `kash-admin webhooks trace --event-id <id>` (operator command).\n- If you legitimately need more replays for one event (e.g., recovering from a long endpoint outage), contact support \u2014 we can clear the counter manually.\n\n## Related codes\n\n- [`RATE_LIMIT_EXCEEDED`](./RATE_LIMIT_EXCEEDED.md) \u2014 general per-key rate limit\n',
63
+ "WEBHOOK_SECRET_ROTATION_COOLDOWN": "# `WEBHOOK_SECRET_ROTATION_COOLDOWN`\n\n**HTTP status:** 429 \xB7 **Title:** \"Rotation cooldown active\"\n\n## When it fires\n\n`POST /v1/auth/api-keys/me/webhook-secret/rotate` was called within the 60-second cooldown that follows a previous successful rotation.\n\n## Why the cooldown exists\n\nThis is the most important error to read carefully \u2014 the cooldown is not a rate-limit, it is a **rollback-safety** guard.\n\nEvery successful rotation moves the previous secret into `webhook_secret_previous` so operations can roll back within 7 days if the rotation breaks the customer's verifier. If you rotate twice in quick succession, the second rotation overwrites that rollback slot with the FIRST rotation's brand-new secret \u2014 a secret you may have never actually received in your response (e.g., the first POST timed out, your HTTP client retried, and the new plaintext from the first attempt was lost in flight). Rolling back later would restore a secret no verifier was ever configured for.\n\nThe cooldown forces this dangerous case into a visible 429 instead of silently corrupting the rollback guarantee.\n\n## How to fix\n\n- **If you successfully captured the new secret from the previous rotation:** wait for the `Retry-After` window to expire and call again. The cooldown is per-key, so other keys are unaffected.\n- **If you did NOT capture the new secret from the previous rotation** (network timeout, lost response, dropped connection): **do not retry**. Contact support so we can rotate via an operator path that preserves the rollback chain. Re-rotating yourself would replace `webhook_secret_previous` with the secret you never received, breaking the only recovery path.\n\n## Response headers\n\n- `Retry-After: <seconds>` \u2014 how long to wait before re-attempting (computed from the prior rotation's timestamp).\n\n## Related codes\n\n- [`RATE_LIMIT_EXCEEDED`](./RATE_LIMIT_EXCEEDED.md) \u2014 generic per-user/per-IP rate limit (volume-based, not safety-based)\n- [`DEPENDENCY_UNAVAILABLE`](./DEPENDENCY_UNAVAILABLE.md) \u2014 Postgres or downstream temporarily unreachable; safe to retry\n"
64
+ });
65
+
66
+ // src/commands/explain.ts
67
+ var API_DOCS_URL_BASE = "https://docs.kash.bot/developer-docs/api-errors";
68
+ function lookupApiErrorBody(code) {
69
+ return API_ERROR_DOCS[code];
70
+ }
71
+ var explainCommand = new Command("explain").description("Look up one or more error codes and their recommended recovery steps.").argument("[codes...]", "error codes to explain (omit to list every known code)").addHelpText(
72
+ "after",
73
+ `
74
+ Examples:
75
+ $ kash explain # list every known error code
76
+ $ kash explain RATE_LIMITED
77
+ $ kash explain RATE_LIMITED NOT_FOUND --json # bulk lookup for AI agents
78
+ `
79
+ ).action((codes, _opts, cmd) => {
80
+ const globals = readGlobals(cmd);
81
+ if (codes.length === 0) {
82
+ if (globals.json) {
83
+ printJson({ codes: ERROR_CATALOG });
84
+ return;
85
+ }
86
+ const table = createTable(["Code", "Summary", "Recoverable"]);
87
+ for (const entry of ERROR_CATALOG) {
88
+ table.push([
89
+ entry.code,
90
+ truncate(entry.summary, 60),
91
+ entry.recoverable ? style.success("yes") : style.dim("no")
92
+ ]);
93
+ }
94
+ print(table.toString());
95
+ return;
96
+ }
97
+ const entries = codes.map((code) => {
98
+ const entry = lookupErrorCode(code);
99
+ if (entry) return { source: "cli", entry };
100
+ const body = lookupApiErrorBody(code);
101
+ if (body) return { source: "api", code, body };
102
+ throw new CliError(`Unknown error code: ${code}`, {
103
+ code: "INVALID_INPUT",
104
+ recoverable: true,
105
+ suggestion: "Run `kash explain` (no argument) to list every known code."
106
+ });
107
+ });
108
+ if (globals.json) {
109
+ const payload = entries.map(
110
+ (r) => r.source === "cli" ? r.entry : {
111
+ code: r.code,
112
+ source: "api",
113
+ docsUrl: `${API_DOCS_URL_BASE}/${r.code}`,
114
+ body: r.body
115
+ }
116
+ );
117
+ printJson(payload.length === 1 ? payload[0] : { codes: payload });
118
+ return;
119
+ }
120
+ for (const [i, resolved] of entries.entries()) {
121
+ if (i > 0) print("");
122
+ print("");
123
+ if (resolved.source === "api") {
124
+ print(resolved.body);
125
+ print(style.dim(`Docs: ${API_DOCS_URL_BASE}/${resolved.code}`));
126
+ continue;
127
+ }
128
+ const { entry } = resolved;
129
+ print(`${style.bold(entry.code)}`);
130
+ print(` ${style.dim("Summary ")} ${entry.summary}`);
131
+ print(
132
+ ` ${style.dim("Recoverable")} ${entry.recoverable ? style.success("yes") : style.dim("no")}`
133
+ );
134
+ if (entry.docsUrl) {
135
+ print(` ${style.dim("Docs ")} ${entry.docsUrl}`);
136
+ }
137
+ print("");
138
+ print(entry.description);
139
+ if (entry.actions.length > 0) {
140
+ print("");
141
+ print(style.bold("Recovery actions:"));
142
+ for (const action of entry.actions) {
143
+ print(` - ${formatAction(action)}`);
144
+ }
145
+ }
146
+ }
147
+ });
148
+ function formatAction(action) {
149
+ switch (action.type) {
150
+ case "run_command":
151
+ return `${style.cyan(action.command)} \u2014 ${action.description}`;
152
+ case "set_env":
153
+ return `${style.cyan(action.variable)} (env var) \u2014 ${action.description}`;
154
+ case "wait_and_retry":
155
+ return `${style.cyan(`wait ${String(Math.round(action.delayMs / 1e3))}s`)} \u2014 ${action.description}`;
156
+ case "open_url":
157
+ return `${style.cyan(action.url)} \u2014 ${action.description}`;
158
+ case "check_input":
159
+ return `${style.cyan(action.field)} (input field) \u2014 ${action.description}`;
160
+ }
161
+ }
162
+ export {
163
+ explainCommand
164
+ };
165
+ //# sourceMappingURL=explain-H4EH3KH3.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/commands/explain.ts","../src/api-error-bundle.generated.ts"],"sourcesContent":["/**\n * `kash explain <code>` — translate an error `code` into a structured\n * description, recovery steps, and a docs URL.\n *\n * Designed to be called by AI agents that hit an error with\n * `--json --quiet`, parse the `code` from the envelope, and want\n * deterministic, machine-readable next steps. The same data feeds\n * the human-mode error suggestion.\n *\n * `kash explain` (no argument) lists every code the CLI emits — useful\n * for agents at startup that want to learn the catalog up front.\n */\n\nimport { Command } from 'commander';\n\nimport { API_ERROR_DOCS } from '../api-error-bundle.generated.js';\nimport { ERROR_CATALOG, lookupErrorCode } from '../error-catalog.js';\nimport { CliError, type CliErrorAction } from '../errors.js';\nimport { createTable, truncate } from '../utils/formatting.js';\nimport { readGlobals } from '../utils/global-options.js';\nimport { print, printJson, style } from '../utils/output.js';\n\nconst API_DOCS_URL_BASE = 'https://docs.kash.bot/developer-docs/api-errors';\n\n/**\n * Fallback for API server-side codes that aren't in the hand-curated\n * CLI ERROR_CATALOG. Returns the markdown body bundled at build time\n * from `docs/api-errors/<CODE>.md` so `kash explain MARKET_NOT_TRADEABLE`\n * works the same as `kash explain RATE_LIMITED` even though the latter\n * has rich CliError metadata and the former is server-only.\n */\nfunction lookupApiErrorBody(code: string): string | undefined {\n return API_ERROR_DOCS[code];\n}\n\nexport const explainCommand = new Command('explain')\n .description('Look up one or more error codes and their recommended recovery steps.')\n .argument('[codes...]', 'error codes to explain (omit to list every known code)')\n .addHelpText(\n 'after',\n `\nExamples:\n $ kash explain # list every known error code\n $ kash explain RATE_LIMITED\n $ kash explain RATE_LIMITED NOT_FOUND --json # bulk lookup for AI agents\n`\n )\n .action((codes: readonly string[], _opts, cmd: Command) => {\n const globals = readGlobals(cmd);\n\n if (codes.length === 0) {\n if (globals.json) {\n printJson({ codes: ERROR_CATALOG });\n return;\n }\n const table = createTable(['Code', 'Summary', 'Recoverable']);\n for (const entry of ERROR_CATALOG) {\n table.push([\n entry.code,\n truncate(entry.summary, 60),\n entry.recoverable ? style.success('yes') : style.dim('no'),\n ]);\n }\n print(table.toString());\n return;\n }\n\n // Resolve every code first so we fail before printing anything if\n // any are unknown — agents iterating a list shouldn't get a\n // half-finished response.\n type Resolved =\n | { readonly source: 'cli'; readonly entry: ReturnType<typeof lookupErrorCode> & object }\n | { readonly source: 'api'; readonly code: string; readonly body: string };\n const entries: Resolved[] = codes.map((code) => {\n const entry = lookupErrorCode(code);\n if (entry) return { source: 'cli', entry };\n // Fall back to the bundled docs/api-errors/<CODE>.md content so\n // server-side API codes (MARKET_NOT_TRADEABLE, INSUFFICIENT_BALANCE,\n // CONFIRMATION_TOKEN_USED, …) work even though they don't have\n // hand-curated CLI metadata. Agents calling `--json` get a\n // distinct `source: 'api'` envelope so they can differentiate.\n const body = lookupApiErrorBody(code);\n if (body) return { source: 'api', code, body };\n throw new CliError(`Unknown error code: ${code}`, {\n code: 'INVALID_INPUT',\n recoverable: true,\n suggestion: 'Run `kash explain` (no argument) to list every known code.',\n });\n });\n\n if (globals.json) {\n // Single code: emit the entry directly (back-compat with the\n // historical single-arg shape).\n // Multiple codes: emit a `codes` array so consumers can branch.\n const payload = entries.map((r) =>\n r.source === 'cli'\n ? r.entry\n : {\n code: r.code,\n source: 'api' as const,\n docsUrl: `${API_DOCS_URL_BASE}/${r.code}`,\n body: r.body,\n }\n );\n printJson(payload.length === 1 ? payload[0] : { codes: payload });\n return;\n }\n\n for (const [i, resolved] of entries.entries()) {\n if (i > 0) print('');\n print('');\n if (resolved.source === 'api') {\n // Render the bundled markdown verbatim — it's already formatted\n // for human reading (per-code page template).\n print(resolved.body);\n print(style.dim(`Docs: ${API_DOCS_URL_BASE}/${resolved.code}`));\n continue;\n }\n const { entry } = resolved;\n print(`${style.bold(entry.code)}`);\n print(` ${style.dim('Summary ')} ${entry.summary}`);\n print(\n ` ${style.dim('Recoverable')} ${entry.recoverable ? style.success('yes') : style.dim('no')}`\n );\n if (entry.docsUrl) {\n print(` ${style.dim('Docs ')} ${entry.docsUrl}`);\n }\n print('');\n print(entry.description);\n if (entry.actions.length > 0) {\n print('');\n print(style.bold('Recovery actions:'));\n for (const action of entry.actions) {\n print(` - ${formatAction(action)}`);\n }\n }\n }\n });\n\nfunction formatAction(action: CliErrorAction): string {\n switch (action.type) {\n case 'run_command':\n return `${style.cyan(action.command)} — ${action.description}`;\n case 'set_env':\n return `${style.cyan(action.variable)} (env var) — ${action.description}`;\n case 'wait_and_retry':\n return `${style.cyan(`wait ${String(Math.round(action.delayMs / 1000))}s`)} — ${action.description}`;\n case 'open_url':\n return `${style.cyan(action.url)} — ${action.description}`;\n case 'check_input':\n return `${style.cyan(action.field)} (input field) — ${action.description}`;\n }\n}\n","/**\n * AUTO-GENERATED by scripts/build-api-error-bundle.ts. DO NOT EDIT.\n *\n * Per-code markdown bodies extracted from docs/api-errors/<CODE>.md at\n * build time so `kash explain <CODE>` can render the full docs page\n * offline. Re-run `pnpm gen:api-error-bundle` (or `pnpm build`,\n * which runs it as a prebuild step) to refresh.\n *\n * Generated at 2026-05-20T19:48:53.782Z; 38 codes covered.\n */\n\nexport const API_ERROR_DOCS: Readonly<Record<string, string>> = Object.freeze({\n \"ACTIVE_KEY_LIMIT_REACHED\": \"# `ACTIVE_KEY_LIMIT_REACHED`\\n\\n**HTTP status:** 409 · **Title:** \\\"Active key limit reached\\\"\\n\\n## When it fires\\n\\nThe user has hit `MAX_ACTIVE_KEYS_PER_USER` (5) and `POST /api/account/api-keys` in the webapp (or `kash-admin api-keys issue`) was called to issue another. The cap is enforced both at the application layer and via the `trg_api_keys_enforce_max_active_per_user` BEFORE INSERT trigger (which closes a TOCTOU window that the app-level check alone leaves open).\\n\\n## Why it happens\\n\\n- A genuine ceiling — most users only need 2-3 keys (one per environment, maybe a CI key). The cap exists to prevent runaway issuance from compromising audit hygiene.\\n- A bot that issues a fresh key per run instead of reusing one (anti-pattern).\\n\\n## How to fix\\n\\n- Revoke an existing key first — Settings → API Keys → Revoke (or `kash-admin api-keys revoke <id>`).\\n- Reuse keys across environments where appropriate: one `kash_live_…` for production code, one `kash_test_…` for everything else.\\n- For mm/enterprise tiers, the cap is higher — contact support if your usage genuinely needs more.\\n\\n## Related codes\\n\\n- [`API_KEY_REVOKED`](./API_KEY_REVOKED.md) — what happens after revocation if you keep using the old key\\n\",\n \"AMOUNT_TOO_LARGE\": \"# `AMOUNT_TOO_LARGE`\\n\\n**HTTP status:** 400 · **Title:** \\\"Amount too large\\\"\\n\\n## When it fires\\n\\n`amount` exceeds `MAX_USDC_PER_TRADE`. This is a defence-in-depth check distinct from your key's per-trade and daily spending limits — it catches obvious mistakes regardless of your tier.\\n\\n## Why it happens\\n\\nThe most common cause is a **decimal-place mistake**: passing `1000000000000000000` (\\\"1 USDC in 18-decimal atomic units\\\") instead of `1` (\\\"1 USDC in human units\\\"). The public API takes amounts in **human units** (the same string a person would type), not 18-decimal atomic units.\\n\\n## How to fix\\n\\n- Pass `amount` as a string of the human-unit USDC value: `\\\"100\\\"`, `\\\"0.50\\\"`, `\\\"2500\\\"`.\\n- If you have an atomic value, divide by 10^6 (USDC is 6 decimals on-chain, but our API normalises to \\\"USDC the user thinks about\\\").\\n- For sustained high-value flows above `MAX_USDC_PER_TRADE`, request a tier upgrade — your key's `per_trade_limit_usdc` can be raised, but the absolute cap is a platform-level safety net.\\n\\n## Related codes\\n\\n- [`SPENDING_LIMIT_EXCEEDED`](./SPENDING_LIMIT_EXCEEDED.md) — your key's per-trade or daily limit, separate from the platform cap\\n- [`VALIDATION_FAILED`](./VALIDATION_FAILED.md) — generic validation failure\\n\",\n \"API_KEY_EXPIRED\": \"# `API_KEY_EXPIRED`\\n\\n**HTTP status:** 401 · **Title:** \\\"API key expired\\\"\\n\\n## When it fires\\n\\nThe key has a non-null `expires_at` and the current time is past it.\\n\\n## Why it happens\\n\\n- You requested a time-limited key at issuance (recommended for CI keys, contractor access, demos).\\n- An operator set an expiry on a long-lived key during a security review.\\n- The key is unchanged; only the wall clock moved.\\n\\n## How to fix\\n\\n- Issue a new key via Settings → API Keys (self-service, free/developer tiers) or `kash-admin api-keys issue` (mm/enterprise tiers).\\n- For automated rotation, schedule a rotation job that issues + activates the new key 24 h before the old one expires.\\n- The new key will have a different `id` and prefix; update every consumer.\\n\\n## Related codes\\n\\n- [`API_KEY_REVOKED`](./API_KEY_REVOKED.md) — explicit operator action, distinct from time-based expiry\\n\",\n \"API_KEY_INVALID\": \"# `API_KEY_INVALID`\\n\\n**HTTP status:** 401 · **Title:** \\\"Authentication required\\\"\\n\\n## When it fires\\n\\nThe `X-API-Key` header is well-formed but no row in the API key store matches its prefix or HMAC.\\n\\n## Why it happens\\n\\n- The key was deleted (not just revoked — see [`API_KEY_REVOKED`](./API_KEY_REVOKED.md) for that distinct case).\\n- A character was modified in transit or storage (e.g., copy-paste truncation that still happened to land on the right length).\\n- Wrong environment — a `kash_test_…` key sent to production, or vice versa. Production never accepts test keys.\\n- The HMAC pepper has been rotated and the rotation grace period has elapsed for an old issuance.\\n\\n## How to fix\\n\\n- Compare the stored value to what the webapp Settings → API Keys page shows for the prefix (`kash_live_xxxx…`).\\n- If the prefix doesn't match anything in your account, the key was never yours or has been deleted — issue a new one.\\n- If the prefix matches but auth still fails, contact support with the prefix and your `X-Request-ID` from a recent failed call.\\n\\n## Related codes\\n\\n- [`API_KEY_MISSING`](./API_KEY_MISSING.md), [`API_KEY_MALFORMED`](./API_KEY_MALFORMED.md) — earlier failure points\\n- [`API_KEY_REVOKED`](./API_KEY_REVOKED.md) — key existed but was explicitly revoked\\n- [`API_KEY_EXPIRED`](./API_KEY_EXPIRED.md) — key existed but is past `expires_at`\\n\",\n \"API_KEY_MALFORMED\": \"# `API_KEY_MALFORMED`\\n\\n**HTTP status:** 401 · **Title:** \\\"Authentication required\\\"\\n\\n## When it fires\\n\\nThe `X-API-Key` header is present but does not match the expected `kash_(live|test)_<32 alphanumeric>` shape.\\n\\n## Why it happens\\n\\n- A leading or trailing whitespace character (e.g., from a copy-paste with a trailing newline).\\n- The wrong env var was substituted (e.g., a Privy token or a bare UUID instead of a Kash API key).\\n- Truncation by a logging or proxy layer that capped header values.\\n\\n## How to fix\\n\\n- The format is `kash_<env>_<32 alphanumeric>` (base62 — `A-Z`, `a-z`, `0-9`, no dashes or underscores after the prefix). The `<env>` segment is `live` or `test`.\\n- `live` keys hit production; `test` keys hit staging. Don't mix.\\n- If you stored the key in an env var, double-check the substitution: `echo $KASH_API_KEY | head -c 16` should print `kash_live_…` or `kash_test_…`.\\n\\n## Related codes\\n\\n- [`API_KEY_MISSING`](./API_KEY_MISSING.md) — no header at all\\n- [`API_KEY_INVALID`](./API_KEY_INVALID.md) — well-formed but unknown\\n\",\n \"API_KEY_MISSING\": \"# `API_KEY_MISSING`\\n\\n**HTTP status:** 401 · **Title:** \\\"Authentication required\\\"\\n\\n## When it fires\\n\\nA route that requires authentication received a request with no `X-API-Key` header.\\n\\n## Why it happens\\n\\n- The request was sent without the header (most common — first integration).\\n- A reverse proxy or HTTP client stripped the header.\\n- The wrong base URL — the documented endpoints live under `/v1/*`; some other route may not require auth.\\n\\n## How to fix\\n\\n```http\\nGET /v1/portfolio HTTP/1.1\\nHost: api.kash.bot\\nX-API-Key: kash_live_<your-key-here>\\n```\\n\\n- Pass the key on every authenticated request. Keys are issued via the webapp Settings → API Keys page or `kash-admin api-keys issue`.\\n- The plaintext is shown once at issuance. If you've lost it, revoke and re-issue.\\n\\n## Related codes\\n\\n- [`API_KEY_MALFORMED`](./API_KEY_MALFORMED.md) — header present but doesn't match the `kash_(live|test)_*` shape\\n- [`API_KEY_INVALID`](./API_KEY_INVALID.md) — header well-formed but no row matches\\n\",\n \"API_KEY_REVOKED\": \"# `API_KEY_REVOKED`\\n\\n**HTTP status:** 401 · **Title:** \\\"API key revoked\\\"\\n\\n## When it fires\\n\\nThe key is well-formed and recognised, but its `revoked_at` column is set. The auth middleware fails closed.\\n\\n## Why it happens\\n\\n- A user (you or a teammate) explicitly revoked the key via Settings → API Keys → Revoke.\\n- An operator revoked the key via `kash-admin api-keys revoke <id>` (e.g., suspected leak).\\n- A security automation revoked it (e.g., per-key velocity anomaly tripped a kill switch).\\n\\n## How to fix\\n\\n- If revocation was unintentional, ask the operator who ran it (the audit log records who/when/why).\\n- If revocation was intentional and you need to keep working, **issue a new key**. Revoked keys cannot be un-revoked — that's the security property.\\n- Update every place the old key was stored (env vars, secret stores, CI configs) before the new key goes live.\\n\\n## Related codes\\n\\n- [`API_KEY_INVALID`](./API_KEY_INVALID.md) — key was deleted, not revoked\\n- [`API_KEY_EXPIRED`](./API_KEY_EXPIRED.md) — key reached its `expires_at` (no operator action)\\n\",\n \"API_TRADE_PROCESSING_HALTED\": \"# `API_TRADE_PROCESSING_HALTED`\\n\\n**HTTP status:** 503 · **Title:** \\\"Service unavailable\\\" · **Retry-After:** 300s\\n\\n## When it fires\\n\\nThe coarse `API_TRADE_PROCESSING` kill switch is active. Ops uses this to halt all trade-write traffic platform-wide during incidents (e.g., RPC outage, oracle failure, security investigation).\\n\\nRead endpoints (markets, portfolio, trade lookup) stay available throughout — only the write path is blocked.\\n\\n## Why it happens\\n\\n- Active incident under operator response.\\n- Pre-deploy lockdown for a high-risk migration.\\n- A security-driven freeze in response to anomaly detection.\\n\\n## How to fix\\n\\n- Honour `Retry-After: 300` and back off. Do NOT poll faster — the kill switch is binary, polling won't unblock you sooner.\\n- Subscribe to the status page or our incident webhook to know when service resumes.\\n- In the meantime, your read paths still work — surface a \\\"trading temporarily paused\\\" banner in your UI rather than failing silently.\\n\\n## Related codes\\n\\n- [`ROUTE_DISABLED`](./ROUTE_DISABLED.md) — single-route kill switch (more granular than the coarse halt)\\n- [`DEPENDENCY_UNAVAILABLE`](./DEPENDENCY_UNAVAILABLE.md) — specific dependency rather than platform-wide\\n\",\n \"API_VERSION_UNSUPPORTED\": \"# `API_VERSION_UNSUPPORTED`\\n\\n**HTTP status:** 410 · **Title:** \\\"API version unsupported\\\"\\n\\n## When it fires\\n\\nYour request sent an `X-Kash-Api-Version` header whose value is not in\\n`SUPPORTED_PUBLIC_API_VERSIONS`. The runtime accepts only\\ndate-string values that the API has actively published as a supported\\ncontract version. Unrecognized values — typos, future-dated guesses,\\nversions long past their sunset window — return this error.\\n\\n## Why 410?\\n\\n`410 Gone` is the right semantic: the version was either once\\naccepted (an old date past its 12-month deprecation window) or never\\nhas been (a typo, a future date). Either way, sending the request\\nagain with the same value will not succeed. `400` would imply the\\nclient could fix syntax and retry without changing the value — which\\nis not the case here. `406 Not Acceptable` is closer but is reserved\\nfor content-type negotiation.\\n\\n## Why it happens\\n\\n- **Typo in the pin** — sent `2026-04-30` when the canonical version\\n is `2026-04-29`.\\n- **Pinned to a sunsetted version** — the date you pinned against\\n passed its 12-month `Sunset` window and was removed from the\\n supported set.\\n- **Pinned to a future version** — your client was upgraded with a\\n version pin that the production API hasn't shipped yet (canary\\n drift between client release and server deploy).\\n- **Multi-valued header confusion** — a load balancer or proxy\\n appended a second `X-Kash-Api-Version` value (`,`-joined) and the\\n runtime correctly refuses to silently pick one.\\n\\n## How to recover\\n\\nThe response body's `metadata.supported` field carries the full list\\nof accepted versions, in canonical-default-first order:\\n\\n```json\\n{\\n \\\"type\\\": \\\"https://docs.kash.bot/developer-docs/api-errors/API_VERSION_UNSUPPORTED\\\",\\n \\\"title\\\": \\\"API version unsupported\\\",\\n \\\"status\\\": 410,\\n \\\"code\\\": \\\"API_VERSION_UNSUPPORTED\\\",\\n \\\"detail\\\": \\\"Requested API version '2099-01-01' is not supported. Send no header to use the current default (2026-04-29), or pick a value from the 'supported' list.\\\",\\n \\\"instance\\\": \\\"/v1/markets\\\",\\n \\\"requestId\\\": \\\"01HQGY8K2N3X4Z5C6D7E8F9G0H\\\",\\n \\\"requested\\\": \\\"2099-01-01\\\",\\n \\\"supported\\\": [\\\"2026-04-29\\\"],\\n \\\"current\\\": \\\"2026-04-29\\\"\\n}\\n```\\n\\nPick a value from `supported` and resend the request. The simplest\\nrecovery is to **drop the header** entirely and let the runtime\\ndefault to `current` — that's the recommended consumer mode unless\\nyou have a specific reason to pin.\\n\\n## Migration policy\\n\\nWhen the API ships a new version, the previous version stays in\\n`SUPPORTED_PUBLIC_API_VERSIONS` for **12 months** (per AD-15 / RFC\\n8594). During that window, every response from the deprecated\\nversion carries `Sunset: <date>` and `Deprecation: true` headers so\\nyour monitoring can flag the impending expiry well before it hits.\\n\\nIf you're seeing this error on a date that is older than 12 months\\nsince its `Sunset` was advertised, that's working as intended — it's\\ntime to update the pin.\\n\\n## Best practice\\n\\nDon't pin if you don't have to. The default contract (no header)\\nruns against the current `PUBLIC_API_VERSION`, and we promise\\nadditive, non-breaking changes within a date version. Pin only when\\nyou're locking down a release for compliance or contract-test\\nreasons.\\n\\nIf you do pin, follow the registry: read\\n`https://api.kash.bot/v1/openapi.json` (or the live OpenAPI route)\\nand let your client library auto-select the highest version it\\nrecognises.\\n\\n## See also\\n\\n- `apps/public-api/src/plugins/api-version.ts` — the negotiation\\n plugin source.\\n- `packages/constants/src/public-api-version.ts` — the canonical\\n registry.\\n- `apps/public-api/openapi/index.json` — the snapshot index, listing\\n every published version.\\n- `docs/runbooks/hmac-algorithm-rotation.md` — example of a version\\n bump driven by a security event.\\n\",\n \"CLIENT_REQUEST_ID_CONFLICT\": \"# `CLIENT_REQUEST_ID_CONFLICT`\\n\\n**HTTP status:** 409 · **Title:** \\\"Client request id conflict\\\"\\n\\n## When it fires\\n\\nThe same `(user_id, clientRequestId)` body pair was used for an earlier trade with a **different body**. The trade row is unique on `(user_id, clientRequestId)` once you opt in.\\n\\n## Why it happens\\n\\n- Power-user mistake: `clientRequestId` is a body field with stricter conflict semantics than the HTTP `Idempotency-Key` header. Most callers should use the header; opt into `clientRequestId` only if you specifically want trade-row-scoped dedup.\\n- A bug regenerated the body but kept the `clientRequestId` constant.\\n\\n## How to fix\\n\\n- For most use cases, drop `clientRequestId` and use only `Idempotency-Key`. See `apps/public-api/README.md` § Idempotency.\\n- If you need `clientRequestId`'s row-scoped dedup, generate a fresh value per logical operation.\\n- The conflicting trade row is referenced in the `detail` field — fetch it via `GET /v1/trades/:id` to see what was previously submitted.\\n\\n## Related codes\\n\\n- [`IDEMPOTENCY_KEY_CONFLICT`](./IDEMPOTENCY_KEY_CONFLICT.md) — same idea, header-level\\n\",\n \"CONFIRMATION_EXPIRED\": \"# `CONFIRMATION_EXPIRED`\\n\\n**HTTP status:** 409 · **Title:** \\\"Confirmation expired\\\"\\n\\n## When it fires\\n\\nThe confirmation token is past its `confirmation_expires_at`. High-value trades have a short confirmation window (60 seconds by default; configurable per-tier).\\n\\n## Why it happens\\n\\n- A human-in-the-loop confirmation flow took longer than the window allows.\\n- A queue/worker latency between `POST /v1/trades` returning the token and the consumer calling confirm.\\n\\n## How to fix\\n\\n- Tighten the consumer's confirm call — ideally fire it immediately after receiving the token.\\n- For human-in-the-loop flows that need longer, request a per-key window extension (mm tier customers who pipeline trades through human review get longer windows).\\n- The trade is automatically cancelled when the window expires — re-create it to retry.\\n\\n## Related codes\\n\\n- [`TRADE_NOT_AWAITING_CONFIRMATION`](./TRADE_NOT_AWAITING_CONFIRMATION.md), [`CONFIRMATION_TOKEN_INVALID`](./CONFIRMATION_TOKEN_INVALID.md)\\n\",\n \"CONFIRMATION_TOKEN_INVALID\": \"# `CONFIRMATION_TOKEN_INVALID`\\n\\n**HTTP status:** 409 · **Title:** \\\"Confirmation token invalid\\\"\\n\\n## When it fires\\n\\nThe token you sent in `POST /v1/trades/:id/confirm` doesn't match the stored hash for that trade.\\n\\n## Why it happens\\n\\n- Wrong trade id — the token is one-time and tied to a specific trade.\\n- Token was truncated or modified in transit/storage.\\n- Replay against a different trade than the one that issued the token.\\n\\nThe token plaintext is **never persisted server-side** — only the HMAC. We compare your candidate via `timingSafeEqual` to prevent per-byte timing attacks.\\n\\n## How to fix\\n\\n- Re-confirm that the trade id in the URL matches the trade id from the create response.\\n- Capture the token verbatim from `confirmation.token` in the create response — the SDK does this automatically; raw HTTP callers must preserve case and length (43 chars, base64url).\\n- If you've lost the token (only shown once in the create response), the trade can't be confirmed — wait for it to auto-expire and re-create.\\n\\n## Related codes\\n\\n- [`CONFIRMATION_EXPIRED`](./CONFIRMATION_EXPIRED.md), [`CONFIRMATION_TOKEN_USED`](./CONFIRMATION_TOKEN_USED.md), [`TRADE_NOT_AWAITING_CONFIRMATION`](./TRADE_NOT_AWAITING_CONFIRMATION.md)\\n\",\n \"CONFIRMATION_TOKEN_USED\": \"# `CONFIRMATION_TOKEN_USED`\\n\\n**HTTP status:** 409 · **Title:** \\\"Confirmation token used\\\"\\n\\n## When it fires\\n\\nThe token has already been redeemed. Confirmation tokens are **one-time use** — the first successful confirm clears the stored hash so a replay can't re-fire the trade.\\n\\n## Why it happens\\n\\n- A retry layer that doesn't check the trade's status before re-confirming (most common).\\n- Two parallel confirmation attempts from the same consumer (race condition in the caller).\\n- A double-click in a human-in-the-loop UI.\\n\\n## How to fix\\n\\n- Check the trade's status via `GET /v1/trades/:id` — if it's anything past `pending_confirmation`, the confirm already succeeded.\\n- Make your retry loop idempotent: poll trade status first, only confirm if `status === 'pending_confirmation'`.\\n- For UI flows: disable the confirm button immediately on click to prevent double-submission.\\n\\n## Related codes\\n\\n- [`TRADE_NOT_AWAITING_CONFIRMATION`](./TRADE_NOT_AWAITING_CONFIRMATION.md) — same outcome via different state-check path\\n\",\n \"DEPENDENCY_UNAVAILABLE\": \"# `DEPENDENCY_UNAVAILABLE`\\n\\n**HTTP status:** 503 · **Title:** \\\"Service unavailable\\\" · **Retry-After:** 30s\\n\\n## When it fires\\n\\nA required external dependency (Postgres, Secrets Manager, Redis, blockchain RPC) is unreachable, and the route can't degrade further.\\n\\nDistinct from [`INTERNAL_ERROR`](./INTERNAL_ERROR.md) (which is \\\"we don't know what happened\\\") and [`ROUTE_DISABLED`](./ROUTE_DISABLED.md) (which is \\\"ops disabled this on purpose\\\").\\n\\n## Why it happens\\n\\n- Transient infrastructure blip (most common — clears in seconds).\\n- Sustained dependency outage under operator response.\\n- A rare cascade where one dependency degraded and another can't compensate.\\n\\n## How to fix\\n\\n- Honour `Retry-After: 30` and back off with jitter. Most occurrences resolve within one retry.\\n- If retries keep failing past 5 minutes, check the status page.\\n- The TS SDK handles this automatically (`KashServerError`, retried with backoff up to its retry budget).\\n\\n## Related codes\\n\\n- [`API_TRADE_PROCESSING_HALTED`](./API_TRADE_PROCESSING_HALTED.md) — ops-driven, not infra-driven\\n- [`REQUEST_TIMEOUT`](./REQUEST_TIMEOUT.md), [`INTERNAL_ERROR`](./INTERNAL_ERROR.md)\\n\",\n \"IDEMPOTENCY_KEY_CONFLICT\": \"# `IDEMPOTENCY_KEY_CONFLICT`\\n\\n**HTTP status:** 409 · **Title:** \\\"Idempotency key conflict\\\"\\n\\n## When it fires\\n\\nThe same `Idempotency-Key` was used for two requests with **different bodies**. The server refuses to either re-execute (would violate idempotency) or replay the cached response (would mislead the caller about which request \\\"won\\\").\\n\\n## Why it happens\\n\\n- Most common: a retry layer regenerated the body on each attempt instead of capturing it once and replaying.\\n- A bug in the caller that mutates the body in-place between the request being prepared and the request being sent.\\n- Two distinct logical operations accidentally sharing a key (e.g., a global key generator that didn't account for concurrency).\\n\\n## How to fix\\n\\n- Pin the request body before generating the idempotency key. Either:\\n - Capture body + key together in your retry wrapper and re-send the exact pair on each attempt.\\n - Generate a fresh key per logical operation (one trade = one key).\\n- For genuinely retryable bodies that legitimately change between attempts (e.g., updated timestamps), generate a fresh key per attempt — that's the contract.\\n\\n## Related codes\\n\\n- [`CLIENT_REQUEST_ID_CONFLICT`](./CLIENT_REQUEST_ID_CONFLICT.md) — analogous conflict, but on the body-level `clientRequestId` field rather than the HTTP header\\n- [`IDEMPOTENCY_KEY_EXPIRED`](./IDEMPOTENCY_KEY_EXPIRED.md) — key valid earlier, now past TTL\\n\",\n \"IDEMPOTENCY_KEY_EXPIRED\": \"# `IDEMPOTENCY_KEY_EXPIRED`\\n\\n**HTTP status:** 410 · **Title:** \\\"Idempotency key expired\\\"\\n\\n## When it fires\\n\\nThe `Idempotency-Key` you sent matches a row in the idempotency store but that row is past its 24-hour TTL.\\n\\n## Why it happens\\n\\n- A retry that took longer than 24 hours to fire (e.g., a job that stalled in a queue and retried days later).\\n- An offline-first client that buffered the request locally and only got online after the TTL.\\n\\nThe 410 (rather than re-executing or returning the cached response) is intentional: by 24 h, the original response is gone and the system gives you a clear \\\"this key is no longer valid\\\" signal so you can decide whether the operation is still desired.\\n\\n## How to fix\\n\\n- Generate a fresh `Idempotency-Key` for the retry.\\n- Before retrying, check whether the original operation actually completed — query `GET /v1/trades/:id` (using a `clientRequestId` you stored, if applicable) so you don't double-execute.\\n- For long-tail retries, prefer the body-level `clientRequestId` field over the header — it has the same conflict semantics but no TTL.\\n\\n## Related codes\\n\\n- [`IDEMPOTENCY_KEY_CONFLICT`](./IDEMPOTENCY_KEY_CONFLICT.md), [`CLIENT_REQUEST_ID_CONFLICT`](./CLIENT_REQUEST_ID_CONFLICT.md)\\n\",\n \"IDEMPOTENCY_KEY_FORMAT_INVALID\": \"# `IDEMPOTENCY_KEY_FORMAT_INVALID`\\n\\n**HTTP status:** 400 · **Title:** \\\"Idempotency key format invalid\\\"\\n\\n## When it fires\\n\\n`Idempotency-Key` header contains a character outside the allowed set: `[A-Za-z0-9_\\\\-:.]`.\\n\\n## Why it happens\\n\\n- The key contained whitespace, a slash, or a non-ASCII character.\\n- Control characters (newlines, NULs) sneaked in from a copy-paste.\\n- An emoji or Unicode separator accidentally landed in the key.\\n\\nThe strict allowlist exists because the value flows through structured logs, Redis keys, and PG rows — non-printable / control characters could poison log shippers or downstream systems that key on the value.\\n\\n## How to fix\\n\\n- Stick to UUIDs (`crypto.randomUUID()` in Node, `uuidgen` in shell) — they're always conforming.\\n- If you must derive the key from external input, sanitise: `key.replace(/[^A-Za-z0-9_\\\\-:.]/g, '')` then check it's still unique.\\n\\n## Related codes\\n\\n- [`IDEMPOTENCY_KEY_TOO_LONG`](./IDEMPOTENCY_KEY_TOO_LONG.md), [`IDEMPOTENCY_KEY_CONFLICT`](./IDEMPOTENCY_KEY_CONFLICT.md)\\n\",\n \"IDEMPOTENCY_KEY_TOO_LONG\": \"# `IDEMPOTENCY_KEY_TOO_LONG`\\n\\n**HTTP status:** 400 · **Title:** \\\"Idempotency key too long\\\"\\n\\n## When it fires\\n\\n`Idempotency-Key` header value exceeds the 255-character cap.\\n\\n## Why it happens\\n\\n- You concatenated several identifiers into the key (user id + market id + timestamp + nonce) and the result blew past 255 chars.\\n- A library generated an unusually long token (e.g., a JWT instead of a UUID).\\n\\n## How to fix\\n\\n- Use a UUID v4 (`uuidgen`, `crypto.randomUUID()`) or a ULID — both are 26–36 chars and unique enough, well under the 255 cap.\\n- If you need to embed semantic information, hash it: `sha256(your-blob).slice(0, 32)` instead of the raw concatenation.\\n- Acceptable character set: `[A-Za-z0-9_\\\\-:.]` — see [`IDEMPOTENCY_KEY_FORMAT_INVALID`](./IDEMPOTENCY_KEY_FORMAT_INVALID.md).\\n\\n## Related codes\\n\\n- [`IDEMPOTENCY_KEY_FORMAT_INVALID`](./IDEMPOTENCY_KEY_FORMAT_INVALID.md) — disallowed characters\\n- [`IDEMPOTENCY_KEY_CONFLICT`](./IDEMPOTENCY_KEY_CONFLICT.md) — same key, different body\\n\",\n \"INSUFFICIENT_BALANCE\": \"# `INSUFFICIENT_BALANCE`\\n\\n**HTTP status:** 409 · **Title:** \\\"Insufficient balance\\\"\\n\\n## When it fires\\n\\nThe actor's smart-account USDC balance is below the requested `amount`.\\n\\n## Why it happens\\n\\n- You haven't funded the account yet.\\n- A previous trade consumed more than expected (gas + slippage) and left a sub-threshold balance.\\n- The auto-funding pipeline is backed up — common during very high-throughput windows.\\n- Org account: trade is being charged to the user but the org policy expected the org to pay (or vice versa). Check `feePayerType`.\\n\\n## How to fix\\n\\n- Check the current balance via `GET /v1/portfolio` — `usdcBalance` field.\\n- For users: fund via the webapp's onramp flow (or transfer USDC into the smart account directly).\\n- For orgs: the org admin tops up the org wallet.\\n- For staging/sandbox: use the testnet faucet flow.\\n\\n## Related codes\\n\\n- [`SMART_ACCOUNT_NOT_PROVISIONED`](./SMART_ACCOUNT_NOT_PROVISIONED.md), [`SPENDING_LIMIT_EXCEEDED`](./SPENDING_LIMIT_EXCEEDED.md)\\n\",\n \"INSUFFICIENT_SCOPE\": \"# `INSUFFICIENT_SCOPE`\\n\\n**HTTP status:** 403 · **Title:** \\\"Insufficient scope\\\"\\n\\n## When it fires\\n\\nThe key authenticated successfully but lacks one of the scopes the route requires.\\n\\n## Why it happens\\n\\n- The key was issued with a narrower scope set than the route needs (e.g., a `markets:read` key calling `POST /v1/trades` which requires `trades:write`).\\n- A new endpoint was added that requires a scope your existing key doesn't carry.\\n\\n## How to fix\\n\\n- Look up the route's required scopes in `apps/public-api/README.md` → Authentication → Scopes table.\\n- Issue a new key with the needed scopes (or revoke + re-issue with a broader scope set).\\n- Principle of least privilege: don't add scopes you don't actually need — narrow keys reduce blast radius if leaked.\\n\\n| Scope | Routes |\\n| ----------------- | ----------------------------------------------------- |\\n| `markets:read` | `GET /v1/markets*`, `GET /v1/markets/:id/predictions` |\\n| `markets:quote` | `GET /v1/markets/:id/quote` |\\n| `trades:read` | `GET /v1/trades(/:id)` |\\n| `trades:write` | `POST /v1/trades`, `POST /v1/trades/:id/confirm` |\\n| `portfolio:read` | `GET /v1/portfolio*` |\\n| `webhooks:manage` | webhook URL/secret rotation, replay endpoint |\\n| `auth:manage` | self-service key CRUD |\\n\\n## Related codes\\n\\n- [`IP_NOT_ALLOWED`](./IP_NOT_ALLOWED.md) — also 403, but driven by IP allowlist rather than scope\\n\",\n \"INTERNAL_ERROR\": \"# `INTERNAL_ERROR`\\n\\n**HTTP status:** 500 · **Title:** \\\"Internal Server Error\\\"\\n\\n## When it fires\\n\\nThe error handler caught something it didn't recognise. The actual cause is logged + Sentry-captured server-side; nothing leaks to the caller.\\n\\nThis is the catch-all — any other code in this catalogue is a structured, anticipated failure. `INTERNAL_ERROR` means we hit something we hadn't categorised.\\n\\n## Why it happens\\n\\n- A genuinely unexpected condition (the most common reason this fires).\\n- A new code path that hasn't been instrumented with a typed error yet — please report it so we can add a proper code.\\n\\n## How to fix\\n\\n- **Always include `requestId` in any support ticket.** It's our handle into the trace, the logs, and the Sentry capture.\\n- Retry once — if it was a transient bug, you may get past it.\\n- If it's reproducible from a specific request, tell us the exact request shape (with secrets redacted) and we'll trace it.\\n- The TS SDK exposes `requestId` on `KashServerError`; raw HTTP callers should pull it from the `requestId` field on the problem response.\\n\\n## Related codes\\n\\nEvery other code in this catalogue is preferable — if you see `INTERNAL_ERROR` consistently for one operation, the right fix is for us to add a new typed code, not for you to work around it.\\n\",\n \"IP_NOT_ALLOWED\": \"# `IP_NOT_ALLOWED`\\n\\n**HTTP status:** 403 · **Title:** \\\"IP not allowed\\\"\\n\\n## When it fires\\n\\nThe key has a non-empty `ip_allowlist` configured AND the request's source IP isn't in it.\\n\\nEmpty allowlist (the default) means any IP is allowed — this code only fires when an allowlist is configured AND the caller's IP doesn't match.\\n\\n## Why it happens\\n\\n- The key is locked to a specific egress IP (common for MM and enterprise tier keys) and the call originated from elsewhere — your laptop, a CI runner, a proxy with a different egress.\\n- The egress IP changed (cloud provider rotated NAT gateway, your home ISP's dynamic IP rolled).\\n\\n## How to fix\\n\\n- Find the request's IP in the `requestId`'s Loki logs — search `requestId=\\\"<value>\\\"`, the structured log carries the source IP.\\n- If the new IP is legitimate, add it to the key's allowlist via Settings → API Keys → Edit (or `kash-admin api-keys` ops command).\\n- If it's not, this code is a successful detection of an exfiltration attempt — investigate.\\n\\n## Related codes\\n\\n- [`INSUFFICIENT_SCOPE`](./INSUFFICIENT_SCOPE.md) — also 403, scope rather than IP\\n\",\n \"MARKET_NOT_FOUND\": \"# `MARKET_NOT_FOUND`\\n\\n**HTTP status:** 404 · **Title:** \\\"Market not found\\\"\\n\\n## When it fires\\n\\nThe `marketId` (UUID) you passed doesn't match any market.\\n\\n## Why it happens\\n\\n- Typo in the market id.\\n- Cross-environment confusion: a staging market id sent to production, or vice versa. Markets do not exist across environments.\\n- The market was never created — you may have a placeholder id from documentation rather than a real one.\\n\\n## How to fix\\n\\n- Look up real market ids via `GET /v1/markets?status=active` (filterable by status).\\n- Cache the id once you've found it; markets are immutable in their identity.\\n- If you suspect the market should exist (e.g., you saw it in the webapp), confirm you're hitting the same environment.\\n\\n## Related codes\\n\\n- [`MARKET_NOT_TRADEABLE`](./MARKET_NOT_TRADEABLE.md) — market exists but is FROZEN/RESOLVED\\n\",\n \"MARKET_NOT_TRADEABLE\": \"# `MARKET_NOT_TRADEABLE`\\n\\n**HTTP status:** 409 · **Title:** \\\"Market not tradeable\\\"\\n\\n## When it fires\\n\\nThe market exists but is in a state that doesn't accept trades — typically `FROZEN` (admin paused trading) or `RESOLVED` (outcome decided, only redemptions allowed).\\n\\n## Why it happens\\n\\n- Market reached its `resolve_time` and froze automatically.\\n- An admin froze the market manually (e.g., pending an oracle dispute).\\n- The market was resolved between your quote call and your trade call.\\n\\n## How to fix\\n\\n- Check the market's current `status` via `GET /v1/markets/:id` before retrying.\\n- If the market was just resolved, your existing positions are still redeemable (handled automatically by the payout pipeline) — you don't need to do anything.\\n- For freeze-then-resume scenarios, retry once the market reopens; subscribe to the `market.resumed` webhook (when streaming lands) to know exactly when.\\n\\n## Related codes\\n\\n- [`MARKET_NOT_FOUND`](./MARKET_NOT_FOUND.md), [`OUTCOME_INDEX_INVALID`](./OUTCOME_INDEX_INVALID.md)\\n\",\n \"OUTCOME_INDEX_INVALID\": \"# `OUTCOME_INDEX_INVALID`\\n\\n**HTTP status:** 400 · **Title:** \\\"Outcome index invalid\\\"\\n\\n## When it fires\\n\\n`outcomeIndex` is greater than or equal to the market's outcome count, or negative.\\n\\n## Why it happens\\n\\n- You hard-coded `outcomeIndex: 1` (assuming a binary market) and pointed at a multi-outcome market.\\n- Off-by-one: the field is **zero-indexed** — first outcome is `0`, not `1`.\\n- A bug computed the index from a label without bounds-checking.\\n\\n## How to fix\\n\\n- Look up the market via `GET /v1/markets/:id` and check `outcomes.length` — valid range is `[0, length - 1]`.\\n- Map outcome labels to indices once when you fetch the market, then use the index throughout your code.\\n\\n## Related codes\\n\\n- [`MARKET_NOT_FOUND`](./MARKET_NOT_FOUND.md), [`MARKET_NOT_TRADEABLE`](./MARKET_NOT_TRADEABLE.md), [`VALIDATION_FAILED`](./VALIDATION_FAILED.md)\\n\",\n \"RATE_LIMIT_EXCEEDED\": \"# `RATE_LIMIT_EXCEEDED`\\n\\n**HTTP status:** 429 · **Title:** \\\"Too Many Requests\\\"\\n\\n## When it fires\\n\\nPer-user (authenticated) or per-IP (anonymous reads) rate limit was exceeded. Limits are enforced as a sliding window in Redis.\\n\\nFor authenticated requests, the limit is **tier-differentiated** — 60 req/min on `free`, 300 req/min on `developer`, custom (admin-set) on `enterprise` and `mm`. See [`docs/api-tiers.md`](../api-tiers.md) for the full matrix.\\n\\n## Response headers\\n\\nEvery 429 response carries:\\n\\n- `Retry-After: <seconds>` — wait at least this long before the next request\\n- `X-RateLimit-Limit: <n>` — the limit that applied\\n- `X-RateLimit-Remaining: 0` — confirms you're capped\\n- `X-RateLimit-Reset: <unix-timestamp>` — when the window resets\\n\\n## Why it happens\\n\\n- Burst traffic spike (e.g., a polling loop without backoff).\\n- A retry storm — every consumer retried at the same instant after a transient failure.\\n- Genuine sustained traffic above your tier's quota.\\n\\n## How to fix\\n\\n- Honour `Retry-After`. The TS SDK does this automatically (`KashRateLimitError.retryAfterSeconds`).\\n- Add jitter to retry timing so multiple consumers don't synchronise.\\n- For polling, switch to webhooks — every poll is a wasted request.\\n- If you consistently bump against the limit during normal operation, upgrade your tier — see [`docs/api-tiers.md`](../api-tiers.md) for the upgrade paths. The 60 → 300 req/min jump from `free` → `developer` is 5×, and `enterprise`/`mm` have no application-level cap.\\n\\n## Related codes\\n\\n- [`RATE_LIMIT_UNAVAILABLE`](./RATE_LIMIT_UNAVAILABLE.md) — 503; the rate-limit subsystem itself is temporarily unavailable. Distinct from `EXCEEDED`: `UNAVAILABLE` means we couldn't check your quota; `EXCEEDED` means we checked and you're over.\\n- [`WEBHOOK_REPLAY_LIMIT_REACHED`](./WEBHOOK_REPLAY_LIMIT_REACHED.md) — also 429, but specific to the webhook replay endpoint's amplification cap\\n\",\n \"RATE_LIMIT_UNAVAILABLE\": \"# `RATE_LIMIT_UNAVAILABLE`\\n\\n**HTTP status:** 503 · **Title:** \\\"Rate limit subsystem unavailable\\\" · **Retry-After:** 1s\\n\\n## When it fires\\n\\nThe rate-limit subsystem (Redis-backed token bucket / sliding window) is temporarily unavailable, and the per-task circuit breaker is still in its CLOSED state — i.e., this is a transient Redis blip, not a sustained outage.\\n\\nThe API fails CLOSED here because your rate limit IS your per-tier quota (a paid product feature). Serving traffic without enforcing the cap during a blip would silently leak quota to free-tier callers. The safe-for-business default is to refuse the request, surface a typed retryable error, and let your client retry.\\n\\nThis is the **transient blip** counterpart to the sustained-outage [`DEPENDENCY_UNAVAILABLE`](./DEPENDENCY_UNAVAILABLE.md) — same family, different policy: a transient blip in the limiter is fail-CLOSED (we know the limit must apply but can't check), while a sustained outage causes the circuit breaker to open and the API to fail-OPEN (serve traffic without quota enforcement, paged to ops).\\n\\n## Why it happens\\n\\n- A momentary ElastiCache Redis network blip — typically clears in `<100ms`.\\n- Pool acquisition contention under burst — a Lua call queued for a free connection past the per-call timeout.\\n- A momentary failover during a Redis cluster maintenance window.\\n\\nIf Redis stays unhealthy past the circuit breaker's failure threshold (10 consecutive failures), the breaker OPENs and requests start passing through fail-OPEN instead of returning 503. Ops gets paged on `kash_public_api_rate_limit_redis_circuit_state == 2`.\\n\\n## How to fix\\n\\n- **Honour `Retry-After: 1`.** The TS SDK's default retry policy already does this — it auto-retries 5xx responses with exponential backoff, so most blips are invisible to your application code. If you've disabled SDK retries (`maxRetries: 0`), wire your own backoff loop.\\n- **Don't treat this as quota exhaustion.** Distinct from [`RATE_LIMIT_EXCEEDED`](./RATE_LIMIT_EXCEEDED.md) (429): `EXCEEDED` is \\\"you're over your tier's cap\\\"; `UNAVAILABLE` is \\\"we couldn't check, so we won't risk leaking your cap.\\\" Your dashboards / billing UI should NOT surface `RATE_LIMIT_UNAVAILABLE` as a quota event.\\n- If you see a sustained rate of these in your client logs (>1/min over several minutes), that's the threshold where Kash ops should already be paged — file a bug at https://github.com/KashDAO/sdk-typescript/issues with the requestId if you want a status check.\\n\\n## Response headers\\n\\n- `Retry-After: 1` — Stripe-style short retry; SDK respects it automatically.\\n- `X-API-Version`, `X-Request-Id` — standard cross-cutting headers.\\n- The `X-RateLimit-*` family is NOT emitted on a 503 (we don't have a quota answer to attach).\\n\\n## Related codes\\n\\n- [`RATE_LIMIT_EXCEEDED`](./RATE_LIMIT_EXCEEDED.md) — 429, you went over your quota.\\n- [`DEPENDENCY_UNAVAILABLE`](./DEPENDENCY_UNAVAILABLE.md) — 503, infrastructure-level dependency outage outside the rate-limit path.\\n\",\n \"REQUEST_SIGNATURE_INVALID\": \"# `REQUEST_SIGNATURE_INVALID`\\n\\n**HTTP status:** 401 · **Title:** \\\"Request signature invalid\\\"\\n\\n## When it fires\\n\\n`X-Kash-Signature` was present but did not verify against the API key plaintext over the canonical signing input `${ts}.${method}.${path}.${body}`.\\n\\nSigning is **opt-in**: requests without `X-Kash-Signature` skip this check entirely. Once present, it MUST verify.\\n\\n## Why it happens\\n\\n- The body was modified in transit (a transparent proxy that re-encoded JSON, an HTTP client that renormalised whitespace).\\n- The timestamp is outside the ±5 min tolerance (clock skew).\\n- The signature was computed over the wrong input — common mistake: signing the URL instead of the path-only, or omitting the leading slash on `path`.\\n- The wrong secret was used (e.g., the `webhook_secret` instead of the API key plaintext).\\n\\n## How to fix\\n\\n- Canonical input format: `${unixMillis}.${UPPERCASE_METHOD}.${pathWithLeadingSlash}.${rawBody}` (raw body bytes, not re-serialised JSON).\\n- Key the HMAC with the API key plaintext (the same value as `X-API-Key`).\\n- Header format: `X-Kash-Signature: t=<unixMillis>,v1=<hexHmacSha256>`.\\n- Sync your clock — NTP. If you're consistently 6+ minutes off, fix the host clock.\\n- Check the `signatureReason` extension field on the problem response — it pinpoints which check failed (`stale`, `mismatch`, `malformed`).\\n\\nSee `apps/public-api/README.md` § Optional request body signing for the full reference.\\n\\n## Related codes\\n\\n- [`API_KEY_MISSING`](./API_KEY_MISSING.md) / [`API_KEY_INVALID`](./API_KEY_INVALID.md) — earlier failure points\\n\",\n \"REQUEST_TIMEOUT\": \"# `REQUEST_TIMEOUT`\\n\\n**HTTP status:** 504 · **Title:** \\\"Gateway timeout\\\" · **Retry-After:** 5s\\n\\n## When it fires\\n\\nThe per-request server timeout fired (default 30 s). Bounded by `bodyLimit` + Fastify timeout config to keep slow upstreams from blocking healthy traffic.\\n\\n## Why it happens\\n\\n- An unusually slow downstream call (Postgres lock contention, RPC latency spike, etc.).\\n- A request that does too much work in one call — most reads complete in <100 ms.\\n- Network path degradation between you and our edge (rare but possible).\\n\\n## How to fix\\n\\n- Retry once with backoff. Most timeouts are transient.\\n- If you're consistently timing out on the same endpoint, narrow the request — pass `limit` parameters, fetch one resource at a time.\\n- For long-running operations, use webhooks instead of waiting on the response.\\n\\n## Related codes\\n\\n- [`DEPENDENCY_UNAVAILABLE`](./DEPENDENCY_UNAVAILABLE.md), [`INTERNAL_ERROR`](./INTERNAL_ERROR.md)\\n\",\n \"RESOURCE_NOT_FOUND\": \"# `RESOURCE_NOT_FOUND`\\n\\n**HTTP status:** 404 · **Title:** \\\"Not Found\\\"\\n\\n## When it fires\\n\\nA request for a specific resource (trade, key, webhook event, etc.) didn't find a matching row that the caller is authorised to see.\\n\\nThis is the generic catch-all 404. Specific resource types may have their own dedicated codes (e.g., [`MARKET_NOT_FOUND`](./MARKET_NOT_FOUND.md)) where there's value in distinguishing the resource type to the caller.\\n\\n## Why it happens\\n\\n- The id is wrong (typo, cross-environment confusion).\\n- The resource exists but belongs to a different user — we return 404 (not 403) deliberately, so attackers can't probe for the existence of resources they don't own.\\n- The resource was deleted.\\n\\n## How to fix\\n\\n- Re-check the id (capitalisation, hyphens, environment).\\n- If the resource should exist but you can't see it, confirm you're authenticated as the owner.\\n- For webhook events: events are retained for 90 days; older events return 404.\\n\\n## Related codes\\n\\n- [`MARKET_NOT_FOUND`](./MARKET_NOT_FOUND.md) — specific case for markets\\n\",\n \"ROUTE_DISABLED\": \"# `ROUTE_DISABLED`\\n\\n**HTTP status:** 503 · **Title:** \\\"Route disabled\\\" · **Retry-After:** 60s\\n\\n## When it fires\\n\\nA single route is disabled by ops via a per-route kill switch. Distinct from the coarse [`API_TRADE_PROCESSING_HALTED`](./API_TRADE_PROCESSING_HALTED.md) so SDK consumers can branch on \\\"my entire trading capability is offline\\\" vs \\\"just one endpoint.\\\"\\n\\nThe Problem detail's `extensions.flag` field carries the kill-switch name (e.g., `\\\"api-quotes-read\\\"`) so you can tell exactly which route is gated.\\n\\n## Why it happens\\n\\n- Targeted rollback after a route-specific bug shipped.\\n- Pre-deploy lockdown of one endpoint while the rest of the API stays live.\\n- Capacity-driven shedding (rare — usually we'd raise rate limits first).\\n\\n## How to fix\\n\\n- Honour `Retry-After`. The flag is binary; polling won't help.\\n- Read the `extensions.flag` field — if it's an endpoint you don't actually need, ignore it; other endpoints are fine.\\n- For mission-critical endpoints, contact support to understand the ETA.\\n\\n## Related codes\\n\\n- [`API_TRADE_PROCESSING_HALTED`](./API_TRADE_PROCESSING_HALTED.md) — coarser kill switch covering all trade writes\\n- [`DEPENDENCY_UNAVAILABLE`](./DEPENDENCY_UNAVAILABLE.md) — distinct: this is \\\"ops chose to disable\\\", that's \\\"infra is broken\\\"\\n\",\n \"SMART_ACCOUNT_NOT_PROVISIONED\": \"# `SMART_ACCOUNT_NOT_PROVISIONED`\\n\\n**HTTP status:** 409 · **Title:** \\\"Smart account not provisioned\\\"\\n\\n## When it fires\\n\\nThe actor (user or organization) doesn't have a smart account row yet, so trades can't execute on-chain.\\n\\n## Why it happens\\n\\n- New account that hasn't completed the wallet-creation step in the webapp.\\n- An organization that's enabled the API but hasn't provisioned its own org-level smart account.\\n- The smart-account worker is backlogged — provisioning is async.\\n\\n## How to fix\\n\\n- For users: complete the wallet setup flow in the webapp (Settings → Wallet). Smart account provisioning is async — typically a few seconds.\\n- For orgs: an admin needs to provision the org smart account via the org settings page.\\n- If you provisioned recently and still see this error, retry after 30 s. If it persists past a minute, contact support with your `requestId`.\\n\\n## Related codes\\n\\n- [`WALLET_DELEGATION_NOT_ENABLED`](./WALLET_DELEGATION_NOT_ENABLED.md) — smart account exists but Privy delegation is off\\n- [`INSUFFICIENT_BALANCE`](./INSUFFICIENT_BALANCE.md) — smart account provisioned but no USDC\\n\",\n \"SPENDING_LIMIT_EXCEEDED\": \"# `SPENDING_LIMIT_EXCEEDED`\\n\\n**HTTP status:** 409 · **Title:** \\\"Spending limit exceeded\\\"\\n\\n## When it fires\\n\\nThe trade would exceed one of the API key's spending caps:\\n\\n- `per_trade_limit_usdc` — single-trade maximum\\n- `daily_spend_limit_usdc` — rolling 24-hour cumulative maximum across all trades\\n\\nThe `detail` field specifies which cap fired and by how much.\\n\\n## Why it happens\\n\\n- A bot iteration that computed a larger order than expected (e.g., signal scaling factor was off).\\n- Cumulative drift over a busy day finally pushed a moderate trade over the daily cap.\\n- The cap was lowered (by you or an admin) since you last designed your sizing logic.\\n\\n## How to fix\\n\\n- Inspect your key's caps in Settings → API Keys → Edit.\\n- For per-trade overruns: split the trade into smaller chunks under the cap.\\n- For daily overruns: wait for the rolling window to clear, or upgrade your tier (see \\\"How to raise the cap\\\" below).\\n- Programmatically: every key's caps are returned in the issuance response — cache them so your client knows the headroom.\\n\\n## How to raise the cap\\n\\nThe cap is set by your API key's tier. See [`docs/api-tiers.md`](../api-tiers.md) for the full tier matrix and rationale, then:\\n\\n- **`free` → `developer`** (10× headroom — $1k → $10k daily, $500 → $2.5k per-trade): email [support@kash.bot](mailto:support@kash.bot) for early access. Self-serve via Stripe is planned ([`docs/todo/public-surface/PUBLIC_API_TIER_BILLING.md`](../todo/PUBLIC_API_TIER_BILLING.md)).\\n- **`developer` → `enterprise`** ($100k daily, $25k per-trade, custom rate limit): [contact sales](mailto:sales@kash.bot) — pricing is contractual.\\n- **MM-tier counterparty terms** ($1M daily, $50k per-trade): [contact partnerships](mailto:partnerships@kash.bot) — KYB and ToS attestation required; this is a relationship, not a price tier.\\n\\nThe caps are not punitive — they exist for blast-radius and AMM-stability reasons documented in [`docs/api-tiers.md`](../api-tiers.md). When you outgrow your tier, the cap is the signal to upgrade, not a problem to work around.\\n\\n## Related codes\\n\\n- [`AMOUNT_TOO_LARGE`](./AMOUNT_TOO_LARGE.md) — platform-wide cap, separate from your key's caps\\n- [`INSUFFICIENT_BALANCE`](./INSUFFICIENT_BALANCE.md) — wallet balance too low (different failure surface)\\n\",\n \"TRADE_NOT_AWAITING_CONFIRMATION\": \"# `TRADE_NOT_AWAITING_CONFIRMATION`\\n\\n**HTTP status:** 409 · **Title:** \\\"Trade not awaiting confirmation\\\"\\n\\n## When it fires\\n\\n`POST /v1/trades/:id/confirm` was called for a trade that's not in `pending_confirmation` status.\\n\\n## Why it happens\\n\\n- The trade was already confirmed (you'll see this if you accidentally call confirm twice) — see also [`CONFIRMATION_TOKEN_USED`](./CONFIRMATION_TOKEN_USED.md).\\n- The trade was below your tier's high-value threshold and never required confirmation in the first place.\\n- The trade has progressed past confirmation into `pending` / `executing` / a terminal state.\\n- The confirmation window expired and the trade was auto-cancelled — see [`CONFIRMATION_EXPIRED`](./CONFIRMATION_EXPIRED.md).\\n\\n## How to fix\\n\\n- Check the trade's current `status` via `GET /v1/trades/:id`. The lifecycle is: `pending_confirmation` → `pending` → `executing` → `completed`/`failed`.\\n- If the trade is `completed`, there's nothing more to do.\\n- If the trade is `cancelled`, re-create it and confirm within the new window.\\n\\n## Related codes\\n\\n- [`CONFIRMATION_EXPIRED`](./CONFIRMATION_EXPIRED.md), [`CONFIRMATION_TOKEN_INVALID`](./CONFIRMATION_TOKEN_INVALID.md), [`CONFIRMATION_TOKEN_USED`](./CONFIRMATION_TOKEN_USED.md)\\n\",\n \"VALIDATION_FAILED\": \"# `VALIDATION_FAILED`\\n\\n**HTTP status:** 400 · **Title:** \\\"Validation failed\\\"\\n\\n## When it fires\\n\\nThe request body failed Zod schema parse. One or more fields are missing, malformed, or out of range.\\n\\n## Why it happens\\n\\n- Required field is missing or `null`.\\n- Field has the wrong type (e.g., `amount` sent as a number instead of a string — we use string for arbitrary-precision USDC amounts).\\n- Field violates a constraint (UUID format, enum value, min/max length).\\n- An unknown extra field was sent — we accept extras (`.passthrough()`) so this is rarely the cause; if it is, you'll see it called out in `detail`.\\n\\n## How to fix\\n\\n- The `detail` field carries the path of the first offending field (e.g., `body.outcomeIndex must be a non-negative integer`).\\n- Cross-reference with the OpenAPI spec at `https://api.kash.bot/v1/openapi.json` — every endpoint's request body is fully typed there.\\n- If you're using the TS SDK, you'd have caught this client-side via `KashValidationError` before the request went out.\\n\\n## Example\\n\\n```json\\n{\\n \\\"type\\\": \\\"https://docs.kash.bot/developer-docs/api-errors/VALIDATION_FAILED\\\",\\n \\\"title\\\": \\\"Validation failed\\\",\\n \\\"status\\\": 400,\\n \\\"code\\\": \\\"VALIDATION_FAILED\\\",\\n \\\"detail\\\": \\\"body.amount must be a string matching ^[0-9]+(\\\\\\\\.[0-9]+)?$\\\",\\n \\\"instance\\\": \\\"/v1/trades\\\",\\n \\\"requestId\\\": \\\"01HX-...\\\"\\n}\\n```\\n\\n## Related codes\\n\\n- [`AMOUNT_TOO_LARGE`](./AMOUNT_TOO_LARGE.md) — specific case for `amount` exceeding the per-trade cap\\n- [`OUTCOME_INDEX_INVALID`](./OUTCOME_INDEX_INVALID.md) — specific case for invalid `outcomeIndex`\\n\",\n \"WALLET_DELEGATION_NOT_ENABLED\": \"# `WALLET_DELEGATION_NOT_ENABLED`\\n\\n**HTTP status:** 409 · **Title:** \\\"Wallet delegation not enabled\\\"\\n\\n## When it fires\\n\\nA smart account exists for the actor, but Privy server-side delegation is disabled. The platform can't sign user-operations on behalf of the user, so trades can't execute.\\n\\nWe surface this at the API layer as a preflight check rather than failing at execution — better UX for the consumer.\\n\\n## Why it happens\\n\\n- The user revoked delegation in Privy after onboarding.\\n- A Privy app config change disabled delegation for a class of users.\\n- An incomplete onboarding flow that skipped the delegation grant step.\\n\\n## How to fix\\n\\n- The user must re-grant delegation via the webapp (Settings → Wallet → Re-enable). One-click; uses the existing Privy session.\\n- For orgs, the org admin re-grants on behalf of the org wallet.\\n- If the issue is platform-wide (multiple users), check the Privy app config or the status page.\\n\\n## Related codes\\n\\n- [`SMART_ACCOUNT_NOT_PROVISIONED`](./SMART_ACCOUNT_NOT_PROVISIONED.md) — earlier failure point\\n\",\n \"WEBHOOK_REPLAY_LIMIT_REACHED\": \"# `WEBHOOK_REPLAY_LIMIT_REACHED`\\n\\n**HTTP status:** 429 · **Title:** \\\"Replay limit reached\\\"\\n\\n## When it fires\\n\\n`POST /v1/webhooks/events/{id}/redeliver` was called for an event that has already been replayed up to its cap (default 5).\\n\\n## Why it happens\\n\\n- A bug in the consumer kept calling the redeliver endpoint in a loop instead of fixing the underlying delivery problem.\\n- An automation re-replayed without checking past attempts.\\n- A genuinely persistent need to replay an event many times — uncommon and usually signals an upstream issue.\\n\\nThe cap exists to stop a compromised key from looping the replay endpoint to flood its own customer endpoint or our delivery worker.\\n\\n## How to fix\\n\\n- Stop calling the redeliver endpoint for this event id. The cap is per-event, so other events still replay normally.\\n- Fix the underlying delivery failure first — check the event's delivery status via `GET /v1/trades/:id` (the `webhookDelivery` field) or via `kash-admin webhooks trace --event-id <id>` (operator command).\\n- If you legitimately need more replays for one event (e.g., recovering from a long endpoint outage), contact support — we can clear the counter manually.\\n\\n## Related codes\\n\\n- [`RATE_LIMIT_EXCEEDED`](./RATE_LIMIT_EXCEEDED.md) — general per-key rate limit\\n\",\n \"WEBHOOK_SECRET_ROTATION_COOLDOWN\": \"# `WEBHOOK_SECRET_ROTATION_COOLDOWN`\\n\\n**HTTP status:** 429 · **Title:** \\\"Rotation cooldown active\\\"\\n\\n## When it fires\\n\\n`POST /v1/auth/api-keys/me/webhook-secret/rotate` was called within the 60-second cooldown that follows a previous successful rotation.\\n\\n## Why the cooldown exists\\n\\nThis is the most important error to read carefully — the cooldown is not a rate-limit, it is a **rollback-safety** guard.\\n\\nEvery successful rotation moves the previous secret into `webhook_secret_previous` so operations can roll back within 7 days if the rotation breaks the customer's verifier. If you rotate twice in quick succession, the second rotation overwrites that rollback slot with the FIRST rotation's brand-new secret — a secret you may have never actually received in your response (e.g., the first POST timed out, your HTTP client retried, and the new plaintext from the first attempt was lost in flight). Rolling back later would restore a secret no verifier was ever configured for.\\n\\nThe cooldown forces this dangerous case into a visible 429 instead of silently corrupting the rollback guarantee.\\n\\n## How to fix\\n\\n- **If you successfully captured the new secret from the previous rotation:** wait for the `Retry-After` window to expire and call again. The cooldown is per-key, so other keys are unaffected.\\n- **If you did NOT capture the new secret from the previous rotation** (network timeout, lost response, dropped connection): **do not retry**. Contact support so we can rotate via an operator path that preserves the rollback chain. Re-rotating yourself would replace `webhook_secret_previous` with the secret you never received, breaking the only recovery path.\\n\\n## Response headers\\n\\n- `Retry-After: <seconds>` — how long to wait before re-attempting (computed from the prior rotation's timestamp).\\n\\n## Related codes\\n\\n- [`RATE_LIMIT_EXCEEDED`](./RATE_LIMIT_EXCEEDED.md) — generic per-user/per-IP rate limit (volume-based, not safety-based)\\n- [`DEPENDENCY_UNAVAILABLE`](./DEPENDENCY_UNAVAILABLE.md) — Postgres or downstream temporarily unreachable; safe to retry\\n\",\n});\n\nexport type ApiErrorCode = keyof typeof API_ERROR_DOCS;\n"],"mappings":";;;;;;;;;;;;;;;;;;;;;AAaA,SAAS,eAAe;;;ACFjB,IAAM,iBAAmD,OAAO,OAAO;AAAA,EAC5E,4BAA4B;AAAA,EAC5B,oBAAoB;AAAA,EACpB,mBAAmB;AAAA,EACnB,mBAAmB;AAAA,EACnB,qBAAqB;AAAA,EACrB,mBAAmB;AAAA,EACnB,mBAAmB;AAAA,EACnB,+BAA+B;AAAA,EAC/B,2BAA2B;AAAA,EAC3B,8BAA8B;AAAA,EAC9B,wBAAwB;AAAA,EACxB,8BAA8B;AAAA,EAC9B,2BAA2B;AAAA,EAC3B,0BAA0B;AAAA,EAC1B,4BAA4B;AAAA,EAC5B,2BAA2B;AAAA,EAC3B,kCAAkC;AAAA,EAClC,4BAA4B;AAAA,EAC5B,wBAAwB;AAAA,EACxB,sBAAsB;AAAA,EACtB,kBAAkB;AAAA,EAClB,kBAAkB;AAAA,EAClB,oBAAoB;AAAA,EACpB,wBAAwB;AAAA,EACxB,yBAAyB;AAAA,EACzB,uBAAuB;AAAA,EACvB,0BAA0B;AAAA,EAC1B,6BAA6B;AAAA,EAC7B,mBAAmB;AAAA,EACnB,sBAAsB;AAAA,EACtB,kBAAkB;AAAA,EAClB,iCAAiC;AAAA,EACjC,2BAA2B;AAAA,EAC3B,mCAAmC;AAAA,EACnC,qBAAqB;AAAA,EACrB,iCAAiC;AAAA,EACjC,gCAAgC;AAAA,EAChC,oCAAoC;AACtC,CAAC;;;AD5BD,IAAM,oBAAoB;AAS1B,SAAS,mBAAmB,MAAkC;AAC5D,SAAO,eAAe,IAAI;AAC5B;AAEO,IAAM,iBAAiB,IAAI,QAAQ,SAAS,EAChD,YAAY,uEAAuE,EACnF,SAAS,cAAc,wDAAwD,EAC/E;AAAA,EACC;AAAA,EACA;AAAA;AAAA;AAAA;AAAA;AAAA;AAMF,EACC,OAAO,CAAC,OAA0B,OAAO,QAAiB;AACzD,QAAM,UAAU,YAAY,GAAG;AAE/B,MAAI,MAAM,WAAW,GAAG;AACtB,QAAI,QAAQ,MAAM;AAChB,gBAAU,EAAE,OAAO,cAAc,CAAC;AAClC;AAAA,IACF;AACA,UAAM,QAAQ,YAAY,CAAC,QAAQ,WAAW,aAAa,CAAC;AAC5D,eAAW,SAAS,eAAe;AACjC,YAAM,KAAK;AAAA,QACT,MAAM;AAAA,QACN,SAAS,MAAM,SAAS,EAAE;AAAA,QAC1B,MAAM,cAAc,MAAM,QAAQ,KAAK,IAAI,MAAM,IAAI,IAAI;AAAA,MAC3D,CAAC;AAAA,IACH;AACA,UAAM,MAAM,SAAS,CAAC;AACtB;AAAA,EACF;AAQA,QAAM,UAAsB,MAAM,IAAI,CAAC,SAAS;AAC9C,UAAM,QAAQ,gBAAgB,IAAI;AAClC,QAAI,MAAO,QAAO,EAAE,QAAQ,OAAO,MAAM;AAMzC,UAAM,OAAO,mBAAmB,IAAI;AACpC,QAAI,KAAM,QAAO,EAAE,QAAQ,OAAO,MAAM,KAAK;AAC7C,UAAM,IAAI,SAAS,uBAAuB,IAAI,IAAI;AAAA,MAChD,MAAM;AAAA,MACN,aAAa;AAAA,MACb,YAAY;AAAA,IACd,CAAC;AAAA,EACH,CAAC;AAED,MAAI,QAAQ,MAAM;AAIhB,UAAM,UAAU,QAAQ;AAAA,MAAI,CAAC,MAC3B,EAAE,WAAW,QACT,EAAE,QACF;AAAA,QACE,MAAM,EAAE;AAAA,QACR,QAAQ;AAAA,QACR,SAAS,GAAG,iBAAiB,IAAI,EAAE,IAAI;AAAA,QACvC,MAAM,EAAE;AAAA,MACV;AAAA,IACN;AACA,cAAU,QAAQ,WAAW,IAAI,QAAQ,CAAC,IAAI,EAAE,OAAO,QAAQ,CAAC;AAChE;AAAA,EACF;AAEA,aAAW,CAAC,GAAG,QAAQ,KAAK,QAAQ,QAAQ,GAAG;AAC7C,QAAI,IAAI,EAAG,OAAM,EAAE;AACnB,UAAM,EAAE;AACR,QAAI,SAAS,WAAW,OAAO;AAG7B,YAAM,SAAS,IAAI;AACnB,YAAM,MAAM,IAAI,SAAS,iBAAiB,IAAI,SAAS,IAAI,EAAE,CAAC;AAC9D;AAAA,IACF;AACA,UAAM,EAAE,MAAM,IAAI;AAClB,UAAM,GAAG,MAAM,KAAK,MAAM,IAAI,CAAC,EAAE;AACjC,UAAM,KAAK,MAAM,IAAI,aAAa,CAAC,IAAI,MAAM,OAAO,EAAE;AACtD;AAAA,MACE,KAAK,MAAM,IAAI,aAAa,CAAC,IAAI,MAAM,cAAc,MAAM,QAAQ,KAAK,IAAI,MAAM,IAAI,IAAI,CAAC;AAAA,IAC7F;AACA,QAAI,MAAM,SAAS;AACjB,YAAM,KAAK,MAAM,IAAI,aAAa,CAAC,IAAI,MAAM,OAAO,EAAE;AAAA,IACxD;AACA,UAAM,EAAE;AACR,UAAM,MAAM,WAAW;AACvB,QAAI,MAAM,QAAQ,SAAS,GAAG;AAC5B,YAAM,EAAE;AACR,YAAM,MAAM,KAAK,mBAAmB,CAAC;AACrC,iBAAW,UAAU,MAAM,SAAS;AAClC,cAAM,OAAO,aAAa,MAAM,CAAC,EAAE;AAAA,MACrC;AAAA,IACF;AAAA,EACF;AACF,CAAC;AAEH,SAAS,aAAa,QAAgC;AACpD,UAAQ,OAAO,MAAM;AAAA,IACnB,KAAK;AACH,aAAO,GAAG,MAAM,KAAK,OAAO,OAAO,CAAC,WAAM,OAAO,WAAW;AAAA,IAC9D,KAAK;AACH,aAAO,GAAG,MAAM,KAAK,OAAO,QAAQ,CAAC,qBAAgB,OAAO,WAAW;AAAA,IACzE,KAAK;AACH,aAAO,GAAG,MAAM,KAAK,QAAQ,OAAO,KAAK,MAAM,OAAO,UAAU,GAAI,CAAC,CAAC,GAAG,CAAC,WAAM,OAAO,WAAW;AAAA,IACpG,KAAK;AACH,aAAO,GAAG,MAAM,KAAK,OAAO,GAAG,CAAC,WAAM,OAAO,WAAW;AAAA,IAC1D,KAAK;AACH,aAAO,GAAG,MAAM,KAAK,OAAO,KAAK,CAAC,yBAAoB,OAAO,WAAW;AAAA,EAC5E;AACF;","names":[]}
@@ -0,0 +1,18 @@
1
+ #!/usr/bin/env node
2
+ import {
3
+ parseOptionalPositiveFloat,
4
+ parseOptionalPositiveInt,
5
+ parsePositiveFloat,
6
+ parsePositiveInt,
7
+ readGlobals
8
+ } from "./chunk-LBIRQHX5.js";
9
+ import "./chunk-MIXOZU2S.js";
10
+ import "./chunk-UZNSYATZ.js";
11
+ export {
12
+ parseOptionalPositiveFloat,
13
+ parseOptionalPositiveInt,
14
+ parsePositiveFloat,
15
+ parsePositiveInt,
16
+ readGlobals
17
+ };
18
+ //# sourceMappingURL=global-options-XOLJUPTT.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":[],"sourcesContent":[],"mappings":"","names":[]}
@@ -0,0 +1,98 @@
1
+ #!/usr/bin/env node
2
+ import {
3
+ buildClient
4
+ } from "./chunk-QJMF73M5.js";
5
+ import {
6
+ readConfig
7
+ } from "./chunk-KMBMQIZ7.js";
8
+ import "./chunk-YHCG2SUC.js";
9
+ import "./chunk-BN2CUM42.js";
10
+ import {
11
+ readGlobals
12
+ } from "./chunk-LBIRQHX5.js";
13
+ import {
14
+ log,
15
+ print,
16
+ printJson,
17
+ style
18
+ } from "./chunk-VIADBYFY.js";
19
+ import "./chunk-MIXOZU2S.js";
20
+ import {
21
+ CliError,
22
+ EXIT_CODES,
23
+ toCliError
24
+ } from "./chunk-UZNSYATZ.js";
25
+
26
+ // src/commands/health.ts
27
+ import { Command } from "commander";
28
+ function buildHealthFailureSuggestion(baseUrl) {
29
+ if (baseUrl.includes("api.kash.bot") && !baseUrl.includes("api-staging.kash.bot")) {
30
+ return "Production (`https://api.kash.bot/v1`) is not yet live. For staging, get a test key at https://app.kash.bot and run `kash setup --api-key kash_test_\u2026` \u2014 the CLI auto-routes test keys to `https://api-staging.kash.bot/v1`. To pin a different host, pass `--base-url <url>` or set `KASH_BASE_URL`.";
31
+ }
32
+ return `The configured API host (${baseUrl}) was not reachable. Check connectivity and retry.`;
33
+ }
34
+ var healthCommand = new Command("health").description(
35
+ "Check connectivity to the Kash API. Exits 1 when not ok. Honors --timeout-ms (default 5000)."
36
+ ).addHelpText(
37
+ "after",
38
+ `
39
+ Examples:
40
+ $ kash health
41
+ $ kash health --json --quiet | jq -r '.ok'
42
+ $ kash --timeout-ms 2000 health
43
+ $ kash health || exit 1 # gate a script on reachability
44
+ `
45
+ ).action(async (_opts, cmd) => {
46
+ const globals = readGlobals(cmd);
47
+ const timeoutMs = globals.timeoutMs ?? 5e3;
48
+ let result;
49
+ try {
50
+ const { client } = await buildClient({ globals });
51
+ result = await client.healthCheck({ timeoutMs });
52
+ } catch (cause) {
53
+ throw toCliError(cause);
54
+ }
55
+ if (!result.ok) {
56
+ let suggestion = "The Kash API was not reachable. Check connectivity and retry.";
57
+ try {
58
+ const cfg = await readConfig({
59
+ ...globals.profile === void 0 ? {} : { profile: globals.profile },
60
+ ...globals.configPath === void 0 ? {} : { configPath: globals.configPath }
61
+ });
62
+ suggestion = buildHealthFailureSuggestion(cfg.baseUrl);
63
+ } catch {
64
+ }
65
+ if (globals.json) {
66
+ throw new CliError(
67
+ `Health check failed (${String(result.latencyMs)}ms elapsed${result.requestId === void 0 ? "" : `, request ${result.requestId}`}).`,
68
+ {
69
+ code: "NETWORK",
70
+ recoverable: true,
71
+ suggestion,
72
+ exitCode: EXIT_CODES.GENERIC,
73
+ ...result.requestId === void 0 ? {} : { requestId: result.requestId }
74
+ }
75
+ );
76
+ }
77
+ log.error(`Kash API not reachable. (${String(result.latencyMs)}ms elapsed)`);
78
+ if (result.requestId !== void 0) {
79
+ log.detail("Request ID", result.requestId);
80
+ }
81
+ throw new CliError("Health check failed.", {
82
+ code: "NETWORK",
83
+ recoverable: true,
84
+ suggestion,
85
+ exitCode: EXIT_CODES.GENERIC
86
+ });
87
+ }
88
+ if (globals.json) {
89
+ printJson(result);
90
+ return;
91
+ }
92
+ const versionTag = result.version === void 0 ? "" : ` (server ${result.version})`;
93
+ print(`${style.success("\u2713")} Kash API reachable in ${String(result.latencyMs)}ms${versionTag}`);
94
+ });
95
+ export {
96
+ healthCommand
97
+ };
98
+ //# sourceMappingURL=health-VZEIII74.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/commands/health.ts"],"sourcesContent":["/**\n * `kash health` — verify connectivity + key validity.\n *\n * Wraps the SDK's `KashClient.healthCheck`, which calls `GET /v1/health`\n * with a tight timeout and a single attempt (no retries — health\n * failures should be surfaced fast). The result is non-throwing: a\n * down server produces `ok: false`, not an exception, so scripts can\n * branch on the boolean without try/catch.\n *\n * Designed for two flows:\n *\n * 1. **Operator preflight** — run before a deploy or batch job to\n * confirm reachability and tag agreement.\n * 2. **AI-agent startup** — call once at boot, fail fast if Kash\n * is unreachable instead of mid-request.\n *\n * Exit code is `0` on `ok: true` and `1` on `ok: false`, so\n * `kash health || exit 1` is a valid one-liner gate.\n */\n\nimport { Command } from 'commander';\n\nimport { CliError, EXIT_CODES, toCliError } from '../errors.js';\nimport { buildClient } from '../utils/client.js';\nimport { readConfig } from '../utils/config-store.js';\nimport { readGlobals } from '../utils/global-options.js';\nimport { log, print, printJson, style } from '../utils/output.js';\n\n/**\n * Build a recovery suggestion for a failed health check based on the\n * resolved base URL. The two common failure modes pre-mainnet-launch:\n *\n * 1. User has no API key set → CLI defaults to `api.kash.bot/v1`,\n * which doesn't resolve until production deploys. Steer them at\n * a test key (which auto-routes to staging via\n * `inferBaseUrlFromApiKey`).\n * 2. User has a key but the host is genuinely unreachable (transient\n * network issue, corporate proxy, DNS hiccup) → generic retry.\n */\nfunction buildHealthFailureSuggestion(baseUrl: string): string {\n if (baseUrl.includes('api.kash.bot') && !baseUrl.includes('api-staging.kash.bot')) {\n return (\n 'Production (`https://api.kash.bot/v1`) is not yet live. ' +\n 'For staging, get a test key at https://app.kash.bot and run `kash setup ' +\n '--api-key kash_test_…` — the CLI auto-routes test keys to ' +\n '`https://api-staging.kash.bot/v1`. ' +\n 'To pin a different host, pass `--base-url <url>` or set `KASH_BASE_URL`.'\n );\n }\n return `The configured API host (${baseUrl}) was not reachable. Check connectivity and retry.`;\n}\n\nexport const healthCommand = new Command('health')\n .description(\n 'Check connectivity to the Kash API. Exits 1 when not ok. Honors --timeout-ms (default 5000).'\n )\n .addHelpText(\n 'after',\n `\nExamples:\n $ kash health\n $ kash health --json --quiet | jq -r '.ok'\n $ kash --timeout-ms 2000 health\n $ kash health || exit 1 # gate a script on reachability\n`\n )\n .action(async (_opts, cmd: Command) => {\n const globals = readGlobals(cmd);\n // Health-check default is tighter than the SDK's general 30s\n // default — failures should surface fast. The global\n // --timeout-ms still wins when set explicitly.\n const timeoutMs = globals.timeoutMs ?? 5000;\n\n let result;\n try {\n const { client } = await buildClient({ globals });\n result = await client.healthCheck({ timeoutMs });\n } catch (cause) {\n // The SDK's healthCheck only throws on caller-driven aborts\n // (KashAbortedError). Anything else is data, not an exception.\n throw toCliError(cause);\n }\n\n // **JSON-mode contract.** Always emit exactly ONE JSON object on\n // stdout — the previous code path emitted both the success-shape\n // `result` AND the error envelope on the !ok path, which broke\n // `jq` consumers. On the !ok path we throw a CliError with the\n // diagnostic data folded into `actions[]` so a single envelope\n // carries both the failure code and the latency / requestId\n // diagnostics.\n if (!result.ok) {\n // Resolve the config so the suggestion can reference the host the\n // CLI actually tried. Tolerate resolution failures (e.g. missing\n // config file) — fall back to a generic suggestion rather than\n // erroring inside an error handler.\n let suggestion = 'The Kash API was not reachable. Check connectivity and retry.';\n try {\n const cfg = await readConfig({\n ...(globals.profile === undefined ? {} : { profile: globals.profile }),\n ...(globals.configPath === undefined ? {} : { configPath: globals.configPath }),\n });\n suggestion = buildHealthFailureSuggestion(cfg.baseUrl);\n } catch {\n // ignore — keep generic suggestion\n }\n\n if (globals.json) {\n // Throw a CliError that carries the diagnostic data in its\n // envelope. The top-level emitError handler will print\n // exactly one JSON object on stdout. We pre-load the\n // recoverable + suggestion fields here rather than in the\n // catalog so the runtime data (latencyMs, requestId) is\n // visible to the agent reading the envelope.\n throw new CliError(\n `Health check failed (${String(result.latencyMs)}ms elapsed${result.requestId === undefined ? '' : `, request ${result.requestId}`}).`,\n {\n code: 'NETWORK',\n recoverable: true,\n suggestion,\n exitCode: EXIT_CODES.GENERIC,\n ...(result.requestId === undefined ? {} : { requestId: result.requestId }),\n }\n );\n }\n // Human mode: emit the error line + diagnostics on stderr,\n // then throw to drive the exit code through the top-level\n // emitError. The top-level handler prints the human error\n // footer on stderr too, so no stdout pollution.\n log.error(`Kash API not reachable. (${String(result.latencyMs)}ms elapsed)`);\n if (result.requestId !== undefined) {\n log.detail('Request ID', result.requestId);\n }\n throw new CliError('Health check failed.', {\n code: 'NETWORK',\n recoverable: true,\n suggestion,\n exitCode: EXIT_CODES.GENERIC,\n });\n }\n\n // ok path\n if (globals.json) {\n printJson(result);\n return;\n }\n const versionTag = result.version === undefined ? '' : ` (server ${result.version})`;\n print(`${style.success('✓')} Kash API reachable in ${String(result.latencyMs)}ms${versionTag}`);\n });\n"],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;AAoBA,SAAS,eAAe;AAmBxB,SAAS,6BAA6B,SAAyB;AAC7D,MAAI,QAAQ,SAAS,cAAc,KAAK,CAAC,QAAQ,SAAS,sBAAsB,GAAG;AACjF,WACE;AAAA,EAMJ;AACA,SAAO,4BAA4B,OAAO;AAC5C;AAEO,IAAM,gBAAgB,IAAI,QAAQ,QAAQ,EAC9C;AAAA,EACC;AACF,EACC;AAAA,EACC;AAAA,EACA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAOF,EACC,OAAO,OAAO,OAAO,QAAiB;AACrC,QAAM,UAAU,YAAY,GAAG;AAI/B,QAAM,YAAY,QAAQ,aAAa;AAEvC,MAAI;AACJ,MAAI;AACF,UAAM,EAAE,OAAO,IAAI,MAAM,YAAY,EAAE,QAAQ,CAAC;AAChD,aAAS,MAAM,OAAO,YAAY,EAAE,UAAU,CAAC;AAAA,EACjD,SAAS,OAAO;AAGd,UAAM,WAAW,KAAK;AAAA,EACxB;AASA,MAAI,CAAC,OAAO,IAAI;AAKd,QAAI,aAAa;AACjB,QAAI;AACF,YAAM,MAAM,MAAM,WAAW;AAAA,QAC3B,GAAI,QAAQ,YAAY,SAAY,CAAC,IAAI,EAAE,SAAS,QAAQ,QAAQ;AAAA,QACpE,GAAI,QAAQ,eAAe,SAAY,CAAC,IAAI,EAAE,YAAY,QAAQ,WAAW;AAAA,MAC/E,CAAC;AACD,mBAAa,6BAA6B,IAAI,OAAO;AAAA,IACvD,QAAQ;AAAA,IAER;AAEA,QAAI,QAAQ,MAAM;AAOhB,YAAM,IAAI;AAAA,QACR,wBAAwB,OAAO,OAAO,SAAS,CAAC,aAAa,OAAO,cAAc,SAAY,KAAK,aAAa,OAAO,SAAS,EAAE;AAAA,QAClI;AAAA,UACE,MAAM;AAAA,UACN,aAAa;AAAA,UACb;AAAA,UACA,UAAU,WAAW;AAAA,UACrB,GAAI,OAAO,cAAc,SAAY,CAAC,IAAI,EAAE,WAAW,OAAO,UAAU;AAAA,QAC1E;AAAA,MACF;AAAA,IACF;AAKA,QAAI,MAAM,4BAA4B,OAAO,OAAO,SAAS,CAAC,aAAa;AAC3E,QAAI,OAAO,cAAc,QAAW;AAClC,UAAI,OAAO,cAAc,OAAO,SAAS;AAAA,IAC3C;AACA,UAAM,IAAI,SAAS,wBAAwB;AAAA,MACzC,MAAM;AAAA,MACN,aAAa;AAAA,MACb;AAAA,MACA,UAAU,WAAW;AAAA,IACvB,CAAC;AAAA,EACH;AAGA,MAAI,QAAQ,MAAM;AAChB,cAAU,MAAM;AAChB;AAAA,EACF;AACA,QAAM,aAAa,OAAO,YAAY,SAAY,KAAK,YAAY,OAAO,OAAO;AACjF,QAAM,GAAG,MAAM,QAAQ,QAAG,CAAC,0BAA0B,OAAO,OAAO,SAAS,CAAC,KAAK,UAAU,EAAE;AAChG,CAAC;","names":[]}