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
16 changes: 16 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,22 @@ on:
pull_request:

jobs:
# Closes #538: guard against duplicate package.json keys.
# Standard JSON.parse() silently keeps the last occurrence, so npm install
# succeeding is NOT sufficient proof a package.json is duplicate-free.
check-pkg-duplicates:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 22
- name: Check for duplicate keys in package.json files
run: |
node scripts/check-duplicate-pkg-keys.js \
backend/package.json \
frontend/package.json

contract:
runs-on: ubuntu-latest
steps:
Expand Down
33 changes: 32 additions & 1 deletion Makefile
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
.PHONY: build test deploy invoke-register invoke-allowlist logs clean
.PHONY: build test deploy invoke-register invoke-allowlist migrate-all logs clean

NETWORK ?= testnet
WASM := contracts/target/wasm32-unknown-unknown/release/solar_grid.wasm
Expand All @@ -18,6 +18,37 @@ invoke-register:
invoke-allowlist:
stellar contract invoke --id $(CONTRACT_ID) --source $(ADMIN_SECRET_KEY) --network $(NETWORK) -- allowlist_add --owner $(OWNER)

## Bulk-migrate all registered meters to the current schema.
##
## Closes #536: replaces the manual one-at-a-time stellar contract invoke
## … migrate_meter workflow with a single command.
##
## Required:
## CONTRACT_ID — deployed Soroban contract address
## ADMIN_SECRET_KEY — admin Stellar secret key (S…)
##
## Optional:
## NETWORK — testnet (default) or mainnet
## DRY_RUN — set to "true" to list meters without migrating
##
## Example:
## make migrate-all CONTRACT_ID=C... ADMIN_SECRET_KEY=S...
## make migrate-all CONTRACT_ID=C... ADMIN_SECRET_KEY=S... DRY_RUN=true
migrate-all:
@if [ -z "$(CONTRACT_ID)" ]; then \
echo "ERROR: CONTRACT_ID is required. Usage: make migrate-all CONTRACT_ID=C... ADMIN_SECRET_KEY=S..."; \
exit 1; \
fi
@if [ -z "$(ADMIN_SECRET_KEY)" ]; then \
echo "ERROR: ADMIN_SECRET_KEY is required. Usage: make migrate-all CONTRACT_ID=C... ADMIN_SECRET_KEY=S..."; \
exit 1; \
fi
CONTRACT_ID=$(CONTRACT_ID) \
ADMIN_SECRET_KEY=$(ADMIN_SECRET_KEY) \
STELLAR_NETWORK=$(NETWORK) \
DRY_RUN=$(DRY_RUN) \
node scripts/migrate-all.js

logs:
docker compose logs -f backend

Expand Down
58 changes: 46 additions & 12 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,7 @@ A Makefile is provided at the repository root to simplify common development and
- **Invoke functions**:
- `make invoke-register CONTRACT_ID=<id> ADMIN_SECRET_KEY=<key> METER_ID=<meter_id> OWNER=<owner>` registers a new meter.
- `make invoke-allowlist CONTRACT_ID=<id> ADMIN_SECRET_KEY=<key> OWNER=<owner>` adds an owner to the allowlist.
- **Bulk migrate all meters**: `make migrate-all CONTRACT_ID=<id> ADMIN_SECRET_KEY=<key>` — see [Contract Upgrades](#contract-upgrades) below.
- **Backend Logs**: `make logs` streams Docker Compose logs for the backend.

### Smart Contracts Deployment CI/CD
Expand Down Expand Up @@ -127,9 +128,26 @@ You can run the Prometheus and Grafana observability stack alongside the backend
docker compose --profile observability up --build
```

- **Prometheus** scrapes the backend metrics (`/metrics`) every 15 seconds, and is accessible at `http://localhost:9090`.
- **Prometheus** scrapes the backend metrics endpoint (`GET /metrics`) every 15 seconds via `infra/prometheus.yml`, and is accessible at `http://localhost:9090`.
- **Grafana** is preconfigured with the Prometheus datasource and is accessible at `http://localhost:3000` (default credentials: `admin` / `admin`). It features dashboard panels for MQTT messages/min, contract calls by method/status, and error rates.

#### `/metrics` — intentionally public (closes #537)

The `GET /metrics` endpoint exposes Prometheus text-format data with **no authentication**. This is by design: Prometheus's pull model requires unauthenticated HTTP GET access to scrape metrics from a target.

**Why this is safe in the default deployment:** the backend container port `3001` is attached only to the internal Docker `app-network` and is not forwarded to a public interface. The Prometheus container scrapes it from within that private network. External traffic never reaches `/metrics`.

**If you expose the backend on a public port** (e.g. via a reverse proxy or `ports: "3001:3001"` in `docker-compose.override.yml`), you should restrict access to `/metrics` at the proxy layer — for example:

```nginx
# nginx — deny external access to /metrics
location /metrics {
deny all;
}
```

Or allow only the Prometheus container's IP via a firewall rule. A `METRICS_ALLOWED_CIDRS` env-var-driven IP-allowlist middleware can also be added to `backend/src/index.ts` in a future hardening pass.

## Smart Contract Overview

The `SolarGrid` contract manages:
Expand Down Expand Up @@ -180,18 +198,34 @@ The `Meter` struct carries a `version: u32` field (currently `1`). When the stru
### Migration flow

1. Deploy the new contract WASM (the old entries remain in persistent storage).
2. For each registered meter, call the admin-only `migrate_meter(meter_id)` function.
It reads the entry as the previous schema (`LegacyMeter`) and writes it back as the current `Meter` v1.
3. Once all entries are migrated, the `LegacyMeter` type and `migrate_meter_v0` helper can be removed in a subsequent release.
2. Run the bulk migration helper to migrate every registered meter in one pass:

```bash
# Example: migrate a single meter via Stellar CLI
stellar contract invoke \
--id <CONTRACT_ID> \
--source <ADMIN_SECRET> \
--network testnet \
-- migrate_meter --meter_id METER1
```
```bash
# Recommended: migrate all meters at once (closes #536)
make migrate-all CONTRACT_ID=<id> ADMIN_SECRET_KEY=<key> NETWORK=testnet
```

The script calls `get_all_meters` to fetch every registered meter ID, then
calls `migrate_meter(meter_id)` for each one, logging per-meter
success/failure and printing a final summary. Exit code is `0` only when
all meters succeed, so it integrates cleanly into CI pipelines.

```bash
# Dry-run: list meters without sending any transactions
make migrate-all CONTRACT_ID=<id> ADMIN_SECRET_KEY=<key> DRY_RUN=true
```

3. Alternatively, migrate a single meter manually via the Stellar CLI:

```bash
stellar contract invoke \
--id <CONTRACT_ID> \
--source <ADMIN_SECRET> \
--network testnet \
-- migrate_meter --meter_id METER1
```

4. Once all entries are migrated, the `LegacyMeter` type and `migrate_meter_v0` helper can be removed in a subsequent release.

> **Note:** `migrate_meter` is idempotent per entry — calling it on an already-migrated meter will overwrite with the same data. Always test migrations on testnet before mainnet.

Expand Down
45 changes: 45 additions & 0 deletions backend/src/config/rateLimits.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
/**
* config/rateLimits.ts — single source of truth for all rate-limiter config.
*
* Closes #539: previously, middleware/rateLimit.ts and index.ts each parsed
* the same RATE_LIMIT_WINDOW_MS / RATE_LIMIT_MAX env vars independently,
* creating two diverging sources of truth that could silently drift apart.
*
* Both files now import from here so any future tuning only needs to happen
* in one place.
*/

/** Window duration in milliseconds (default: 60 s). */
export const RATE_LIMIT_WINDOW_MS = Number(
process.env.RATE_LIMIT_WINDOW_MS ?? 60 * 1000,
);

/**
* Maximum requests per window for the general / global limiter (default: 60).
* This is also used as the ceiling for read-heavy routes.
*/
export const RATE_LIMIT_MAX = Number(process.env.RATE_LIMIT_MAX ?? 60);

/**
* Maximum write requests per window for the strict write limiter (default:
* 30). Used by writeLimiter (admin login, webhooks, allowlist, etc.).
* Setting this lower than RATE_LIMIT_MAX intentionally throttles mutating
* operations harder than reads.
*/
export const WRITE_RATE_LIMIT_MAX = Number(
process.env.WRITE_RATE_LIMIT_MAX ?? 30,
);

/**
* Maximum payment requests per window (default: 10). Payments hit the
* Stellar network and cost gas, so they get a tighter budget than generic
* writes.
*/
export const PAYMENTS_RATE_LIMIT_MAX = Number(
process.env.PAYMENTS_RATE_LIMIT_MAX ?? 10,
);

/** Human-readable message returned to clients that exceed any limiter. */
export const RATE_LIMIT_MESSAGE =
process.env.RATE_LIMIT_MESSAGE ??
"Too many requests, please try again later.";
Loading