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
3 changes: 3 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,9 @@ JWT_SECRET=your_super_secret_jwt_key_at_least_32_chars_long

# --- RPC / Chain ---
RPC_URL=https://api.mainnet-beta.solana.com
# Optional deep health probe controls.
# HEALTH_CHECK_TIMEOUT_MS=5000
# RPC_PROBE_ENABLED=false

# --- Webhooks ---
# INDEXER_WEBHOOK_SECRET=
Expand Down
18 changes: 15 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -70,15 +70,26 @@ The backend supports API key authentication for internal jobs and partner integr

- Header: `x-api-key` or `Authorization: ApiKey <key>`
- Keys are hashed with SHA-256 at rest
- Constant-time comparison via `crypto.timingSafeEqual`
- Constant-time digest comparison via `crypto.timingSafeEqual`
- Revoked keys are rejected and treated as invalid

Set environment variable(s) before starting:

- `API_KEYS`: comma-separated plaintext keys (development/test only)
- `API_KEY_HASHES`: comma-separated SHA256 hashes (production / at-rest hashes)
- `API_KEY_HASHES`: comma-separated SHA-256 hashes (production / at-rest hashes)

Add `x-api-key` to `/api/v1/*` and `/webhooks/indexer` requests.
Required route scope:

| Route | Methods | Required headers |
|-------|---------|------------------|
| `/api/v1/streams` | `GET` | `x-api-key` or `Authorization: ApiKey <key>` |
| `/api/v1/streams/:id` | `GET`, `PATCH` | `x-api-key` or `Authorization: ApiKey <key>` |
| `/api/v1/streams/:id/accrual-preview` | `GET` | `x-api-key` or `Authorization: ApiKey <key>` |
| `/webhooks/indexer` | `POST` only | `x-api-key` or `Authorization: ApiKey <key>`, plus `x-indexer-signature` |

API key authentication runs before JSON body parsing on `/api/v1/*` and before raw-body parsing on `POST /webhooks/indexer`, so unauthenticated requests do not reach validation, repository calls, or HMAC verification. Missing keys return `401` with `{ "error": "API key missing" }`; invalid or revoked keys return `401` with `{ "error": "API key invalid or revoked" }`.

Public routes that do not require API key authentication are `GET /health` and `GET /api/openapi.json`.

## Indexer webhook ingestion

Expand All @@ -105,6 +116,7 @@ Example payload:
Security notes:

- Signature verification uses the raw request body and `crypto.timingSafeEqual`.
- API key authentication is enforced before raw-body parsing and HMAC verification.
- Replay protection is enforced by deduplicating `eventId` values in the ingestion service.
- Duplicate deliveries are treated as safe no-ops and return `202 Accepted`.

Expand Down
7 changes: 7 additions & 0 deletions docs/CONFIGURATION.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,13 @@ startup. Defaults shown are applied when the variable is unset.
| `DB_CONNECTION_TIMEOUT` | `5000` / `10000` | Connection acquisition timeout in ms. |
| `DB_STATEMENT_TIMEOUT` | `30000` / `60000` | Per-statement timeout in ms. |

## Health checks

| Variable | Default | Description |
|----------|---------|-------------|
| `HEALTH_CHECK_TIMEOUT_MS` | `5000` | Timeout for deep health probes. |
| `RPC_PROBE_ENABLED` | `false` | When true, deep health checks also probe the configured RPC endpoint. Accepts `true`/`false`, `1`/`0`, `yes`/`no`, or `on`/`off`. |

## Rate limiting

| Variable | Default | Description |
Expand Down
21 changes: 20 additions & 1 deletion docs/SECURITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,14 +14,33 @@ StreamPay Backend handles partner integrations and chain-indexer webhooks.
Key security controls include:

- **API key authentication** with SHA-256 hashes at rest and constant-time
comparison (`crypto.timingSafeEqual`).
digest comparison (`crypto.timingSafeEqual`). The middleware is enforced
before JSON/raw-body parsing for protected routes.
- **HMAC verification** of indexer webhook payloads against the raw request
body, using `INDEXER_WEBHOOK_SECRET`.
- **Replay protection** via deduplication of `eventId` values in the
ingestion service.
- **IP-based and API-key-based rate limiting** through `express-rate-limit`.
- **Strict CORS** allowlists in production (no wildcard).

## API Key Enforcement Scope

All routes under `/api/v1/*` require either `x-api-key` or
`Authorization: ApiKey <key>`. Current protected routes are
`GET /api/v1/streams`, `GET /api/v1/streams/:id`,
`GET /api/v1/streams/:id/accrual-preview`, and
`PATCH /api/v1/streams/:id`. This middleware runs before route handlers and
before JSON body parsing, so unauthenticated requests do not reach validation,
repository, or mutating handlers.

`POST /webhooks/indexer` requires both API key authentication and the existing
`x-indexer-signature` HMAC signature. API key authentication runs first, then
the raw JSON body is parsed and passed to HMAC verification. The API key layer is
an additional control and does not replace HMAC verification.

Public operational routes remain unauthenticated: `GET /health` and
`GET /api/openapi.json`.

## Dependency Hygiene

- Dependabot is configured to open weekly PRs for npm updates.
Expand Down
2 changes: 2 additions & 0 deletions drizzle/0001_add_streams_deleted_at.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
ALTER TABLE "streams" ADD COLUMN IF NOT EXISTS "deleted_at" timestamp;
CREATE INDEX IF NOT EXISTS "streams_deleted_at_idx" ON "streams" ("deleted_at");
7 changes: 7 additions & 0 deletions drizzle/meta/_journal.json
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,13 @@
"when": 1743350400000,
"tag": "0000_init",
"breakpoints": true
},
{
"idx": 1,
"version": "7",
"when": 1781678686511,
"tag": "0001_add_streams_deleted_at",
"breakpoints": true
}
]
}
6 changes: 5 additions & 1 deletion jest.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,11 @@
module.exports = {
preset: "ts-jest",
testEnvironment: "node",
testMatch: ["**/__tests__/**/*.test.ts"],
testMatch: [
"<rootDir>/src/**/__tests__/**/*.test.ts",
"<rootDir>/src/apiKeyAuth.test.ts",
"<rootDir>/src/indexerWebhook.test.ts",
],
collectCoverageFrom: [
"src/cache/**/*.ts",
"src/services/**/*.ts",
Expand Down
Loading