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
77 changes: 77 additions & 0 deletions docs/API_KEYS.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
# User API Keys (#374)

Scoped, long-lived credentials for programmatic access to a user's own account.

## Key format

```
nwk_<keyId>_<secret>
```

The raw token is shown **once** at creation or rotation. It is stored as a bcrypt hash with a SHA-256 `tokenPrefix` for fast lookup (mirrors `AdminApiKey`).

## Authentication

```http
Authorization: Bearer nwk_<keyId>_<secret>
```

API keys authenticate via the same `requireAuth` entry point as session JWTs. The middleware detects the `nwk_` prefix and routes to the dedicated API-key path.

## Scopes

| Scope | Description |
|-------|-------------|
| `portfolio:read` | Read portfolio data |
| `transactions:read` | Read transaction history |
| `deposit:write` | Create deposits |
| `withdraw:write` | Create withdrawals (opt-in per key) |
| `alerts:manage` | Manage alert rules |
| `fiat:write` | Create fiat orders |
| `recurring_deposits:write` | Manage recurring deposit plans |
| `goals:write` | Manage savings goals |
| `strategies:write` | Manage strategies |
| `webhooks:manage` | Manage webhook subscriptions |
| `vault:read` | Read vault data |
| `vault:write` | Write vault operations |

New keys are **read-only by default**. Write scopes must be explicitly requested.

### Withdrawal guardrails

- `withdraw:write` requires `allowWithdrawals: true` at key creation.
- Platform kill-switch: `USER_API_KEY_WITHDRAWALS_ENABLED=false` blocks all API-key withdrawals.
- API-key withdrawals still honor approval workflows, compliance freeze, and sub-account permissions.

## Management endpoints (session auth only)

| Method | Path | Description |
|--------|------|-------------|
| `POST` | `/api/v1/keys` | Create key (returns secret once) |
| `GET` | `/api/v1/keys` | List keys (metadata only) |
| `DELETE` | `/api/v1/keys/:id` | Revoke key |
| `POST` | `/api/v1/keys/:id/rotate` | Rotate secret |
| `GET` | `/api/v1/keys/:id/usage` | Usage metadata |

An API key **cannot** create, rotate, or revoke other keys.

## Errors

| Status | Error | Meaning |
|--------|-------|---------|
| 401 | `key_expired` | Key past `expiresAt` |
| 401 | `Invalid or revoked API key` | Bad token or revoked |
| 403 | `insufficient_scope` | Missing required scope |
| 403 | Session authentication required | API key used on session-only endpoint |
| 409 | Maximum active API keys reached | Per-user cap exceeded |

## Notifications

Every create/revoke/rotate emits `security.api_key_changed` on the real-time alerts stream.

## Configuration

| Variable | Default | Description |
|----------|---------|-------------|
| `USER_API_KEY_MAX_ACTIVE` | 10 | Max active keys per user |
| `USER_API_KEY_WITHDRAWALS_ENABLED` | true | Platform withdrawal kill-switch |
50 changes: 50 additions & 0 deletions docs/IDEMPOTENCY.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
# Idempotency-Key Contract (#375)

Client-supplied idempotency keys protect mutating REST endpoints from duplicate side effects on retry.

## Header

```http
Idempotency-Key: <opaque, client-generated, ≤255 chars>
```

## Behavior

| Case | Response |
|------|----------|
| First request (miss) | Handler runs normally; response stored |
| Retry, same fingerprint | Original `statusCode` + body replayed; `Idempotency-Replayed: true` |
| Same key, different body | `422 idempotency_key_reuse` |
| Request still in flight | `409 idempotency_request_in_flight` |
| Missing on money routes | `400 idempotency_key_required` |

## Fingerprint

Hash of `(method, path, userId, canonicalized JSON body)`. Key ordering is normalized; arrays are order-sensitive.

## Storage

- **Primary:** Redis (`idem:<userId>:<key>`, TTL-bound)
- **Durability:** `IdempotencyRecord` DB table for money routes (Redis miss fallback)
- **Lock:** Redis `SET NX PX` (30s) prevents concurrent double-submit

## Route policy

| Route | Header | Fail mode | TTL |
|-------|--------|-----------|-----|
| `POST /deposit` | Required | Fail closed | 24h |
| `POST /withdraw` | Required | Fail closed | 24h |
| `POST /fiat/orders` | Required | Fail closed | 24h |
| `POST /deposit/recurring` | Required | Fail closed | 24h |

When Redis and DB are both unavailable on money routes → `503`. Non-money routes fail open (no dedupe).

## Relationship to outbox idempotency

The client `Idempotency-Key` sits **in front of** the outbox. A replayed request returns the original response (referencing the original outbox op). The outbox's `deriveIdempotencyKey` remains a second line of defense.

## Configuration

| Variable | Default | Description |
|----------|---------|-------------|
| `IDEM_MAX_BODY_BYTES` | 65536 | Max stored response body size |
43 changes: 43 additions & 0 deletions docs/PR_WEBHOOK_SYSTEM.md
Original file line number Diff line number Diff line change
Expand Up @@ -132,3 +132,46 @@ curl -X POST http://localhost:3000/api/webhooks \
echo -n '<raw_body>' | openssl dgst -sha256 -hmac '<secret>'
# should match X-Neurowealth-Signature header (minus "sha256=" prefix)
```

---

## Delivery Hardening (#377)

### Signature v2 (replay protection)

```
X-NW-Webhook-Timestamp: <unix>
X-NW-Webhook-Id: <deliveryId>
X-NW-Webhook-Signature: v2,<hex> v1,<hex>
```

v2 signs `"<timestamp>.<deliveryId>.<body>"`. Consumers should reject if `|now - timestamp| > 300s` and dedupe on `X-NW-Webhook-Id`.

### Secret rotation

- `POST /api/webhooks/:id/rotate-secret` — sets `secretNext`, dual-signs during overlap
- `POST /api/webhooks/:id/promote-secret` — promotes `secretNext` to `secret`

### Dead-letter queue

Exhausted deliveries (default 6 attempts, full-jitter backoff) move to `WebhookDeadLetter` as `PENDING`.

- `POST /api/webhooks/dead-letters/:id/replay` — single replay with `X-NW-Webhook-Replay: true`
- `POST /api/webhooks/:id/replay?since=` — bulk replay (max 50)

### Circuit breaker

Per-subscription breaker: `closed` → `open` (after 5 failures) → `half_open` (probe). Open subscriptions skip delivery but capture payloads in DLQ. Prolonged open auto-disables the subscription.

### Health endpoint

`GET /api/webhooks/:id/health` — circuit state, DLQ depth, recent failure rate.

### Configuration

| Variable | Default | Description |
|----------|---------|-------------|
| `WEBHOOK_MAX_ATTEMPTS` | 6 | Max delivery attempts |
| `WEBHOOK_CIRCUIT_BREAKER_THRESHOLD` | 5 | Failures before open |
| `WEBHOOK_AUTO_DISABLE_HOURS` | 24 | Auto-disable after open |
| `WEBHOOK_SEND_V1_SIGNATURE` | true | Include v1 during deprecation |
57 changes: 57 additions & 0 deletions docs/SESSIONS.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
# Session & Device Management (#376)

Users can view, name, and revoke their active sessions across devices.

## Session model

Each session carries:

| Field | Description |
|-------|-------------|
| `label` | User-set device name |
| `deviceType` | Best-effort UA hint: `web`, `ios`, `android`, `cli`, `unknown` |
| `approxLocation` | Coarse city/country from offline GeoIP (null for private IPs) |
| `lastSeenAt` / `lastSeenIp` | Updated async, throttled to ≤1/min |
| `revokedAt` / `revokedReason` | Soft revocation (`user`, `logout_others`, `admin`, etc.) |

## Endpoints (session auth only)

| Method | Path | Description |
|--------|------|-------------|
| `GET` | `/api/v1/sessions` | List caller's sessions |
| `PATCH` | `/api/v1/sessions/:id` | Set label |
| `DELETE` | `/api/v1/sessions/:id` | Revoke one session |
| `POST` | `/api/v1/sessions/revoke-others` | Revoke all except current (step-up) |

### IP masking

IPs are masked to `/24` by default (`1.2.3.xxx`). Pass `?fullIp=true` for the full address (logged server-side).

## Auth behavior

- Revoked sessions return `401 session_revoked` (distinct from `session_expired`)
- Refresh tokens on revoked sessions are rejected with `401 session_revoked`
- API keys cannot access session endpoints

## New session notifications

On `verify`, a `security.new_session` event is emitted with device metadata and a signed deep link. The link requires normal auth to act — it does not auto-revoke.

## Admin endpoints

| Method | Path | Scope |
|--------|------|-------|
| `GET` | `/api/admin/users/:id/sessions` | `read` |
| `POST` | `/api/admin/users/:id/sessions/revoke-all` | `write` |

All admin session actions are audit-logged.

## Cleanup

Revoked sessions are retained for `REVOKED_SESSION_RETAIN_DAYS` (default 7) then hard-deleted by `sessionCleanup`.

## Configuration

| Variable | Default | Description |
|----------|---------|-------------|
| `REVOKED_SESSION_RETAIN_DAYS` | 7 | Days to keep revoked sessions visible |
28 changes: 28 additions & 0 deletions prisma/migrations/20260827120000_add_user_api_keys/migration.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
-- CreateTable
CREATE TABLE "user_api_keys" (
"id" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"name" TEXT NOT NULL,
"scopes" TEXT[],
"hash" TEXT NOT NULL,
"tokenPrefix" TEXT NOT NULL,
"ipAllowlist" TEXT[] DEFAULT ARRAY[]::TEXT[],
"rateLimitPerMin" INTEGER,
"allowWithdrawals" BOOLEAN NOT NULL DEFAULT false,
"lastUsedAt" TIMESTAMP(3),
"lastUsedIp" TEXT,
"expiresAt" TIMESTAMP(3),
"revokedAt" TIMESTAMP(3),
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,

CONSTRAINT "user_api_keys_pkey" PRIMARY KEY ("id")
);

-- CreateIndex
CREATE INDEX "user_api_keys_userId_idx" ON "user_api_keys"("userId");

-- CreateIndex
CREATE INDEX "user_api_keys_tokenPrefix_idx" ON "user_api_keys"("tokenPrefix");

-- AddForeignKey
ALTER TABLE "user_api_keys" ADD CONSTRAINT "user_api_keys_userId_fkey" FOREIGN KEY ("userId") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
ALTER TABLE "user_api_keys" DROP CONSTRAINT IF EXISTS "user_api_keys_userId_fkey";
DROP TABLE IF EXISTS "user_api_keys";
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
CREATE TABLE "idempotency_records" (
"id" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"idempotencyKey" TEXT NOT NULL,
"fingerprint" TEXT NOT NULL,
"status" TEXT NOT NULL,
"statusCode" INTEGER,
"responseBody" JSONB,
"completedAt" TIMESTAMP(3),
"expiresAt" TIMESTAMP(3) NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,

CONSTRAINT "idempotency_records_pkey" PRIMARY KEY ("id")
);

CREATE UNIQUE INDEX "idempotency_records_userId_idempotencyKey_key" ON "idempotency_records"("userId", "idempotencyKey");
CREATE INDEX "idempotency_records_expiresAt_idx" ON "idempotency_records"("expiresAt");
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
DROP TABLE IF EXISTS "idempotency_records";
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
ALTER TABLE "sessions" ADD COLUMN "label" TEXT;
ALTER TABLE "sessions" ADD COLUMN "deviceType" TEXT;
ALTER TABLE "sessions" ADD COLUMN "lastSeenAt" TIMESTAMP(3);
ALTER TABLE "sessions" ADD COLUMN "lastSeenIp" TEXT;
ALTER TABLE "sessions" ADD COLUMN "revokedAt" TIMESTAMP(3);
ALTER TABLE "sessions" ADD COLUMN "revokedReason" TEXT;
ALTER TABLE "sessions" ADD COLUMN "approxLocation" TEXT;
CREATE INDEX "sessions_revokedAt_idx" ON "sessions"("revokedAt");
8 changes: 8 additions & 0 deletions prisma/migrations/20260827140000_enrich_sessions/rollback.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
DROP INDEX IF EXISTS "sessions_revokedAt_idx";
ALTER TABLE "sessions" DROP COLUMN IF EXISTS "approxLocation";
ALTER TABLE "sessions" DROP COLUMN IF EXISTS "revokedReason";
ALTER TABLE "sessions" DROP COLUMN IF EXISTS "revokedAt";
ALTER TABLE "sessions" DROP COLUMN IF EXISTS "lastSeenIp";
ALTER TABLE "sessions" DROP COLUMN IF EXISTS "lastSeenAt";
ALTER TABLE "sessions" DROP COLUMN IF EXISTS "deviceType";
ALTER TABLE "sessions" DROP COLUMN IF EXISTS "label";
21 changes: 21 additions & 0 deletions prisma/migrations/20260827150000_webhook_hardening/migration.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
ALTER TABLE "webhook_subscriptions" ADD COLUMN "secretNext" TEXT;
ALTER TABLE "webhook_subscriptions" ADD COLUMN "secretNextActiveAt" TIMESTAMP(3);
ALTER TABLE "webhook_subscriptions" ADD COLUMN "autoReplay" BOOLEAN NOT NULL DEFAULT false;

CREATE TABLE "webhook_dead_letters" (
"id" TEXT NOT NULL,
"subscriptionId" TEXT NOT NULL,
"event" TEXT NOT NULL,
"payload" JSONB NOT NULL,
"firstFailedAt" TIMESTAMP(3) NOT NULL,
"lastError" TEXT NOT NULL,
"attempts" INTEGER NOT NULL,
"status" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,

CONSTRAINT "webhook_dead_letters_pkey" PRIMARY KEY ("id")
);

CREATE INDEX "webhook_dead_letters_subscriptionId_status_idx" ON "webhook_dead_letters"("subscriptionId", "status");

ALTER TABLE "webhook_dead_letters" ADD CONSTRAINT "webhook_dead_letters_subscriptionId_fkey" FOREIGN KEY ("subscriptionId") REFERENCES "webhook_subscriptions"("id") ON DELETE CASCADE ON UPDATE CASCADE;
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
ALTER TABLE "webhook_dead_letters" DROP CONSTRAINT IF EXISTS "webhook_dead_letters_subscriptionId_fkey";
DROP TABLE IF EXISTS "webhook_dead_letters";
ALTER TABLE "webhook_subscriptions" DROP COLUMN IF EXISTS "autoReplay";
ALTER TABLE "webhook_subscriptions" DROP COLUMN IF EXISTS "secretNextActiveAt";
ALTER TABLE "webhook_subscriptions" DROP COLUMN IF EXISTS "secretNext";
Loading
Loading