Skip to content

Commit 106d42d

Browse files
authored
Merge branch 'main' into feat/llm-tool-calling-assistant-318
2 parents 9e2e400 + 2e89774 commit 106d42d

126 files changed

Lines changed: 13421 additions & 1501 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

README.md

Lines changed: 0 additions & 78 deletions
This file was deleted.

docs/ALERTS.md

Lines changed: 14 additions & 128 deletions
Original file line numberDiff line numberDiff line change
@@ -1,134 +1,20 @@
1-
# Custom Price & Yield Alert Rules
1+
# Alert Rules, Acknowledgement, Snooze & Escalation (#366)
22

3-
User-defined rules that proactively notify a user when a market or portfolio
4-
condition they care about is met — e.g. "tell me if Blend's APY drops below 5%"
5-
or "alert me if my portfolio value falls under $1,000".
3+
NeuroWealth provides user-defined alert rules watching portfolio metrics (`PROTOCOL_APY`, `PORTFOLIO_VALUE`, `POSITION_DRAWDOWN`, `DRIFT`, `VOLATILITY_REGIME`, `ANOMALY`).
64

7-
This is **end-user** alerting and is deliberately distinct from the operator-
8-
facing Prometheus/Grafana alerting in [`OBSERVABILITY.md`](./OBSERVABILITY.md)
9-
(`agent_loop_status`, `cursor_lag_ledgers`, `dlq_size`, …), which watches system
10-
health for on-call engineers rather than portfolio conditions for users.
5+
## Concepts & Control Flow
116

12-
- Data model: `AlertRule` in [`prisma/schema.prisma`](../prisma/schema.prisma)
13-
- Evaluation core (pure, unit-tested): [`src/services/alertEvaluator.ts`](../src/services/alertEvaluator.ts)
14-
- Scheduled job: [`src/jobs/alertRules.ts`](../src/jobs/alertRules.ts)
15-
- CRUD API: [`src/routes/alerts.ts`](../src/routes/alerts.ts)
16-
- Delivery: reuses [`src/services/webhookDispatcher.ts`](../src/services/webhookDispatcher.ts)
17-
(webhook) and [`src/whatsapp/formatters.ts`](../src/whatsapp/formatters.ts) (WhatsApp)
7+
- **Cooldown (`cooldownMinutes`)**: Restricts re-firing frequency while a condition remains true across consecutive ticks.
8+
- **Snooze (`POST /api/v1/alerts/:id/snooze`)**: Temporarily mutes an alert rule for a duration (clamped to max 30 days). Evaluation is skipped while snoozed, but would-be fires are recorded in `AlertFire` history with `suppressedBySnooze: true` for auditing. Auto-expires emitting `alert.snooze_expired`.
9+
- **Acknowledge (`POST /api/v1/alerts/:id/ack`)**: Marks an alert episode as "seen" by linking an `AlertAck` to `AlertFire` records. Resets the escalation counter. Can be invoked via API or via signed single-use `ackToken` in WhatsApp/Telegram/Email notifications.
10+
- **Escalation**: Triggers when the count of **un-acknowledged** fires (`AlertFire` where `ackId IS NULL`) within a rule's window reaches `escalationThreshold`. Escalated fires set `escalated = true` and deliver to `escalationChannel`.
1811

19-
## Rule model
12+
---
2013

21-
A rule is a single condition (compound/multi-condition rules are out of scope
22-
for v1):
14+
## API Endpoints
2315

24-
| Field | Meaning |
25-
| ----------------- | ------------------------------------------------------------------- |
26-
| `metric` | `PROTOCOL_APY`, `PORTFOLIO_VALUE`, or `POSITION_DRAWDOWN` |
27-
| `protocolName` | required for `PROTOCOL_APY`, rejected for the other metrics |
28-
| `comparator` | `LT`, `LTE`, `GT`, `GTE` |
29-
| `threshold` | compared against the observed value (units below) |
30-
| `deliveryChannel` | `WEBHOOK`, `WHATSAPP`, or `BOTH` |
31-
| `cooldownMinutes` | minimum gap between notifications for this rule (default 60) |
32-
| `lastFiredAt` | when the rule last fired; drives cooldown |
33-
| `isActive` | inactive rules are never evaluated |
34-
35-
### Units per metric
36-
37-
- **`PROTOCOL_APY`** — threshold and observed value are **percentages**
38-
(`5` == 5%). `ProtocolRate.supplyApy` is stored as a fraction (`0.05`), so the
39-
evaluator scales it by 100 before comparing.
40-
- **`PORTFOLIO_VALUE`** — threshold and observed value are the **USD sum of the
41-
user's ACTIVE positions' `currentValue`**.
42-
- **`POSITION_DRAWDOWN`** — threshold and observed value are a **percentage
43-
decline from a reference peak** (see below).
44-
45-
## POSITION_DRAWDOWN reference window
46-
47-
"Drawdown" is meaningless without a reference point, so we fix one explicitly:
48-
49-
> Drawdown is measured against the **rolling 30-day peak of the user's total
50-
> portfolio value**.
51-
52-
The peak is the maximum of:
53-
54-
1. every historical whole-portfolio value reconstructed from `YieldSnapshot`
55-
rows (`principalAmount + yieldAmount`, summed across the user's positions per
56-
snapshot instant) within the trailing 30 days, and
57-
2. the current total portfolio value.
58-
59-
Including the current value as a candidate means a fresh all-time high reports
60-
**0% drawdown** rather than a spurious decline against a stale sample.
61-
62-
```
63-
drawdown% = max(0, (peak - current) / peak * 100)
64-
```
65-
66-
The window length is `WINDOW_DAYS` in [`src/jobs/alertRules.ts`](../src/jobs/alertRules.ts).
67-
68-
## Evaluation & cooldown
69-
70-
The job runs on a fixed interval (`ALERT_RULES_INTERVAL_MS`, default 60s). On
71-
each tick it loads all `isActive` rules and, for each:
72-
73-
1. Observes the current value for the rule's metric.
74-
2. Checks the comparator against the threshold.
75-
3. If the condition holds, **atomically claims a fire** with an `updateMany`
76-
guarded on `{ id, isActive, lastFiredAt outside cooldown }`, setting
77-
`lastFiredAt = now`. Only if that update matches exactly one row does it
78-
deliver.
79-
80-
The cooldown is essential: a rule sitting right at its threshold would otherwise
81-
fire on every tick. With it, a rule notifies **at most once per
82-
`cooldownMinutes`**. The condition does **not** have to flip false→true again —
83-
if it is still true once the cooldown elapses, the rule re-fires.
84-
85-
### Edge cases
86-
87-
- **Condition true across many ticks** — cooldown suppresses repeats; the rule
88-
stays active and re-fires after the cooldown if still true.
89-
- **Rule deleted/deactivated mid-tick** — the atomic fire-claim matches 0 rows,
90-
so delivery is skipped silently (no error, no send to a gone rule).
91-
- **Protocol delisted** — a `PROTOCOL_APY` rule whose protocol has no
92-
`ProtocolRate` row is **auto-deactivated** (`isActive = false`) with a logged
93-
reason, rather than evaluated against missing/stale data.
94-
95-
## Delivery & failed-delivery retry policy
96-
97-
Delivery reuses the existing HMAC-signed webhook dispatcher
98-
(`dispatchWebhookEvent('alert_rule.triggered', …)`) and/or the Twilio WhatsApp
99-
sender. No new unsigned delivery path is introduced.
100-
101-
**Decision (per issue #289): alert deliveries reuse `dispatchWebhookEvent`
102-
as-is and get no additional retry sweep beyond its synchronous 3-attempt
103-
exponential backoff (1s/2s/4s).**
104-
105-
Rationale: alerts are about a *live* condition. A separate sweep that later
106-
replays a `FAILED` delivery could fire a stale alert for a condition that has
107-
since reversed. Instead:
108-
109-
- If **all** requested channels hard-fail during a fire, the job **rolls back
110-
`lastFiredAt`** to its prior value, so the next tick re-evaluates the *current*
111-
condition and retries if it still holds (bounded by cooldown). A transient
112-
failure therefore self-heals on the following tick without replaying stale
113-
data.
114-
- The webhook dispatcher still persists a `WebhookDelivery` row with
115-
`status = FAILED` for observability, exactly as for every other event.
116-
117-
If durable, at-least-once alert delivery is required later, the follow-up is a
118-
dedicated retry sweep over `FAILED` `WebhookDelivery` rows — explicitly out of
119-
scope here.
120-
121-
## Configuration
122-
123-
| Env var | Default | Meaning |
124-
| ------------------------ | ------- | ------------------------------------ |
125-
| `ALERT_RULES_INTERVAL_MS`| `60000` | Evaluation tick interval (ms) |
126-
127-
## Conversational management (WhatsApp)
128-
129-
Alert rules can be managed over WhatsApp via the NLP intents in
130-
[`src/nlp/parser.ts`](../src/nlp/parser.ts) (`alert_create`, `alert_list`,
131-
`alert_delete`), handled in [`src/whatsapp/handler.ts`](../src/whatsapp/handler.ts).
132-
As with the other intents (#281/#282), the intent union, the `KNOWN_ACTIONS`
133-
allowlist, and the handler switch are kept in sync **manually**. WhatsApp-created
134-
rules default to `WHATSAPP` delivery.
16+
| Method | Endpoint | Description |
17+
| :--- | :--- | :--- |
18+
| `POST` | `/api/v1/alerts/:id/snooze` | Mute rule for `durationMinutes` or `until` date |
19+
| `POST` | `/api/v1/alerts/:id/ack` | Acknowledge alert fire(s) using `fireId` or `ackToken` |
20+
| `GET` | `/api/v1/alerts/:id/fires` | View paginated fire history with ack & snooze status |

docs/API_KEYS.md

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
# User API Keys (#374)
2+
3+
Scoped, long-lived credentials for programmatic access to a user's own account.
4+
5+
## Key format
6+
7+
```
8+
nwk_<keyId>_<secret>
9+
```
10+
11+
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`).
12+
13+
## Authentication
14+
15+
```http
16+
Authorization: Bearer nwk_<keyId>_<secret>
17+
```
18+
19+
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.
20+
21+
## Scopes
22+
23+
| Scope | Description |
24+
|-------|-------------|
25+
| `portfolio:read` | Read portfolio data |
26+
| `transactions:read` | Read transaction history |
27+
| `deposit:write` | Create deposits |
28+
| `withdraw:write` | Create withdrawals (opt-in per key) |
29+
| `alerts:manage` | Manage alert rules |
30+
| `fiat:write` | Create fiat orders |
31+
| `recurring_deposits:write` | Manage recurring deposit plans |
32+
| `goals:write` | Manage savings goals |
33+
| `strategies:write` | Manage strategies |
34+
| `webhooks:manage` | Manage webhook subscriptions |
35+
| `vault:read` | Read vault data |
36+
| `vault:write` | Write vault operations |
37+
38+
New keys are **read-only by default**. Write scopes must be explicitly requested.
39+
40+
### Withdrawal guardrails
41+
42+
- `withdraw:write` requires `allowWithdrawals: true` at key creation.
43+
- Platform kill-switch: `USER_API_KEY_WITHDRAWALS_ENABLED=false` blocks all API-key withdrawals.
44+
- API-key withdrawals still honor approval workflows, compliance freeze, and sub-account permissions.
45+
46+
## Management endpoints (session auth only)
47+
48+
| Method | Path | Description |
49+
|--------|------|-------------|
50+
| `POST` | `/api/v1/keys` | Create key (returns secret once) |
51+
| `GET` | `/api/v1/keys` | List keys (metadata only) |
52+
| `DELETE` | `/api/v1/keys/:id` | Revoke key |
53+
| `POST` | `/api/v1/keys/:id/rotate` | Rotate secret |
54+
| `GET` | `/api/v1/keys/:id/usage` | Usage metadata |
55+
56+
An API key **cannot** create, rotate, or revoke other keys.
57+
58+
## Errors
59+
60+
| Status | Error | Meaning |
61+
|--------|-------|---------|
62+
| 401 | `key_expired` | Key past `expiresAt` |
63+
| 401 | `Invalid or revoked API key` | Bad token or revoked |
64+
| 403 | `insufficient_scope` | Missing required scope |
65+
| 403 | Session authentication required | API key used on session-only endpoint |
66+
| 409 | Maximum active API keys reached | Per-user cap exceeded |
67+
68+
## Notifications
69+
70+
Every create/revoke/rotate emits `security.api_key_changed` on the real-time alerts stream.
71+
72+
## Configuration
73+
74+
| Variable | Default | Description |
75+
|----------|---------|-------------|
76+
| `USER_API_KEY_MAX_ACTIVE` | 10 | Max active keys per user |
77+
| `USER_API_KEY_WITHDRAWALS_ENABLED` | true | Platform withdrawal kill-switch |

0 commit comments

Comments
 (0)