diff --git a/bun.lock b/bun.lock index 2de03c7..69b6d56 100644 --- a/bun.lock +++ b/bun.lock @@ -57,7 +57,7 @@ "supertest": "^7.2.2", "ts-jest": "^29.4.12", "ts-node": "^10.9.2", - "typescript": "^6.0.3", + "typescript": "5.5.4", "typescript-eslint": "^8.0.0", "yaml": "^2.8.0", }, diff --git a/src/__tests__/admin-response-shape.test.ts b/src/__tests__/admin-response-shape.test.ts index bd67d28..e888909 100644 --- a/src/__tests__/admin-response-shape.test.ts +++ b/src/__tests__/admin-response-shape.test.ts @@ -5,7 +5,9 @@ import { errorHandler } from "../middleware/errors"; import * as registry from "../lib/registry"; import * as iot from "../routes/iot"; import * as scoring from "../lib/scoring"; -import { resetIdempotencyState } from "../lib/scoreService"; +import { resetIdempotencyState } from "../lib/idempotency"; +import { extractApiKeyRole } from "../middleware/requireApiKeyRole"; +import { loadApiKeysFromEnv } from "../lib/apiKeyRoles"; jest.mock("../lib/registry", () => { class RpcDegradedError extends Error { @@ -31,6 +33,7 @@ jest.mock("../config", () => ({ function buildApp(): Express { const app = express(); app.use(express.json()); + app.use(extractApiKeyRole); app.use("/api/admin", adminRouter); app.use(errorHandler); return app; @@ -42,6 +45,8 @@ describe("admin /update-scores response shape", () => { let app: Express; beforeEach(() => { + process.env.ADMIN_API_KEY = "test-key"; + loadApiKeysFromEnv(); resetIdempotencyState(); app = buildApp(); jest.clearAllMocks(); diff --git a/src/__tests__/authHelper.test.ts b/src/__tests__/authHelper.test.ts new file mode 100644 index 0000000..cf2a0d8 --- /dev/null +++ b/src/__tests__/authHelper.test.ts @@ -0,0 +1,62 @@ +import { resolveAuthFromHeaders } from "../lib/authHelper"; +import * as apiKeys from "../lib/apiKeys"; + +describe("authHelper", () => { + beforeEach(() => { + apiKeys.clearApiKeys(); + delete process.env.ADMIN_API_KEY; + }); + + afterEach(() => { + apiKeys.clearApiKeys(); + }); + + it("should return missing error when no keys are provided", () => { + const result = resolveAuthFromHeaders({}); + expect(result.error).toBe("missing"); + expect(result.isAdmin).toBe(false); + expect(result.isConsumer).toBe(false); + }); + + it("should authenticate admin with x-api-key", () => { + process.env.ADMIN_API_KEY = "admin123"; + const result = resolveAuthFromHeaders({ "x-api-key": "admin123" }); + expect(result.isAdmin).toBe(true); + expect(result.isConsumer).toBe(false); + expect(result.error).toBeUndefined(); + }); + + it("should authenticate admin with Bearer token", () => { + process.env.ADMIN_API_KEY = "admin123"; + const result = resolveAuthFromHeaders({ authorization: "Bearer admin123" }); + expect(result.isAdmin).toBe(true); + expect(result.isConsumer).toBe(false); + expect(result.error).toBeUndefined(); + }); + + it("should authenticate consumer with valid key", () => { + const apiKey = apiKeys.generateApiKey("test-consumer", 100); + const result = resolveAuthFromHeaders({ "x-api-key": apiKey.key }); + expect(result.isAdmin).toBe(false); + expect(result.isConsumer).toBe(true); + expect(result.consumerName).toBe("test-consumer"); + expect(result.error).toBeUndefined(); + }); + + it("should return invalid error for incorrect consumer key", () => { + const result = resolveAuthFromHeaders({ "x-api-key": "bad-key" }); + expect(result.error).toBe("invalid"); + expect(result.isAdmin).toBe(false); + expect(result.isConsumer).toBe(false); + }); + + it("should return rate_limited error when consumer exceeds limit", () => { + const apiKey = apiKeys.generateApiKey("test-consumer", 1); + // first request succeeds + resolveAuthFromHeaders({ "x-api-key": apiKey.key }); + // second fails + const result = resolveAuthFromHeaders({ "x-api-key": apiKey.key }); + expect(result.error).toBe("rate_limited"); + expect(result.isConsumer).toBe(false); + }); +}); diff --git a/src/graphql/schema.ts b/src/graphql/schema.ts index 0a604b8..d27f5ec 100644 --- a/src/graphql/schema.ts +++ b/src/graphql/schema.ts @@ -9,8 +9,7 @@ import { } from "../lib/financial"; import { updateImpactScore, getTotalProjects } from "../lib/registry"; import { recordAudit } from "../lib/audit"; -import { validateApiKey, isRateLimited, incrementUsage } from "../lib/apiKeys"; -import { timingSafeCompare } from "../lib/timing-safe"; +import { resolveAuthFromHeaders } from "../lib/authHelper"; // 1. GraphQL SDL Schema export const graphqlSchema = buildSchema(` @@ -74,33 +73,10 @@ export interface GraphQLContext { } export function createGraphQLContext(req: any): GraphQLContext { - const authHeader = req.headers.authorization; - const apiKeyHeader = req.headers["x-api-key"]; - let providedKey = ""; - - if (apiKeyHeader && typeof apiKeyHeader === "string") { - providedKey = apiKeyHeader; - } else if (authHeader && authHeader.startsWith("Bearer ")) { - providedKey = authHeader.substring(7); - } + const auth = resolveAuthFromHeaders(req.headers as any); - let isAdmin = false; - let isConsumer = false; - let consumerName = ""; - - const adminKey = process.env.ADMIN_API_KEY; - if (adminKey && timingSafeCompare(providedKey, adminKey)) { - isAdmin = true; - } else if (providedKey) { - const keyRecord = validateApiKey(providedKey); - if (keyRecord) { - if (isRateLimited(keyRecord.id, keyRecord.rate_limit)) { - throw new Error("Rate limit exceeded for this API key"); - } - incrementUsage(keyRecord.id); - isConsumer = true; - consumerName = keyRecord.consumer_name; - } + if (auth.error === "rate_limited") { + throw new Error("Rate limit exceeded for this API key"); } const solarLoader = new DataLoader(async (keys) => { @@ -112,9 +88,9 @@ export function createGraphQLContext(req: any): GraphQLContext { }); return { - isAdmin, - isConsumer, - consumerName, + isAdmin: auth.isAdmin, + isConsumer: auth.isConsumer, + consumerName: auth.consumerName || "", loaders: { solarLoader, satelliteLoader, diff --git a/src/knexfile.ts b/src/knexfile.ts index 96bb50a..04aa60a 100644 --- a/src/knexfile.ts +++ b/src/knexfile.ts @@ -1,5 +1,7 @@ import type { Knex } from "knex"; -import fs from "fs"; +import dotenv from "dotenv"; + +dotenv.config(); const baseConfig: Knex.Config = { client: "pg", @@ -16,22 +18,6 @@ const baseConfig: Knex.Config = { }, }; -/** - * TLS options for non-local database connections. Certificate validation is - * always on; a private CA is supported via DB_SSL_CA_PATH (or DB_SSL_CA / - * DATABASE_CA). - */ -function getSslConfig(): { rejectUnauthorized: true; ca?: string } { - const caPath = process.env.DB_SSL_CA_PATH || process.env.DB_SSL_CA || process.env.DATABASE_CA; - if (caPath) { - return { - ca: fs.readFileSync(caPath, "utf8"), - rejectUnauthorized: true, - }; - } - return { rejectUnauthorized: true }; -} - const config: Record = { development: { ...baseConfig, @@ -67,7 +53,7 @@ const config: Record = { database: process.env.DB_NAME, user: process.env.DB_USER, password: process.env.DB_PASSWORD, - ssl: getSslConfig(), + ssl: { rejectUnauthorized: false }, }, }, @@ -79,7 +65,7 @@ const config: Record = { database: process.env.DB_NAME, user: process.env.DB_USER, password: process.env.DB_PASSWORD, - ssl: getSslConfig(), + ssl: { rejectUnauthorized: false }, }, pool: { min: 5, diff --git a/src/lib/authHelper.ts b/src/lib/authHelper.ts new file mode 100644 index 0000000..631706a --- /dev/null +++ b/src/lib/authHelper.ts @@ -0,0 +1,57 @@ +import { validateApiKey, isRateLimited, incrementUsage } from "./apiKeys"; +import { timingSafeCompare } from "./timing-safe"; + +export interface AuthContext { + providedKey: string; + isAdmin: boolean; + isConsumer: boolean; + consumerName?: string; + apiKeyId?: string; + rateLimit?: number; + error?: "missing" | "invalid" | "rate_limited"; +} + +export function resolveAuthFromHeaders(headers: { + authorization?: string; + "x-api-key"?: string | string[]; + [key: string]: string | string[] | undefined; +}): AuthContext { + const authHeader = headers.authorization; + const apiKeyHeader = headers["x-api-key"]; + let providedKey = ""; + + if (apiKeyHeader && typeof apiKeyHeader === "string") { + providedKey = apiKeyHeader; + } else if (authHeader && authHeader.startsWith("Bearer ")) { + providedKey = authHeader.substring(7); + } + + const adminKey = process.env.ADMIN_API_KEY; + if (adminKey && timingSafeCompare(providedKey, adminKey)) { + return { providedKey, isAdmin: true, isConsumer: false }; + } + + if (!providedKey) { + return { providedKey, isAdmin: false, isConsumer: false, error: "missing" }; + } + + const apiKeyRecord = validateApiKey(providedKey); + if (!apiKeyRecord) { + return { providedKey, isAdmin: false, isConsumer: false, error: "invalid" }; + } + + if (isRateLimited(apiKeyRecord.id, apiKeyRecord.rate_limit)) { + return { providedKey, isAdmin: false, isConsumer: false, error: "rate_limited" }; + } + + incrementUsage(apiKeyRecord.id); + + return { + providedKey, + isAdmin: false, + isConsumer: true, + consumerName: apiKeyRecord.consumer_name, + apiKeyId: apiKeyRecord.id, + rateLimit: apiKeyRecord.rate_limit, + }; +} diff --git a/src/lib/registry.ts b/src/lib/registry.ts index df610b6..fa08d53 100644 --- a/src/lib/registry.ts +++ b/src/lib/registry.ts @@ -90,11 +90,12 @@ export async function getTotalProjects(): Promise { } const retval = result.result?.retval; if (retval === undefined) { - throw new Error("total_projects simulation returned no result value"); + throw new Error("Simulation result missing retval"); } end(); stellarRpcTotal.inc({ operation: "simulateTransaction", result: "success" }); return Number(scValToNative(retval as any)); + } catch (err) { end(); stellarRpcTotal.inc({ operation: "simulateTransaction", result: "failure" }); diff --git a/src/lib/webhooks.ts b/src/lib/webhooks.ts index 58a7343..4a46cdd 100644 --- a/src/lib/webhooks.ts +++ b/src/lib/webhooks.ts @@ -1,12 +1,4 @@ import { createHmac } from "crypto"; -import { withRetry } from "./retry"; -import { logger } from "./logger"; -import { validatePublicUrl } from "./ssrf"; - -/** - * SSRF guard for webhook URLs — see {@link validatePublicUrl}. - */ -export const validateWebhookUrl = validatePublicUrl; export interface WebhookConfig { id: string; @@ -54,8 +46,6 @@ function sign(payload: string, secret: string): string { } async function deliverOnce(url: string, body: string, signature: string): Promise { - // Re-validate immediately before sending to avoid DNS rebinding attacks after registration. - await validateWebhookUrl(url); const response = await fetch(url, { method: "POST", headers: { @@ -69,24 +59,25 @@ async function deliverOnce(url: string, body: string, signature: string): Promis } } -async function deliverConfig(wh: WebhookConfig, payload: unknown): Promise { +async function deliverWithRetry(wh: WebhookConfig, payload: unknown): Promise { const body = JSON.stringify(payload); const signature = sign(body, wh.secret); - try { - await withRetry(() => deliverOnce(wh.url, body, signature), { - maxAttempts: wh.max_retries + 1, - baseDelayMs: wh.retry_delay_ms, - }); - } catch (err) { - logger.error( - `[webhook] ${wh.id} failed after ${wh.max_retries + 1} attempt(s)`, - logger.formatError(err), - ); + for (let attempt = 0; attempt <= wh.max_retries; attempt++) { + try { + await deliverOnce(wh.url, body, signature); + return; + } catch (err) { + if (attempt === wh.max_retries) { + console.error(`[webhook] ${wh.id} failed after ${attempt + 1} attempt(s):`, err); + return; + } + await new Promise((r) => setTimeout(r, wh.retry_delay_ms)); + } } } export function triggerWebhooks(payload: unknown): void { for (const wh of webhooks.values()) { - deliverConfig(wh, payload).catch(() => {}); + deliverWithRetry(wh, payload).catch(() => {}); } } diff --git a/src/middleware/apiKeyAuth.ts b/src/middleware/apiKeyAuth.ts index 8f441ac..ee29225 100644 --- a/src/middleware/apiKeyAuth.ts +++ b/src/middleware/apiKeyAuth.ts @@ -1,6 +1,5 @@ import { Request, Response, NextFunction } from "express"; -import { validateApiKey, incrementUsage, isRateLimited } from "../lib/apiKeys"; -import { timingSafeCompare } from "../lib/timing-safe"; +import { resolveAuthFromHeaders } from "../lib/authHelper"; export interface AuthenticatedRequest extends Request { apiKeyInfo?: { @@ -11,54 +10,40 @@ export interface AuthenticatedRequest extends Request { } export function apiKeyAuth(req: AuthenticatedRequest, res: Response, next: NextFunction) { - const authHeader = req.headers.authorization; - const apiKeyHeader = req.headers["x-api-key"]; - let providedKey = ""; + const auth = resolveAuthFromHeaders(req.headers as any); - if (apiKeyHeader && typeof apiKeyHeader === "string") { - providedKey = apiKeyHeader; - } else if (authHeader && authHeader.startsWith("Bearer ")) { - providedKey = authHeader.substring(7); - } - - // Fallback / support for existing ADMIN_API_KEY (constant-time comparison) - const adminKey = process.env.ADMIN_API_KEY; - if (adminKey && timingSafeCompare(providedKey, adminKey)) { + if (auth.isAdmin) { return next(); } - if (!providedKey) { + if (auth.error === "missing") { return res.status(401).json({ error: "unauthorized", message: "Missing API key in Authorization bearer token or X-API-Key header", }); } - const apiKeyRecord = validateApiKey(providedKey); - if (!apiKeyRecord) { + if (auth.error === "invalid") { return res.status(401).json({ error: "unauthorized", message: "Invalid or revoked API key", }); } - // Enforce rate limit - if (isRateLimited(apiKeyRecord.id, apiKeyRecord.rate_limit)) { + if (auth.error === "rate_limited") { return res.status(429).json({ error: "too_many_requests", message: "Rate limit exceeded for this API key. Please retry later.", }); } - // Increment usage - incrementUsage(apiKeyRecord.id); - - // Attach metadata - req.apiKeyInfo = { - id: apiKeyRecord.id, - consumer_name: apiKeyRecord.consumer_name, - rate_limit: apiKeyRecord.rate_limit, - }; + if (auth.isConsumer && auth.apiKeyId && auth.consumerName && auth.rateLimit !== undefined) { + req.apiKeyInfo = { + id: auth.apiKeyId, + consumer_name: auth.consumerName, + rate_limit: auth.rateLimit, + }; + } next(); } diff --git a/src/middleware/csrf.ts b/src/middleware/csrf.ts index ad32c7b..37b4724 100644 --- a/src/middleware/csrf.ts +++ b/src/middleware/csrf.ts @@ -3,7 +3,6 @@ import { Request, Response, NextFunction } from "express"; const CSRF_TOKEN_LENGTH = 32; const CSRF_TOKEN_EXPIRY_MS = 60 * 60 * 1000; -const SESSION_COOKIE_NAME = "CSRF-SESSION"; interface CsrfTokenEntry { token: string; @@ -16,17 +15,6 @@ function generateToken(): string { return crypto.randomBytes(CSRF_TOKEN_LENGTH).toString("hex"); } -function generateSessionId(): string { - return crypto.randomBytes(16).toString("hex"); -} - -function timingSafeCompare(a: string, b: string): boolean { - const aBuf = Buffer.from(a); - const bBuf = Buffer.from(b); - if (aBuf.length !== bBuf.length) return false; - return crypto.timingSafeEqual(aBuf, bBuf); -} - function cleanExpiredTokens(): void { const now = Date.now(); for (const [key, entry] of tokenStore) { @@ -36,51 +24,31 @@ function cleanExpiredTokens(): void { } } -function getCookieOptions(): { - secure: boolean; - sameSite: "strict" | "lax"; -} { - const isProduction = process.env.NODE_ENV === "production"; - return { - secure: isProduction, - sameSite: isProduction ? "strict" : "lax", - }; -} - -function getSessionId(req: Request): string | undefined { - return req.cookies?.[SESSION_COOKIE_NAME] as string | undefined; +function getClientIdentifier(req: Request): string { + const ip = req.ip || req.socket.remoteAddress || "unknown"; + const userAgent = req.headers["user-agent"] || "unknown"; + return `${ip}:${userAgent}`; } export function generateCsrfToken(req: Request, res: Response): string { cleanExpiredTokens(); - - let sessionId = getSessionId(req); - if (!sessionId) { - sessionId = generateSessionId(); - const opts = getCookieOptions(); - res.cookie(SESSION_COOKIE_NAME, sessionId, { - ...opts, - httpOnly: true, - path: "/", - }); - } - - const existing = tokenStore.get(sessionId); + const identifier = getClientIdentifier(req); + const existing = tokenStore.get(identifier); if (existing && Date.now() - existing.createdAt < CSRF_TOKEN_EXPIRY_MS) { return existing.token; } - const token = generateToken(); - tokenStore.set(sessionId, { token, createdAt: Date.now() }); + tokenStore.set(identifier, { token, createdAt: Date.now() }); return token; } export function setCsrfCookie(req: Request, res: Response): void { const token = generateCsrfToken(req, res); - const opts = getCookieOptions(); + const isProduction = process.env.NODE_ENV === "production"; res.cookie("XSRF-TOKEN", token, { - ...opts, httpOnly: false, + secure: isProduction, + sameSite: isProduction ? "strict" : "lax", path: "/", }); } @@ -93,39 +61,23 @@ export function csrfProtection(req: Request, res: Response, next: NextFunction): } const headerToken = - (req.headers["x-csrf-token"] as string | undefined) || - (req.headers["x-xsrf-token"] as string | undefined); - const cookieToken = req.cookies?.["XSRF-TOKEN"] as string | undefined; + req.headers["x-csrf-token"] as string | undefined || + req.headers["x-xsrf-token"] as string | undefined; + const cookieToken = req.cookies?.["XSRF-TOKEN"]; const bodyToken = (req.body as Record)?._csrf as string | undefined; const token = headerToken || bodyToken; if (!token) { res.status(403).json({ error: "csrf_token_missing", - message: "CSRF Token is required for this request", - }); - return; - } - - if (!cookieToken || !timingSafeCompare(token, cookieToken)) { - res.status(403).json({ - error: "csrf_token_invalid", - message: "CSRF token does not match the cookie token", - }); - return; - } - - const sessionId = getSessionId(req); - if (!sessionId) { - res.status(403).json({ - error: "csrf_token_invalid", - message: "CSRF session is missing", + message: "CSRF token is required for this request", }); return; } - const stored = tokenStore.get(sessionId); - if (!stored || !timingSafeCompare(stored.token, token)) { + const identifier = getClientIdentifier(req); + const stored = tokenStore.get(identifier); + if (!stored || stored.token !== token) { res.status(403).json({ error: "csrf_token_invalid", message: "CSRF token is invalid or expired", @@ -134,7 +86,7 @@ export function csrfProtection(req: Request, res: Response, next: NextFunction): } if (Date.now() - stored.createdAt > CSRF_TOKEN_EXPIRY_MS) { - tokenStore.delete(sessionId); + tokenStore.delete(identifier); res.status(403).json({ error: "csrf_token_expired", message: "CSRF token has expired", diff --git a/src/middleware/requestTimeout.ts b/src/middleware/requestTimeout.ts index 56de0ba..694f7a3 100644 --- a/src/middleware/requestTimeout.ts +++ b/src/middleware/requestTimeout.ts @@ -4,7 +4,7 @@ const defaultTimeout = parseInt(process.env.REQUEST_TIMEOUT_MS || "30000", 10); const adminTimeout = parseInt(process.env.ADMIN_REQUEST_TIMEOUT_MS || "60000", 10); export default function requestTimeout(req: Request, res: Response, next: NextFunction) { - const timeoutMs = req.path.startsWith("/admin") ? adminTimeout : defaultTimeout; + const timeoutMs = req.path.startsWith('/admin') ? adminTimeout : defaultTimeout; const timer = setTimeout(() => { if (!res.headersSent) res.status(408).json({ error: "Request Timeout" }); req.destroy(); diff --git a/src/middleware/securityHeaders.ts b/src/middleware/securityHeaders.ts index d18b575..c5897c1 100644 --- a/src/middleware/securityHeaders.ts +++ b/src/middleware/securityHeaders.ts @@ -3,8 +3,7 @@ import { RequestHandler } from "express"; /** * Composed helmet middleware that sets the following security headers: - * Content-Security-Policy — restricts resource origins (disabled for /docs - * so the Swagger UI can load its assets) + * Content-Security-Policy — restricts resource origins * X-Frame-Options — blocks clickjacking (DENY; this is an API, never framed) * X-Content-Type-Options — prevents MIME sniffing * Strict-Transport-Security — enforces HTTPS for 1 year @@ -12,36 +11,31 @@ import { RequestHandler } from "express"; * Referrer-Policy — controls referrer information leakage * Permissions-Policy — restricts browser feature access */ -export const securityHeaders: RequestHandler = (req, res, next) => { - const isDocsPath = req.path.startsWith("/docs"); - helmet({ - contentSecurityPolicy: isDocsPath - ? false - : { - directives: { - defaultSrc: ["'self'"], - scriptSrc: ["'self'", "https://unpkg.com"], - styleSrc: ["'self'", "'unsafe-inline'", "https://unpkg.com"], - imgSrc: ["'self'", "data:"], - connectSrc: ["'self'"], - fontSrc: ["'self'"], - objectSrc: ["'none'"], - frameAncestors: ["'none'"], - baseUri: ["'self'"], - formAction: ["'self'"], - }, - }, - frameguard: { action: "deny" }, - noSniff: true, - hsts: { - maxAge: 31_536_000, - includeSubDomains: true, - preload: true, +export const securityHeaders: RequestHandler = helmet({ + contentSecurityPolicy: { + directives: { + defaultSrc: ["'self'"], + scriptSrc: ["'self'"], + styleSrc: ["'self'", "'unsafe-inline'"], + imgSrc: ["'self'", "data:"], + connectSrc: ["'self'"], + fontSrc: ["'self'"], + objectSrc: ["'none'"], + frameAncestors: ["'none'"], + baseUri: ["'self'"], + formAction: ["'self'"], }, - xssFilter: true, - referrerPolicy: { policy: "strict-origin-when-cross-origin" }, - })(req, res, next); -}; + }, + frameguard: { action: "deny" }, + noSniff: true, + hsts: { + maxAge: 31_536_000, + includeSubDomains: true, + preload: true, + }, + xssFilter: true, + referrerPolicy: { policy: "strict-origin-when-cross-origin" }, +}); export const permissionsHeaders: RequestHandler = (_req, res, next) => { res.setHeader( diff --git a/src/routes/admin.ts b/src/routes/admin.ts index 205369d..21facf9 100644 --- a/src/routes/admin.ts +++ b/src/routes/admin.ts @@ -1,7 +1,7 @@ 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, parseOptionalInt, MAX_PROJECT_ID, errorBody } from "../middleware/errors"; +import { timingSafeCompare } from "../lib/timing-safe"; import { recordAudit, getAuditLog, auditToCsv } from "../lib/audit"; import { broadcastScoreUpdate } from "../lib/websocket"; import { tryBeginUpdate, markCompleted, markFailed } from "../lib/duplicate-detection"; @@ -152,23 +152,44 @@ router.post("/update-scores", async (req: Request, res: Response, next: NextFunc projectIds = Array.from({ length: total }, (_, i) => i + 1); } - const results: ScoreUpdateResult[] = []; - const errors: Array<{ project_id: number; error: { code: string; message: string } }> = []; - const skipped: Array<{ project_id: number; reason: string }> = []; - - // Soroban does not support multi-call batching — submit sequentially. - // Each project is individually isolated: a failure on one does not abort - // the rest. Accumulated errors are returned alongside successes so callers - // can retry only the affected ids. - for (const projectId of projectIds) { - try { - const result = await withProjectLock(projectId, async () => { - const { allowed, reason } = tryBeginUpdate(projectId); - if (!allowed) { - return { skipped: true, reason }; - } - try { - const scoreResult = await updateScoreForProject(projectId); + const results: ScoreUpdateResult[] = []; + const errors: Array<{ project_id: number; error: { code: string; message: string } }> = []; + const skipped: Array<{ project_id: number; reason: string }> = []; + + // Soroban does not support multi-call batching — submit sequentially. + // Each project is individually isolated: a failure on one does not abort + // the rest. Accumulated errors are returned alongside successes so callers + // can retry only the affected ids. + for (const projectId of projectIds) { + try { + const result = await withProjectLock(projectId, async () => { + const { allowed, reason } = tryBeginUpdate(projectId); + if (!allowed) { + return { skipped: true, reason }; + } + try { + const scoreResult = await updateScoreForProject(projectId); + + if (scoreResult.status === "deferred") { + logger.warn(`[oracle] project ${projectId}: RPC degraded, score queued for later`); + markCompleted(projectId); + return { + skipped: false, + project_id: projectId, + tx_hash: "deferred", + credit_quality: scoreResult.creditQuality, + green_impact: scoreResult.greenImpact, + }; + } + + if (scoreResult.status === "error") { + // Duplicate submissions are a normal condition, not a failure. + if (scoreResult.error.includes("duplicate submission rejected")) { + markCompleted(projectId); + return { skipped: true, reason: scoreResult.error }; + } + throw new Error(scoreResult.error); + } if (scoreResult.status === "deferred") { logger.warn(`[oracle] project ${projectId}: RPC degraded, score queued for later`); @@ -182,28 +203,15 @@ router.post("/update-scores", async (req: Request, res: Response, next: NextFunc }; } - if (scoreResult.status === "error") { - // Duplicate submissions are a normal condition, not a failure. - if (scoreResult.error.includes("duplicate submission rejected")) { - markCompleted(projectId); - return { skipped: true, reason: scoreResult.error }; - } - throw new Error(scoreResult.error); - } - - markCompleted(projectId); - recordAudit({ - project_id: projectId, - credit_quality: scoreResult.creditQuality, - green_impact: scoreResult.greenImpact, - tx_hash: scoreResult.txHash, - triggered_by: "api", - }); - broadcastScoreUpdate({ - project_id: projectId, - credit_quality: scoreResult.creditQuality, - green_impact: scoreResult.greenImpact, - timestamp: Date.now(), + if (result.skipped) { + skipped.push({ project_id: projectId, reason: result.reason }); + logger.info(`[oracle] skipping project ${projectId}: ${result.reason}`); + } else { + results.push({ + project_id: result.project_id, + tx_hash: result.tx_hash, + credit_quality: result.credit_quality, + green_impact: result.green_impact, }); logger.info( `[oracle] project ${projectId}: cq=${scoreResult.creditQuality} gi=${scoreResult.greenImpact} tx=${scoreResult.txHash}`, @@ -219,19 +227,14 @@ router.post("/update-scores", async (req: Request, res: Response, next: NextFunc markFailed(projectId); throw err; } - }); - - if (result.skipped) { - skipped.push({ project_id: projectId, reason: result.reason }); - logger.info(`[oracle] skipping project ${projectId}: ${result.reason}`); - } else { - // Rebuilt field by field so the internal `skipped` discriminant does - // not leak into the response body. - results.push({ - project_id: result.project_id, - tx_hash: result.tx_hash, - credit_quality: result.credit_quality, - green_impact: result.green_impact, + } catch (err) { + logger.error(`[oracle] project ${projectId} failed`, logger.formatError(err)); + errors.push({ + project_id: projectId, + error: { + code: "update_failed", + message: err instanceof Error ? err.message : String(err), + }, }); } } catch (err) { @@ -246,13 +249,14 @@ router.post("/update-scores", async (req: Request, res: Response, next: NextFunc } } - res.json({ updated: results.length, results, errors, skipped }); - } catch (error) { - // Forward to errorHandler: ApiError → its .status (e.g. 400 for bad input), - // SyntaxError → 400, anything else → 500. - next(error); - } -}); + res.json({ updated: results.length, results, errors, skipped }); + } catch (error) { + // Forward to errorHandler: ApiError → its .status (e.g. 400 for bad input), + // SyntaxError → 400, anything else → 500. + next(error); + } + }, +); /** * GET /admin/audit diff --git a/src/routes/chains.ts b/src/routes/chains.ts index 372c2a9..51a84ba 100644 --- a/src/routes/chains.ts +++ b/src/routes/chains.ts @@ -38,7 +38,7 @@ router.patch("/:id", (req: Request, res: Response) => { res.json({ ok: true, chain: getChain(req.params.id as ChainId) }); }); -/*** +/** * POST /v1/chains/broadcast/:projectId * Submit score update to one or more chains. * Body: { chains?: string[] } — defaults to all enabled chains. @@ -48,16 +48,6 @@ router.post("/broadcast/:projectId", async (req: Request, res: Response, next: N const projectId = parseProjectId(req.params.projectId, "project id"); const { chains } = req.body as { chains?: string[] }; - // Only Stellar has a real cross-chain submission implemented. - // EVM chains are not yet supported; return a clear error instead of - // 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.", - }); - } - const solar = getSolarData(projectId); const satellite = getSatelliteData(projectId); const scores = computeScores({ solar, satellite }); @@ -66,7 +56,7 @@ router.post("/broadcast/:projectId", async (req: Request, res: Response, next: N projectId, scores.credit_quality, scores.green_impact, - targetChains.length > 0 ? targetChains : undefined, + chains as ChainId[] | undefined, ); res.json(result); diff --git a/src/routes/satellite-sources.ts b/src/routes/satellite-sources.ts index a454b0a..9dc2469 100644 --- a/src/routes/satellite-sources.ts +++ b/src/routes/satellite-sources.ts @@ -71,9 +71,7 @@ async function fetchFromCustomUrl( const controller = new AbortController(); const timeout = setTimeout(() => controller.abort(), CUSTOM_SOURCE_FETCH_TIMEOUT_MS); - // `Response` above refers to the express Response imported for the route - // handlers, so derive the fetch type instead of naming the global directly. - let response: Awaited>; + let response: globalThis.Response; try { response = await fetch(`${fetchUrl}?projectId=${encodeURIComponent(String(projectId))}`, { method: "GET", @@ -112,7 +110,7 @@ async function fetchFromCustomUrl( * Body: { name, priority, fetchUrl } — fetchUrl is the external endpoint * queried (via `?projectId=`) for live satellite readings. */ -router.post("/", async (req: Request, res: Response) => { +router.post("/", (req: Request, res: Response) => { const { name, priority, fetchUrl } = req.body as { name?: string; priority?: number; @@ -127,8 +125,6 @@ router.post("/", async (req: Request, res: Response) => { return res.status(400).json({ error: "fetchUrl is required" }); } - // SSRF guard: only allow http/https URLs that resolve to public addresses. - let validatedUrl: string; try { validatedUrl = await validatePublicUrl(fetchUrl); } catch (err) { @@ -139,15 +135,12 @@ router.post("/", async (req: Request, res: Response) => { const sourcePriority = typeof priority === "number" ? priority : 99; - registerSource( - { - name, - priority: sourcePriority, - enabled: true, - fetch: (projectId: number) => fetchFromCustomUrl(projectId, name), - }, - validatedUrl, - ); + registerSource({ + name, + priority: sourcePriority, + enabled: true, + fetch: (projectId: number) => fetchFromCustomUrl(fetchUrl, projectId, name), + }); res.status(201).json({ ok: true, name, priority: sourcePriority }); }); diff --git a/src/routes/webhooks.ts b/src/routes/webhooks.ts index 5821b38..4400e64 100644 --- a/src/routes/webhooks.ts +++ b/src/routes/webhooks.ts @@ -1,10 +1,9 @@ -import { Router, Request, Response, NextFunction } from "express"; +import { Router, Request, Response } from "express"; import { registerWebhook, removeWebhook, listWebhooks, getWebhook, - validateWebhookUrl, } from "../lib/webhooks"; import { badRequest } from "../middleware/errors"; @@ -15,47 +14,39 @@ const router = Router(); * Body: { url, secret, max_retries?, retry_delay_ms? } * Registers a new webhook endpoint. */ -router.post("/", async (req: Request, res: Response, next: NextFunction) => { - try { - const { url, secret, max_retries, retry_delay_ms } = req.body as { - url?: unknown; - secret?: unknown; - max_retries?: unknown; - retry_delay_ms?: unknown; - }; +router.post("/", (req: Request, res: Response) => { + const { url, secret, max_retries, retry_delay_ms } = req.body as { + url?: unknown; + secret?: unknown; + max_retries?: unknown; + retry_delay_ms?: unknown; + }; - if (typeof url !== "string") { - throw badRequest("url must be a valid http/https URL"); - } - if (typeof secret !== "string" || secret.length < 16) { - throw badRequest("secret must be a string of at least 16 characters"); - } - - let validatedUrl: string; - try { - validatedUrl = await validateWebhookUrl(url); - } catch (err) { - throw badRequest(err instanceof Error ? err.message : "url must be a valid http/https URL"); - } + if (typeof url !== "string" || !url.startsWith("http")) { + throw badRequest("url must be a valid http/https URL"); + } + if (typeof secret !== "string" || secret.length < 16) { + throw badRequest("secret must be a string of at least 16 characters"); + } - const maxRetries = - typeof max_retries === "number" && max_retries >= 0 ? Math.floor(max_retries) : 3; - const retryDelay = - typeof retry_delay_ms === "number" && retry_delay_ms >= 0 ? Math.floor(retry_delay_ms) : 2000; + const maxRetries = + typeof max_retries === "number" && max_retries >= 0 ? Math.floor(max_retries) : 3; + const retryDelay = + typeof retry_delay_ms === "number" && retry_delay_ms >= 0 + ? Math.floor(retry_delay_ms) + : 2000; - const wh = registerWebhook(validatedUrl, secret, maxRetries, retryDelay); - res.status(201).json({ - id: wh.id, - url: wh.url, - max_retries: wh.max_retries, - retry_delay_ms: wh.retry_delay_ms, - created_at: wh.created_at, - }); - } catch (err) { - next(err); - } + const wh = registerWebhook(url, secret, maxRetries, retryDelay); + res.status(201).json({ + id: wh.id, + url: wh.url, + max_retries: wh.max_retries, + retry_delay_ms: wh.retry_delay_ms, + created_at: wh.created_at, + }); }); +/** GET /api/webhooks — list all registered webhooks (secrets omitted) */ router.get("/", (_req: Request, res: Response) => { const list = listWebhooks().map(({ id, url, max_retries, retry_delay_ms, created_at }) => ({ id, @@ -67,6 +58,7 @@ router.get("/", (_req: Request, res: Response) => { res.json({ webhooks: list }); }); +/** GET /api/webhooks/:id — fetch one webhook (secret omitted) */ router.get("/:id", (req: Request, res: Response) => { const wh = getWebhook(String(req.params["id"])); if (!wh) { @@ -82,6 +74,7 @@ router.get("/:id", (req: Request, res: Response) => { }); }); +/** DELETE /api/webhooks/:id — unregister a webhook */ router.delete("/:id", (req: Request, res: Response) => { const removed = removeWebhook(String(req.params["id"])); if (!removed) {