diff --git a/backend/.env.example b/backend/.env.example index c5cdd9a..0d56c47 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -6,6 +6,9 @@ CONTRACT_ID=YOUR_CONTRACT_ID_HERE ADMIN_SECRET_KEY=YOUR_ADMIN_SECRET_KEY_HERE MQTT_BROKER=mqtt://mqtt:1883 +# Must be set — server refuses to start without it (startup fatal). +# Use a random secret with at least 32 characters of entropy in production, +# e.g.: openssl rand -hex 32 ADMIN_API_KEY=change-me-in-production # Optional environment variables with defaults diff --git a/backend/openapi.yaml b/backend/openapi.yaml index 3a1f15d..2ca1444 100644 --- a/backend/openapi.yaml +++ b/backend/openapi.yaml @@ -160,6 +160,42 @@ components: # ── Dead-letter ─────────────────────────────────────────────────────────── + MeterNote: + type: object + required: [id, meter_id, text, created_at] + properties: + id: + type: integer + meter_id: + type: string + author_ip: + type: string + nullable: true + description: IP address of the admin who created the note + text: + type: string + maxLength: 1000 + created_at: + type: string + format: date-time + + MeterNoteList: + type: object + required: [notes, total, page, pageSize, hasMore] + properties: + notes: + type: array + items: + $ref: "#/components/schemas/MeterNote" + total: + type: integer + page: + type: integer + pageSize: + type: integer + hasMore: + type: boolean + DeadLetterList: type: object required: [total, limit, offset, events] @@ -514,10 +550,12 @@ paths: schema: { type: string } - name: page in: query - schema: { type: integer, default: 1 } + schema: { type: integer, default: 1, minimum: 1 } + description: Page number (1-based) - name: pageSize in: query - schema: { type: integer, default: 25, maximum: 100 } + schema: { type: integer, default: 20, minimum: 1, maximum: 100 } + description: Events per page (default 20, max 100) responses: "200": description: Usage history @@ -525,6 +563,7 @@ paths: application/json: schema: type: object + required: [events, page, pageSize, total, hasMore] properties: events: type: array @@ -534,6 +573,95 @@ paths: pageSize: { type: integer } total: { type: integer } hasMore: { type: boolean } + "400": + $ref: "#/components/responses/ValidationError" + + /api/meters/{id}/notes: + get: + summary: List all notes for a meter + operationId: getMeterNotes + parameters: + - name: id + in: path + required: true + schema: { type: string } + - name: page + in: query + schema: { type: integer, default: 1 } + - name: pageSize + in: query + schema: { type: integer, default: 20, maximum: 100 } + responses: + "200": + description: Paginated notes list + content: + application/json: + schema: + $ref: "#/components/schemas/MeterNoteList" + post: + summary: Create a note for a meter (admin only) + operationId: createMeterNote + security: + - AdminKey: [] + parameters: + - name: id + in: path + required: true + schema: { type: string } + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [text] + properties: + text: + type: string + maxLength: 1000 + responses: + "201": + description: Note created + content: + application/json: + schema: + $ref: "#/components/schemas/MeterNote" + "400": + $ref: "#/components/responses/ValidationError" + "401": + $ref: "#/components/responses/Unauthorized" + "404": + $ref: "#/components/responses/NotFound" + + /api/meters/{id}/notes/{noteId}: + delete: + summary: Delete a meter note (admin only) + operationId: deleteMeterNote + security: + - AdminKey: [] + parameters: + - name: id + in: path + required: true + schema: { type: string } + - name: noteId + in: path + required: true + schema: { type: integer } + responses: + "200": + description: Note deleted + content: + application/json: + schema: + type: object + properties: + deleted: { type: boolean } + noteId: { type: integer } + "401": + $ref: "#/components/responses/Unauthorized" + "404": + $ref: "#/components/responses/NotFound" # ── Webhooks ────────────────────────────────────────────────────────────────── @@ -761,6 +889,101 @@ paths: "401": $ref: "#/components/responses/Unauthorized" + # ── Usage Events ───────────────────────────────────────────────────────────── + + /api/usage-events: + delete: + summary: Purge submitted usage events older than N days (admin only) + description: | + Hard-deletes usage events with `status = submitted` older than `olderThanDays` days. + Defaults to 90 days. Only submitted events are deleted — pending and failed events + are never purged. + operationId: purgeUsageEvents + security: + - AdminKey: [] + parameters: + - name: olderThanDays + in: query + schema: { type: integer, default: 90, minimum: 0 } + responses: + "200": + description: Deleted count + content: + application/json: + schema: + type: object + properties: + deletedCount: { type: integer } + "400": + $ref: "#/components/responses/ValidationError" + "401": + $ref: "#/components/responses/Unauthorized" + + /api/usage-events/failed: + get: + summary: List dead-lettered usage events (admin only) + description: | + Returns events with `status = failed` (exhausted all retry attempts) with pagination. + operationId: getFailedUsageEvents + security: + - AdminKey: [] + parameters: + - name: page + in: query + schema: { type: integer, default: 1 } + - name: pageSize + in: query + schema: { type: integer, default: 10, maximum: 100 } + responses: + "200": + description: Paginated failed events + content: + application/json: + schema: + type: object + properties: + events: + type: array + items: + $ref: "#/components/schemas/UsageEvent" + pagination: + type: object + properties: + page: { type: integer } + pageSize: { type: integer } + total: { type: integer } + pages: { type: integer } + "401": + $ref: "#/components/responses/Unauthorized" + + /api/usage-events/{id}/replay: + post: + summary: Replay a failed usage event (admin only) + description: | + Resets a failed event back to `pending` with `attempt_count = 0` so + the retry worker picks it up on its next tick. + operationId: replayUsageEvent + security: + - AdminKey: [] + parameters: + - name: id + in: path + required: true + schema: { type: integer } + responses: + "200": + description: Updated event record + content: + application/json: + schema: + $ref: "#/components/schemas/UsageEvent" + "400": + $ref: "#/components/responses/ValidationError" + "401": + $ref: "#/components/responses/Unauthorized" + "404": + $ref: "#/components/responses/NotFound" + # ── Admin: Dead-letter ──────────────────────────────────────────────────────── /api/admin/dead-letters: diff --git a/backend/src/index.ts b/backend/src/index.ts index 3b09216..d72108a 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -1,12 +1,9 @@ import "dotenv/config"; +import { createRequire } from "module"; import express, { NextFunction, Request, Response } from "express"; import cors from "cors"; -import compression from "compression"; import timeout from "connect-timeout"; import mqtt from "mqtt"; -import helmet from "helmet"; -import swaggerUi from "swagger-ui-express"; -import YAML from "yamljs"; import { stellarService, server } from "./lib/stellar.js"; import { createMeterRouter } from "./routes/meters.js"; import { paymentsRouter } from "./routes/payments.js"; @@ -15,10 +12,16 @@ import { statsRouter } from "./routes/stats.js"; import { deadLettersRouter } from "./routes/deadLetters.js"; import { metricsRouter } from "./routes/metrics.js"; import { providerRouter } from "./routes/provider.js"; +import { adminLoginRouter } from "./routes/adminLogin.js"; +import { allowlistRouter } from "./routes/allowlist.js"; +import { collaboratorRouter } from "./routes/collaborators.js"; +import { smsConfigRouter } from "./routes/smsConfig.js"; +import { clientErrorsRouter } from "./routes/clientErrors.js"; +import { usageEventsRouter } from "./routes/usageEvents.js"; import { startIoTBridge } from "./iot/bridge.js"; import { logger } from "./lib/logger.js"; import { register } from "./lib/metrics.js"; -import { writeLimiter } from "./middleware/rateLimit.js"; +import { writeLimiter, paymentsLimiter } from "./middleware/rateLimit.js"; import { sanitiseBody } from "./middleware/sanitise.js"; import requestLoggerMiddleware from "./middleware/requestLogger.js"; import { @@ -35,11 +38,9 @@ import { getReqId } from "./lib/requestContext.js"; import { initMeterNotesStore } from "./lib/meterNotes.js"; const PORT = process.env.PORT ?? 3001; -// #423: configurable body size limit const BODY_LIMIT = process.env.REQUEST_BODY_LIMIT ?? "100kb"; const app = express(); -const startTime = Date.now(); app.use( cors({ @@ -50,9 +51,6 @@ app.use( }), ); -// Capture raw body for webhook signature verification before JSON parsing -// Capture raw body for webhook signature verification before JSON parsing. -// #423: apply body size limit app.use( express.json({ limit: BODY_LIMIT, @@ -62,11 +60,9 @@ app.use( }), ); app.use(express.urlencoded({ extended: true, limit: BODY_LIMIT })); - app.use(sanitiseBody); app.use(requestLoggerMiddleware); -// Request timeout — configurable via REQUEST_TIMEOUT env var (default 15s) const requestTimeout = process.env.REQUEST_TIMEOUT ?? "15s"; app.use(timeout(requestTimeout)); @@ -88,14 +84,13 @@ app.use("/api/client-errors", writeLimiter, clientErrorsRouter); app.use("/api/metrics", metricsRouter); app.use("/api/admin/dead-letters", deadLettersRouter); app.use("/api/provider", providerRouter); +app.use("/api/usage-events", usageEventsRouter); // ── Health ──────────────────────────────────────────────────────────────────── app.get("/health", async (_req, res) => { const checks: Record = {}; - // Check Stellar RPC - let rpcOk = false; try { await server.getLatestLedger(); checks.stellar = "ok"; @@ -104,7 +99,6 @@ app.get("/health", async (_req, res) => { checks.stellar = "error"; } - // Check MQTT by attempting a short-lived connection const broker = process.env.MQTT_BROKER ?? "mqtt://localhost:1883"; try { const client = mqtt.connect(broker, { reconnectPeriod: 0, connectTimeout: 3000 }); @@ -134,53 +128,39 @@ app.get("/metrics", async (_req, res) => { // ── Error handlers ──────────────────────────────────────────────────────────── -// 404 catch-all — must come after all routes app.use((_req: Request, res: Response) => res.status(404).json({ error: "Route not found", code: "NOT_FOUND" }), ); -// Timeout error handler -app.use((err: any, req: any, res: any, next: any) => { +app.use((err: any, req: any, res: Response, _next: NextFunction) => { if (req.timedout) { - logger.error("Request timed out", { - method: req.method, - path: req.path, - timeout: requestTimeout, - }); + logger.error("Request timed out", { method: req.method, path: req.path, timeout: requestTimeout }); return res.status(504).json({ error: "Request timed out", code: "TIMEOUT" }); } - next(err); -}); -// #423: 413 payload too large handler + global error handler (#418) -app.use((err: any, _req: Request, res: Response, _next: NextFunction) => { logger.error({ error: err.message, stack: err.stack }, "Unhandled error"); - const requestId = getReqId(); if (err.type === "entity.too.large") { - return res.status(413).json({ error: "Request body too large", code: "PAYLOAD_TOO_LARGE", requestId }); + return res.status(413).json({ error: "Request body too large", code: "PAYLOAD_TOO_LARGE" }); } if (err.type === "entity.parse.failed" || (err instanceof SyntaxError && (err as any).body !== undefined)) { - return res.status(400).json({ error: "Invalid JSON body", code: "INVALID_JSON", requestId }); + return res.status(400).json({ error: "Invalid JSON body", code: "INVALID_JSON" }); } if ((err as any).status === 404) { - return res.status(404).json({ error: "Resource not found", code: "NOT_FOUND", requestId }); + return res.status(404).json({ error: "Resource not found", code: "NOT_FOUND" }); } if (err.code === "VALIDATION_ERROR" && err.details) { return res .status(400) .json({ error: "Validation failed", code: "VALIDATION_ERROR", details: err.details, requestId }); } - res.status(500).json({ error: err.message || "Internal server error", code: "INTERNAL_ERROR", requestId }); + res.status(500).json({ error: err.message || "Internal server error", code: "INTERNAL_ERROR" }); }); // ── Startup ─────────────────────────────────────────────────────────────────── app.listen(PORT, () => { - logger.info( - { port: PORT, network: process.env.STELLAR_NETWORK ?? "testnet" }, - "SolarGrid backend started", - ); + logger.info({ port: PORT, network: process.env.STELLAR_NETWORK ?? "testnet" }, "SolarGrid backend started"); initUsageEventStore(); initMeterNotesStore(); startUsageEventRetryWorker(); diff --git a/backend/src/lib/adminAuth.ts b/backend/src/lib/adminAuth.ts index 44083cd..352e1c1 100644 --- a/backend/src/lib/adminAuth.ts +++ b/backend/src/lib/adminAuth.ts @@ -31,6 +31,6 @@ export function adminAuth(req: Request, res: Response, next: NextFunction) { return next(); } - logger.warn({ path: req.path, method: req.method }, 'Unauthorized admin request'); + logger.warn({ ip: req.ip, path: req.path, method: req.method }, 'Unauthorized admin request'); return res.status(401).json({ error: 'Unauthorized', code: 'UNAUTHORIZED' }); } diff --git a/backend/src/lib/meterNotes.ts b/backend/src/lib/meterNotes.ts index fa0a97b..6e13eaa 100644 --- a/backend/src/lib/meterNotes.ts +++ b/backend/src/lib/meterNotes.ts @@ -9,12 +9,11 @@ const DB_PATH = export type MeterNoteRecord = { id: number; meter_id: string; + author_ip: string | null; text: string; created_at: string; }; -// Cast needed: `moduleResolution: node16` resolves better-sqlite3's export= type -// such that the instance type loses its namespace-declared methods at this call site. // eslint-disable-next-line @typescript-eslint/no-explicit-any const db = openDatabase() as any; @@ -28,6 +27,7 @@ function openDatabase() { CREATE TABLE IF NOT EXISTS meter_notes ( id INTEGER PRIMARY KEY AUTOINCREMENT, meter_id TEXT NOT NULL, + author_ip TEXT, text TEXT NOT NULL, created_at TEXT NOT NULL ); @@ -35,6 +35,13 @@ function openDatabase() { CREATE INDEX IF NOT EXISTS idx_meter_notes_meter_created ON meter_notes (meter_id, created_at DESC); `); + + // Migrate existing tables that lack the author_ip column + const cols = database.pragma("table_info(meter_notes)") as Array<{ name: string }>; + if (!cols.some((c) => c.name === "author_ip")) { + database.exec(`ALTER TABLE meter_notes ADD COLUMN author_ip TEXT`); + } + return database; } @@ -42,35 +49,58 @@ export function initMeterNotesStore() { return db; } -export function addMeterNote(meterId: string, text: string): MeterNoteRecord { +export function addMeterNote(meterId: string, text: string, authorIp?: string): MeterNoteRecord { const createdAt = new Date().toISOString(); const result = db .prepare( - `INSERT INTO meter_notes (meter_id, text, created_at) VALUES (?, ?, ?)`, + `INSERT INTO meter_notes (meter_id, author_ip, text, created_at) VALUES (?, ?, ?, ?)`, ) - .run(meterId, text, createdAt); + .run(meterId, authorIp ?? null, text, createdAt); return { id: Number(result.lastInsertRowid), meter_id: meterId, + author_ip: authorIp ?? null, text, created_at: createdAt, }; } -export function getLatestMeterNotes( - meterId: string, - limit = 5, -): MeterNoteRecord[] { - const rows = db +export function getLatestMeterNotes(meterId: string, limit = 5): MeterNoteRecord[] { + return db .prepare( - `SELECT id, meter_id, text, created_at + `SELECT id, meter_id, author_ip, text, created_at FROM meter_notes WHERE meter_id = ? ORDER BY created_at DESC, id DESC LIMIT ?`, ) .all(meterId, limit) as MeterNoteRecord[]; +} + +export function getAllMeterNotes( + meterId: string, + page: number, + pageSize: number, +): { notes: MeterNoteRecord[]; total: number; page: number; pageSize: number; hasMore: boolean } { + const offset = (page - 1) * pageSize; + const notes = db + .prepare( + `SELECT id, meter_id, author_ip, text, created_at + FROM meter_notes WHERE meter_id = ? + ORDER BY created_at DESC, id DESC + LIMIT ? OFFSET ?`, + ) + .all(meterId, pageSize, offset) as MeterNoteRecord[]; + + const { count } = db + .prepare(`SELECT COUNT(*) as count FROM meter_notes WHERE meter_id = ?`) + .get(meterId) as { count: number }; + + return { notes, total: count, page, pageSize, hasMore: offset + pageSize < count }; +} - return rows; +export function deleteMeterNote(noteId: number): boolean { + const result = db.prepare(`DELETE FROM meter_notes WHERE id = ?`).run(noteId); + return (result.changes as number) > 0; } diff --git a/backend/src/lib/usageEvents.ts b/backend/src/lib/usageEvents.ts index 703ff29..2a82e61 100644 --- a/backend/src/lib/usageEvents.ts +++ b/backend/src/lib/usageEvents.ts @@ -338,7 +338,12 @@ async function submitUsageEvent(id: number) { nextAttemptCount >= MAX_RETRIES ? "failed" : "pending"; if (finalStatus === "failed") { - logger.warn({ eventId: id, meterId: event.meter_id, attempts: nextAttemptCount }, 'Usage event dead-lettered after max retries'); + logger.error({ + eventId: id, + meter_id: event.meter_id, + units: event.units, + last_error: error instanceof Error ? error.message : String(error), + }, 'Usage event transitioned to failed state after max retries'); deadLetterEvents.inc({ meter_id: event.meter_id }); } diff --git a/backend/src/lib/validation.ts b/backend/src/lib/validation.ts index b2295fa..6a9757f 100644 --- a/backend/src/lib/validation.ts +++ b/backend/src/lib/validation.ts @@ -40,13 +40,19 @@ export const UsageUpdateSchema = z }) .strict(); +export const MqttPayloadSchema = z.object({ + meterId: MeterIdSchema, + units: z.number().int("units must be an integer").positive("units must be positive"), + cost: z.number().int("cost must be an integer").positive("cost must be positive"), +}); + export const MeterNoteSchema = z .object({ text: z .string() .trim() .min(1, "text is required") - .max(2000, "text must be at most 2000 characters"), + .max(1000, "text must be at most 1000 characters"), }) .strict(); diff --git a/backend/src/middleware/rateLimit.ts b/backend/src/middleware/rateLimit.ts index ee49cf7..1de4208 100644 --- a/backend/src/middleware/rateLimit.ts +++ b/backend/src/middleware/rateLimit.ts @@ -17,3 +17,11 @@ export const readLimiter = rateLimit({ standardHeaders: true, legacyHeaders: false, }); + +export const paymentsLimiter = rateLimit({ + windowMs, + max: parseInt(process.env.PAYMENTS_RATE_LIMIT_MAX ?? '10', 10), + standardHeaders: true, + legacyHeaders: false, + message: { error: 'Too many payment requests', code: 'RATE_LIMITED' }, +}); diff --git a/backend/src/routes/meters.ts b/backend/src/routes/meters.ts index e2f1f11..8e2428b 100644 --- a/backend/src/routes/meters.ts +++ b/backend/src/routes/meters.ts @@ -1,7 +1,6 @@ import { Router } from "express"; import * as StellarSdk from "@stellar/stellar-sdk"; -import { contractQuery, adminInvoke } from "../lib/stellar.js"; -import { StellarService, stellarService, server } from "../lib/stellar.js"; +import { StellarService, server } from "../lib/stellar.js"; import { getUsageHistory, persistAndSubmitUsageEvent, @@ -10,6 +9,8 @@ import { import { addMeterNote, getLatestMeterNotes, + getAllMeterNotes, + deleteMeterNote, } from "../lib/meterNotes.js"; import { asyncHandler } from "../lib/asyncHandler.js"; import { @@ -37,6 +38,12 @@ export function createMeterRouter(stellar: StellarService) { * * Fixes #268. */ + /** + * GET /api/meters?page=1&pageSize=20 — list all meters with pagination + * + * Registered BEFORE /:id so the literal string "meters" is never matched + * as a meter ID parameter. + */ meterRouter.get( "/", asyncHandler(async (req, res) => { @@ -357,12 +364,60 @@ export function createMeterRouter(stellar: StellarService) { return res.status(404).json({ error: "Meter not found", code: "NOT_FOUND" }); } - const note = addMeterNote(meterId, req.body.text); + const note = addMeterNote(meterId, req.body.text, req.ip); invalidateCache(`/api/meters/${meterId}`); res.status(201).json(note); }), ); + /** GET /api/meters/:id/notes — all notes for a meter (paginated, no auth required) */ + meterRouter.get( + "/:id/notes", + asyncHandler(async (req, res) => { + const page = Math.max(1, Number(req.query.page ?? 1) || 1); + const pageSize = Math.min(100, Math.max(1, Number(req.query.pageSize ?? 20) || 20)); + const result = getAllMeterNotes(req.params.id, page, pageSize); + res.json(result); + }), + ); + + /** POST /api/meters/:id/notes — create a note (admin only) */ + meterRouter.post( + "/:id/notes", + requireAdminKey, + validateRequest({ body: MeterNoteSchema }), + asyncHandler(async (req, res) => { + const meterId = req.params.id; + try { + await stellar.query("get_meter", [ + StellarSdk.nativeToScVal(meterId, { type: "symbol" }), + ]); + } catch { + return res.status(404).json({ error: "Meter not found", code: "NOT_FOUND" }); + } + const note = addMeterNote(meterId, req.body.text, req.ip); + invalidateCache(`/api/meters/${meterId}`); + res.status(201).json(note); + }), + ); + + /** DELETE /api/meters/:id/notes/:noteId — hard-delete a note (admin only) */ + meterRouter.delete( + "/:id/notes/:noteId", + requireAdminKey, + asyncHandler(async (req, res) => { + const noteId = Number(req.params.noteId); + if (!Number.isInteger(noteId) || noteId <= 0) { + return res.status(400).json({ error: "Invalid noteId", code: "VALIDATION_ERROR" }); + } + const deleted = deleteMeterNote(noteId); + if (!deleted) { + return res.status(404).json({ error: "Note not found", code: "NOT_FOUND" }); + } + res.json({ deleted: true, noteId }); + }), + ); + /** GET /api/meters/:id/access — check if meter is active */ meterRouter.get( "/:id/access", @@ -546,16 +601,20 @@ export function createMeterRouter(stellar: StellarService) { }), ); - /** GET /api/meters/:id/history — paginated local usage history */ + /** GET /api/meters/:id/history?page=1&pageSize=20 — paginated local usage history */ meterRouter.get("/:id/history", (req, res) => { - const page = Math.max(1, Number(req.query.page ?? 1) || 1); - const pageSize = Math.min( - 100, - Math.max(1, Number(req.query.pageSize ?? 25) || 25), - ); + const rawPage = Number(req.query.page ?? 1); + const rawPageSize = Number(req.query.pageSize ?? 20); + + if (!Number.isInteger(rawPage) || rawPage < 1) { + return res.status(400).json({ error: "page must be a positive integer", code: "VALIDATION_ERROR" }); + } + if (!Number.isInteger(rawPageSize) || rawPageSize < 1 || rawPageSize > 100) { + return res.status(400).json({ error: "pageSize must be between 1 and 100", code: "VALIDATION_ERROR" }); + } try { - const history = getUsageHistory(req.params.id, page, pageSize); + const history = getUsageHistory(req.params.id, rawPage, rawPageSize); res.json(history); } catch (err: any) { res.status(500).json({ error: err.message, code: "INTERNAL_ERROR" }); diff --git a/backend/src/routes/stats.ts b/backend/src/routes/stats.ts index 8277000..2e1ace1 100644 --- a/backend/src/routes/stats.ts +++ b/backend/src/routes/stats.ts @@ -14,8 +14,6 @@ const CACHE_TTL_MS = 60_000; // Cache for the contract-based stats endpoint (30s TTL) let contractCache: { data: object; expiresAt: number } | null = null; - -// Cache for the prom-client metrics summary endpoint (15s TTL) let metricsCache: { data: object; expiresAt: number } | null = null; // Cache for meter counts grouped by plan (30s TTL) @@ -54,19 +52,15 @@ const emptyPlanBreakdown = (): MeterPlanBreakdown => ({ const normalizePlan = (plan: unknown): keyof Omit | null => { const raw = - typeof plan === "string" - ? plan - : typeof plan === "symbol" - ? plan.toString() - : plan && typeof plan === "object" - ? String( - (plan as { tag?: unknown; name?: unknown; variant?: unknown }).tag ?? - (plan as { tag?: unknown; name?: unknown; variant?: unknown }).name ?? - (plan as { tag?: unknown; name?: unknown; variant?: unknown }).variant ?? - "", - ) - : ""; - + typeof plan === "string" ? plan + : typeof plan === "symbol" ? plan.toString() + : plan && typeof plan === "object" + ? String( + (plan as { tag?: unknown; name?: unknown; variant?: unknown }).tag ?? + (plan as { tag?: unknown; name?: unknown; variant?: unknown }).name ?? + (plan as { tag?: unknown; name?: unknown; variant?: unknown }).variant ?? "", + ) + : ""; const normalized = raw.toLowerCase().replace(/[^a-z]/g, ""); if (normalized === "daily") return "Daily"; if (normalized === "weekly") return "Weekly"; @@ -166,15 +160,8 @@ statsRouter.get("/", asyncHandler(async (_req, res) => { const avgUnitsPerMeter = total > 0 ? units / total : 0; const avgRevenue = total > 0 ? revenue / total : 0; - const data = { - totalMeters: total, - activeMeters: active, - inactiveMeters: total - active, - totalUnits: units, - avgUnitsPerMeter, - totalRevenue: revenue, - avgRevenue, - }; + const data = { totalMeters: total, activeMeters: active, inactiveMeters: total - active, + totalUnits: units, avgUnitsPerMeter, totalRevenue: revenue, avgRevenue }; contractCache = { data, expiresAt: Date.now() + 30_000 }; res.json(data); })); @@ -188,7 +175,6 @@ statsRouter.get("/summary", asyncHandler(async (_req, res) => { } const metrics = await register.getMetricsAsJSON(); - const find = (name: string): number => { const metric = metrics.find((m: any) => m.name === name); if (!metric?.values) return 0; @@ -201,7 +187,6 @@ statsRouter.get("/summary", asyncHandler(async (_req, res) => { activeMeters: find("solargrid_active_meters"), paymentVolumeXlm: find("solargrid_payment_volume_xlm"), }; - metricsCache = { data, expiresAt: Date.now() + 15_000 }; res.json(data); })); diff --git a/backend/src/routes/webhooks.ts b/backend/src/routes/webhooks.ts index 55b7a90..b8ad1a3 100644 --- a/backend/src/routes/webhooks.ts +++ b/backend/src/routes/webhooks.ts @@ -117,13 +117,21 @@ webhookRouter.post( }); } - const { webhook_url } = req.body; + // Store in environment for legacy bridge compatibility + process.env.PROVIDER_WEBHOOK_URL = webhook_url; + + let secretHash: string | undefined; + if (secret) { + process.env.PROVIDER_WEBHOOK_SECRET = secret; + secretHash = crypto.createHash("sha256").update(secret).digest("hex"); + } const record = registerWebhook(providerId, webhook_url); logger.info("Low-balance webhook registered", { provider_id: providerId, webhook_url, + secretHash, }); return res.status(200).json({