feat(security): add adaptive API abuse protection - #893
Conversation
✅ Deploy Preview for nesterhq canceled.
|
|
@bamiebot-maker Great news! 🎉 Based on an automated assessment of this PR, the linked Wave issue(s) no longer count against your application limits. You can now already apply to more issues while waiting for a review of this PR. Keep up the great work! 🚀 |
✅ Deploy Preview for nesterdapp ready!
To edit notification comments on pull requests, go to your Netlify project configuration. |
|
Warning Review limit reached
Next review available in: 45 minutes Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available. How can I continue?After more reviews become available, a review can be triggered using the To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews. How do review limits work?CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability. For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window. Please refer docs for additional details. Review details⚙️ Run configurationConfiguration used: Path: .coderabbit.yaml Review profile: ASSERTIVE Plan: Pro Plus Run ID: 📒 Files selected for processing (3)
WalkthroughAdds an in-memory abuse protector for aggregate auth failures, resource probes, and fingerprint velocity. It emits abuse events, applies challenge middleware responses, provides uniform lookup responses, and documents the protection model. ChangesAPI abuse protection
Estimated code review effort: 3 (Moderate) | ~20 minutes Sequence Diagram(s)sequenceDiagram
participant Client
participant ChallengeMiddleware
participant AbuseProtector
participant AbuseObserver
participant Handler
Client->>ChallengeMiddleware: HTTP request
ChallengeMiddleware->>AbuseProtector: Decide abuse action
AbuseProtector->>AbuseObserver: ObserveAbuse(AbuseEvent)
alt AbuseChallenge
ChallengeMiddleware-->>Client: JSON STEP_UP_REQUIRED, HTTP 412
else AbuseAllow
ChallengeMiddleware->>Handler: Forward request
Handler-->>Client: Normal response
end
Suggested reviewers: 🚥 Pre-merge checks | ✅ 5✅ Passed checks (5 passed)
✨ Finishing Touches🧪 Generate unit tests (beta)
Comment |
There was a problem hiding this comment.
Actionable comments posted: 6
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@apps/api/internal/middleware/abuse_test.go`:
- Around line 25-89: Consolidate the detector-policy coverage in the tests
around TestDistributedCredentialStuffingTriggersAggregateChallenge,
TestEnumerationUsesUniformResponseAndTriggersChallenge,
TestBotVelocityChallengesWhileNormalSignupPasses, and
TestAdaptiveEscalationRelaxesAndChallengeIsRecoverable into table-driven cases.
Each case should exercise just-below-threshold, threshold-triggering, expiry
behavior where applicable, and the expected AbuseAction, while preserving the
existing middleware response and observability assertions.
In `@apps/api/internal/middleware/abuse.go`:
- Around line 122-126: Update AbuseProtector record methods and emit so the
AbuseEvent is constructed while p.mu is held, then release the mutex before
invoking observer.ObserveAbuse. Ensure emit no longer runs observer callbacks
under the lock, while preserving the existing event fields and return action
behavior.
- Around line 68-73: Update the window-rotation logic in the abuse middleware
around the endpoint’s abuse window so replacing an expired window does not reset
an unexpired escalation. Preserve the existing escalatedTil value or store
endpoint escalation separately, ensuring escalation remains active until its
configured TTL even when EscalationTTL exceeds Window.
- Around line 40-45: Bound attacker-controlled state in abuseWindow and the
associated RecordProbe flow: cap resource and fingerprint identifier lengths and
map cardinality, and stop retaining new resource entries once the escalation
threshold is reached while preserving the state required for escalation and
response decisions. Use the existing window, probe, fingerprint, and escalation
symbols rather than adding unbounded collections.
- Around line 153-158: Update WriteUniformLookupResponse and its callers to
perform equivalent lookup work for both existing and non-existing resources,
then enforce a bounded response-time policy before returning the identical
response. Centralize this behavior in the shared lookup flow rather than only
normalizing status and body, and add timing tests covering both existence paths.
- Around line 51-63: Replace AbuseProtector’s process-local windows map with a
storage interface supporting atomic window updates and endpoint escalation
state, and add a Redis-backed implementation in
apps/api/internal/middleware/abuse.go. In
apps/api/internal/middleware/abuse_test.go, add coverage with multiple
AbuseProtector instances sharing one backend to verify cross-instance
escalation. In docs/API_ABUSE_PROTECTION.md, document that the in-memory
implementation is single-instance-only until the Redis adapter is available.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Path: .coderabbit.yaml
Review profile: ASSERTIVE
Plan: Pro Plus
Run ID: 065cf672-5038-4cdd-847b-73b7483dfb13
📒 Files selected for processing (3)
apps/api/internal/middleware/abuse.goapps/api/internal/middleware/abuse_test.godocs/API_ABUSE_PROTECTION.md
| func TestDistributedCredentialStuffingTriggersAggregateChallenge(t *testing.T) { | ||
| p, _, observer := testProtector() | ||
| for _, fingerprint := range []string{"ip-a", "ip-b", "ip-c"} { | ||
| if got := p.RecordFailedAuth("/auth/login", fingerprint); got != AbuseAllow { | ||
| t.Fatalf("early action = %s", got) | ||
| } | ||
| } | ||
| if got := p.RecordFailedAuth("/auth/login", "ip-d"); got != AbuseChallenge { | ||
| t.Fatalf("aggregate action = %s, want challenge", got) | ||
| } | ||
| if observer.events[len(observer.events)-1].Kind != "credential_stuffing" { | ||
| t.Fatal("aggregate abuse was not observable") | ||
| } | ||
| } | ||
|
|
||
| func TestEnumerationUsesUniformResponseAndTriggersChallenge(t *testing.T) { | ||
| p, _, _ := testProtector() | ||
| for _, resource := range []string{"one@example.com", "two@example.com"} { | ||
| if got := p.RecordProbe("/account/check", resource, "scanner"); got != AbuseAllow { | ||
| t.Fatalf("early probe = %s", got) | ||
| } | ||
| } | ||
| if got := p.RecordProbe("/account/check", "three@example.com", "scanner"); got != AbuseChallenge { | ||
| t.Fatalf("probe action = %s", got) | ||
| } | ||
| a, b := httptest.NewRecorder(), httptest.NewRecorder() | ||
| WriteUniformLookupResponse(a) | ||
| WriteUniformLookupResponse(b) | ||
| if a.Code != b.Code || a.Body.String() != b.Body.String() { | ||
| t.Fatal("exists/not-exists lookup responses differ") | ||
| } | ||
| } | ||
|
|
||
| func TestBotVelocityChallengesWhileNormalSignupPasses(t *testing.T) { | ||
| p, _, _ := testProtector() | ||
| if p.RecordSensitiveFlow("/signup", "human-a") != AbuseAllow { | ||
| t.Fatal("normal signup challenged") | ||
| } | ||
| p.RecordSensitiveFlow("/signup", "bot-shared") | ||
| p.RecordSensitiveFlow("/signup", "bot-shared") | ||
| if p.RecordSensitiveFlow("/signup", "bot-shared") != AbuseChallenge { | ||
| t.Fatal("bot pattern did not trigger challenge") | ||
| } | ||
| } | ||
|
|
||
| func TestAdaptiveEscalationRelaxesAndChallengeIsRecoverable(t *testing.T) { | ||
| p, now, _ := testProtector() | ||
| for range 4 { | ||
| p.RecordFailedAuth("/auth/login", "distributed") | ||
| } | ||
| if p.RecordSensitiveFlow("/auth/login", "legitimate") != AbuseChallenge { | ||
| t.Fatal("endpoint did not tighten under attack") | ||
| } | ||
| *now = now.Add(31 * time.Second) | ||
| if p.RecordSensitiveFlow("/auth/login", "legitimate") != AbuseAllow { | ||
| t.Fatal("endpoint did not relax after escalation TTL") | ||
| } | ||
|
|
||
| handler := ChallengeMiddleware(func(*http.Request) AbuseAction { return AbuseChallenge })(ok200) | ||
| rec := httptest.NewRecorder() | ||
| handler.ServeHTTP(rec, httptest.NewRequest(http.MethodPost, "/signup", nil)) | ||
| if rec.Code != http.StatusPreconditionRequired || rec.Header().Get("X-Abuse-Action") != "challenge" { | ||
| t.Fatal("graduated response did not expose a recoverable challenge") | ||
| } | ||
| } |
There was a problem hiding this comment.
📐 Maintainability & Code Quality | 🟠 Major | ⚡ Quick win
Add table-driven detector-policy tests.
The new service logic is only covered by separate hand-written cases. Consolidate threshold, just-below-threshold, expiry, and action expectations into table-driven coverage.
As per path instructions, “Require table-driven tests for new service logic.”
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@apps/api/internal/middleware/abuse_test.go` around lines 25 - 89, Consolidate
the detector-policy coverage in the tests around
TestDistributedCredentialStuffingTriggersAggregateChallenge,
TestEnumerationUsesUniformResponseAndTriggersChallenge,
TestBotVelocityChallengesWhileNormalSignupPasses, and
TestAdaptiveEscalationRelaxesAndChallengeIsRecoverable into table-driven cases.
Each case should exercise just-below-threshold, threshold-triggering, expiry
behavior where applicable, and the expected AbuseAction, while preserving the
existing middleware response and observability assertions.
Source: Path instructions
| type AbuseProtector struct { | ||
| mu sync.Mutex | ||
| cfg AbuseConfig | ||
| now func() time.Time | ||
| windows map[string]*abuseWindow | ||
| observer AbuseObserver | ||
| } | ||
|
|
||
| func NewAbuseProtector(cfg AbuseConfig, observer AbuseObserver) *AbuseProtector { | ||
| return &AbuseProtector{ | ||
| cfg: cfg, now: time.Now, windows: make(map[string]*abuseWindow), observer: observer, | ||
| } | ||
| } |
There was a problem hiding this comment.
🔒 Security & Privacy | 🟠 Major | 🏗️ Heavy lift
Implement shared storage before claiming distributed abuse detection.
AbuseProtector always stores counters in a process-local map, so requests distributed across replicas never aggregate toward one threshold. That defeats the distributed low-and-slow detection objective.
apps/api/internal/middleware/abuse.go#L51-L63: introduce a storage interface and Redis-backed implementation for atomic window updates and endpoint escalation state.apps/api/internal/middleware/abuse_test.go#L14-L23: add coverage using multiple protectors sharing one backend to verify cross-instance escalation.docs/API_ABUSE_PROTECTION.md#L16-L18: document the in-memory implementation as single-instance-only until the Redis adapter is available.
📍 Affects 3 files
apps/api/internal/middleware/abuse.go#L51-L63(this comment)apps/api/internal/middleware/abuse_test.go#L14-L23docs/API_ABUSE_PROTECTION.md#L16-L18
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@apps/api/internal/middleware/abuse.go` around lines 51 - 63, Replace
AbuseProtector’s process-local windows map with a storage interface supporting
atomic window updates and endpoint escalation state, and add a Redis-backed
implementation in apps/api/internal/middleware/abuse.go. In
apps/api/internal/middleware/abuse_test.go, add coverage with multiple
AbuseProtector instances sharing one backend to verify cross-instance
escalation. In docs/API_ABUSE_PROTECTION.md, document that the in-memory
implementation is single-instance-only until the Redis adapter is available.
| // WriteUniformLookupResponse prevents exists/not-exists response enumeration. | ||
| // Callers perform the real lookup first, then use this identical response. | ||
| func WriteUniformLookupResponse(w http.ResponseWriter) { | ||
| w.Header().Set("Content-Type", "application/json") | ||
| w.WriteHeader(http.StatusAccepted) | ||
| _, _ = w.Write([]byte(`{"success":true,"message":"If the resource is eligible, follow-up will be sent."}`)) |
There was a problem hiding this comment.
🔒 Security & Privacy | 🟠 Major | 🏗️ Heavy lift
Uniform bytes do not provide uniform lookup timing.
This helper only normalizes status/body. Because callers perform the real lookup first, existence-dependent lookup work can still be measured remotely. Centralize equivalent lookup work and enforce a bounded response-time policy, with timing tests for exists/non-exists paths.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@apps/api/internal/middleware/abuse.go` around lines 153 - 158, Update
WriteUniformLookupResponse and its callers to perform equivalent lookup work for
both existing and non-existing resources, then enforce a bounded response-time
policy before returning the identical response. Centralize this behavior in the
shared lookup flow rather than only normalizing status and body, and add timing
tests covering both existence paths.
* feat(security): add adaptive abuse protection * fix(security): harden adaptive abuse state
* chore: add CodeRabbit AI review config (auto-review PRs targeting dev/main)
* fix(ci): unblock Rust and Go pipelines (#798)
Bump ethnum 1.5.0 -> 1.5.3 in packages/contracts so the contract crate
compiles on the current stable toolchain (rustc 1.97.1). ethnum 1.5.0 fails
with error[E0512] transmuting () into TryFromIntError, which no longer share
a size; the crate fixed it in 1.5.1.
Bump apps/api to Go 1.25.12 and golang.org/x/text v0.39.0 to clear the two
govulncheck findings not in the allowlist: GO-2026-5856 (crypto/tls, fixed
in go1.25.12) and GO-2026-5970 (x/text, fixed in v0.39.0).
* Branch to solve issue#788 (#797)
* test(contracts): add negative authorization coverage
* test: tighten negative authorization assertions
---------
Co-authored-by: Deon <110722148+0xDeon@users.noreply.github.com>
* feat(api): envelope encryption, key versioning & rotation for account cipher (#799)
* feat(crypto): version-tagged envelope cipher for account numbers
Replace the single-key AccountCipher with a multi-version AES-256-GCM
cipher. Encrypt seals with the active key and returns a CipherEnvelope
carrying the key version; Decrypt resolves the key by the ciphertext's
version and fails with ErrUnknownKeyVersion when it is not registered.
NewAccountCipher is retained (registers the key as v1) for backward
compatibility. Fingerprints use a stable pepper independent of the active
key so the uniqueness index survives rotation.
* test(crypto): cover active-key encrypt, cross-version decrypt, unknown version, fingerprint stability
* feat(config): add AccountCipherConfig accessor and versioned key set type
* feat(config): parse ACCOUNT_CIPHER_KEYS/ACTIVE_KEY with legacy single-key fallback
ACCOUNT_CIPHER_KEYS (comma-separated version:base64 pairs) plus
ACCOUNT_CIPHER_ACTIVE_KEY take precedence; when unset, the existing
BANK_ACCOUNT_ENCRYPTION_KEY is registered as v1 so single-key
deployments keep working. Validates active version membership and pair
format at startup.
* test(config): cover multi-key parsing, legacy fallback, and validation errors
* docs(config): document account cipher key set and rotation env vars in .env.example
* feat(db): add bank_accounts.key_version column defaulting existing rows to v1
New column records which key sealed each row so rotation never rewrites
history. Indexed so the rotation tool can cheaply find un-rotated rows.
* feat(db): down migration dropping key_version column and index
* feat(bankaccount): thread key version through Repository Create/GetByID
* feat(repo): persist key_version on bank account insert
* feat(repo): return key_version from GetByID and add rotation Store methods
GetByID now yields the stored key version so callers can decrypt with the
right key. CountPending/ScanPending/UpdateCipher implement rotation.Store
for the rotation tool; UpdateCipher leaves the fingerprint untouched so the
uniqueness index is unaffected.
* feat(rotation): idempotent, resumable batch key-rotation engine
Rotator scans rows not on the active key version, decrypts each with its
recorded version, re-seals with the active key, and commits per row. A
second run finds nothing (idempotent); an interrupted run resumes from the
remainder. Logs only counts and row IDs, never plaintext, keys, or
ciphertext.
* test(rotation): re-encrypt to active, idempotency, resume-after-interrupt, no data loss
* feat(service): seal new accounts with the active key envelope
* feat(service): decrypt saved accounts by their stored key version
ResolveForSettlement reconstructs the CipherEnvelope from the stored
ciphertext and key version; SetDefault/Remove absorb the extra GetByID
return value.
* test(service): update in-memory repo mock for key-versioned signatures
* test(service): new writes use active key; legacy row decrypts after key added
* feat(cmd): rotate_keys CLI to re-encrypt accounts onto the active key
Loads the same key config as the API, refuses to run when no cipher is
configured, and drives the rotation engine over the bank_accounts store
with -batch-size and -timeout flags.
* feat(api): wire the multi-key account cipher from AccountCipherConfig
* docs(security): key versioning model, env format, and 5-step rotation runbook
* fix(crypto): require explicit fingerprint pepper when no v1 key is configured
Defaulting the fingerprint pepper to the active key let it change on every
rotation (e.g. v2->v3) and silently break blind-index uniqueness. Fail closed
with ErrFingerprintKeyRequired instead; cover the no-v1 active-key rotation
case. (CodeRabbit)
* fix(config): fail closed on empty keyset, over-long versions, and v1-less sets
- ACCOUNT_CIPHER_KEYS that parses to zero entries now errors instead of
silently disabling the cipher, and an active version absent from the set is
always rejected.
- Reject key versions longer than 32 chars (bank_accounts.key_version is
VARCHAR(32)) before they fail at the DB boundary.
- Require ACCOUNT_CIPHER_FINGERPRINT_KEY when the key set has no v1. (CodeRabbit)
* fix(db): guard key_version rollback and build its index concurrently
- 057 down aborts if any row is on a non-v1 key, since dropping key_version
would make rotated ciphertext undecryptable.
- Move the index into 058 using CREATE INDEX CONCURRENTLY so a large
bank_accounts table is not write-locked during deploy. (CodeRabbit)
* docs: clarify that a v1-less key set must set an explicit fingerprint pepper (CodeRabbit)
* test(service): consolidate key-versioning scenarios into a table-driven test (CodeRabbit)
* docs: clarify no active-key fallback; config-load failure vs constructor sentinel (CodeRabbit)
* feat(api): distributed rate limiting with strict route limits (#800)
* feat(api): add distributed rate limiting with strict route limits
Extend the existing in-memory rate limiter with a dual-mode backend: a
Redis fixed-window counter for cross-instance enforcement, falling back
to the in-memory token bucket when REDIS_ADDR is unset.
- Add Limiter interface + NewLimiter factory (Redis or in-memory)
- Global per-IP limiter now excludes /health*, /readyz, /metrics
- Strict per-IP limiter on POST /auth/challenge and /auth/verify
(credential stuffing) and strict per-user limiter on
POST /settlements (settlement spam)
- New RATELIMIT_AUTH_* and RATELIMIT_SETTLEMENT_* config knobs + .env
- Redis limiter fails open on outage so it never blocks live traffic
- Table-driven tests: under/over limit, 429 + Retry-After, window
reset, per-IP and per-user isolation, memory fallback, and a
Redis integration test guarded by REDIS_ADDR
* fix(api): address CodeRabbit review on rate limiting
- CORS: move cors middleware outermost so 429 responses from the global
and auth-route limiters still carry Access-Control-Allow-Origin and
stay readable to browser clients
- Redis: bound each limiter round-trip with a 75ms timeout so a slow
(not just down) Redis fails fast into fail-open instead of adding
multi-second latency to every request; log fail-open events
- Proxy-aware client IP: add RATELIMIT_TRUSTED_PROXY_COUNT (default 0).
When set, derive the client IP from X-Forwarded-For counting hops from
the right, so traffic behind a load balancer keys off the real client
instead of collapsing onto the proxy address, without letting clients
spoof past the trusted-proxy boundary
- Tests: proxy-aware keying + spoof resistance, and config default /
override / negative-validation for the new knob
* fix(api): reject sub-millisecond rate-limit windows
The Redis limiter converts the window to whole milliseconds for PEXPIRE,
so a positive but sub-1ms window (e.g. 500us) truncates to 0, expiring
the counter immediately and silently disabling enforcement. Reject
global/auth/settlement windows below 1ms at config load, with a
regression test.
* feat: core backend + AI primitives — job queue, harvest engine, portfolio valuation, RAG grounding (#824 #845 #832 #852) (#876)
* feat(api): durable async job queue (#824)
PostgreSQL-backed job queue with FOR UPDATE SKIP LOCKED dequeue, lease-based
visibility timeout with crash recovery, exponential backoff + full jitter,
dead-letter queue, per-job-type concurrency limits, idempotent enqueue, and
graceful drain on shutdown. Queue-depth/DLQ/latency metrics and correlation-ID
propagation included. Worker pool wired into the API with config knobs.
* feat(api): yield harvest orchestration engine (#845)
Cadence + event-triggered engine that applies the economic gate (harvest iff
accrued yield > gas fee + margin), defers under network congestion, and submits
harvests as idempotent, window-deduplicated jobs on the #824 queue. Includes a
gas oracle abstraction, vault/user/service adapters, an idempotent job handler,
and an owner-scoped harvest-status API (pending yield, threshold, estimated next
harvest). Pure decision core and engine fully unit-tested.
* feat(api): real-time portfolio valuation service (#832)
Stroop-exact aggregation of positions, pending deposits, accrued yield, goal
allocations, and claimable rewards with a structured per-vault/per-goal
breakdown (principal vs yield, locked vs flexible, settled vs pending,
claimable). Multi-asset oracle pricing with confidence propagation, per-user
cache with event-driven invalidation on confirmed transactions, and WebSocket
push of refreshed valuations. Pure aggregator, cache, and service unit-tested.
* feat(intelligence): RAG grounding for Prometheus AI (#852)
Structured retrieval layer that routes queries to the right user-scoped data
sources (positions, goals, transactions, yield landscape) without embeddings,
assembling only the minimal context needed with citations. Grounding rules
force the model to answer solely from retrieved context, cite it, and refuse
when data is missing; post-generation numeric validation flags any figure not
present in the context to catch hallucinations. Strict per-user isolation: scope
is fixed by the JWT subject and cannot be widened by prompt injection. Wired into
streaming chat (and WebSocket chat via the shared path). Fully unit-tested.
* feat(api): savings goal archive-on-delete, amount/name validation, yield cache warming (#874)
* fix(savingsgoal): soft-archive goals on DELETE instead of hard-delete (#685)
Replace the permanent DELETE with an UPDATE that stamps archived_at and
sets status to 'archived'. Adds migration 059 to introduce the
archived_at column. Already-archived goals surface as ErrGoalNotFound
(404) so callers get a sensible response without a 500. Adds two unit
tests via sqlmock asserting the soft-delete and already-archived cases.
* feat(yield): warm DeFiLlama Stellar cache on service startup (#667)
* fix(savingsgoal): validate target_amount and goal name (#692 #681)
#692 — Add savingsgoal.ErrInvalidAmount (defined in the savingsgoal
domain, not imported from vault) and update validateSavingsGoalInput to
return it when target_amount is zero, negative, or below MinTargetAmount
(0.01). Handler writeError now maps ErrInvalidAmount to 400 Bad Request,
fixing the 500 that was returned when vault.ErrInvalidAmount was not
recognised.
#681 — Add validateGoalName capping name at MaxGoalNameLength (100
chars) consistent with the savings_goals.name column width. Called on
both Create and Update paths so over-long names return a 400 instead of
a DB error.
* test(savingsgoal): cover amount and name validation cases (#692 #681)
* feat(contracts): add reentrancy guard and callee allowlist framework (#811) (#879)
Introduce shared temporary-storage reentrancy guards and callee allowlists in libs/common, apply them across vault, treasury, and allocation strategy fund-moving paths, and add hostile mocks with adversarial integration tests plus documented resource costs.
* chore(security): fix IDOR vulnerabilities and harden JWT configuration (#872)
* chore(security): fix IDOR vulnerabilities and harden JWT configuration
Addresses highest-priority findings from security assessment (Issue #589):
Fixed:
- Added ownership validation for vault retrieval endpoints (GET /vaults/{id}, GET /vaults/{id}/allocations)
- Added ownership validation for transaction creation (POST /transactions)
- Added ownership validation for transaction retrieval (GET /transactions/{hash})
- Hardened Intelligence service by preventing production startup without JWT secret via Pydantic model_validator
Documentation:
- Added docs/security/threat-model.md (assets, trust boundaries, entry points, threat actors, controls)
- Added docs/security/pentest-report-v1.md (11 findings with evidence, impact, root cause, remediation, verification)
Authorization was implemented at the handler level rather than the service layer because the service methods are shared by numerous trusted internal system components (scheduler, rebalance, TVL, projections). Refactoring those interfaces would have expanded scope considerably and increased regression risk.
Closes #589
* style: fix ruff line length in test_config.py
* fix: return 404 for missing vault in transaction ownership check
* chore: address CodeRabbit review comments
- Remove dead var _ = decimal.Zero
- Fix function name extractClientIP -> clientIP in evidence
- Update Go test result from manual to ALL PASS (CI confirmed)
- Correct deposit flow wording (price_per_share is not user-supplied)
- Fix WebSocket nil-authenticator mitigation claim (no nil check exists)
* fix(api): resolve duplicate 059 migration prefix on dev
Two migrations shared the prefix 059. `059_create_jobs` landed first in
#876; `059_add_savings_goal_archived_at` landed eight minutes later in
#874 and collided with it.
The consequence was worse than a lint failure. golang-migrate refuses to
load a directory containing duplicate versions, so migrations could not
run at all past 058, and the migration-prefix guard in the API (Go)
workflow failed on every pull request touching the Go API — six open PRs
were red through no fault of their authors.
Renumber the later arrival to 060 and leave `059_create_jobs` in place,
since it merged first and is the version any environment already sitting
at 59 will have applied. Renumbering it instead would have desynced those
environments. `059_add_savings_goal_archived_at` has never been applied
anywhere, because the collision prevented golang-migrate from loading the
directory in the first place, so moving it is safe.
The only in-repo reference to either filename is in
job_repository_integration_test.go, which points at `059_create_jobs.up.sql`
and is unaffected.
Open PRs claiming 060 will need to rebase and renumber.
* feat(api): feature flags, server-side exports, replica routing, API versioning (#882)
Implements four platform capabilities:
Feature flags (#838) — internal/flags/
- Boolean kill-switch, deterministic percentage rollout, cohort and
typed-value flags stored in Postgres (migration 060)
- Percentage membership is hash-based and stable: a user in at 10% stays
in at 20%
- In-process cache with TTL backstop and pub/sub invalidation channel so
changes propagate across instances within seconds
- Kill switches fail SAFE: evaluator returns the registered safe position
when the flag service is unreachable, never fail-open
- Secret guard rejects secret-marked names from the flag store
- Every change goes through a required AuditRecorder
Server-side exports (#839) — internal/export/
- Transaction-history CSV generated from the ledger source of truth with a
stable, documented column schema
- Reconciliation invariant: exported movements must sum to the ledger's
net change per asset or the export errors instead of delivering a wrong
document
- Exports above a documented row threshold route to the durable job queue
- HMAC-signed, time-limited, ownership-verified download tokens; another
user cannot fetch someone's export
Read-replica routing (#841) — internal/db/router.go
- Explicit Read/Write paths declared at call sites, never inferred from SQL
- Read-your-writes: users are pinned to the primary for a bounded window
after writing (Pinner interface; in-memory impl, Redis-ready)
- Unhealthy or lag-exceeding replicas are routed around automatically with
primary fallback; transactions always use the primary
- Per-role pool stats exposed for metrics; Close drains all pools
API versioning (#842) — internal/server/versioning.go, docs/api-versioning.md
- Uniform URL-path versioning with versioned route groups
- Deprecated versions emit Deprecation, Sunset and successor Link headers
on every response; retired versions return 410 Gone with guidance
- Unversioned requests route to a pinned default, not 'latest'
- Per-version usage counting so retirement is data-driven
All packages fully unit-tested (33 tests).
* feat(intelligence): yield optimization engine with constraint-based allocation strategies (#889)
Adds a deterministic, constraint-based yield optimizer to the intelligence
service (#848). Given candidate yield sources and hard constraints
(diversification cap, liquidity floor, lock-horizon fit, risk ceiling,
deposit caps, source status), app.services.yield_optimizer.optimize()
solves a concave-quadratic risk-adjusted-return objective with
scipy.optimize.minimize (SLSQP) and returns per-source weights (fraction
and basis points), expected yield, aggregate risk, and a diversification
index. Infeasible constraint sets are reported explicitly via
infeasibility_reasons and never silently relaxed.
The optimizer is a pure, synchronous, dependency-free function, kept
separate from app.services.yield_explanation, which has Claude narrate an
already-computed result in plain language and validates (via the existing
extract_numbers/normalize_number helpers from retrieval.py) that no number
absent from the result appears in the explanation, falling back to a
deterministic template otherwise.
Also wires a new POST /intelligence/yield-optimization endpoint, adds
scipy/numpy to requirements.txt, and fixes config.py's stale
anthropic_model default (claude-sonnet-4-6 -> claude-sonnet-5).
Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
* chore(security): create load and stress testing plan for vault API and real-time balance endpoints (#891)
Co-authored-by: felladaniel36-hash <felladaniel36@gmail.com>
* feat(api): scheduler leader election for safe multi-instance background jobs (#894)
Adds Postgres-advisory-lock leader election (internal/scheduler/leadership.go)
gating all five scheduler background job loops (rebalancer, recurring
deposits, APY deviation alerts, goal deadline reminders, protocol health
checks) so exactly one instance runs them at a time, with automatic failover
bounded by a 3s heartbeat interval and an execution-time leadership recheck
immediately before every money-moving/notification action to guard against
split-brain during failover.
Also fixes a real latent bug the recurring-deposit job had: its transaction
hash was derived from the schedule ID alone (constant across every
occurrence of a recurring schedule), so only a schedule's first-ever
occurrence could ever be recorded — every later occurrence hit
vault_transactions' unique transaction_hash constraint and retried forever.
The hash now folds in the occurrence timestamp, and the deposit-recording
step is routed through the existing durable job queue (internal/domain/
jobqueue) with a per-occurrence idempotency key, mirroring the harvest
engine's enqueue pattern, for at-least-once safety.
Wires the three job loops that existed but were never started in main.go
(the rebalance-decision Scheduler remains unwired pending real on-chain
RebalanceSubmitter/YieldFetcher adapters — a pre-existing gap, not a
regression), and exposes current leader/instance/since via a new
GET /api/v1/admin/scheduler/leadership endpoint.
Closes #846
Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
* feat: AI savings coaching, AI rebalance engine, PWA offline support, i18n framework (#896)
Closes #112, #110, #790, #789
- Savings goal AI coaching (#112): on-demand GET /api/v1/users/savings-goals/{id}/coaching
endpoint plus a weekly GoalCoachingScheduler background job, both backed by the
existing intelligence /intelligence/coaching endpoint. Progress tracking, on-track
status, and required-deposit math already existed in the savingsgoal domain/service;
this closes the remaining AI-coaching gap in the issue.
- AI rebalancing engine (#110): new risk_model.py (Sharpe-ratio inspired
risk_adjusted_score, per-protocol risk factors) and rebalance_engine.py in
apps/intelligence, combining live DeFiLlama APY data with a cached-baseline
fallback and Claude-generated rationale. New POST /vaults/{id}/rebalance/suggest
and /execute endpoints (execute builds an unsigned Stellar transaction via
stellar-sdk), proxied through the Go API at the same paths.
- PWA installability + offline handling (#790): web app manifest, hand-rolled
service worker (app-shell precache, network-first navigation with an /offline
fallback, API requests never cached), and an offline guard on the offramp
withdraw action. The online/offline hook, banner, and deposit/withdraw guards
already existed; this fills the manifest/SW/offline-route/offramp gaps.
- i18n framework (#789): locale provider + en/fr message catalogs + a shared
formatCurrency/formatNumber/formatDate helper (Intl-based), wired into
dashboard/savings/offramp/settings screens, replacing several ad-hoc
formatCurrency implementations. Locale persists to localStorage and is
selectable from Settings > Preferences.
Verified: go build/vet/test + golangci-lint clean; pytest (84 tests) + ruff +
mypy --strict clean; vitest (83 tests) + tsc --noEmit + eslint clean.
Co-authored-by: Chidimj <Chidimj@users.noreply.github.com>
* feat(contracts): granular RBAC, autonomous circuit breaker, vault factory, referral program (#900)
Closes #820, #817, #816, #818
- access_control: granular Role enum (Guardian, Upgrader, Attester,
FeeManager, RebalanceKeeper, Treasurer, VaultCreator), generalised
two-step role transfer (transfer_role/accept_role/cancel_role_transfer),
time-bounded grants (grant_role_until), bounded on-chain enumeration
(get_role_members, role_expires_at). Guardian can pause/halt but never
unpause/upgrade/withdraw.
- vault: autonomous staged circuit breaker (breaker.rs) with independently
configurable trip conditions (share-price move, yield sanity, withdrawal
velocity with anti-griefing margin, source failure), graded severity
(Normal/Throttled/DepositsHalted/FullHalt), staged cooled-down recovery
gated to Admin/Upgrader, and an emergency withdrawal path that works at
every severity. Guardian-only pause/halt entrypoints added.
- vault_factory: new contract deploying vaults from a governed WASM hash
via the Soroban deployer, atomic deploy+init, deterministic address
prediction, O(1) is_nester_vault registry, bounded pagination, timelocked
WASM-hash governance, deprecate_vault.
- referral: new standalone contract for a trustless referral program.
Rewards accrue from the protocol's performance-fee slice (never the
referred user's own yield), gated by minimum deposit/tenure, capped per
referrer and by a global budget that halts accrual without clawback.
Vault is the sole trusted caller, mirroring the existing
treasury.receive_fees pattern.
- Narrower roles wired into treasury (Treasurer), yield_registry
(Attester), and allocation_strategy/vault (RebalanceKeeper, FeeManager)
alongside existing Admin/Operator checks.
- EVENTS.md, SECURITY.md, and the contracts README document the new role
model, Guardian asymmetry, and breaker/factory/referral event surface.
All contracts build to wasm32-unknown-unknown; full workspace test suite
and clippy (-D warnings) pass clean.
* feat(intelligence): add sourced market context signals (#892)
* feat(intelligence): add sourced market context signals
* fix(intelligence): satisfy market context lint
* fix(intelligence): type extraction client boundary
* fix(intelligence): harden signal provenance and batching
* feat(security): add adaptive API abuse protection (#893)
* feat(security): add adaptive abuse protection
* fix(security): harden adaptive abuse state
* feat(intelligence): personalized savings recommendation engine grounded in user data (#897)
* feat(intelligence): personalized savings recommendation engine grounded in user data
Adds a savings recommendation engine (app/services/recommendation_engine.py)
that generates personalized, actionable recommendations from a user's real
goals, positions, and cash-flow behavior. Candidate actions (increase a
goal's contribution, move idle balance to higher yield, lock for a term
boost, consolidate goals toward the nearest deadline) and every number
attached to them are computed deterministically in Python -- Claude, called
via tool use, only selects 2-4 candidates, orders them, and writes a short
explanation, constrained to a `select_recommendations` tool schema that can
only reference candidates by id. A fabrication guard
(`_validate_selection`) checks every number in the model's prose against the
set of numbers the referenced candidate actually carries, rejects and
regenerates once on violation, then falls back to a fully templated
explanation built straight from the candidate's own fields -- so a fabricated
number can never reach the response.
Yield-related candidates always carry risk context (from the vault's real
risk score or a documented default disclosure); goal-success figures are
integrated with the #843 Monte Carlo simulation endpoint when reachable
(two calls -- current vs. proposed contribution -- diffed into a real
probability delta) and degrade to a documented heuristic otherwise, since
per-user (Redis-backed with an in-memory fallback, mirroring
conversation_store.py's pattern) and filtered out permanently; acted-on
action types get a deterministic priority boost. Recommendations are cached
per-user for 6 hours and invalidated automatically when goal/vault figures
change materially, rather than recomputed per page load. Fixes the stale
`claude-sonnet-4-6` model id.
Closes #847
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
* fix(intelligence): correct #843 simulation contract, mypy/ruff cleanup for #847
- projection_client.py: point ProjectionProvider at the real #843 endpoint
contract (POST /api/v1/tools/simulation, goal_success.probability) now
that it's known, instead of the placeholder GET route guessed before
#843's shape was finalized. Computes the success-probability delta from
two simulation calls (current vs required contribution) rather than
guessing the Go service's internal sensitivity-grid step sizes.
- recommendation_engine.py: thread GoalContext into enrich_with_projections
so it can build the simulation request; type the Anthropic tool-use call
properly (ToolParam/ToolChoiceToolParam/MessageParam) instead of bare
dicts, fixing mypy strict errors -- this is the first tool-use call in
the intelligence service, so no prior typed precedent existed.
- ruff: import sort, drop pointless f-string prefixes, wrap one long line.
mypy --strict and ruff both clean; full suite still 105/105 passing.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
* chore: retrigger CI (no functional change)
---------
Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
* feat(intelligence): backend plumbing for periodic financial insight digests (#898)
Adds the Go-side data plumbing for #859's periodic digest: a digest_cadence
notification preference (off/weekly/monthly, opt-out respected), a
user_digests cache/audit table for one-generation-per-period, a
digest-ledger source endpoint exposing deterministic period deposit/yield/
streak facts for the intelligence service to narrate via the relay, and a
leader-elected daily scheduler job that generates and delivers digests
through the existing notification dispatcher.
This PR covers the Go backend groundwork only. The intelligence-service
narrative generation (grounded LLM prompt, zero-save honesty handling,
attention items, Redis caching), frontend insights card, and test coverage
described in #859's acceptance criteria are not yet implemented — tracked
as follow-up. #865, #864, and #856 are referenced per this repo's issue
numbering but have no implementation in this branch.
Note: this environment has no Go toolchain available, so these changes are
reviewed manually but not compiled or test-run locally.
* feat(vault): fair-ordering emergency queue, tiered fees, slippage-safe rebalance, penalty escrow (#901)
Implements four vault contract features plus their backend indexing:
- #814: fair-ordering emergency withdrawal queue (queue.rs) so paused-vault
exits are served in request order instead of first-caller-wins.
- #813: duration/size-tiered fee schedule (performance, exit, management)
replacing the flat-rate config, with a continuous tenure curve superseding
the old binary min-lock gate.
- #810: slippage-safe multi-hop rebalance split into plan/execute steps
(rebalance.rs) with per-leg minimum-out enforcement.
- #805: early-exit penalty escrow with depositor/treasury split distribution
instead of penalties vanishing into thin air.
Backend: migrations for the four new event-sourced tables, Stellar event
indexer wiring for all seven new on-chain events (including the previously
unhandled rebalance-completed event), and read-only history endpoints under
/api/v1/vaults/{id}/.
Co-authored-by: dslegacy <dslegacy@users.noreply.github.com>
* feat(contracts): on-chain savings goal registry with milestone attestation (#903)
* feat(contracts): on-chain savings goal registry with milestone attestation
Adds a savings_goal Soroban contract recording goal ownership, target,
deadline, and progress trustlessly, with an idempotent 25/50/75/100%
milestone bitmask and bounded multi-contributor accounting. The registry
never custodies funds — only the vault does. Vaults are validated against
the deployed vault_factory at goal creation.
Backend: onchain_goal_id/onchain_status columns and model fields, repo
read/write wiring, and a bitmask<->milestone translation helper aligned
with the contract's semantics so the existing notifier can treat an
on-chain attestation as equivalent to a notified milestone.
* fix: renumber duplicate/colliding migrations to match landed dev sequence
* test(api): add unit tests for YieldHarvest model (#962)
Co-authored-by: opascal221-design <opascal221-design@users.noreply.github.com>
* feat(dapp): savings goal and vault progress visualization with locked-position support
Add rich progress visualization for savings goals and vaults:
- Segmented progress bar distinguishing locked vs flexible portions
- Principal vs earned yield composition breakdown
- Maturity timeline for locked positions with boost badges and unlock dates
- Probabilistic projection band (confidence interval + success probability)
- Constructive at-risk messaging when goal is off track
- Multi-asset vault composition donut (reuses existing recharts pattern)
- Celebration/completion state, encouraging empty state
- Respects reduced-motion preferences via existing useReducedMotion hook
- All states: empty, in-progress-with-locks, at-risk, completed
- Backward compatible: falls back to simple progress bar when rich data absent
Closes #869
* test(api): add unit tests for YieldHarvest model (#962)
Co-authored-by: opascal221-design <opascal221-design@users.noreply.github.com>
* fix: remove unused variable and import
- Remove unused startKYC variable from rotate_keys/main.go (go vet error).
- Remove unused time import from backfill_kyc_encryption/main.go (go vet error).
* fix(apysnapshot): add unique constraint and idempotent upsert (#963)
Adds DB-level uniqueness on (protocol_slug, captured_at) via migration
069, and updates Upsert to use an explicit ON CONFLICT target so duplicate
oracle reports for the same protocol+timestamp are silently ignored.
* feat: session hardening, Prometheus tool-use, nudge engine, unified list search (#967)
* fix(ci): unblock Rust and Go pipelines
Bump ethnum 1.5.0 -> 1.5.3 in packages/contracts so the contract crate
compiles on the current stable toolchain (rustc 1.97.1). ethnum 1.5.0 fails
with error[E0512] transmuting () into TryFromIntError, which no longer share
a size; the crate fixed it in 1.5.1.
Bump apps/api to Go 1.25.12 and golang.org/x/text v0.39.0 to clear the two
govulncheck findings not in the allowlist: GO-2026-5856 (crypto/tls, fixed
in go1.25.12) and GO-2026-5970 (x/text, fixed in v0.39.0).
* chore: remove internal audit and decision report files from repo root
* feat(intelligence): add prompt-injection and output-safety guardrails for chat/analyze (#875)
* feat(intelligence): add prompt-injection and output-safety guardrails
Claude calls in the chat and analyze paths had no defense against prompt
injection or system-prompt extraction, and recommendation output wasn't
schema-enforced. Add input screening (regex-based, logs request_id + a
non-reversible fingerprint, never raw content), a hardened system prompt
with an explicit trust boundary and tagged untrusted-content wrapping,
deterministic history/message bounding, and output post-processing that
strips leaked system-prompt text and enforces a non-model-controlled
disclaimer on /analyze and related endpoints.
* fix(intelligence): close remaining guardrail gaps from review
- Validate inbound X-Request-Id against a bounded safe charset before
trusting it in state/headers/logs, falling back to a fresh UUID
otherwise (prevents log/header injection via a client-supplied header).
- Fix the chat streaming leak-redaction buffer to retain a sanitized
lookback tail on flush instead of resetting to empty, so a
system-prompt marker split across two deltas is still caught.
- Wrap the remaining unwrapped context data interpolated into the
recommend/vault and analyze prompts (positions, vault/user context
lines) in the same trust-boundary tags used elsewhere.
- Sanitize the few model-derived output fields that were missed:
confidence_reason/data_freshness in Recommendation, insight card
action.label/href, and deposit schedule note.
* feat(api/pkg): add keyset cursor and list query grammar parsing
* feat(api/vault): add full-text search and advanced list filtering
* feat(api/settlement): implement memo search and filter updates
* feat(api/savingsgoal): implement search, list filters and repository updates
* feat(api/activity): introduce activity domain, repository, handlers and filter
* feat(dapp/history): update history page to support list filtering and search
* feat(db): add migration for session family rotation and tracking
* feat(api): implement session domain models, repositories, and config
* feat(api): add auth services for token rotation, revocation, and anomaly detection
* feat(api): add session-aware auth middleware, HTTP handlers, and wire main API
* feat(ws): disconnect active WebSocket connections on session revocation
* feat(frontend): implement automatic token refresh and auth provider state
* feat(frontend): add active sessions UI management in settings
* ci: add concurrency groups with cancel-in-progress across workflows
* test(api/savingsgoal): implement ListPaginated mock in template handler tests
* db(migrations): add schemas for user timezone, activity events, nudge log, and preferences
Add DB migration files:
- 057: User timezone column on users table
- 058: Activity events table for tracking user logins and interactions
- 059: Nudge dispatch log table for dispatch history and outcome tracking
- 060: Nudges enabled preference flag
* feat(domain): define smart nudge catalog, user signals, anti-fatigue rules, and intelligence DTOs
Introduce core domain primitives for smart savings nudges:
- Nudge catalog, trigger condition evaluation, priority ranking, and anti-fatigue limits
- User activity, engagement heuristics, responsive timing window, and user segmentation
- Intelligence request/response DTOs for AI copy generation
- User model update for timezone preferences
* feat(repo): add data access for user timezones, activity events, nudge history, and goals
Implement Postgres repository methods for:
- User profile updates supporting timezone
- Recording and querying user activity events
- Logging nudge dispatches, checking anti-fatigue thresholds, and tracking conversion outcomes
- Fetching active savings goals for nudge evaluation
* feat(intelligence): add AI nudge copy generation endpoint with numeric grounding guardrails
Add FastAPI endpoint and AI services for dynamic push copy generation:
- Generate personalized nudge copy via Anthropic Claude model integration
- Validate numeric grounding in guardrails to prevent hallucinated currency figures
- Register /intelligence/nudges route in main FastAPI application
* feat(service): implement nudge engine orchestration, copy generation, and outcome tracking
Add core service logic for smart savings nudges:
- Composite copy generator (static templates fallback + LLM generated copy)
- Prometheus client method for generating nudge copy
- Nudge notifier adapter and milestone-to-nudge milestone mapper
- Nudge outcome service for recording deposits, goal completions, and return visits
- Core NudgeEngineService evaluating rules, user signals, ranking, and anti-fatigue limits
- Register EventSavingsNudge in notifications package
* feat(auth,savings): integrate timezone capture, activity tracking, and nudge outcome hooks
Hook user actions into nudge signals and outcome tracking:
- Return userID from Auth.VerifyAndIssue to record user timezone, login activity event, and return visit outcome
- Attach OutcomeRecorder to SavingsGoalService to track goal completion outcomes
* feat(scheduler,cmd): replace legacy reminder job with periodic nudge engine and wire main app
Wire up the smart savings nudge engine:
- Replace legacy goal deadline reminder job with background NudgeEngineJob
- Initialize repositories, services, and nudge notification dispatcher in main.go
- Trigger nudge evaluation and outcome tracking on completed transaction deposits
* refactor(api): extract audit entry model to domain layer to prevent import cycle
* feat(api): add jti claim to access tokens for unique token minting
* db(migrations): add 057_create_tool_invocations for tool audit logging
* feat(api): add tool audit domain, repository, service, handlers, and proxy routes
* feat(intelligence): implement Prometheus tool execution loop, tool registry, cost governor, and audit client
* feat(dapp): add interactive tool confirmation flow to Prometheus chatbot UI
* style: format code and sort imports across Python intelligence service and Go test files
* fix(api): add timezone field to UpdateProfileInput in UserService
Extend UpdateProfileInput struct with Timezone field to enable clean profile updates from auth handler during wallet verification.
* test(api): add unit test coverage for nudge rules, signals, and outcome recording
Add unit tests covering:
- Anti-fatigue cooldown limits and cap checks
- Static copy template formatting and facts mapping
- Priority scoring and ranking for candidate nudges
- Responsive window signal calculations
- Nudge outcome recorder (deposit, goal completion, return visit tracking)
* fix(intelligence): enforce strict numeric grounding on percentage values and add tests
Update validate_numeric_grounding guardrail:
- Treat percentage values (e.g., '8%') as fact-grounded regardless of digit count to prevent APY mismatches
- Add unit test suite for numeric grounding validation across dollar amounts, Naira figures, percentages, and prose integers
* feat(api): migrate refresh tokens to httpOnly secure cookies
* feat(frontend): adapt API client and auth store for httpOnly refresh cookies
* fix(intelligence): harden input screening against nested boundary tags
* fix(cmd,usersignal): fix vault lookup method in txPoller and remove unused import
Fix vault lookup in main.go transaction poller callback from GetByID to GetVault, and remove unused time import from usersignal interfaces.
* style(intelligence): format Python nudge models, router, and services
Clean up import order and apply ruff/black formatting across Python intelligence service nudge endpoints and functions.
* style: format code and sort imports across Python intelligence service and Go test files
* refactor(api/migrations): renumber search & activity migrations to 061-064
* refactor(intelligence): add strict type hints and defensive checks to tool handlers
* test(api): update auth_service_test for 3-tuple return from VerifyAndIssue
Update unit test assertions in auth_service_test.go to match the updated VerifyAndIssue signature returning (token, userID, err).
* style(intelligence): format Pydantic schema in nudge models
Format blank lines around Pydantic classes in nudge.py according to PEP 8 standards.
* refactor(intelligence): add strict type hints and defensive checks to tool handlers
* fix(intelligence): remove duplicate Any import
* feat(intelligence): secure nudge copy router with JWT auth and strong typing
Update nudge copy endpoint contract:
- Switch route authentication dependency from API key to JWT verification (verify_jwt)
- Update generate_nudge_copy service function to return strongly typed NudgeCopyResponse Pydantic models
* fix(intelligence): remove unused type ignores and add missing kwargs type
* fix(intelligence): source rebalance rationale model from settings
---------
Co-authored-by: 0xDeon <oluwadamilare_daniel@outlook.com>
Co-authored-by: G-ELM <alfygodwin@gmail.com>
* Feat/issues 943 944 945 946 (#969)
* test(api): add unit tests for protocoltvl model
- Add coverage for TVL delta computation
- Add tests for negative and zero TVL edge cases
- Test 24h change percentage calculations
* test(api): add unit tests for tvl model
- Add coverage for aggregation across protocols
- Test zero and negative TVL edge cases
- Test precision handling for USDC formatting
* feat(api): add vault capacity limits and soft-cap warnings
- Add SoftCapacity and CapacityWarningPct fields to Vault model
- Implement GetCapacityStatus() for API exposure
- Implement CanAcceptDeposit() to gate deposits at capacity
- Add ErrCapacityExceeded error type
- Add comprehensive tests for capacity status and gating
* feat(api): add harvest dry-run/simulation mode
- Add SimulateHarvest() method to harvest engine
- Returns expected gas cost and net yield without execution
- Integrates with existing gas estimation in gas.go
- Useful for user-facing harvest preview features
---------
Co-authored-by: meloball9993 <starmeloball9993@gmail.com>
* test(api): unit tests for apysnapshot model validation (#975)
apps/api/internal/domain/apysnapshot/model.go had no test coverage.
Adds model_test.go covering:
- Validate(): required protocol slug, non-negative APY/TVL, non-zero
capture timestamp
- ByCapturedAt: chronological ordering of a snapshot slice
- DuplicateTimestamps: detecting repeated captured_at values within a
protocol's snapshot history, which the (protocol_slug, captured_at)
unique constraint should otherwise prevent from reaching storage
- error message assertions for ErrProtocolNotFound and
ErrDuplicateSnapshot
Validate, ByCapturedAt, and DuplicateTimestamps are small additions to
model.go needed to give the requested validation/ordering/duplicate
tests something concrete to exercise at the domain layer, independent
of the Postgres repository.
Co-authored-by: AdaBliss <295242925+AdaBliss@users.noreply.github.com>
* fix: correct computePctChange for negative TVL values and fix precision test expectation (#977)
- computePctChange now treats negative current as zero and returns 0 for
negative prior (avoid division-by-zero with negative denominator)
- Fix TestPrecisionHandling expected value: StringFixed(2) rounds half-up,
so 1234.567890 -> 1234.57, not 1234.56
* feat(dapp): market sentiment component historical trend view (#978)
components/ai/marketSentiment.tsx showed current sentiment only. Adds a
small 7/30 day sparkline so users see the trend, not just a
point-in-time read.
- app/services/sentiment_history.py: records each successfully computed
sentiment (signal + confidence) with a timestamp, backed by Redis
when available (same pattern as coingecko.py's cache) with an
in-memory fallback, retaining 30 days of points
- wire recording into prometheus.get_market_sentiment on its success
path
- new endpoint GET /api/v1/market/sentiment/history?days=7|30 in
analyze.py, clamped to [1, 30]
- dapp: intelligence.getMarketSentimentHistory(days) client method and
a SentimentSparkline component in marketSentiment.tsx rendering an
inline SVG confidence trend line with a 7d/30d toggle, colored by the
most recent point's signal, with a graceful "not enough history yet"
state when fewer than 2 points are available
Co-authored-by: AdaBliss <295242925+AdaBliss@users.noreply.github.com>
* feat(api): per-vault configurable harvest frequency (#974)
The harvest engine previously evaluated every vault on a single global
tick interval, so a small vault and a large one paid the same harvest
cadence regardless of their gas-cost tradeoffs. Vaults can now be
configured for daily or weekly harvesting.
- add harvest_frequency and last_harvested_at columns to vaults
(migration 080), defaulting new vaults to daily
- add vault.ParseHarvestFrequency and a Repository.UpdateHarvestFrequency
method
- gate the harvest engine's tick, TriggerVault and status/simulation paths
on a new DueForHarvest check alongside the existing economic gate, and
record last_harvested_at whenever a harvest is applied
- add a PATCH /api/v1/vaults/{id}/harvest-frequency endpoint, restricted
to the vault owner
Complements the harvest engine from #845.
Co-authored-by: AdaBliss <295242925+AdaBliss@users.noreply.github.com>
* feat(contracts): add timelock-governed upgrade framework for Soroban contracts (#959)
* feat(contracts): implement secure timelock-governed upgrade framework
* fix(ci): build contract packages explicitly for WASM target
* fix(ci): strip CR/LF from extracted package name to fix WASM build loop
* fix(ci): use cargo metadata to enumerate workspace-member contracts for WASM build
* fix(api): fix TVL negative edge cases and precision truncation
- protocoltvl: add computePctChange() — clamps negative current to 0,
returns 0 for negative/zero prior (undefined %). Add model_test.go.
- tvl: add FormatUSD() (truncate-2) and FormatUSDC() (truncate-6) to
prevent rounding-up of displayed balances. Use them in tvl service.
Add model_test.go covering TestPrecisionHandling.
* fix(api): move computePctChange to model_test.go to avoid redeclaration
---------
Co-authored-by: Hamfit <opefawazademolar@gmail.com>
* feat(api): Monte Carlo savings forecasting engine (#890)
* feat(api): Monte Carlo savings forecasting engine
Upgrade savings projections from a single deterministic point estimate to
a Monte Carlo forecast: thousands of randomized paths over the horizon
varying yield (grounded in a vault's real historical APY volatility) and
contribution behavior (grounded in the user's own active savings
schedule, with a documented new-user prior), reporting a P10/P50/P90
band and a goal-success probability plus a deposit/deadline sensitivity
grid whose "more deposit never lowers success probability" guarantee is
an exact structural property (common random numbers), not statistical.
- internal/domain/projection/simulation.go: pure Monte Carlo engine
(RunMonteCarloSimulation, SensitivityGrid, DeriveSeed, MeanStdDev) and
supporting types, carried over from a prior session and left
unmodified except for adding SimulationOutput.ContributionSource.
- internal/service/projection_simulation.go: SimulateVaultProjection
resolves real APY history/schedule data, derives a stable seed, and
caches results in a small in-process TTL cache (5 min window).
- internal/handler/projection_handler.go: new authenticated
POST /api/v1/tools/simulation endpoint.
- cmd/api/main.go: wires the savings goal/schedule repos into
ProjectionService.
- internal/domain/projection/README.md: documents every distributional
assumption (yield model, contribution/skip model + new-user prior,
path count rationale, RNG seeding/caching scheme).
- calculator_test.go: percentile stability across runs with the same
seed, zero-volatility collapse to the deterministic projection,
goal-success probability against a hand-computed case, and
sensitivity-grid deposit monotonicity.
- Frontend: lib/api/projection.ts gains typed simulation types/client;
savings-calculator.tsx renders the P10/P90 band + P50 line and a
goal-success probability tile alongside the existing deterministic
chart.
Closes #843
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
* fix(api): bound Monte Carlo simulation horizon to fix CodeQL memory-exhaustion alerts
CodeQL flagged two high-severity findings on this PR: make([][]float64,
months) and make([]PercentileTimelinePoint, months) in
RunMonteCarloSimulation size their allocation directly off the caller-
supplied PeriodMonths, with no upper bound. A caller (or a bug upstream)
supplying an extreme period_months value would drive an unbounded
allocation before any other check fires -- a memory-exhaustion DoS vector.
Adds MaxPeriodMonths (50 years) and:
- SimulationInput.Validate rejects PeriodMonths/DeadlineMonths beyond it
with a new ErrPeriodTooLong, so a caller gets a clear error instead of
a silently truncated result.
- RunMonteCarloSimulation also clamps to MaxPeriodMonths directly at the
allocation site, as defense in depth for any caller that reaches it
without going through Validate first.
Adds regression tests for both: TestSimulationInput_Validate_RejectsExcessivePeriod
and TestRunMonteCarloSimulation_ClampsExcessivePeriodMonths (the latter
passing quickly without an OOM is itself the assertion for a
2-billion-month input).
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
* fix(api): use min() in make() calls to satisfy CodeQL flow analysis
* fix(api): guard against excessive PeriodMonths with early return instead of clamping
CodeQL's taint tracking for 'Slice memory allocation with excessive size
value' could not verify the defensive clamp (months = MaxPeriodMonths)
as a sufficient bounds check. Replacing with a guard clause
(months <= 0 || months > MaxPeriodMonths -> early return) makes the
invariant explicit: the make([]T, months) calls are only reachable when
months is already within [1, MaxPeriodMonths].
---------
Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
* feat: add reconciliation engine foundation (#887)
Co-authored-by: kitWarse <278602811+kitWarse@users.noreply.github.com>
* feat: add time-series rollup store (#885)
Co-authored-by: kitWarse <278602811+kitWarse@users.noreply.github.com>
* fix: address review feedback - useMemo dependency, unused import and vars
* feat(api): yield APY snapshot anomaly flagging before ingestion
apysnapshot/model.go stores APY snapshots straight from the DeFiLlama
poller with no sanity check, so a bad upstream reading (oracle glitch,
scraping error, a genuinely manipulated pool) flows straight into vault
APY history and user-facing yield figures. This adds a guard that flags
implausible jumps before a snapshot is persisted, complementing the
oracle aggregation/failover work in #830.
- add apysnapshot.DetectAnomalousJump: compares a new snapshot's APY to
the protocol's most recent prior reading and flags moves of more than
AnomalyJumpMultiplier (3x) in either direction, skipping near-zero
baselines (< 0.5%) where large percentage swings are normal noise
- add Flagged/FlagReason fields to APYSnapshot, persisted via migration
081 (apy_snapshots.flagged, flag_reason)
- wire the guard into APYService.poll via a new flagIfAnomalous step
that looks up the most recent snapshot within a 48h lookback window
and flags (does not reject) the incoming snapshot, so a genuine
market dislocation doesn't starve history/oracle failover of data
* feat: Claude rate-limit handling, retrieval tests, defillama staleness guard, AI opt-out
- #928: apps/intelligence/app/services/prometheus.py's stream_chat caught
every Claude error identically (generic "trouble connecting" message).
Added a specific anthropic.APIStatusError handler that distinguishes
429 (rate-limited) / 529 (overloaded) with a clearer "receiving a lot
of requests, try again shortly" message, while other API status errors
and non-API exceptions keep the existing generic fallback. Neither
chat.py nor ws_chat.py ever surfaced a raw 500 for this — stream_chat
already caught everything — but the message didn't call out the
specific, actionable rate-limit/overload case.
- #930: added tests/test_retrieval_relevance.py. retrieval.py's
"relevance filtering" is intent-based section gating (route_query ->
which of GOALS/TRANSACTIONS/YIELD_LANDSCAPE/POSITIONS get fetched),
not numeric relevance scoring — tests confirm sections NOT matched by
the query's intents are never even fetched (not just absent from
output), plus the existing empty-result fallback behavior.
tests/test_retrieval.py (from #852) already covered routing and basic
empty-fallback; this fills the specific "sections excluded, not just
empty" gap #930 asks for.
- #931: apps/intelligence/app/services/defillama.py already had a TTL
cache; added the staleness guard the issue is actually about — every
successful fetch also writes a long-TTL (24h) "last known good" copy,
and a live-fetch failure after the short-TTL entry has expired now
serves that stale copy instead of an empty list, so a DefiLlama
outage degrades to slightly-stale yield data instead of no data.
- #935: apps/api/internal/service/goal_coaching_scheduler.go's weekly
AI goal-coaching job iterated every active goal and called the
intelligence service unconditionally — no opt-out check at all,
unlike nudge_engine_service.go's existing NudgesEnabled gate for
generic nudges. Added the same nudge.PreferenceChecker gate before
any intelligence-service call, and added an explicit
ai_insights_enabled field to CoachingRequest (both Go and the
intelligence service's Pydantic model) so the intelligence service
also refuses to generate content when told a user opted out —
enforcement independent of the caller, not just "trust the API
already checked." Defaults to enabled so on-demand (user-initiated)
coaching requests, which opt-out doesn't apply to, are unaffected.
Verification notes:
- Python (apps/intelligence): full suite run locally in a fresh uv venv
— 212 passed, including all new/changed tests.
- Go (apps/api): no Go toolchain was available in the environment this
was authored in, so main.go / goal_coaching_scheduler.go / model.go
and their test changes could not be compiled or run locally —
reviewed by hand for signature/interface consistency (nudgeHistoryRepo
already implements nudge.PreferenceChecker; the *T-vs-T receiver on
the new recordingGoalCoachingClient test double satisfies the
GoalCoachingClient interface). Please confirm via CI or a local
`go build ./... && go test ./...` before merging.
Closes #928
Closes #930
Closes #931
Closes #935
* feat(api): typed GetOrCompute cache layer with single-flight and stale-while-revalidate (#827)
Adds a generic Redis-backed cache (internal/cache) sitting in front of any
compute function: in-process single-flight collapses concurrent same-key
misses to one compute regardless of Redis, a best-effort cross-process Redis
lock reduces duplicate work across instances, TTLs are jittered to avoid
synchronized expiry, and soft/hard TTLs enable serve-stale-while-revalidate
(a stale value is returned immediately while a background refresh runs).
Namespace-scoped Invalidate targets a single key; a nil Redis client
degrades the cache to in-process-only behavior rather than failing.
closes #827
* feat(api): horizontally-scalable WebSocket layer with Redis pub/sub fan-out (#828)
Extends the WebSocket hub so events reach connected clients regardless of
which API instance holds their socket or produced the event: each instance
publishes broadcast events to Redis pub/sub and re-injects events received
from other instances into its own local delivery path, skipping its own
echoed publishes via an origin-instance tag. Per-topic Redis subscriptions
are reference-counted against local subscriber counts so an instance only
subscribes to channels its own clients actually need, and are released on
the last local unsubscribe or on graceful shutdown.
Adds Redis-backed presence tracking with a heartbeat-refreshed TTL so a
crashed instance's presence entries self-expire rather than lingering.
Adds per-IP connection limits (429 on exceeding the configured cap) and
keeps the existing slow-client backpressure (disconnect on a full send
buffer) intact. All of this degrades to the pre-existing single-instance
in-process-only behavior when no Redis client is configured (nil-safe
throughout, matching the codebase's existing dual-mode convention for
middleware.NewLimiter).
Tests include a real two-Redis-sharing two-Hub cross-instance delivery test,
an own-event-not-double-delivered test, a reconnect-moves-subscriptions
test, a cross-instance presence test, a slow-client disconnect test, and a
per-IP limit rejection test — all passing against a real Redis instance.
closes #828
* feat(api): multi-channel notification service with categories, preferences, dedup and delivery tracking (#829)
Adds a suppressibility Category (safety/transactional/promotional) per
EventType: safety notifications always bypass preference and rate-limit
checks (a breaker trip must never be silently opted out of), promotional
fully honors opt-out, transactional sits in between. Preferences can now be
resolved per-category via an optional CategoryPreferenceStore seam (a
Postgres-backed GetForCategory/SetCategoryOverride is added to
NotificationRepository, storing overrides in a new category_overrides JSONB
column added by migration 069) while stores that only implement the
existing flat PreferenceStore keep working unchanged.
Adds dedup (in-memory and Redis-backed, SET-NX-EX) and per-user-per-category
rate limiting (reusing middleware.NewLimiter's existing dual-mode Redis/
in-process pattern) — both suppress a Send while still persisting the
notification with a recorded SuppressedReason, so a suppressed message is
auditable rather than silently dropped. A suppressed or delivered
notification's outcome is tracked per channel (Delivered/Error/IsFallback)
via a new optional DeliveryOutcomeRecorder seam, with a Push/Email failure
falling back to WebSocket delivery (deduped against a WebSocket delivery
already in that event's normal channel matrix). Failed Email/Push
deliveries enqueue a durable retry job through the existing job queue
(jobqueue.Client); the job handler redelivers via the specific channel that
failed. Dispatcher.Stats() exposes per-category attempted/delivered/failed/
suppressed counts for a metrics endpoint.
Also fixes the stale "TODO: Fix interface implementation" in main.go that
had left NewWebSocketChannel commented out — WebSocketHub's PushToUser
signature already matched Hub's once #828's hub.go changes landed, so
in-app websocket delivery through the notification dispatcher is now
actually wired, not just persisted-and-discarded.
Deliberately deferred (disclosed rather than silently skipped): HTTP
handler/frontend surface for editing category preferences (the existing
flat-preference handler/settings page is unchanged); migrating
goal_milestone_notifier's own notified_milestones dedup onto the new
generic Deduplicator (that table is a permanent, non-windowed,
correctness-sensitive dedup — migrating it is exactly the kind of
unreviewed, regression-risk change this PR intentionally avoids); real SMTP/
push provider integrations (the existing MailSender/PushSender seams and
their Noop/Recording implementations are unchanged).
Also fixes CI: the api job's Redis service only exported REDIS_URL, but
every Redis-backed test (existing internal/cache tests included) skips via
REDIS_ADDR per the established convention, so these tests have been
silently skipping in CI. Sets REDIS_ADDR alongside REDIS_URL.
closes #829
* fix(ci): export REDIS_ADDR alongside REDIS_URL so Redis-backed tests actually run
The api job's redis service was only exposed to tests via REDIS_URL, but
every Redis-backed test in this codebase (internal/cache, internal/
middleware's rate limiter, and this PR's internal/ws and internal/
notifications tests) skips via t.Skip when REDIS_ADDR specifically is
unset. That means these tests have been silently skipping in CI even
though a real Redis service was running right next to them the whole time.
* feat(api): oracle aggregation layer with multi-source consensus and failover (#830)
Adds Aggregate: queries every healthy registered source for a data type in
parallel (each bounded by a per-source timeout so one slow source cannot
stall the result), then reconciles responses via median-with-deviation-band
outlier rejection — a source more than MaxDeviationBPS from the pre-filter
median is discarded before the final median is recomputed from survivors,
so a single bad or manipulated print cannot move the consensus. Returns
Unavailable only when zero sources respond; a lone responding source below
MinAgreeingSources still produces a value (preserving the existing
priority-failover availability guarantee) but with reduced Confidence
rather than a blind pass-through, so a caller that needs full consensus can
gate on Confidence instead of merely on "a value came back".
Adds HealthTracker: per-source consecutive-failure count, last error, and
an exponential backoff window (5s base, doubling per consecutive failure,
capped at 5m) during which a source is skipped rather than queried on every
request; a success clears the failure history immediately.
Wires this into RateService.fetchXLM (internal/oracle/service.go),
replacing the previous "try providers in priority order, first success
wins" loop with real two-source (Horizon, DeFiLlama) consensus — the
existing XLM sanity-bounds check is kept as a second, independent defense
against every source being corrupted in the same direction, which
deviation-band rejection alone can't catch. ExchangeRate gains Confidence
and SourcesUsed fields (empty/zero for rates that don't go through the
aggregator, e.g. the fixed USDC/USD peg) and a MeetsConfidenceThreshold
helper for downstream consumers to gate on. All existing service_test.go
cases pass unchanged, including the priority-style single-surviving-source
assertions (SourceName() reports a lone source's own name, matching the
prior Source field behavior exactly, and only joins names when more than
one source genuinely agreed).
Deliberately deferred (disclosed rather than silently skipped): a second
independent source for TVL and for the DeFiLlama-sourced portions of the
APY pipeline (apy_service.go / apy_refresh.go) — those currently have only
one real external provider each in this codebase, and standing up a second
genuine external data provider integration is out of scope here; migrating
risk_service.go and the on-chain attestation signer (a separate contracts
repo) onto Confidence gating. One added cost worth flagging: because
Aggregate queries every healthy source in parallel rather than stopping at
the first success, DeFiLlama is now called on every XLM/USD refresh even
when Horizon succeeds, not only as a fallback.
Tests cover: agreeing sources produce the correct median consensus; a
wildly-off outlier is rejected without moving the consensus; all-but-one
source down yields a value with reduced (not zero, not full) confidence
rather than Unavailable; every source down is Unavailable; a slow source
times out without stalling the result; a source is skipped once unhealthy
and re-probed after its backoff window elapses; backoff grows with
consecutive failures; a success clears failure history; confidence decay
for staleness reaches exactly half at maxAge.
closes #830
* feat(intelligence): add per-user AI tone/style preference (#927)
* feat(intelligence): add explainability trace for AI-suggested actions (#925)
* feat(api): add soft-delete with recovery window for savings goals (#924)
* fix(api): prevent duplicate deadline reminders across timezones (#923)
* feat: contribution limits, admin goal templates, and calculator export
- api: add optional min/max per-contribution limits on savings goals,
validated at deposit time in DepositSplit (savingsgoal/model.go)
- api: let admins publish/edit/remove curated savings goal templates via
domain/admin, growing the catalog beyond the pre-built #778 defaults
without a redeploy
- dapp: add CSV/PDF export to the savings calculator, reusing the existing
lib/export utilities
Closes #918
Closes #919
Closes #922
* fix(api): restore soft-delete code erased by merge 9c30de1 (#1000)
* fix(api): restore soft-delete code erased by merge 9c30de1 (#994)
Merge 9c30de1 (via PR #985) resolved conflicts in the savings g…
Summary
Tests
go/gofmtexecutable on PATH; GitHub Actions will run the repository toolchainCloses #866
Summary by CodeRabbit
New Features
Documentation
Tests