From 347babafc873272970ebfab71ce96c2d660e91b8 Mon Sep 17 00:00:00 2001 From: autostack-art Date: Wed, 26 Aug 2026 15:29:51 +0000 Subject: [PATCH] Add batch meter registration, receipts, request logging, a11y improvements MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - contracts: add batch_register_meters(meters: Vec<(String, Address)>) to register up to 100 meters in a single admin transaction, skipping duplicates/non-allowlisted owners/already-registered IDs with a batch_skip event, and emitting meter_registered per success (#682) - backend: wire batch_register_meters via new POST /api/meters/batch, with request validation and duplicate-id rejection (#682) - backend: add requestLogger middleware — per-request request_id (X-Request-Id header), sensitive-field redaction, 10% sampling of successful requests with all errors logged, toggle via LOG_REQUESTS (#684) - frontend: add "Download Receipt" PDF generation (jsPDF + QR code linking to the blockchain explorer) on payment history rows (#683) - frontend: accessibility pass — skip-to-content link, high-contrast focus-visible outline, modal focus trap + Escape-to-close (useModalA11y), Escape closes mobile nav, aria-labels on icon buttons, nav landmark label, main-content landmarks on every page (#681) - docs: document the above in backend/API.md, contracts/README.md, and new docs/ACCESSIBILITY.md Closes #681, #682, #683, #684 Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01HL4h1sEGCEoCUuH1qxex3j --- backend/.env.example | 5 + backend/API.md | 37 ++ backend/src/index.ts | 6 +- backend/src/lib/requestLogger.ts | 94 ++++ backend/src/lib/validation.ts | 9 + backend/src/routes/meters.ts | 37 +- contracts/README.md | 3 + contracts/solar_grid/src/lib.rs | 77 ++++ docs/ACCESSIBILITY.md | 36 ++ frontend/package-lock.json | 413 +++++++++++++++++- frontend/package.json | 3 + frontend/src/app/dashboard/provider/page.tsx | 2 +- frontend/src/app/dashboard/user/page.tsx | 2 +- frontend/src/app/globals.css | 27 ++ frontend/src/app/history/page.tsx | 49 ++- frontend/src/app/layout.tsx | 3 + frontend/src/app/page.tsx | 2 +- frontend/src/app/pay/page.tsx | 2 +- frontend/src/components/Navbar.tsx | 15 +- .../src/components/OfflinePaymentModal.tsx | 9 +- frontend/src/hooks/useModalA11y.ts | 57 +++ frontend/src/lib/receipt.ts | 99 +++++ 22 files changed, 966 insertions(+), 21 deletions(-) create mode 100644 backend/src/lib/requestLogger.ts create mode 100644 docs/ACCESSIBILITY.md create mode 100644 frontend/src/hooks/useModalA11y.ts create mode 100644 frontend/src/lib/receipt.ts diff --git a/backend/.env.example b/backend/.env.example index f58b96a..27934a6 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -23,3 +23,8 @@ MQTT_MAX_RECONNECT_ATTEMPTS=10 # Low-balance webhook configuration (optional) 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 diff --git a/backend/API.md b/backend/API.md index ff163ed..1624b20 100644 --- a/backend/API.md +++ b/backend/API.md @@ -39,6 +39,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. @@ -76,6 +88,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 54c7ce8..ed2bf4f 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -9,6 +9,7 @@ import { paymentsRouter } from "./routes/payments.js"; import { webhookRouter } from "./routes/webhooks.js"; import { startIoTBridge } from "./iot/bridge.js"; import { logger } from "./lib/logger.js"; +import { requestLogger } from "./lib/requestLogger.js"; import { initUsageEventStore, startUsageEventRetryWorker, @@ -55,10 +56,7 @@ app.use((req: any, _res: any, next: any) => { if (!req.timedout) next(); }); -app.use((req, _res, next) => { - logger.info({ method: req.method, path: req.path }); - next(); -}); +app.use(requestLogger()); app.use("/api/meters", createMeterRouter(stellarService)); app.use("/api/payments", paymentsRouter); 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 9d901a7..a3992c1 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 f903a4a..c176a9b 100644 --- a/backend/src/routes/meters.ts +++ b/backend/src/routes/meters.ts @@ -6,7 +6,11 @@ import { persistAndSubmitUsageEvent, } from "../lib/usageEvents.js"; import { asyncHandler } from "../lib/asyncHandler.js"; -import { validateRequest, RegisterMeterSchema } from "../lib/validation.js"; +import { + validateRequest, + RegisterMeterSchema, + BatchRegisterMetersSchema, +} from "../lib/validation.js"; const balanceCache = new Map(); const BALANCE_CACHE_TTL_MS = 5_000; // 5-second cache to reduce RPC load @@ -158,6 +162,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/:id/usage — IoT oracle reports usage */ meterRouter.post("/:id/usage", async (req, res) => { const { units, cost } = req.body as { units: unknown; cost: unknown }; diff --git a/contracts/README.md b/contracts/README.md index 539e895..918853b 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 32207be..ec7756b 100644 --- a/contracts/solar_grid/src/lib.rs +++ b/contracts/solar_grid/src/lib.rs @@ -238,6 +238,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: `