Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions backend/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
37 changes: 37 additions & 0 deletions backend/API.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -91,6 +103,31 @@ Submit a payment for a meter.
{ "hash": "<transaction-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": "<tx_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.
Expand Down
2 changes: 2 additions & 0 deletions backend/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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).

Expand Down
94 changes: 94 additions & 0 deletions backend/src/lib/requestLogger.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown> = {};
for (const [key, val] of Object.entries(value as Record<string, unknown>)) {
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();
};
}
9 changes: 9 additions & 0 deletions backend/src/lib/validation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
36 changes: 36 additions & 0 deletions backend/src/routes/meters.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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<string>();
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",
Expand Down
3 changes: 3 additions & 0 deletions contracts/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
77 changes: 77 additions & 0 deletions contracts/solar_grid/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<Vec<bool>, 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<String> = env
.storage()
.instance()
.get(&METER_LIST)
.unwrap_or_else(|| vec![&env]);

let mut seen: Vec<String> = vec![&env];
let mut results: Vec<bool> = 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<String> = 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<Vec<String>, ContractError> {
let owner_key = DataKey::OwnerMeters(owner);
Expand Down
36 changes: 36 additions & 0 deletions docs/ACCESSIBILITY.md
Original file line number Diff line number Diff line change
@@ -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: `<nav aria-label="Main navigation">` and a single `<main>` per
page.

## Testing

- Automated: run `axe-core` against each route in CI/local dev.
- Manual: verify with a keyboard only (no mouse) and with a screen reader
(NVDA, JAWS, or VoiceOver) on the dashboard, pay, and history pages.
Loading
Loading