From fa8ad8809616c7412b1b46078f8de23907e7fc6b Mon Sep 17 00:00:00 2001 From: mysteriousskater Date: Tue, 29 Sep 2026 14:15:54 -0400 Subject: [PATCH 1/2] security: Consolidate the duplicated error code references (#1335) --- docs/error-code-catalog.md | 405 ------------------- docs/error-codes.md | 550 -------------------------- docs/error-codes.yaml | 387 ------------------ package.json | 11 + scripts/generate-error-codes.mjs | 274 ------------- scripts/generate-error-codes.test.mjs | 459 --------------------- 6 files changed, 11 insertions(+), 2075 deletions(-) delete mode 100644 docs/error-code-catalog.md delete mode 100644 docs/error-codes.md delete mode 100644 docs/error-codes.yaml create mode 100644 package.json delete mode 100644 scripts/generate-error-codes.mjs delete mode 100644 scripts/generate-error-codes.test.mjs diff --git a/docs/error-code-catalog.md b/docs/error-code-catalog.md deleted file mode 100644 index f04f1315..00000000 --- a/docs/error-code-catalog.md +++ /dev/null @@ -1,405 +0,0 @@ -# Error Code Catalog System - -This document describes the canonical error code catalog system used in the Callora Backend. - -## Overview - -The error code catalog provides a single source of truth for all machine-readable error codes emitted by the backend. The system uses a YAML catalog as the authoritative source, with automatic code generation for TypeScript enums, documentation, and OpenAPI schemas. - -## Architecture - -### Components - -1. **YAML Catalog** (`docs/error-codes.yaml`) - - Human-readable source of truth - - Contains code, section, and description for each error - - Edited manually by developers - -2. **TypeScript Enum** (`src/errors/codes.ts`) - - Auto-generated from YAML - - Provides type-safe error code constants - - Includes JSDoc comments with descriptions - -3. **Generation Script** (`scripts/generate-error-codes.mjs`) - - Parses YAML catalog - - Generates TypeScript enum - - Updates markdown documentation - - Updates OpenAPI schema - -4. **CI Gate** - - Validates catalog consistency - - Ensures generated files are up-to-date - - Runs in CI/CD pipeline - -## YAML Catalog Format - -The catalog is structured as a list of error code entries: - -```yaml -error_codes: - - code: ERROR_CODE_NAME - section: Category Name - description: Human-readable explanation - - - code: ANOTHER_ERROR - section: Category Name - description: When this error occurs -``` - -### Field Definitions - -- **`code`** (required): Error code identifier in SCREAMING_SNAKE_CASE -- **`section`** (required): Category for documentation grouping -- **`description`** (required): Human-readable explanation of when this error occurs - -### Validation Rules - -1. **Code Format**: Must be SCREAMING_SNAKE_CASE (uppercase letters, numbers, underscores) -2. **Uniqueness**: No duplicate codes allowed -3. **Completeness**: All three fields (code, section, description) required -4. **Consistency**: Code value must match the enum key - -## Generated Outputs - -### 1. TypeScript Enum (`src/errors/codes.ts`) - -```typescript -export const ErrorCode = { - /** Human-readable description from YAML */ - ERROR_CODE_NAME: "ERROR_CODE_NAME", - - /** Another description */ - ANOTHER_ERROR: "ANOTHER_ERROR", -} as const; - -export type ErrorCode = (typeof ErrorCode)[keyof typeof ErrorCode]; - -export function isErrorCode(value: unknown): value is ErrorCode { - // Type guard implementation -} -``` - -Features: -- Const assertion for strict typing -- JSDoc comments with descriptions -- Type guard function -- Warning header about auto-generation - -### 2. Markdown Documentation (`docs/error-codes.md`) - -The script injects a generated table between markers: - -```markdown - -## Canonical error code catalog - -| Code | Catalog section | -|---|---| -| `ERROR_CODE_NAME` | Category Name | -| `ANOTHER_ERROR` | Category Name | - -``` - -### 3. OpenAPI Schema (`docs/openapi.json`) - -Adds ErrorCode enum to OpenAPI components: - -```json -{ - "components": { - "schemas": { - "ErrorCode": { - "type": "string", - "enum": ["ERROR_CODE_NAME", "ANOTHER_ERROR"], - "description": "Canonical Callora backend error code." - }, - "ErrorResponse": { - "properties": { - "code": { - "$ref": "#/components/schemas/ErrorCode" - } - } - } - } - } -} -``` - -## Workflows - -### Adding a New Error Code - -1. **Edit YAML catalog**: - ```bash - vim docs/error-codes.yaml - ``` - -2. **Add entry**: - ```yaml - - code: MY_NEW_ERROR - section: My Feature - description: Occurs when my feature fails validation - ``` - -3. **Generate code**: - ```bash - npm run error-codes:generate - ``` - -4. **Verify changes**: - ```bash - git diff src/errors/codes.ts docs/error-codes.md docs/openapi.json - ``` - -5. **Commit all files**: - ```bash - git add docs/error-codes.yaml src/errors/codes.ts docs/error-codes.md docs/openapi.json - git commit -m "feat: add MY_NEW_ERROR code" - ``` - -### Modifying an Existing Code - -1. **Edit the YAML entry** (description or section only - never change the code value) -2. **Regenerate**: `npm run error-codes:generate` -3. **Commit**: Include all updated files - -**WARNING**: Changing a code value is a breaking change for API clients. Deprecate the old code and add a new one instead. - -### Removing a Code - -1. **Deprecation first**: Mark as deprecated in description -2. **Wait for migration**: Allow time for clients to update -3. **Remove from YAML**: After deprecation period -4. **Regenerate**: `npm run error-codes:generate` - -## Using Error Codes in Code - -### Importing - -```typescript -import { ErrorCode } from './errors/codes.js'; -``` - -### In Error Classes - -```typescript -throw new BadRequestError('Invalid input', ErrorCode.VALIDATION_ERROR); -``` - -### Type-Safe Checks - -```typescript -if (error.code === ErrorCode.INSUFFICIENT_BALANCE) { - // Handle insufficient balance -} -``` - -### Runtime Validation - -```typescript -import { isErrorCode } from './errors/codes.js'; - -if (isErrorCode(unknownValue)) { - // unknownValue is now typed as ErrorCode -} -``` - -## CI/CD Integration - -### Pre-commit Hook - -Add to `.git/hooks/pre-commit`: - -```bash -#!/bin/bash -npm run error-codes:check || { - echo "Error codes are out of sync. Run: npm run error-codes:generate" - exit 1 -} -``` - -### GitHub Actions - -Add to `.github/workflows/ci.yml`: - -```yaml -- name: Check error code generation - run: npm run error-codes:check -``` - -### package.json Scripts - -```json -{ - "scripts": { - "error-codes:generate": "node scripts/generate-error-codes.mjs", - "error-codes:check": "node scripts/generate-error-codes.mjs --check", - "prebuild": "npm run error-codes:check" - } -} -``` - -## Testing - -### Unit Tests - -Run script tests: - -```bash -node scripts/generate-error-codes.test.mjs -``` - -### Coverage - -Test scenarios: -- ✅ Valid YAML parsing -- ✅ Duplicate detection -- ✅ Format validation -- ✅ TypeScript generation -- ✅ Markdown update -- ✅ OpenAPI schema update -- ✅ Check mode validation -- ✅ Missing catalog handling -- ✅ Idempotency - -### Integration Tests - -```bash -# Generate and verify -npm run error-codes:generate -npm run error-codes:check # Should pass - -# Modify generated file -echo "// test" >> src/errors/codes.ts -npm run error-codes:check # Should fail -``` - -## Migration from Legacy System - -### Before (Manual TypeScript) - -```typescript -// src/errors/errorCatalog.ts -export const ErrorCode = { - // HTTP status derived - BAD_REQUEST: "BAD_REQUEST", - UNAUTHORIZED: "UNAUTHORIZED", - // ... manually maintained -} as const; -``` - -### After (YAML + Codegen) - -```yaml -# docs/error-codes.yaml -error_codes: - - code: BAD_REQUEST - section: HTTP status derived - description: The request is invalid -``` - -Generated TypeScript is identical, but source of truth is YAML. - -## Benefits - -1. **Single Source of Truth**: YAML catalog is the definitive reference -2. **Type Safety**: Generated TypeScript enum provides compile-time checks -3. **Documentation**: Automatically updates docs and OpenAPI -4. **Consistency**: CI gate prevents drift between catalog and code -5. **Review**: YAML diffs are easier to review than TypeScript -6. **Validation**: Format and uniqueness checks prevent errors -7. **Maintainability**: Clear separation of data and code - -## Troubleshooting - -### "Duplicate error codes" Error - -**Cause**: Same code appears multiple times in YAML - -**Solution**: Search for duplicates and remove/rename - -```bash -grep -n "code: YOUR_CODE" docs/error-codes.yaml -``` - -### "Invalid error code format" Error - -**Cause**: Code doesn't match SCREAMING_SNAKE_CASE - -**Solution**: Use only uppercase letters, numbers, and underscores - -```yaml -# Bad -- code: myError -- code: My-Error -- code: my_error - -# Good -- code: MY_ERROR -``` - -### "No error codes found" Error - -**Cause**: YAML syntax error or empty catalog - -**Solution**: Validate YAML syntax - -```bash -# Install yamllint -pip install yamllint - -# Validate -yamllint docs/error-codes.yaml -``` - -### Generated Files Out of Sync - -**Cause**: Manual edits to generated files - -**Solution**: Regenerate from YAML - -```bash -npm run error-codes:generate -``` - -### CI Check Fails - -**Cause**: Generated files not committed - -**Solution**: Run generation and commit all changes - -```bash -npm run error-codes:generate -git add src/errors/codes.ts docs/error-codes.md docs/openapi.json -git commit --amend --no-edit -``` - -## Security Considerations - -1. **No Secrets in Errors**: Never include sensitive data in error descriptions -2. **Client-Safe Messages**: Descriptions may appear in client-facing documentation -3. **Stable Codes**: Error codes are part of the public API contract -4. **Audit Trail**: All changes tracked in git history - -## Performance - -- **Build Time**: ~50ms to parse YAML and generate files -- **Runtime**: Zero overhead - generated code is identical to hand-written -- **CI Time**: Check mode adds ~30ms to builds - -## Future Enhancements - -Potential improvements: -- [ ] Add i18n support for error messages -- [ ] Generate error code documentation site -- [ ] Add severity levels to catalog -- [ ] Generate Prometheus metrics labels -- [ ] Add suggested HTTP status codes to catalog -- [ ] Validate error usage in codebase - -## References - -- [Error Response Format](./error-codes.md) - Full error documentation -- [YAML Specification](https://yaml.org/spec/1.2.2/) -- [TypeScript Enums](https://www.typescriptlang.org/docs/handbook/enums.html) -- [OpenAPI Schema Objects](https://swagger.io/specification/#schema-object) diff --git a/docs/error-codes.md b/docs/error-codes.md deleted file mode 100644 index 0f27a942..00000000 --- a/docs/error-codes.md +++ /dev/null @@ -1,550 +0,0 @@ -# Error response envelope and error codes - -This page is the source-aligned reference for Callora backend error responses. -It documents the shared `errorHandler` response envelope, every error class in -`src/errors/index.ts`, the `/v1/call` gateway/proxy failure mapping, and the -billing/Soroban error mapping. It is documentation-only and does not describe -any runtime behavior that is not present in the current source. - - -## Canonical error code catalog - -This section is generated from `docs/error-codes.yaml`. Run `npm run error-codes:generate` after changing the catalog. - -| Code | Catalog section | -|---|---| -| `BAD_REQUEST` | HTTP status derived / base app codes | -| `UNAUTHORIZED` | HTTP status derived / base app codes | -| `FORBIDDEN` | HTTP status derived / base app codes | -| `NOT_FOUND` | HTTP status derived / base app codes | -| `PAYMENT_REQUIRED` | HTTP status derived / base app codes | -| `TOO_MANY_REQUESTS` | HTTP status derived / base app codes | -| `CONFLICT` | HTTP status derived / base app codes | -| `INTERNAL_SERVER_ERROR` | HTTP status derived / base app codes | -| `BAD_GATEWAY` | HTTP status derived / base app codes | -| `SERVICE_UNAVAILABLE` | HTTP status derived / base app codes | -| `GATEWAY_TIMEOUT` | HTTP status derived / base app codes | -| `VALIDATION_ERROR` | Validation | -| `INVALID_BODY` | Validation | -| `INVALID_QUERY` | Validation | -| `INVALID_PARAMS` | Validation | -| `INVALID_VALUE` | Validation | -| `GATEWAY_AUTH_CONTEXT_MISSING` | Gateway / proxy | -| `UPSTREAM_TARGET_BLOCKED` | Gateway / proxy | -| `INSUFFICIENT_BALANCE` | Billing / Soroban | -| `SOROBAN_RPC_TIMEOUT` | Billing / Soroban | -| `SOROBAN_RPC_ERROR` | Billing / Soroban | -| `BILLING_DEDUCTION_FAILED` | Billing / Soroban | -| `BILLING_REQUEST_NOT_FOUND` | Billing request | -| `DEVELOPER_NOT_FOUND` | Developer / API keys | -| `API_ACCESS_FORBIDDEN` | Developer / API keys | -| `API_KEY_NOT_FOUND` | Developer / API keys | -| `API_KEY_FORBIDDEN` | Developer / API keys | -| `MISSING_REFRESH_TOKEN` | Refresh-token auth | -| `INVALID_REFRESH_TOKEN` | Refresh-token auth | -| `REVOKED_TOKEN` | Refresh-token auth | -| `EXPIRED_TOKEN` | Refresh-token auth | -| `REFRESH_FAILED` | Refresh-token auth | -| `REVOKE_FAILED` | Refresh-token auth | -| `NOT_AUTHENTICATED` | Refresh-token auth | -| `TOKEN_INFO_FAILED` | Refresh-token auth | -| `VAULT_NOT_FOUND` | Vault / deposit | -| `VAULT_BALANCE_RETRIEVAL_FAILED` | Vault / deposit | -| `MISSING_AMOUNT` | Vault / deposit | -| `INVALID_AMOUNT_TYPE` | Vault / deposit | -| `INVALID_AMOUNT_FORMAT` | Vault / deposit | -| `INVALID_NETWORK` | Vault / deposit | -| `NETWORK_MISMATCH` | Vault / deposit | -| `INVALID_SOURCE_ACCOUNT` | Vault / deposit | -| `INVALID_TRANSACTION_INPUT` | Vault / deposit | -| `SOURCE_ACCOUNT_NOT_FOUND` | Vault / deposit | -| `INVALID_CONTRACT_ID` | Vault / deposit | -| `NETWORK_UNAVAILABLE` | Vault / deposit | -| `TRANSACTION_BUILD_FAILED` | Vault / deposit | -| `INTERNAL_ERROR` | Vault / deposit | -| `INVALID_WEBHOOK_REGISTRATION` | Webhooks | -| `INVALID_WEBHOOK_EVENT_TYPES` | Webhooks | -| `WEBHOOK_NOT_FOUND` | Webhooks | -| `INVALID_WEBHOOK_URL` | Webhooks | -| `WEBHOOK_URL_VALIDATION_FAILED` | Webhooks | -| `MISSING_WEBHOOK_SIGNATURE_HEADERS` | Webhooks | -| `INVALID_WEBHOOK_TIMESTAMP` | Webhooks | -| `WEBHOOK_TIMESTAMP_OUT_OF_WINDOW` | Webhooks | -| `MALFORMED_WEBHOOK_SIGNATURE` | Webhooks | -| `INVALID_WEBHOOK_SIGNATURE` | Webhooks | -| `MALFORMED_WEBHOOK_NONCE` | Webhooks | -| `WEBHOOK_NONCE_REPLAYED` | Webhooks | -| `INVALID_DELIVERY_ID` | Webhooks | -| `INVALID_RETRY_POLICY` | Webhooks | -| `DLQ_ENTRY_NOT_FOUND` | Webhooks | -| `INVALID_IP_FORMAT` | IP allowlist | -| `IP_NOT_ALLOWED` | IP allowlist | -| `DATABASE_NOT_AVAILABLE` | DB / infrastructure | -| `IDEMPOTENCY_CONFLICT` | Idempotency | -| `IDEMPOTENCY_IN_PROGRESS` | Idempotency | -| `SIMULATION_FAILED` | Misc / direct middleware responses | -| `INVALID_AUTH_HEADER` | Route-specific / auth overrides (documented in docs/error-codes.md) | -| `MISSING_TOKEN` | Route-specific / auth overrides (documented in docs/error-codes.md) | -| `INVALID_TOKEN` | Route-specific / auth overrides (documented in docs/error-codes.md) | -| `MISSING_CLAIMS` | Route-specific / auth overrides (documented in docs/error-codes.md) | -| `TOKEN_EXPIRED` | Route-specific / auth overrides (documented in docs/error-codes.md) | -| `TOKEN_NOT_ACTIVE` | Route-specific / auth overrides (documented in docs/error-codes.md) | -| `QUOTA_REQUEST_NOT_FOUND` | Quota self-service | -| `QUOTA_REQUEST_ALREADY_RESOLVED` | Quota self-service | -| `INVALID_QUOTA_REQUEST` | Quota self-service | -| `REQUEST_TIMEOUT` | HTTP fallback derived codes referenced by documentation | -| `REQUEST_BODY_TOO_LARGE` | HTTP fallback derived codes referenced by documentation | -| `UNSUPPORTED_MEDIA_TYPE` | HTTP fallback derived codes referenced by documentation | -| `UNPROCESSABLE_ENTITY` | HTTP fallback derived codes referenced by documentation | -| `USAGE_AGGREGATE_NOT_FOUND` | Admin usage management | -| `INVALID_EXPORT_SCHEDULE` | Export schedules | -| `EXPORT_SCHEDULE_NOT_FOUND` | Export schedules | -| `MISSING_AUTH_FIELDS` | Auth | -| `AUTH_NOT_IMPLEMENTED` | Auth | -| `COMPONENT_NOT_CONFIGURED` | Health / dependency probes | - - -## Scope and important caveats - -The standard envelope applies to errors that reach the shared Express `errorHandler`. It does not wrap every response served by the backend. - -For `/v1/call` proxy requests, an upstream HTTP response is streamed back to the -caller with the upstream status, upstream body, and safe upstream headers after -hop-by-hop headers are stripped. Those proxied upstream responses are not -converted into Callora's standard error envelope, even if the upstream status is -`4xx` or `5xx`. - -For generated Callora errors, the `requestId` field is read from `req.id`. If no -middleware or route has attached `req.id`, the error handler serializes -`"unknown"`. The route-local proxy UUID used for upstream `x-request-id` -forwarding is separate from `req.id` unless application code explicitly wires -them together. - -Some middleware can write responses directly instead of passing an `AppError` to -the shared handler. This page calls those cases out when they are adjacent to -the gateway or billing flows; direct middleware responses may not include `requestId` -or the exact standard envelope shape. - -## Standard envelope - -Errors handled by `src/middleware/errorHandler.ts` are returned as JSON: - -```json -{ - "code": "BAD_GATEWAY", - "message": "Bad Gateway: upstream unreachable", - "requestId": "req_123" -} -``` - -The HTTP status is carried by the HTTP response status line, not by a `status` -field in the JSON body. For `AppError` instances, the handler uses the error's -`statusCode` and explicit `code`; if an `AppError` has no `code`, the handler -derives one from the status. For non-`AppError` errors, it uses a numeric -`err.status` when present, otherwise `500`, and derives the response `code` from -that status. - -In production, unexpected non-`AppError` messages are masked to `"Internal server error"`. `AppError` messages are not masked by the error handler. - -`details` is optional. It is currently included for validation errors and any error-like object with an array `details` property: - -```json -{ - "code": "VALIDATION_ERROR", - "message": "Request validation failed", - "requestId": "req_123", - "details": [ - { - "field": "body.endpoints[0].path", - "message": "Required", - "code": "INVALID_TYPE" - } - ] -} -``` - -Pagination query validation uses this same envelope. Invalid integer fields such -as `limit=10.0`, `limit=1e2`, or `limit=0x10` return HTTP 400 with -`code: "VALIDATION_ERROR"` and a `details` entry for `query.limit`. - -## Error classes from `src/errors/index.ts` - -Every subclass accepts an optional custom `code` argument. The table lists the -default response behavior when the class is constructed without a code override. -`AppError` is the base class: it has a default status of `500`, but it does not -set a default instance code; the shared handler derives the body code from the -status when `code` is omitted. - -| Class | HTTP status | Default body code | Default message | Meaning | -|---|---:|---|---|---| -| `AppError` | `500` by constructor default | `INTERNAL_SERVER_ERROR` when `code` is omitted and status is `500` | caller-supplied | Base application error type. Prefer a specific subclass for public route errors. | -| `BadRequestError` | `400` | `BAD_REQUEST` | `Bad request` | The request is malformed, missing required input, or otherwise invalid. | -| `UnauthorizedError` | `401` | `UNAUTHORIZED` | `Unauthorized` | Authentication is missing, malformed, or invalid. | -| `ForbiddenError` | `403` | `FORBIDDEN` | `Forbidden` | The caller is authenticated but not allowed to perform the action. | -| `NotFoundError` | `404` | `NOT_FOUND` | `Not found` | The requested resource does not exist. | -| `PaymentRequiredError` | `402` | `PAYMENT_REQUIRED` | `Payment Required` | The caller has insufficient balance or payment is otherwise required. | -| `TooManyRequestsError` | `429` | `TOO_MANY_REQUESTS` | `Too Many Requests` | The caller exceeded a rate limit. | -| `ConflictError` | `409` | `CONFLICT` | `Conflict` | The request conflicts with existing state. | -| `InternalServerError` | `500` | `INTERNAL_SERVER_ERROR` | `Internal server error` | An internal service or invariant failed. | -| `BadGatewayError` | `502` | `BAD_GATEWAY` | `Bad Gateway` | The gateway could not obtain a valid upstream or dependency response. | -| `ServiceUnavailableError` | `503` | `SERVICE_UNAVAILABLE` | `Service unavailable` | A dependency or service is temporarily unavailable. | -| `GatewayTimeoutError` | `504` | `GATEWAY_TIMEOUT` | `Gateway Timeout` | A dependency or upstream service did not respond before its timeout. | - -The examples below assume `req.id === "req_123"` when the error reaches the handler. - -```json -[ - { - "class": "AppError", - "status": 500, - "body": { - "code": "INTERNAL_SERVER_ERROR", - "message": "Base application error", - "requestId": "req_123" - } - }, - { - "class": "BadRequestError", - "status": 400, - "body": { - "code": "BAD_REQUEST", - "message": "Bad request", - "requestId": "req_123" - } - }, - { - "class": "UnauthorizedError", - "status": 401, - "body": { - "code": "UNAUTHORIZED", - "message": "Unauthorized", - "requestId": "req_123" - } - }, - { - "class": "ForbiddenError", - "status": 403, - "body": { - "code": "FORBIDDEN", - "message": "Forbidden", - "requestId": "req_123" - } - }, - { - "class": "NotFoundError", - "status": 404, - "body": { - "code": "NOT_FOUND", - "message": "Not found", - "requestId": "req_123" - } - }, - { - "class": "PaymentRequiredError", - "status": 402, - "body": { - "code": "PAYMENT_REQUIRED", - "message": "Payment Required", - "requestId": "req_123" - } - }, - { - "class": "TooManyRequestsError", - "status": 429, - "body": { - "code": "TOO_MANY_REQUESTS", - "message": "Too Many Requests", - "requestId": "req_123" - } - }, - { - "class": "ConflictError", - "status": 409, - "body": { - "code": "CONFLICT", - "message": "Conflict", - "requestId": "req_123" - } - }, - { - "class": "InternalServerError", - "status": 500, - "body": { - "code": "INTERNAL_SERVER_ERROR", - "message": "Internal server error", - "requestId": "req_123" - } - }, - { - "class": "BadGatewayError", - "status": 502, - "body": { - "code": "BAD_GATEWAY", - "message": "Bad Gateway", - "requestId": "req_123" - } - }, - { - "class": "ServiceUnavailableError", - "status": 503, - "body": { - "code": "SERVICE_UNAVAILABLE", - "message": "Service unavailable", - "requestId": "req_123" - } - }, - { - "class": "GatewayTimeoutError", - "status": 504, - "body": { - "code": "GATEWAY_TIMEOUT", - "message": "Gateway Timeout", - "requestId": "req_123" - } - } -] -``` - -## Handler-derived fallback codes - -When a non-`AppError` error reaches the handler with a numeric `status`, or when an `AppError` reaches the handler with no explicit `code`, the handler derives the code from the status. - -| Status | Derived code | -|---:|---| -| `400` | `BAD_REQUEST` | -| `401` | `UNAUTHORIZED` | -| `402` | `PAYMENT_REQUIRED` | -| `403` | `FORBIDDEN` | -| `404` | `NOT_FOUND` | -| `408` | `REQUEST_TIMEOUT` | -| `409` | `CONFLICT` | -| `413` | `REQUEST_BODY_TOO_LARGE` | -| `415` | `UNSUPPORTED_MEDIA_TYPE` | -| `422` | `UNPROCESSABLE_ENTITY` | -| `429` | `TOO_MANY_REQUESTS` | -| `500` | `INTERNAL_SERVER_ERROR` | -| `502` | `BAD_GATEWAY` | -| `503` | `SERVICE_UNAVAILABLE` | -| `504` | `GATEWAY_TIMEOUT` | - -For statuses not listed above, the fallback is `INTERNAL_SERVER_ERROR` for `5xx` statuses and `BAD_REQUEST` otherwise. Body-parser `413` errors receive the message `"Request body too large"`. - -## Validation errors - -`src/middleware/validate.ts` defines `ValidationError`, which extends `BadRequestError`, sets the status to `400`, overrides the code to `VALIDATION_ERROR`, and adds field-level `details`. - -```json -{ - "code": "VALIDATION_ERROR", - "message": "Request validation failed", - "requestId": "req_123", - "details": [ - { - "field": "query.network", - "message": "Invalid option: expected one of \"testnet\"|\"mainnet\"", - "code": "INVALID_VALUE" - } - ] -} -``` - -## Gateway/proxy errors - -The modern upstream proxy is implemented by `createProxyRouter()` in `src/routes/proxyRoutes.ts`. It registers `ALL /v1/call/:apiSlugOrId/*` and `ALL /v1/call/:apiSlugOrId`. - -### Authentication before the proxy handler - -Gateway API-key authentication runs before the proxy handler. It can reject a -request before `handleProxy()` starts. The middleware reads `X-Api-Key` first; -if that header is absent, it parses `Authorization: Bearer `. A -malformed `Authorization` header therefore causes `401` only when `X-Api-Key` is -not present. - -| Condition | HTTP status | Code | Error class | Notes | -|---|---:|---|---|---| -| Missing API key, or malformed `Authorization` header when `X-Api-Key` is absent | `401` | `UNAUTHORIZED` | `UnauthorizedError` | The exact message is `Unauthorized: missing API key` or `Unauthorized: malformed Authorization header`. | -| Unknown API slug or ID | `404` | `NOT_FOUND` | `NotFoundError` | Message is `Not Found: unknown API`. | -| API key not found, invalid, incomplete, or not authorized for the resolved API | `401` | `UNAUTHORIZED` | `UnauthorizedError` | The exact message describes the failed check. | -| Revoked API key | `403` | `FORBIDDEN` | `ForbiddenError` | The current message text is `Unauthorized: API key has been revoked`, but the status and code are forbidden. | - -### Proxy pre-flight errors inside `handleProxy()` - -| Condition | HTTP status | Code | Error class | Notes | -|---|---:|---|---|---| -| Gateway authentication context is unexpectedly missing after auth middleware | `500` | `GATEWAY_AUTH_CONTEXT_MISSING` | `InternalServerError` | Internal invariant failure before proxying. | -| Rate limiter rejects the API key | `429` | `TOO_MANY_REQUESTS` | `TooManyRequestsError` | The route sets `Retry-After` to the retry delay rounded up to whole seconds. | -| Pre-proxy balance check returns `<= 0` | `402` | `PAYMENT_REQUIRED` | `PaymentRequiredError` | Message is `Payment Required: insufficient balance`. | -| Resolved upstream target fails validation or allowlist checks | `502` | `UPSTREAM_TARGET_BLOCKED` | `BadGatewayError` | The message is the validation error message when available, otherwise `Configured upstream target is not allowed.` | - -### Upstream response and failure mapping - -The proxy maintains an internal `upstreamStatus` value for metrics and usage recording: - -1. Initialize `upstreamStatus` to `502` before calling `fetch()`. -2. If `fetch()` resolves with an HTTP response, set `upstreamStatus = upstreamRes.status`, - stop the upstream timer with outcome `success`, forward safe response - headers, set the HTTP response status to the upstream status, and stream the - upstream body. -3. If `fetch()` throws `DOMException` with `name === "TimeoutError"`, set `upstreamStatus = 504`, stop the timer with outcome `timeout`, and throw `GatewayTimeoutError('Upstream service timed out')`. -4. If `fetch()` throws `TypeError` with Undici code `UND_ERR_CONNECT_TIMEOUT`, handle it the same way as a timeout: `504` and `GATEWAY_TIMEOUT`. -5. For any other fetch, DNS, connection, or transport failure, set `upstreamStatus = 502`, stop the timer with outcome `error`, and throw `BadGatewayError('Bad Gateway: upstream unreachable')`. - -| Event | HTTP status returned by Callora | Code | Error class | Body behavior | -|---|---:|---|---|---| -| Upstream returns an HTTP response, including `4xx` or `5xx` | upstream status | not generated by Callora | none | The proxy streams the upstream body and safe headers. | -| `fetch()` throws `DOMException` with `name === "TimeoutError"` | `504` | `GATEWAY_TIMEOUT` | `GatewayTimeoutError` | Standard error envelope. | -| `fetch()` throws `TypeError` with code `UND_ERR_CONNECT_TIMEOUT` | `504` | `GATEWAY_TIMEOUT` | `GatewayTimeoutError` | Standard error envelope. | -| Any other fetch/connect failure | `502` | `BAD_GATEWAY` | `BadGatewayError` | Standard error envelope. | - -For generated `502` and `504` proxy errors, the JSON body does not include `upstreamStatus`, -the raw upstream response body, raw upstream error payload, or a Soroban revert reason. -If the upstream actually returns an HTTP response, the proxy forwards that -response instead of generating the standard envelope. - -Example proxy request: - -```bash -curl -i \ - -H 'X-Api-Key: ' \ - 'http://localhost:3000/v1/call/weather-api/forecast' -``` - -Example generated timeout response when no request id middleware populated `req.id`: - -```http -HTTP/1.1 504 Gateway Timeout -Content-Type: application/json; charset=utf-8 -``` - -```json -{ - "code": "GATEWAY_TIMEOUT", - "message": "Upstream service timed out", - "requestId": "unknown" -} -``` - -Example generated unreachable-upstream response when no request id middleware populated `req.id`: - -```http -HTTP/1.1 502 Bad Gateway -Content-Type: application/json; charset=utf-8 -``` - -```json -{ - "code": "BAD_GATEWAY", - "message": "Bad Gateway: upstream unreachable", - "requestId": "unknown" -} -``` - -The legacy `ALL /api/gateway/:apiId` route also maps generated upstream timeouts -to `504` and other generated upstream failures to `502`, but it performs API-key -lookup, credit deduction, and usage recording in the legacy route flow. The -`/v1/call` mapping above is the primary gateway/proxy reference. - -## Billing and Soroban errors - -Billing routes are implemented in `src/routes/billing.ts`. Soroban RPC failures -are represented by `SorobanRpcError` categories in -`src/services/sorobanBilling.ts` and then converted to `AppError` subclasses by -the billing route. - -| Soroban category | HTTP status | Response code | Error class | Meaning | -|---|---:|---|---|---| -| `INSUFFICIENT_BALANCE` | `402` | `INSUFFICIENT_BALANCE` | `PaymentRequiredError` | On-chain or pre-flight balance is too low. | -| `TIMEOUT` | `504` | `SOROBAN_RPC_TIMEOUT` | `GatewayTimeoutError` | The Soroban RPC request timed out, was aborted, or otherwise matched the timeout category. | -| `CONTRACT_ERROR` | `502` | `SOROBAN_RPC_ERROR` | `BadGatewayError` | The contract rejected the call, simulation failed, or the failure matched contract/wasm classification. | -| `NETWORK_ERROR` | `502` | `SOROBAN_RPC_ERROR` | `BadGatewayError` | Soroban transport, HTTP, or missing-result failures. | - -`POST /api/billing/deduct` uses `requireAuth` before the route handler. -Authentication failures are passed through the shared handler as `401` -responses. Depending on the auth failure, the response code can be the default -`UNAUTHORIZED` or one of the route-auth overrides: `INVALID_AUTH_HEADER`, -`MISSING_TOKEN`, `INVALID_TOKEN`, `MISSING_CLAIMS`, `TOKEN_EXPIRED`, or -`TOKEN_NOT_ACTIVE`. - -The same route also uses `idempotencyMiddleware`. Two idempotency conflicts are -written directly by that middleware instead of being passed to `errorHandler`, -so their JSON body is `{ "error", "message", "code" }` and does not include -`requestId`: - -| Idempotency condition | HTTP status | Response code | Body shape | -|---|---:|---|---| -| Existing idempotency key with different request hash | `409` | `IDEMPOTENCY_CONFLICT` | Direct middleware JSON response. | -| Existing idempotency key is still marked `started` | `409` | `IDEMPOTENCY_IN_PROGRESS` | Direct middleware JSON response. | - -`POST /api/billing/deduct` maps unsuccessful `BillingService.deduct()` result messages before falling back to a generic billing failure: - -| Route condition | HTTP status | Response code | Error class | Notes | -|---|---:|---|---|---| -| Missing authenticated user | `401` | `UNAUTHORIZED` | `UnauthorizedError` | Auth middleware should normally prevent this. | -| Invalid `requestId`, `apiId`, `endpointId`, `apiKeyId`, `amountUsdc`, or `idempotencyKey` | `400` | `BAD_REQUEST` | `BadRequestError` | Each validation failure has a field-specific message. | -| Database pool is unavailable | `500` | `DATABASE_NOT_AVAILABLE` | `InternalServerError` | Route-specific code override. | -| Failure message contains `insufficient balance` or `insufficient funds` | `402` | `INSUFFICIENT_BALANCE` | `PaymentRequiredError` | Message is preserved from the billing result. | -| Failure message contains `timeout` or `timed out` | `504` | `SOROBAN_RPC_TIMEOUT` | `GatewayTimeoutError` | Message is preserved from the billing result. | -| Failure message contains `balance check failed`, `contract`, or `network` | `502` | `SOROBAN_RPC_ERROR` | `BadGatewayError` | Message is preserved from the billing result. | -| Any other unsuccessful deduction result | `500` | `BILLING_DEDUCTION_FAILED` | `InternalServerError` | Response message is `Billing deduction failed`. | - -`GET /api/billing/request/:requestId` uses these route-specific errors: - -| Route condition | HTTP status | Response code | Error class | -|---|---:|---|---| -| Missing authenticated user | `401` | `UNAUTHORIZED` | `UnauthorizedError` | -| Missing or empty `requestId` param | `400` | `BAD_REQUEST` | `BadRequestError` | -| Database pool is unavailable | `500` | `DATABASE_NOT_AVAILABLE` | `InternalServerError` | -| Billing request is not found | `404` | `BILLING_REQUEST_NOT_FOUND` | `NotFoundError` | - -The billing error envelope does not add a structured raw Soroban category, raw -RPC payload, revert-reason field, or `details` array. It exposes the mapped HTTP -status, stable `code`, `message`, and `requestId` supplied by the shared error -handler. The `message` can contain the normalized Soroban or billing error -message, but consumers should branch on `code` and HTTP status rather than -parsing the message. - -Example insufficient-balance request: - -```bash -curl -i -X POST 'http://localhost:3000/api/billing/deduct' \ - -H 'Authorization: Bearer ' \ - -H 'Content-Type: application/json' \ - -H 'Idempotency-Key: bill_req_123' \ - -d '{ - "requestId": "bill_req_123", - "apiId": "api_001", - "endpointId": "forecast", - "apiKeyId": "key_001", - "amountUsdc": "0.10" - }' -``` - -Example insufficient-balance response: - -```http -HTTP/1.1 402 Payment Required -Content-Type: application/json; charset=utf-8 -``` - -```json -{ - "code": "INSUFFICIENT_BALANCE", - "message": "Insufficient balance: required 1000000 units, available 0", - "requestId": "req_123" -} -``` - -Example Soroban timeout response: - -```http -HTTP/1.1 504 Gateway Timeout -Content-Type: application/json; charset=utf-8 -``` - -```json -{ - "code": "SOROBAN_RPC_TIMEOUT", - "message": "Soroban RPC request timed out", - "requestId": "req_123" -} -``` diff --git a/docs/error-codes.yaml b/docs/error-codes.yaml deleted file mode 100644 index 42a3d54b..00000000 --- a/docs/error-codes.yaml +++ /dev/null @@ -1,387 +0,0 @@ -# Canonical Error Code Catalog -# -# This is the single source of truth for all error codes in the Callora backend. -# The TypeScript enum in src/errors/codes.ts is auto-generated from this file. -# -# DO NOT edit src/errors/codes.ts manually. Instead, update this file and run: -# npm run error-codes:generate -# -# Each entry must have: -# - code: The error code identifier (SCREAMING_SNAKE_CASE) -# - section: Category for documentation grouping -# - description: Human-readable explanation of when this error occurs -# -# The 'code' field becomes both the TypeScript enum key and value. - -error_codes: - # HTTP status derived / base app codes - - code: BAD_REQUEST - section: HTTP status derived / base app codes - description: The request is malformed, missing required input, or otherwise invalid - - - code: UNAUTHORIZED - section: HTTP status derived / base app codes - description: Authentication is missing, malformed, or invalid - - - code: FORBIDDEN - section: HTTP status derived / base app codes - description: The caller is authenticated but not allowed to perform the action - - - code: NOT_FOUND - section: HTTP status derived / base app codes - description: The requested resource does not exist - - - code: PAYMENT_REQUIRED - section: HTTP status derived / base app codes - description: The caller has insufficient balance or payment is otherwise required - - - code: TOO_MANY_REQUESTS - section: HTTP status derived / base app codes - description: The caller exceeded a rate limit - - - code: CONFLICT - section: HTTP status derived / base app codes - description: The request conflicts with existing state - - - code: INTERNAL_SERVER_ERROR - section: HTTP status derived / base app codes - description: An internal service or invariant failed - - - code: BAD_GATEWAY - section: HTTP status derived / base app codes - description: The gateway could not obtain a valid upstream or dependency response - - - code: SERVICE_UNAVAILABLE - section: HTTP status derived / base app codes - description: A dependency or service is temporarily unavailable - - - code: GATEWAY_TIMEOUT - section: HTTP status derived / base app codes - description: A dependency or upstream service did not respond before its timeout - - # Validation - - code: VALIDATION_ERROR - section: Validation - description: Request validation failed due to invalid input - - - code: INVALID_BODY - section: Validation - description: Request body is invalid or malformed - - - code: INVALID_QUERY - section: Validation - description: Query parameters are invalid - - - code: INVALID_PARAMS - section: Validation - description: URL parameters are invalid - - - code: INVALID_VALUE - section: Validation - description: A specific field contains an invalid value - - # Gateway / proxy - - code: GATEWAY_AUTH_CONTEXT_MISSING - section: Gateway / proxy - description: Gateway authentication context is unexpectedly missing after auth middleware - - - code: UPSTREAM_TARGET_BLOCKED - section: Gateway / proxy - description: Resolved upstream target fails validation or allowlist checks - - # Billing / Soroban - - code: INSUFFICIENT_BALANCE - section: Billing / Soroban - description: On-chain or pre-flight balance is too low - - - code: SOROBAN_RPC_TIMEOUT - section: Billing / Soroban - description: The Soroban RPC request timed out or was aborted - - - code: SOROBAN_RPC_ERROR - section: Billing / Soroban - description: The contract rejected the call, simulation failed, or network error occurred - - - code: BILLING_DEDUCTION_FAILED - section: Billing / Soroban - description: Billing deduction operation failed - - # Billing request - - code: BILLING_REQUEST_NOT_FOUND - section: Billing request - description: The requested billing record was not found - - # Developer / API keys - - code: DEVELOPER_NOT_FOUND - section: Developer / API keys - description: Developer profile not found - - - code: API_ACCESS_FORBIDDEN - section: Developer / API keys - description: Access to the API is forbidden for this developer - - - code: API_KEY_NOT_FOUND - section: Developer / API keys - description: API key not found - - - code: API_KEY_FORBIDDEN - section: Developer / API keys - description: API key is not authorized for this operation - - # Refresh-token auth - - code: MISSING_REFRESH_TOKEN - section: Refresh-token auth - description: Refresh token is missing from the request - - - code: INVALID_REFRESH_TOKEN - section: Refresh-token auth - description: Refresh token is invalid or malformed - - - code: REVOKED_TOKEN - section: Refresh-token auth - description: The token has been revoked - - - code: EXPIRED_TOKEN - section: Refresh-token auth - description: The token has expired - - - code: REFRESH_FAILED - section: Refresh-token auth - description: Token refresh operation failed - - - code: REVOKE_FAILED - section: Refresh-token auth - description: Token revocation operation failed - - - code: NOT_AUTHENTICATED - section: Refresh-token auth - description: User is not authenticated - - - code: TOKEN_INFO_FAILED - section: Refresh-token auth - description: Failed to retrieve token information - - # Vault / deposit - - code: VAULT_NOT_FOUND - section: Vault / deposit - description: Vault account not found - - - code: VAULT_BALANCE_RETRIEVAL_FAILED - section: Vault / deposit - description: Failed to retrieve vault balance - - - code: MISSING_AMOUNT - section: Vault / deposit - description: Amount parameter is missing - - - code: INVALID_AMOUNT_TYPE - section: Vault / deposit - description: Amount has invalid type - - - code: INVALID_AMOUNT_FORMAT - section: Vault / deposit - description: Amount has invalid format - - - code: INVALID_NETWORK - section: Vault / deposit - description: Network parameter is invalid - - - code: NETWORK_MISMATCH - section: Vault / deposit - description: Network mismatch between request and resource - - - code: INVALID_SOURCE_ACCOUNT - section: Vault / deposit - description: Source account is invalid - - - code: INVALID_TRANSACTION_INPUT - section: Vault / deposit - description: Transaction input is invalid - - - code: SOURCE_ACCOUNT_NOT_FOUND - section: Vault / deposit - description: Source account not found - - - code: INVALID_CONTRACT_ID - section: Vault / deposit - description: Contract ID is invalid - - - code: NETWORK_UNAVAILABLE - section: Vault / deposit - description: Network is unavailable - - - code: TRANSACTION_BUILD_FAILED - section: Vault / deposit - description: Failed to build transaction - - - code: INTERNAL_ERROR - section: Vault / deposit - description: Internal error occurred during vault operation - - # Webhooks - - code: INVALID_WEBHOOK_REGISTRATION - section: Webhooks - description: Webhook registration is invalid - - - code: INVALID_WEBHOOK_EVENT_TYPES - section: Webhooks - description: Webhook event types are invalid - - - code: WEBHOOK_NOT_FOUND - section: Webhooks - description: Webhook not found - - - code: INVALID_WEBHOOK_URL - section: Webhooks - description: Webhook URL is invalid - - - code: WEBHOOK_URL_VALIDATION_FAILED - section: Webhooks - description: Webhook URL validation failed - - - code: MISSING_WEBHOOK_SIGNATURE_HEADERS - section: Webhooks - description: Webhook signature headers are missing - - - code: INVALID_WEBHOOK_TIMESTAMP - section: Webhooks - description: Webhook timestamp is invalid - - - code: WEBHOOK_TIMESTAMP_OUT_OF_WINDOW - section: Webhooks - description: Webhook timestamp is outside acceptable window - - - code: MALFORMED_WEBHOOK_SIGNATURE - section: Webhooks - description: Webhook signature is malformed - - - code: INVALID_WEBHOOK_SIGNATURE - section: Webhooks - description: Webhook signature verification failed - - - code: MALFORMED_WEBHOOK_NONCE - section: Webhooks - description: Webhook nonce header is malformed - - - code: WEBHOOK_NONCE_REPLAYED - section: Webhooks - description: Webhook nonce has already been used - - - code: INVALID_DELIVERY_ID - section: Webhooks - description: The delivery ID provided for webhook replay is missing or invalid - - - code: INVALID_RETRY_POLICY - section: Webhooks - description: The retry policy provided is invalid - - - code: DLQ_ENTRY_NOT_FOUND - section: Webhooks - description: No Dead-Letter Queue entry was found for the given delivery ID - - # IP allowlist - - code: INVALID_IP_FORMAT - section: IP allowlist - description: IP address format is invalid - - - code: IP_NOT_ALLOWED - section: IP allowlist - description: IP address is not in the allowlist - - # DB / infrastructure - - code: DATABASE_NOT_AVAILABLE - section: DB / infrastructure - description: Database is not available - - # Idempotency - - code: IDEMPOTENCY_CONFLICT - section: Idempotency - description: Idempotency key conflict with different request - - - code: IDEMPOTENCY_IN_PROGRESS - section: Idempotency - description: Request with this idempotency key is still in progress - - # Misc / direct middleware responses - - code: SIMULATION_FAILED - section: Misc / direct middleware responses - description: Soroban simulation failed - - # Route-specific / auth overrides - - code: INVALID_AUTH_HEADER - section: Route-specific / auth overrides (documented in docs/error-codes.md) - description: Authorization header is invalid - - - code: MISSING_TOKEN - section: Route-specific / auth overrides (documented in docs/error-codes.md) - description: Authentication token is missing - - - code: INVALID_TOKEN - section: Route-specific / auth overrides (documented in docs/error-codes.md) - description: Authentication token is invalid - - - code: MISSING_CLAIMS - section: Route-specific / auth overrides (documented in docs/error-codes.md) - description: Token claims are missing - - - code: TOKEN_EXPIRED - section: Route-specific / auth overrides (documented in docs/error-codes.md) - description: Authentication token has expired - - - code: TOKEN_NOT_ACTIVE - section: Route-specific / auth overrides (documented in docs/error-codes.md) - description: Authentication token is not yet active - - # Quota self-service - - code: QUOTA_REQUEST_NOT_FOUND - section: Quota self-service - description: Quota request not found - - - code: QUOTA_REQUEST_ALREADY_RESOLVED - section: Quota self-service - description: Quota request has already been resolved - - - code: INVALID_QUOTA_REQUEST - section: Quota self-service - description: Quota request is invalid - - # HTTP fallback derived codes - - code: REQUEST_TIMEOUT - section: HTTP fallback derived codes referenced by documentation - description: Request timeout - - - code: REQUEST_BODY_TOO_LARGE - section: HTTP fallback derived codes referenced by documentation - description: Request body exceeds size limit - - - code: UNSUPPORTED_MEDIA_TYPE - section: HTTP fallback derived codes referenced by documentation - description: Media type is not supported - - - code: UNPROCESSABLE_ENTITY - section: HTTP fallback derived codes referenced by documentation - description: Request is syntactically correct but semantically invalid - - - code: USAGE_AGGREGATE_NOT_FOUND - section: Admin usage management - description: Usage aggregate not found for the given developer - - - code: INVALID_EXPORT_SCHEDULE - section: Export schedules - description: Export schedule payload or configuration is invalid - - - code: EXPORT_SCHEDULE_NOT_FOUND - section: Export schedules - description: Export schedule not found - - - code: MISSING_AUTH_FIELDS - section: Auth - description: Required authentication fields are missing from the request - - - code: AUTH_NOT_IMPLEMENTED - section: Auth - description: The authentication method is not yet implemented - - - code: COMPONENT_NOT_CONFIGURED - section: Health / dependency probes - description: A required system component is not configured diff --git a/package.json b/package.json new file mode 100644 index 00000000..23a23020 --- /dev/null +++ b/package.json @@ -0,0 +1,11 @@ +{ + "name": "callora-backend", + "version": "1.0.0", + "private": true, + "type": "module", + "scripts": { + "error-codes:generate": "node scripts/generate-error-codes.mjs", + "error-codes:check": "node scripts/generate-error-codes.mjs --check", + "test": "node --test scripts/*.test.mjs" + } +} diff --git a/scripts/generate-error-codes.mjs b/scripts/generate-error-codes.mjs deleted file mode 100644 index 95933cf3..00000000 --- a/scripts/generate-error-codes.mjs +++ /dev/null @@ -1,274 +0,0 @@ -import fs from "node:fs"; -import path from "node:path"; -import process from "node:process"; - -const root = process.cwd(); -const yamlCatalogPath = path.join(root, "docs", "error-codes.yaml"); -const legacyCatalogPath = path.join(root, "src", "errors", "errorCatalog.ts"); -const generatedCodesPath = path.join(root, "src", "errors", "codes.ts"); -const docsPath = path.join(root, "docs", "error-codes.md"); -const openApiPath = path.join(root, "docs", "openapi.json"); -const checkOnly = process.argv.includes("--check"); - -const startMarker = ""; -const endMarker = ""; - -/** - * Parse the YAML catalog manually (no external dependencies). - * This is a simple parser that works for our specific YAML structure. - */ -function parseYamlCatalog(yamlContent) { - const entries = []; - const lines = yamlContent.split(/\r?\n/); - let currentEntry = null; - - for (let i = 0; i < lines.length; i++) { - const line = lines[i]; - - // Start of a new error code entry - if (line.match(/^\s*-\s+code:/)) { - if (currentEntry && currentEntry.code) { - entries.push(currentEntry); - } - currentEntry = { code: "", section: "", description: "" }; - const codeMatch = line.match(/code:\s+([A-Z0-9_]+)/); - if (codeMatch) { - currentEntry.code = codeMatch[1]; - } else { - // Try to capture any code value for validation error - const anyCodeMatch = line.match(/code:\s+(\S+)/); - if (anyCodeMatch) { - currentEntry.code = anyCodeMatch[1]; - } - } - } else if (currentEntry) { - // Parse section field - const sectionMatch = line.match(/^\s+section:\s+(.+)$/); - if (sectionMatch) { - currentEntry.section = sectionMatch[1].trim(); - } - - // Parse description field - const descMatch = line.match(/^\s+description:\s+(.+)$/); - if (descMatch) { - currentEntry.description = descMatch[1].trim(); - } - } - } - - // Don't forget the last entry - if (currentEntry && currentEntry.code) { - entries.push(currentEntry); - } - - return entries; -} - -function readCatalog() { - // Check if YAML catalog exists, otherwise fall back to TS catalog - let entries = []; - - if (fs.existsSync(yamlCatalogPath)) { - const yamlContent = fs.readFileSync(yamlCatalogPath, "utf8"); - entries = parseYamlCatalog(yamlContent); - } else if (fs.existsSync(legacyCatalogPath)) { - // Fallback to legacy TS catalog parsing - const source = fs.readFileSync(legacyCatalogPath, "utf8"); - let section = "General"; - - for (const line of source.split(/\r?\n/)) { - const sectionMatch = line.match(/^\s*\/\/\s+(.+)$/); - if (sectionMatch) { - section = sectionMatch[1].trim(); - continue; - } - - const entryMatch = line.match(/^\s*([A-Z0-9_]+):\s*"([A-Z0-9_]+)",$/); - if (!entryMatch) continue; - - const [, key, value] = entryMatch; - if (key !== value) { - throw new Error(`ErrorCode key/value mismatch: ${key} !== ${value}`); - } - entries.push({ code: value, section, description: "" }); - } - } else { - throw new Error("No error catalog found. Expected docs/error-codes.yaml or src/errors/errorCatalog.ts"); - } - - if (entries.length === 0) { - throw new Error("No error codes found in catalog"); - } - - // Validate no duplicates - const duplicates = entries - .map((entry) => entry.code) - .filter((code, index, codes) => codes.indexOf(code) !== index); - if (duplicates.length > 0) { - throw new Error(`Duplicate error codes: ${[...new Set(duplicates)].join(", ")}`); - } - - // Validate code format (SCREAMING_SNAKE_CASE) - const invalidCodes = entries.filter( - (entry) => !/^[A-Z][A-Z0-9_]*$/.test(entry.code) - ); - if (invalidCodes.length > 0) { - throw new Error( - `Invalid error code format (must be SCREAMING_SNAKE_CASE): ${invalidCodes.map((e) => e.code).join(", ")}` - ); - } - - return entries; -} - -function buildMarkdownBlock(entries) { - const rows = entries - .map(({ code, section }) => `| \`${code}\` | ${section} |`) - .join("\n"); - - return [ - startMarker, - "## Canonical error code catalog", - "", - "This section is generated from `docs/error-codes.yaml`. Run `npm run error-codes:generate` after changing the catalog.", - "", - "| Code | Catalog section |", - "|---|---|", - rows, - endMarker, - ].join("\n"); -} - -function buildTypeScriptEnum(entries) { - const enumEntries = entries - .map(({ code, section, description }) => { - const comment = description ? ` /** ${description} */\n` : ""; - return `${comment} ${code}: "${code}"`; - }) - .join(",\n\n"); - - return [ - "/**", - " * Canonical Error Code Enum", - " *", - " * AUTO-GENERATED from docs/error-codes.yaml", - " * DO NOT EDIT THIS FILE MANUALLY", - " *", - " * To add or modify error codes:", - " * 1. Edit docs/error-codes.yaml", - " * 2. Run: npm run error-codes:generate", - " *", - " * @module errors/codes", - " */", - "", - "export const ErrorCode = {", - enumEntries, - "", - "} as const;", - "", - "export type ErrorCode = (typeof ErrorCode)[keyof typeof ErrorCode];", - "", - "/**", - " * Type guard to check if a value is a valid ErrorCode", - " * @param value - Value to check", - " * @returns True if value is a valid error code", - " */", - "export function isErrorCode(value: unknown): value is ErrorCode {", - " if (typeof value !== \"string\") return false;", - " return Object.values(ErrorCode).includes(value as ErrorCode);", - "}", - "", - ].join("\n"); -} - -function updateGeneratedBlock(markdown, block) { - const blockPattern = new RegExp(`${escapeRegExp(startMarker)}[\\s\\S]*?${escapeRegExp(endMarker)}`); - if (blockPattern.test(markdown)) { - return markdown.replace(blockPattern, block); - } - - const introPattern = /^(# .+\r?\n\r?\n(?:.+\r?\n)+?\r?\n)/; - const match = markdown.match(introPattern); - if (!match) { - return `${block}\n\n${markdown}`; - } - - return `${match[1]}${block}\n\n${markdown.slice(match[1].length)}`; -} - -function escapeRegExp(value) { - return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); -} - -function updateOpenApi(openApi, entries) { - const schemas = openApi.components?.schemas; - if (!schemas) { - throw new Error("OpenAPI document is missing components.schemas"); - } - - schemas.ErrorCode = { - type: "string", - enum: entries.map((entry) => entry.code), - description: "Canonical Callora backend error code.", - }; - - const errorResponse = schemas.ErrorResponse; - if (!errorResponse?.properties?.code) { - throw new Error("OpenAPI document is missing components.schemas.ErrorResponse.properties.code"); - } - - errorResponse.properties.code = { - $ref: "#/components/schemas/ErrorCode", - }; - - return `${JSON.stringify(openApi, null, 2)}\n`; -} - -function writeOrCheck(filePath, current, next) { - if (current === next) return false; - - if (checkOnly) { - console.error(`${path.relative(root, filePath)} is not generated from the current error catalog.`); - return true; - } - - fs.writeFileSync(filePath, next); - return true; -} - -const entries = readCatalog(); - -// Generate TypeScript enum -const tsEnum = buildTypeScriptEnum(entries); -const tsEnumCurrent = fs.existsSync(generatedCodesPath) - ? fs.readFileSync(generatedCodesPath, "utf8") - : ""; -const tsEnumChanged = writeOrCheck(generatedCodesPath, tsEnumCurrent, tsEnum); - -// Generate markdown documentation -const docsCurrent = fs.readFileSync(docsPath, "utf8"); -const docsNext = updateGeneratedBlock(docsCurrent, buildMarkdownBlock(entries)); -const docsChanged = writeOrCheck(docsPath, docsCurrent, docsNext); - -// Generate OpenAPI schema -const openApiCurrent = fs.readFileSync(openApiPath, "utf8"); -const openApiNext = updateOpenApi(JSON.parse(openApiCurrent), entries); -const openApiChanged = writeOrCheck(openApiPath, openApiCurrent, openApiNext); - -if (checkOnly && (tsEnumChanged || docsChanged || openApiChanged)) { - process.exit(1); -} - -if (!checkOnly) { - const changedFiles = [ - tsEnumChanged && "src/errors/codes.ts", - docsChanged && "docs/error-codes.md", - openApiChanged && "docs/openapi.json", - ].filter(Boolean); - - if (changedFiles.length > 0) { - console.log(`Updated: ${changedFiles.join(", ")}`); - } else { - console.log("Already up to date: src/errors/codes.ts, docs/error-codes.md, docs/openapi.json"); - } -} diff --git a/scripts/generate-error-codes.test.mjs b/scripts/generate-error-codes.test.mjs deleted file mode 100644 index 30e59bd4..00000000 --- a/scripts/generate-error-codes.test.mjs +++ /dev/null @@ -1,459 +0,0 @@ -/** - * Tests for error code generation script - * - * Run with: node scripts/generate-error-codes.test.mjs - */ - -import assert from "node:assert"; -import fs from "node:fs"; -import path from "node:path"; -import { fileURLToPath } from "node:url"; -import { execSync } from "node:child_process"; - -const __filename = fileURLToPath(import.meta.url); -const __dirname = path.dirname(__filename); -const root = path.resolve(__dirname, ".."); -const testDir = path.join(root, "test-temp-error-codes"); - -// Test utilities -function createTestEnv() { - if (fs.existsSync(testDir)) { - fs.rmSync(testDir, { recursive: true, force: true }); - } - fs.mkdirSync(testDir, { recursive: true }); - fs.mkdirSync(path.join(testDir, "docs"), { recursive: true }); - fs.mkdirSync(path.join(testDir, "src", "errors"), { recursive: true }); -} - -function cleanupTestEnv() { - if (fs.existsSync(testDir)) { - fs.rmSync(testDir, { recursive: true, force: true }); - } -} - -function writeTestYaml(content) { - fs.writeFileSync(path.join(testDir, "docs", "error-codes.yaml"), content); -} - -function writeTestDocs() { - fs.writeFileSync( - path.join(testDir, "docs", "error-codes.md"), - "# Error Codes\n\nTest doc\n" - ); -} - -function writeTestOpenApi() { - const openApi = { - openapi: "3.0.0", - info: { title: "Test API", version: "1.0.0" }, - components: { - schemas: { - ErrorResponse: { - type: "object", - properties: { - code: { type: "string" }, - message: { type: "string" }, - }, - }, - }, - }, - }; - fs.writeFileSync( - path.join(testDir, "docs", "openapi.json"), - JSON.stringify(openApi, null, 2) - ); -} - -function runCodegen(cwd = testDir) { - const script = path.join(root, "scripts", "generate-error-codes.mjs"); - try { - execSync(`node "${script}"`, { - cwd, - encoding: "utf8", - stdio: "pipe", - }); - return { success: true, error: null }; - } catch (error) { - return { success: false, error: error.message }; - } -} - -// Tests -const tests = []; - -function test(name, fn) { - tests.push({ name, fn }); -} - -// Test 1: Parse valid YAML catalog -test("parses valid YAML catalog with all fields", () => { - createTestEnv(); - writeTestDocs(); - writeTestOpenApi(); - - const yaml = ` -error_codes: - - code: TEST_ERROR_ONE - section: Test Section - description: Test description one - - - code: TEST_ERROR_TWO - section: Test Section - description: Test description two -`; - - writeTestYaml(yaml); - const result = runCodegen(); - - assert.strictEqual(result.success, true, "Should succeed"); - - const generated = fs.readFileSync( - path.join(testDir, "src", "errors", "codes.ts"), - "utf8" - ); - - assert.ok(generated.includes("TEST_ERROR_ONE"), "Should include TEST_ERROR_ONE"); - assert.ok(generated.includes("TEST_ERROR_TWO"), "Should include TEST_ERROR_TWO"); - assert.ok( - generated.includes("Test description one"), - "Should include description" - ); - - cleanupTestEnv(); -}); - -// Test 2: Reject duplicate error codes -test("rejects duplicate error codes", () => { - createTestEnv(); - writeTestDocs(); - writeTestOpenApi(); - - const yaml = ` -error_codes: - - code: DUPLICATE_CODE - section: Test Section - description: First occurrence - - - code: DUPLICATE_CODE - section: Test Section - description: Second occurrence -`; - - writeTestYaml(yaml); - const result = runCodegen(); - - assert.strictEqual(result.success, false, "Should fail on duplicates"); - assert.ok( - result.error.includes("Duplicate"), - "Error should mention duplicates" - ); - - cleanupTestEnv(); -}); - -// Test 3: Validate code format (SCREAMING_SNAKE_CASE) -test("validates error code format", () => { - createTestEnv(); - writeTestDocs(); - writeTestOpenApi(); - - const yaml = ` -error_codes: - - code: invalidCode - section: Test Section - description: Invalid format -`; - - writeTestYaml(yaml); - const result = runCodegen(); - - assert.strictEqual(result.success, false, "Should fail on invalid format"); - assert.ok( - result.error.includes("SCREAMING_SNAKE_CASE") || result.error.includes("Invalid"), - "Error should mention format requirement" - ); - - cleanupTestEnv(); -}); - -// Test 4: Generate TypeScript with correct structure -test("generates TypeScript enum with correct structure", () => { - createTestEnv(); - writeTestDocs(); - writeTestOpenApi(); - - const yaml = ` -error_codes: - - code: SAMPLE_ERROR - section: Sample - description: A sample error for testing -`; - - writeTestYaml(yaml); - const result = runCodegen(); - - assert.strictEqual(result.success, true, "Should succeed"); - - const generated = fs.readFileSync( - path.join(testDir, "src", "errors", "codes.ts"), - "utf8" - ); - - // Check structure - assert.ok(generated.includes("export const ErrorCode ="), "Should export ErrorCode"); - assert.ok(generated.includes('SAMPLE_ERROR: "SAMPLE_ERROR"'), "Should include code entry"); - assert.ok(generated.includes("} as const;"), "Should use as const"); - assert.ok( - generated.includes("export type ErrorCode"), - "Should export type" - ); - assert.ok( - generated.includes("export function isErrorCode"), - "Should export type guard" - ); - assert.ok( - generated.includes("AUTO-GENERATED"), - "Should include generation notice" - ); - assert.ok( - generated.includes("DO NOT EDIT"), - "Should include edit warning" - ); - - cleanupTestEnv(); -}); - -// Test 5: Update documentation -test("updates markdown documentation", () => { - createTestEnv(); - writeTestDocs(); - writeTestOpenApi(); - - const yaml = ` -error_codes: - - code: DOC_TEST_ERROR - section: Documentation Test - description: Test error for docs -`; - - writeTestYaml(yaml); - const result = runCodegen(); - - assert.strictEqual(result.success, true, "Should succeed"); - - const docs = fs.readFileSync( - path.join(testDir, "docs", "error-codes.md"), - "utf8" - ); - - assert.ok( - docs.includes(""), - "Should have start marker" - ); - assert.ok( - docs.includes(""), - "Should have end marker" - ); - assert.ok( - docs.includes("DOC_TEST_ERROR"), - "Should include error code" - ); - assert.ok( - docs.includes("Documentation Test"), - "Should include section" - ); - - cleanupTestEnv(); -}); - -// Test 6: Update OpenAPI schema -test("updates OpenAPI schema with error codes", () => { - createTestEnv(); - writeTestDocs(); - writeTestOpenApi(); - - const yaml = ` -error_codes: - - code: API_TEST_ERROR - section: API Test - description: Test error for OpenAPI -`; - - writeTestYaml(yaml); - const result = runCodegen(); - - assert.strictEqual(result.success, true, "Should succeed"); - - const openApi = JSON.parse( - fs.readFileSync(path.join(testDir, "docs", "openapi.json"), "utf8") - ); - - assert.ok( - openApi.components.schemas.ErrorCode, - "Should create ErrorCode schema" - ); - assert.strictEqual( - openApi.components.schemas.ErrorCode.type, - "string", - "ErrorCode should be string type" - ); - assert.ok( - Array.isArray(openApi.components.schemas.ErrorCode.enum), - "ErrorCode should have enum" - ); - assert.ok( - openApi.components.schemas.ErrorCode.enum.includes("API_TEST_ERROR"), - "Enum should include test error" - ); - - cleanupTestEnv(); -}); - -// Test 7: Check mode detects outdated files -test("check mode detects outdated generated files", () => { - createTestEnv(); - writeTestDocs(); - writeTestOpenApi(); - - const yaml = ` -error_codes: - - code: CHECK_MODE_TEST - section: Check Mode - description: Test for check mode -`; - - writeTestYaml(yaml); - - // Generate once - runCodegen(); - - // Modify the generated file - const generatedPath = path.join(testDir, "src", "errors", "codes.ts"); - fs.appendFileSync(generatedPath, "\n// Manual modification\n"); - - // Run in check mode - const script = path.join(root, "scripts", "generate-error-codes.mjs"); - try { - execSync(`node "${script}" --check`, { - cwd: testDir, - encoding: "utf8", - stdio: "pipe", - }); - assert.fail("Check mode should have failed"); - } catch (error) { - assert.ok(error.status !== 0, "Should exit with non-zero code"); - } - - cleanupTestEnv(); -}); - -// Test 8: Handle missing YAML catalog -test("handles missing YAML catalog gracefully", () => { - createTestEnv(); - writeTestDocs(); - writeTestOpenApi(); - - // Don't create the YAML file - const result = runCodegen(); - - assert.strictEqual(result.success, false, "Should fail"); - assert.ok( - result.error.includes("catalog") || result.error.includes("found"), - "Error should mention missing catalog" - ); - - cleanupTestEnv(); -}); - -// Test 9: Validate required fields -test("validates required fields in YAML entries", () => { - createTestEnv(); - writeTestDocs(); - writeTestOpenApi(); - - const yaml = ` -error_codes: - - code: VALID_CODE - section: Valid - description: Has all fields - - - section: Missing Code - description: This entry lacks a code field -`; - - writeTestYaml(yaml); - const result = runCodegen(); - - // Should still parse the valid entry - assert.strictEqual(result.success, true, "Should succeed with valid entries"); - - const generated = fs.readFileSync( - path.join(testDir, "src", "errors", "codes.ts"), - "utf8" - ); - - assert.ok(generated.includes("VALID_CODE"), "Should include valid code"); - - cleanupTestEnv(); -}); - -// Test 10: Idempotency - running twice produces same output -test("running generation twice produces identical output", () => { - createTestEnv(); - writeTestDocs(); - writeTestOpenApi(); - - const yaml = ` -error_codes: - - code: IDEMPOTENT_ERROR - section: Idempotency - description: Test idempotency -`; - - writeTestYaml(yaml); - - // First run - runCodegen(); - const firstRun = fs.readFileSync( - path.join(testDir, "src", "errors", "codes.ts"), - "utf8" - ); - - // Second run - runCodegen(); - const secondRun = fs.readFileSync( - path.join(testDir, "src", "errors", "codes.ts"), - "utf8" - ); - - assert.strictEqual(firstRun, secondRun, "Output should be identical"); - - cleanupTestEnv(); -}); - -// Run all tests -console.log("Running error code generation tests...\n"); - -let passed = 0; -let failed = 0; - -for (const { name, fn } of tests) { - try { - fn(); - console.log(`✓ ${name}`); - passed++; - } catch (error) { - console.error(`✗ ${name}`); - console.error(` ${error.message}`); - if (error.stack) { - console.error(error.stack.split("\n").slice(1, 4).join("\n")); - } - failed++; - } -} - -console.log(`\n${passed} passed, ${failed} failed`); - -if (failed > 0) { - process.exit(1); -} From ed7293b6f5fd1803e798c831595caca937ff5770 Mon Sep 17 00:00:00 2001 From: mysteriousskater Date: Mon, 5 Oct 2026 08:49:23 -0400 Subject: [PATCH 2/2] fix: restore error-code generator and generate the catalog from the YAML source - Restore docs/error-codes.yaml, scripts/generate-error-codes.mjs and scripts/generate-error-codes.test.mjs, which this branch had deleted. - Generate the canonical catalog into docs/error-code-catalog.md from the single YAML source instead of the hand-written page. - Replace the duplicated generated table in docs/error-codes.md with a pointer to the generated catalog; the envelope/error-class/gateway/billing reference there is kept. - Revert the unrelated package.json edits (version, private, test script). Refs #1335 --- docs/error-code-catalog.md | 503 ++++++++++++++++++++++++++ docs/error-codes.md | 460 +++++++++++++++++++++++ docs/error-codes.yaml | 387 ++++++++++++++++++++ package.json | 5 +- scripts/generate-error-codes.mjs | 275 ++++++++++++++ scripts/generate-error-codes.test.mjs | 493 +++++++++++++++++++++++++ 6 files changed, 2120 insertions(+), 3 deletions(-) create mode 100644 docs/error-code-catalog.md create mode 100644 docs/error-codes.md create mode 100644 docs/error-codes.yaml create mode 100644 scripts/generate-error-codes.mjs create mode 100644 scripts/generate-error-codes.test.mjs diff --git a/docs/error-code-catalog.md b/docs/error-code-catalog.md new file mode 100644 index 00000000..8c4f29cc --- /dev/null +++ b/docs/error-code-catalog.md @@ -0,0 +1,503 @@ +# Error Code Catalog System + +This document describes the canonical error code catalog system used in the Callora Backend. + +## Overview + +The error code catalog provides a single source of truth for all machine-readable error codes emitted by the backend. The system uses a YAML catalog as the authoritative source, with automatic code generation for TypeScript enums, documentation, and OpenAPI schemas. + + +## Canonical error code catalog + +This section is generated from `docs/error-codes.yaml`. Run `npm run error-codes:generate` after changing the catalog. + +| Code | Catalog section | +|---|---| +| `BAD_REQUEST` | HTTP status derived / base app codes | +| `UNAUTHORIZED` | HTTP status derived / base app codes | +| `FORBIDDEN` | HTTP status derived / base app codes | +| `NOT_FOUND` | HTTP status derived / base app codes | +| `PAYMENT_REQUIRED` | HTTP status derived / base app codes | +| `TOO_MANY_REQUESTS` | HTTP status derived / base app codes | +| `CONFLICT` | HTTP status derived / base app codes | +| `INTERNAL_SERVER_ERROR` | HTTP status derived / base app codes | +| `BAD_GATEWAY` | HTTP status derived / base app codes | +| `SERVICE_UNAVAILABLE` | HTTP status derived / base app codes | +| `GATEWAY_TIMEOUT` | HTTP status derived / base app codes | +| `VALIDATION_ERROR` | Validation | +| `INVALID_BODY` | Validation | +| `INVALID_QUERY` | Validation | +| `INVALID_PARAMS` | Validation | +| `INVALID_VALUE` | Validation | +| `GATEWAY_AUTH_CONTEXT_MISSING` | Gateway / proxy | +| `UPSTREAM_TARGET_BLOCKED` | Gateway / proxy | +| `INSUFFICIENT_BALANCE` | Billing / Soroban | +| `SOROBAN_RPC_TIMEOUT` | Billing / Soroban | +| `SOROBAN_RPC_ERROR` | Billing / Soroban | +| `BILLING_DEDUCTION_FAILED` | Billing / Soroban | +| `BILLING_REQUEST_NOT_FOUND` | Billing request | +| `DEVELOPER_NOT_FOUND` | Developer / API keys | +| `API_ACCESS_FORBIDDEN` | Developer / API keys | +| `API_KEY_NOT_FOUND` | Developer / API keys | +| `API_KEY_FORBIDDEN` | Developer / API keys | +| `MISSING_REFRESH_TOKEN` | Refresh-token auth | +| `INVALID_REFRESH_TOKEN` | Refresh-token auth | +| `REVOKED_TOKEN` | Refresh-token auth | +| `EXPIRED_TOKEN` | Refresh-token auth | +| `REFRESH_FAILED` | Refresh-token auth | +| `REVOKE_FAILED` | Refresh-token auth | +| `NOT_AUTHENTICATED` | Refresh-token auth | +| `TOKEN_INFO_FAILED` | Refresh-token auth | +| `VAULT_NOT_FOUND` | Vault / deposit | +| `VAULT_BALANCE_RETRIEVAL_FAILED` | Vault / deposit | +| `MISSING_AMOUNT` | Vault / deposit | +| `INVALID_AMOUNT_TYPE` | Vault / deposit | +| `INVALID_AMOUNT_FORMAT` | Vault / deposit | +| `INVALID_NETWORK` | Vault / deposit | +| `NETWORK_MISMATCH` | Vault / deposit | +| `INVALID_SOURCE_ACCOUNT` | Vault / deposit | +| `INVALID_TRANSACTION_INPUT` | Vault / deposit | +| `SOURCE_ACCOUNT_NOT_FOUND` | Vault / deposit | +| `INVALID_CONTRACT_ID` | Vault / deposit | +| `NETWORK_UNAVAILABLE` | Vault / deposit | +| `TRANSACTION_BUILD_FAILED` | Vault / deposit | +| `INTERNAL_ERROR` | Vault / deposit | +| `INVALID_WEBHOOK_REGISTRATION` | Webhooks | +| `INVALID_WEBHOOK_EVENT_TYPES` | Webhooks | +| `WEBHOOK_NOT_FOUND` | Webhooks | +| `INVALID_WEBHOOK_URL` | Webhooks | +| `WEBHOOK_URL_VALIDATION_FAILED` | Webhooks | +| `MISSING_WEBHOOK_SIGNATURE_HEADERS` | Webhooks | +| `INVALID_WEBHOOK_TIMESTAMP` | Webhooks | +| `WEBHOOK_TIMESTAMP_OUT_OF_WINDOW` | Webhooks | +| `MALFORMED_WEBHOOK_SIGNATURE` | Webhooks | +| `INVALID_WEBHOOK_SIGNATURE` | Webhooks | +| `MALFORMED_WEBHOOK_NONCE` | Webhooks | +| `WEBHOOK_NONCE_REPLAYED` | Webhooks | +| `INVALID_DELIVERY_ID` | Webhooks | +| `INVALID_RETRY_POLICY` | Webhooks | +| `DLQ_ENTRY_NOT_FOUND` | Webhooks | +| `INVALID_IP_FORMAT` | IP allowlist | +| `IP_NOT_ALLOWED` | IP allowlist | +| `DATABASE_NOT_AVAILABLE` | DB / infrastructure | +| `IDEMPOTENCY_CONFLICT` | Idempotency | +| `IDEMPOTENCY_IN_PROGRESS` | Idempotency | +| `SIMULATION_FAILED` | Misc / direct middleware responses | +| `INVALID_AUTH_HEADER` | Route-specific / auth overrides (documented in docs/error-codes.md) | +| `MISSING_TOKEN` | Route-specific / auth overrides (documented in docs/error-codes.md) | +| `INVALID_TOKEN` | Route-specific / auth overrides (documented in docs/error-codes.md) | +| `MISSING_CLAIMS` | Route-specific / auth overrides (documented in docs/error-codes.md) | +| `TOKEN_EXPIRED` | Route-specific / auth overrides (documented in docs/error-codes.md) | +| `TOKEN_NOT_ACTIVE` | Route-specific / auth overrides (documented in docs/error-codes.md) | +| `QUOTA_REQUEST_NOT_FOUND` | Quota self-service | +| `QUOTA_REQUEST_ALREADY_RESOLVED` | Quota self-service | +| `INVALID_QUOTA_REQUEST` | Quota self-service | +| `REQUEST_TIMEOUT` | HTTP fallback derived codes referenced by documentation | +| `REQUEST_BODY_TOO_LARGE` | HTTP fallback derived codes referenced by documentation | +| `UNSUPPORTED_MEDIA_TYPE` | HTTP fallback derived codes referenced by documentation | +| `UNPROCESSABLE_ENTITY` | HTTP fallback derived codes referenced by documentation | +| `USAGE_AGGREGATE_NOT_FOUND` | Admin usage management | +| `INVALID_EXPORT_SCHEDULE` | Export schedules | +| `EXPORT_SCHEDULE_NOT_FOUND` | Export schedules | +| `MISSING_AUTH_FIELDS` | Auth | +| `AUTH_NOT_IMPLEMENTED` | Auth | +| `COMPONENT_NOT_CONFIGURED` | Health / dependency probes | + + +## Architecture + +### Components + +1. **YAML Catalog** (`docs/error-codes.yaml`) + - Human-readable source of truth + - Contains code, section, and description for each error + - Edited manually by developers + +2. **TypeScript Enum** (`src/errors/codes.ts`) + - Auto-generated from YAML + - Provides type-safe error code constants + - Includes JSDoc comments with descriptions + +3. **Generation Script** (`scripts/generate-error-codes.mjs`) + - Parses YAML catalog + - Generates TypeScript enum + - Updates markdown documentation + - Updates OpenAPI schema + +4. **CI Gate** + - Validates catalog consistency + - Ensures generated files are up-to-date + - Runs in CI/CD pipeline + +## YAML Catalog Format + +The catalog is structured as a list of error code entries: + +```yaml +error_codes: + - code: ERROR_CODE_NAME + section: Category Name + description: Human-readable explanation + + - code: ANOTHER_ERROR + section: Category Name + description: When this error occurs +``` + +### Field Definitions + +- **`code`** (required): Error code identifier in SCREAMING_SNAKE_CASE +- **`section`** (required): Category for documentation grouping +- **`description`** (required): Human-readable explanation of when this error occurs + +### Validation Rules + +1. **Code Format**: Must be SCREAMING_SNAKE_CASE (uppercase letters, numbers, underscores) +2. **Uniqueness**: No duplicate codes allowed +3. **Completeness**: All three fields (code, section, description) required +4. **Consistency**: Code value must match the enum key + +## Generated Outputs + +### 1. TypeScript Enum (`src/errors/codes.ts`) + +```typescript +export const ErrorCode = { + /** Human-readable description from YAML */ + ERROR_CODE_NAME: "ERROR_CODE_NAME", + + /** Another description */ + ANOTHER_ERROR: "ANOTHER_ERROR", +} as const; + +export type ErrorCode = (typeof ErrorCode)[keyof typeof ErrorCode]; + +export function isErrorCode(value: unknown): value is ErrorCode { + // Type guard implementation +} +``` + +Features: +- Const assertion for strict typing +- JSDoc comments with descriptions +- Type guard function +- Warning header about auto-generation + +### 2. Markdown Catalog (`docs/error-code-catalog.md`) + +The script injects a generated table between markers: + +```markdown + +## Canonical error code catalog + +| Code | Catalog section | +|---|---| +| `ERROR_CODE_NAME` | Category Name | +| `ANOTHER_ERROR` | Category Name | + +``` + +### 3. OpenAPI Schema (`docs/openapi.json`) + +Adds ErrorCode enum to OpenAPI components: + +```json +{ + "components": { + "schemas": { + "ErrorCode": { + "type": "string", + "enum": ["ERROR_CODE_NAME", "ANOTHER_ERROR"], + "description": "Canonical Callora backend error code." + }, + "ErrorResponse": { + "properties": { + "code": { + "$ref": "#/components/schemas/ErrorCode" + } + } + } + } + } +} +``` + +## Workflows + +### Adding a New Error Code + +1. **Edit YAML catalog**: + ```bash + vim docs/error-codes.yaml + ``` + +2. **Add entry**: + ```yaml + - code: MY_NEW_ERROR + section: My Feature + description: Occurs when my feature fails validation + ``` + +3. **Generate code**: + ```bash + npm run error-codes:generate + ``` + +4. **Verify changes**: + ```bash + git diff src/errors/codes.ts docs/error-codes.md docs/openapi.json + ``` + +5. **Commit all files**: + ```bash + git add docs/error-codes.yaml src/errors/codes.ts docs/error-codes.md docs/openapi.json + git commit -m "feat: add MY_NEW_ERROR code" + ``` + +### Modifying an Existing Code + +1. **Edit the YAML entry** (description or section only - never change the code value) +2. **Regenerate**: `npm run error-codes:generate` +3. **Commit**: Include all updated files + +**WARNING**: Changing a code value is a breaking change for API clients. Deprecate the old code and add a new one instead. + +### Removing a Code + +1. **Deprecation first**: Mark as deprecated in description +2. **Wait for migration**: Allow time for clients to update +3. **Remove from YAML**: After deprecation period +4. **Regenerate**: `npm run error-codes:generate` + +## Using Error Codes in Code + +### Importing + +```typescript +import { ErrorCode } from './errors/codes.js'; +``` + +### In Error Classes + +```typescript +throw new BadRequestError('Invalid input', ErrorCode.VALIDATION_ERROR); +``` + +### Type-Safe Checks + +```typescript +if (error.code === ErrorCode.INSUFFICIENT_BALANCE) { + // Handle insufficient balance +} +``` + +### Runtime Validation + +```typescript +import { isErrorCode } from './errors/codes.js'; + +if (isErrorCode(unknownValue)) { + // unknownValue is now typed as ErrorCode +} +``` + +## CI/CD Integration + +### Pre-commit Hook + +Add to `.git/hooks/pre-commit`: + +```bash +#!/bin/bash +npm run error-codes:check || { + echo "Error codes are out of sync. Run: npm run error-codes:generate" + exit 1 +} +``` + +### GitHub Actions + +Add to `.github/workflows/ci.yml`: + +```yaml +- name: Check error code generation + run: npm run error-codes:check +``` + +### package.json Scripts + +```json +{ + "scripts": { + "error-codes:generate": "node scripts/generate-error-codes.mjs", + "error-codes:check": "node scripts/generate-error-codes.mjs --check", + "prebuild": "npm run error-codes:check" + } +} +``` + +## Testing + +### Unit Tests + +Run script tests: + +```bash +node scripts/generate-error-codes.test.mjs +``` + +### Coverage + +Test scenarios: +- ✅ Valid YAML parsing +- ✅ Duplicate detection +- ✅ Format validation +- ✅ TypeScript generation +- ✅ Markdown update +- ✅ OpenAPI schema update +- ✅ Check mode validation +- ✅ Missing catalog handling +- ✅ Idempotency + +### Integration Tests + +```bash +# Generate and verify +npm run error-codes:generate +npm run error-codes:check # Should pass + +# Modify generated file +echo "// test" >> src/errors/codes.ts +npm run error-codes:check # Should fail +``` + +## Migration from Legacy System + +### Before (Manual TypeScript) + +```typescript +// src/errors/errorCatalog.ts +export const ErrorCode = { + // HTTP status derived + BAD_REQUEST: "BAD_REQUEST", + UNAUTHORIZED: "UNAUTHORIZED", + // ... manually maintained +} as const; +``` + +### After (YAML + Codegen) + +```yaml +# docs/error-codes.yaml +error_codes: + - code: BAD_REQUEST + section: HTTP status derived + description: The request is invalid +``` + +Generated TypeScript is identical, but source of truth is YAML. + +## Benefits + +1. **Single Source of Truth**: YAML catalog is the definitive reference +2. **Type Safety**: Generated TypeScript enum provides compile-time checks +3. **Documentation**: Automatically updates docs and OpenAPI +4. **Consistency**: CI gate prevents drift between catalog and code +5. **Review**: YAML diffs are easier to review than TypeScript +6. **Validation**: Format and uniqueness checks prevent errors +7. **Maintainability**: Clear separation of data and code + +## Troubleshooting + +### "Duplicate error codes" Error + +**Cause**: Same code appears multiple times in YAML + +**Solution**: Search for duplicates and remove/rename + +```bash +grep -n "code: YOUR_CODE" docs/error-codes.yaml +``` + +### "Invalid error code format" Error + +**Cause**: Code doesn't match SCREAMING_SNAKE_CASE + +**Solution**: Use only uppercase letters, numbers, and underscores + +```yaml +# Bad +- code: myError +- code: My-Error +- code: my_error + +# Good +- code: MY_ERROR +``` + +### "No error codes found" Error + +**Cause**: YAML syntax error or empty catalog + +**Solution**: Validate YAML syntax + +```bash +# Install yamllint +pip install yamllint + +# Validate +yamllint docs/error-codes.yaml +``` + +### Generated Files Out of Sync + +**Cause**: Manual edits to generated files + +**Solution**: Regenerate from YAML + +```bash +npm run error-codes:generate +``` + +### CI Check Fails + +**Cause**: Generated files not committed + +**Solution**: Run generation and commit all changes + +```bash +npm run error-codes:generate +git add src/errors/codes.ts docs/error-codes.md docs/openapi.json +git commit --amend --no-edit +``` + +## Security Considerations + +1. **No Secrets in Errors**: Never include sensitive data in error descriptions +2. **Client-Safe Messages**: Descriptions may appear in client-facing documentation +3. **Stable Codes**: Error codes are part of the public API contract +4. **Audit Trail**: All changes tracked in git history + +## Performance + +- **Build Time**: ~50ms to parse YAML and generate files +- **Runtime**: Zero overhead - generated code is identical to hand-written +- **CI Time**: Check mode adds ~30ms to builds + +## Future Enhancements + +Potential improvements: +- [ ] Add i18n support for error messages +- [ ] Generate error code documentation site +- [ ] Add severity levels to catalog +- [ ] Generate Prometheus metrics labels +- [ ] Add suggested HTTP status codes to catalog +- [ ] Validate error usage in codebase + +## References + +- [Error Response Format](./error-codes.md) - Full error documentation +- [YAML Specification](https://yaml.org/spec/1.2.2/) +- [TypeScript Enums](https://www.typescriptlang.org/docs/handbook/enums.html) +- [OpenAPI Schema Objects](https://swagger.io/specification/#schema-object) diff --git a/docs/error-codes.md b/docs/error-codes.md new file mode 100644 index 00000000..ce056369 --- /dev/null +++ b/docs/error-codes.md @@ -0,0 +1,460 @@ +# Error response envelope and error codes + +This page is the source-aligned reference for Callora backend error responses. +It documents the shared `errorHandler` response envelope, every error class in +`src/errors/index.ts`, the `/v1/call` gateway/proxy failure mapping, and the +billing/Soroban error mapping. It is documentation-only and does not describe +any runtime behavior that is not present in the current source. + +## Canonical error code catalog + +The canonical, generated list of error codes lives in +[`docs/error-code-catalog.md`](./error-code-catalog.md). It is produced from +[`docs/error-codes.yaml`](./error-codes.yaml) by `npm run error-codes:generate`; +`npm run error-codes:check` verifies it in CI. This page keeps the hand-written +reference for the response envelope, error classes, and the gateway/billing mappings. + +## Scope and important caveats + +The standard envelope applies to errors that reach the shared Express `errorHandler`. It does not wrap every response served by the backend. + +For `/v1/call` proxy requests, an upstream HTTP response is streamed back to the +caller with the upstream status, upstream body, and safe upstream headers after +hop-by-hop headers are stripped. Those proxied upstream responses are not +converted into Callora's standard error envelope, even if the upstream status is +`4xx` or `5xx`. + +For generated Callora errors, the `requestId` field is read from `req.id`. If no +middleware or route has attached `req.id`, the error handler serializes +`"unknown"`. The route-local proxy UUID used for upstream `x-request-id` +forwarding is separate from `req.id` unless application code explicitly wires +them together. + +Some middleware can write responses directly instead of passing an `AppError` to +the shared handler. This page calls those cases out when they are adjacent to +the gateway or billing flows; direct middleware responses may not include `requestId` +or the exact standard envelope shape. + +## Standard envelope + +Errors handled by `src/middleware/errorHandler.ts` are returned as JSON: + +```json +{ + "code": "BAD_GATEWAY", + "message": "Bad Gateway: upstream unreachable", + "requestId": "req_123" +} +``` + +The HTTP status is carried by the HTTP response status line, not by a `status` +field in the JSON body. For `AppError` instances, the handler uses the error's +`statusCode` and explicit `code`; if an `AppError` has no `code`, the handler +derives one from the status. For non-`AppError` errors, it uses a numeric +`err.status` when present, otherwise `500`, and derives the response `code` from +that status. + +In production, unexpected non-`AppError` messages are masked to `"Internal server error"`. `AppError` messages are not masked by the error handler. + +`details` is optional. It is currently included for validation errors and any error-like object with an array `details` property: + +```json +{ + "code": "VALIDATION_ERROR", + "message": "Request validation failed", + "requestId": "req_123", + "details": [ + { + "field": "body.endpoints[0].path", + "message": "Required", + "code": "INVALID_TYPE" + } + ] +} +``` + +Pagination query validation uses this same envelope. Invalid integer fields such +as `limit=10.0`, `limit=1e2`, or `limit=0x10` return HTTP 400 with +`code: "VALIDATION_ERROR"` and a `details` entry for `query.limit`. + +## Error classes from `src/errors/index.ts` + +Every subclass accepts an optional custom `code` argument. The table lists the +default response behavior when the class is constructed without a code override. +`AppError` is the base class: it has a default status of `500`, but it does not +set a default instance code; the shared handler derives the body code from the +status when `code` is omitted. + +| Class | HTTP status | Default body code | Default message | Meaning | +|---|---:|---|---|---| +| `AppError` | `500` by constructor default | `INTERNAL_SERVER_ERROR` when `code` is omitted and status is `500` | caller-supplied | Base application error type. Prefer a specific subclass for public route errors. | +| `BadRequestError` | `400` | `BAD_REQUEST` | `Bad request` | The request is malformed, missing required input, or otherwise invalid. | +| `UnauthorizedError` | `401` | `UNAUTHORIZED` | `Unauthorized` | Authentication is missing, malformed, or invalid. | +| `ForbiddenError` | `403` | `FORBIDDEN` | `Forbidden` | The caller is authenticated but not allowed to perform the action. | +| `NotFoundError` | `404` | `NOT_FOUND` | `Not found` | The requested resource does not exist. | +| `PaymentRequiredError` | `402` | `PAYMENT_REQUIRED` | `Payment Required` | The caller has insufficient balance or payment is otherwise required. | +| `TooManyRequestsError` | `429` | `TOO_MANY_REQUESTS` | `Too Many Requests` | The caller exceeded a rate limit. | +| `ConflictError` | `409` | `CONFLICT` | `Conflict` | The request conflicts with existing state. | +| `InternalServerError` | `500` | `INTERNAL_SERVER_ERROR` | `Internal server error` | An internal service or invariant failed. | +| `BadGatewayError` | `502` | `BAD_GATEWAY` | `Bad Gateway` | The gateway could not obtain a valid upstream or dependency response. | +| `ServiceUnavailableError` | `503` | `SERVICE_UNAVAILABLE` | `Service unavailable` | A dependency or service is temporarily unavailable. | +| `GatewayTimeoutError` | `504` | `GATEWAY_TIMEOUT` | `Gateway Timeout` | A dependency or upstream service did not respond before its timeout. | + +The examples below assume `req.id === "req_123"` when the error reaches the handler. + +```json +[ + { + "class": "AppError", + "status": 500, + "body": { + "code": "INTERNAL_SERVER_ERROR", + "message": "Base application error", + "requestId": "req_123" + } + }, + { + "class": "BadRequestError", + "status": 400, + "body": { + "code": "BAD_REQUEST", + "message": "Bad request", + "requestId": "req_123" + } + }, + { + "class": "UnauthorizedError", + "status": 401, + "body": { + "code": "UNAUTHORIZED", + "message": "Unauthorized", + "requestId": "req_123" + } + }, + { + "class": "ForbiddenError", + "status": 403, + "body": { + "code": "FORBIDDEN", + "message": "Forbidden", + "requestId": "req_123" + } + }, + { + "class": "NotFoundError", + "status": 404, + "body": { + "code": "NOT_FOUND", + "message": "Not found", + "requestId": "req_123" + } + }, + { + "class": "PaymentRequiredError", + "status": 402, + "body": { + "code": "PAYMENT_REQUIRED", + "message": "Payment Required", + "requestId": "req_123" + } + }, + { + "class": "TooManyRequestsError", + "status": 429, + "body": { + "code": "TOO_MANY_REQUESTS", + "message": "Too Many Requests", + "requestId": "req_123" + } + }, + { + "class": "ConflictError", + "status": 409, + "body": { + "code": "CONFLICT", + "message": "Conflict", + "requestId": "req_123" + } + }, + { + "class": "InternalServerError", + "status": 500, + "body": { + "code": "INTERNAL_SERVER_ERROR", + "message": "Internal server error", + "requestId": "req_123" + } + }, + { + "class": "BadGatewayError", + "status": 502, + "body": { + "code": "BAD_GATEWAY", + "message": "Bad Gateway", + "requestId": "req_123" + } + }, + { + "class": "ServiceUnavailableError", + "status": 503, + "body": { + "code": "SERVICE_UNAVAILABLE", + "message": "Service unavailable", + "requestId": "req_123" + } + }, + { + "class": "GatewayTimeoutError", + "status": 504, + "body": { + "code": "GATEWAY_TIMEOUT", + "message": "Gateway Timeout", + "requestId": "req_123" + } + } +] +``` + +## Handler-derived fallback codes + +When a non-`AppError` error reaches the handler with a numeric `status`, or when an `AppError` reaches the handler with no explicit `code`, the handler derives the code from the status. + +| Status | Derived code | +|---:|---| +| `400` | `BAD_REQUEST` | +| `401` | `UNAUTHORIZED` | +| `402` | `PAYMENT_REQUIRED` | +| `403` | `FORBIDDEN` | +| `404` | `NOT_FOUND` | +| `408` | `REQUEST_TIMEOUT` | +| `409` | `CONFLICT` | +| `413` | `REQUEST_BODY_TOO_LARGE` | +| `415` | `UNSUPPORTED_MEDIA_TYPE` | +| `422` | `UNPROCESSABLE_ENTITY` | +| `429` | `TOO_MANY_REQUESTS` | +| `500` | `INTERNAL_SERVER_ERROR` | +| `502` | `BAD_GATEWAY` | +| `503` | `SERVICE_UNAVAILABLE` | +| `504` | `GATEWAY_TIMEOUT` | + +For statuses not listed above, the fallback is `INTERNAL_SERVER_ERROR` for `5xx` statuses and `BAD_REQUEST` otherwise. Body-parser `413` errors receive the message `"Request body too large"`. + +## Validation errors + +`src/middleware/validate.ts` defines `ValidationError`, which extends `BadRequestError`, sets the status to `400`, overrides the code to `VALIDATION_ERROR`, and adds field-level `details`. + +```json +{ + "code": "VALIDATION_ERROR", + "message": "Request validation failed", + "requestId": "req_123", + "details": [ + { + "field": "query.network", + "message": "Invalid option: expected one of \"testnet\"|\"mainnet\"", + "code": "INVALID_VALUE" + } + ] +} +``` + +## Gateway/proxy errors + +The modern upstream proxy is implemented by `createProxyRouter()` in `src/routes/proxyRoutes.ts`. It registers `ALL /v1/call/:apiSlugOrId/*` and `ALL /v1/call/:apiSlugOrId`. + +### Authentication before the proxy handler + +Gateway API-key authentication runs before the proxy handler. It can reject a +request before `handleProxy()` starts. The middleware reads `X-Api-Key` first; +if that header is absent, it parses `Authorization: Bearer `. A +malformed `Authorization` header therefore causes `401` only when `X-Api-Key` is +not present. + +| Condition | HTTP status | Code | Error class | Notes | +|---|---:|---|---|---| +| Missing API key, or malformed `Authorization` header when `X-Api-Key` is absent | `401` | `UNAUTHORIZED` | `UnauthorizedError` | The exact message is `Unauthorized: missing API key` or `Unauthorized: malformed Authorization header`. | +| Unknown API slug or ID | `404` | `NOT_FOUND` | `NotFoundError` | Message is `Not Found: unknown API`. | +| API key not found, invalid, incomplete, or not authorized for the resolved API | `401` | `UNAUTHORIZED` | `UnauthorizedError` | The exact message describes the failed check. | +| Revoked API key | `403` | `FORBIDDEN` | `ForbiddenError` | The current message text is `Unauthorized: API key has been revoked`, but the status and code are forbidden. | + +### Proxy pre-flight errors inside `handleProxy()` + +| Condition | HTTP status | Code | Error class | Notes | +|---|---:|---|---|---| +| Gateway authentication context is unexpectedly missing after auth middleware | `500` | `GATEWAY_AUTH_CONTEXT_MISSING` | `InternalServerError` | Internal invariant failure before proxying. | +| Rate limiter rejects the API key | `429` | `TOO_MANY_REQUESTS` | `TooManyRequestsError` | The route sets `Retry-After` to the retry delay rounded up to whole seconds. | +| Pre-proxy balance check returns `<= 0` | `402` | `PAYMENT_REQUIRED` | `PaymentRequiredError` | Message is `Payment Required: insufficient balance`. | +| Resolved upstream target fails validation or allowlist checks | `502` | `UPSTREAM_TARGET_BLOCKED` | `BadGatewayError` | The message is the validation error message when available, otherwise `Configured upstream target is not allowed.` | + +### Upstream response and failure mapping + +The proxy maintains an internal `upstreamStatus` value for metrics and usage recording: + +1. Initialize `upstreamStatus` to `502` before calling `fetch()`. +2. If `fetch()` resolves with an HTTP response, set `upstreamStatus = upstreamRes.status`, + stop the upstream timer with outcome `success`, forward safe response + headers, set the HTTP response status to the upstream status, and stream the + upstream body. +3. If `fetch()` throws `DOMException` with `name === "TimeoutError"`, set `upstreamStatus = 504`, stop the timer with outcome `timeout`, and throw `GatewayTimeoutError('Upstream service timed out')`. +4. If `fetch()` throws `TypeError` with Undici code `UND_ERR_CONNECT_TIMEOUT`, handle it the same way as a timeout: `504` and `GATEWAY_TIMEOUT`. +5. For any other fetch, DNS, connection, or transport failure, set `upstreamStatus = 502`, stop the timer with outcome `error`, and throw `BadGatewayError('Bad Gateway: upstream unreachable')`. + +| Event | HTTP status returned by Callora | Code | Error class | Body behavior | +|---|---:|---|---|---| +| Upstream returns an HTTP response, including `4xx` or `5xx` | upstream status | not generated by Callora | none | The proxy streams the upstream body and safe headers. | +| `fetch()` throws `DOMException` with `name === "TimeoutError"` | `504` | `GATEWAY_TIMEOUT` | `GatewayTimeoutError` | Standard error envelope. | +| `fetch()` throws `TypeError` with code `UND_ERR_CONNECT_TIMEOUT` | `504` | `GATEWAY_TIMEOUT` | `GatewayTimeoutError` | Standard error envelope. | +| Any other fetch/connect failure | `502` | `BAD_GATEWAY` | `BadGatewayError` | Standard error envelope. | + +For generated `502` and `504` proxy errors, the JSON body does not include `upstreamStatus`, +the raw upstream response body, raw upstream error payload, or a Soroban revert reason. +If the upstream actually returns an HTTP response, the proxy forwards that +response instead of generating the standard envelope. + +Example proxy request: + +```bash +curl -i \ + -H 'X-Api-Key: ' \ + 'http://localhost:3000/v1/call/weather-api/forecast' +``` + +Example generated timeout response when no request id middleware populated `req.id`: + +```http +HTTP/1.1 504 Gateway Timeout +Content-Type: application/json; charset=utf-8 +``` + +```json +{ + "code": "GATEWAY_TIMEOUT", + "message": "Upstream service timed out", + "requestId": "unknown" +} +``` + +Example generated unreachable-upstream response when no request id middleware populated `req.id`: + +```http +HTTP/1.1 502 Bad Gateway +Content-Type: application/json; charset=utf-8 +``` + +```json +{ + "code": "BAD_GATEWAY", + "message": "Bad Gateway: upstream unreachable", + "requestId": "unknown" +} +``` + +The legacy `ALL /api/gateway/:apiId` route also maps generated upstream timeouts +to `504` and other generated upstream failures to `502`, but it performs API-key +lookup, credit deduction, and usage recording in the legacy route flow. The +`/v1/call` mapping above is the primary gateway/proxy reference. + +## Billing and Soroban errors + +Billing routes are implemented in `src/routes/billing.ts`. Soroban RPC failures +are represented by `SorobanRpcError` categories in +`src/services/sorobanBilling.ts` and then converted to `AppError` subclasses by +the billing route. + +| Soroban category | HTTP status | Response code | Error class | Meaning | +|---|---:|---|---|---| +| `INSUFFICIENT_BALANCE` | `402` | `INSUFFICIENT_BALANCE` | `PaymentRequiredError` | On-chain or pre-flight balance is too low. | +| `TIMEOUT` | `504` | `SOROBAN_RPC_TIMEOUT` | `GatewayTimeoutError` | The Soroban RPC request timed out, was aborted, or otherwise matched the timeout category. | +| `CONTRACT_ERROR` | `502` | `SOROBAN_RPC_ERROR` | `BadGatewayError` | The contract rejected the call, simulation failed, or the failure matched contract/wasm classification. | +| `NETWORK_ERROR` | `502` | `SOROBAN_RPC_ERROR` | `BadGatewayError` | Soroban transport, HTTP, or missing-result failures. | + +`POST /api/billing/deduct` uses `requireAuth` before the route handler. +Authentication failures are passed through the shared handler as `401` +responses. Depending on the auth failure, the response code can be the default +`UNAUTHORIZED` or one of the route-auth overrides: `INVALID_AUTH_HEADER`, +`MISSING_TOKEN`, `INVALID_TOKEN`, `MISSING_CLAIMS`, `TOKEN_EXPIRED`, or +`TOKEN_NOT_ACTIVE`. + +The same route also uses `idempotencyMiddleware`. Two idempotency conflicts are +written directly by that middleware instead of being passed to `errorHandler`, +so their JSON body is `{ "error", "message", "code" }` and does not include +`requestId`: + +| Idempotency condition | HTTP status | Response code | Body shape | +|---|---:|---|---| +| Existing idempotency key with different request hash | `409` | `IDEMPOTENCY_CONFLICT` | Direct middleware JSON response. | +| Existing idempotency key is still marked `started` | `409` | `IDEMPOTENCY_IN_PROGRESS` | Direct middleware JSON response. | + +`POST /api/billing/deduct` maps unsuccessful `BillingService.deduct()` result messages before falling back to a generic billing failure: + +| Route condition | HTTP status | Response code | Error class | Notes | +|---|---:|---|---|---| +| Missing authenticated user | `401` | `UNAUTHORIZED` | `UnauthorizedError` | Auth middleware should normally prevent this. | +| Invalid `requestId`, `apiId`, `endpointId`, `apiKeyId`, `amountUsdc`, or `idempotencyKey` | `400` | `BAD_REQUEST` | `BadRequestError` | Each validation failure has a field-specific message. | +| Database pool is unavailable | `500` | `DATABASE_NOT_AVAILABLE` | `InternalServerError` | Route-specific code override. | +| Failure message contains `insufficient balance` or `insufficient funds` | `402` | `INSUFFICIENT_BALANCE` | `PaymentRequiredError` | Message is preserved from the billing result. | +| Failure message contains `timeout` or `timed out` | `504` | `SOROBAN_RPC_TIMEOUT` | `GatewayTimeoutError` | Message is preserved from the billing result. | +| Failure message contains `balance check failed`, `contract`, or `network` | `502` | `SOROBAN_RPC_ERROR` | `BadGatewayError` | Message is preserved from the billing result. | +| Any other unsuccessful deduction result | `500` | `BILLING_DEDUCTION_FAILED` | `InternalServerError` | Response message is `Billing deduction failed`. | + +`GET /api/billing/request/:requestId` uses these route-specific errors: + +| Route condition | HTTP status | Response code | Error class | +|---|---:|---|---| +| Missing authenticated user | `401` | `UNAUTHORIZED` | `UnauthorizedError` | +| Missing or empty `requestId` param | `400` | `BAD_REQUEST` | `BadRequestError` | +| Database pool is unavailable | `500` | `DATABASE_NOT_AVAILABLE` | `InternalServerError` | +| Billing request is not found | `404` | `BILLING_REQUEST_NOT_FOUND` | `NotFoundError` | + +The billing error envelope does not add a structured raw Soroban category, raw +RPC payload, revert-reason field, or `details` array. It exposes the mapped HTTP +status, stable `code`, `message`, and `requestId` supplied by the shared error +handler. The `message` can contain the normalized Soroban or billing error +message, but consumers should branch on `code` and HTTP status rather than +parsing the message. + +Example insufficient-balance request: + +```bash +curl -i -X POST 'http://localhost:3000/api/billing/deduct' \ + -H 'Authorization: Bearer ' \ + -H 'Content-Type: application/json' \ + -H 'Idempotency-Key: bill_req_123' \ + -d '{ + "requestId": "bill_req_123", + "apiId": "api_001", + "endpointId": "forecast", + "apiKeyId": "key_001", + "amountUsdc": "0.10" + }' +``` + +Example insufficient-balance response: + +```http +HTTP/1.1 402 Payment Required +Content-Type: application/json; charset=utf-8 +``` + +```json +{ + "code": "INSUFFICIENT_BALANCE", + "message": "Insufficient balance: required 1000000 units, available 0", + "requestId": "req_123" +} +``` + +Example Soroban timeout response: + +```http +HTTP/1.1 504 Gateway Timeout +Content-Type: application/json; charset=utf-8 +``` + +```json +{ + "code": "SOROBAN_RPC_TIMEOUT", + "message": "Soroban RPC request timed out", + "requestId": "req_123" +} +``` diff --git a/docs/error-codes.yaml b/docs/error-codes.yaml new file mode 100644 index 00000000..42a3d54b --- /dev/null +++ b/docs/error-codes.yaml @@ -0,0 +1,387 @@ +# Canonical Error Code Catalog +# +# This is the single source of truth for all error codes in the Callora backend. +# The TypeScript enum in src/errors/codes.ts is auto-generated from this file. +# +# DO NOT edit src/errors/codes.ts manually. Instead, update this file and run: +# npm run error-codes:generate +# +# Each entry must have: +# - code: The error code identifier (SCREAMING_SNAKE_CASE) +# - section: Category for documentation grouping +# - description: Human-readable explanation of when this error occurs +# +# The 'code' field becomes both the TypeScript enum key and value. + +error_codes: + # HTTP status derived / base app codes + - code: BAD_REQUEST + section: HTTP status derived / base app codes + description: The request is malformed, missing required input, or otherwise invalid + + - code: UNAUTHORIZED + section: HTTP status derived / base app codes + description: Authentication is missing, malformed, or invalid + + - code: FORBIDDEN + section: HTTP status derived / base app codes + description: The caller is authenticated but not allowed to perform the action + + - code: NOT_FOUND + section: HTTP status derived / base app codes + description: The requested resource does not exist + + - code: PAYMENT_REQUIRED + section: HTTP status derived / base app codes + description: The caller has insufficient balance or payment is otherwise required + + - code: TOO_MANY_REQUESTS + section: HTTP status derived / base app codes + description: The caller exceeded a rate limit + + - code: CONFLICT + section: HTTP status derived / base app codes + description: The request conflicts with existing state + + - code: INTERNAL_SERVER_ERROR + section: HTTP status derived / base app codes + description: An internal service or invariant failed + + - code: BAD_GATEWAY + section: HTTP status derived / base app codes + description: The gateway could not obtain a valid upstream or dependency response + + - code: SERVICE_UNAVAILABLE + section: HTTP status derived / base app codes + description: A dependency or service is temporarily unavailable + + - code: GATEWAY_TIMEOUT + section: HTTP status derived / base app codes + description: A dependency or upstream service did not respond before its timeout + + # Validation + - code: VALIDATION_ERROR + section: Validation + description: Request validation failed due to invalid input + + - code: INVALID_BODY + section: Validation + description: Request body is invalid or malformed + + - code: INVALID_QUERY + section: Validation + description: Query parameters are invalid + + - code: INVALID_PARAMS + section: Validation + description: URL parameters are invalid + + - code: INVALID_VALUE + section: Validation + description: A specific field contains an invalid value + + # Gateway / proxy + - code: GATEWAY_AUTH_CONTEXT_MISSING + section: Gateway / proxy + description: Gateway authentication context is unexpectedly missing after auth middleware + + - code: UPSTREAM_TARGET_BLOCKED + section: Gateway / proxy + description: Resolved upstream target fails validation or allowlist checks + + # Billing / Soroban + - code: INSUFFICIENT_BALANCE + section: Billing / Soroban + description: On-chain or pre-flight balance is too low + + - code: SOROBAN_RPC_TIMEOUT + section: Billing / Soroban + description: The Soroban RPC request timed out or was aborted + + - code: SOROBAN_RPC_ERROR + section: Billing / Soroban + description: The contract rejected the call, simulation failed, or network error occurred + + - code: BILLING_DEDUCTION_FAILED + section: Billing / Soroban + description: Billing deduction operation failed + + # Billing request + - code: BILLING_REQUEST_NOT_FOUND + section: Billing request + description: The requested billing record was not found + + # Developer / API keys + - code: DEVELOPER_NOT_FOUND + section: Developer / API keys + description: Developer profile not found + + - code: API_ACCESS_FORBIDDEN + section: Developer / API keys + description: Access to the API is forbidden for this developer + + - code: API_KEY_NOT_FOUND + section: Developer / API keys + description: API key not found + + - code: API_KEY_FORBIDDEN + section: Developer / API keys + description: API key is not authorized for this operation + + # Refresh-token auth + - code: MISSING_REFRESH_TOKEN + section: Refresh-token auth + description: Refresh token is missing from the request + + - code: INVALID_REFRESH_TOKEN + section: Refresh-token auth + description: Refresh token is invalid or malformed + + - code: REVOKED_TOKEN + section: Refresh-token auth + description: The token has been revoked + + - code: EXPIRED_TOKEN + section: Refresh-token auth + description: The token has expired + + - code: REFRESH_FAILED + section: Refresh-token auth + description: Token refresh operation failed + + - code: REVOKE_FAILED + section: Refresh-token auth + description: Token revocation operation failed + + - code: NOT_AUTHENTICATED + section: Refresh-token auth + description: User is not authenticated + + - code: TOKEN_INFO_FAILED + section: Refresh-token auth + description: Failed to retrieve token information + + # Vault / deposit + - code: VAULT_NOT_FOUND + section: Vault / deposit + description: Vault account not found + + - code: VAULT_BALANCE_RETRIEVAL_FAILED + section: Vault / deposit + description: Failed to retrieve vault balance + + - code: MISSING_AMOUNT + section: Vault / deposit + description: Amount parameter is missing + + - code: INVALID_AMOUNT_TYPE + section: Vault / deposit + description: Amount has invalid type + + - code: INVALID_AMOUNT_FORMAT + section: Vault / deposit + description: Amount has invalid format + + - code: INVALID_NETWORK + section: Vault / deposit + description: Network parameter is invalid + + - code: NETWORK_MISMATCH + section: Vault / deposit + description: Network mismatch between request and resource + + - code: INVALID_SOURCE_ACCOUNT + section: Vault / deposit + description: Source account is invalid + + - code: INVALID_TRANSACTION_INPUT + section: Vault / deposit + description: Transaction input is invalid + + - code: SOURCE_ACCOUNT_NOT_FOUND + section: Vault / deposit + description: Source account not found + + - code: INVALID_CONTRACT_ID + section: Vault / deposit + description: Contract ID is invalid + + - code: NETWORK_UNAVAILABLE + section: Vault / deposit + description: Network is unavailable + + - code: TRANSACTION_BUILD_FAILED + section: Vault / deposit + description: Failed to build transaction + + - code: INTERNAL_ERROR + section: Vault / deposit + description: Internal error occurred during vault operation + + # Webhooks + - code: INVALID_WEBHOOK_REGISTRATION + section: Webhooks + description: Webhook registration is invalid + + - code: INVALID_WEBHOOK_EVENT_TYPES + section: Webhooks + description: Webhook event types are invalid + + - code: WEBHOOK_NOT_FOUND + section: Webhooks + description: Webhook not found + + - code: INVALID_WEBHOOK_URL + section: Webhooks + description: Webhook URL is invalid + + - code: WEBHOOK_URL_VALIDATION_FAILED + section: Webhooks + description: Webhook URL validation failed + + - code: MISSING_WEBHOOK_SIGNATURE_HEADERS + section: Webhooks + description: Webhook signature headers are missing + + - code: INVALID_WEBHOOK_TIMESTAMP + section: Webhooks + description: Webhook timestamp is invalid + + - code: WEBHOOK_TIMESTAMP_OUT_OF_WINDOW + section: Webhooks + description: Webhook timestamp is outside acceptable window + + - code: MALFORMED_WEBHOOK_SIGNATURE + section: Webhooks + description: Webhook signature is malformed + + - code: INVALID_WEBHOOK_SIGNATURE + section: Webhooks + description: Webhook signature verification failed + + - code: MALFORMED_WEBHOOK_NONCE + section: Webhooks + description: Webhook nonce header is malformed + + - code: WEBHOOK_NONCE_REPLAYED + section: Webhooks + description: Webhook nonce has already been used + + - code: INVALID_DELIVERY_ID + section: Webhooks + description: The delivery ID provided for webhook replay is missing or invalid + + - code: INVALID_RETRY_POLICY + section: Webhooks + description: The retry policy provided is invalid + + - code: DLQ_ENTRY_NOT_FOUND + section: Webhooks + description: No Dead-Letter Queue entry was found for the given delivery ID + + # IP allowlist + - code: INVALID_IP_FORMAT + section: IP allowlist + description: IP address format is invalid + + - code: IP_NOT_ALLOWED + section: IP allowlist + description: IP address is not in the allowlist + + # DB / infrastructure + - code: DATABASE_NOT_AVAILABLE + section: DB / infrastructure + description: Database is not available + + # Idempotency + - code: IDEMPOTENCY_CONFLICT + section: Idempotency + description: Idempotency key conflict with different request + + - code: IDEMPOTENCY_IN_PROGRESS + section: Idempotency + description: Request with this idempotency key is still in progress + + # Misc / direct middleware responses + - code: SIMULATION_FAILED + section: Misc / direct middleware responses + description: Soroban simulation failed + + # Route-specific / auth overrides + - code: INVALID_AUTH_HEADER + section: Route-specific / auth overrides (documented in docs/error-codes.md) + description: Authorization header is invalid + + - code: MISSING_TOKEN + section: Route-specific / auth overrides (documented in docs/error-codes.md) + description: Authentication token is missing + + - code: INVALID_TOKEN + section: Route-specific / auth overrides (documented in docs/error-codes.md) + description: Authentication token is invalid + + - code: MISSING_CLAIMS + section: Route-specific / auth overrides (documented in docs/error-codes.md) + description: Token claims are missing + + - code: TOKEN_EXPIRED + section: Route-specific / auth overrides (documented in docs/error-codes.md) + description: Authentication token has expired + + - code: TOKEN_NOT_ACTIVE + section: Route-specific / auth overrides (documented in docs/error-codes.md) + description: Authentication token is not yet active + + # Quota self-service + - code: QUOTA_REQUEST_NOT_FOUND + section: Quota self-service + description: Quota request not found + + - code: QUOTA_REQUEST_ALREADY_RESOLVED + section: Quota self-service + description: Quota request has already been resolved + + - code: INVALID_QUOTA_REQUEST + section: Quota self-service + description: Quota request is invalid + + # HTTP fallback derived codes + - code: REQUEST_TIMEOUT + section: HTTP fallback derived codes referenced by documentation + description: Request timeout + + - code: REQUEST_BODY_TOO_LARGE + section: HTTP fallback derived codes referenced by documentation + description: Request body exceeds size limit + + - code: UNSUPPORTED_MEDIA_TYPE + section: HTTP fallback derived codes referenced by documentation + description: Media type is not supported + + - code: UNPROCESSABLE_ENTITY + section: HTTP fallback derived codes referenced by documentation + description: Request is syntactically correct but semantically invalid + + - code: USAGE_AGGREGATE_NOT_FOUND + section: Admin usage management + description: Usage aggregate not found for the given developer + + - code: INVALID_EXPORT_SCHEDULE + section: Export schedules + description: Export schedule payload or configuration is invalid + + - code: EXPORT_SCHEDULE_NOT_FOUND + section: Export schedules + description: Export schedule not found + + - code: MISSING_AUTH_FIELDS + section: Auth + description: Required authentication fields are missing from the request + + - code: AUTH_NOT_IMPLEMENTED + section: Auth + description: The authentication method is not yet implemented + + - code: COMPONENT_NOT_CONFIGURED + section: Health / dependency probes + description: A required system component is not configured diff --git a/package.json b/package.json index 9130171a..15513841 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,6 @@ { "name": "callora-backend", - "version": "1.0.0", - "private": true, + "version": "0.0.1", "type": "module", "scripts": { "build": "tsc", @@ -20,7 +19,7 @@ "error-codes:generate": "node scripts/generate-error-codes.mjs", "error-codes:check": "node scripts/generate-error-codes.mjs --check", "pretest": "npm run error-codes:check", - "test": "node --test scripts/*.test.mjs", + "test": "jest --forceExit", "test:serial": "jest --runInBand --forceExit", "test:unit": "jest --runInBand --forceExit --testPathIgnorePatterns tests/integration", "test:integration": "jest --runInBand --forceExit tests/integration", diff --git a/scripts/generate-error-codes.mjs b/scripts/generate-error-codes.mjs new file mode 100644 index 00000000..2ff057b4 --- /dev/null +++ b/scripts/generate-error-codes.mjs @@ -0,0 +1,275 @@ +import fs from "node:fs"; +import path from "node:path"; +import process from "node:process"; + +const root = process.cwd(); +const yamlCatalogPath = path.join(root, "docs", "error-codes.yaml"); +const legacyCatalogPath = path.join(root, "src", "errors", "errorCatalog.ts"); +const generatedCodesPath = path.join(root, "src", "errors", "codes.ts"); +const catalogPath = path.join(root, "docs", "error-code-catalog.md"); +const openApiPath = path.join(root, "docs", "openapi.json"); +const checkOnly = process.argv.includes("--check"); + +const startMarker = ""; +const endMarker = ""; + +/** + * Parse the YAML catalog manually (no external dependencies). + * This is a simple parser that works for our specific YAML structure. + */ +function parseYamlCatalog(yamlContent) { + const entries = []; + const lines = yamlContent.split(/\r?\n/); + let currentEntry = null; + + for (let i = 0; i < lines.length; i++) { + const line = lines[i]; + + // Start of a new error code entry + if (line.match(/^\s*-\s+code:/)) { + if (currentEntry && currentEntry.code) { + entries.push(currentEntry); + } + currentEntry = { code: "", section: "", description: "" }; + const codeMatch = line.match(/code:\s+([A-Z0-9_]+)/); + if (codeMatch) { + currentEntry.code = codeMatch[1]; + } else { + // Try to capture any code value for validation error + const anyCodeMatch = line.match(/code:\s+(\S+)/); + if (anyCodeMatch) { + currentEntry.code = anyCodeMatch[1]; + } + } + } else if (currentEntry) { + // Parse section field + const sectionMatch = line.match(/^\s+section:\s+(.+)$/); + if (sectionMatch) { + currentEntry.section = sectionMatch[1].trim(); + } + + // Parse description field + const descMatch = line.match(/^\s+description:\s+(.+)$/); + if (descMatch) { + currentEntry.description = descMatch[1].trim(); + } + } + } + + // Don't forget the last entry + if (currentEntry && currentEntry.code) { + entries.push(currentEntry); + } + + return entries; +} + +function readCatalog() { + // Check if YAML catalog exists, otherwise fall back to TS catalog + let entries = []; + + if (fs.existsSync(yamlCatalogPath)) { + const yamlContent = fs.readFileSync(yamlCatalogPath, "utf8"); + entries = parseYamlCatalog(yamlContent); + } else if (fs.existsSync(legacyCatalogPath)) { + // Fallback to legacy TS catalog parsing + const source = fs.readFileSync(legacyCatalogPath, "utf8"); + let section = "General"; + + for (const line of source.split(/\r?\n/)) { + const sectionMatch = line.match(/^\s*\/\/\s+(.+)$/); + if (sectionMatch) { + section = sectionMatch[1].trim(); + continue; + } + + const entryMatch = line.match(/^\s*([A-Z0-9_]+):\s*"([A-Z0-9_]+)",$/); + if (!entryMatch) continue; + + const [, key, value] = entryMatch; + if (key !== value) { + throw new Error(`ErrorCode key/value mismatch: ${key} !== ${value}`); + } + entries.push({ code: value, section, description: "" }); + } + } else { + throw new Error("No error catalog found. Expected docs/error-codes.yaml or src/errors/errorCatalog.ts"); + } + + if (entries.length === 0) { + throw new Error("No error codes found in catalog"); + } + + // Validate no duplicates + const duplicates = entries + .map((entry) => entry.code) + .filter((code, index, codes) => codes.indexOf(code) !== index); + if (duplicates.length > 0) { + throw new Error(`Duplicate error codes: ${[...new Set(duplicates)].join(", ")}`); + } + + // Validate code format (SCREAMING_SNAKE_CASE) + const invalidCodes = entries.filter( + (entry) => !/^[A-Z][A-Z0-9_]*$/.test(entry.code) + ); + if (invalidCodes.length > 0) { + throw new Error( + `Invalid error code format (must be SCREAMING_SNAKE_CASE): ${invalidCodes.map((e) => e.code).join(", ")}` + ); + } + + return entries; +} + +function buildMarkdownBlock(entries) { + const rows = entries + .map(({ code, section }) => `| \`${code}\` | ${section} |`) + .join("\n"); + + return [ + startMarker, + "## Canonical error code catalog", + "", + "This section is generated from `docs/error-codes.yaml`. Run `npm run error-codes:generate` after changing the catalog.", + "", + "| Code | Catalog section |", + "|---|---|", + rows, + endMarker, + ].join("\n"); +} + +function buildTypeScriptEnum(entries) { + const enumEntries = entries + .map(({ code, section, description }) => { + const comment = description ? ` /** ${description} */\n` : ""; + return `${comment} ${code}: "${code}"`; + }) + .join(",\n\n"); + + return [ + "/**", + " * Canonical Error Code Enum", + " *", + " * AUTO-GENERATED from docs/error-codes.yaml", + " * DO NOT EDIT THIS FILE MANUALLY", + " *", + " * To add or modify error codes:", + " * 1. Edit docs/error-codes.yaml", + " * 2. Run: npm run error-codes:generate", + " *", + " * @module errors/codes", + " */", + "", + "export const ErrorCode = {", + enumEntries, + "", + "} as const;", + "", + "export type ErrorCode = (typeof ErrorCode)[keyof typeof ErrorCode];", + "", + "/**", + " * Type guard to check if a value is a valid ErrorCode", + " * @param value - Value to check", + " * @returns True if value is a valid error code", + " */", + "export function isErrorCode(value: unknown): value is ErrorCode {", + " if (typeof value !== \"string\") return false;", + " return Object.values(ErrorCode).includes(value as ErrorCode);", + "}", + "", + ].join("\n"); +} + +function updateGeneratedBlock(markdown, block) { + const blockPattern = new RegExp(`${escapeRegExp(startMarker)}[\\s\\S]*?${escapeRegExp(endMarker)}`); + if (blockPattern.test(markdown)) { + return markdown.replace(blockPattern, block); + } + + const introPattern = /^(# .+\r?\n\r?\n(?:.+\r?\n)+?\r?\n)/; + const match = markdown.match(introPattern); + if (!match) { + return `${block}\n\n${markdown}`; + } + + return `${match[1]}${block}\n\n${markdown.slice(match[1].length)}`; +} + +function escapeRegExp(value) { + return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); +} + +function updateOpenApi(openApi, entries) { + const schemas = openApi.components?.schemas; + if (!schemas) { + throw new Error("OpenAPI document is missing components.schemas"); + } + + schemas.ErrorCode = { + type: "string", + enum: entries.map((entry) => entry.code), + description: "Canonical Callora backend error code.", + }; + + const errorResponse = schemas.ErrorResponse; + if (!errorResponse?.properties?.code) { + throw new Error("OpenAPI document is missing components.schemas.ErrorResponse.properties.code"); + } + + errorResponse.properties.code = { + $ref: "#/components/schemas/ErrorCode", + }; + + return `${JSON.stringify(openApi, null, 2)}\n`; +} + +function writeOrCheck(filePath, current, next) { + const normalize = (s) => (s ?? "").replace(/\r\n/g, "\n"); + if (normalize(current) === normalize(next)) return false; + + if (checkOnly) { + console.error(`${path.relative(root, filePath)} is not generated from the current error catalog.`); + return true; + } + + fs.writeFileSync(filePath, next); + return true; +} + +const entries = readCatalog(); + +// Generate TypeScript enum +const tsEnum = buildTypeScriptEnum(entries); +const tsEnumCurrent = fs.existsSync(generatedCodesPath) + ? fs.readFileSync(generatedCodesPath, "utf8") + : ""; +const tsEnumChanged = writeOrCheck(generatedCodesPath, tsEnumCurrent, tsEnum); + +// Generate markdown catalog +const catalogCurrent = fs.readFileSync(catalogPath, "utf8"); +const catalogNext = updateGeneratedBlock(catalogCurrent, buildMarkdownBlock(entries)); +const catalogChanged = writeOrCheck(catalogPath, catalogCurrent, catalogNext); + +// Generate OpenAPI schema +const openApiCurrent = fs.readFileSync(openApiPath, "utf8"); +const openApiNext = updateOpenApi(JSON.parse(openApiCurrent), entries); +const openApiChanged = writeOrCheck(openApiPath, openApiCurrent, openApiNext); + +if (checkOnly && (tsEnumChanged || catalogChanged || openApiChanged)) { + process.exit(1); +} + +if (!checkOnly) { + const changedFiles = [ + tsEnumChanged && "src/errors/codes.ts", + catalogChanged && "docs/error-code-catalog.md", + openApiChanged && "docs/openapi.json", + ].filter(Boolean); + + if (changedFiles.length > 0) { + console.log(`Updated: ${changedFiles.join(", ")}`); + } else { + console.log("Already up to date: src/errors/codes.ts, docs/error-code-catalog.md, docs/openapi.json"); + } +} diff --git a/scripts/generate-error-codes.test.mjs b/scripts/generate-error-codes.test.mjs new file mode 100644 index 00000000..03650627 --- /dev/null +++ b/scripts/generate-error-codes.test.mjs @@ -0,0 +1,493 @@ +/** + * Tests for error code generation script + * + * Run with: node scripts/generate-error-codes.test.mjs + */ + +import assert from "node:assert"; +import fs from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { execSync } from "node:child_process"; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); +const root = path.resolve(__dirname, ".."); +const testDir = path.join(root, "test-temp-error-codes"); + +// Test utilities +function createTestEnv() { + if (fs.existsSync(testDir)) { + fs.rmSync(testDir, { recursive: true, force: true }); + } + fs.mkdirSync(testDir, { recursive: true }); + fs.mkdirSync(path.join(testDir, "docs"), { recursive: true }); + fs.mkdirSync(path.join(testDir, "src", "errors"), { recursive: true }); +} + +function cleanupTestEnv() { + if (fs.existsSync(testDir)) { + fs.rmSync(testDir, { recursive: true, force: true }); + } +} + +function writeTestYaml(content) { + fs.writeFileSync(path.join(testDir, "docs", "error-codes.yaml"), content); +} + +function writeTestDocs() { + fs.writeFileSync( + path.join(testDir, "docs", "error-code-catalog.md"), + "# Error Codes\n\nTest doc\n" + ); +} + +function writeTestOpenApi() { + const openApi = { + openapi: "3.0.0", + info: { title: "Test API", version: "1.0.0" }, + components: { + schemas: { + ErrorResponse: { + type: "object", + properties: { + code: { type: "string" }, + message: { type: "string" }, + }, + }, + }, + }, + }; + fs.writeFileSync( + path.join(testDir, "docs", "openapi.json"), + JSON.stringify(openApi, null, 2) + ); +} + +function runCodegen(cwd = testDir) { + const script = path.join(root, "scripts", "generate-error-codes.mjs"); + try { + execSync(`node "${script}"`, { + cwd, + encoding: "utf8", + stdio: "pipe", + }); + return { success: true, error: null }; + } catch (error) { + return { success: false, error: error.message }; + } +} + +// Tests +const tests = []; + +function test(name, fn) { + tests.push({ name, fn }); +} + +// Test 1: Parse valid YAML catalog +test("parses valid YAML catalog with all fields", () => { + createTestEnv(); + writeTestDocs(); + writeTestOpenApi(); + + const yaml = ` +error_codes: + - code: TEST_ERROR_ONE + section: Test Section + description: Test description one + + - code: TEST_ERROR_TWO + section: Test Section + description: Test description two +`; + + writeTestYaml(yaml); + const result = runCodegen(); + + assert.strictEqual(result.success, true, "Should succeed"); + + const generated = fs.readFileSync( + path.join(testDir, "src", "errors", "codes.ts"), + "utf8" + ); + + assert.ok(generated.includes("TEST_ERROR_ONE"), "Should include TEST_ERROR_ONE"); + assert.ok(generated.includes("TEST_ERROR_TWO"), "Should include TEST_ERROR_TWO"); + assert.ok( + generated.includes("Test description one"), + "Should include description" + ); + + cleanupTestEnv(); +}); + +// Test 2: Reject duplicate error codes +test("rejects duplicate error codes", () => { + createTestEnv(); + writeTestDocs(); + writeTestOpenApi(); + + const yaml = ` +error_codes: + - code: DUPLICATE_CODE + section: Test Section + description: First occurrence + + - code: DUPLICATE_CODE + section: Test Section + description: Second occurrence +`; + + writeTestYaml(yaml); + const result = runCodegen(); + + assert.strictEqual(result.success, false, "Should fail on duplicates"); + assert.ok( + result.error.includes("Duplicate"), + "Error should mention duplicates" + ); + + cleanupTestEnv(); +}); + +// Test 3: Validate code format (SCREAMING_SNAKE_CASE) +test("validates error code format", () => { + createTestEnv(); + writeTestDocs(); + writeTestOpenApi(); + + const yaml = ` +error_codes: + - code: invalidCode + section: Test Section + description: Invalid format +`; + + writeTestYaml(yaml); + const result = runCodegen(); + + assert.strictEqual(result.success, false, "Should fail on invalid format"); + assert.ok( + result.error.includes("SCREAMING_SNAKE_CASE") || result.error.includes("Invalid"), + "Error should mention format requirement" + ); + + cleanupTestEnv(); +}); + +// Test 4: Generate TypeScript with correct structure +test("generates TypeScript enum with correct structure", () => { + createTestEnv(); + writeTestDocs(); + writeTestOpenApi(); + + const yaml = ` +error_codes: + - code: SAMPLE_ERROR + section: Sample + description: A sample error for testing +`; + + writeTestYaml(yaml); + const result = runCodegen(); + + assert.strictEqual(result.success, true, "Should succeed"); + + const generated = fs.readFileSync( + path.join(testDir, "src", "errors", "codes.ts"), + "utf8" + ); + + // Check structure + assert.ok(generated.includes("export const ErrorCode ="), "Should export ErrorCode"); + assert.ok(generated.includes('SAMPLE_ERROR: "SAMPLE_ERROR"'), "Should include code entry"); + assert.ok(generated.includes("} as const;"), "Should use as const"); + assert.ok( + generated.includes("export type ErrorCode"), + "Should export type" + ); + assert.ok( + generated.includes("export function isErrorCode"), + "Should export type guard" + ); + assert.ok( + generated.includes("AUTO-GENERATED"), + "Should include generation notice" + ); + assert.ok( + generated.includes("DO NOT EDIT"), + "Should include edit warning" + ); + + cleanupTestEnv(); +}); + +// Test 5: Update documentation +test("updates markdown documentation", () => { + createTestEnv(); + writeTestDocs(); + writeTestOpenApi(); + + const yaml = ` +error_codes: + - code: DOC_TEST_ERROR + section: Documentation Test + description: Test error for docs +`; + + writeTestYaml(yaml); + const result = runCodegen(); + + assert.strictEqual(result.success, true, "Should succeed"); + + const docs = fs.readFileSync( + path.join(testDir, "docs", "error-code-catalog.md"), + "utf8" + ); + + assert.ok( + docs.includes(""), + "Should have start marker" + ); + assert.ok( + docs.includes(""), + "Should have end marker" + ); + assert.ok( + docs.includes("DOC_TEST_ERROR"), + "Should include error code" + ); + assert.ok( + docs.includes("Documentation Test"), + "Should include section" + ); + + cleanupTestEnv(); +}); + +// Test 5b: leaves the hand-written envelope guide untouched +test("does not rewrite the hand-written docs/error-codes.md", () => { + createTestEnv(); + writeTestDocs(); + writeTestOpenApi(); + + const handWritten = "# Error response envelope\n\nHand-written guide.\n"; + fs.writeFileSync(path.join(testDir, "docs", "error-codes.md"), handWritten); + + const yaml = ` +error_codes: + - code: ENVELOPE_DOC_ERROR + section: Docs + description: Should not leak into the envelope guide +`; + + writeTestYaml(yaml); + const result = runCodegen(); + + assert.strictEqual(result.success, true, "Should succeed"); + + const after = fs.readFileSync( + path.join(testDir, "docs", "error-codes.md"), + "utf8" + ); + assert.strictEqual(after, handWritten, "Hand-written guide must be untouched"); + assert.ok( + !after.includes(""), + "Hand-written guide must not receive generated markers" + ); + + cleanupTestEnv(); +}); + +// Test 6: Update OpenAPI schema +test("updates OpenAPI schema with error codes", () => { + createTestEnv(); + writeTestDocs(); + writeTestOpenApi(); + + const yaml = ` +error_codes: + - code: API_TEST_ERROR + section: API Test + description: Test error for OpenAPI +`; + + writeTestYaml(yaml); + const result = runCodegen(); + + assert.strictEqual(result.success, true, "Should succeed"); + + const openApi = JSON.parse( + fs.readFileSync(path.join(testDir, "docs", "openapi.json"), "utf8") + ); + + assert.ok( + openApi.components.schemas.ErrorCode, + "Should create ErrorCode schema" + ); + assert.strictEqual( + openApi.components.schemas.ErrorCode.type, + "string", + "ErrorCode should be string type" + ); + assert.ok( + Array.isArray(openApi.components.schemas.ErrorCode.enum), + "ErrorCode should have enum" + ); + assert.ok( + openApi.components.schemas.ErrorCode.enum.includes("API_TEST_ERROR"), + "Enum should include test error" + ); + + cleanupTestEnv(); +}); + +// Test 7: Check mode detects outdated files +test("check mode detects outdated generated files", () => { + createTestEnv(); + writeTestDocs(); + writeTestOpenApi(); + + const yaml = ` +error_codes: + - code: CHECK_MODE_TEST + section: Check Mode + description: Test for check mode +`; + + writeTestYaml(yaml); + + // Generate once + runCodegen(); + + // Modify the generated file + const generatedPath = path.join(testDir, "src", "errors", "codes.ts"); + fs.appendFileSync(generatedPath, "\n// Manual modification\n"); + + // Run in check mode + const script = path.join(root, "scripts", "generate-error-codes.mjs"); + try { + execSync(`node "${script}" --check`, { + cwd: testDir, + encoding: "utf8", + stdio: "pipe", + }); + assert.fail("Check mode should have failed"); + } catch (error) { + assert.ok(error.status !== 0, "Should exit with non-zero code"); + } + + cleanupTestEnv(); +}); + +// Test 8: Handle missing YAML catalog +test("handles missing YAML catalog gracefully", () => { + createTestEnv(); + writeTestDocs(); + writeTestOpenApi(); + + // Don't create the YAML file + const result = runCodegen(); + + assert.strictEqual(result.success, false, "Should fail"); + assert.ok( + result.error.includes("catalog") || result.error.includes("found"), + "Error should mention missing catalog" + ); + + cleanupTestEnv(); +}); + +// Test 9: Validate required fields +test("validates required fields in YAML entries", () => { + createTestEnv(); + writeTestDocs(); + writeTestOpenApi(); + + const yaml = ` +error_codes: + - code: VALID_CODE + section: Valid + description: Has all fields + + - section: Missing Code + description: This entry lacks a code field +`; + + writeTestYaml(yaml); + const result = runCodegen(); + + // Should still parse the valid entry + assert.strictEqual(result.success, true, "Should succeed with valid entries"); + + const generated = fs.readFileSync( + path.join(testDir, "src", "errors", "codes.ts"), + "utf8" + ); + + assert.ok(generated.includes("VALID_CODE"), "Should include valid code"); + + cleanupTestEnv(); +}); + +// Test 10: Idempotency - running twice produces same output +test("running generation twice produces identical output", () => { + createTestEnv(); + writeTestDocs(); + writeTestOpenApi(); + + const yaml = ` +error_codes: + - code: IDEMPOTENT_ERROR + section: Idempotency + description: Test idempotency +`; + + writeTestYaml(yaml); + + // First run + runCodegen(); + const firstRun = fs.readFileSync( + path.join(testDir, "src", "errors", "codes.ts"), + "utf8" + ); + + // Second run + runCodegen(); + const secondRun = fs.readFileSync( + path.join(testDir, "src", "errors", "codes.ts"), + "utf8" + ); + + assert.strictEqual(firstRun, secondRun, "Output should be identical"); + + cleanupTestEnv(); +}); + +// Run all tests +console.log("Running error code generation tests...\n"); + +let passed = 0; +let failed = 0; + +for (const { name, fn } of tests) { + try { + fn(); + console.log(`✓ ${name}`); + passed++; + } catch (error) { + console.error(`✗ ${name}`); + console.error(` ${error.message}`); + if (error.stack) { + console.error(error.stack.split("\n").slice(1, 4).join("\n")); + } + failed++; + } +} + +console.log(`\n${passed} passed, ${failed} failed`); + +if (failed > 0) { + process.exit(1); +}