From f7b06609d68f468de02b0be90c6021d687c5e41d Mon Sep 17 00:00:00 2001 From: Petrelid Date: Wed, 21 Oct 2026 21:52:39 +0100 Subject: [PATCH 1/4] fix(docker): build with bun.lock instead of missing package-lock.json (#838) npm ci had no package-lock.json to install from, and bun.lock was dockerignored. Install with bun --frozen-lockfile in the build and prod-deps stages, and keep the node:20-alpine runtime. The build script also never copied src/proto into dist, so the built server crashed on startup (ENOENT dist/proto/heliobond.proto). Verified: docker build succeeds, the container starts, /health -> 200. --- .dockerignore | 1 - Dockerfile | 30 ++++++++++++++++++++++++------ package.json | 2 +- 3 files changed, 25 insertions(+), 8 deletions(-) diff --git a/.dockerignore b/.dockerignore index d973cff6..dff04887 100644 --- a/.dockerignore +++ b/.dockerignore @@ -18,6 +18,5 @@ load-tests eslint.config.mjs jest.config.ts tsconfig.test.json -bun.lock tmp-* docs diff --git a/Dockerfile b/Dockerfile index ea5222db..db0ef8b6 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,15 +1,33 @@ # ── Build stage ────────────────────────────────────────────────────────────── -FROM node:20-alpine AS builder +# bun.lock is the canonical lockfile (CI installs with `bun install +# --frozen-lockfile`); there is no package-lock.json, so `npm ci` cannot work. +FROM oven/bun:1-alpine AS builder WORKDIR /app -COPY package*.json ./ -RUN npm ci --ignore-scripts +# Skip the husky prepare hook — it needs a .git checkout that images lack. +ENV HUSKY=0 + +COPY package.json bun.lock ./ +RUN bun install --frozen-lockfile COPY tsconfig.json ./ COPY src ./src -RUN npm run build +RUN bun run build + +# ── Production dependency stage ───────────────────────────────────────────── +# Prod-only install for the Node runtime image below. +FROM oven/bun:1-alpine AS prod-deps + +WORKDIR /app + +ENV HUSKY=0 + +# --ignore-scripts: the `prepare` hook runs `husky`, which is a devDependency +# and therefore absent from a --production install. +COPY package.json bun.lock ./ +RUN bun install --frozen-lockfile --production --ignore-scripts && bun pm cache rm # ── Production stage ────────────────────────────────────────────────────────── FROM node:20-alpine AS production @@ -25,8 +43,8 @@ ENV NODE_OPTIONS="--max-old-space-size=384" WORKDIR /app -COPY package*.json ./ -RUN npm ci --omit=dev --ignore-scripts && npm cache clean --force +COPY --from=prod-deps /app/node_modules ./node_modules +COPY package.json ./ COPY --from=builder /app/dist ./dist diff --git a/package.json b/package.json index 8de06d44..bca484c4 100644 --- a/package.json +++ b/package.json @@ -10,7 +10,7 @@ "scripts": { "dev": "node --watch -r ts-node/register src/index.ts", "dev:no-watch": "ts-node src/index.ts", - "build": "tsc && mkdir -p dist/data dist/schemas && cp -R src/data/. dist/data/ && cp -R src/schemas/. dist/schemas/", + "build": "tsc && mkdir -p dist/data dist/schemas dist/proto && cp -R src/data/. dist/data/ && cp -R src/schemas/. dist/schemas/ && cp -R src/proto/. dist/proto/", "build:dist-smoke": "npm run build && node -e \"require('./dist/lib/contractErrors'); console.log('dist contractErrors smoke test ok');\"", "start": "node dist/index.js", "test": "jest", From c58d4c2ea62d2c35d0df4b88580bac00aa719dc4 Mon Sep 17 00:00:00 2001 From: Petrelid Date: Wed, 21 Oct 2026 21:52:41 +0100 Subject: [PATCH 2/4] docs: correct ADMIN_API_KEY and portfolio auth documentation (#837) ADMIN_API_KEY is not optional: requireAdminBearer fails closed with 500 server_misconfigured when it is unset. /api/portfolio and /v1/portfolio both mount apiKeyAuth, so their auth is the same. --- API.md | 13 +++++++++---- README.md | 2 +- 2 files changed, 10 insertions(+), 5 deletions(-) diff --git a/API.md b/API.md index e4fdb372..d4e3cc1e 100644 --- a/API.md +++ b/API.md @@ -74,7 +74,7 @@ Legacy unversioned `/api/*` routes are deprecated and maintained for backward co Replace the `/api` prefix with `/v1` (e.g. `/api/projects` → `/v1/projects`). Notes: -- Auth is not identical between the two mounts (for example `/api/portfolio` requires an API key while `/v1/portfolio` does not; admin routes on `/v1` additionally require request signing). Follow the `/v1` requirements. +- Auth is the same on both mounts: `/api/portfolio` and `/v1/portfolio` both require an API key (`401` without one); admin routes on `/v1` additionally require request signing. Follow the `/v1` requirements. - Legacy traffic is counted in the Prometheus metric `legacy_api_requests_total{path}`; check it to find remaining callers. - On and after **2027-01-01T00:00:00Z** every `/api/*` request returns `410 Gone` with a `Link: ; rel="successor-version"` header and a JSON body pointing at the `/v1` equivalent. The mounts are then deleted; see [docs/LEGACY_API_REMOVAL.md](docs/LEGACY_API_REMOVAL.md). @@ -526,6 +526,9 @@ deterministic per `(address, clock hour)` — the same address returns the same value within a clock hour, and `current_value` stays between `1.5x` and `2.0x` of `current_shares`. +**Auth:** An API key is required on both the `/v1` and legacy `/api` mounts +(`401 unauthorized` without one). + **Response `200`** ```json @@ -1331,9 +1334,11 @@ Computes and submits `update_impact_score` transactions to the Soroban smart con **Headers** -| Header | Required | Value | -| :-------------- | :----------------------------- | :----------------------- | -| `Authorization` | Yes (when `ADMIN_API_KEY` set) | `Bearer ` | +| Header | Required | Value | +| :-------------- | :------- | :----------------------- | +| `Authorization` | Yes | `Bearer ` | + +If `ADMIN_API_KEY` is unset the endpoint fails closed with `500 server_misconfigured`. **Request Body (optional)** diff --git a/README.md b/README.md index dd2c37da..85659a47 100644 --- a/README.md +++ b/README.md @@ -276,7 +276,7 @@ Create a `.env` file (see `.env.example`): | `RPC_URL` | No | `https://soroban-testnet.stellar.org` | Stellar RPC endpoint | | `PORT` | No | `3001` | HTTP port the server listens on | | `FRONTEND_URL` | No | `http://localhost:3000` | Origin allowed by CORS | -| `ADMIN_API_KEY` | No | — | Bearer token for `/api/admin/*`. If unset, auth is skipped (dev only) | +| `ADMIN_API_KEY` | No | — | Bearer token for `/v1/admin/*` and other admin-gated routes. If unset, the server starts but admin endpoints fail closed with `500 server_misconfigured` | | `RATE_LIMIT_WINDOW_MS` | No | `60000` | Public rate-limit window (ms) | | `RATE_LIMIT_MAX` | No | `100` | Public max requests per IP per window | | `RATE_LIMIT_ADMIN_WINDOW_MS` | No | `RATE_LIMIT_WINDOW_MS` | Admin rate-limit window (ms) | From f2149cb8a8ba750fe884dfb0bc907abe0b0d39b2 Mon Sep 17 00:00:00 2001 From: Petrelid Date: Wed, 21 Oct 2026 21:52:44 +0100 Subject: [PATCH 3/4] fix(openapi): document GET /portfolio/{address} with its path param (#836) The spec listed /portfolio with no address parameter, so the generated client's example hit a route that 404s. Document the address path param, cursor/limit query params, API-key security and 400/401 responses, regenerate openapi.json and the api-client types, and fix the README example. --- openapi.json | 56 +++++++++++++++++++++++++- packages/api-client/README.md | 4 +- packages/api-client/src/types.ts | 32 +++++++++++++-- scripts/generate-api-client.ts | 4 +- src/__tests__/openapi-contract.test.ts | 2 +- src/lib/swagger.ts | 26 +++++++++++- 6 files changed, 116 insertions(+), 8 deletions(-) diff --git a/openapi.json b/openapi.json index 020f0b7b..7f075303 100644 --- a/openapi.json +++ b/openapi.json @@ -904,16 +904,70 @@ } } }, - "/portfolio": { + "/portfolio/{address}": { "get": { "summary": "Get portfolio summary", "tags": [ "Portfolio" ], + "security": [ + { + "ApiKeyAuth": [] + } + ], + "parameters": [ + { + "name": "address", + "in": "path", + "required": true, + "schema": { + "type": "string" + }, + "description": "Stellar account (G...) or contract (C...) strkey" + }, + { + "name": "cursor", + "in": "query", + "schema": { + "type": "string" + }, + "description": "Pagination cursor from a previous response's next_cursor" + }, + { + "name": "limit", + "in": "query", + "schema": { + "type": "integer", + "minimum": 1, + "maximum": 200 + }, + "description": "Maximum number of events to return (1-200)" + } + ], "responses": { "200": { "description": "Portfolio metrics" }, + "400": { + "description": "Invalid address or pagination parameters", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "401": { + "description": "Unauthorized", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, "429": { "description": "Too many requests", "content": { diff --git a/packages/api-client/README.md b/packages/api-client/README.md index 4e0b2660..a81718c8 100644 --- a/packages/api-client/README.md +++ b/packages/api-client/README.md @@ -23,7 +23,9 @@ const api = createApiClient({ // Fully typed API calls const projects = await api.GET("/projects"); const project = await api.GET("/projects/{id}", { params: { path: { id: 1 } } }); -const portfolio = await api.GET("/portfolio"); +const portfolio = await api.GET("/portfolio/{address}", { + params: { path: { address: "GCFIRY65OQE7DFP5KLNS2PF2LVZMUZYJX4OZIEQ36N2IQANUB5XVYOJR" } }, +}); ``` ## Regenerating diff --git a/packages/api-client/src/types.ts b/packages/api-client/src/types.ts index 6c605fb3..32ff9399 100644 --- a/packages/api-client/src/types.ts +++ b/packages/api-client/src/types.ts @@ -538,7 +538,7 @@ export interface paths { patch?: never; trace?: never; }; - "/portfolio": { + "/portfolio/{address}": { parameters: { query?: never; header?: never; @@ -548,9 +548,17 @@ export interface paths { /** Get portfolio summary */ get: { parameters: { - query?: never; + query?: { + /** @description Pagination cursor from a previous response's next_cursor */ + cursor?: string; + /** @description Maximum number of events to return (1-200) */ + limit?: number; + }; header?: never; - path?: never; + path: { + /** @description Stellar account (G...) or contract (C...) strkey */ + address: string; + }; cookie?: never; }; requestBody?: never; @@ -562,6 +570,24 @@ export interface paths { }; content?: never; }; + /** @description Invalid address or pagination parameters */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description Unauthorized */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; /** @description Too many requests */ 429: { headers: { diff --git a/scripts/generate-api-client.ts b/scripts/generate-api-client.ts index 40e730e8..0267247d 100644 --- a/scripts/generate-api-client.ts +++ b/scripts/generate-api-client.ts @@ -196,7 +196,9 @@ const api = createApiClient({ // Fully typed API calls const projects = await api.GET("/projects"); const project = await api.GET("/projects/{id}", { params: { path: { id: 1 } } }); -const portfolio = await api.GET("/portfolio"); +const portfolio = await api.GET("/portfolio/{address}", { + params: { path: { address: "GCFIRY65OQE7DFP5KLNS2PF2LVZMUZYJX4OZIEQ36N2IQANUB5XVYOJR" } }, +}); \`\`\` ## Regenerating diff --git a/src/__tests__/openapi-contract.test.ts b/src/__tests__/openapi-contract.test.ts index 05abf626..5b8a17b8 100644 --- a/src/__tests__/openapi-contract.test.ts +++ b/src/__tests__/openapi-contract.test.ts @@ -78,7 +78,7 @@ describe("OpenAPI Spec Contract Tests", () => { "/projects/{id}", "/projects/{id}/history", "/projects/{id}/history/trend", - "/portfolio", + "/portfolio/{address}", "/admin/score-update", "/admin/batch/score-update", "/admin/batch/{batchId}/status", diff --git a/src/lib/swagger.ts b/src/lib/swagger.ts index 77539da3..a94432dc 100644 --- a/src/lib/swagger.ts +++ b/src/lib/swagger.ts @@ -411,12 +411,36 @@ export const openApiSpec = { }, }, }, - "/portfolio": { + "/portfolio/{address}": { get: { summary: "Get portfolio summary", tags: ["Portfolio"], + security: [{ ApiKeyAuth: [] }], + parameters: [ + { + name: "address", + in: "path", + required: true, + schema: { type: "string" }, + description: "Stellar account (G...) or contract (C...) strkey", + }, + { + name: "cursor", + in: "query", + schema: { type: "string" }, + description: "Pagination cursor from a previous response's next_cursor", + }, + { + name: "limit", + in: "query", + schema: { type: "integer", minimum: 1, maximum: 200 }, + description: "Maximum number of events to return (1-200)", + }, + ], responses: { 200: { description: "Portfolio metrics" }, + 400: errorResponse("Invalid address or pagination parameters"), + 401: errorResponse("Unauthorized"), 429: errorResponse("Too many requests"), 500: errorResponse("Internal server error"), }, From b126af70c701bb731ad0466fcb680c0b3994d925 Mon Sep 17 00:00:00 2001 From: Petrelid Date: Wed, 21 Oct 2026 21:52:54 +0100 Subject: [PATCH 4/4] fix(errors): use { error: { code, message } } for every error response (#835) Replace flat { error: "...", message } bodies across routes and middleware with the shared errorBody() helper, normalise the fallback handler code to internal_error, and update tests to assert the nested shape. --- src/__tests__/benchmarking.test.ts | 12 ++- src/__tests__/chains.test.ts | 2 +- src/__tests__/dashboard.test.ts | 8 +- src/__tests__/error-handler.test.ts | 6 +- src/__tests__/feature-flags.test.ts | 4 +- src/__tests__/investorActivity.test.ts | 4 +- src/__tests__/notifications.test.ts | 110 ++++++++++++++++++++---- src/__tests__/requestTimeout.test.ts | 3 +- src/__tests__/requireApiKeyRole.test.ts | 16 ++-- src/__tests__/satellite-sources.test.ts | 14 +-- src/__tests__/txQueueRetryCron.test.ts | 1 + src/__tests__/versioning.test.ts | 3 +- src/index.ts | 84 +++++++++++------- src/lib/maintenance-tracking.ts | 73 ++++++++++++---- src/lib/stellar.ts | 6 +- src/lib/stellar/project-registry.ts | 10 +-- src/middleware/errors.ts | 2 +- src/middleware/requestTimeout.ts | 3 +- src/middleware/requireAdminBearer.ts | 5 +- src/middleware/requireApiKeyRole.ts | 93 +++++++++----------- src/middleware/versioning.ts | 14 +-- src/routes/aggregate.ts | 31 +++++-- src/routes/apiKeys.ts | 31 +++++-- src/routes/batch.ts | 4 +- src/routes/benchmarking.ts | 72 +++++++++------- src/routes/chains.ts | 23 +++-- src/routes/email.ts | 17 ++-- src/routes/maintenance.ts | 6 +- src/routes/metadata.ts | 4 +- src/routes/notifications.ts | 16 ++-- src/routes/oracle-status.ts | 5 +- src/routes/panels.ts | 4 +- src/routes/projects.ts | 1 + src/routes/roles.ts | 4 +- src/routes/satellite-sources.ts | 29 +++++-- src/routes/scoring-formulas.ts | 20 +++-- src/routes/webhooks.ts | 22 +++-- 37 files changed, 491 insertions(+), 271 deletions(-) diff --git a/src/__tests__/benchmarking.test.ts b/src/__tests__/benchmarking.test.ts index 56d5fe8c..1bafb7e4 100644 --- a/src/__tests__/benchmarking.test.ts +++ b/src/__tests__/benchmarking.test.ts @@ -43,10 +43,8 @@ describe("benchmarking routes", () => { }); it("GET /api/benchmarking/benchmarks/:id — 404 for unknown benchmark", async () => { - const res = await request(app) - .get("/api/benchmarking/benchmarks/nonexistent") - .expect(404); - expect(res.body.error).toBe("not_found"); + const res = await request(app).get("/api/benchmarking/benchmarks/nonexistent").expect(404); + expect(res.body.error.code).toBe("not_found"); }); it("POST /api/benchmarking/benchmarks — creates a custom benchmark with admin auth", async () => { @@ -73,7 +71,7 @@ describe("benchmarking routes", () => { thresholds: { poor: 10, fair: 30, good: 50, excellent: 80 }, }) .expect(401); - expect(res.body.error).toBe("unauthorized"); + expect(res.body.error.code).toBe("unauthorized"); }); it("POST /api/benchmarking/benchmarks — 400 for missing fields", async () => { @@ -95,7 +93,7 @@ describe("benchmarking routes", () => { thresholds: { poor: 80, fair: 70, good: 90, excellent: 95 }, }) .expect(400); - expect(res.body.error).toBe("bad_request"); + expect(res.body.error.code).toBe("bad_request"); }); it("POST /api/benchmarking/benchmarks — 400 when a built-in benchmark id is reused", async () => { @@ -109,7 +107,7 @@ describe("benchmarking routes", () => { thresholds: { poor: 20, fair: 40, good: 60, excellent: 80 }, }) .expect(400); - expect(res.body.error).toBe("bad_request"); + expect(res.body.error.code).toBe("bad_request"); }); it("GET /api/benchmarking/:id — evaluates all benchmarks for a project", async () => { diff --git a/src/__tests__/chains.test.ts b/src/__tests__/chains.test.ts index 2197d0bd..e4eb25b0 100644 --- a/src/__tests__/chains.test.ts +++ b/src/__tests__/chains.test.ts @@ -83,7 +83,7 @@ describe("chains routes", () => { .post("/v1/chains/broadcast/1") .send({ chains: ["ethereum"] }) .expect(501); - expect(res.body.error).toContain("only Stellar"); + expect(res.body.error.message).toContain("only Stellar"); }); it("POST /v1/chains/broadcast/:projectId broadcasts to Stellar", async () => { diff --git a/src/__tests__/dashboard.test.ts b/src/__tests__/dashboard.test.ts index 2ad28463..f9fa4678 100644 --- a/src/__tests__/dashboard.test.ts +++ b/src/__tests__/dashboard.test.ts @@ -23,6 +23,12 @@ jest.mock("../lib/analytics", () => ({ summaryToCsv: jest.fn(), })); +// Factory mock avoids loading the real registry module, which throws at import +// time when PROJECT_REGISTRY_CONTRACT_ID is unset (e.g. in CI). +jest.mock("../lib/registry", () => ({ + projectExists: jest.fn().mockResolvedValue(true), +})); + import { collectScores, portfolioSummary, @@ -123,6 +129,6 @@ describe("dashboard routes (#679)", () => { mockedCollect.mockRejectedValueOnce(new Error("db down")); const res = await request(app).get("/v1/dashboard/summary"); expect(res.status).toBe(500); - expect(res.body.error.code).toBe("INTERNAL_ERROR"); + expect(res.body.error.code).toBe("internal_error"); }); }); diff --git a/src/__tests__/error-handler.test.ts b/src/__tests__/error-handler.test.ts index fe9a347f..6f74e207 100644 --- a/src/__tests__/error-handler.test.ts +++ b/src/__tests__/error-handler.test.ts @@ -17,7 +17,7 @@ function createAppWithError(throwFn: () => void) { } describe("error handling middleware", () => { - it("unhandled error returns 500 with JSON body and INTERNAL_ERROR code", async () => { + it("unhandled error returns 500 with JSON body and internal_error code", async () => { const app = createAppWithError(() => { throw new Error("something broke"); }); @@ -25,7 +25,7 @@ describe("error handling middleware", () => { expect(res.headers["content-type"]).toMatch(/json/); expect(res.body).toEqual({ error: { - code: "INTERNAL_ERROR", + code: "internal_error", message: "An unexpected error occurred", }, }); @@ -69,7 +69,7 @@ describe("error handling middleware", () => { app.use(errorHandler); const res = await request(app).get("/async-error").expect(500); - expect(res.body.error.code).toBe("INTERNAL_ERROR"); + expect(res.body.error.code).toBe("internal_error"); expect(res.body.error.message).toBe("An unexpected error occurred"); }); diff --git a/src/__tests__/feature-flags.test.ts b/src/__tests__/feature-flags.test.ts index 71cdf82a..438d5dbb 100644 --- a/src/__tests__/feature-flags.test.ts +++ b/src/__tests__/feature-flags.test.ts @@ -340,7 +340,7 @@ describe("feature flags", () => { expect(res.status).toBe(500); expect(res.body).toEqual({ error: { - code: "INTERNAL_ERROR", + code: "internal_error", message: "An unexpected error occurred", }, }); @@ -371,7 +371,7 @@ describe("feature flags", () => { expect(res.status).toBe(500); expect(res.body).toEqual({ error: { - code: "INTERNAL_ERROR", + code: "internal_error", message: "An unexpected error occurred", }, }); diff --git a/src/__tests__/investorActivity.test.ts b/src/__tests__/investorActivity.test.ts index 16258d25..ab8cf4d0 100644 --- a/src/__tests__/investorActivity.test.ts +++ b/src/__tests__/investorActivity.test.ts @@ -15,7 +15,9 @@ const app = express(); app.use("/v1/investors", investorActivityRouter); app.use(errorHandler); -const ADDRESS = `G${"A".repeat(55)}`; +// Checksum-valid G-address: the routes run StrKey.decodeEd25519PublicKey, which +// rejects fabricated addresses like "G" + "A".repeat(55). +const ADDRESS = "GAAQCAIBAEAQCAIBAEAQCAIBAEAQCAIBAEAQCAIBAEAQCAIBAEAQDZ7H"; describe("GET /v1/investors/:address/activity", () => { beforeEach(() => getActivity.mockResolvedValue({ events: [], next_cursor: null })); diff --git a/src/__tests__/notifications.test.ts b/src/__tests__/notifications.test.ts index 77493ae7..bb265de3 100644 --- a/src/__tests__/notifications.test.ts +++ b/src/__tests__/notifications.test.ts @@ -12,10 +12,27 @@ import { import * as email from "../lib/email"; import * as ssrf from "../lib/ssrf"; -jest.mock("../lib/email", () => ({ sendEmail: jest.fn().mockResolvedValue({ provider: "console", delivered: true }) })); +jest.mock("../lib/email", () => ({ + sendEmail: jest.fn().mockResolvedValue({ provider: "console", delivered: true }), +})); jest.mock("../lib/ssrf", () => ({ validatePublicUrl: jest.fn(async (u: string) => u) })); +// #965 made walletAuth require a signed address header by default; this suite +// covers notifications validation and 404 behaviour, not wallet auth, so treat +// the requested address as the authenticated wallet. +jest.mock("../middleware/walletAuth", () => ({ + walletAuth: ( + req: { walletAddress?: string; params: { address?: string } }, + _res: unknown, + next: () => void, + ) => { + req.walletAddress = req.params.address; + next(); + }, +})); -const ADDR = "G" + "A".repeat(55); +// Checksum-valid G-address: address() runs StrKey.decodeEd25519PublicKey, which +// rejects fabricated addresses like "G" + "A".repeat(55). +const ADDR = "GADQOBYHA4DQOBYHA4DQOBYHA4DQOBYHA4DQOBYHA4DQOBYHA4DQOZPI"; const sendEmail = email.sendEmail as jest.Mock; function confirmTokenFromEmail(): string { @@ -33,36 +50,87 @@ describe("investor notifications (#661)", () => { it("does not email until the address is confirmed (double opt-in)", async () => { await updatePreferences(ADDR, { email: "a@example.com" }); expect(sendEmail).toHaveBeenCalledTimes(1); // confirmation only - const r = await notify({ type: "yield_distributed", id: "e1", address: ADDR, data: { amount: 10 } }); + const r = await notify({ + type: "yield_distributed", + id: "e1", + address: ADDR, + data: { amount: 10 }, + }); expect(r.email).toBe(0); expect(confirmEmail(confirmTokenFromEmail())).toBe(true); - const r2 = await notify({ type: "yield_distributed", id: "e2", address: ADDR, data: { amount: 10 } }); + const r2 = await notify({ + type: "yield_distributed", + id: "e2", + address: ADDR, + data: { amount: 10 }, + }); expect(r2.email).toBe(1); expect(sendEmail.mock.calls[1][0].body).toContain("/v1/notifications/unsubscribe?token="); }); it("de-duplicates per event and channel", async () => { - await updatePreferences(ADDR, { email: "a@example.com", webhook_url: "https://hooks.example.com/x" }); + await updatePreferences(ADDR, { + email: "a@example.com", + webhook_url: "https://hooks.example.com/x", + }); confirmEmail(confirmTokenFromEmail()); - const ev = { type: "withdrawal_claimable", id: "w1", address: ADDR, data: { amount: 5 } } as const; + const ev = { + type: "withdrawal_claimable", + id: "w1", + address: ADDR, + data: { amount: 5 }, + } as const; expect(await notify(ev)).toEqual({ email: 1, webhook: 1 }); expect(await notify(ev)).toEqual({ email: 0, webhook: 0 }); }); it("signs webhook payloads with the per-address secret", async () => { - const { webhook_secret } = await updatePreferences(ADDR, { webhook_url: "https://hooks.example.com/x" }); + const { webhook_secret } = await updatePreferences(ADDR, { + webhook_url: "https://hooks.example.com/x", + }); await notify({ type: "yield_distributed", id: "y1", address: ADDR, data: {} }); const [, init] = (global as any).fetch.mock.calls[0]; expect(init.headers["X-Heliobond-Signature"]).toBe(signPayload(init.body, webhook_secret!)); }); it("only sends score_changed above the threshold and for chosen projects", async () => { - await updatePreferences(ADDR, { webhook_url: "https://hooks.example.com/x", score_change_threshold: 10, project_ids: [3] }); + await updatePreferences(ADDR, { + webhook_url: "https://hooks.example.com/x", + score_change_threshold: 10, + project_ids: [3], + }); const base = { type: "score_changed", address: undefined } as const; - expect((await notify({ ...base, id: "a", project_id: 3, data: { credit_quality_delta: 4, green_impact_delta: 0 } })).webhook).toBe(0); - expect((await notify({ ...base, id: "b", project_id: 4, data: { credit_quality_delta: 40, green_impact_delta: 0 } })).webhook).toBe(0); - expect((await notify({ ...base, id: "c", project_id: 3, data: { credit_quality_delta: -12, green_impact_delta: 0 } })).webhook).toBe(1); + expect( + ( + await notify({ + ...base, + id: "a", + project_id: 3, + data: { credit_quality_delta: 4, green_impact_delta: 0 }, + }) + ).webhook, + ).toBe(0); + expect( + ( + await notify({ + ...base, + id: "b", + project_id: 4, + data: { credit_quality_delta: 40, green_impact_delta: 0 }, + }) + ).webhook, + ).toBe(0); + expect( + ( + await notify({ + ...base, + id: "c", + project_id: 3, + data: { credit_quality_delta: -12, green_impact_delta: 0 }, + }) + ).webhook, + ).toBe(1); }); it("retries after a failed delivery instead of marking it delivered", async () => { @@ -83,7 +151,9 @@ describe("investor notifications (#661)", () => { it("reads and updates preferences, then confirms and unsubscribes", async () => { expect((await request(app).get(`/n/${ADDR}/preferences`)).status).toBe(404); - const put = await request(app).put(`/n/${ADDR}/preferences`).send({ email: "a@example.com", events: ["yield_distributed"] }); + const put = await request(app) + .put(`/n/${ADDR}/preferences`) + .send({ email: "a@example.com", events: ["yield_distributed"] }); expect(put.status).toBe(200); expect(put.body.confirmation_sent).toBe(true); expect(put.body.preferences.email_verified).toBe(false); @@ -94,14 +164,24 @@ describe("investor notifications (#661)", () => { await notify({ type: "yield_distributed", id: "h1", address: ADDR, data: {} }); const unsubToken = /unsubscribe\?token=([a-f0-9]+)/.exec(sendEmail.mock.calls[1][0].body)![1]; - expect((await request(app).get(`/n/unsubscribe?token=${unsubToken}`)).body).toEqual({ unsubscribed: true }); + expect((await request(app).get(`/n/unsubscribe?token=${unsubToken}`)).body).toEqual({ + unsubscribed: true, + }); expect((await request(app).get(`/n/${ADDR}/preferences`)).body.events).toEqual([]); }); it("rejects bad addresses and bad input", async () => { expect((await request(app).get("/n/nope/preferences")).status).toBe(400); - expect((await request(app).put(`/n/${ADDR}/preferences`).send({ email: "bad" })).status).toBe(400); - expect((await request(app).put(`/n/${ADDR}/preferences`).send({ events: ["x"] })).status).toBe(400); + expect((await request(app).put(`/n/${ADDR}/preferences`).send({ email: "bad" })).status).toBe( + 400, + ); + expect( + ( + await request(app) + .put(`/n/${ADDR}/preferences`) + .send({ events: ["x"] }) + ).status, + ).toBe(400); }); }); }); diff --git a/src/__tests__/requestTimeout.test.ts b/src/__tests__/requestTimeout.test.ts index 30935e5b..813a7a48 100644 --- a/src/__tests__/requestTimeout.test.ts +++ b/src/__tests__/requestTimeout.test.ts @@ -90,8 +90,7 @@ describe("requestTimeout middleware", () => { expect(res.status).toHaveBeenCalledWith(408); expect(res.json).toHaveBeenCalledWith({ - error: "request_timeout", - message: "Request timed out", + error: { code: "request_timeout", message: "Request timed out" }, }); expect(signal.aborted).toBe(true); expect(req.destroy).toHaveBeenCalledTimes(1); diff --git a/src/__tests__/requireApiKeyRole.test.ts b/src/__tests__/requireApiKeyRole.test.ts index 8829f961..e28d52eb 100644 --- a/src/__tests__/requireApiKeyRole.test.ts +++ b/src/__tests__/requireApiKeyRole.test.ts @@ -125,8 +125,8 @@ describe("requireApiKeyRole middleware", () => { .set("Authorization", "Bearer read-only-token"); expect(res.status).toBe(403); - expect(res.body.error).toBe("forbidden"); - expect(res.body.message).toBe("This action requires the 'admin:write' role or higher"); + expect(res.body.error.code).toBe("forbidden"); + expect(res.body.error.message).toBe("This action requires the 'admin:write' role or higher"); expect(mockHasRolePermission).toHaveBeenCalledWith("admin:read", "admin:write"); }); @@ -139,7 +139,7 @@ describe("requireApiKeyRole middleware", () => { .set("Authorization", "Bearer invalid-token"); expect(res.status).toBe(403); - expect(res.body.error).toBe("forbidden"); + expect(res.body.error.code).toBe("forbidden"); expect(mockHasRolePermission).toHaveBeenCalledWith(undefined, "admin:write"); }); @@ -149,7 +149,7 @@ describe("requireApiKeyRole middleware", () => { const res = await request(app).get("/admin-write"); expect(res.status).toBe(403); - expect(res.body.error).toBe("forbidden"); + expect(res.body.error.code).toBe("forbidden"); expect(mockHasRolePermission).toHaveBeenCalledWith(undefined, "admin:write"); }); @@ -192,16 +192,16 @@ describe("requireApiKeyRole middleware", () => { const res = await request(app).get("/protected").set("Authorization", "Bearer invalid-token"); expect(res.status).toBe(401); - expect(res.body.error).toBe("unauthorized"); - expect(res.body.message).toBe("Missing or invalid bearer token"); + expect(res.body.error.code).toBe("unauthorized"); + expect(res.body.error.message).toBe("Missing or invalid bearer token"); }); it("should return 401 when no Authorization header is present", async () => { const res = await request(app).get("/protected"); expect(res.status).toBe(401); - expect(res.body.error).toBe("unauthorized"); - expect(res.body.message).toBe("Missing or invalid bearer token"); + expect(res.body.error.code).toBe("unauthorized"); + expect(res.body.error.message).toBe("Missing or invalid bearer token"); }); }); }); diff --git a/src/__tests__/satellite-sources.test.ts b/src/__tests__/satellite-sources.test.ts index 19f28880..6829fcea 100644 --- a/src/__tests__/satellite-sources.test.ts +++ b/src/__tests__/satellite-sources.test.ts @@ -41,7 +41,7 @@ describe("satellite-sources routes", () => { .send({ name: "custom-source" }) .expect(400); - expect(res.body.error).toBe("fetchUrl is required"); + expect(res.body.error.message).toBe("fetchUrl is required"); }); it("POST /v1/satellite-sources — 400 for an invalid URL", async () => { @@ -50,7 +50,7 @@ describe("satellite-sources routes", () => { .send({ name: "custom-source", fetchUrl: "not-a-url" }) .expect(400); - expect(res.body.error).toBe("url must be a valid http/https URL"); + expect(res.body.error.message).toBe("url must be a valid http/https URL"); }); it("POST /v1/satellite-sources — 400 for non-http(s) protocols", async () => { @@ -59,7 +59,7 @@ describe("satellite-sources routes", () => { .send({ name: "custom-source", fetchUrl: "ftp://example.com/data" }) .expect(400); - expect(res.body.error).toBe("url must be a valid http/https URL"); + expect(res.body.error.message).toBe("url must be a valid http/https URL"); }); it("POST /v1/satellite-sources — 400 for loopback URLs (SSRF)", async () => { @@ -68,7 +68,7 @@ describe("satellite-sources routes", () => { .send({ name: "custom-source", fetchUrl: "http://127.0.0.1:8080/data" }) .expect(400); - expect(res.body.error).toBe( + expect(res.body.error.message).toBe( "url must not point to a private, loopback, link-local, or metadata address", ); }); @@ -79,7 +79,7 @@ describe("satellite-sources routes", () => { .send({ name: "custom-source", fetchUrl: "http://10.0.0.1/data" }) .expect(400); - expect(res.body.error).toBe( + expect(res.body.error.message).toBe( "url must not point to a private, loopback, link-local, or metadata address", ); }); @@ -90,7 +90,7 @@ describe("satellite-sources routes", () => { .send({ name: "custom-source", fetchUrl: "http://169.254.169.254/latest/meta-data" }) .expect(400); - expect(res.body.error).toBe( + expect(res.body.error.message).toBe( "url must not point to a private, loopback, link-local, or metadata address", ); }); @@ -101,7 +101,7 @@ describe("satellite-sources routes", () => { .send({ name: "custom-source", fetchUrl: "http://[::1]/data" }) .expect(400); - expect(res.body.error).toBe( + expect(res.body.error.message).toBe( "url must not point to a private, loopback, link-local, or metadata address", ); }); diff --git a/src/__tests__/txQueueRetryCron.test.ts b/src/__tests__/txQueueRetryCron.test.ts index cc007309..a4afa1d8 100644 --- a/src/__tests__/txQueueRetryCron.test.ts +++ b/src/__tests__/txQueueRetryCron.test.ts @@ -30,6 +30,7 @@ jest.mock("../lib/scoring", () => ({ jest.mock("../lib/idempotency", () => ({ generateIdempotencyKey: jest.fn(), checkIdempotency: jest.fn(), + removeIdempotencyKey: jest.fn(), })); jest.mock("../lib/stellar", () => ({ diff --git a/src/__tests__/versioning.test.ts b/src/__tests__/versioning.test.ts index f23bde9e..c3c211b7 100644 --- a/src/__tests__/versioning.test.ts +++ b/src/__tests__/versioning.test.ts @@ -44,7 +44,8 @@ describe("API versioning middleware", () => { it("rejects an unsupported Accept-Version header", async () => { const res = await request(app).get("/v1/ping").set("Accept-Version", "99"); expect(res.status).toBe(400); - expect(res.body.error).toMatch(/unsupported api version/i); + expect(res.body.error.code).toBe("unsupported_version"); + expect(res.body.error.message).toMatch(/unsupported api version/i); }); it("serves the v1 route at /api/v1 and sets the version headers", async () => { diff --git a/src/index.ts b/src/index.ts index 354e54cf..79e55eff 100644 --- a/src/index.ts +++ b/src/index.ts @@ -54,7 +54,7 @@ import { attachWebSocketServer } from "./lib/websocket"; import { rpcPool } from "./lib/stellar"; import { openApiSpec } from "./lib/swagger"; import { requestLogger } from "./middleware/requestLogger"; -import { errorHandler, notFoundHandler } from "./middleware/errors"; +import { errorHandler, notFoundHandler, errorBody } from "./middleware/errors"; import { sanitizeInputs } from "./middleware/sanitize"; import { securityHeaders, permissionsHeaders } from "./middleware/securityHeaders"; import { publicLimiter, adminLimiter, parseTrustProxy } from "./middleware/rateLimit"; @@ -228,32 +228,50 @@ app.get("/v1/admin/secrets/status", ipWhitelist, adminLimiter, requireAdminBeare }); // ── Migration management ────────────────────────────────────────────────── -app.get("/v1/admin/migrations", ipWhitelist, adminLimiter, requireAdminBearer, async (_req, res, next) => { - try { - const status = await getMigrationStatus(); - res.json(status); - } catch (err) { - next(err); - } -}); +app.get( + "/v1/admin/migrations", + ipWhitelist, + adminLimiter, + requireAdminBearer, + async (_req, res, next) => { + try { + const status = await getMigrationStatus(); + res.json(status); + } catch (err) { + next(err); + } + }, +); -app.post("/v1/admin/migrations/up", ipWhitelist, adminLimiter, requireAdminBearer, async (_req, res, next) => { - try { - const result = await runMigrations(); - res.json(result); - } catch (err) { - next(err); - } -}); +app.post( + "/v1/admin/migrations/up", + ipWhitelist, + adminLimiter, + requireAdminBearer, + async (_req, res, next) => { + try { + const result = await runMigrations(); + res.json(result); + } catch (err) { + next(err); + } + }, +); -app.post("/v1/admin/migrations/rollback", ipWhitelist, adminLimiter, requireAdminBearer, async (_req, res, next) => { - try { - const result = await rollbackMigration(); - res.json(result); - } catch (err) { - next(err); - } -}); +app.post( + "/v1/admin/migrations/rollback", + ipWhitelist, + adminLimiter, + requireAdminBearer, + async (_req, res, next) => { + try { + const result = await rollbackMigration(); + res.json(result); + } catch (err) { + next(err); + } + }, +); // ── Log level management ─────────────────────────────────────────────────── app.get("/v1/admin/logging/level", ipWhitelist, adminLimiter, requireAdminBearer, (_req, res) => { @@ -263,7 +281,7 @@ app.get("/v1/admin/logging/level", ipWhitelist, adminLimiter, requireAdminBearer app.put("/v1/admin/logging/level", ipWhitelist, adminLimiter, requireAdminBearer, (req, res) => { const { level } = req.body as { level?: string }; if (!level) { - res.status(400).json({ error: "missing_level", message: "Log level is required" }); + res.status(400).json(errorBody("missing_level", "Log level is required")); return; } try { @@ -272,14 +290,20 @@ app.put("/v1/admin/logging/level", ipWhitelist, adminLimiter, requireAdminBearer } catch (err) { res .status(400) - .json({ error: "invalid_level", message: err instanceof Error ? err.message : String(err) }); + .json(errorBody("invalid_level", err instanceof Error ? err.message : String(err))); } }); // ── Feature flag analytics ─────────────────────────────────────────────── -app.get("/v1/admin/feature-flags/analytics", ipWhitelist, adminLimiter, requireAdminBearer, (_req, res) => { - res.json(getFlagAnalytics()); -}); +app.get( + "/v1/admin/feature-flags/analytics", + ipWhitelist, + adminLimiter, + requireAdminBearer, + (_req, res) => { + res.json(getFlagAnalytics()); + }, +); // Register feature flag CRUD routes under /v1/admin const flagAdminRouter = express.Router(); diff --git a/src/lib/maintenance-tracking.ts b/src/lib/maintenance-tracking.ts index b3d905a4..08a3d2b8 100644 --- a/src/lib/maintenance-tracking.ts +++ b/src/lib/maintenance-tracking.ts @@ -1,6 +1,9 @@ export type TaskStatus = "scheduled" | "in_progress" | "completed" | "cancelled"; export type TaskPriority = "low" | "medium" | "high" | "critical"; +/** Whitelist of assignable task priorities (#875). */ +const validPriorities: TaskPriority[] = ["low", "medium", "high", "critical"]; + export interface MaintenanceTask { id: string; project_id: number; @@ -91,8 +94,22 @@ function todayDate(): string { return new Date().toISOString().split("T")[0]; } -function validateTaskInput(input: TaskInput): Omit { - const { project_id, title, description, action_type, priority, scheduled_date, assigned_to, estimated_cost } = input; +function validateTaskInput( + input: TaskInput, +): Omit< + MaintenanceTask, + "id" | "status" | "completed_date" | "actual_cost" | "notes" | "created_at" | "updated_at" +> { + const { + project_id, + title, + description, + action_type, + priority, + scheduled_date, + assigned_to, + estimated_cost, + } = input; if (typeof project_id !== "number" || !Number.isInteger(project_id) || project_id < 1) { throw new Error("project_id must be a positive integer"); @@ -106,7 +123,6 @@ function validateTaskInput(input: TaskInput): Omit= 0 ? { estimated_cost: patch.estimated_cost } : {}), + ...(typeof patch.estimated_cost === "number" && + Number.isFinite(patch.estimated_cost) && + patch.estimated_cost >= 0 + ? { estimated_cost: patch.estimated_cost } + : {}), updated_at: now(), }; @@ -235,9 +264,10 @@ export function completeTask( const effBeforeVal = effBefore ?? null; const effAfterVal = effAfter ?? null; - const effectiveness = (effBeforeVal !== null && effAfterVal !== null && effBeforeVal !== 0) - ? Math.round(((effAfterVal - effBeforeVal) / effBeforeVal) * 10000) / 100 - : null; + const effectiveness = + effBeforeVal !== null && effAfterVal !== null && effBeforeVal !== 0 + ? Math.round(((effAfterVal - effBeforeVal) / effBeforeVal) * 10000) / 100 + : null; const record: MaintenanceRecord = { id: `mh_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`, @@ -341,9 +371,10 @@ export function recordManualMaintenance( efficiencyAfter?: number, notes?: string, ): MaintenanceRecord { - const effectiveness = (efficiencyBefore !== undefined && efficiencyAfter !== undefined && efficiencyBefore !== 0) - ? Math.round(((efficiencyAfter - efficiencyBefore) / efficiencyBefore) * 10000) / 100 - : null; + const effectiveness = + efficiencyBefore !== undefined && efficiencyAfter !== undefined && efficiencyBefore !== 0 + ? Math.round(((efficiencyAfter - efficiencyBefore) / efficiencyBefore) * 10000) / 100 + : null; const record: MaintenanceRecord = { id: `mh_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`, @@ -394,17 +425,21 @@ function escapeCsv(val: string): string { } export function tasksToCsv(tasks: MaintenanceTask[]): string { - const header = "id,project_id,title,action_type,priority,status,scheduled_date,completed_date,assigned_to,estimated_cost,actual_cost,notes"; - const rows = tasks.map((t) => - `${t.id},${t.project_id},${escapeCsv(t.title)},${escapeCsv(t.action_type)},${t.priority},${t.status},${t.scheduled_date},${t.completed_date ?? ""},${escapeCsv(t.assigned_to)},${t.estimated_cost},${t.actual_cost ?? ""},${escapeCsv(t.notes)}`, + const header = + "id,project_id,title,action_type,priority,status,scheduled_date,completed_date,assigned_to,estimated_cost,actual_cost,notes"; + const rows = tasks.map( + (t) => + `${t.id},${t.project_id},${escapeCsv(t.title)},${escapeCsv(t.action_type)},${t.priority},${t.status},${t.scheduled_date},${t.completed_date ?? ""},${escapeCsv(t.assigned_to)},${t.estimated_cost},${t.actual_cost ?? ""},${escapeCsv(t.notes)}`, ); return [header, ...rows].join("\n") + "\n"; } export function historyToCsv(records: MaintenanceRecord[]): string { - const header = "id,project_id,task_id,action_type,description,completed_date,cost,efficiency_before,efficiency_after,effectiveness_pct,notes"; - const rows = records.map((r) => - `${r.id},${r.project_id},${r.task_id ?? ""},${escapeCsv(r.action_type)},${escapeCsv(r.description)},${r.completed_date},${r.cost},${r.efficiency_before ?? ""},${r.efficiency_after ?? ""},${r.effectiveness_pct ?? ""},${escapeCsv(r.notes)}`, + const header = + "id,project_id,task_id,action_type,description,completed_date,cost,efficiency_before,efficiency_after,effectiveness_pct,notes"; + const rows = records.map( + (r) => + `${r.id},${r.project_id},${r.task_id ?? ""},${escapeCsv(r.action_type)},${escapeCsv(r.description)},${r.completed_date},${r.cost},${r.efficiency_before ?? ""},${r.efficiency_after ?? ""},${r.effectiveness_pct ?? ""},${escapeCsv(r.notes)}`, ); return [header, ...rows].join("\n") + "\n"; } diff --git a/src/lib/stellar.ts b/src/lib/stellar.ts index 8f77b360..6452ea50 100644 --- a/src/lib/stellar.ts +++ b/src/lib/stellar.ts @@ -197,10 +197,12 @@ async function _attemptSubmit( // Each retry starts from the prepared transaction's operations and fee, but // refreshes its sequence from the ledger before signing. + // (`in` rather than `instanceof Transaction`: FeeBumpTransaction has no + // sequence, and tests mock the SDK without the Transaction class.) const firstEntry = accountResponse.entries?.[0]; - if (firstEntry && firstEntry.val.type === "account") { + if ("sequence" in tx && firstEntry && firstEntry.val.type === "account") { const nextSequence = firstEntry.val.account.seqNum + 1n; - tx.toEnvelope().v1().tx().seqNum(xdr.Int64.fromString(nextSequence.toString())); + tx.sequence = nextSequence.toString(); } tx.sign(keypair); diff --git a/src/lib/stellar/project-registry.ts b/src/lib/stellar/project-registry.ts index d39c85b2..cf0ea74e 100644 --- a/src/lib/stellar/project-registry.ts +++ b/src/lib/stellar/project-registry.ts @@ -73,11 +73,10 @@ export class ProjectRegistryService { logger.debug(`[project-registry] Fetching project data for: ${projectId}`); return { - projectId, - creditQuality: 80, - greenImpact: 85, - lastUpdated: Math.floor(Date.now() / 1000) - 7200, - confidence: 90, + id: projectId, + name: `Project ${projectId}`, + impact_score: 80, + last_update_timestamp: Math.floor(Date.now() / 1000) - 7200, }; } catch (error: any) { logger.error(`[project-registry] Failed to fetch project ${projectId}:`, error); @@ -174,6 +173,7 @@ export class ProjectRegistryService { return { success: true, + skipped: false, transactionHash: `tx_${projectId}_${Date.now()}`, }; } catch (error: any) { diff --git a/src/middleware/errors.ts b/src/middleware/errors.ts index 2fc2ee05..fae1ee14 100644 --- a/src/middleware/errors.ts +++ b/src/middleware/errors.ts @@ -150,5 +150,5 @@ export function errorHandler( } logger.error("[error] unhandled error", logger.formatError(err)); - res.status(500).json(errorBody("INTERNAL_ERROR", "An unexpected error occurred")); + res.status(500).json(errorBody("internal_error", "An unexpected error occurred")); } diff --git a/src/middleware/requestTimeout.ts b/src/middleware/requestTimeout.ts index 030dbcb9..dafaf2d3 100644 --- a/src/middleware/requestTimeout.ts +++ b/src/middleware/requestTimeout.ts @@ -1,4 +1,5 @@ import { Request, Response, NextFunction } from "express"; +import { errorBody } from "./errors"; const DEFAULT_TIMEOUT_MS = parseInt(process.env.REQUEST_TIMEOUT_MS || "30000", 10); const ADMIN_TIMEOUT_MS = parseInt(process.env.ADMIN_REQUEST_TIMEOUT_MS || "60000", 10); @@ -49,7 +50,7 @@ export function createRequestTimeout( const timer = setTimeout(() => { clearTimer(); if (!res.headersSent) { - res.status(408).json({ error: "request_timeout", message }); + res.status(408).json(errorBody("request_timeout", message)); } controller.abort(new Error("request_timeout")); req.destroy(); diff --git a/src/middleware/requireAdminBearer.ts b/src/middleware/requireAdminBearer.ts index 418b77ea..a8fb5b00 100644 --- a/src/middleware/requireAdminBearer.ts +++ b/src/middleware/requireAdminBearer.ts @@ -1,5 +1,6 @@ import { Request, Response, NextFunction } from "express"; import { timingSafeCompare } from "../lib/timing-safe"; +import { errorBody } from "./errors"; /** * Shared middleware that enforces the ADMIN_API_KEY bearer token using a @@ -17,13 +18,13 @@ import { timingSafeCompare } from "../lib/timing-safe"; export function requireAdminBearer(req: Request, res: Response, next: NextFunction): void { const apiKey = process.env.ADMIN_API_KEY; if (!apiKey) { - res.status(500).json({ error: "server_misconfigured", message: "ADMIN_API_KEY is not set" }); + res.status(500).json(errorBody("server_misconfigured", "ADMIN_API_KEY is not set")); return; } const provided = req.headers.authorization ?? ""; if (!timingSafeCompare(provided, `Bearer ${apiKey}`)) { - res.status(401).json({ error: "unauthorized", message: "Missing or invalid bearer token" }); + res.status(401).json(errorBody("unauthorized", "Missing or invalid bearer token")); return; } diff --git a/src/middleware/requireApiKeyRole.ts b/src/middleware/requireApiKeyRole.ts index 21d7f02b..5f307910 100644 --- a/src/middleware/requireApiKeyRole.ts +++ b/src/middleware/requireApiKeyRole.ts @@ -5,14 +5,15 @@ import { Request, Response, NextFunction } from "express"; import { timingSafeCompare } from "../lib/timing-safe"; import { getApiKeyRole, hasRolePermission, ApiKeyRole } from "../lib/apiKeyRoles"; +import { errorBody } from "./errors"; declare global { - // eslint-disable-next-line @typescript-eslint/no-namespace - namespace Express { - interface Request { - apiKeyRole?: ApiKeyRole; - } + + namespace Express { + interface Request { + apiKeyRole?: ApiKeyRole; } + } } /** @@ -20,34 +21,30 @@ declare global { * Sets req.apiKeyRole if a valid token is found, otherwise continues without auth. * Use this as the first auth middleware in a route. */ -export function extractApiKeyRole( - req: Request, - res: Response, - next: NextFunction -): void { - const authHeader = req.headers.authorization ?? ""; +export function extractApiKeyRole(req: Request, res: Response, next: NextFunction): void { + const authHeader = req.headers.authorization ?? ""; - // Extract the token from "Bearer " - const bearerPrefix = "Bearer "; - if (!authHeader.startsWith(bearerPrefix)) { - // No Bearer token provided, continue without role - next(); - return; - } + // Extract the token from "Bearer " + const bearerPrefix = "Bearer "; + if (!authHeader.startsWith(bearerPrefix)) { + // No Bearer token provided, continue without role + next(); + return; + } - const token = authHeader.substring(bearerPrefix.length); - if (!token) { - next(); - return; - } + const token = authHeader.substring(bearerPrefix.length); + if (!token) { + next(); + return; + } - // Look up the role for this token - const role = getApiKeyRole(token); - if (role) { - req.apiKeyRole = role; - } + // Look up the role for this token + const role = getApiKeyRole(token); + if (role) { + req.apiKeyRole = role; + } - next(); + next(); } /** @@ -55,33 +52,25 @@ export function extractApiKeyRole( * Returns a 403 Forbidden if the request doesn't have the required role. */ export function requireApiKeyRole(requiredRole: ApiKeyRole) { - return (req: Request, res: Response, next: NextFunction): void => { - if (!hasRolePermission(req.apiKeyRole, requiredRole)) { - res.status(403).json({ - error: "forbidden", - message: `This action requires the '${requiredRole}' role or higher`, - }); - return; - } - next(); - }; + return (req: Request, res: Response, next: NextFunction): void => { + if (!hasRolePermission(req.apiKeyRole, requiredRole)) { + res + .status(403) + .json(errorBody("forbidden", `This action requires the '${requiredRole}' role or higher`)); + return; + } + next(); + }; } /** * Middleware to require authentication but allow any valid role. * Returns 401 if no valid token is provided. */ -export function requireApiKeyAuth( - req: Request, - res: Response, - next: NextFunction -): void { - if (!req.apiKeyRole) { - res.status(401).json({ - error: "unauthorized", - message: "Missing or invalid bearer token", - }); - return; - } - next(); +export function requireApiKeyAuth(req: Request, res: Response, next: NextFunction): void { + if (!req.apiKeyRole) { + res.status(401).json(errorBody("unauthorized", "Missing or invalid bearer token")); + return; + } + next(); } diff --git a/src/middleware/versioning.ts b/src/middleware/versioning.ts index 9d902d99..5465f680 100644 --- a/src/middleware/versioning.ts +++ b/src/middleware/versioning.ts @@ -15,9 +15,12 @@ export function acceptVersion(req: Request, res: Response, next: NextFunction): const requested = req.headers["accept-version"] as string | undefined; if (requested && !SUPPORTED_VERSIONS.includes(requested)) { res.status(400).json({ - error: "Unsupported API version", - requested, - supported: SUPPORTED_VERSIONS, + error: { + code: "unsupported_version", + message: `Unsupported API version: ${requested}. Supported versions: ${SUPPORTED_VERSIONS.join(", ")}`, + requested, + supported: SUPPORTED_VERSIONS, + }, }); return; } @@ -29,10 +32,7 @@ export const LEGACY_SUNSET_DATE = new Date("2027-01-01T00:00:00Z"); export function deprecationHeaders(_req: Request, res: Response, next: NextFunction): void { res.setHeader("Deprecation", "true"); res.setHeader("Sunset", "2027-01-01T00:00:00Z"); - res.setHeader( - "Link", - '; rel="successor-version"' - ); + res.setHeader("Link", '; rel="successor-version"'); next(); } diff --git a/src/routes/aggregate.ts b/src/routes/aggregate.ts index 8ff0eaeb..0880eb1e 100644 --- a/src/routes/aggregate.ts +++ b/src/routes/aggregate.ts @@ -1,7 +1,14 @@ import { Router, Request, Response, NextFunction } from "express"; import { parseOptionalInt, errorBody } from "../middleware/errors"; -import { loadProjectScores, computeAggregateScores, inferCategory, REGIONS, type Category, type Region } from "../lib/aggregation"; -import { getTotalProjects } from "../lib/stellar/project-registry"; +import { + loadProjectScores, + computeAggregateScores, + inferCategory, + REGIONS, + type Category, + type Region, +} from "../lib/aggregation"; +import { getTotalProjects } from "../lib/registry"; const router = Router(); @@ -28,18 +35,30 @@ router.get("/", async (req: Request, res: Response, next: NextFunction) => { return; } } - const limit = Math.min(parseOptionalInt(req.query.limit as string | undefined, "limit", 20), 100); - const cursor = Math.max(parseOptionalInt(req.query.cursor as string | undefined, "cursor", 0), 0); + const limit = Math.min( + parseOptionalInt(req.query.limit as string | undefined, "limit", 20), + 100, + ); + const cursor = Math.max( + parseOptionalInt(req.query.cursor as string | undefined, "cursor", 0), + 0, + ); const categoryFilter = req.query.category as string | undefined; const regionFilter = req.query.region as string | undefined; if (categoryFilter && !VALID_CATEGORIES.has(categoryFilter as Category)) { - res.status(400).json(errorBody("bad_request", `category must be one of: ${[...VALID_CATEGORIES].join(", ")}`)); + res + .status(400) + .json( + errorBody("bad_request", `category must be one of: ${[...VALID_CATEGORIES].join(", ")}`), + ); return; } if (regionFilter && !VALID_REGIONS.has(regionFilter as Region)) { - res.status(400).json(errorBody("bad_request", `region must be one of: ${[...VALID_REGIONS].join(", ")}`)); + res + .status(400) + .json(errorBody("bad_request", `region must be one of: ${[...VALID_REGIONS].join(", ")}`)); return; } diff --git a/src/routes/apiKeys.ts b/src/routes/apiKeys.ts index e6ea8da3..bfe00fef 100644 --- a/src/routes/apiKeys.ts +++ b/src/routes/apiKeys.ts @@ -9,7 +9,7 @@ import { getRotationStatus, onRotation, } from "../lib/apiKeys"; -import { badRequest } from "../middleware/errors"; +import { badRequest, errorBody } from "../middleware/errors"; import { logger } from "../lib/logger"; import { requireAdminBearer } from "../middleware/requireAdminBearer"; @@ -31,14 +31,23 @@ onRotation((notification) => { router.post("/", (req: Request, res: Response, next: NextFunction) => { try { const { consumer_name, rate_limit, rotation_interval_days } = req.body; - if (!consumer_name || typeof consumer_name !== "string" || consumer_name.trim().length === 0 || consumer_name.length > 128) { + if ( + !consumer_name || + typeof consumer_name !== "string" || + consumer_name.trim().length === 0 || + consumer_name.length > 128 + ) { throw badRequest("consumer_name is required and must be a string up to 128 characters"); } let parsedRateLimit: number | undefined; if (rate_limit !== undefined) { parsedRateLimit = Number(rate_limit); - if (!Number.isInteger(parsedRateLimit) || parsedRateLimit <= 0 || parsedRateLimit > 1_000_000) { + if ( + !Number.isInteger(parsedRateLimit) || + parsedRateLimit <= 0 || + parsedRateLimit > 1_000_000 + ) { throw badRequest("rate_limit must be a positive integer up to 1000000"); } } @@ -46,7 +55,11 @@ router.post("/", (req: Request, res: Response, next: NextFunction) => { let parsedRotationInterval: number | undefined; if (rotation_interval_days !== undefined) { parsedRotationInterval = Number(rotation_interval_days); - if (!Number.isInteger(parsedRotationInterval) || parsedRotationInterval <= 0 || parsedRotationInterval > 3650) { + if ( + !Number.isInteger(parsedRotationInterval) || + parsedRotationInterval <= 0 || + parsedRotationInterval > 3650 + ) { throw badRequest("rotation_interval_days must be a positive integer up to 3650"); } } @@ -106,14 +119,16 @@ router.post("/:id/rotate", (req: Request, res: Response, next: NextFunction) => const parsed = Number(body.grace_period_ms); const MAX_GRACE_MS = 30 * 24 * 60 * 60 * 1000; // 30 days if (!Number.isInteger(parsed) || parsed < 0 || parsed > MAX_GRACE_MS) { - throw badRequest(`grace_period_ms must be an integer between 0 and ${MAX_GRACE_MS} (30 days)`); + throw badRequest( + `grace_period_ms must be an integer between 0 and ${MAX_GRACE_MS} (30 days)`, + ); } grace_period_ms = parsed; } const rotated = rotateApiKey(id, grace_period_ms); if (!rotated) { - return res.status(404).json({ error: "not_found", message: "Active API key not found" }); + return res.status(404).json(errorBody("not_found", "Active API key not found")); } res.json(rotated); } catch (error) { @@ -127,7 +142,7 @@ router.delete("/:id", (req: Request, res: Response, next: NextFunction) => { const id = String(req.params.id); const revoked = revokeApiKey(id); if (!revoked) { - return res.status(404).json({ error: "not_found", message: "API key not found" }); + return res.status(404).json(errorBody("not_found", "API key not found")); } res.json({ success: true, message: "API key revoked successfully" }); } catch (error) { @@ -141,7 +156,7 @@ router.get("/:id/usage", (req: Request, res: Response, next: NextFunction) => { const id = String(req.params.id); const keyDetails = getApiKeyDetails(id); if (!keyDetails) { - return res.status(404).json({ error: "not_found", message: "API key not found" }); + return res.status(404).json(errorBody("not_found", "API key not found")); } res.json({ id: keyDetails.id, diff --git a/src/routes/batch.ts b/src/routes/batch.ts index 645236f0..6c7fe909 100644 --- a/src/routes/batch.ts +++ b/src/routes/batch.ts @@ -9,7 +9,7 @@ import { withProjectLock } from "../lib/request-queue"; import { tryBeginUpdate, markCompleted, markFailed } from "../lib/duplicate-detection"; import { generateIdempotencyKey, checkIdempotency } from "../lib/idempotency"; import { enqueue } from "../lib/tx-queue"; -import { badRequest, maxProjectId } from "../middleware/errors"; +import { badRequest, maxProjectId, errorBody } from "../middleware/errors"; import { parseContractError } from "../lib/contractErrors"; const router = Router(); @@ -143,7 +143,7 @@ router.post("/score-update", async (req: Request, res: Response) => { router.get("/:batchId/status", (req: Request, res: Response) => { const job = getJob(String(req.params["batchId"])); if (!job) { - res.status(404).json({ error: "not_found", message: "Batch job not found" }); + res.status(404).json(errorBody("not_found", "Batch job not found")); return; } res.json({ diff --git a/src/routes/benchmarking.ts b/src/routes/benchmarking.ts index 39d8ba42..5d0ea96c 100644 --- a/src/routes/benchmarking.ts +++ b/src/routes/benchmarking.ts @@ -1,6 +1,5 @@ import { Router, Request, Response, NextFunction } from "express"; -import { parseProjectId } from "../middleware/errors"; -import { badRequest } from "../middleware/errors"; +import { parseProjectId, badRequest, errorBody } from "../middleware/errors"; import { apiKeyAuth } from "../middleware/apiKeyAuth"; import { evaluateAllBenchmarks, @@ -29,7 +28,7 @@ const VALID_METRICS = [ function requireAdminAccess(req: Request, res: Response, next: NextFunction): void { const adminKey = process.env.ADMIN_API_KEY; if (!adminKey) { - res.status(401).json({ error: "unauthorized", message: "Admin API key is not configured" }); + res.status(401).json(errorBody("unauthorized", "Admin API key is not configured")); return; } @@ -41,7 +40,7 @@ function requireAdminAccess(req: Request, res: Response, next: NextFunction): vo (typeof authHeader === "string" && authHeader === `Bearer ${adminKey}`); if (!isAuthorized) { - res.status(401).json({ error: "unauthorized", message: "Admin access required" }); + res.status(401).json(errorBody("unauthorized", "Admin access required")); return; } @@ -56,38 +55,53 @@ router.get("/benchmarks/:id", (req: Request, res: Response) => { const id = req.params.id as string; const benchmark = getBenchmarkById(id); if (!benchmark) { - return res.status(404).json({ error: "not_found", message: `Benchmark "${id}" not found` }); + return res.status(404).json(errorBody("not_found", `Benchmark "${id}" not found`)); } res.json(benchmark); }); -router.post("/benchmarks", requireAdminAccess, (req: Request, res: Response, next: NextFunction) => { - const { name, description, metric, thresholds, source, id } = req.body; - if (!name || !metric || !thresholds) { - return next(badRequest("name, metric, and thresholds are required")); - } +router.post( + "/benchmarks", + requireAdminAccess, + (req: Request, res: Response, next: NextFunction) => { + const { name, description, metric, thresholds, source, id } = req.body; + if (!name || !metric || !thresholds) { + return next(badRequest("name, metric, and thresholds are required")); + } - if (!VALID_METRICS.includes(metric)) { - return next(badRequest(`metric must be one of: ${VALID_METRICS.join(", ")}`)); - } + if (!VALID_METRICS.includes(metric)) { + return next(badRequest(`metric must be one of: ${VALID_METRICS.join(", ")}`)); + } - const { poor, fair, good, excellent } = thresholds; - if ([poor, fair, good, excellent].some((v: number) => typeof v !== "number" || !Number.isFinite(v))) { - return next(badRequest("thresholds must have numeric poor, fair, good, excellent values")); - } - if (poor > fair || fair > good || good > excellent) { - return next(badRequest("thresholds must be ordered poor <= fair <= good <= excellent")); - } + const { poor, fair, good, excellent } = thresholds; + if ( + [poor, fair, good, excellent].some( + (v: number) => typeof v !== "number" || !Number.isFinite(v), + ) + ) { + return next(badRequest("thresholds must have numeric poor, fair, good, excellent values")); + } + if (poor > fair || fair > good || good > excellent) { + return next(badRequest("thresholds must be ordered poor <= fair <= good <= excellent")); + } - const requestedId = typeof id === "string" && id.trim().length > 0 ? id.trim() : undefined; - const benchmarkId = requestedId ?? String(name).trim().toLowerCase().replace(/\s+/g, "_"); - if (getBenchmarkById(benchmarkId)) { - return next(badRequest(`Benchmark "${benchmarkId}" already exists`)); - } + const requestedId = typeof id === "string" && id.trim().length > 0 ? id.trim() : undefined; + const benchmarkId = requestedId ?? String(name).trim().toLowerCase().replace(/\s+/g, "_"); + if (getBenchmarkById(benchmarkId)) { + return next(badRequest(`Benchmark "${benchmarkId}" already exists`)); + } - const benchmark = defineCustomBenchmark({ name, description, metric, thresholds, source, id: benchmarkId }); - res.status(201).json(benchmark); -}); + const benchmark = defineCustomBenchmark({ + name, + description, + metric, + thresholds, + source, + id: benchmarkId, + }); + res.status(201).json(benchmark); + }, +); router.get("/:id", (req: Request, res: Response, next: NextFunction) => { try { @@ -128,7 +142,7 @@ router.get("/:id/trend", (req: Request, res: Response, next: NextFunction) => { const benchmark = getBenchmarkById(benchmarkId); if (!benchmark) { - return res.status(404).json({ error: "not_found", message: `Benchmark "${benchmarkId}" not found` }); + return res.status(404).json(errorBody("not_found", `Benchmark "${benchmarkId}" not found`)); } const result = trendVsBenchmark(id, metric, benchmarkId); diff --git a/src/routes/chains.ts b/src/routes/chains.ts index 1b43a820..da69aeb4 100644 --- a/src/routes/chains.ts +++ b/src/routes/chains.ts @@ -8,7 +8,7 @@ import { broadcastToChains, ChainId, } from "../lib/multichain"; -import { badRequest, parseProjectId } from "../middleware/errors"; +import { badRequest, parseProjectId, errorBody } from "../middleware/errors"; import { ipWhitelist } from "../middleware/ipWhitelist"; import { requireAdminBearer } from "../middleware/requireAdminBearer"; import { requestSigning } from "../middleware/requestSigning"; @@ -40,7 +40,7 @@ router.get("/", (_req: Request, res: Response) => { /** GET /v1/chains/:id — get chain config (public, see note on `GET /`). */ router.get("/:id", (req: Request, res: Response) => { const chain = getChain(req.params.id as ChainId); - if (!chain) return res.status(404).json({ error: "chain not found" }); + if (!chain) return res.status(404).json(errorBody("not_found", "chain not found")); res.json(chain); }); @@ -59,10 +59,12 @@ router.patch("/:id", ...adminAuth, async (req: Request, res: Response, next: Nex if (chains !== undefined) { if (!Array.isArray(chains)) { - return res.status(400).json({ error: "chains must be an array of chain identifiers" }); + return res + .status(400) + .json(errorBody("bad_request", "chains must be an array of chain identifiers")); } if (chains.length === 0) { - return res.status(400).json({ error: "chains array must not be empty" }); + return res.status(400).json(errorBody("bad_request", "chains array must not be empty")); } } @@ -130,7 +132,7 @@ router.patch("/:id", ...adminAuth, async (req: Request, res: Response, next: Nex contractAddress, name, }); - if (!ok) return res.status(404).json({ error: "chain not found" }); + if (!ok) return res.status(404).json(errorBody("not_found", "chain not found")); res.json({ ok: true, chain: getChain(req.params.id as ChainId) }); } catch (err) { next(err); @@ -157,9 +159,14 @@ router.post( // fabricating a fake transaction hash. const targetChains = (chains ?? getEnabledChains().map((c) => c.id)) as ChainId[]; if (targetChains.some((id) => id !== "stellar")) { - return res.status(501).json({ - error: "EVM chain submission not implemented; only Stellar is currently supported.", - }); + return res + .status(501) + .json( + errorBody( + "not_implemented", + "EVM chain submission not implemented; only Stellar is currently supported.", + ), + ); } const solar = getSolarData(projectId); diff --git a/src/routes/email.ts b/src/routes/email.ts index aa7eaa98..6de01fc4 100644 --- a/src/routes/email.ts +++ b/src/routes/email.ts @@ -11,7 +11,7 @@ import { sendDigest, Frequency, } from "../lib/email"; -import { badRequest } from "../middleware/errors"; +import { badRequest, errorBody } from "../middleware/errors"; const router = Router(); @@ -44,10 +44,7 @@ function handlePublicUnsubscribe(req: Request, res: Response): void { } const removed = unsubscribeByToken(token); if (!removed) { - res.status(404).json({ - error: "not_found", - message: "Unknown or already-used unsubscribe token", - }); + res.status(404).json(errorBody("not_found", "Unknown or already-used unsubscribe token")); return; } res.json({ unsubscribed: true }); @@ -132,7 +129,9 @@ router.put("/templates", (req: Request, res: Response) => { res.json(upsertTemplate({ name, subject, body })); }); -function isValidScoreChange(change: unknown): change is { project_id: number; credit_quality_delta: number; green_impact_delta: number } { +function isValidScoreChange( + change: unknown, +): change is { project_id: number; credit_quality_delta: number; green_impact_delta: number } { return ( typeof change === "object" && change !== null && @@ -151,12 +150,12 @@ router.post("/digest", async (req: Request, res: Response, next: NextFunction) = if (frequency !== "daily" && frequency !== "weekly") { throw badRequest("frequency must be 'daily' or 'weekly'"); } - + // Validate changes array if (changes !== undefined && !Array.isArray(changes)) { throw badRequest("changes must be an array"); } - + const validatedChanges = Array.isArray(changes) ? changes.filter((c) => { if (!isValidScoreChange(c)) { @@ -166,7 +165,7 @@ router.post("/digest", async (req: Request, res: Response, next: NextFunction) = return true; }) : []; - + try { const sent = await sendDigest(frequency, validatedChanges); res.json({ frequency, sent }); diff --git a/src/routes/maintenance.ts b/src/routes/maintenance.ts index 1c9ed4dd..a51db9f0 100644 --- a/src/routes/maintenance.ts +++ b/src/routes/maintenance.ts @@ -23,7 +23,7 @@ import { tasksToCsv, historyToCsv, } from "../lib/maintenance-tracking"; -import { badRequest, parseProjectId, parseOptionalInt } from "../middleware/errors"; +import { badRequest, parseProjectId, parseOptionalInt, errorBody } from "../middleware/errors"; const router = Router(); @@ -204,7 +204,7 @@ router.get("/tasks/:taskId", (req: Request, res: Response, next: NextFunction) = try { const task = getTask(req.params.taskId as string); if (!task) { - res.status(404).json({ error: "not_found", message: "Task not found" }); + res.status(404).json(errorBody("not_found", "Task not found")); return; } res.json(task); @@ -268,7 +268,7 @@ router.delete("/tasks/:taskId", (req: Request, res: Response, next: NextFunction try { const removed = deleteTask(req.params.taskId as string); if (!removed) { - res.status(404).json({ error: "not_found", message: "Task not found" }); + res.status(404).json(errorBody("not_found", "Task not found")); return; } res.json({ removed: true }); diff --git a/src/routes/metadata.ts b/src/routes/metadata.ts index 3d517ce9..5b01f002 100644 --- a/src/routes/metadata.ts +++ b/src/routes/metadata.ts @@ -7,7 +7,7 @@ import { MetadataValidationError, } from "../lib/metadata"; import { getPanelConfig } from "../lib/panels"; -import { parseProjectId, badRequest } from "../middleware/errors"; +import { parseProjectId, badRequest, errorBody } from "../middleware/errors"; const router = Router(); @@ -27,7 +27,7 @@ router.get("/:id", (req: Request, res: Response) => { const id = parseProjectId(req.params.id, "project id"); const meta = getMetadata(id); if (!meta) { - res.status(404).json({ error: "not_found", message: "No metadata for this project" }); + res.status(404).json(errorBody("not_found", "No metadata for this project")); return; } res.json(present(id, meta)); diff --git a/src/routes/notifications.ts b/src/routes/notifications.ts index db0efeb2..b398a584 100644 --- a/src/routes/notifications.ts +++ b/src/routes/notifications.ts @@ -1,5 +1,5 @@ import { Router, Request, Response, NextFunction } from "express"; -import { badRequest } from "../middleware/errors"; +import { badRequest, errorBody } from "../middleware/errors"; import { walletAuth } from "../middleware/walletAuth"; import { getPreferences, @@ -18,7 +18,7 @@ const MAX_PROJECT_IDS = 100; function requireAddressOwner(req: Request, res: Response, next: NextFunction): void { if (req.walletAddress !== req.params.address) { - res.status(403).json({ error: "forbidden", message: "Wallet signature does not match address" }); + res.status(403).json(errorBody("forbidden", "Wallet signature does not match address")); return; } next(); @@ -29,7 +29,9 @@ function address(req: Request): string { try { validateStellarAddress(a); } catch (err) { - throw badRequest(err instanceof Error ? err.message : "address must be a valid Stellar address"); + throw badRequest( + err instanceof Error ? err.message : "address must be a valid Stellar address", + ); } return a; } @@ -45,7 +47,7 @@ export const publicNotificationsRouter = Router(); publicNotificationsRouter.get("/confirm", (req: Request, res: Response, next: NextFunction) => { try { if (!confirmEmail(queryToken(req))) { - res.status(404).json({ error: "not_found", message: "Unknown or already-used confirmation token" }); + res.status(404).json(errorBody("not_found", "Unknown or already-used confirmation token")); return; } res.json({ confirmed: true }); @@ -58,7 +60,7 @@ publicNotificationsRouter.get("/confirm", (req: Request, res: Response, next: Ne publicNotificationsRouter.get("/unsubscribe", (req: Request, res: Response, next: NextFunction) => { try { if (!unsubscribe(queryToken(req))) { - res.status(404).json({ error: "not_found", message: "Unknown unsubscribe token" }); + res.status(404).json(errorBody("not_found", "Unknown unsubscribe token")); return; } res.json({ unsubscribed: true }); @@ -76,9 +78,7 @@ router.get( try { const prefs = getPreferences(address(req)); if (!prefs) { - res - .status(404) - .json({ error: "not_found", message: "No preferences registered for this address" }); + res.status(404).json(errorBody("not_found", "No preferences registered for this address")); return; } res.json(prefs); diff --git a/src/routes/oracle-status.ts b/src/routes/oracle-status.ts index 242858c0..60232b15 100644 --- a/src/routes/oracle-status.ts +++ b/src/routes/oracle-status.ts @@ -1,7 +1,7 @@ import { Router, Request, Response, NextFunction } from "express"; import { register } from "../lib/prometheus"; import { logger } from "../lib/logger"; -import { getTotalProjects } from "../lib/stellar/project-registry"; +import { getTotalProjects } from "../lib/registry"; const router = Router(); @@ -34,7 +34,8 @@ function percentileFromBuckets(buckets: { le: number; count: number }[], q: numb .map(([le, count]) => ({ le, count })) .sort((a, b) => a.le - b.le); - const total = sorted.find((b) => b.le === Infinity)?.count ?? sorted[sorted.length - 1]?.count ?? 0; + const total = + sorted.find((b) => b.le === Infinity)?.count ?? sorted[sorted.length - 1]?.count ?? 0; if (total === 0) return null; const target = q * total; diff --git a/src/routes/panels.ts b/src/routes/panels.ts index 19ffe54d..5f99f6af 100644 --- a/src/routes/panels.ts +++ b/src/routes/panels.ts @@ -7,7 +7,7 @@ import { effectiveCapacityKw, PanelValidationError, } from "../lib/panels"; -import { parseProjectId, badRequest } from "../middleware/errors"; +import { parseProjectId, badRequest, errorBody } from "../middleware/errors"; const router = Router(); @@ -26,7 +26,7 @@ router.get("/:id", (req: Request, res: Response) => { const id = parseProjectId(req.params.id, "project id"); const config = getPanelConfig(id); if (!config) { - res.status(404).json({ error: "not_found", message: "No panel configuration for this project" }); + res.status(404).json(errorBody("not_found", "No panel configuration for this project")); return; } res.json(present(config)); diff --git a/src/routes/projects.ts b/src/routes/projects.ts index acdeb492..1488acf8 100644 --- a/src/routes/projects.ts +++ b/src/routes/projects.ts @@ -62,6 +62,7 @@ interface ProjectDetailData { ndvi_score: number; timestamp: number; funding: number; + funding_source: string; } /** Nested detail shape the frontend's `ProjectWithDetail` expects (#768). */ diff --git a/src/routes/roles.ts b/src/routes/roles.ts index 69646336..bc610b49 100644 --- a/src/routes/roles.ts +++ b/src/routes/roles.ts @@ -1,7 +1,7 @@ import { Router, Request, Response } from "express"; import { assignRole, removeRole, listRoles, type Role } from "../lib/roles"; import { requireAdminBearer } from "../middleware/requireAdminBearer"; -import { badRequest } from "../middleware/errors"; +import { badRequest, errorBody } from "../middleware/errors"; const router = Router(); @@ -32,7 +32,7 @@ router.get("/", (_req: Request, res: Response) => { router.delete("/:userId", (req: Request, res: Response) => { const removed = removeRole(String(req.params["userId"])); if (!removed) { - res.status(404).json({ error: "not_found", message: "User has no assigned role" }); + res.status(404).json(errorBody("not_found", "User has no assigned role")); return; } res.json({ removed: true }); diff --git a/src/routes/satellite-sources.ts b/src/routes/satellite-sources.ts index 721f25a6..bd995d44 100644 --- a/src/routes/satellite-sources.ts +++ b/src/routes/satellite-sources.ts @@ -7,7 +7,7 @@ import { registerSource, getCustomFetchUrl, } from "../lib/satellite-sources"; -import { parseProjectId, badRequest } from "../middleware/errors"; +import { parseProjectId, badRequest, errorBody } from "../middleware/errors"; import { validatePublicUrl } from "../lib/ssrf"; const router = Router(); @@ -43,7 +43,7 @@ router.patch("/:name", (req: Request, res: Response) => { } const ok = configureSource(String(req.params.name), { enabled, priority }); - if (!ok) return res.status(404).json({ error: "source not found" }); + if (!ok) return res.status(404).json(errorBody("not_found", "source not found")); res.json({ ok: true, sources: getSources() }); }); @@ -120,20 +120,26 @@ router.post("/", async (req: Request, res: Response) => { const NAME_RE = /^[a-z0-9_-]{1,64}$/i; if (!name || typeof name !== "string" || !NAME_RE.test(name.trim())) { - return res.status(400).json({ error: "name must be 1-64 characters (alphanumeric, dash, underscore)" }); + return res + .status(400) + .json( + errorBody("bad_request", "name must be 1-64 characters (alphanumeric, dash, underscore)"), + ); } const cleanName = name.trim(); if (cleanName === "sentinel-2" || cleanName === "landsat-8") { - return res.status(400).json({ error: `Cannot replace built-in source "${cleanName}"` }); + return res + .status(400) + .json(errorBody("bad_request", `Cannot replace built-in source "${cleanName}"`)); } if (priority !== undefined && (!Number.isInteger(priority) || priority < 1)) { - return res.status(400).json({ error: "priority must be a positive integer" }); + return res.status(400).json(errorBody("bad_request", "priority must be a positive integer")); } if (!fetchUrl || typeof fetchUrl !== "string") { - return res.status(400).json({ error: "fetchUrl is required" }); + return res.status(400).json(errorBody("bad_request", "fetchUrl is required")); } // SSRF guard: only allow http/https URLs that resolve to public addresses. @@ -143,7 +149,12 @@ router.post("/", async (req: Request, res: Response) => { } catch (err) { return res .status(400) - .json({ error: err instanceof Error ? err.message : "fetchUrl must be a valid URL" }); + .json( + errorBody( + "bad_request", + err instanceof Error ? err.message : "fetchUrl must be a valid URL", + ), + ); } const sourcePriority = typeof priority === "number" ? priority : 99; @@ -159,7 +170,9 @@ router.post("/", async (req: Request, res: Response) => { validatedUrl, ); } catch (err) { - return res.status(400).json({ error: err instanceof Error ? err.message : String(err) }); + return res + .status(400) + .json(errorBody("bad_request", err instanceof Error ? err.message : String(err))); } res.status(201).json({ ok: true, name: cleanName, priority: sourcePriority }); diff --git a/src/routes/scoring-formulas.ts b/src/routes/scoring-formulas.ts index 937bf3cb..9dc79a35 100644 --- a/src/routes/scoring-formulas.ts +++ b/src/routes/scoring-formulas.ts @@ -10,7 +10,7 @@ import { validateWeights, } from "../lib/scoring-formula"; import { getSolarData, getSatelliteData } from "./iot"; -import { parseProjectId, badRequest } from "../middleware/errors"; +import { parseProjectId, badRequest, errorBody } from "../middleware/errors"; const router = Router(); @@ -29,15 +29,17 @@ router.post("/", (req: Request, res: Response) => { }; if (!id || typeof id !== "string") { - return res.status(400).json({ error: "id is required" }); + return res.status(400).json(errorBody("bad_request", "id is required")); } if (!name || typeof name !== "string") { - return res.status(400).json({ error: "name is required" }); + return res.status(400).json(errorBody("bad_request", "name is required")); } const result = createFormula(id, name, weights ?? {}, description); if (!result.valid) { - return res.status(400).json({ error: "validation failed", errors: result.errors }); + return res.status(400).json({ + error: { code: "bad_request", message: "validation failed", errors: result.errors }, + }); } res.status(201).json(result.formula); @@ -46,28 +48,28 @@ router.post("/", (req: Request, res: Response) => { /** GET /v1/scoring/formulas/:id — get a formula */ router.get("/:id", (req: Request, res: Response) => { const formula = getFormula(String(req.params.id)); - if (!formula) return res.status(404).json({ error: "formula not found" }); + if (!formula) return res.status(404).json(errorBody("not_found", "formula not found")); res.json(formula); }); /** DELETE /v1/scoring/formulas/:id — delete a formula */ router.delete("/:id", (req: Request, res: Response) => { const deleted = deleteFormula(String(req.params.id)); - if (!deleted) return res.status(404).json({ error: "formula not found" }); + if (!deleted) return res.status(404).json(errorBody("not_found", "formula not found")); res.json({ ok: true }); }); /** POST /v1/scoring/formulas/:id/activate — set as active formula */ router.post("/:id/activate", (req: Request, res: Response) => { const ok = setActiveFormula(String(req.params.id)); - if (!ok) return res.status(404).json({ error: "formula not found" }); + if (!ok) return res.status(404).json(errorBody("not_found", "formula not found")); res.json({ ok: true, active: String(req.params.id) }); }); /** POST /v1/scoring/formulas/validate — validate weights without saving */ router.post("/validate", (req: Request, res: Response) => { const { weights } = req.body as { weights?: Record }; - if (!weights) return res.status(400).json({ error: "weights required" }); + if (!weights) return res.status(400).json(errorBody("bad_request", "weights required")); res.json(validateWeights(weights)); }); @@ -79,7 +81,7 @@ router.get("/:id/preview/:projectId", (req: Request, res: Response, next: NextFu try { const projectId = parseProjectId(req.params.projectId, "project id"); const formula = getFormula(String(req.params.id)); - if (!formula) return res.status(404).json({ error: "formula not found" }); + if (!formula) return res.status(404).json(errorBody("not_found", "formula not found")); const solar = getSolarData(projectId); const satellite = getSatelliteData(projectId); diff --git a/src/routes/webhooks.ts b/src/routes/webhooks.ts index 892c9c6c..2ab0f7b1 100644 --- a/src/routes/webhooks.ts +++ b/src/routes/webhooks.ts @@ -6,7 +6,7 @@ import { getWebhook, validateWebhookUrl, } from "../lib/webhooks"; -import { badRequest } from "../middleware/errors"; +import { badRequest, errorBody } from "../middleware/errors"; const router = Router(); @@ -48,12 +48,22 @@ router.post("/", async (req: Request, res: Response, next: NextFunction) => { } if (max_retries !== undefined) { - if (typeof max_retries !== "number" || !Number.isInteger(max_retries) || max_retries < 0 || max_retries > 10) { + if ( + typeof max_retries !== "number" || + !Number.isInteger(max_retries) || + max_retries < 0 || + max_retries > 10 + ) { throw badRequest("max_retries must be an integer between 0 and 10"); } } if (retry_delay_ms !== undefined) { - if (typeof retry_delay_ms !== "number" || !Number.isInteger(retry_delay_ms) || retry_delay_ms < 0 || retry_delay_ms > 60000) { + if ( + typeof retry_delay_ms !== "number" || + !Number.isInteger(retry_delay_ms) || + retry_delay_ms < 0 || + retry_delay_ms > 60000 + ) { throw badRequest("retry_delay_ms must be an integer between 0 and 60000"); } } @@ -93,7 +103,7 @@ router.get("/:id", (req: Request, res: Response) => { const owner = getOwner(req); const wh = getWebhook(String(req.params["id"])); if (!wh || (owner && wh.owner && wh.owner !== owner)) { - res.status(404).json({ error: "not_found", message: "Webhook not found" }); + res.status(404).json(errorBody("not_found", "Webhook not found")); return; } res.json({ @@ -110,12 +120,12 @@ router.delete("/:id", (req: Request, res: Response) => { const id = String(req.params["id"]); const wh = getWebhook(id); if (!wh || (owner && wh.owner && wh.owner !== owner)) { - res.status(404).json({ error: "not_found", message: "Webhook not found" }); + res.status(404).json(errorBody("not_found", "Webhook not found")); return; } const removed = removeWebhook(id); if (!removed) { - res.status(404).json({ error: "not_found", message: "Webhook not found" }); + res.status(404).json(errorBody("not_found", "Webhook not found")); return; } res.json({ removed: true });