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); -}