Skip to content
Open
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
76 changes: 40 additions & 36 deletions docs/API_KEYS.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,9 +28,9 @@ fails CI if the catalogue, the call sites and this document disagree.

The **Enforcement** column says whether the scope actually gates anything:

| Enforcement | Meaning |
| --- | --- |
| `enforced` | At least one route requires it. Granting it is meaningful; omitting it is restrictive. |
| Enforcement | Meaning |
| --------------- | ----------------------------------------------------------------------------------------------------- |
| `enforced` | At least one route requires it. Granting it is meaningful; omitting it is restrictive. |
| `declared-only` | Declared and reservable, but no route requires it today — granting it currently grants nothing extra. |

The **Implies** column lists the scopes a scope grants on its own. `super`
Expand All @@ -44,7 +44,7 @@ implies every admin scope, and the legacy `agent` implies `agent:read` and
| --- | --- | --- | --- | --- | --- | --- |
| `loans:read` | read | enforced | — | Read loan state and borrowing capacity. | — | `GET /api/v1/loans`, `GET /api/v1/loans/:loanId`, `GET /api/v1/loans/capacity` |
| `portfolio:read` | read | enforced | — | Read portfolio positions, balances, history, earnings and tax reports. | — | `GET /api/v1/portfolio/:userId`, `GET /api/v1/portfolio/:userId/earnings`, `GET /api/v1/portfolio/:userId/history`, `GET /api/v1/portfolio/:userId/suggestions`, `GET /api/v1/portfolio/:userId/tax-report` |
| `transactions:read` | read | enforced | — | Read transaction history. | — | `GET /api/v1/transactions/:id/events`, `GET /api/v1/transactions/:userId`, `GET /api/v1/transactions/detail/:txHash` |
| `transactions:read` | read | enforced | — | Read transaction history. | — | `GET /api/v1/accounting/export`, `GET /api/v1/transactions/:id/events`, `GET /api/v1/transactions/:userId`, `GET /api/v1/transactions/detail/:txHash` |
| `vault:read` | read | enforced | — | Read vault state and balance. | — | `GET /api/v1/vault/balance` |
| `alerts:manage` | write | enforced | — | Manage alert rules. | — | `DELETE /api/v1/alerts/:id`, `PATCH /api/v1/alerts/:id`, `POST /api/v1/alerts`, `POST /api/v1/alerts/:id/restore` |
| `goals:write` | write | enforced | — | Manage savings goals. | — | `DELETE /api/v1/portfolio/goals/:id`, `PATCH /api/v1/portfolio/goals/:id`, `POST /api/v1/portfolio/goals` |
Expand All @@ -69,13 +69,13 @@ New keys are **read-only by default**. Write scopes must be explicitly requested
| `agent:read` | read | enforced | — | Read agent decisions and circuit-breaker state. | — | `GET /api/v1/admin/agent/breakers`, `GET /api/v1/admin/agent/decisions` |
| `dlq:read` | read | enforced | — | Inspect the dead-letter queue. | — | `GET /api/v1/admin/dlq/inspect`, `GET /api/v1/admin/notifications/dlq` |
| `erasure:read` | read | declared-only | — | Preview what an erasure would delete. | No route requires this scope today — the erasure preview route is gated by the generic read scope. Declared so a key can be provisioned ahead of a route that needs it. | — |
| `fiat:read` | read | enforced | — | Read fiat providers and their health. | — | `GET /api/v1/admin/fiat/providers` |
| `fiat:read` | read | enforced | — | Read fiat providers and their health. | — | `GET /api/v1/admin/fiat/limit-policies`, `GET /api/v1/admin/fiat/providers` |
| `flags:read` | read | enforced | — | Read feature flags and rollout state. | — | `GET /api/v1/admin/feature-flags`, `GET /api/v1/admin/feature-flags/:key`, `GET /api/v1/admin/feature-flags/history` |
| `keys:read` | read | enforced | — | Read admin API keys and wallet rotation status. | — | `GET /api/v1/admin/keys`, `GET /api/v1/admin/wallets/rotation-status` |
| `messages:read` | read | enforced | — | Read message delivery state. | — | `GET /api/v1/admin/messages`, `GET /api/v1/admin/messages/:id`, `GET /api/v1/admin/messages/stats` |
| `metrics:read` | read | enforced | — | Read operational metrics and dashboards. | — | `GET /api/v1/admin/jobs/dashboard`, `GET /api/v1/admin/stellar/metrics` |
| `outbox:read` | read | enforced | — | Read the durable outbox. | — | `GET /api/v1/admin/outbox`, `GET /api/v1/admin/outbox/:id`, `GET /api/v1/admin/outbox/stats` |
| `read` | read | enforced | — | General read access to admin resources. | — | `GET /api/v1/admin/erasure/preview/:userId`, `GET /api/v1/admin/erasure/requests`, `GET /api/v1/admin/referrals/review` |
| `read` | read | enforced | — | General read access to admin resources. | — | `GET /api/v1/admin/audit/export`, `GET /api/v1/admin/erasure/preview/:userId`, `GET /api/v1/admin/erasure/requests`, `GET /api/v1/admin/referrals/review` |
| `referrals:read` | read | enforced | — | Read flagged referral conversions. | — | `GET /api/v1/admin/referrals/flagged`, `GET /api/v1/admin/referrals/fraud/calibration`, `GET /api/v1/admin/referrals/fraud/queue` |
| `reserves:read` | read | enforced | — | Read reserve and sponsorship state. | — | `GET /api/v1/admin/reserves` |
| `risk-metadata:read` | read | enforced | — | Read protocol risk metadata. | `GET /api/v1/admin/risk-metadata` lists entries with a stale flag. | `GET /api/v1/admin/risk-metadata` |
Expand All @@ -97,8 +97,8 @@ New keys are **read-only by default**. Write scopes must be explicitly requested
| `agent` | operational | declared-only | `agent:read`, `agent:write` | Agent loop operations. | — | — |
| `agent:write` | operational | enforced | — | Change agent circuit breakers. | — | `POST /api/v1/admin/agent/breakers`, `POST /api/v1/admin/agent/breakers/:id/reset` |
| `backfill:write` | operational | enforced | — | Run Stellar event backfills. | — | `POST /api/v1/admin/stellar/backfill` |
| `dlq:write` | operational | enforced | — | Replay, retry and resolve dead-lettered work. | — | `POST /api/v1/admin/dlq/replay`, `POST /api/v1/admin/dlq/resolve`, `POST /api/v1/admin/dlq/retry`, `POST /api/v1/admin/notifications/dlq/:id/retry` |
| `fiat:write` | operational | enforced | — | Change fiat provider failover state. | — | `POST /api/v1/admin/fiat/providers/:name/failover` |
| `dlq:write` | operational | enforced | — | Replay, retry and resolve dead-lettered work. | — | `POST /api/v1/admin/dlq/discard`, `POST /api/v1/admin/dlq/replay`, `POST /api/v1/admin/dlq/requeue`, `POST /api/v1/admin/dlq/resolve`, `POST /api/v1/admin/dlq/retry`, `POST /api/v1/admin/notifications/dlq/:id/retry` |
| `fiat:write` | operational | enforced | — | Change fiat provider failover state. | — | `POST /api/v1/admin/fiat/providers/:name/failover`, `POST /api/v1/admin/users/:id/kyc-tier`, `PUT /api/v1/admin/fiat/limit-policies` |
| `flags:write` | operational | enforced | — | Manage feature flags, staged rollouts and emergency rollbacks. | — | `POST /api/v1/admin/feature-flags/:key/reset`, `POST /api/v1/admin/feature-flags/:key/rollback`, `POST /api/v1/admin/feature-flags/:key/staged-rollout`, `POST /api/v1/admin/feature-flags/emergency-disable-all`, `PUT /api/v1/admin/feature-flags/:key` |
| `messages:write` | operational | enforced | — | Retry and recover failed message deliveries. | — | `DELETE /api/v1/admin/messages/:id`, `POST /api/v1/admin/messages/:id/retry`, `POST /api/v1/admin/messages/retry-dead-letters` |
| `risk-metadata:write` | operational | enforced | — | Review and publish protocol risk metadata. | `PUT /api/v1/admin/risk-metadata/:protocolName` upserts an entry; each change writes a history row and an admin audit log row. | `PUT /api/v1/admin/risk-metadata/:protocolName` |
Expand All @@ -112,21 +112,21 @@ New keys are **read-only by default**. Write scopes must be explicitly requested

## 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 |
| 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.

## Scope catalogue endpoint (admin auth)

| Method | Path | Description | Scope |
|--------|------|-------------|-------|
| `GET` | `/api/v1/admin/keys/scopes` | The machine-readable scope catalogue, with the endpoints each scope covers | `keys:read` |
| Method | Path | Description | Scope |
| ------ | --------------------------- | -------------------------------------------------------------------------- | ----------- |
| `GET` | `/api/v1/admin/keys/scopes` | The machine-readable scope catalogue, with the endpoints each scope covers | `keys:read` |

The response carries both scope families:

Expand All @@ -135,7 +135,11 @@ The response carries both scope families:
"success": true,
"data": {
"user": {
"defaultReadScopes": ["portfolio:read", "transactions:read", "vault:read"],
"defaultReadScopes": [
"portfolio:read",
"transactions:read",
"vault:read",
],
"scopes": [
{
"scope": "withdraw:write",
Expand All @@ -144,14 +148,14 @@ The response carries both scope families:
"grants": "Submits withdrawals to the durable outbox for on-chain settlement.",
"enforcement": "enforced",
"requires": "Requires allowWithdrawals on the key AND the platform kill-switch USER_API_KEY_WITHDRAWALS_ENABLED to be on.",
"endpoints": ["POST /api/v1/withdraw"]
}
]
"endpoints": ["POST /api/v1/withdraw"],
},
],
},
"admin": {
"scopes": [/* the same shape, for admin key scopes */]
}
}
"scopes": [/* the same shape, for admin key scopes */],
},
},
}
```

Expand All @@ -160,21 +164,21 @@ claim to unlock an endpoint that stopped requiring it.

## 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 |
| 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 |
| Variable | Default | Description |
| ---------------------------------- | ------- | ------------------------------- |
| `USER_API_KEY_MAX_ACTIVE` | 10 | Max active keys per user |
| `USER_API_KEY_WITHDRAWALS_ENABLED` | true | Platform withdrawal kill-switch |
72 changes: 72 additions & 0 deletions docs/FIAT_LIMITS.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
# Fiat Transaction Limits, Velocity Controls & KYC-Tier Enforcement (#362)

This document describes the platform-side fiat transaction limits, rolling velocity controls, and KYC-tier enforcement engine (`src/fiat/limits.ts`).

---

## 1. KYC Tiers & Default Policy Table

Every user has a `KycProfile` (`userId`, `tier`, `providerRef`, `verifiedAt`, `reviewNote`) defaulting to `TIER_0` until verified by a signed provider callback or an audited admin override.

| Tier | Per-Txn Max (On-Ramp / Off-Ramp) | Rolling 24h Daily Max (On-Ramp / Off-Ramp) | Rolling 30d Monthly Max (On-Ramp / Off-Ramp) | Max Txns / 1h (On-Ramp / Off-Ramp) | Max Txns / 24h (On-Ramp / Off-Ramp) | Base Currency |
| :------- | :------------------------------- | :----------------------------------------- | :------------------------------------------- | :--------------------------------- | :---------------------------------- | :------------ |
| `TIER_0` | $0 / $0 | $0 / $0 | $0 / $0 | 0 / 0 | 0 / 0 | `USD` |
| `TIER_1` | $1,000 / $500 | $2,500 / $1,000 | $10,000 / $5,000 | 3 / 2 | 10 / 5 | `USD` |
| `TIER_2` | $10,000 / $5,000 | $25,000 / $15,000 | $100,000 / $50,000 | 5 / 4 | 20 / 10 | `USD` |
| `TIER_3` | $50,000 / $25,000 | $100,000 / $50,000 | $500,000 / $250,000 | 10 / 6 | 50 / 25 | `USD` |

Operators can inspect and tune these limits at runtime via `GET /api/v1/admin/fiat/limit-policies` and `PUT /api/v1/admin/fiat/limit-policies` (persisted in `fiat_limit_policies` and recorded in `admin_audit_logs`).

---

## 2. Evaluation Order & Invariants (`evaluateFiatOrder`)

Every path that creates a `FiatOrder` — manual `POST /api/v1/fiat/orders`, scheduled fiat purchase jobs, and scheduled fiat payout jobs — calls `evaluateFiatOrder` first. Checks run in deterministic priority order:

1. **Compliance Freeze (`compliance_freeze`)**:
- Checked **before** any tier or limit calculation. If the user is inactive (`User.isActive === false`) or has an active `#321` `ComplianceCase` (`OPEN`, `TRIAGE`, `INVESTIGATING`, `ESCALATED`, `PENDING_SAR`), the order is denied immediately with `reason: 'compliance_freeze'`.
2. **Tier Zero (`tier_zero`)**:
- Unverified (`TIER_0`) accounts have $0 caps and are denied with `reason: 'tier_zero'`.
3. **FX Conversion to Base Currency (`fx_unavailable`)**:
- Policy caps are denominated in `policy.currency` (default `USD`). Cross-currency orders are converted via the FX feed (`rateSource: 'FX_FEED'`, never assumed `1.0`).
- If the FX feed is down or a currency pair cannot be priced, cross-currency checks **fail closed** (`reason: 'fx_unavailable'`).
4. **Per-Transaction Cap (`per_txn`)**:
- Denies orders where `baseAmount > perTxnMax`.
5. **Rolling 1-Hour Velocity (`velocity_hourly`)**:
- Counts counting orders in `(now - 1h, now]`. Denies when `count + 1 > maxTxnsPerHour` and returns `retryAfter` (seconds until the oldest order in the 1h window ages out).
6. **Rolling 24-Hour Velocity (`velocity_daily`)**:
- Counts counting orders in `(now - 24h, now]`. Denies when `count + 1 > maxTxnsPerDay`.
7. **Rolling 24-Hour Volume (`daily`)**:
- Sums base-currency volume in `(now - 24h, now]`. Denies when `used + baseAmount > dailyMax`.
8. **Rolling Monthly Volume (`monthly`)**:
- Sums base-currency volume in the rolling 30-day window (or UTC calendar month when configured). Denies when `used + baseAmount > monthlyMax`.

---

## 3. Pending-Order Headroom & Refund / Failure Release Semantics

- **Pending Headroom Reservation**:
- Orders in `PENDING`, `PROCESSING`, and `SETTLED` states all reserve headroom immediately upon creation (`createdAt`). A burst of concurrent order creations cannot bypass velocity or volume caps before provider webhooks arrive.
- **Headroom Release on `FAILED` / `REFUNDED`**:
- When a `PENDING` order transitions to `FAILED` (provider webhook or stale-order age-out), or a `SETTLED` order transitions to `REFUNDED`, its headroom is released **as of the failure/refund timestamp** (`refundedAt ?? updatedAt`), and `fiat_limit_headroom_released_total` is incremented.
- Historical windows prior to `refundedAt` are **never** retroactively rewritten: point-in-time evaluations at `t < refundedAt` still reflect the headroom that was reserved at time `t`, while evaluations at `t >= refundedAt` see the released headroom.

---

## 4. Soft Warnings & Scheduled Plan Auto-Pause

- **Soft Warning (`fiat.limit.approaching`)**:
- When an allowed order pushes any window's utilization above `LIMIT_SOFT_WARN_PCT` (default `0.80` / 80%), the order succeeds, attaches `limitWarning` (`{ window, utilizationPct, used, limit, remaining, currency }`) to the response, and emits `fiat.limit.approaching` on the `alerts` stream topic and webhook channel.
- **Scheduled Plan Auto-Pause (`evaluateScheduledFiatRun`)**:
- When a scheduled fiat purchase or payout run is denied by `evaluateFiatOrder`, it records a `skipped_limit` outcome and increments `consecutiveSkippedLimit`.
- After `FIAT_SCHEDULED_SKIP_AUTO_PAUSE_THRESHOLD` (default `3`) consecutive `skipped_limit` runs, the plan automatically transitions to `PAUSED` (`shouldAutoPause: true`) and logs an alert rather than silently no-op'ing indefinitely.

---

## 5. API Endpoints

- `GET /api/v1/fiat/limits` — Returns the authenticated caller's `tier`, `currency`, and `{ limit, used, remaining, nextResetAt }` for `perTxn`, `daily`, `monthly`, `velocityHourly`, and `velocityDaily`.
- `POST /api/v1/kyc/verify` — Initiates the provider-hosted KYC verification session. Rejects any client-supplied `tier`.
- `POST /api/v1/kyc/webhook/:provider` — Signature-verified provider callback that updates `KycProfile.tier` and `KycProfile.providerRef` (opaque token only; no PII stored).
- `GET /api/v1/admin/fiat/limit-policies` & `PUT /api/v1/admin/fiat/limit-policies` — Admin inspection and tuning of per-tier policies (`fiat:read` / `fiat:write`, audited).
- `POST /api/v1/admin/users/:id/kyc-tier` — Manual KYC tier override requiring a non-empty `reviewNote` (`fiat:write`, audited).
Loading