Backend service to read/write the deployed PayrollEscrow + WorkAgreement contracts over RPC.
- Copy env template:
cp env.example .env- Fill in:
STARKNET_RPC_URL- (optional)
PAYROLL_ESCROW_ADDRESS,WORK_AGREEMENT_ADDRESS - (optional)
CONTACT_RECIPIENT_EMAIL— recipient for contact-form submissions (required to deliver them)
- Install + run:
npm install
npm run devIf you use pnpm:
pnpm install
pnpm run devFor production:
pnpm startAll configuration is parsed and validated in src/config.ts. env.example has the full annotated list; the main settings and their defaults are:
| Variable | Default | Notes |
|---|---|---|
STARKNET_RPC_URL |
(required) | Comma-separated HTTPS JSON-RPC URLs; failover tries each in order on RPC errors |
NODE_ENV |
development |
production enforces the ABI path guard below |
PORT |
4000 |
|
CORS_ORIGIN |
* |
See the CORS Configuration section |
POSTGRES_CONNECTION_STRING |
postgresql://localhost:5432/stellopay_indexer |
|
RATE_LIMIT_WINDOW_MS / RATE_LIMIT_MAX |
900000 / 100 |
Global rate limiter |
RATE_LIMIT_STRICT_WINDOW_MS / RATE_LIMIT_STRICT_MAX |
300000 / 10 |
Auth and contact limiter |
TRUST_PROXY |
1 |
Number of proxies, or true |
SHUTDOWN_DRAIN_TIMEOUT_MS |
10000 |
Graceful shutdown drain timeout |
TOKEN_METADATA_CACHE_TTL_MS |
300000 |
In-memory token metadata cache lifetime |
BILLING_ENABLED |
false |
Only the literal true enables billing routes |
CONTACT_RECIPIENT_EMAIL |
(none) | Must be a valid email; required to deliver contact emails |
ESCROW_CONTRACT_CLASS_JSON / AGREEMENT_CONTRACT_CLASS_JSON |
local contracts/ files in dev |
Required in production; startup fails if unset |
LOG_LEVEL |
info |
Specifies the minimum logging level |
LOG_FORMAT |
json |
Use json for structured logging or text for readable console output |
GET /api/v1/token/:address/metadata returns the token's name, symbol, and
decimals. Results are cached in memory by canonical Starknet address for
TOKEN_METADATA_CACHE_TTL_MS; expired entries are refreshed on the next request.
The application includes structured JSON access logging and request correlation to monitor traffic, latency, error rates, and trace requests across the frontend/backend boundary.
Every request is assigned a unique request_id that flows through the entire request lifecycle:
- Client-supplied IDs (via
X-Request-Idheader) are validated, sanitised, and echoed back on the response header- Length-capped at 128 characters
- Restricted to printable ASCII (no control chars, newlines, or carriage returns)
- Invalid IDs are silently rejected; a server-generated UUID is used instead
- Server-generated IDs (when no header is provided) use
crypto.randomUUID()to ensure uniqueness - The ID is available on
res.locals.requestIdfor all downstream handlers and middleware - Every response includes the
X-Request-Idheader so clients can correlate their logs with server-side logs
Example: correlating a 500 error
Frontend logs report a failed request:
[app] POST /api/v1/escrow/0x123/deposit failed with status 500
Request-Id: req-client-001
Backend logs include the same correlation ID:
{"level":"error","request_id":"req-client-001","message":"database connection failed",...}Client library integration (e.g., in React/Vue):
// Send a client-managed request ID to link frontend logs with backend
const requestId = `req-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
const response = await fetch("/api/v1/escrow/0x123/deposit", {
method: "POST",
headers: {
"X-Request-Id": requestId,
"Content-Type": "application/json",
},
body: JSON.stringify({/* ... */}),
});
console.log("[app]", requestId, "server echo:", response.headers.get("X-Request-Id"));By default, the logger records:
method— HTTP method (GET, POST, etc.)path— request path and query stringstatus— HTTP response statusduration_ms— request duration in millisecondsrequest_id— correlation ID (see above)timestamp— ISO 8601 timestamp
JSON format (structured, recommended for production):
{
"timestamp": "2024-06-20T15:51:29.123Z",
"level": "info",
"method": "POST",
"path": "/api/v1/escrow/0x123/initialize",
"status": 200,
"duration_ms": 45.23,
"request_id": "req-client-001"
}Text format (human-readable, development):
[2024-06-20T15:51:29.123Z] INFO POST /api/v1/escrow/0x123/initialize 200 45.23ms [req-client-001]
Configuration:
LOG_FORMAT=json # Use 'json' for structured logging (production) or 'text' for console (development)
LOG_LEVEL=info # Minimum logging level (debug, info, warn, error)Sensitive fields such as request bodies and authorization tokens are strictly omitted from all access logs.
The noisy /health endpoint is completely excluded from logging to reduce noise.
When an error occurs, the central error handler logs the full error context with the request ID:
{
"level": "error",
"request_id": "req-client-001",
"message": "Insufficient balance in escrow",
"cause": "SmartContract validation failed",
"stack": "Error: ...",
"status": 400
}The client also receives the correlation ID in the error response body:
{
"error": "Insufficient balance in escrow",
"request_id": "req-client-001",
"details": null
}In development mode, the response includes cause and stack for debugging:
{
"error": "Insufficient balance in escrow",
"request_id": "req-client-001",
"cause": "SmartContract validation failed",
"stack": "Error: ...\n at ..."
}To integrate with external monitoring systems (Datadog, New Relic, Grafana Loki, etc.):
- Set
LOG_FORMAT=jsonto emit structured JSON that most systems can ingest - Parse the JSON stream in your collector to extract metrics:
status >= 500→ alert on server errorsduration_ms > threshold→ alert on slow requests- Group errors by
request_idto trace failure chains
- Use
request_idas a unique trace identifier across services:- Pass
X-Request-Idheader to downstream services - Include it in log aggregation queries for full request traces
- Pass
The service uses a configured Postgres pool with explicit limits and timeouts. The connection string is validated at startup, and the pool listens for runtime errors without crashing the process.
GET /healthreturns{ "ok": true }for process liveness.GET /readyrunsSELECT 1against the database and returns:200when the database responds successfully.503when the database is unreachable or returns an error.
- During startup, the HTTP server may accept connections before Postgres is verified. Until the initial DB readiness check succeeds, all routes except
/healthand/readyrespond with503instead of surfacing connection errors from handlers.
The implementation never logs the raw connection string. Any log output that references the DSN uses a masked value so credentials are not exposed.
Database schema migrations are managed using Drizzle Kit. To bootstrap or update the database schema:
- Ensure you have configured
POSTGRES_CONNECTION_STRINGin your.envfile (e.g.POSTGRES_CONNECTION_STRING=postgresql://postgres:postgres@localhost:5432/stellopay_indexer). - Run database migrations to create/update tables and indexes:
pnpm db:migrate
Naming Convention: All migration filenames in
src/db/migrationsmust start with a 13 to 14 digit timestamp prefix (e.g.,20240101123000_add_users.sql). This ensures migrations are ordered chronologically and unambiguously. This rule is enforced in CI viapnpm check:migrations.
Migration execution uses a StelloPay-namespaced PostgreSQL advisory lock. If another migration process is already running against the same database, subsequent runs wait for it to finish; the lock is released after either success or failure.
To preview pending migration files without applying schema changes, run:
pnpm db:migrate -- --dry-runDry-run only reads migration state and does not acquire the advisory lock.
If you make any changes to the database schema in src/db/schema.ts, you can generate new migration files by running:
pnpm db:generateImportant
The database schema is shared with the external Apibara indexer (see INDEXER_INTEGRATION.md). Ensure any schema modifications remain compatible with the indexer's write paths.
The service uses a configured Postgres pool with explicit limits and timeouts. The connection string is validated at startup, and the pool listens for runtime errors without crashing the process.
GET /healthreturns{ "ok": true }for process liveness.GET /readyrunsSELECT 1against the database and returns:200when the database responds successfully.503when the database is unreachable or returns an error.
- During startup, the HTTP server may accept connections before Postgres is verified. Until the initial DB readiness check succeeds, all routes except
/healthand/readyrespond with503instead of surfacing connection errors from handlers.
The implementation never logs the raw connection string. Any log output that references the DSN uses a masked value so credentials are not exposed.
The server captures SIGTERM and SIGINT signals to gracefully shutdown:
- Stops accepting new connections (drains HTTP server).
- Waits for existing in-flight requests to finish, bounded by a timeout (
SHUTDOWN_DRAIN_TIMEOUT_MS, default 10 seconds). - Closes the Postgres connection pool gracefully.
- Exits with code
0. If the drain timeout is exceeded, it force-exits with1.
When deploying under a process manager (like PM2 or systemd) or container orchestrator (like Kubernetes/Docker Swarm), ensure that the orchestrator sends SIGTERM and waits at least SHUTDOWN_DRAIN_TIMEOUT_MS before sending SIGKILL. This ensures no in-flight requests are dropped and database connections are returned cleanly.
Unit tests run with Vitest. They need no database or live
Starknet RPC — a dummy STARKNET_RPC_URL is injected via vitest.config.ts, and
route tests mock the DB/RPC layer.
pnpm test # run the suite once
pnpm test:watch # watch mode
pnpm test:coverage # run with a coverage report
pnpm mutation:test # run the scoped Stryker mutation baselineMutation testing is initially scoped to src/utils/validation.ts and
src/auth/session.ts, using their dedicated Vitest suites. Stryker reports
the score in the terminal and writes an HTML report under reports/mutation.
Run it locally with pnpm mutation:test; future work can expand the file
scope and add a CI score threshold without making this baseline a gate.
Coverage thresholds (95% statements/lines/functions, 90% branches) are enforced on
the core auth/codec modules. CI (.github/workflows/ci.yml) runs the lint, build,
audit, and tests on every push and pull request, and runs a standalone dependency
vulnerability audit every Monday.
Use pnpm as the source of truth for dependency installs and lockfile updates. The
canonical lockfile is pnpm-lock.yaml. The old npm lockfile has been removed and
package-lock.json is ignored so dependency changes do not create competing lock
state. package.json pins the expected pnpm version through the packageManager
field.
Dependabot checks the npm ecosystem weekly and groups minor/patch dependency updates into a single pull request to reduce review noise. Security-sensitive updates may still be opened separately by Dependabot.
Run the same audit gate locally before merging dependency changes:
pnpm install --frozen-lockfile
pnpm audit --prod --audit-level highThe CI workflow fails pull requests when production dependencies contain high or critical advisories, then runs linting, build, and tests.
All automation lives in .github/workflows/ci.yml.
A CycloneDX 1.5 software bill of materials (SBOM) is generated automatically on
every tag push (tags matching v*). The SBOM is produced from pnpm-lock.yaml
using @cyclonedx/cyclonedx-npm, verified for well-formedness via
scripts/verify-sbom.ts, and uploaded as a GitHub Actions artifact named
sbom-<tag>.
To download the SBOM for a given release, go to the Actions tab, select the
workflow run for the tag, and download the sbom-<tag> artifact.
To generate and verify an SBOM locally:
npx @cyclonedx/cyclonedx-npm --spec-version 1.5 --output-format JSON --output-file bom.json
pnpm tsx scripts/verify-sbom.ts bom.jsonRuns on every push and pull request targeting main, and on manual dispatch. It:
- Spins up a Postgres 16 service container and applies all Drizzle migrations.
- Runs
pnpm lint,pnpm build, andpnpm test. - Runs
pnpm audit --prod --audit-level high— the workflow fails if production dependencies contain high or critical advisories, blocking the merge.
Runs every Monday at 09:00 UTC (independent of pushes and pull requests) and
also on manual dispatch via workflow_dispatch. It:
- Installs dependencies from the frozen lockfile.
- Runs
pnpm audit --prod --audit-level high. - On a scheduled run: if high or critical advisories are found, automatically
opens (or updates) a GitHub issue labelled
security+dependencieswith a link to the failing run and remediation steps. - On a push/PR run: re-exits with a non-zero code so the workflow shows red and the failure is visible to reviewers before merging.
The two jobs are fully independent — the scheduled audit never interferes with the push/PR build, and the push/PR build never skips the lint/test/build gate.
To trigger the audit manually without waiting for the weekly schedule:
# From the GitHub UI: Actions → CI → Run workflow
# Or via the CLI:
gh workflow run ci.ymlThe repository uses ESLint flat config and Prettier for local quality checks.
pnpm lint # run the blocking ESLint gate
pnpm lint:all # run ESLint and show non-blocking warnings
pnpm lint:fix # run ESLint with safe fixes
pnpm format # format files with Prettier
pnpm format:check # check formatting without writing changesThe lint config enables @typescript-eslint/no-unused-vars and keeps the existing
no-console disable comments meaningful in the entrypoint and middleware files that
already annotate intentional startup, warning, and error logs.
The server enforces strict CORS rules to prevent credential leakage:
CORS_ORIGIN value |
credentials |
Behaviour |
|---|---|---|
http://localhost:3000 |
✅ true |
Only that origin is allowed; unlisted origins are rejected |
http://a.com,https://b.com |
✅ true |
Both origins allowed; all others rejected |
* |
❌ false |
All origins allowed, but cookies/auth headers are not forwarded |
Security rule (enforced by the CORS spec): you cannot combine
credentials: truewith a wildcard*origin. The server will never silently reflect an unknown origin — any origin not on the allowlist receives an explicit rejection error.
Development (default — single origin):
CORS_ORIGIN=http://localhost:3000Production (explicit allowlist — recommended):
CORS_ORIGIN=https://app.stellopay.com,https://staging.stellopay.comPublic / unauthenticated API (no cookies/auth forwarded):
CORS_ORIGIN=*GET /healthGET /api/v1/network/chain_idGET /api/v1/account/:address/nonce
POST /api/v1/auth/challengePOST /api/v1/auth/verifyPOST /api/v1/auth/session/validate
GET /api/v1/escrow/defaultsGET /api/v1/escrow/:address/get_tokenGET /api/v1/escrow/:address/is_initializedGET /api/v1/escrow/:address/get_agreement_balance/:agreement_idGET /api/v1/escrow/:address/get_agreement_employer/:agreement_id
POST /api/v1/prepare/escrow/:address/initializePOST /api/v1/prepare/escrow/:address/fund_agreementPOST /api/v1/prepare/escrow/:address/releasePOST /api/v1/prepare/escrow/:address/refund_remaining
GET /api/v1/agreement/defaultsGET /api/v1/agreement/:address/get_employer/:agreement_idGET /api/v1/agreement/:address/get_contributor/:agreement_idGET /api/v1/agreement/:address/get_token/:agreement_idGET /api/v1/agreement/:address/get_escrowGET /api/v1/agreement/:address/is_initializedGET /api/v1/agreement/:address/get_total_amount/:agreement_idGET /api/v1/agreement/:address/get_paid_amount/:agreement_idGET /api/v1/agreement/:address/get_status/:agreement_idGET /api/v1/agreement/:address/get_agreement_mode/:agreement_idGET /api/v1/agreement/:address/get_employee_count/:agreement_idGET /api/v1/agreement/:address/get_employee/:agreement_id/:indexGET /api/v1/agreement/:address/get_employee_salary/:agreement_id/:indexGET /api/v1/agreement/:address/get_dispute_status/:agreement_idGET /api/v1/agreement/:address/is_grace_period_active/:agreement_idGET /api/v1/agreement/:address/list/:user_address
POST /api/v1/agreement/:address/get_agreement_id_from_txPOST /api/v1/agreement/:address/sync_index
POST /api/v1/prepare/agreement/:address/initializePOST /api/v1/prepare/agreement/:address/create_time_based_agreementPOST /api/v1/prepare/agreement/:address/create_milestone_agreementPOST /api/v1/prepare/agreement/:address/create_payroll_agreementPOST /api/v1/prepare/agreement/:address/add_employeePOST /api/v1/prepare/agreement/:address/fund_agreementPOST /api/v1/prepare/agreement/:address/add_milestonePOST /api/v1/prepare/agreement/:address/approve_milestonePOST /api/v1/prepare/agreement/:address/claim_milestonePOST /api/v1/prepare/agreement/:address/activatePOST /api/v1/prepare/agreement/:address/pausePOST /api/v1/prepare/agreement/:address/resumePOST /api/v1/prepare/agreement/:address/cancelPOST /api/v1/prepare/agreement/:address/finalize_grace_periodPOST /api/v1/prepare/agreement/:address/raise_disputePOST /api/v1/prepare/agreement/:address/resolve_disputePOST /api/v1/prepare/agreement/:address/claim_time_basedPOST /api/v1/prepare/agreement/:address/claim_payroll
All billing routes live under a single canonical prefix.
Previously there were multiple duplicate paths (/billing/profile/..., /billing-profiles/..., /settings/billing-profiles/..., etc.) — these have been consolidated.
| Method | Path | Description |
|---|---|---|
GET |
/api/v1/billing/profiles/:profileId |
Full profile (info + payment methods + invoices) |
GET |
/api/v1/billing/profiles/:profileId/general-information |
Identity / contact fields (sensitive fields excluded) |
GET |
/api/v1/billing/profiles/:profileId/payment-methods |
Payment methods (masked numbers only) |
GET |
/api/v1/billing/profiles/:profileId/invoices |
Invoice history |
GET |
/api/v1/billing/profiles/:profileId/summary |
Reward-limit / spend summary |
Feature flag: Set BILLING_ENABLED=true in your environment once the billing_profiles database migration has been applied.
Until then every route returns HTTP 501 Not Implemented — no mock PII is served at any time.
Response envelope (all routes):
{ "success": true, "data": { ... } }
// or on error:
{ "success": false, "error": "message" }404 envelopes (route-level resource not-found + unmatched-route catch-all)
all use this same shape — see src/routes/not-found.ts:
{ "success": false, "error": "Resource not found" }The unmatched-route catch-all additionally attaches the requested method and
normalized path under data:
{
"success": false,
"error": "Route not found",
"data": { "method": "GET", "path": "/api/v1/missing-route" }
}All 404 responses serialize as JSON (never HTML), so user-controlled paths are
never reflected as markup. /health remains outside the API router and is not
intercepted by the not-found handler.
Database tables added (see src/db/schema.ts):
billing_profiles— identity, address, limitsbilling_payment_methods— masked payment method referencesbilling_invoices— invoice records
Security note: taxId and dateOfBirth are stored in the database but are never returned by any API endpoint. They must only be accessed through separately-authorised, audited internal processes.
Path and query parameters on the indexed and indexer-status routes are validated with Zod before any database call:
- Address parameters (
contract_address,user_address) must be hex with an optional0xprefix, up to 64 hex characters; malformed values are rejected with400. agreement_idmust be a numeric string.- List endpoints (
/indexed/agreements/...,/indexed/payments/user/..., and/indexer/user/:user_address/events) acceptlimitandoffsetquery parameters.limitis clamped server-side to the range 1 to 100 (default 50) andoffsetto 0 or more, so a client cannot request an unbounded result set.
Validation failures return 400 with a structured details array of the Zod issues.
By default the backend loads ABI from:
../Starknet-Contracts/target/release/starknet_contracts_PayrollEscrow.contract_class.json../Starknet-Contracts/target/release/starknet_contracts_WorkAgreement.contract_class.json
- The backend does not hold private keys.
- Users first prove wallet ownership by signing a backend-issued challenge (
/auth/challenge→ sign typed data →/auth/verify). - For contract mutations, the backend returns a prepared
call+nonce; the frontend wallet/account should sign + execute.
Authentication is a wallet-ownership proof. The backend creates a short-lived challenge, the frontend asks the wallet to sign the returned SNIP-12 typed data, and the backend verifies the signature against the Starknet account contract through the configured RPC provider before issuing a session token.
POST /api/v1/auth/challenge
Request:
{
"address": "0xWALLET_ADDRESS"
}Response:
{
"address": "0xWALLET_ADDRESS",
"nonce": "0xRANDOM_16_BYTE_NONCE",
"expires_in_ms": 300000,
"chain_id": "0xCHAIN_ID_FELT",
"typed_data": {
"types": {
"StarknetDomain": [
{ "name": "name", "type": "felt" },
{ "name": "version", "type": "felt" },
{ "name": "chainId", "type": "felt" },
{ "name": "revision", "type": "felt" }
],
"Challenge": [
{ "name": "action", "type": "felt" },
{ "name": "wallet", "type": "felt" },
{ "name": "nonce", "type": "felt" }
]
},
"primaryType": "Challenge",
"domain": {
"name": "StelloPay",
"version": "1",
"chainId": "SN_SEPOLIA",
"revision": "1"
},
"message": {
"action": "LOGIN",
"wallet": "0xWALLET_ADDRESS",
"nonce": "0xRANDOM_16_BYTE_NONCE"
}
}
}expires_in_ms is the remaining challenge lifetime in milliseconds. Challenges
currently live for five minutes (300000 ms) and are stored in process memory.
The server enforces this expiry; clients should treat the value as display or
retry guidance, not as an authority to extend the challenge lifetime.
chain_id is the raw chain ID returned by the configured Starknet RPC provider.
The typed_data object is Starknet SNIP-12 typed data, similar in shape to
EIP-712: it declares domain fields, a primary type, and the message fields that
the wallet signs. typed_data.domain.chainId is the decoded short-string label,
for example SN_SEPOLIA on Starknet Sepolia. Sign exactly the returned
typed_data; do not reconstruct it with a different chain ID, nonce, domain, or
message.
Safe challenge request:
curl -sS http://localhost:4000/api/v1/auth/challenge \
-H 'content-type: application/json' \
--data '{"address":"0xWALLET_ADDRESS"}'The frontend passes typed_data to the connected Starknet wallet. The backend
does not receive or need a private key.
POST /api/v1/auth/verify
Request:
{
"address": "0xWALLET_ADDRESS",
"signature": ["0xSIGNATURE_PART_0", "0xSIGNATURE_PART_1", "0xOPTIONAL_SIGNATURE_PART_2"]
}Response:
{
"ok": true,
"address": "0xWALLET_ADDRESS",
"session_token": "SESSION_TOKEN",
"expires_in_ms": 86400000
}signature is an array of felts encoded as strings. It must contain at least two
items, but it is intentionally variable length because Starknet wallets and
account contracts do not all emit exactly two signature elements. Send the array
returned by the wallet without truncating or padding it.
expires_in_ms in the verify response is the session lifetime in milliseconds.
It is controlled by SESSION_TTL_MS and defaults to 24 hours (86400000 ms).
Session TTL enforcement is server-side, following the fix tracked in
#41; do not trust a
client-reported timestamp or cached expiry as proof that a session is still
valid. The used challenge is cleared after successful verification.
If the challenge is missing or expired, /auth/verify returns 400:
{
"error": "No active challenge (or expired). Call /auth/challenge again."
}If the wallet signature does not validate through the Starknet account contract,
/auth/verify returns 401:
{
"error": "Invalid signature"
}Safe verify request shape with placeholder values:
curl -sS http://localhost:4000/api/v1/auth/verify \
-H 'content-type: application/json' \
--data '{
"address": "0xWALLET_ADDRESS",
"signature": [
"0xSIGNATURE_PART_0",
"0xSIGNATURE_PART_1"
]
}'POST /api/v1/auth/session/validate
Request:
{
"address": "0xWALLET_ADDRESS",
"session_token": "SESSION_TOKEN"
}Response:
{
"ok": true,
"address": "0xWALLET_ADDRESS"
}Invalid, expired, unknown, or wrong-address tokens return 401:
{
"ok": false,
"error": "Invalid session"
}Safe validation request shape with placeholder values:
curl -sS http://localhost:4000/api/v1/auth/session/validate \
-H 'content-type: application/json' \
--data '{
"address": "0xWALLET_ADDRESS",
"session_token": "SESSION_TOKEN"
}'Sessions have a sliding expiry. A successful /auth/session/validate refreshes
the token for another full SESSION_TTL_MS, although the validation response
does not include the refreshed expiry. Expired tokens are rejected and purged
lazily on use, with a periodic background sweep for tokens that are never used
again.
Contract prepare routes currently validate sessions from the JSON request body
with wallet_address and session_token. Middleware-protected routes use the
same session store but expect Authorization: Bearer <session_token> plus an
x-user-address header.
Challenges and sessions are both in-memory only. Restarting the server clears
all outstanding challenges and session tokens, so clients should handle
401 Invalid session by starting the challenge → verify flow again.
- Get challenge and sign it:
import { connect, type TypedData } from "starknet";
const BACKEND = "http://localhost:4000/api/v1";
async function login(address: string) {
const chRes = await fetch(`${BACKEND}/auth/challenge`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ address }),
});
const ch = await chRes.json();
const typed: TypedData = ch.typed_data;
const conn = await connect();
if (!conn?.account) throw new Error("Wallet not connected");
const signature = await conn.account.signMessage(typed);
const vRes = await fetch(`${BACKEND}/auth/verify`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ address, signature }),
});
return await vRes.json(); // { session_token, ... }
}- Prepare a call (example: funding an escrow agreement), then execute from wallet:
async function fundAgreement({
walletAddress,
sessionToken,
escrowAddress,
agreementId,
employer,
amount,
}: {
walletAddress: string;
sessionToken: string;
escrowAddress: string;
agreementId: string;
employer: string;
amount: string; // decimal string
}) {
const prepRes = await fetch(`${BACKEND}/prepare/escrow/${escrowAddress}/fund_agreement`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({
wallet_address: walletAddress,
session_token: sessionToken,
agreement_id: agreementId,
employer,
amount,
}),
});
const prep = await prepRes.json(); // { call, nonce, chain_id, wallet_address }
const conn = await connect();
if (!conn?.account) throw new Error("Wallet not connected");
// Wallet signs + sends the transaction directly
return await conn.account.execute(prep.call, { nonce: prep.nonce });
}The backend includes multiple security layers:
Mutating endpoints and backend administration routes require session authentication. A bearer token (session token) and an x-user-address header are required. Some routes are strictly limited to administrators defined in the ADMIN_ADDRESSES environment variable.
Authenticated Endpoints (requireAuth)
POST /api/v1/events/process_tx/:tx_hashPOST /api/v1/events/process_batch
Admin Endpoints (requireAuth + requireAdmin)
POST /api/v1/backfill/employee-eventsPOST /api/v1/backfill/milestone-eventsPOST /api/v1/reprocess-events/tx/:tx_hashPOST /api/v1/reprocess-events/status-changesGET /api/v1/diagnostics/events
Note: Indexed reading routes remain public because they only expose aggregated on-chain data and do not trigger remote RPC calls.
GET /api/v1/diagnostics/events is operator-only. The whole diagnostics router is gated by requireAuth + requireAdmin, so only an authenticated address listed in ADMIN_ADDRESSES can reach it.
- It returns aggregate counts (event-type counts and per-table totals) for operators.
- The
poolStatsobject reports the Postgres pool's point-in-timetotal,idle,active, andwaitingconnection counts without exposing connection details. - The recent-activity list is redacted to
event_typeandcreated_atonly. Transaction hashes and agreement IDs are never returned, since the aggregate counts already convey volume and the raw identifiers aid reconnaissance. - Every query is static SQL with no request input, so there is no injection surface.
Helmet middleware is applied to all responses, setting secure HTTP headers including:
Content-Security-PolicyStrict-Transport-Security(HSTS)X-Frame-OptionsX-Content-Type-OptionsX-XSS-Protection- And many others
This provides baseline protection against common web vulnerabilities.
express-rate-limit is configured with a tiered approach:
Global Rate Limit (applies to all /api/v1 endpoints):
- Window: 15 minutes (configurable via
RATE_LIMIT_WINDOW_MS) - Max requests: 100 per window (configurable via
RATE_LIMIT_MAX) - Returns HTTP 429 with JSON error response
Strict Rate Limit (applies to sensitive endpoints):
- Endpoints:
/api/v1/auth/*and/api/v1/contact/* - Window: 5 minutes (configurable via
RATE_LIMIT_STRICT_WINDOW_MS) - Max requests: 10 per window (configurable via
RATE_LIMIT_STRICT_MAX) - Returns HTTP 429 with JSON error response
- Why: These endpoints are unauthenticated and have side effects:
/auth/challengeand/auth/verifytrigger RPC calls to Starknet/contact/send-messagesends emails via nodemailer
This prevents:
- Denial-of-service (DoS) attacks via resource exhaustion
- Spam campaigns targeting the contact form
- Brute force attacks on authentication endpoints
For deployments behind a reverse proxy or CDN (nginx, Cloudflare, AWS ALB, etc.):
- Set
TRUST_PROXYto the number of trusted proxies (default:1) - This ensures rate limits key on the real client IP via
X-Forwarded-Forheader - In containerized deployments, typical value is
1(requests come through one proxy layer) - See Express trust proxy documentation
Development (relaxed limits):
RATE_LIMIT_WINDOW_MS=900000 # 15 minutes
RATE_LIMIT_MAX=100 # 100 requests
RATE_LIMIT_STRICT_WINDOW_MS=300000 # 5 minutes
RATE_LIMIT_STRICT_MAX=10 # 10 requests
TRUST_PROXY=1Production with high traffic (stricter limits):
RATE_LIMIT_WINDOW_MS=600000 # 10 minutes
RATE_LIMIT_MAX=50 # 50 requests
RATE_LIMIT_STRICT_WINDOW_MS=300000 # 5 minutes
RATE_LIMIT_STRICT_MAX=5 # 5 requests
TRUST_PROXY=1 # or higher if behind multiple proxiesProduction with CDN (e.g., Cloudflare):
RATE_LIMIT_WINDOW_MS=900000
RATE_LIMIT_MAX=100
RATE_LIMIT_STRICT_WINDOW_MS=300000
RATE_LIMIT_STRICT_MAX=10
TRUST_PROXY=1 # Cloudflare is the only proxyRate-limit responses are JSON, consistent with the error-handler format:
{
"error": "Too many requests, please try again later."
}All limiters are built by a single factory, makeLimiter, in
src/middleware/rate-limit.ts. This removes the
duplicated keyGenerator/handler/message wiring that previously lived inline
and gives the app one place to tune limits and swap the backing store:
import { makeLimiter } from "./middleware/rate-limit.js";
const adminLimiter = makeLimiter({
name: "admin", // label for docs/debugging and future shared stores
windowMs: 60_000, // sliding window length
max: 20, // max requests per window, per client IP
message: "Too many admin requests, please try again later.", // optional
skip: (req) => req.path === "/health", // optional bypass predicate
});
app.use("/api/v1/admin", adminLimiter);Every limiter shares the same client-IP key generator and emits the same JSON
429 envelope ({ "error": string }), so adding a new named limiter for
write/admin endpoints is a one-liner that cannot drift from the others.
Security: the shared key generator keys on req.ip, which honours the
Express trust proxy setting (TRUST_PROXY). When trust proxy is unset, a
forged X-Forwarded-For header is ignored and the direct socket IP is used —
clients cannot spoof the rate-limit key.
The factory uses express-rate-limit's default in-memory store. Counters
live in the process heap, which means:
- Not shared across instances — each replica enforces its own counts, so behind a load balancer the effective limit scales with the number of instances.
- Resets on restart/redeploy — counters are lost, briefly relaxing enforcement.
For multi-instance deployments, replace the store with a shared backend (e.g.
Redis via rate-limit-redis).
makeLimiter is the single seam for this: construct a shared store and pass it
to the rateLimit call inside the factory (see the marked store comment in
src/middleware/rate-limit.ts). No call sites
change.
The codebase includes comprehensive verification patterns:
- Drizzle Foreign Key Index Consistency: Automated checks in
src/db/schema-fk-indexes.tsensure all*_idforeign key columns are indexed. - Address Normalization:
normalizeStarknetAddressenforces canonical 66-character lower-case hex format and SNIP-23 checksum validation. - Starknet Failover:
STARKNET_RPC_URLsupports comma-separated RPC endpoints with automatic failover. - Auth Session Family Revocation: Session token rotation with
familyIdrevocation protects against refresh token replay attacks.
GET /metrics exposes the existing process-local auth, billing, diagnostics,
session, and Starknet snapshots in Prometheus text format. It is mounted
outside /api/v1 so a scraper does not need API-version routing. Protect the
endpoint at the ingress/network layer when operational metrics should not be
public.