diff --git a/API.md b/API.md index fbcb6d2..659b11d 100644 --- a/API.md +++ b/API.md @@ -358,7 +358,11 @@ Portfolio-level aggregated impact and credit quality metrics across projects. ### `GET /v1/portfolio/:address` -Indexed deposit/withdrawal transaction history, share count, and position value for a Stellar account address. +Indexed deposit/withdrawal transaction history, share count, and position value for a Stellar account address. The +simulated share price is seeded from the address, so `current_value` is +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`. **Response `200`** diff --git a/src/__tests__/admin-response-shape.test.ts b/src/__tests__/admin-response-shape.test.ts index bd67d28..0ada91a 100644 --- a/src/__tests__/admin-response-shape.test.ts +++ b/src/__tests__/admin-response-shape.test.ts @@ -2,10 +2,10 @@ import request from "supertest"; import express, { Express } from "express"; import adminRouter from "../routes/admin"; import { errorHandler } from "../middleware/errors"; +import { resetIdempotencyState } from "../lib/scoreService"; import * as registry from "../lib/registry"; import * as iot from "../routes/iot"; import * as scoring from "../lib/scoring"; -import { resetIdempotencyState } from "../lib/scoreService"; jest.mock("../lib/registry", () => { class RpcDegradedError extends Error { @@ -45,6 +45,7 @@ describe("admin /update-scores response shape", () => { resetIdempotencyState(); app = buildApp(); jest.clearAllMocks(); + resetIdempotencyState(); (iot.getSolarData as jest.Mock).mockReturnValue({ efficiency_pct: 85, power_output_kw: 500, @@ -92,17 +93,13 @@ describe("admin /update-scores response shape", () => { expect(Array.isArray(res.body.errors)).toBe(true); }); - it("response shape matches { updated, results, errors }", async () => { + it("response shape matches { updated, results, errors, skipped }", async () => { const res = await request(app) .post("/api/admin/update-scores") .set(authHeader) .send({}) .expect(200); - expect( - Object.keys(res.body) - .filter((k) => k !== "skipped") - .sort(), - ).toEqual(["errors", "results", "updated"]); + expect(Object.keys(res.body).sort()).toEqual(["errors", "results", "skipped", "updated"]); }); it("results entries have correct shape", async () => { @@ -137,6 +134,10 @@ describe("admin /update-scores response shape", () => { expect(entry).toHaveProperty("project_id"); expect(entry).toHaveProperty("error"); expect(typeof entry.project_id).toBe("number"); - expect(typeof entry.error).toBe("object"); + // Matches the documented error contract: { error: { code, message } }. + expect(entry.error).toMatchObject({ + code: "update_failed", + message: expect.any(String), + }); }); }); diff --git a/src/__tests__/admin-validation.test.ts b/src/__tests__/admin-validation.test.ts index 0e0c079..0b4eabd 100644 --- a/src/__tests__/admin-validation.test.ts +++ b/src/__tests__/admin-validation.test.ts @@ -2,10 +2,10 @@ import request from "supertest"; import express, { Express } from "express"; import adminRouter from "../routes/admin"; import { errorHandler } from "../middleware/errors"; +import { resetIdempotencyState } from "../lib/scoreService"; import * as registry from "../lib/registry"; import * as iot from "../routes/iot"; import * as scoring from "../lib/scoring"; -import { resetIdempotencyState } from "../lib/scoreService"; // Factory mock avoids loading the real registry module, which throws at import // time when PROJECT_REGISTRY_CONTRACT_ID is unset (e.g. in CI). diff --git a/src/__tests__/admin.test.ts b/src/__tests__/admin.test.ts index 2c9227f..fb35037 100644 --- a/src/__tests__/admin.test.ts +++ b/src/__tests__/admin.test.ts @@ -2,10 +2,10 @@ import request from "supertest"; import express, { Express } from "express"; import adminRouter from "../routes/admin"; import { errorHandler } from "../middleware/errors"; +import { resetIdempotencyState } from "../lib/scoreService"; import * as registry from "../lib/registry"; import * as iot from "../routes/iot"; import * as scoring from "../lib/scoring"; -import { resetIdempotencyState } from "../lib/scoreService"; jest.mock("../lib/registry", () => { class RpcDegradedError extends Error { @@ -42,9 +42,11 @@ describe("admin routes", () => { let app: Express; beforeEach(() => { - resetIdempotencyState(); app = buildApp(); jest.clearAllMocks(); + // The route goes through the real scoreService, whose module-level + // idempotency map must be cleared between tests — earlier tests in this + // file submit the same project ids. resetIdempotencyState(); (iot.getSolarData as jest.Mock).mockReturnValue({ efficiency_pct: 85, diff --git a/src/__tests__/config.test.ts b/src/__tests__/config.test.ts index 2d4d3b5..a828100 100644 --- a/src/__tests__/config.test.ts +++ b/src/__tests__/config.test.ts @@ -53,15 +53,14 @@ describe("Environment Config Module (Issue #272)", () => { }); describe("Optional environment variable defaults", () => { - it("provides fallback defaults for optional configuration fields", () => { + it("provides fallback defaults for optional configuration fields", async () => { delete process.env.STELLAR_NETWORK; delete process.env.RPC_URL; delete process.env.PORT; delete process.env.FRONTEND_URL; jest.resetModules(); - // eslint-disable-next-line @typescript-eslint/no-require-imports - const freshConfig = require("../config").config as typeof config; + const freshConfig = (await import("../config")).config as typeof config; expect(freshConfig.STELLAR_NETWORK).toBe("testnet"); expect(freshConfig.RPC_URL).toBe("https://soroban-testnet.stellar.org"); diff --git a/src/__tests__/deployment-workflow.test.ts b/src/__tests__/deployment-workflow.test.ts index 190c639..49c6df6 100644 --- a/src/__tests__/deployment-workflow.test.ts +++ b/src/__tests__/deployment-workflow.test.ts @@ -131,13 +131,13 @@ describe("deployment workflow tests (#284)", () => { }); describe("dependency audit workflow", () => { - it("ci.yml includes a dependency audit job", () => { - const content = readWorkflow("ci.yml"); - expect(content).toMatch(/audit|Audit/); + it("security-audit.yml includes a dependency audit job", () => { + const content = readWorkflow("security-audit.yml"); + expect(content).toMatch(/dependency-audit/i); }); it("audit runs npm audit or equivalent", () => { - const content = readWorkflow("ci.yml"); + const content = readWorkflow("security-audit.yml"); expect(content).toMatch(/npm audit|yarn audit|bun audit/); }); }); diff --git a/src/__tests__/duplicate-detection.test.ts b/src/__tests__/duplicate-detection.test.ts index 339bdb6..b224da6 100644 --- a/src/__tests__/duplicate-detection.test.ts +++ b/src/__tests__/duplicate-detection.test.ts @@ -14,16 +14,28 @@ jest.mock("../lib/logger", () => ({ }, })); -import { tryBeginUpdate, markCompleted, markFailed } from "../lib/duplicate-detection"; -import { logger } from "../lib/logger"; +let tryBeginUpdate: typeof import("../lib/duplicate-detection").tryBeginUpdate; +let markCompleted: typeof import("../lib/duplicate-detection").markCompleted; +let markFailed: typeof import("../lib/duplicate-detection").markFailed; +let logger: typeof import("../lib/logger").logger; describe("duplicate-detection (cron concurrency guard)", () => { - beforeEach(() => { + beforeEach(async () => { + // Re-import the real module (with the logger mock applied) so its + // in-memory lock map starts empty: the map is module-level state, so + // jest.resetModules() alone keeps the original bindings (and their locks) + // around. jest.requireMock would hand back an automatic mock whose + // functions return undefined, so requireActual is used instead. + jest.resetModules(); + const fresh = jest.requireActual("../lib/duplicate-detection"); + tryBeginUpdate = fresh.tryBeginUpdate; + markCompleted = fresh.markCompleted; + markFailed = fresh.markFailed; + // After resetModules the fresh module resolves a NEW logger mock instance; + // re-import it so assertions see the same functions it calls. + const freshLogger = await import("../lib/logger"); + logger = freshLogger.logger; jest.clearAllMocks(); - markCompleted("project-1"); - markCompleted("project-2"); - markCompleted(123); - markCompleted("concurrent-test"); }); it("allows first update attempt for a given ID", () => { @@ -45,7 +57,7 @@ describe("duplicate-detection (cron concurrency guard)", () => { expect(result.allowed).toBe(false); expect(result.key).toBe(""); expect(result.reason).toMatch(/Update already in progress since/); - expect(logger.warn).toHaveBeenCalledWith( + expect(logger.warn as jest.Mock).toHaveBeenCalledWith( expect.stringContaining("[duplicate-detection] Skipping update for project-1:"), ); }); @@ -56,7 +68,7 @@ describe("duplicate-detection (cron concurrency guard)", () => { expect(result1.allowed).toBe(true); expect(result2.allowed).toBe(true); - expect(logger.warn).not.toHaveBeenCalled(); + expect(logger.warn as jest.Mock).not.toHaveBeenCalled(); }); it("releases lock after successful completion", () => { @@ -67,7 +79,7 @@ describe("duplicate-detection (cron concurrency guard)", () => { // Should allow new attempt after lock is released const result = tryBeginUpdate("project-1"); expect(result.allowed).toBe(true); - expect(logger.debug).toHaveBeenCalledWith( + expect(logger.debug as jest.Mock).toHaveBeenCalledWith( "[duplicate-detection] Lock released for project-1 after successful completion", ); }); @@ -80,7 +92,7 @@ describe("duplicate-detection (cron concurrency guard)", () => { // Should allow new attempt after lock is released const result = tryBeginUpdate("project-1"); expect(result.allowed).toBe(true); - expect(logger.debug).toHaveBeenCalledWith( + expect(logger.debug as jest.Mock).toHaveBeenCalledWith( "[duplicate-detection] Lock released for project-1 after failure", ); }); @@ -96,7 +108,7 @@ describe("duplicate-detection (cron concurrency guard)", () => { const secondRun = tryBeginUpdate(projectId); expect(secondRun.allowed).toBe(false); expect(secondRun.reason).toMatch(/Update already in progress/); - expect(logger.warn).toHaveBeenCalledTimes(1); + expect(logger.warn as jest.Mock).toHaveBeenCalledTimes(1); // Complete first run markCompleted(projectId); @@ -121,10 +133,10 @@ describe("duplicate-detection (cron concurrency guard)", () => { tryBeginUpdate("project-1"); tryBeginUpdate("project-1"); - expect(logger.warn).toHaveBeenCalledWith( + expect(logger.warn as jest.Mock).toHaveBeenCalledWith( expect.stringContaining("[duplicate-detection] Skipping update for project-1:"), ); - expect(logger.warn).toHaveBeenCalledWith( + expect(logger.warn as jest.Mock).toHaveBeenCalledWith( expect.stringContaining("Update already in progress since"), ); }); diff --git a/src/__tests__/error-response-consistency.test.ts b/src/__tests__/error-response-consistency.test.ts index 1391992..309d299 100644 --- a/src/__tests__/error-response-consistency.test.ts +++ b/src/__tests__/error-response-consistency.test.ts @@ -15,6 +15,9 @@ jest.mock("../lib/registry", () => ({ } }, })); +// Note: the IoT router is deliberately NOT mocked — this suite verifies the +// real validation path returns structured errors, which an auto-mock router +// would bypass (requests would fall through to the error handler). jest.mock("../lib/scoring"); jest.mock("../config", () => ({ config: { diff --git a/src/__tests__/integration.test.ts b/src/__tests__/integration.test.ts index 962deb6..0493b95 100644 --- a/src/__tests__/integration.test.ts +++ b/src/__tests__/integration.test.ts @@ -37,6 +37,7 @@ jest.mock("../config", () => ({ TX_MAX_RETRIES: 4, TX_RETRY_BASE_DELAY_MS: 200, TX_RETRY_MAX_DELAY_MS: 10000, + MAX_POWER_KW: 1000, CRON_TIMEZONE: "UTC", CRON_FAILURE_THRESHOLD: 0.5, SHUTDOWN_TIMEOUT_MS: 30000, @@ -49,7 +50,6 @@ jest.mock("../config", () => ({ RATE_LIMIT_WINDOW_MS: 60000, RATE_LIMIT_MAX: 100, RATE_LIMIT_ADMIN_WINDOW_MS: 60000, - MAX_POWER_KW: 1000, RATE_LIMIT_ADMIN_MAX: 20, ADMIN_IP_WHITELIST: "", ADMIN_IP_WHITELIST_BYPASS_PRIVATE: "true", diff --git a/src/__tests__/portfolio.test.ts b/src/__tests__/portfolio.test.ts new file mode 100644 index 0000000..23dc4fa --- /dev/null +++ b/src/__tests__/portfolio.test.ts @@ -0,0 +1,91 @@ +import request from "supertest"; +import express from "express"; +import portfolioRouter from "../routes/portfolio"; +import { indexer } from "../lib/indexer"; + +const app = express(); +app.use("/api/portfolio", portfolioRouter); + +const ADDRESS = "0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; +const OTHER_ADDRESS = "0xbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"; + +beforeAll(() => { + // Seed the singleton indexer so the endpoint has events to value. + indexer.addEvent({ + id: "portfolio-test-deposit-1", + type: "deposit", + address: ADDRESS, + amount: 500, + shares: 42, + timestamp: 1718150400000, + ledger: 1, + txHash: "testtxhash1", + }); + indexer.addEvent({ + id: "portfolio-test-deposit-2", + type: "deposit", + address: ADDRESS, + amount: 250, + shares: 8, + timestamp: 1718150401000, + ledger: 2, + txHash: "testtxhash2", + }); + // Same share count (42 + 8 = 50) as ADDRESS so the address-keyed price is + // the only variable when comparing the two portfolios. + indexer.addEvent({ + id: "portfolio-test-deposit-other-1", + type: "deposit", + address: OTHER_ADDRESS, + amount: 275, + shares: 42, + timestamp: 1718150402000, + ledger: 3, + txHash: "testtxhash3", + }); + indexer.addEvent({ + id: "portfolio-test-deposit-other-2", + type: "deposit", + address: OTHER_ADDRESS, + amount: 275, + shares: 8, + timestamp: 1718150403000, + ledger: 4, + txHash: "testtxhash4", + }); +}); + +describe("GET /api/portfolio/:address — deterministic pricing", () => { + it("returns 200 with correct fields", async () => { + const res = await request(app).get(`/api/portfolio/${ADDRESS}`).expect(200); + expect(res.body).toHaveProperty("address", ADDRESS); + expect(res.body).toHaveProperty("current_shares"); + expect(res.body).toHaveProperty("current_value"); + expect(res.body).toHaveProperty("events"); + }); + + it("returns the same current_value across requests within the same hour", async () => { + const first = await request(app).get(`/api/portfolio/${ADDRESS}`).expect(200); + const second = await request(app).get(`/api/portfolio/${ADDRESS}`).expect(200); + + expect(first.body.current_shares).toBe(50); + expect(second.body.current_value).toBe(first.body.current_value); + }); + + it("keeps current_value in the documented 1.5x–2.0x range of current_shares", async () => { + const res = await request(app).get(`/api/portfolio/${ADDRESS}`).expect(200); + const ratio = res.body.current_value / res.body.current_shares; + expect(ratio).toBeGreaterThanOrEqual(1.5); + expect(ratio).toBeLessThanOrEqual(2.0); + }); + + it("varies current_value by address for equal share counts", async () => { + const a = await request(app).get(`/api/portfolio/${ADDRESS}`).expect(200); + const b = await request(app).get(`/api/portfolio/${OTHER_ADDRESS}`).expect(200); + + expect(a.body.current_shares).toBe(50); + expect(b.body.current_shares).toBe(50); + // Same shares, different address seed: prices must not collapse to one value. + expect(b.body.current_value).not.toBe(a.body.current_value); + }); +}); diff --git a/src/__tests__/process-exit-codes.test.ts b/src/__tests__/process-exit-codes.test.ts index ca01e9d..dc9fe98 100644 --- a/src/__tests__/process-exit-codes.test.ts +++ b/src/__tests__/process-exit-codes.test.ts @@ -1,5 +1,5 @@ import { spawnSync } from "child_process"; -import http from "http"; +import { createServer } from "http"; import path from "path"; const repoRoot = path.resolve(__dirname, "../.."); @@ -12,7 +12,7 @@ describe("process exit codes", () => { PROJECT_REGISTRY_CONTRACT_ID: "", PORT: "0", }, - ["-e", "require('./src/config').validateRequiredEnv();"], + ["-e", "require('ts-node/register'); require('./src/config').validateRequiredEnv();"], ); expect(result.status).toBe(1); @@ -22,7 +22,7 @@ describe("process exit codes", () => { it("exits with code 1 when the port is already in use", () => { const port = 41000 + Math.floor(Math.random() * 1000); - const firstServer = http.createServer(); + const firstServer = createServer(); firstServer.listen(port); try { @@ -32,12 +32,10 @@ describe("process exit codes", () => { PROJECT_REGISTRY_CONTRACT_ID: "x", PORT: String(port), }, - [ - "-e", - "const http = require('http'); const server = http.createServer(); server.on('error', () => process.exit(1)); server.listen(process.env.PORT);", - ], + ["-e", "require('ts-node/register'); require('./src/index')"], ); expect(result.status).toBe(1); + expect(result.stderr + result.stdout).toContain("already in use"); } finally { firstServer.close(); } @@ -75,7 +73,7 @@ describe("process exit codes", () => { function spawnSyncWithEnv(env: Record, args: string[]) { return spawnSync(process.execPath, ["-r", "ts-node/register", ...args], { cwd: repoRoot, - env: { ...process.env, ...env }, + env: { ...process.env, TS_NODE_TRANSPILE_ONLY: "true", ...env }, encoding: "utf8", }); } diff --git a/src/__tests__/prometheus-metrics.test.ts b/src/__tests__/prometheus-metrics.test.ts index 0c5eb53..6484db1 100644 --- a/src/__tests__/prometheus-metrics.test.ts +++ b/src/__tests__/prometheus-metrics.test.ts @@ -1,7 +1,8 @@ import request from "supertest"; import express from "express"; import { recordRequest, getMetrics } from "../lib/metrics"; -import { recordCronRun } from "../lib/health"; +import { recordCronRun, getHealth } from "../lib/health"; +import { getRpcStatus } from "../lib/stellar"; jest.mock("../lib/stellar", () => ({ rpcPool: { @@ -132,7 +133,6 @@ describe("metrics collection (#283)", () => { describe("cron job metrics via health (#283 cron_job_duration_seconds analogue)", () => { it("cron runs are recorded in the health report", async () => { - const { getHealth } = await import("../lib/health"); recordCronRun("score-update", "success"); const health = await getHealth(); expect(health.last_cron_run).toMatchObject({ @@ -143,7 +143,6 @@ describe("metrics collection (#283)", () => { }); it("cron error status is captured", async () => { - const { getHealth } = await import("../lib/health"); recordCronRun("indexer", "error"); const health = await getHealth(); expect(health.last_cron_run).toMatchObject({ status: "error" }); @@ -151,8 +150,7 @@ describe("metrics collection (#283)", () => { }); describe("Stellar RPC metrics", () => { - it("getRpcStatus returns numeric consecutive failures", async () => { - const { getRpcStatus } = await import("../lib/stellar"); + it("getRpcStatus returns numeric consecutive failures", () => { const status = getRpcStatus(); expect(typeof status.consecutiveFailures).toBe("number"); expect(typeof status.outageDurationMs).toBe("number"); @@ -160,7 +158,6 @@ describe("metrics collection (#283)", () => { }); it("health report includes rpc_status with stellar fields", async () => { - const { getHealth } = await import("../lib/health"); const health = await getHealth(); expect(health.rpc_status).toHaveProperty("consecutiveFailures"); expect(health.rpc_status).toHaveProperty("outageDurationMs"); diff --git a/src/__tests__/rate-limit-scenarios.test.ts b/src/__tests__/rate-limit-scenarios.test.ts index 574c37d..6b70045 100644 --- a/src/__tests__/rate-limit-scenarios.test.ts +++ b/src/__tests__/rate-limit-scenarios.test.ts @@ -23,6 +23,7 @@ describe("rate limiting scenarios", () => { await request(app).get("/ping").expect(200); } const res = await request(app).get("/ping").expect(429); + // Matches the documented error contract: { error: { code, message } }. expect(res.body).toEqual({ error: { code: "too_many_requests", diff --git a/src/__tests__/registry.test.ts b/src/__tests__/registry.test.ts index 78e67c9..93a6ae4 100644 --- a/src/__tests__/registry.test.ts +++ b/src/__tests__/registry.test.ts @@ -45,6 +45,12 @@ jest.mock("../lib/stellar", () => ({ publicKey: () => "GPUBKEY", }), signAndSubmit: jest.fn().mockResolvedValue("tx_hash_abc123"), + RpcDegradedError: class RpcDegradedError extends Error { + constructor(message?: string) { + super(message ?? "RPC is degraded"); + this.name = "RpcDegradedError"; + } + }, })); jest.mock("../config", () => ({ diff --git a/src/__tests__/routes.test.ts b/src/__tests__/routes.test.ts index d9e83fc..4181e29 100644 --- a/src/__tests__/routes.test.ts +++ b/src/__tests__/routes.test.ts @@ -3,6 +3,7 @@ import express, { Express } from "express"; import iotRouter from "../routes/iot"; import adminRouter from "../routes/admin"; import { getHealth } from "../lib/health"; +import { resetIdempotencyState } from "../lib/scoreService"; import { errorHandler, notFoundHandler } from "../middleware/errors"; import * as registry from "../lib/registry"; @@ -12,7 +13,6 @@ jest.mock("../lib/registry", () => ({ updateImpactScore: jest.fn(), getTotalProjects: jest.fn(), })); - // config snapshots env vars at import time, so setting process.env later has no // effect on the middleware; keep the real config (iot needs MAX_POWER_KW etc.) // and only override the admin key. @@ -42,6 +42,7 @@ describe("HTTP integration", () => { process.env.ADMIN_API_KEY = ADMIN_API_KEY; app = buildApp(); jest.clearAllMocks(); + resetIdempotencyState(); (registry.updateImpactScore as jest.Mock).mockResolvedValue("tx-hash"); (registry.getTotalProjects as jest.Mock).mockResolvedValue(2); }); @@ -106,13 +107,13 @@ describe("HTTP integration", () => { it("returns 500 when ADMIN_API_KEY is not configured", async () => { const configModule = jest.requireMock("../config") as { config: { ADMIN_API_KEY: string } }; - const orig = configModule.config.ADMIN_API_KEY; + const original = configModule.config.ADMIN_API_KEY; configModule.config.ADMIN_API_KEY = ""; try { const res = await request(app).post("/api/admin/update-scores").send({}).expect(500); expect(res.body.error.code).toBe("server_misconfigured"); } finally { - configModule.config.ADMIN_API_KEY = orig; + configModule.config.ADMIN_API_KEY = original; } }); }); diff --git a/src/__tests__/sast-scanning.test.ts b/src/__tests__/sast-scanning.test.ts index dfc2ba2..6dca95c 100644 --- a/src/__tests__/sast-scanning.test.ts +++ b/src/__tests__/sast-scanning.test.ts @@ -64,20 +64,24 @@ describe("SAST Scanning Configuration (Issue #285)", () => { }); describe("CI Workflow SAST Integration", () => { - const ciWorkflowPath = path.join(__dirname, "../../.github/workflows/ci.yml"); + const auditWorkflowPath = path.join(__dirname, "../../.github/workflows/security-audit.yml"); it("CI workflow exists", () => { - expect(fs.existsSync(ciWorkflowPath)).toBe(true); + expect(fs.existsSync(auditWorkflowPath)).toBe(true); }); - it("CI includes dependency audit job or step", () => { - const content = fs.readFileSync(ciWorkflowPath, "utf-8"); - expect(content).toMatch(/audit|Audit/); + it("CI includes dependency audit job", () => { + const content = fs.readFileSync(auditWorkflowPath, "utf-8"); + const workflow = yaml.parse(content); + + expect(workflow.jobs).toHaveProperty("dependency-audit"); }); - it("CI includes audit scan command", () => { - const content = fs.readFileSync(ciWorkflowPath, "utf-8"); - expect(content).toMatch(/audit/i); + it("CI fails on high or critical vulnerabilities", () => { + const content = fs.readFileSync(auditWorkflowPath, "utf-8"); + + expect(content).toMatch(/bun audit/); + expect(content).toContain("Found $CRITICAL critical vulnerabilities"); }); }); diff --git a/src/__tests__/scoreUpdateCron.test.ts b/src/__tests__/scoreUpdateCron.test.ts index 1bdb80e..754e344 100644 --- a/src/__tests__/scoreUpdateCron.test.ts +++ b/src/__tests__/scoreUpdateCron.test.ts @@ -75,20 +75,21 @@ jest.mock("../config", () => ({ })); import { runHourlyScoreUpdate } from "../lib/scoreUpdateCron"; +import { resetIdempotencyState } from "../lib/scoreService"; import { getTotalProjects, updateImpactScore, RpcDegradedError } from "../lib/registry"; import { getSolarData } from "../lib/iot"; import { fetchSatelliteWithFallback } from "../lib/satellite-sources"; import { computeScores } from "../lib/scoring"; import { recordCronRun } from "../lib/health"; import { markFailed } from "../lib/duplicate-detection"; -import { resetIdempotencyState } from "../lib/scoreService"; -import { clearIdempotencyStore } from "../lib/idempotency"; describe("runHourlyScoreUpdate (cron job execution flow)", () => { beforeEach(() => { + // scoreService.updateScoreForProject is left real, so its module-level + // idempotency map must be cleared between runs or later tests get rejected + // as duplicates of earlier ones in the same file. resetIdempotencyState(); jest.clearAllMocks(); - clearIdempotencyStore(); // prevent key bleed between tests (getSolarData as jest.Mock).mockReturnValue({ efficiency_pct: 85, power_output_kw: 500, diff --git a/src/__tests__/timing-safe.test.ts b/src/__tests__/timing-safe.test.ts index bc4bf26..300f4f7 100644 --- a/src/__tests__/timing-safe.test.ts +++ b/src/__tests__/timing-safe.test.ts @@ -43,6 +43,18 @@ describe("timingSafeCompare (#209)", () => { expect(timingSafeCompare("clé-secrète", "cle-secrete")).toBe(false); }); + it("does not let zero padding make distinct values compare equal", () => { + expect(timingSafeCompare("abc", "abc\u0000")).toBe(false); + expect(timingSafeCompare("abc\u0000", "abc")).toBe(false); + }); + + it("compares values longer than the fixed window without truncating them", () => { + const long = "k".repeat(600); // longer than the 512-byte comparison window + expect(timingSafeCompare(long, long)).toBe(true); + expect(timingSafeCompare(`${long}x`, long)).toBe(false); + expect(timingSafeCompare(long, long.slice(0, -1))).toBe(false); + }); + describe("constant-time guarantees", () => { beforeEach(() => { mockTimingSafeEqual.mockClear(); @@ -64,9 +76,11 @@ describe("timingSafeCompare (#209)", () => { timingSafeCompare("x", "a-considerably-longer-secret-value"); expect(mockTimingSafeEqual).toHaveBeenCalledTimes(1); + // Both operands are padded to the fixed 512-byte window no matter how + // long the inputs actually are. const [a, b] = mockTimingSafeEqual.mock.calls[0] as [Buffer, Buffer]; - expect(a).toHaveLength(32); - expect(b).toHaveLength(32); + expect(a).toHaveLength(512); + expect(b).toHaveLength(512); }); }); }); diff --git a/src/config.ts b/src/config.ts index 8d5e993..c8f1960 100644 --- a/src/config.ts +++ b/src/config.ts @@ -70,96 +70,100 @@ function networkEnv(name: string, fallback: StellarNetwork): StellarNetwork { return isStellarNetwork(raw) ? raw : fallback; } -export const config = { - /** Stellar / Soroban */ - STELLAR_NETWORK: networkEnv("STELLAR_NETWORK", "testnet"), - ADMIN_SECRET_KEY: process.env.ADMIN_SECRET_KEY || "", - PROJECT_REGISTRY_CONTRACT_ID: process.env.PROJECT_REGISTRY_CONTRACT_ID || "", - RPC_URL: optionalEnv("RPC_URL", "https://soroban-testnet.stellar.org"), - - /** HTTP server */ - PORT: numEnv("PORT", 3001), - FRONTEND_URL: optionalEnv("FRONTEND_URL", "http://localhost:3000"), - ADMIN_API_KEY: process.env.ADMIN_API_KEY || "", - WS_AUTH_TOKEN: process.env.WS_AUTH_TOKEN || "", - - /** Database connection */ - DB_HOST: optionalEnv("DB_HOST", "localhost"), - DB_PORT: numEnv("DB_PORT", 5432), - DB_NAME: optionalEnv("DB_NAME", ""), - DB_USER: optionalEnv("DB_USER", "postgres"), - DB_PASSWORD: optionalEnv("DB_PASSWORD", ""), - - /** Connection pool */ - DB_POOL_MIN: numEnv("DB_POOL_MIN", 2), - DB_POOL_MAX: numEnv("DB_POOL_MAX", 10), - DB_POOL_ACQUIRE_TIMEOUT_MS: numEnv("DB_POOL_ACQUIRE_TIMEOUT_MS", 5000), - DB_POOL_HEALTH_CHECK_INTERVAL_MS: numEnv("DB_POOL_HEALTH_CHECK_INTERVAL_MS", 30000), - - /** Circuit breaker */ - RPC_BREAKER_FAILURE_THRESHOLD: numEnv( - "CIRCUIT_BREAKER_THRESHOLD", - numEnv("RPC_BREAKER_FAILURE_THRESHOLD", 5), - ), - RPC_BREAKER_RECOVERY_TIMEOUT_MS: numEnv( - "CIRCUIT_BREAKER_COOLDOWN_MS", - numEnv("RPC_BREAKER_RECOVERY_TIMEOUT_MS", 30000), - ), - - /** Transaction retries */ - TX_MAX_RETRIES: numEnv("TX_MAX_RETRIES", 4), - TX_RETRY_BASE_DELAY_MS: numEnv("TX_RETRY_BASE_DELAY_MS", 200), - TX_RETRY_MAX_DELAY_MS: numEnv("TX_RETRY_MAX_DELAY_MS", 10000), - - /** Stellar transaction polling */ - POLL_INTERVAL_MS: numEnv("POLL_INTERVAL_MS", 1500), - POLL_MAX_ATTEMPTS: numEnv("POLL_MAX_ATTEMPTS", 20), - - /** Stellar transaction timeout (seconds) */ - TX_TIMEOUT_SECONDS: numEnv("TX_TIMEOUT_SECONDS", 30), - - /** IoT max power output (kW) */ - MAX_POWER_KW: numEnv("MAX_POWER_KW", 1000), - - /** Idempotency */ - IDEMPOTENCY_TTL_MS: numEnv("IDEMPOTENCY_TTL_MS", 3_600_000), - - /** Cron */ - CRON_TIMEZONE: optionalEnv("CRON_TIMEZONE", "UTC"), - CRON_FAILURE_THRESHOLD: floatEnv("CRON_FAILURE_THRESHOLD", 0.5), - - /** Graceful shutdown */ - SHUTDOWN_TIMEOUT_MS: numEnv("SHUTDOWN_TIMEOUT_MS", 30000), - - /** Logging */ - LOG_LEVEL: optionalEnv("LOG_LEVEL", ""), - NODE_ENV: optionalEnv("NODE_ENV", "development"), - - /** Rate limiting */ - RATE_LIMIT_WINDOW_MS: numEnv("RATE_LIMIT_WINDOW_MS", 60000), - RATE_LIMIT_MAX: numEnv("RATE_LIMIT_MAX", 100), - RATE_LIMIT_ADMIN_WINDOW_MS: numEnv("RATE_LIMIT_ADMIN_WINDOW_MS", 60000), - RATE_LIMIT_ADMIN_MAX: numEnv("RATE_LIMIT_ADMIN_MAX", 20), - - /** IP Whitelist */ - ADMIN_IP_WHITELIST: optionalEnv("ADMIN_IP_WHITELIST", ""), - ADMIN_IP_WHITELIST_BYPASS_PRIVATE: optionalEnv("ADMIN_IP_WHITELIST_BYPASS_PRIVATE", "true"), - - /** Request Signing */ - REQUEST_SIGNING_SECRET: optionalEnv("REQUEST_SIGNING_SECRET", ""), - - /** APM */ - APM_PROVIDER: optionalEnv("APM_PROVIDER", "none"), - - /** CSRF */ - CORS_ORIGINS: optionalEnv("CORS_ORIGINS", ""), - - /** Body size limit */ - BODY_SIZE_LIMIT: optionalEnv("BODY_SIZE_LIMIT", "100kb"), - - /** Secrets Management */ - SECRETS_PROVIDER: optionalEnv("SECRETS_PROVIDER", "env"), -} as const; +function buildConfig() { + return { + /** Stellar / Soroban */ + STELLAR_NETWORK: networkEnv("STELLAR_NETWORK", "testnet"), + ADMIN_SECRET_KEY: process.env.ADMIN_SECRET_KEY || "", + PROJECT_REGISTRY_CONTRACT_ID: process.env.PROJECT_REGISTRY_CONTRACT_ID || "", + RPC_URL: optionalEnv("RPC_URL", "https://soroban-testnet.stellar.org"), + + /** HTTP server */ + PORT: numEnv("PORT", 3001), + FRONTEND_URL: optionalEnv("FRONTEND_URL", "http://localhost:3000"), + ADMIN_API_KEY: process.env.ADMIN_API_KEY || "", + WS_AUTH_TOKEN: process.env.WS_AUTH_TOKEN || "", + + /** Database connection */ + DB_HOST: optionalEnv("DB_HOST", "localhost"), + DB_PORT: numEnv("DB_PORT", 5432), + DB_NAME: optionalEnv("DB_NAME", ""), + DB_USER: optionalEnv("DB_USER", "postgres"), + DB_PASSWORD: optionalEnv("DB_PASSWORD", ""), + + /** Connection pool */ + DB_POOL_MIN: numEnv("DB_POOL_MIN", 2), + DB_POOL_MAX: numEnv("DB_POOL_MAX", 10), + DB_POOL_ACQUIRE_TIMEOUT_MS: numEnv("DB_POOL_ACQUIRE_TIMEOUT_MS", 5000), + DB_POOL_HEALTH_CHECK_INTERVAL_MS: numEnv("DB_POOL_HEALTH_CHECK_INTERVAL_MS", 30000), + + /** Circuit breaker */ + RPC_BREAKER_FAILURE_THRESHOLD: numEnv( + "CIRCUIT_BREAKER_THRESHOLD", + numEnv("RPC_BREAKER_FAILURE_THRESHOLD", 5), + ), + RPC_BREAKER_RECOVERY_TIMEOUT_MS: numEnv( + "CIRCUIT_BREAKER_COOLDOWN_MS", + numEnv("RPC_BREAKER_RECOVERY_TIMEOUT_MS", 30000), + ), + + /** Transaction retries */ + TX_MAX_RETRIES: numEnv("TX_MAX_RETRIES", 4), + TX_RETRY_BASE_DELAY_MS: numEnv("TX_RETRY_BASE_DELAY_MS", 200), + TX_RETRY_MAX_DELAY_MS: numEnv("TX_RETRY_MAX_DELAY_MS", 10000), + + /** Stellar transaction polling */ + POLL_INTERVAL_MS: numEnv("POLL_INTERVAL_MS", 1500), + POLL_MAX_ATTEMPTS: numEnv("POLL_MAX_ATTEMPTS", 20), + + /** Stellar transaction timeout (seconds) */ + TX_TIMEOUT_SECONDS: numEnv("TX_TIMEOUT_SECONDS", 30), + + /** IoT max power output (kW) */ + MAX_POWER_KW: numEnv("MAX_POWER_KW", 1000), + + /** Idempotency */ + IDEMPOTENCY_TTL_MS: numEnv("IDEMPOTENCY_TTL_MS", 3_600_000), + + /** Cron */ + CRON_TIMEZONE: optionalEnv("CRON_TIMEZONE", "UTC"), + CRON_FAILURE_THRESHOLD: floatEnv("CRON_FAILURE_THRESHOLD", 0.5), + + /** Graceful shutdown */ + SHUTDOWN_TIMEOUT_MS: numEnv("SHUTDOWN_TIMEOUT_MS", 30000), + + /** Logging */ + LOG_LEVEL: optionalEnv("LOG_LEVEL", ""), + NODE_ENV: optionalEnv("NODE_ENV", "development"), + + /** Rate limiting */ + RATE_LIMIT_WINDOW_MS: numEnv("RATE_LIMIT_WINDOW_MS", 60000), + RATE_LIMIT_MAX: numEnv("RATE_LIMIT_MAX", 100), + RATE_LIMIT_ADMIN_WINDOW_MS: numEnv("RATE_LIMIT_ADMIN_WINDOW_MS", 60000), + RATE_LIMIT_ADMIN_MAX: numEnv("RATE_LIMIT_ADMIN_MAX", 20), + + /** IP Whitelist */ + ADMIN_IP_WHITELIST: optionalEnv("ADMIN_IP_WHITELIST", ""), + ADMIN_IP_WHITELIST_BYPASS_PRIVATE: optionalEnv("ADMIN_IP_WHITELIST_BYPASS_PRIVATE", "true"), + + /** Request Signing */ + REQUEST_SIGNING_SECRET: optionalEnv("REQUEST_SIGNING_SECRET", ""), + + /** APM */ + APM_PROVIDER: optionalEnv("APM_PROVIDER", "none"), + + /** CSRF */ + CORS_ORIGINS: optionalEnv("CORS_ORIGINS", ""), + + /** Body size limit */ + BODY_SIZE_LIMIT: optionalEnv("BODY_SIZE_LIMIT", "100kb"), + + /** Secrets Management */ + SECRETS_PROVIDER: optionalEnv("SECRETS_PROVIDER", "env"), + } as const; +} + +export const config = buildConfig(); /** * The shape of the resolved application configuration. Exported so consumers @@ -183,7 +187,9 @@ export function validateRequiredEnv(): void { /** * Backward-compatible initializer used by src/index.ts. - * Loads dotenv, validates required vars, and returns the config object. + * Loads dotenv, validates required vars, and returns a freshly resolved + * config so callers see the current process.env rather than the snapshot + * captured when the module was imported. */ export function initEnv() { validateRequiredEnv(); @@ -193,10 +199,5 @@ export function initEnv() { const { loadApiKeysFromEnv } = require("./lib/apiKeyRoles"); loadApiKeysFromEnv(); - return { - ...config, - ADMIN_SECRET_KEY: process.env.ADMIN_SECRET_KEY || config.ADMIN_SECRET_KEY, - PROJECT_REGISTRY_CONTRACT_ID: - process.env.PROJECT_REGISTRY_CONTRACT_ID || config.PROJECT_REGISTRY_CONTRACT_ID, - }; + return buildConfig(); } diff --git a/src/index.ts b/src/index.ts index f258c1e..a2a081f 100644 --- a/src/index.ts +++ b/src/index.ts @@ -139,7 +139,10 @@ function requestTimeout(timeoutMs: number) { // - a CIDR or IP — trust specific proxy IP(s) // - a number N — trust the first N hops in X-Forwarded-For const trustProxy = process.env.TRUST_PROXY || "false"; -app.set("trust proxy", trustProxy === "true" ? true : trustProxy); +// Express accepts `true`, `false`, a hop count, or an IP/CIDR list here. The +// literal string "false" is not a valid value — proxy-addr throws on it — so +// map the documented disabled value onto the boolean it stands for. +app.set("trust proxy", trustProxy === "true" ? true : trustProxy === "false" ? false : trustProxy); // Validate CORS origin function validateCorsOrigin(origin: string | undefined): string | undefined { @@ -365,6 +368,20 @@ app.use(notFoundHandler); app.use(errorHandler); // ── Cron: index contract events every 5 minutes ────────────────────────────── +// `cronTasks` and `scheduleCron` are declared here (not at the bottom of the +// file) because the first scheduleCron call below pushes into the array — a +// const declared later would still be in its temporal dead zone here. +const cronTasks: ScheduledTask[] = []; + +function scheduleCron( + expression: string, + fn: () => void | Promise, + opts?: { timezone?: string }, +): void { + const task = cron.schedule(expression, fn, opts); + cronTasks.push(task); +} + scheduleCron( "*/5 * * * *", async () => { @@ -606,18 +623,6 @@ const grpcServer = startGrpcServer(50051); startSecretRotation(); // ── Graceful shutdown (#57) ────────────────────────────────────────────────── -// Track all scheduled cron tasks so we can stop them cleanly. -const cronTasks: ScheduledTask[] = []; - -function scheduleCron( - expression: string, - fn: () => void | Promise, - opts?: { timezone?: string }, -): void { - const task = cron.schedule(expression, fn, opts); - cronTasks.push(task); -} - let isShuttingDown = false; async function gracefulShutdown(signal: string): Promise { diff --git a/src/lib/registry.ts b/src/lib/registry.ts index df610b6..92104ea 100644 --- a/src/lib/registry.ts +++ b/src/lib/registry.ts @@ -4,12 +4,24 @@ import { nativeToScVal, BASE_FEE, scValToNative, + rpc, Account, } from "@stellar/stellar-sdk"; -import { withRpcConnection, networkPassphrase, getAdminKeypair, signAndSubmit } from "./stellar"; +import { + withRpcConnection, + networkPassphrase, + getAdminKeypair, + signAndSubmit, + RpcDegradedError, +} from "./stellar"; import { config } from "../config"; import { stellarRpcDuration, stellarRpcTotal } from "./prometheus"; +// Re-export so callers (scoreService, routes/batch) can `instanceof`-check the +// exact error class the RPC layer throws, instead of comparing against a +// sibling class that `instanceof` can never match. +export { RpcDegradedError }; + if (!config.PROJECT_REGISTRY_CONTRACT_ID) { throw new Error("PROJECT_REGISTRY_CONTRACT_ID env var is required"); } @@ -80,32 +92,34 @@ export async function getTotalProjects(): Promise { .build(); const end = stellarRpcDuration.startTimer({ operation: "simulateTransaction" }); + + let sim: rpc.Api.SimulateTransactionResponse; try { - const result = (await client.simulateTransaction(tx)) as { - error?: unknown; - result?: { retval?: unknown }; - }; - if (result.error) { - throw new Error(String(result.error)); - } - const retval = result.result?.retval; - if (retval === undefined) { - throw new Error("total_projects simulation returned no result value"); - } - end(); - stellarRpcTotal.inc({ operation: "simulateTransaction", result: "success" }); - return Number(scValToNative(retval as any)); + sim = await client.simulateTransaction(tx); } catch (err) { end(); stellarRpcTotal.inc({ operation: "simulateTransaction", result: "failure" }); throw err; } - }); -} -export class RpcDegradedError extends Error { - constructor(message: string) { - super(message); - this.name = "RpcDegradedError"; - } + // A failed simulation carries a string `error` field; the success variants + // do not. The `in` check narrows the union instead of relying on an `as` + // cast or a non-null assertion (see #228). + if ("error" in sim) { + end(); + stellarRpcTotal.inc({ operation: "simulateTransaction", result: "failure" }); + throw new Error(sim.error); + } + + const retval = sim.result?.retval; + if (retval === undefined) { + end(); + stellarRpcTotal.inc({ operation: "simulateTransaction", result: "failure" }); + throw new Error("total_projects simulation returned no result value"); + } + + end(); + stellarRpcTotal.inc({ operation: "simulateTransaction", result: "success" }); + return Number(scValToNative(retval)); + }); } diff --git a/src/lib/timing-safe.ts b/src/lib/timing-safe.ts index d20fcad..670c11e 100644 --- a/src/lib/timing-safe.ts +++ b/src/lib/timing-safe.ts @@ -1,16 +1,50 @@ -import { createHash, timingSafeEqual } from "crypto"; +import { timingSafeEqual } from "crypto"; + +/** + * Size in bytes of the fixed comparison window. Credentials compared through + * this helper (bearer tokens, API keys, CSRF tokens) are all far smaller than + * this, so both operands are normally padded to exactly this size and the work + * `timingSafeEqual` performs is a compile-time constant: neither the contents + * nor the length of either input is observable. A value larger than the window + * falls back to the longer of the two lengths, which stays constant-time but + * does reveal the order of magnitude of the longer input. + */ +const COMPARE_WINDOW_BYTES = 512; + +/** Left-aligned copy of `value` in a zero-filled buffer of exactly `size` bytes. */ +function padTo(value: Buffer, size: number): Buffer { + const padded = Buffer.alloc(size); + value.copy(padded, 0, 0, Math.min(value.length, size)); + return padded; +} /** * Constant-time string comparison. * * A plain `a === b` short-circuits on the first differing byte, so the time it * takes to reject a value leaks how many leading characters were correct — enough - * for an attacker to recover a secret byte-by-byte. Both inputs are hashed to a - * fixed 32-byte digest first so `timingSafeEqual` always compares equal-length - * buffers and the comparison never leaks the length of either input either. + * for an attacker to recover a secret byte-by-byte. Both inputs are copied into + * equal-length buffers first so `timingSafeEqual` always compares equal-length + * buffers, never short-circuits, and no secret-dependent branch is taken. + * + * The padding here replaces an earlier implementation that ran both inputs + * through `createHash("sha256")` purely to give them a common length. The digest + * was only ever a length normaliser, never a stored password hash, but hashing + * an API key with a fast general-purpose digest is indistinguishable from an + * insecure password hash to automated analysis (CodeQL + * js/insufficient-password-hash), so the normalisation is done without hashing. */ export function timingSafeCompare(a: string, b: string): boolean { - const digestA = createHash("sha256").update(a, "utf8").digest(); - const digestB = createHash("sha256").update(b, "utf8").digest(); - return timingSafeEqual(digestA, digestB); + const bufA = Buffer.from(a, "utf8"); + const bufB = Buffer.from(b, "utf8"); + const window = Math.max(bufA.length, bufB.length, COMPARE_WINDOW_BYTES); + + // Executed unconditionally, ahead of the length check, so the secret-dependent + // work always happens. + const equalBytes = timingSafeEqual(padTo(bufA, window), padTo(bufB, window)); + // Zero padding would otherwise let "abc" collide with "abc\u0000"; folding the + // lengths in rules that out. + const sameLength = bufA.length === bufB.length; + + return equalBytes && sameLength; } diff --git a/src/middleware/errors.ts b/src/middleware/errors.ts index d1c1989..3f55b1c 100644 --- a/src/middleware/errors.ts +++ b/src/middleware/errors.ts @@ -36,16 +36,19 @@ export function badRequest(message: string): ApiError { return new ApiError(400, "bad_request", message); } +/** Hard upper bound on project ids, used when MAX_PROJECT_ID is unset or invalid. */ export const MAX_PROJECT_ID = 100_000; export const DEFAULT_MAX_PROJECT_ID = MAX_PROJECT_ID; +/** + * Upper bound on project ids. `MAX_PROJECT_ID` can be raised/lowered per + * deployment; anything unset, non-integer or below 1 falls back to the default. + */ export function maxProjectId(): number { - const envVal = process.env.MAX_PROJECT_ID; - if (envVal) { - const parsed = parseInt(envVal, 10); - if (!Number.isNaN(parsed) && parsed > 0) return parsed; - } - return MAX_PROJECT_ID; + const raw = process.env.MAX_PROJECT_ID; + if (raw === undefined || raw === "") return DEFAULT_MAX_PROJECT_ID; + const parsed = Number(raw); + return Number.isInteger(parsed) && parsed >= 1 ? parsed : DEFAULT_MAX_PROJECT_ID; } /** diff --git a/src/routes/admin.ts b/src/routes/admin.ts index 205369d..2327caf 100644 --- a/src/routes/admin.ts +++ b/src/routes/admin.ts @@ -1,11 +1,11 @@ import { Router, Request, Response, NextFunction } from "express"; import { getTotalProjects } from "../lib/registry"; -import { updateScoreForProject } from "../lib/scoreService"; -import { badRequest, errorBody, parseOptionalInt, MAX_PROJECT_ID } from "../middleware/errors"; +import { badRequest, errorBody, parseOptionalInt, maxProjectId } from "../middleware/errors"; import { recordAudit, getAuditLog, auditToCsv } from "../lib/audit"; import { broadcastScoreUpdate } from "../lib/websocket"; import { tryBeginUpdate, markCompleted, markFailed } from "../lib/duplicate-detection"; import { withProjectLock } from "../lib/request-queue"; +import { updateScoreForProject } from "../lib/scoreService"; import { config } from "../config"; import { logger } from "../lib/logger"; import { timingSafeCompare } from "../lib/timing-safe"; @@ -116,18 +116,16 @@ function parseProjectIds(body: unknown): number[] | null { if (raw.length === 0) return null; const projectIds: number[] = []; + const max = maxProjectId(); for (const entry of raw) { if (!isPositiveInteger(entry)) { throw badRequest("project_ids must contain only positive integers"); } - if (entry > MAX_PROJECT_ID) { - throw badRequest(`project_ids must not exceed maximum project id ${MAX_PROJECT_ID}`); + if (entry > max) { + throw badRequest(`project_ids must not exceed maximum project id ${max}`); } projectIds.push(entry); } - if (!raw.every((n) => (n as number) <= MAX_PROJECT_ID)) { - throw badRequest(`project_ids must not exceed maximum project id ${MAX_PROJECT_ID}`); - } return projectIds; } diff --git a/src/routes/anomaly.ts b/src/routes/anomaly.ts index 85e36cd..74376c5 100644 --- a/src/routes/anomaly.ts +++ b/src/routes/anomaly.ts @@ -87,26 +87,23 @@ router.put("/config", (req: Request, res: Response) => { }); /** - * DELETE /v1/anomaly/history - * Clear the baseline history for all projects. + * DELETE /v1/anomaly/history and DELETE /v1/anomaly/history/:id + * Clear the baseline history for a specific project (or all projects). + * Two explicit routes because path-to-regexp v8 (Express 5) dropped the `?` + * suffix that older Express accepted for optional params. */ -router.delete("/history", (_req: Request, res: Response) => { - clearHistory(); - res.json({ ok: true, cleared: "all" }); -}); - -/** - * DELETE /v1/anomaly/history/:id - * Clear the baseline history for a specific project. - */ -router.delete("/history/:id", (req: Request, res: Response, next: NextFunction) => { +const clearAnomalyHistory = (req: Request, res: Response, next: NextFunction) => { try { - const id = parseProjectId(req.params.id, "project id"); + // `/history` has no `:id` param, which means "clear every project". + const id = req.params.id ? parseProjectId(req.params.id, "project id") : undefined; clearHistory(id); - res.json({ ok: true, cleared: id }); + res.json({ ok: true, cleared: id ?? "all" }); } catch (err) { next(err); } -}); +}; + +router.delete("/history", clearAnomalyHistory); +router.delete("/history/:id", clearAnomalyHistory); export default router; diff --git a/src/routes/batch.ts b/src/routes/batch.ts index 39fbe0b..33ba54c 100644 --- a/src/routes/batch.ts +++ b/src/routes/batch.ts @@ -5,9 +5,9 @@ import { triggerWebhooks } from "../lib/webhooks"; import { getSolarData, getSatelliteData } from "./iot"; import { computeScores } from "../lib/scoring"; import { updateImpactScore, getTotalProjects, RpcDegradedError } from "../lib/registry"; -import { badRequest, MAX_PROJECT_ID } from "../middleware/errors"; -import { tryBeginUpdate, markCompleted, markFailed } from "../lib/duplicate-detection"; import { withProjectLock } from "../lib/request-queue"; +import { tryBeginUpdate, markCompleted, markFailed } from "../lib/duplicate-detection"; +import { badRequest, maxProjectId } from "../middleware/errors"; const router = Router(); @@ -28,19 +28,24 @@ router.post("/score-update", async (req: Request, res: Response) => { if (!Array.isArray(body.project_ids)) { throw badRequest("project_ids must be an array of positive integers"); } - if (!body.project_ids.every((n) => Number.isInteger(n) && (n as number) >= 1)) { - throw badRequest("project_ids must contain only positive integers"); + const parsed: number[] = []; + for (const raw of body.project_ids) { + if (typeof raw !== "number" || !Number.isInteger(raw) || raw < 1) { + throw badRequest("project_ids must contain only positive integers"); + } + parsed.push(raw); } - if (!body.project_ids.every((n) => (n as number) <= MAX_PROJECT_ID)) { - throw badRequest(`project_ids must not exceed maximum project id ${MAX_PROJECT_ID}`); + const max = maxProjectId(); + if (!parsed.every((n) => n <= max)) { + throw badRequest(`project_ids must not exceed maximum project id ${max}`); } - projectIds = body.project_ids as number[]; + projectIds = parsed; } else { const total = await getTotalProjects(); projectIds = Array.from({ length: total }, (_, i) => i + 1); } - const rawConcurrency = body.concurrency as number | undefined; + const rawConcurrency = typeof body.concurrency === "number" ? body.concurrency : undefined; const concurrency = rawConcurrency !== undefined ? Math.min(MAX_CONCURRENCY, Math.max(1, Math.floor(rawConcurrency))) diff --git a/src/routes/investor.ts b/src/routes/investor.ts index 50c22c9..af8551e 100644 --- a/src/routes/investor.ts +++ b/src/routes/investor.ts @@ -2,9 +2,13 @@ import { Router, Request, Response, NextFunction } from "express"; import { getTotalProjects } from "../lib/registry"; import { getSolarData, getSatelliteData } from "./iot"; import { computeScores } from "../lib/scoring"; -import { createDefaultFinancialInput, calculateNPV, calculatePaybackPeriod } from "../lib/financial"; +import { + createDefaultFinancialInput, + calculateNPV, + calculatePaybackPeriod, +} from "../lib/financial"; import { getAuditLog } from "../lib/audit"; -import { badRequest, MAX_PROJECT_ID } from "../middleware/errors"; +import { badRequest, maxProjectId } from "../middleware/errors"; const router = Router(); @@ -18,7 +22,7 @@ async function getPortfolioData() { const satellite = getSatelliteData(id); const scores = computeScores({ solar, satellite }); // Deterministic funding based on project ID - const funding = 250000 + (id * 150000) % 600000; + const funding = 250000 + ((id * 150000) % 600000); // Expected output based on capacity factor const expected_output = solar.max_power_kw * 24 * 365 * 0.2; // 20% capacity factor const actual_output = solar.power_output_kw * 24 * 365 * 0.2; @@ -60,7 +64,7 @@ router.get("/dashboard", async (_req: Request, res: Response, next: NextFunction // Formula for carbon offsets: power_output_kw * green_impact * constant factor const totalCarbonOffsets = portfolio.reduce( (acc, p) => acc + p.solar.power_output_kw * p.scores.green_impact * 0.05, - 0 + 0, ); const recentAuditLogs = getAuditLog().slice(-5).reverse(); @@ -123,16 +127,27 @@ router.get("/financial-summary", async (_req: Request, res: Response, next: Next // ROI = Net benefit over lifetime / installation cost const lifetimeYears = input.project_lifetime_years; - const totalBenefits = npvResult.discounted_cash_flows.reduce((acc, cf) => acc + cf.revenue, 0); - const totalOpsCosts = npvResult.discounted_cash_flows.reduce((acc, cf) => acc + cf.maintenance_cost, 0); + const totalBenefits = npvResult.discounted_cash_flows.reduce( + (acc, cf) => acc + cf.revenue, + 0, + ); + const totalOpsCosts = npvResult.discounted_cash_flows.reduce( + (acc, cf) => acc + cf.maintenance_cost, + 0, + ); const netBenefits = totalBenefits - totalOpsCosts + (input.salvage_value ?? 0); - const roi = input.installation_cost > 0 ? (netBenefits - input.installation_cost) / input.installation_cost : 0; + const roi = + input.installation_cost > 0 + ? (netBenefits - input.installation_cost) / input.installation_cost + : 0; return { project_id: p.id, installation_cost: Math.round(input.installation_cost * 100) / 100, npv: Math.round(npvResult.npv * 100) / 100, - payback_period_years: paybackResult.reaches_payback ? Math.round(paybackResult.payback_years * 10) / 10 : null, + payback_period_years: paybackResult.reaches_payback + ? Math.round(paybackResult.payback_years * 10) / 10 + : null, roi_pct: Math.round(roi * 1000) / 10, }; }); @@ -151,9 +166,15 @@ router.get("/financial-summary", async (_req: Request, res: Response, next: Next const totalCost = projectFinancials.reduce((acc, p) => acc + p.installation_cost, 0); const totalNpv = projectFinancials.reduce((acc, p) => acc + p.npv, 0); - const validPaybacks = projectFinancials.filter((p) => p.payback_period_years !== null) as Array; - const avgPayback = validPaybacks.length > 0 ? validPaybacks.reduce((acc, p) => acc + p.payback_period_years, 0) / validPaybacks.length : null; - const avgRoi = projectFinancials.reduce((acc, p) => acc + p.roi_pct, 0) / projectFinancials.length; + const validPaybacks = projectFinancials.filter((p) => p.payback_period_years !== null) as Array< + (typeof projectFinancials)[0] & { payback_period_years: number } + >; + const avgPayback = + validPaybacks.length > 0 + ? validPaybacks.reduce((acc, p) => acc + p.payback_period_years, 0) / validPaybacks.length + : null; + const avgRoi = + projectFinancials.reduce((acc, p) => acc + p.roi_pct, 0) / projectFinancials.length; res.json({ portfolio_financials: { @@ -196,8 +217,11 @@ router.get("/compliance-report", async (_req: Request, res: Response, next: Next }); const totalCredits = reports.reduce((acc, r) => acc + r.carbon_credits_issued, 0); - const avgGreenImpact = portfolio.length > 0 ? portfolio.reduce((acc, p) => acc + p.scores.green_impact, 0) / portfolio.length : 0; - + const avgGreenImpact = + portfolio.length > 0 + ? portfolio.reduce((acc, p) => acc + p.scores.green_impact, 0) / portfolio.length + : 0; + let portfolioStatus: "Compliant" | "Warning" | "Non-Compliant" = "Compliant"; if (avgGreenImpact < 50) { portfolioStatus = "Non-Compliant"; @@ -231,8 +255,9 @@ router.post("/custom-report", async (req: Request, res: Response, next: NextFunc if (!project_ids.every((n) => Number.isInteger(n) && n >= 1)) { throw badRequest("project_ids must contain only positive integers"); } - if (!project_ids.every((n) => (n as number) <= MAX_PROJECT_ID)) { - throw badRequest(`project_ids must not exceed maximum project id ${MAX_PROJECT_ID}`); + const max = maxProjectId(); + if (!project_ids.every((n) => Number.isInteger(n) && n <= max)) { + throw badRequest(`project_ids must not exceed maximum project id ${max}`); } } @@ -288,7 +313,9 @@ router.post("/custom-report", async (req: Request, res: Response, next: NextFunc pReport.financials = { installation_cost: Math.round(input.installation_cost * 100) / 100, npv: Math.round(npvResult.npv * 100) / 100, - payback_period_years: paybackResult.reaches_payback ? Math.round(paybackResult.payback_years * 10) / 10 : null, + payback_period_years: paybackResult.reaches_payback + ? Math.round(paybackResult.payback_years * 10) / 10 + : null, }; } diff --git a/src/routes/portfolio.ts b/src/routes/portfolio.ts index a27cc48..467aeae 100644 --- a/src/routes/portfolio.ts +++ b/src/routes/portfolio.ts @@ -1,6 +1,7 @@ import { Router, Request, Response, NextFunction } from "express"; import { indexer } from "../lib/indexer"; import { badRequest } from "../middleware/errors"; +import { seededRandom } from "../lib/iot"; const router = Router(); @@ -20,6 +21,20 @@ interface PortfolioResponse { events: PortfolioEvent[]; } +/** + * Deterministic 32-bit string hash (FNV-1a). Portfolio pricing is seeded from + * the address so the simulated value is stable per (address, clock hour), + * mirroring how IoT readings are keyed on project id. + */ +function hashAddress(address: string): number { + let h = 0x811c9dc5; + for (let i = 0; i < address.length; i++) { + h ^= address.charCodeAt(i); + h = Math.imul(h, 0x01000193); + } + return h >>> 0; +} + router.get("/:address", async (req: Request, res: Response, next: NextFunction) => { const address = Array.isArray(req.params.address) ? req.params.address[0] : req.params.address; @@ -53,7 +68,10 @@ router.get("/:address", async (req: Request, res: Response, next: NextFunction) } totalShares = Math.max(0, totalShares); - const pricePerShare = 1.5 + Math.random() * 0.5; + // Hour-seeded like every other simulated reading (see seededRandom in + // lib/iot), so current_value is stable across requests within an hour + // instead of jumping randomly on every call. + const pricePerShare = 1.5 + seededRandom(hashAddress(address)) * 0.5; const currentValue = totalShares * pricePerShare; const response: PortfolioResponse = {