diff --git a/backend/.env.example b/backend/.env.example index 1c89ff5..43e1743 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -44,6 +44,10 @@ MQTT_MAX_RECONNECT_ATTEMPTS=10 PROVIDER_WEBHOOK_URL=https://example.com/webhook LOW_BALANCE_THRESHOLD=1000000 +# Request/response logging middleware. Set to 'false' to disable entirely. +# When enabled, all errors are logged and 10% of successful requests are sampled. +# Sensitive fields (secrets, tokens, passwords, API keys) are redacted. Default: true +LOG_REQUESTS=true # Web Push configuration (optional; required to enable browser push notifications) WEB_PUSH_VAPID_SUBJECT=mailto:alerts@example.com WEB_PUSH_VAPID_PUBLIC_KEY=YOUR_VAPID_PUBLIC_KEY diff --git a/backend/API.md b/backend/API.md index 7b2d410..c9e4335 100644 --- a/backend/API.md +++ b/backend/API.md @@ -49,6 +49,18 @@ To also remove volumes (e.g., for a clean restart): docker-compose down -v ``` +## Request/Response Logging + +All requests are logged by `requestLogger` middleware (`backend/src/lib/requestLogger.ts`): + +- Every request gets a unique `request_id` (also returned as the `X-Request-Id` + response header) linking its request and response log lines. +- Sensitive fields (secrets, tokens, passwords, API keys) are redacted from + logged request bodies. +- Errors (status >= 400) are always logged; successful requests are sampled + at 10% by default to limit log volume. +- Disable entirely by setting `LOG_REQUESTS=false`. + ## Idempotency Payment endpoints support the `Idempotency-Key` header to prevent duplicate submissions on network retries. @@ -91,6 +103,31 @@ Submit a payment for a meter. { "hash": "" } ``` +## Batch Meter Registration + +### `POST /api/meters/batch` + +Registers up to 100 meters in a single on-chain transaction (admin only), +wrapping the contract's `batch_register_meters` function. + +Request body: + +```json +{ + "meters": [ + { "meter_id": "METER1", "owner": "GABC...XYZ" }, + { "meter_id": "METER2", "owner": "GDEF...UVW" } + ] +} +``` + +- Rejects (400) if `meters` is empty, exceeds 100 entries, or contains + duplicate `meter_id` values within the request. +- Entries whose owner is not allowlisted, or whose `meter_id` already exists + on-chain, are skipped rather than failing the whole batch (see the + `batch_skip` event in `contracts/README.md`). +- Response: `{ "hash": "", "meter_ids": [...] }`. + ## Low-Balance Webhook Notifications Providers can register webhook URLs to receive notifications when a customer's meter balance drops below a configurable threshold. diff --git a/backend/src/index.ts b/backend/src/index.ts index fbf8c55..fb1f479 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -30,6 +30,7 @@ import { solarRouter } from "./routes/solar.js"; import { usageEventsRouter } from "./routes/usageEvents.js"; import { startIoTBridge } from "./iot/bridge.js"; import { logger } from "./lib/logger.js"; +import { requestLogger } from "./lib/requestLogger.js"; import { register } from "./lib/metrics.js"; import { writeLimiter, paymentsLimiter } from "./middleware/rateLimit.js"; import { sanitiseBody } from "./middleware/sanitise.js"; @@ -145,6 +146,7 @@ app.use((req: any, _res: any, next: any) => { if (!req.timedout) next(); }); +app.use(requestLogger()); // ── Rate limiters ───────────────────────────────────────────────────────────── // Env-var parsing is centralised in config/rateLimits.ts (closes #539). diff --git a/backend/src/lib/requestLogger.ts b/backend/src/lib/requestLogger.ts new file mode 100644 index 0000000..9c3131e --- /dev/null +++ b/backend/src/lib/requestLogger.ts @@ -0,0 +1,94 @@ +import { randomUUID } from "crypto"; +import type { NextFunction, Request, Response } from "express"; +import { logger } from "./logger.js"; + +// Field names (case-insensitive) whose values are never written to logs. +const SENSITIVE_FIELDS = [ + "secret", + "adminsecretkey", + "admin_secret_key", + "secretkey", + "secret_key", + "privatekey", + "private_key", + "password", + "apikey", + "api_key", + "token", + "authorization", +]; + +function isSensitiveKey(key: string): boolean { + const normalized = key.toLowerCase(); + return SENSITIVE_FIELDS.some((field) => normalized.includes(field)); +} + +/** Deep-clones a value, replacing any sensitive field values with "[REDACTED]". */ +function redact(value: unknown): unknown { + if (Array.isArray(value)) { + return value.map((item) => redact(item)); + } + if (value && typeof value === "object") { + const out: Record = {}; + for (const [key, val] of Object.entries(value as Record)) { + out[key] = isSensitiveKey(key) ? "[REDACTED]" : redact(val); + } + return out; + } + return value; +} + +export interface RequestLoggerOptions { + /** Fraction (0-1) of successful (status < 400) requests to log. Errors are always logged. */ + sampleRate?: number; +} + +/** + * Express middleware that logs request/response pairs with a shared request_id. + * + * - Assigns a unique `request_id` per request (also set on the `X-Request-Id` response header). + * - Redacts sensitive fields (secrets, tokens, passwords, API keys) from logged bodies. + * - Samples successful requests (default 10%); errors (status >= 400) are always logged. + * - Enable/disable via the `LOG_REQUESTS` env var (default: enabled). + */ +export function requestLogger(options: RequestLoggerOptions = {}) { + const sampleRate = options.sampleRate ?? 0.1; + + return function requestLoggerMiddleware(req: Request, res: Response, next: NextFunction) { + if (process.env.LOG_REQUESTS === "false") { + return next(); + } + + const requestId = randomUUID(); + (req as Request & { requestId: string }).requestId = requestId; + res.setHeader("X-Request-Id", requestId); + + const startedAt = Date.now(); + + res.on("finish", () => { + const isError = res.statusCode >= 400; + const sampled = isError || Math.random() < sampleRate; + if (!sampled) return; + + logger.info("request", { + type: "request", + method: req.method, + path: req.path, + request_id: requestId, + ip: req.ip, + user_agent: req.get("user-agent"), + body: redact(req.body), + }); + + logger.info("response", { + type: "response", + request_id: requestId, + status: res.statusCode, + duration_ms: Date.now() - startedAt, + body_size: Number(res.get("content-length")) || 0, + }); + }); + + next(); + }; +} diff --git a/backend/src/lib/validation.ts b/backend/src/lib/validation.ts index 9dda000..d2da71f 100644 --- a/backend/src/lib/validation.ts +++ b/backend/src/lib/validation.ts @@ -27,6 +27,15 @@ export const RegisterMeterSchema = z }) .strict(); +export const BatchRegisterMetersSchema = z + .object({ + meters: z + .array(RegisterMeterSchema) + .min(1, "meters must contain at least one entry") + .max(100, "batch size cannot exceed 100 meters"), + }) + .strict(); + export const UsageUpdateSchema = z .object({ units: z diff --git a/backend/src/routes/meters.ts b/backend/src/routes/meters.ts index 24e1c1a..bf8959c 100644 --- a/backend/src/routes/meters.ts +++ b/backend/src/routes/meters.ts @@ -14,6 +14,11 @@ import { deleteMeterNote, } from "../lib/meterNotes.js"; import { asyncHandler } from "../lib/asyncHandler.js"; +import { + validateRequest, + RegisterMeterSchema, + BatchRegisterMetersSchema, +} from "../lib/validation.js"; import { logger } from "../lib/logger.js"; import { validateRequest, RegisterMeterSchema } from "../lib/validation.js"; import { @@ -725,6 +730,37 @@ export function createMeterRouter(stellar: StellarService) { }), ); + /** POST /api/meters/batch — register multiple meters in a single transaction (admin only) */ + meterRouter.post( + "/batch", + validateRequest({ body: BatchRegisterMetersSchema }), + asyncHandler(async (req, res) => { + const { meters } = req.body as { meters: { meter_id: string; owner: string }[] }; + + const seen = new Set(); + const duplicates = meters + .map((m) => m.meter_id) + .filter((id) => (seen.has(id) ? true : (seen.add(id), false))); + if (duplicates.length > 0) { + return res.status(400).json({ + error: "Duplicate meter_id values in batch", + duplicates: [...new Set(duplicates)], + }); + } + + const entries = meters.map(({ meter_id, owner }) => + StellarSdk.xdr.ScVal.scvVec([ + StellarSdk.nativeToScVal(meter_id, { type: "symbol" }), + StellarSdk.nativeToScVal(owner, { type: "address" }), + ]), + ); + const encoded = StellarSdk.xdr.ScVal.scvVec(entries); + + const hash = await stellar.invoke("batch_register_meters", [encoded]); + res.json({ hash, meter_ids: meters.map((m) => m.meter_id) }); + }), + ); + /** POST /api/meters/batch — batch register meters (admin only) */ meterRouter.post( "/batch", diff --git a/contracts/README.md b/contracts/README.md index c47ee6c..a8a95b6 100644 --- a/contracts/README.md +++ b/contracts/README.md @@ -55,6 +55,9 @@ Emitted when a meter is deactivated (balance drained to zero or via `set_active( - **Data:** `()` (empty) Emitted when a meter ID in `batch_update_usage` is not found and skipped. +Also emitted (with the same shape) by `batch_register_meters` for each entry +skipped because the meter ID already exists, is duplicated within the batch, +or the owner is not on the allowlist. #### revenue_withdrawn - **Topic 0:** `rev_wdrl` (symbol_short) diff --git a/contracts/solar_grid/src/lib.rs b/contracts/solar_grid/src/lib.rs index 73f0c17..3af822e 100644 --- a/contracts/solar_grid/src/lib.rs +++ b/contracts/solar_grid/src/lib.rs @@ -305,6 +305,83 @@ impl SolarGridContract { Ok(()) } + /// Register multiple new smart meters in a single transaction. + /// + /// Accepts a vector of `(meter_id, owner)` tuples. Each entry is skipped + /// (rather than aborting the whole batch) if the meter_id already exists, + /// is duplicated within the batch, or the owner is not on the allowlist; + /// a `batch_skip` event is emitted for each skip and `meter_registered` + /// for each success. Returns one bool per input entry (true = registered) + /// in the same order as the input. Admin-only. Maximum batch size: 100. + pub fn batch_register_meters( + env: Env, + meters: Vec<(String, Address)>, + ) -> Result, ContractError> { + Self::require_admin(&env)?; + if meters.len() > 100 { + return Err(ContractError::BatchTooLarge); + } + let allowlist = Self::get_allowlist(env.clone())?; + let now = env.ledger().timestamp(); + + let mut global_list: Vec = env + .storage() + .instance() + .get(&METER_LIST) + .unwrap_or_else(|| vec![&env]); + + let mut seen: Vec = vec![&env]; + let mut results: Vec = vec![&env]; + + for (meter_id, owner) in meters.iter() { + let key = DataKey::Meter(meter_id.clone()); + if seen.contains(&meter_id) + || env.storage().persistent().has(&key) + || !allowlist.contains(&owner) + { + env.events().publish( + (symbol_short!("btch_skip"), EVT_NS, meter_id.clone()), + (), + ); + results.push_back(false); + continue; + } + seen.push_back(meter_id.clone()); + + let meter = Meter { + version: 2, + owner: owner.clone(), + active: false, + units_used: 0, + plan: PaymentPlan::Daily, + last_payment: now, + expires_at: now, + daily_limit: 0, + day_spent: 0, + day_start: now, + }; + env.storage().persistent().set(&key, &meter); + + let owner_key = DataKey::OwnerMeters(owner.clone()); + let mut owner_list: Vec = env + .storage() + .persistent() + .get(&owner_key) + .unwrap_or_else(|| vec![&env]); + owner_list.push_back(meter_id.clone()); + env.storage().persistent().set(&owner_key, &owner_list); + + global_list.push_back(meter_id.clone()); + + env.events() + .publish(("meter", "registered"), (meter_id.clone(), owner.clone())); + results.push_back(true); + } + + env.storage().instance().set(&METER_LIST, &global_list); + Ok(results) + } + /// Get all meter IDs registered under a given owner address. pub fn get_meters_by_owner(env: Env, owner: Address) -> Result, ContractError> { let owner_key = DataKey::OwnerMeters(owner); diff --git a/docs/ACCESSIBILITY.md b/docs/ACCESSIBILITY.md new file mode 100644 index 0000000..18e4732 --- /dev/null +++ b/docs/ACCESSIBILITY.md @@ -0,0 +1,36 @@ +# Accessibility + +Stellar SolarGrid targets WCAG 2.1 AA compliance across the dashboard and +payment flows. + +## Keyboard navigation + +- **Skip link**: a "Skip to main content" link is the first focusable element + on every page (`frontend/src/app/layout.tsx`), jumping to each page's + `#main-content` landmark. +- **Escape closes overlays**: the mobile nav menu and modals (see + `useModalA11y`, used by `OfflinePaymentModal`) close on `Escape`. +- **Focus trap in modals**: `Tab`/`Shift+Tab` cycles only within an open + modal's focusable elements, and focus returns to the triggering element on + close (`frontend/src/hooks/useModalA11y.ts`). +- **Visible focus indicator**: a 3px, high-contrast `:focus-visible` outline + is defined globally in `frontend/src/app/globals.css` so keyboard focus is + always distinguishable from mouse hover. + +## Screen reader support + +- Interactive icon-only controls (theme toggle, wallet copy button, menu + toggle, dismiss buttons) carry descriptive `aria-label`s. +- Toast notifications use `role="alert"` with `aria-live="assertive"` + (`frontend/src/components/Toast.tsx`) so balance/payment updates are + announced as they happen. +- Modals use `role="dialog"`, `aria-modal="true"`, and `aria-labelledby` + pointing at their heading. +- Landmarks: `