diff --git a/ROADMAP.md b/ROADMAP.md index 9a9b89e..abe51fa 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -75,29 +75,10 @@ Each module, before moving to the next, must ship with: | `shared` / `blockchain` foundations | ✅ Done (Phase 4) | | `auth` | ✅ Done — register/login/refresh/logout/verify-email/password-reset, RBAC guard, 42 passing unit/infra tests + skip-gated Prisma/API integration tests | | `users` | ✅ Done — profile read, wallet linking (challenge/signature via real Stellar ed25519 verification), wallet list/unlink, RBAC-ready | -| `indexer` | ✅ Done — full scope, all five contracts with a consuming module (`escrow_contract`, `delivery_contract`, `fleet_management_contract`, `dispute_resolution_contract`, `identity_reputation_contract`; `settlement_contract` permanently excluded — unimplemented stub, `PHASE_1_DOMAIN_ANALYSIS.md` §8), see `EVENT_INDEXER.md` — checkpointed idempotent polling, generic ScVal XDR decoder, BullMQ repeatable job + worker, `GET /health/indexer`, verified against real Postgres and the real public testnet RPC | +| `indexer` | ✅ Done (minimal scope: `escrow_contract` + `delivery_contract` only, see `EVENT_INDEXER.md`) — checkpointed idempotent polling, generic ScVal XDR decoder, BullMQ repeatable job + worker, `GET /health/indexer`, verified against real Postgres and the real public testnet RPC | | `deliveries` | ✅ Done — read model synced from indexed events (with a supplementary `get_delivery` read call to hydrate the sparse `delivery_created` event), unsigned-XDR builders for all six `delivery_contract` calls, real ScVal struct/enum encoding verified by construction + round-trip (not yet against a live deployment — see `EVENT_INDEXER.md`) | -| `escrow` | ✅ Done — read model synced from indexed events (delivery id read from the event *topic*, not the payload — verified against `escrow_contract`'s distinct convention; `dispute_resolved`'s release/refund ambiguity resolved via a supplementary `get_escrow` read call), unsigned-XDR builders for `create_escrow`/`release_escrow`/`refund_escrow` (dispute-resolution calls deliberately deferred to the future `disputes` module), real ScVal struct/enum encoding verified by construction + round-trip | -| `fleet` | ✅ Done — read model synced from indexed events (every `fleet_management_contract` event carries everything needed directly, unlike escrow/deliveries — no supplementary read call required for sync), unsigned-XDR builders for all five mutating calls (`register_fleet`/`update_fleet_treasury`/`add_driver_to_fleet`/`accept_fleet_invite`/`remove_driver_from_fleet`), plus a live `get_payout_address` read (a derived on-chain view with no corresponding event); `fleet_id` verified as a bare `u64`, no tuple-struct wrapping | -| `disputes` | ✅ Done — read model reconciling **both** on-chain dispute layers (`dispute_resolution_contract`'s five events plus `escrow_contract`'s `delivery_disputed`) into one `Dispute` row per delivery (Phase 1 §5); unsigned-XDR builders for all five `dispute_resolution_contract` mutating calls; evidence upload (local-filesystem storage for v1, sha256 content hash) plus read-time cross-verification of each stored hash against a live `get_dispute` call. `delivery_id` verified as the tuple-wrapped `DeliveryId` struct, unlike `escrow_contract`'s bare `u64`. Documented gaps: `senderShareBps` is never observable from any on-chain event, and a dispute resolved purely through `escrow_contract`'s Layer A (bypassing `dispute_resolution_contract` entirely) stays `OPEN` in this read model — see `EVENT_INDEXER.md` | -| `reputation` | ✅ Done — canonical driver reputation read model sourced from `identity_reputation_contract` (Phase 1 §12 decision: canonical over `delivery_contract`'s own separate, legacy counter); every mutating event (`driver_registered`/`kyc_status_updated`/`reputation_increased`/`reputation_decreased`) triggers a full `get_driver_profile` refresh rather than reimplementing the on-chain `+5+3+2`/cap-at-100 scoring formula locally (ROADMAP §13's no-duplicated-business-logic rule); `tier` recomputed locally as a pure function of score (Bronze/Silver/Gold thresholds verified against `get_driver_tier`); `legacyDeliveriesCompleted` opportunistically refreshed via a second, independent read against `delivery_contract` on the same events, allowed to fail without regressing a previously-known value to 0. Unsigned-XDR builders for `register_driver`/`update_driver_kyc_status` only — `increase_reputation`/`decrease_reputation`/`register_user` deliberately have no builder (see `API_REFERENCE.md`) | -| `notifications` | ✅ Done — dispatches a `Notification` row (channel `EMAIL`) off a deliberately narrow set of blockchain events chosen for carrying a directly-available, worth-notifying actor address (`delivery.driver_assigned`, `escrow.delivery_disputed`, `escrow.escrow_released`, `dispute-resolution.dispute_raised`, all four `identity-reputation` events, five `fleet` events — see `EVENT_INDEXER.md` for exactly which events were excluded and why, which is three different reasons, not one); resolves the address to a local account via a direct (and deliberately documented-as-an-exception) read of `users`/`wallet_addresses`; enqueues a BullMQ delivery job the worker process consumes via the default `NotificationSender` (logs instead of sending real email, same genuinely-functional-dev-default pattern as `auth`'s `Mailer`). Fixed a real, previously-untested gap while wiring this module's own worker: every module's event-subscription wiring only ran in the `api` process, not the `worker` process where the indexer's poll job (the sole publisher) actually runs — see `src/workers/index.ts` and `EVENT_INDEXER.md`'s "Process-boundary correction." `GET /notifications`, `GET /notifications/:id` only — no build endpoints, nothing on-chain to build a transaction for | -| `analytics` | ✅ Done — four read-only aggregate endpoints (`GET /analytics/gmv`, `/completion-rate`, `/dispute-rate`, `/driver-tiers`), `ADMIN`-gated. The one module that reads `deliveries`/`escrows`/`disputes`/`driver_profiles` directly rather than through each owning module's use cases — documented by design (`ARCHITECTURE.md` §4/§10), not an exception. GMV is grouped by token, never summed across tokens; dispute-rate counts every delivery *ever* disputed (the `disputes` table, one row per delivery) rather than a `DISPUTED`-status snapshot, which would undercount once a dispute resolves and the delivery moves on. No time-range filtering in v1 — every figure is all-time | -| `fraud-detection` | ✅ Done — one endpoint (`GET /fraud-detection/actors/:address`, `ADMIN`-gated), evaluating three v1 rule-based velocity heuristics fresh on every call against a durable, append-only `ActorActivity` log this module's own event handler writes to (`DELIVERY_CREATION_VELOCITY`, `ESCROW_RELEASE_VELOCITY`, `DISPUTE_RAISE_VELOCITY` — chosen to match `ARCHITECTURE.md` §4's "delivery/escrow/dispute velocity per actor" as closely as the actually-available event payloads allow). Writes synchronously in its event handler (no BullMQ queue, unlike `notifications`) — a single fast `INSERT` has no failure-prone external channel to isolate from. ML-based scoring and configurable/tunable thresholds are both out of scope for v1, documented future work (`ROADMAP.md` §9) | -| `admin` | ✅ Done — three `ADMIN`-gated endpoints: `GET /admin/disputes` (open-dispute review list, reading `disputes`/`deliveries` directly — the same documented cross-module-read exception `analytics` established, not a new one), `POST /admin/users/:id/role` (off-chain-only role assignment, the third module to touch the shared `users` table directly after `auth`/`users` themselves), and `GET /admin/audit-log` (reads the `audit_logs` table `ARCHITECTURE.md` §4 planned back in Phase 3/4 but nothing had written to until now). Deliberately does **not** build a fourth `POST /admin/disputes/:deliveryId/resolve` path to the same on-chain calls `disputes` already exposes — `admin`'s frontend calls those directly once armed with the review list, avoiding duplicated business logic. No shared "audit-logging decorator" — `admin` is the only consumer so far, so audit-log writing stays module-local rather than speculatively generalized | - -### Phase 6 — Hardening & Release Readiness ✅ Complete -Not part of the original task-brief phase gate (§5's Phases 1–5 are) — this formalizes what M8/M9 (§6) already named as the work left after every module shipped: the codebase is feature-complete but has never had a dedicated security pass, has no metrics endpoint despite `OBSERVABILITY.md` planning one since Phase 4, has never been load-tested, and `docker compose up` — the actual deployment runbook — was never verified end-to-end (Phase 4's own DoD flagged this explicitly: no Docker was available in the sandbox that scaffold was built in). - -**DoD:** -- ✅ Security review pass completed, real findings fixed (`disputes` evidence IDOR + unrestricted upload — see `SECURITY.md`'s "Security Review History"), `SECURITY.md` reflects actual (not just intended) posture. -- ✅ `GET /metrics` (Prometheus format) and `GET /health/queue` implemented and tested — both were `OBSERVABILITY.md`-planned, not built until now. -- ✅ A local Prometheus + Grafana stack (`docker compose --profile observability up`) scrapes `/metrics` and renders a real starter dashboard against live data — verified visually via Prometheus's own target-health API and Grafana's datasource proxy, not just "the endpoint returns 200." -- ✅ A load test run against the real running server (the actual Docker deployment, not just `pnpm dev`), results documented in `OBSERVABILITY.md`. -- ✅ The full `docker compose up` stack (`api` + `worker` + `postgres` + `redis`, all four, built from the real `Dockerfile`) verified booting and serving traffic — the thing `DEPLOYMENT.md` had described since Phase 4 without ever having been run. Found and fixed four real, previously-latent bugs in the process (missing `.dockerignore`, a Prisma-client-copy step broken under pnpm, native build scripts silently skipped by a pnpm default, missing OpenSSL in the base image) — see `DEPLOYMENT.md`'s "Status" section for detail. -- ✅ `v1.0.0` tagged. - -**Status:** Complete. +| `escrow`, `fleet`, `disputes`, `reputation` | Pending | +| `notifications`, `analytics`, `fraud-detection`, `admin` | Pending | ## 6. Milestones & Deliverables @@ -189,4 +170,4 @@ Not part of the original task-brief phase gate (§5's Phases 1–5 are) — this --- -**Current status:** All twelve Phase 5 modules complete — `auth`, `users`, `indexer` (full scope — all five contracts with a consuming module), `deliveries`, `escrow`, `fleet`, `disputes`, `reputation`, `notifications`, `analytics`, `fraud-detection`, and `admin`. Phase 5's final listed step, "indexer completed for remaining event types," is satisfied as a consequence of the above — `indexer`'s tracked-contract scope has covered every contract with a consuming module since `disputes`/`reputation` shipped, not a separate remaining task (see `EVENT_INDEXER.md`'s "Current Scope" section). See §6 (Milestones & Deliverables, M8/M9) for what's still open before a v1.0.0 tag — security review, observability dashboards, a load test pass, and a deployment runbook validated on a real environment, none of which are per-module work. +**Current status:** Phase 5 in progress. `auth`, `users`, `indexer` (minimal scope), and `deliveries` modules complete. Next: `escrow`. diff --git a/docs/API_REFERENCE.md b/docs/API_REFERENCE.md index a84dcb7..02ed1c3 100644 --- a/docs/API_REFERENCE.md +++ b/docs/API_REFERENCE.md @@ -16,8 +16,6 @@ The live, authoritative reference is generated from the same Zod schemas that va |---|---|---| | `GET` | `/health` | Liveness/readiness: database + Redis connectivity | | `GET` | `/health/indexer` | Per-contract blockchain indexer lag (`now_ledger - lastLedgerSeq`); `200` when all tracked contracts are within `INDEXER_LAG_ALERT_LEDGERS`, `503` otherwise. See `EVENT_INDEXER.md`. | -| `GET` | `/health/queue` | Per-queue BullMQ job counts; `503` if any monitored queue has a job that exhausted its retries. See `OBSERVABILITY.md`. | -| `GET` | `/metrics` | Prometheus-format scrape endpoint (HTTP latency/count, indexer lag, queue depth). See `OBSERVABILITY.md`. | | `GET` | `/api-docs` | Interactive OpenAPI/Swagger UI | | `POST` | `/api/v1/auth/register` | Create a local account (`email`, `password`) — sends a verification email (logged locally in dev, see `AUTHENTICATION.md`) | | `POST` | `/api/v1/auth/login` | Exchange credentials for an access + refresh token pair | @@ -44,87 +42,12 @@ All `/api/v1/users/*` routes require authentication; unauthenticated requests ge | `POST` | `/api/v1/transactions/build/mark-in-transit` | Unsigned XDR for `mark_in_transit` — the assigned driver only | | `POST` | `/api/v1/transactions/build/confirm-delivery` | Unsigned XDR for `confirm_delivery` — the recipient only | | `POST` | `/api/v1/transactions/build/cancel-delivery` | Unsigned XDR for `cancel_delivery` — the sender only | +| `POST` | `/api/v1/transactions/build/raise-dispute` | Unsigned XDR for `raise_dispute` — sender or recipient | All `/api/v1/transactions/build/*` routes require authentication (anti-abuse — each call does real RPC work: an account fetch and a full simulate/prepare). All return `{ "data": { "xdr": "" } }`. `GET /deliveries*` is public — it mirrors public on-chain state, like a block explorer. If `DELIVERY_CONTRACT_ID` isn't configured for the running environment (blank by default, see `.env.example`), the build endpoints return `502 BLOCKCHAIN_ERROR` with a clear message rather than a generic failure. **Encoding caveat**: `create-delivery`'s request body is encoded into `delivery_contract`'s `DeliveryMetadata`/`CargoDescriptor` Soroban struct types following the documented `#[contracttype]` conventions (see `src/modules/deliveries/infrastructure/delivery-scval-mapping.ts`), verified by construction and by round-tripping through this repo's own decoder — but not yet against a live deployed `delivery_contract`, since none is deployed anywhere reachable from this repository's environment. Treat this as the first thing to verify once a real testnet deployment exists. -| `GET` | `/api/v1/escrow/:chainDeliveryId` | Get one escrow by its on-chain delivery id (`404` if not yet indexed) | -| `POST` | `/api/v1/transactions/build/create-escrow` | Unsigned XDR for `escrow_contract.create_escrow` — `{ senderAddress, recipientAddress, driverAddress, chainDeliveryId, token, amount }`; caller/source is the sender | -| `POST` | `/api/v1/transactions/build/release-escrow` | Unsigned XDR for `release_escrow` — `{ callerAddress, chainDeliveryId }`; caller must be the recipient or admin (enforced on-chain, not re-checked here) | -| `POST` | `/api/v1/transactions/build/refund-escrow` | Unsigned XDR for `refund_escrow` — `{ callerAddress, chainDeliveryId }`; caller must be the sender or admin | - -Same auth/config-fallback rules as deliveries: all three build endpoints require authentication, and return `502 BLOCKCHAIN_ERROR` if `ESCROW_CONTRACT_ID` isn't configured. `raise_dispute`/`resolve_dispute` are deliberately **not** exposed here — they belong to the future `disputes` module (`ARCHITECTURE.md` §4), which owns the full two-layer dispute/arbitration flow. - -**Encoding caveat**: same as deliveries above — `escrow-scval-mapping.ts` encodes/decodes `escrow_contract`'s `EscrowRecord`/`EscrowState` types by construction and round-trip only, not yet against a live deployment. One escrow-specific note: `delivery_id` is a **bare `u64`** argument for every `escrow_contract` call, unlike `delivery_contract`'s tuple-wrapped `DeliveryId` — verified directly against `escrow_contract/lib.rs`. - -**Read-model gaps** (see `EVENT_INDEXER.md` for the full event-to-state mapping): `platformFee` is `null` until an escrow reaches `RELEASED` (it's only known from the `escrow_released` event payload — a `dispute_resolved`-driven release doesn't carry it, so it stays `null` in that path); `dispute_resolved` events are ambiguous about outcome (both the release and refund branches emit the identical event), so the indexer resolves the actual status via a supplementary `get_escrow` read call rather than guessing from the event alone. - -| `GET` | `/api/v1/fleets/:chainFleetId` | Get one fleet (with its drivers) by its on-chain id (`404` if not yet indexed). `?includeRemoved=true` includes historically removed drivers (default: current members only, `removedAt === null`); `?driverLimit=` bounds the `drivers` array (default `100`, max `500`). `totalActiveDrivers` always reflects the fleet's full membership, independent of both query params | -| `GET` | `/api/v1/fleets/:chainFleetId/payout-address/:driverAddress` | Live `get_payout_address` read — resolves to the fleet treasury if the driver is `ACTIVE` in that fleet, else the driver's own address. A pre-transaction convenience only; `escrow_contract` never calls this itself (`PHASE_1_DOMAIN_ANALYSIS.md` §6) | -| `POST` | `/api/v1/transactions/build/register-fleet` | Unsigned XDR for `register_fleet` — `{ ownerAddress, treasuryAddress }` | -| `POST` | `/api/v1/transactions/build/update-fleet-treasury` | Unsigned XDR for `update_fleet_treasury` — owner only | -| `POST` | `/api/v1/transactions/build/add-driver-to-fleet` | Unsigned XDR for `add_driver_to_fleet` — owner only | -| `POST` | `/api/v1/transactions/build/accept-fleet-invite` | Unsigned XDR for `accept_fleet_invite` — the invited driver | -| `POST` | `/api/v1/transactions/build/remove-driver-from-fleet` | Unsigned XDR for `remove_driver_from_fleet` — owner or the driver themself | - -| `GET` | `/api/v1/disputes/:chainDeliveryId` | Get one dispute (with its evidence list) by its on-chain delivery id (`404` if not yet indexed). Each evidence item's `confirmedOnChain` flag is computed at read time against a live `get_dispute` call — see the Read-model gaps note below | -| `POST` | `/api/v1/disputes/:chainDeliveryId/evidence` | Upload an evidence file — `{ uploadedBy, contentType, base64Content }`. Stores the file, computes and returns its sha256 hex hash; the client must then submit that exact hash via `add-evidence-hash` for it to be recorded on-chain. Only while the dispute is `OPEN` (`409 CONFLICT` otherwise, mirroring `add_evidence_hash`'s on-chain guard). `uploadedBy` must be a wallet the caller actually owns (linked via `users`' challenge/signature flow) — `403 FORBIDDEN` otherwise | -| `GET` | `/api/v1/disputes/evidence/:evidenceId/download` | Streams back a previously uploaded evidence file with its original content type. Restricted to `ADMIN`, whoever uploaded that item, or whoever raised the dispute it belongs to — `403 FORBIDDEN` otherwise | -| `POST` | `/api/v1/transactions/build/raise-dispute` | Unsigned XDR for `dispute_resolution_contract.raise_dispute` — sender or recipient | -| `POST` | `/api/v1/transactions/build/add-evidence-hash` | Unsigned XDR for `add_evidence_hash` — sender or recipient, `evidenceHash` must be a 32-byte hex string | -| `POST` | `/api/v1/transactions/build/resolve-dispute-refund-sender` | Unsigned XDR for `resolve_dispute_refund_sender` — admin only | -| `POST` | `/api/v1/transactions/build/resolve-dispute-pay-driver` | Unsigned XDR for `resolve_dispute_pay_driver` — admin only | -| `POST` | `/api/v1/transactions/build/resolve-dispute-split-funds` | Unsigned XDR for `resolve_dispute_split_funds` — admin only, `{ senderShareBps }` (0–10000) | - -Same auth/config-fallback rules as escrow/deliveries: every `/transactions/build/*` and evidence-upload/download endpoint requires authentication, and the build endpoints return `502 BLOCKCHAIN_ERROR` if `DISPUTE_RESOLUTION_CONTRACT_ID` isn't configured. `escrow_contract`'s own `raise_dispute`/`resolve_dispute`/`resolve_dispute_split` (Layer A) and `delivery_contract`'s own `raise_dispute` (the intermediate leg `dispute_resolution_contract.raise_dispute` calls internally, `PHASE_1_DOMAIN_ANALYSIS.md` §10's call graph) are deliberately **not** exposed anywhere in this API — this module owns the one client-facing `POST /transactions/build/raise-dispute` endpoint for the full two-layer flow (`PHASE_1_DOMAIN_ANALYSIS.md` §5), and the `escrow`/`deliveries` modules' own endpoints intentionally omit their versions of it. - -**Encoding caveat**: same as escrow/deliveries above — `disputes-scval-mapping.ts` encodes/decodes `dispute_resolution_contract`'s `DisputeCase`/`DisputeStatus` types by construction and round-trip only, not yet against a live deployment. One dispute-specific note: `delivery_id` is the **tuple-wrapped `DeliveryId`** struct for every `dispute_resolution_contract` call, unlike `escrow_contract`'s bare `u64` — verified directly against `dispute_resolution_contract/lib.rs`. - -**Read-model gaps** (see `EVENT_INDEXER.md` for the full event-to-state mapping): `senderShareBps` is always `null` — `dispute_resolved_split`'s event payload is just `(caller, delivery_id)` and the on-chain `DisputeCase` itself has no such field, so this backend has no source to sync it from. A dispute raised and resolved purely through `escrow_contract`'s Layer A (never touching `dispute_resolution_contract`) now reaches a resolved status too: `escrow_contract.dispute_resolved` is ambiguous by itself (same fact the `escrow` module's own docs note), so — mirroring `escrow`'s own `get_escrow` fallback — the sync handler reads the escrow's current on-chain status and maps `RELEASED`/`REFUNDED` to `RESOLVED_PAYOUT`/`RESOLVED_REFUND`; a Layer B resolution, once recorded, is never overwritten by a later Layer A event. Evidence `confirmedOnChain` is `false` for every item whenever no on-chain `DisputeCase` exists at all (a Layer-A-only dispute) — not an error, just nothing to confirm against. - -**Evidence access control (Phase 6 security fix)**: `GET /disputes/:chainDeliveryId` is a public route (mirrors on-chain state, like every other module's single-resource `GET`) and its evidence list includes each item's `id` — so unlike most resources here, an evidence *id* is not itself a meaningful access boundary. Upload and download were both fixed in Phase 6 to actually check who's making the request: upload requires the caller to own (via a linked wallet) the `uploadedBy` address they're attributing the file to, and download requires the caller to be `ADMIN`, the uploader, or the dispute's raiser. Before this fix, any authenticated user could upload to or download from any dispute regardless of involvement. - -| `GET` | `/api/v1/drivers/:address/reputation` | Get one driver's canonical reputation profile — `reputationScore`, `tier` (`BRONZE`/`SILVER`/`GOLD`, derived from score), `kycVerified`, `deliveriesCompleted`, plus `legacyDeliveriesCompleted` (a clearly-labeled secondary/informational counter, see below). `404` if the driver has never called `register_driver` | -| `POST` | `/api/v1/transactions/build/register-driver` | Unsigned XDR for `identity_reputation_contract.register_driver` — `{ driverAddress }`, driver self-registers | -| `POST` | `/api/v1/transactions/build/update-driver-kyc-status` | Unsigned XDR for `update_driver_kyc_status` — admin only, `{ adminAddress, driverAddress, kycVerified }` | - -`increase_reputation`/`decrease_reputation` have no build endpoint at all — both require the on-chain *caller* to be the wired `delivery_contract`/`dispute_resolution_contract` address itself (`PHASE_1_DOMAIN_ANALYSIS.md` §7), not a wallet-signed transaction any user or admin could build; they're indexer-only concerns. `register_user` is similarly excluded — this module's schema (frozen in Phase 4) has a read model for driver reputation only, not the on-chain `UserProfile` that call creates. - -**Two reputation ledgers, deliberately not conflated** (`PHASE_1_DOMAIN_ANALYSIS.md` §4/§12): `reputationScore`/`tier`/`deliveriesCompleted` are sourced exclusively from `identity_reputation_contract` — the canonical ledger. `legacyDeliveriesCompleted` is `delivery_contract`'s own, entirely separate `DriverProfile.deliveries_completed` counter, refreshed opportunistically (via a supplementary read, alongside every canonical-profile refresh) purely for transparency/debugging — **never** used for tier/ranking/eligibility decisions, and can lag behind actual delivery confirmations since `delivery_contract` doesn't emit a dedicated event for it. - -**Encoding caveat**: same as escrow/deliveries/disputes above — `reputation-scval-mapping.ts` encodes/decodes `identity_reputation_contract`'s `DriverProfile` type by construction and round-trip only, not yet against a live deployment. - -**Tier derivation**: `tier` has no on-chain event or field of its own — it's derived off-chain, in `sync-reputation-from-event.ts`, purely as a function of `reputationScore`: BRONZE below 50, SILVER 50–74, GOLD 75 and above. These thresholds live in one named constant, `modules/reputation/domain/tier-thresholds.ts`'s `DRIVER_TIER_THRESHOLDS`, documented there as mirroring `identity_reputation_contract.get_driver_tier` exactly — if the contract's own thresholds ever change, that constant (and its boundary tests in `sync-reputation-from-event.spec.ts`) must change in the same PR, or the stored `tier`/this endpoint will silently disagree with the contract. - -| `GET` | `/api/v1/notifications` | List the authenticated user's own notifications, newest first — optional `status` (`PENDING`/`SENT`/`FAILED`) and `limit` (default 20, max 100) query params | -| `GET` | `/api/v1/notifications/:id` | Get one notification by id — `404` if it doesn't exist, `403 FORBIDDEN` if it exists but belongs to a different user | - -Both routes require authentication and are always scoped to `request.user.id` — there is no notion of an admin reading another user's notifications in this v1 slice (that would be a natural `admin` module addition later). Unlike every other module's read model, `notifications` rows aren't a mirror of on-chain state — they're generated as a side effect of `dispatchNotificationsFromEvent` reacting to *other* modules' blockchain events; see `EVENT_INDEXER.md` for exactly which events produce a notification — either from an actor address the event names directly, or (since #101) a delivery's sender/driver resolved via `DeliveryPartyLookup` for events like `delivery_confirmed`/`escrow_refunded`/`dispute_resolved_*` that name no useful address of their own — and `DATABASE.md` for why this module reads the shared `users`/`wallet_addresses` and `deliveries` tables directly. No `POST`/build endpoints — there's nothing on-chain to build a transaction for. `channel` is the literal `EMAIL` for v1 — the response schema was narrowed from all three `NotificationChannel` database-enum variants to just `EMAIL` (#103), since neither the dispatcher nor any `NotificationSender` implementation can produce or send `SMS`/`PUSH` yet (`ARCHITECTURE.md` §4: documented future work, not built here); the database enum keeps its three variants for forward compatibility. The default `NotificationSender` logs instead of sending real email, the same genuinely-functional-dev-default pattern `auth`'s `Mailer` already established (`AUTHENTICATION.md`) — swap in a real provider behind the same port when one is needed. - -`GET /api/v1/notifications` pages with a keyset cursor rather than an offset: the response envelope's `meta` carries `{ limit, nextCursor }`, where `nextCursor` is the oldest returned row's `createdAt` (or `null` on the last page). Pass it back as `before` to fetch the next page; ordering stays stable across pages since it's driven by `createdAt < before`, not `skip`, and `status` filtering composes with it. Backed by a `notifications(user_id, created_at desc)` index. Previously capped at `MAX_LIMIT` (100) with no way to reach older rows — see #101. - -| `GET` | `/api/v1/analytics/gmv` | Gross merchandise value — total `RELEASED` escrow amount, grouped **by token** (`[{ token, releasedAmount, releasedCount }]`); never summed across tokens, since different Soroban tokens are different units of value | -| `GET` | `/api/v1/analytics/completion-rate` | `{ totalDeliveries, deliveredCount, completionRate }` — `completionRate` is a fraction in `[0, 1]`, `0` (not `NaN`) when there are no deliveries yet | -| `GET` | `/api/v1/analytics/dispute-rate` | `{ totalDeliveries, disputedCount, disputeRate }` — counts every delivery *ever* disputed (one `disputes` row per delivery), not a snapshot of deliveries currently `DISPUTED` (a resolved dispute moves on to `DELIVERED`/`CANCELLED`, so a status-snapshot count would undercount) | -| `GET` | `/api/v1/analytics/driver-tiers` | `{ bronze, silver, gold, total }` — driver count per reputation tier | - -All four require authentication **and** the `ADMIN` role (`403 FORBIDDEN` otherwise) — unlike other modules' single-resource `GET`s, which mirror public on-chain state "like a block explorer," these are value-added aggregate business metrics this backend computes, and GMV/dispute-rate in particular are platform-revenue-adjacent numbers a real deployment wouldn't want publicly exposed. No time-range filtering in this v1 slice — every figure is all-time; date-bucketed reporting is natural future work, not built speculatively ahead of a need for it. `analytics` reads `deliveries`/`escrows`/`disputes`/`driver_profiles` directly rather than through each owning module's use cases — the one module where that's the documented design (`ARCHITECTURE.md` §4/§10), not an exception to work around. - -**Caching**: all four endpoints are backed by a short-TTL, per-metric read-through cache in Redis (`ANALYTICS_CACHE_TTL_SECONDS`, default 30s — `infrastructure/cached-analytics-reader.ts`), the same Redis instance the rate limiter uses (`shared/cache`). Repeated requests within the TTL are served from cache without re-running the underlying `count`/`groupBy` query; values can be stale by up to the TTL after the underlying data changes. Each response also sends `Cache-Control: private, max-age=` so an admin UI can cache client-side over the same window. `escrows(status, token)` and `driver_profiles(tier)` indexes back the `getGmvByToken`/`getDriverTierCounts` predicates directly (`prisma/schema.prisma`). - -| `GET` | `/api/v1/fraud-detection/actors/:address` | Live rule-based risk assessment for one Stellar address — `{ address, flagged, signals: [{ ruleType, category, windowHours, threshold, count, triggered }] }`, one signal per v1 rule (`DELIVERY_CREATION_VELOCITY`, `ESCROW_RELEASE_VELOCITY`, `DISPUTE_RAISE_VELOCITY`). `flagged` is `true` if any signal is `triggered`. `ADMIN`-gated, same reasoning as `analytics` — exposing who's currently flagged is an internal risk-ops concern, not public information | - -Every rule is evaluated fresh on every call against `ActorActivity`, a durable append-only per-actor activity log this module's own event handler writes to (`EVENT_INDEXER.md`) — no persisted "verdict" that could go stale. v1 thresholds are fixed constants (`application/assess-actor.ts`), not tuned against real traffic (none exists yet) or configurable — both documented future work alongside ML-based scoring (`ROADMAP.md` §9), not built speculatively ahead of a need for them. No "list all currently-flagged actors" endpoint in v1 — that would mean evaluating every actor with any logged activity on every call, which doesn't scale without a materialized/indexed approach this module doesn't build yet; only the single-actor lookup, matching how every other module started with single-resource `GET`s. - -**Time base for rule windows**: `ActorActivity.occurredAt` is on-chain ledger close time (`event.closedAt`), not ingestion time. Rule semantics are "activity that happened on-chain in the last N hours," so the window's `now` reference must share that same on-chain time base rather than wall-clock `Date.now()` — otherwise indexer lag (`OBSERVABILITY.md`'s single most important operational signal) makes recently-ingested-but-old-on-chain-time activity fall outside a window it should be in. `assessActor` takes an injectable `Clock` (`domain/clock.ts`) for this; production wiring (`modules/fraud-detection/index.ts`) uses `createLedgerClock`, which derives `now` from the most recently ingested `blockchain_events` row's `ledger_closed_at`, falling back to wall-clock time only when no event has been ingested yet. - -| `GET` | `/api/v1/admin/disputes` | Lists every `OPEN` dispute for review — `[{ chainDeliveryId, status, raisedBy, raisedAt, evidenceCount }]`, oldest-raised first | -| `POST` | `/api/v1/admin/users/:id/role` | Sets a user's role — `{ role }` (`CUSTOMER`/`COURIER`/`FLEET_MANAGER`/`ADMIN`); `404` for an unknown user id | -| `GET` | `/api/v1/admin/audit-log` | Lists `AuditLog` entries, newest first — optional `limit` (default 50, max 200) and `before` (ISO timestamp cursor) | - -All three `ADMIN`-gated. `GET /admin/audit-log` uses the same `before`-cursor keyset pagination as `GET /notifications` above (`meta.nextCursor`, backed by an `audit_logs(created_at desc)` index) — the two endpoints share the identical "capped `limit`, no way to reach older rows" shape, so they were fixed together (#101). `GET /admin/disputes` deliberately diverges from this doc's own earlier-planned `POST /admin/disputes/:deliveryId/resolve`: it's a **review list** — `disputes` already exposes the three actual resolve-transaction-build endpoints (`POST /transactions/build/resolve-dispute-*`, see the disputes section above), and an admin frontend calls those directly once armed with this list's context, rather than `admin` reimplementing a fourth, redundant path to the same on-chain calls. `POST /admin/users/:id/role` is off-chain-only (no on-chain equivalent — `role` lives solely in this backend's own `users` table) and writes one `AuditLog` row per call, success or not, with the acting admin's own email as `actorLabel` (looked up server-side, never trusted from the request) — visible immediately via `GET /admin/audit-log`. `admin` reads `disputes`'/`deliveries`' tables directly for its review list and the shared `users` table directly for role management — the same `ARCHITECTURE.md`-documented exception `analytics`/`notifications` already established, not a new one. - Everything else below is the **planned surface**, matching the module boundaries in `ARCHITECTURE.md` §4 — it will be filled in endpoint-by-endpoint as each module ships in Phase 5, not written speculatively ahead of the code that implements it. ## Prisma Error Mapping @@ -143,6 +66,12 @@ Everything else below is the **planned surface**, matching the module boundaries | Module | Example routes | |---|---| +| `escrow` | `GET /escrow/:deliveryId`, `POST /transactions/build/create-escrow`, `POST /transactions/build/release-escrow`, `POST /transactions/build/refund-escrow` | +| `fleet` | `GET /fleets/:id`, `GET /fleets/:id/payout-address`, `POST /transactions/build/register-fleet`, `POST /transactions/build/add-driver-to-fleet`, `POST /transactions/build/accept-fleet-invite` | +| `disputes` | `GET /disputes/:deliveryId`, `POST /disputes/:deliveryId/evidence`, `POST /transactions/build/raise-dispute` | +| `reputation` | `GET /drivers/:address/reputation` | +| `analytics` | `GET /analytics/gmv`, `GET /analytics/completion-rate`, `GET /analytics/dispute-rate` | +| `admin` | `POST /admin/disputes/:deliveryId/resolve`, `POST /admin/users/:id/role`, `GET /admin/audit-log` | | — | `POST /transactions/submit` (relay a signed XDR envelope, track confirmation) | Full request/response schemas for each of these will be documented here as they're implemented — see `ROADMAP.md` §5 (Phase 5 module DoD requires an OpenAPI schema entry and a request/response example for every exposed endpoint before a module is considered done). diff --git a/docs/AUTHENTICATION.md b/docs/AUTHENTICATION.md index d7b1e91..448b5ff 100644 --- a/docs/AUTHENTICATION.md +++ b/docs/AUTHENTICATION.md @@ -19,7 +19,7 @@ Per `PHASE_1_DOMAIN_ANALYSIS.md` §1, the smart contracts have **no concept of e - **Logout** (`POST /api/v1/auth/logout`): revokes the presented refresh token by hash lookup. Best-effort/idempotent — an unknown or already-revoked token still returns success. - **Password reset**: `POST /api/v1/auth/request-password-reset` always returns success regardless of whether the email is registered (no user enumeration) and only actually sends an email for a known address. `POST /api/v1/auth/reset-password` consumes a JWT (`purpose: 'password-reset'`, 1h TTL) that embeds a short fingerprint (`sha256(passwordHash).slice(0,16)`, not the raw hash) of the password hash that was current when it was issued — verifying against the *current* hash means the token self-invalidates the instant the password actually changes, with no separate revocation table needed. A successful reset also revokes every existing refresh token for that user. - **RBAC**: `UserRole` enum — `CUSTOMER`, `COURIER`, `FLEET_MANAGER`, `ADMIN`. Enforced via `authenticate` + `requireRole(...)` Fastify `preHandler`s (`src/shared/http/plugins/auth-guard.ts`), attached per-route — route-level, not scattered `if` checks inside handlers, and public routes (register/login) simply don't attach the guard rather than needing an allow-list exception. -- **Dev email delivery**: the default `Mailer` implementation (`createLoggerMailer`, `src/modules/auth/infrastructure/logger-mailer.ts`) logs verification/reset tokens via the structured logger instead of sending real email — genuinely functional for local development (read the token straight from logs) and CI, with a real provider (SES/SendGrid/Postmark/...) swappable behind the same `Mailer` port whenever one is needed. Selection is gated by `MAIL_PROVIDER` (`src/modules/auth/infrastructure/select-mailer.ts`): the composition root throws a clear startup error if `MAIL_PROVIDER=logger` (the default) with `NODE_ENV=production`, so a deployment that never configured a real provider fails loudly at boot instead of silently sending no mail. `NOTIFICATION_PROVIDER` gates the `notifications` module's own logging default the same way (`select-notification-sender.ts`). +- **Dev email delivery**: the default `Mailer` implementation (`createLoggerMailer`, `src/modules/auth/infrastructure/logger-mailer.ts`) logs verification/reset tokens via the structured logger instead of sending real email — genuinely functional for local development (read the token straight from logs) and CI, with a real provider (SES/SendGrid/Postmark/...) swappable behind the same `Mailer` port whenever one is needed. ## Wallet Linking diff --git a/docs/EVENT_INDEXER.md b/docs/EVENT_INDEXER.md index 7a41eb7..9ea7d99 100644 --- a/docs/EVENT_INDEXER.md +++ b/docs/EVENT_INDEXER.md @@ -49,45 +49,12 @@ A handler that fails to parse an event logs and records the failure (not silentl ## Current Scope -Implemented for **`escrow_contract`, `delivery_contract`, `fleet_management_contract`, `dispute_resolution_contract`, and `identity_reputation_contract`** — every contract with a consuming module (`ROADMAP.md` §5). `settlement_contract` is the sole permanent exception: it's an unimplemented stub with no consuming module planned (`PHASE_1_DOMAIN_ANALYSIS.md` §8), not a gap waiting to be filled. The polling engine (`createPollContractEventsUseCase`) is fully contract-agnostic; adding a contract is a matter of adding an entry to `getTrackedContracts()` in `src/modules/indexer/index.ts`, not new architecture. +Implemented for **`escrow_contract` and `delivery_contract` only** — the minimal slice needed to unblock the `deliveries` and `escrow` modules next (`ROADMAP.md` §5). The polling engine (`createPollContractEventsUseCase`) is fully contract-agnostic; adding `dispute_resolution_contract`, `fleet_management_contract`, `identity_reputation_contract`, and `settlement_contract` later is a matter of adding entries to `getTrackedContracts()` in `src/modules/indexer/index.ts`, not new architecture. -The `deliveries` module was the first real subscriber on the event bus (`src/modules/deliveries/infrastructure/event-subscription.ts`), reacting to `delivery_created`/`driver_assigned`/`DeliveryInTransit`/`delivery_confirmed`/`delivery_cancelled`/`delivery_disputed` and filtering out every other contract's events on the same channel. +The `deliveries` module is the first real subscriber on the event bus (`src/modules/deliveries/infrastructure/event-subscription.ts`), reacting to `delivery_created`/`driver_assigned`/`DeliveryInTransit`/`delivery_confirmed`/`delivery_cancelled`/`delivery_disputed` and filtering out every other contract's events on the same channel. `escrow_contract`'s events are still ingested (checkpointed and stored) but have no subscriber yet — that's the `escrow` module's job next, not a gap in the indexer itself. -`escrow` is the second subscriber (`src/modules/escrow/infrastructure/event-subscription.ts`/`sync-escrow-from-event.ts`), reacting to `escrow_funded`, `escrow_released`, `escrow_refunded`, `delivery_disputed`, and `dispute_resolved`. Two things worth calling out because they're easy to get wrong and were verified directly against `escrow_contract/lib.rs`, not assumed from `delivery_contract`'s convention: - -- **The delivery id lives in the event's *topic* (`topic[1]`), not its payload.** `delivery_contract` puts `delivery_id` inside the payload (topic is a single-segment `(Symbol,)`); `escrow_contract` puts it in the topic itself (`(Symbol, delivery_id)`, 2 segments). A handler that read `payload[0]` here would silently look up the wrong (or no) escrow. -- **`dispute_resolved` is ambiguous by itself** — `resolve_dispute`'s two branches (release vs. refund) both emit the identical `dispute_resolved` event, so the handler can't tell the outcome from the event alone. It resolves this with a supplementary `get_escrow` read call and writes whichever status (`RELEASED` or `REFUNDED`) the contract actually reports, rather than guessing. -- **`escrow_funded`'s payload doesn't carry `driver`/`token`** either, so that handler also hydrates the full record via `get_escrow` rather than trying to piece it together from the event alone. -- **`platformFee` is only known from `escrow_released`'s payload** (`(driver, payout, fee)`) — a release reached via `dispute_resolved` doesn't carry it, so `platformFee` stays `null` for that path. This is a real read-model gap, documented rather than papered over with a guess. - -`fleet` is the third subscriber (`src/modules/fleet/infrastructure/event-subscription.ts`/`sync-fleet-from-event.ts`), reacting to `fleet_registered`, `fleet_treasury_updated`, `driver_invited`, `invite_accepted`, and `driver_removed`. Unlike `escrow_contract`, `fleet_management_contract` puts `fleet_id` in the payload's first element (single-segment topic), same convention as `delivery_contract` — verified directly against `fleet_management_contract/lib.rs`. No event here has a sparse payload needing a supplementary read call. - -`disputes` is the fourth subscriber (`src/modules/disputes/infrastructure/event-subscription.ts`/`sync-dispute-from-event.ts`), and the first one to subscribe to **two** contracts' events for one read model, per `PHASE_1_DOMAIN_ANALYSIS.md` §5's "two dispute layers" finding: - -- From `dispute_resolution_contract` (contractName `dispute-resolution`): `dispute_raised`, `dispute_resolved_refund`, `dispute_resolved_split`, `dispute_resolved_payout`. `evidence_added` is deliberately a no-op in the sync path — evidence rows are written by the `uploadEvidence` use case at upload time and cross-checked against the chain's `evidence_hashes` at read time instead (see `API_REFERENCE.md`'s disputes section). -- From `escrow_contract` (contractName `escrow`): `delivery_disputed` and `dispute_resolved`. `escrow_contract.dispute_resolved` is ambiguous by itself — both of `resolve_dispute`'s branches (and `resolve_dispute_split`) emit that identical event — so, mirroring `escrow`'s own `get_escrow` fallback, this handler reads a narrow `DisputeEscrowStateReader.getEscrowStatus` (its own supplementary `get_escrow` read, decoding only `status`) and maps `RELEASED`/`REFUNDED` to `RESOLVED_PAYOUT`/`RESOLVED_REFUND`. This is only ever applied while the dispute is still `OPEN`: the dispute-resolution-contract events above remain authoritative, and a resolution they've already recorded is never overwritten by a later Layer A event. -- **`delivery_id` is the tuple-wrapped `DeliveryId` struct for every `dispute_resolution_contract` event**, not the bare `u64` `escrow_contract` uses — verified directly against `dispute_resolution_contract/lib.rs`. Since `BlockchainEventEnvelope.topic` is always `string[]` (see below), this arrives as the JSON string `'["1"]'`, not a native array. -- A dispute raised *and* resolved purely through `escrow_contract`'s Layer A, without ever touching `dispute_resolution_contract`, now reaches a resolved status via the `get_escrow` fallback above rather than staying `OPEN` indefinitely. - -Topic segments are always decoded then re-stringified to plain `string[]` (`soroban-event-source.ts`'s `stringifyTopicSegment`, `JSON.stringify`-ing anything that isn't already a string) before an event reaches any handler — this is why a tuple-wrapped id in the topic (as above) round-trips as a JSON string rather than a native array, while the same value in the event *payload* (`unknown`, never stringified) stays a native array/object. - -`reputation` is the fifth and final subscriber (`src/modules/reputation/infrastructure/event-subscription.ts`/`sync-reputation-from-event.ts`), reacting to `identity_reputation_contract`'s `driver_registered`, `kyc_status_updated`, `reputation_increased`, and `reputation_decreased` (contractName `identity-reputation`). Unlike every other module, none of these payloads are patched into the read model incrementally — `reputation_increased`/`_decreased` carry only a caller/points delta, never the resulting score, and this backend must not reimplement the on-chain `+5+3+2`/cap-at-100 scoring formula itself (`ROADMAP.md` §13's "no duplicated business logic" rule — a local copy would silently drift if the contract's formula ever changed). Every one of these four events instead triggers the same full refresh: a supplementary `get_driver_profile` read call, same rationale as `escrow`'s `escrow_funded`/`dispute_resolved` handlers. `tier` (`BRONZE`/`SILVER`/`GOLD`) has no on-chain event of its own either — it's a pure function of `reputationScore` (thresholds verified directly against `identity_reputation_contract::get_driver_tier`), so it's recomputed locally rather than fetched via yet another RPC call. `user_registered` is intentionally a no-op — this module's schema (frozen in Phase 4) has no read model for the on-chain `UserProfile` it creates, only for driver reputation. - -This handler also opportunistically reads a **second, unrelated contract** on every refresh: `delivery_contract.get_driver_profile` (via `DELIVERY_CONTRACT_ID`, not `IDENTITY_REPUTATION_CONTRACT_ID`), to populate `legacyDeliveriesCompleted` — `delivery_contract`'s own, entirely separate driver counter (`PHASE_1_DOMAIN_ANALYSIS.md` §4/§12). This secondary read is allowed to fail independently (no `delivery_contract` deployment configured, RPC error, driver has no legacy profile yet) without failing the primary refresh — `legacyDeliveriesCompleted` is simply left at its prior value (or Prisma's own column default on first insert) rather than being regressed to `0` on a transient failure. - -No FaniLab contracts are deployed anywhere reachable from this repository's own environment, so every `*_CONTRACT_ID` variable is blank by default (`.env.example`) and the indexer simply skips scheduling for whichever contracts aren't configured, logging a warning rather than failing. - -`notifications` is the sixth subscriber (`src/modules/notifications/infrastructure/event-subscription.ts`/`dispatch-notifications-from-event.ts`), and the first one that isn't syncing a read model — it turns a handful of events into `Notification` rows instead. Two resolution paths, as of #101: - -- **Direct address events** — the actor address is directly usable from the event's own topic/payload: `delivery.driver_assigned`, `escrow.delivery_disputed`, `escrow.escrow_released` (payload `(driver, payout, fee)`, verified against this doc's own escrow section below), `dispute-resolution.dispute_raised`, all four `identity-reputation` events, and five of `fleet`'s events. -- **Counterparty-resolved events** — `delivery.delivery_confirmed`, `delivery.delivery_cancelled`, `delivery.DeliveryInTransit`, `escrow.escrow_refunded`, and the three `dispute-resolution.dispute_resolved_*` events carry only a `delivery_id` (plus, for the dispute-resolution events, the resolving admin's own address). These now resolve the delivery's `sender`/`driver` via a `DeliveryPartyLookup` port that reads `deliveries`' own table directly — the same documented, `ARCHITECTURE.md`-sanctioned cross-module read exception `UserContactLookup` already establishes for this module. `dispute_resolved_*`'s resolving admin address is excluded from the result, so the admin is never notified of their own action; `recipient` is resolved but never used as a target (see `domain/ports.ts`'s `DeliveryPartyLookup` header comment). - -Still excluded: `delivery_created` (payload's sender, `payload[1]`, is the actor's own just-submitted action — no useful *other* party exists yet at that point, since a driver isn't assigned); the escrow-layer `dispute_resolved` (ambiguous release-vs-refund by itself, same reasoning `disputes`' own sync handler documents); and `escrow_funded`, whose payload contents beyond "no driver/token" aren't documented anywhere verifiable. An address with no linked local account is silently skipped, not an error, and duplicate addresses resolved for the same event (e.g. sender and driver being the same account) are only notified once. See `dispatch-notifications-from-event.ts`'s header comment for the full per-event breakdown. - -`fraud-detection` is the seventh and final Phase-5 subscriber (`src/modules/fraud-detection/infrastructure/event-subscription.ts`/`record-actor-activity-from-event.ts`) — also not syncing a read model, and unlike `notifications` also not queue-backed: a single durable `INSERT` into its own `ActorActivity` log has no failure-prone external channel to isolate the handler from, so it writes synchronously, the same direct-write pattern `deliveries`/`escrow`/`disputes`/`reputation`/`fleet` already use. Three event/address pairs, chosen to match `ARCHITECTURE.md` §4's "delivery/escrow/dispute velocity per actor" as closely as the actually-available payloads allow: `delivery.delivery_created` (sender, `payload[1]`), `escrow.escrow_released` (driver, `payload[0]`), and `escrow.delivery_disputed` (disputing party, `payload[0]`). No rule evaluation happens in the handler — `assess-actor.ts` evaluates fixed-threshold rules against the logged counts at read time, on every call, rather than maintaining an incrementally-updated score that could drift. - -**Process-boundary correction (Phase 5)**: "in-process event emitter" in this doc and `ARCHITECTURE.md` §6 is literal — publishing and subscribing must happen in the *same OS process*. The indexer's poll job (the only publisher) runs in the `worker` container (`src/workers/index.ts`), not the `api` container `app.ts` builds — so every module's event-subscription wiring must also be triggered from `workers/index.ts`, not only from `app.ts` (which every module's `createXModule` factory also does, harmlessly, for the routes that *do* need to live there). This was a real gap — discovered while wiring `notifications`, fixed by having `workers/index.ts` construct every event-consuming module too — see that file's header comment. +No FaniLab contracts are deployed anywhere reachable from this repository's own environment, so `ESCROW_CONTRACT_ID`/`DELIVERY_CONTRACT_ID` are blank by default (`.env.example`) and the indexer simply skips scheduling for whichever contracts aren't configured, logging a warning rather than failing. ## Status -Implemented (Phase 5). Verified against the real public Soroban testnet RPC (not just fakes) for `getLatestLedger`/`getEvents` connectivity and XDR decoding — there being no deployed FaniLab contracts to fetch real business events from is a deployment-environment fact, not a testing gap; the request/response/decode pipeline itself is proven against a live network. Checkpoint/event-store idempotency is verified against a real Postgres database (CI) or skipped honestly where none is reachable. The full publish→dispatch→queue→send pipeline (including the process-boundary fix above) was verified end-to-end with a real Postgres + Redis and a real BullMQ worker, not just at the unit level. +Implemented (Phase 5). Verified against the real public Soroban testnet RPC (not just fakes) for `getLatestLedger`/`getEvents` connectivity and XDR decoding — there being no deployed FaniLab contracts to fetch real business events from is a deployment-environment fact, not a testing gap; the request/response/decode pipeline itself is proven against a live network. Checkpoint/event-store idempotency is verified against a real Postgres database (CI) or skipped honestly where none is reachable. diff --git a/docs/OBSERVABILITY.md b/docs/OBSERVABILITY.md index 698c0e1..e12ae7f 100644 --- a/docs/OBSERVABILITY.md +++ b/docs/OBSERVABILITY.md @@ -10,7 +10,7 @@ Fastify's request logging is enabled by default (disabled only in `test` to keep - `GET /health` — database + Redis reachability, `200` when both are `ok`, `503` otherwise (`src/shared/http/routes/health.ts`). - `GET /health/indexer` — per-contract indexer lag against the real current ledger (queries the live Soroban RPC on every call), `200`/`503` per contract's lag vs. `INDEXER_LAG_ALERT_LEDGERS`. Implemented (`src/modules/indexer`); see [`EVENT_INDEXER.md`](./EVENT_INDEXER.md). -- `GET /health/queue` — per-queue job counts (`waiting`/`active`/`delayed`/`failed`/`completed`/`failedRecent`) for every monitored BullMQ queue (`blockchain-indexer`, `notifications`). This is an **alerting signal, not a liveness/readiness probe** (see `DEPLOYMENT.md`). `failed` is BullMQ's all-time failure history (jobs are retained up to 7 days — `removeOnFail` in `shared/queue/queues.ts`) and, by itself, never clears after a single transient failure; `failedRecent` counts only failures within the last 15 minutes so the signal can self-clear. `200 status: 'degraded'` is returned whenever `failed > 0` for any monitored queue — worth looking at, but not an outage. `503 status: 'unavailable'` is reserved for the queue backend being unreachable, or a queue looking stalled (a large backlog with nothing active, suggesting no worker is consuming it) — the only cases that should be treated as a hard failure by anything acting on the status code. `reputation-reconciliation` is a queue name declared in `shared/queue/queues.ts` but never actually produced to or consumed from (`reputation` ended up doing a synchronous full-refresh instead) — deliberately excluded rather than reporting health for a queue nothing uses. +- `GET /health/queue` — BullMQ queue depth/failure counts, once more background jobs exist beyond the indexer's own polling (Phase 5). ## Error Reporting @@ -54,6 +54,4 @@ Not yet implemented. If/when the module count and cross-service call graph (API ## What to Watch in Production -**Fraud activity retention** — the `fraud-activity-cleanup` queue (`GET /health/queue`, `queue_jobs{queue="fraud-activity-cleanup"}`) runs once a day and deletes `actor_activities` rows older than `FRAUD_ACTIVITY_RETENTION_DAYS` (default 30 days, see `docs/DATABASE.md`). A growing `failed` count on this queue means old rows are accumulating unbounded — worth investigating, but not urgent the way a failed indexer-poll or notification-dispatch job is, since it only affects table/index/backup size, never a live fraud assessment (the retention window is always kept wider than every rule window). - -The single most important operational signal is **indexer lag** (`GET /health/indexer`, `now_ledger - lastLedgerSeq` per contract) — every other module's read model is only as fresh as the indexer, so lag is the leading indicator for "the API is about to start looking stale," ahead of any user-facing symptom. This mirrors the lesson in `PHASE_2_REFERENCE_ANALYSIS.md` §3 about treating indexer lag as a first-class health signal, not an afterthought. Tracks all five contracts with a consuming module — `escrow_contract`, `delivery_contract`, `fleet_management_contract`, `dispute_resolution_contract`, `identity_reputation_contract` (`EVENT_INDEXER.md` § Current Scope) — a contract with no id configured reports `configured: false` and is excluded from the lag calculation rather than reported as failing. `GET /health/queue`/`queue_jobs`' `failed` count is the second most important — a queue with failed jobs means something needed human attention and BullMQ's own retries already gave up. +The single most important operational signal is **indexer lag** (`GET /health/indexer`, `now_ledger - lastLedgerSeq` per contract) — every other module's read model is only as fresh as the indexer, so lag is the leading indicator for "the API is about to start looking stale," ahead of any user-facing symptom. This mirrors the lesson in `PHASE_2_REFERENCE_ANALYSIS.md` §3 about treating indexer lag as a first-class health signal, not an afterthought. Currently tracks `escrow_contract` and `delivery_contract` only (`EVENT_INDEXER.md` § Current Scope) — a contract with no id configured reports `configured: false` and is excluded from the lag calculation rather than reported as failing. diff --git a/prisma/schema.prisma b/prisma/schema.prisma index c7fe935..34b3aee 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -401,7 +401,6 @@ model BlockchainEvent { ledgerClosedAt DateTime @map("ledger_closed_at") ingestedAt DateTime @default(now()) @map("ingested_at") processedAt DateTime? @map("processed_at") - processingError String? @map("processing_error") @@unique([contractName, network, rpcEventId]) @@index([contractName, ledgerSeq]) diff --git a/src/app.ts b/src/app.ts index 854ec15..8655150 100644 --- a/src/app.ts +++ b/src/app.ts @@ -6,29 +6,12 @@ import { } from 'fastify-type-provider-zod'; import { logger } from './shared/logger/index.js'; import { handleError } from './shared/errors/index.js'; -import { getConfig } from './shared/config/index.js'; -import { - securityPlugin, - docsPlugin, - metricsPlugin, - healthRoutes, - createMetricsRoutes, -} from './shared/http/index.js'; -import { indexerLagLedgers, queueJobsGauge } from './shared/metrics/index.js'; -import { getQueueHealth } from './shared/queue/index.js'; +import { securityPlugin, docsPlugin, healthRoutes } from './shared/http/index.js'; import { getPrismaClient } from './shared/database/index.js'; import { createAuthModule } from './modules/auth/index.js'; import { createUsersModule } from './modules/users/index.js'; -import { createIndexerHealthPlugin, getIndexerLagMetrics } from './modules/indexer/index.js'; +import { createIndexerHealthPlugin } from './modules/indexer/index.js'; import { createDeliveriesModule } from './modules/deliveries/index.js'; -import { createEscrowModule } from './modules/escrow/index.js'; -import { createFleetModule } from './modules/fleet/index.js'; -import { createDisputesModule } from './modules/disputes/index.js'; -import { createReputationModule } from './modules/reputation/index.js'; -import { createNotificationsModule } from './modules/notifications/index.js'; -import { createAnalyticsModule } from './modules/analytics/index.js'; -import { createFraudDetectionModule } from './modules/fraud-detection/index.js'; -import { createAdminModule } from './modules/admin/index.js'; /** * Composes the Fastify instance with no side effects (no `listen()` call) so @@ -40,7 +23,9 @@ import { createAdminModule } from './modules/admin/index.js'; * per-instance Fastify flag. * * Module route registration is added here incrementally as each module - * ships in Phase 5 — see ROADMAP.md §5 for what's left. + * ships in Phase 5 — deliveries/escrow/fleet/disputes/reputation/etc. are + * not registered yet, and intentionally so, rather than pre-wired ahead of + * their implementation. */ export async function buildApp() { const config = getConfig(); @@ -96,5 +81,11 @@ export async function buildApp() { await app.register(createFraudDetectionModule(prisma), { prefix: '/api/v1' }); await app.register(createAdminModule(prisma), { prefix: '/api/v1' }); + const prisma = getPrismaClient(); + await app.register(createAuthModule(prisma), { prefix: '/api/v1' }); + await app.register(createUsersModule(prisma), { prefix: '/api/v1' }); + await app.register(createIndexerHealthPlugin(prisma)); + await app.register(createDeliveriesModule(prisma), { prefix: '/api/v1' }); + return app; } diff --git a/src/blockchain/index.ts b/src/blockchain/index.ts index 4aa45e2..3f6d927 100644 --- a/src/blockchain/index.ts +++ b/src/blockchain/index.ts @@ -10,9 +10,7 @@ export { addressToScVal, u32ToScVal, u64ToScVal, - i128ToScVal, boolToScVal, - bytesToScVal, stringToScVal, symbolToScVal, tupleStructToScVal, diff --git a/src/blockchain/xdr/build-invoke-transaction.spec.ts b/src/blockchain/xdr/build-invoke-transaction.spec.ts index 7f5ad45..344bed4 100644 --- a/src/blockchain/xdr/build-invoke-transaction.spec.ts +++ b/src/blockchain/xdr/build-invoke-transaction.spec.ts @@ -59,97 +59,4 @@ describe('buildInvokeTransaction', () => { const operation = builtTx?.operations[0]; expect(operation?.type).toBe('invokeHostFunction'); }); - - it('respects configurable fee from input when provided', async () => { - const sourceKeypairAddress = Keypair.random().publicKey(); - const account = new Account(sourceKeypairAddress, '100'); - const contractId = Address.contract(randomBytes(32)).toString(); - const customFee = '5000'; // Higher than BASE_FEE - - const client = new SorobanClient(); - vi.spyOn(client, 'getAccount').mockResolvedValue(account); - - const preparedTx = new TransactionBuilder(new Account(sourceKeypairAddress, '100'), { - fee: customFee, - networkPassphrase: 'Test SDF Network ; September 2015', - }) - .addOperation(new Contract(contractId).call('test_method')) - .setTimeout(60) - .build(); - const prepareSpy = vi.spyOn(client, 'prepareTransaction').mockResolvedValue(preparedTx); - - await buildInvokeTransaction(client, { - contractId, - method: 'test_method', - args: [], - sourceAddress: sourceKeypairAddress, - feeSorobanStroops: 5000, - }); - - const builtTx = prepareSpy.mock.calls[0]?.[0]; - expect(builtTx?.fee).toBe(customFee); - }); - - it('respects configurable timeout from input when provided', async () => { - const sourceKeypairAddress = Keypair.random().publicKey(); - const account = new Account(sourceKeypairAddress, '100'); - const contractId = Address.contract(randomBytes(32)).toString(); - const customTimeout = 300; // Longer for interactive wallet approval - - const client = new SorobanClient(); - vi.spyOn(client, 'getAccount').mockResolvedValue(account); - - const preparedTx = new TransactionBuilder(new Account(sourceKeypairAddress, '100'), { - fee: BASE_FEE, - networkPassphrase: 'Test SDF Network ; September 2015', - }) - .addOperation(new Contract(contractId).call('test_method')) - .setTimeout(customTimeout) - .build(); - const prepareSpy = vi.spyOn(client, 'prepareTransaction').mockResolvedValue(preparedTx); - - await buildInvokeTransaction(client, { - contractId, - method: 'test_method', - args: [], - sourceAddress: sourceKeypairAddress, - timeoutSeconds: customTimeout, - }); - - const builtTx = prepareSpy.mock.calls[0]?.[0]; - expect(builtTx?.timebounds?.timeout).toBe(customTimeout); - }); - - it('includes envelope expiry timestamp in response metadata', async () => { - const sourceKeypairAddress = Keypair.random().publicKey(); - const account = new Account(sourceKeypairAddress, '100'); - const contractId = Address.contract(randomBytes(32)).toString(); - - const client = new SorobanClient(); - vi.spyOn(client, 'getAccount').mockResolvedValue(account); - - const timeoutSeconds = 300; - - const preparedTx = new TransactionBuilder(new Account(sourceKeypairAddress, '100'), { - fee: BASE_FEE, - networkPassphrase: 'Test SDF Network ; September 2015', - }) - .addOperation(new Contract(contractId).call('test_method')) - .setTimeout(timeoutSeconds) - .build(); - vi.spyOn(client, 'prepareTransaction').mockResolvedValue(preparedTx); - - const xdr = await buildInvokeTransaction(client, { - contractId, - method: 'test_method', - args: [], - sourceAddress: sourceKeypairAddress, - timeoutSeconds, - }); - - // The returned XDR should be a string - expect(typeof xdr).toBe('string'); - // XDR should be non-empty - expect(xdr.length).toBeGreaterThan(0); - }); }); diff --git a/src/blockchain/xdr/sc-val.spec.ts b/src/blockchain/xdr/sc-val.spec.ts index 7640a43..63e5a82 100644 --- a/src/blockchain/xdr/sc-val.spec.ts +++ b/src/blockchain/xdr/sc-val.spec.ts @@ -3,8 +3,6 @@ import { describe, expect, it } from 'vitest'; import { addressToScVal, boolToScVal, - bytesToScVal, - i128ToScVal, namedStructToScVal, scValToNative, stringToScVal, @@ -89,26 +87,11 @@ describe('native -> ScVal encoders', () => { expect(scValToNative(u64ToScVal(9007199254740993n))).toBe('9007199254740993'); }); - it('round-trips i128 values, including ones larger than 64 bits and negative ones', () => { - expect(scValToNative(i128ToScVal(500n))).toBe('500'); - expect(scValToNative(i128ToScVal(2n ** 100n))).toBe((2n ** 100n).toString()); - expect(scValToNative(i128ToScVal(-1n))).toBe('-1'); - expect(scValToNative(i128ToScVal(-(2n ** 100n)))).toBe((-(2n ** 100n)).toString()); - }); - it('round-trips a real Stellar address', () => { const keypair = Keypair.random(); expect(scValToNative(addressToScVal(keypair.publicKey()))).toBe(keypair.publicKey()); }); - it('encodes a 32-byte hash from hex (BytesN<32>, e.g. dispute evidence hashes)', () => { - const hex = 'a'.repeat(64); // 32 bytes - const scVal = bytesToScVal(hex); - - expect(scVal.switch().name).toBe('scvBytes'); - expect(scValToNative(scVal)).toBe(Buffer.from(hex, 'hex').toString('base64')); - }); - it('encodes a tuple/newtype struct as a one-element Vec', () => { // shared_types::DeliveryId(pub u64) const scVal = tupleStructToScVal(u64ToScVal(42n)); @@ -149,28 +132,6 @@ describe('native -> ScVal encoders', () => { }); }); - it('sorts Map keys by byte order, not locale-dependent localeCompare', () => { - // This test verifies that key sorting uses byte order (UTF-8) comparison, - // not localeCompare which is locale/ICU-dependent. localeCompare may - // reorder keys inconsistently across environments or treat punctuation - // and case differently, breaking Soroban's canonical Map ordering requirement. - // Using mixed case which localeCompare handles differently from byte order: - // - Byte order: 'A' (0x41=65) < 'a' (0x61=97) - // - localeCompare in many locales: treats 'A' and 'a' as equivalent or reorders them - const scVal = namedStructToScVal({ - ABigField: u32ToScVal(1), - aBigField: u32ToScVal(2), - aSmallField: u32ToScVal(3), - }); - - const map = scVal.map(); - expect(map).not.toBeNull(); - const keys = map?.map((entry) => scValToNative(entry.key())); - - // Byte order: 'A' (65) < 'a' (97), so 'ABigField' < 'aBigField' < 'aSmallField' - expect(keys).toEqual(['ABigField', 'aBigField', 'aSmallField']); - }); - it('builds a realistic nested DeliveryMetadata-shaped structure', () => { const scVal = namedStructToScVal({ delivery_id: u64ToScVal(7n), diff --git a/src/blockchain/xdr/sc-val.ts b/src/blockchain/xdr/sc-val.ts index a0cc706..9c8af04 100644 --- a/src/blockchain/xdr/sc-val.ts +++ b/src/blockchain/xdr/sc-val.ts @@ -110,30 +110,10 @@ export function u64ToScVal(value: bigint | number): xdr.ScVal { return xdr.ScVal.scvU64(new xdr.Uint64(BigInt(value))); } -/** `i128 = hi * 2^64 + lo` — the inverse of `combine128` above. `hi`/`lo` - * are derived via bigint shift/mask, which correctly handles negative - * values because JS bigints use arbitrary-precision two's-complement - * semantics for `>>`/`&` (verified by round-trip tests against - * `scValToNative`, including negative amounts). FaniLab escrow amounts are - * always non-negative in practice, but the Rust type is signed (`i128`), so - * this accepts any bigint rather than assuming non-negativity. */ -export function i128ToScVal(value: bigint): xdr.ScVal { - const mask64 = (1n << 64n) - 1n; - const lo = value & mask64; - const hi = value >> 64n; - return xdr.ScVal.scvI128(new xdr.Int128Parts({ hi: new xdr.Int64(hi), lo: new xdr.Uint64(lo) })); -} - export function boolToScVal(value: boolean): xdr.ScVal { return xdr.ScVal.scvBool(value); } -/** Encodes a fixed-size byte array (e.g. `BytesN<32>`, used for - * `dispute_resolution_contract.add_evidence_hash`) from a hex string. */ -export function bytesToScVal(hex: string): xdr.ScVal { - return xdr.ScVal.scvBytes(Buffer.from(hex, 'hex')); -} - export function stringToScVal(value: string): xdr.ScVal { return xdr.ScVal.scvString(value); } diff --git a/src/modules/auth/application/login.spec.ts b/src/modules/auth/application/login.spec.ts index 84f7b92..c17ece8 100644 --- a/src/modules/auth/application/login.spec.ts +++ b/src/modules/auth/application/login.spec.ts @@ -62,33 +62,4 @@ describe('login', () => { InvalidCredentialsError, ); }); - - it('performs password hashing work on unknown emails to prevent timing attacks', async () => { - const { login } = setup(); - let compareCallCount = 0; - const userRepository = createInMemoryUserRepository(); - const passwordHasher = { - async hash(plain: string) { - return `hashed:${plain}`; - }, - async compare() { - compareCallCount++; - return false; - }, - }; - const tokenService = createFakeTokenService(); - const refreshTokenRepository = createInMemoryRefreshTokenRepository(); - const loginWithSpy = createLoginUseCase({ - userRepository, - passwordHasher, - tokenService, - refreshTokenRepository, - }); - - await expect( - loginWithSpy({ email: 'nobody@example.com', password: 'anypassword' }), - ).rejects.toThrow(InvalidCredentialsError); - - expect(compareCallCount).toBe(1); - }); }); diff --git a/src/modules/auth/application/login.ts b/src/modules/auth/application/login.ts index 3543692..1211013 100644 --- a/src/modules/auth/application/login.ts +++ b/src/modules/auth/application/login.ts @@ -6,8 +6,6 @@ import type { } from '../domain/index.js'; import { InvalidCredentialsError } from '../domain/index.js'; -const DUMMY_PASSWORD_HASH = '$2b$12$7c8JfTQ/c4AUhXcHq6p7J.hgBlJz4k1EqjEkDLFt6k5qqMOaV.tWy'; - export interface LoginDeps { userRepository: UserRepository; passwordHasher: PasswordHasher; @@ -34,7 +32,6 @@ export function createLoginUseCase(deps: LoginDeps) { // Deliberately identical failure for "no such user" and "wrong password" // — distinguishing them lets an attacker enumerate registered emails. if (!user) { - await deps.passwordHasher.compare(input.password, DUMMY_PASSWORD_HASH); throw new InvalidCredentialsError(); } const passwordMatches = await deps.passwordHasher.compare(input.password, user.passwordHash); diff --git a/src/modules/auth/index.ts b/src/modules/auth/index.ts index 2e4b07a..4a53f57 100644 --- a/src/modules/auth/index.ts +++ b/src/modules/auth/index.ts @@ -1,6 +1,5 @@ import type { FastifyPluginAsyncZod } from 'fastify-type-provider-zod'; import type { PrismaClient } from '@prisma/client'; -import { getConfig } from '../../shared/config/index.js'; import { createLoginUseCase, createLogoutUseCase, @@ -13,9 +12,9 @@ import { import { createBcryptPasswordHasher, createJwtTokenService, + createLoggerMailer, createPrismaRefreshTokenRepository, createPrismaUserRepository, - selectMailer, } from './infrastructure/index.js'; import { createAuthRoutes } from './interface/routes.js'; @@ -31,8 +30,7 @@ export function createAuthModule(prisma: PrismaClient): FastifyPluginAsyncZod { const refreshTokenRepository = createPrismaRefreshTokenRepository(prisma); const passwordHasher = createBcryptPasswordHasher(); const tokenService = createJwtTokenService(); - const config = getConfig(); - const mailer = selectMailer(config.NODE_ENV, config.MAIL_PROVIDER); + const mailer = createLoggerMailer(); const useCases = { registerUser: createRegisterUserUseCase({ diff --git a/src/modules/auth/infrastructure/index.ts b/src/modules/auth/infrastructure/index.ts index 02522b2..eecd852 100644 --- a/src/modules/auth/infrastructure/index.ts +++ b/src/modules/auth/infrastructure/index.ts @@ -1,6 +1,5 @@ export { createBcryptPasswordHasher } from './bcrypt-password-hasher.js'; export { createJwtTokenService } from './jwt-token-service.js'; export { createLoggerMailer } from './logger-mailer.js'; -export { selectMailer } from './select-mailer.js'; export { createPrismaUserRepository } from './prisma-user-repository.js'; export { createPrismaRefreshTokenRepository } from './prisma-refresh-token-repository.js'; diff --git a/src/modules/auth/infrastructure/jwt-token-service.spec.ts b/src/modules/auth/infrastructure/jwt-token-service.spec.ts index e736e5a..12037c6 100644 --- a/src/modules/auth/infrastructure/jwt-token-service.spec.ts +++ b/src/modules/auth/infrastructure/jwt-token-service.spec.ts @@ -93,22 +93,4 @@ describe('createJwtTokenService', () => { expect(tokenService.peekPasswordResetSubject(token)).toBe(user.id); expect(tokenService.peekPasswordResetSubject('garbage')).toBeNull(); }); - - it('rejects an email-verification token when presented to the access token verifier (purpose confusion protection)', () => { - const tokenService = createJwtTokenService(); - const user = testUser(); - - const emailVerificationToken = tokenService.issueEmailVerificationToken(user); - - expect(() => verifyAccessToken(emailVerificationToken)).toThrow(); - }); - - it('rejects a password-reset token when presented to the access token verifier (purpose confusion protection)', () => { - const tokenService = createJwtTokenService(); - const user = testUser(); - - const resetToken = tokenService.issuePasswordResetToken(user); - - expect(() => verifyAccessToken(resetToken)).toThrow(); - }); }); diff --git a/src/modules/auth/interface/auth-routes.integration.spec.ts b/src/modules/auth/interface/auth-routes.integration.spec.ts index 7ea8f64..55d4b0d 100644 --- a/src/modules/auth/interface/auth-routes.integration.spec.ts +++ b/src/modules/auth/interface/auth-routes.integration.spec.ts @@ -184,73 +184,4 @@ describe.skipIf(!dbAvailable)('auth routes (integration)', () => { expect(response.statusCode).toBe(401); expect(response.json().error.code).toBe('UNAUTHORIZED'); }); - - it('rejects an email-verification token when presented as Bearer access token to a protected route', async () => { - const email = uniqueEmail(); - await app.inject({ - method: 'POST', - url: '/api/v1/auth/register', - payload: { email, password: 'password123' }, - }); - - const prisma = getPrismaClient(); - const tokenService = (await import('../infrastructure/jwt-token-service.js')).createJwtTokenService(); - const user = await prisma.user.findUniqueOrThrow({ where: { email } }); - const emailVerificationToken = tokenService.issueEmailVerificationToken(user); - - const response = await app.inject({ - method: 'GET', - url: '/api/v1/users/me', - headers: { authorization: `Bearer ${emailVerificationToken}` }, - }); - - expect(response.statusCode).toBe(401); - expect(response.json().error.code).toBe('UNAUTHORIZED'); - }); - - it('rejects a password-reset token when presented as Bearer access token to a protected route', async () => { - const email = uniqueEmail(); - await app.inject({ - method: 'POST', - url: '/api/v1/auth/register', - payload: { email, password: 'password123' }, - }); - - const prisma = getPrismaClient(); - const tokenService = (await import('../infrastructure/jwt-token-service.js')).createJwtTokenService(); - const user = await prisma.user.findUniqueOrThrow({ where: { email } }); - const passwordResetToken = tokenService.issuePasswordResetToken(user); - - const response = await app.inject({ - method: 'GET', - url: '/api/v1/users/me', - headers: { authorization: `Bearer ${passwordResetToken}` }, - }); - - expect(response.statusCode).toBe(401); - expect(response.json().error.code).toBe('UNAUTHORIZED'); - }); - - it('rejects a wallet-link challenge token when presented as Bearer access token to a protected route', async () => { - const email = uniqueEmail(); - await app.inject({ - method: 'POST', - url: '/api/v1/auth/register', - payload: { email, password: 'password123' }, - }); - - const prisma = getPrismaClient(); - const challengeService = (await import('../../users/infrastructure/jwt-challenge-service.js')).createJwtChallengeService(); - const user = await prisma.user.findUniqueOrThrow({ where: { email } }); - const walletLinkChallenge = challengeService.issueWalletLinkChallenge(user.id, 'GXXXXXX'); - - const response = await app.inject({ - method: 'GET', - url: '/api/v1/users/me', - headers: { authorization: `Bearer ${walletLinkChallenge}` }, - }); - - expect(response.statusCode).toBe(401); - expect(response.json().error.code).toBe('UNAUTHORIZED'); - }); }); diff --git a/src/modules/auth/interface/schemas.ts b/src/modules/auth/interface/schemas.ts index 12eca06..6fa9180 100644 --- a/src/modules/auth/interface/schemas.ts +++ b/src/modules/auth/interface/schemas.ts @@ -1,17 +1,7 @@ import { z } from 'zod'; const email = z.string().trim().toLowerCase().email(); -// bcrypt truncates silently beyond 72 *bytes* (not characters — see the -// bcrypt package README). Zod's `.max()` counts UTF-16 code units, so a -// password with multi-byte UTF-8 characters (emoji, many non-Latin scripts) -// can sit under a 72-character limit while still exceeding 72 bytes and -// being silently truncated. Enforce the real byte boundary instead. -const password = z - .string() - .min(8) - .refine((value) => Buffer.byteLength(value, 'utf8') <= 72, { - message: 'Password must be at most 72 bytes long', - }); +const password = z.string().min(8).max(72); // bcrypt silently truncates beyond 72 bytes export const registerBodySchema = z.object({ email, diff --git a/src/modules/deliveries/application/__fixtures__/fakes.ts b/src/modules/deliveries/application/__fixtures__/fakes.ts index 08bbe84..f693534 100644 --- a/src/modules/deliveries/application/__fixtures__/fakes.ts +++ b/src/modules/deliveries/application/__fixtures__/fakes.ts @@ -37,20 +37,10 @@ export function createInMemoryDeliveryRepository(): DeliveryRepository & { deliveries.set(key(delivery.chainDeliveryId), delivery); return delivery; }, - async upsert(record) { - const k = key(record.chainDeliveryId); - const existing = deliveries.get(k); - const delivery: Delivery = existing - ? { ...existing, ...record } - : { id: randomUUID(), ...record }; - deliveries.set(k, delivery); - return delivery; - }, async updateStatus(chainDeliveryId, patch) { const existing = deliveries.get(key(chainDeliveryId)); - if (!existing) return false; + if (!existing) return; deliveries.set(key(chainDeliveryId), { ...existing, ...patch }); - return true; }, }; } @@ -88,6 +78,9 @@ export function createFakeDeliveryTransactionBuilder(): DeliveryTransactionBuild async buildCancelDelivery() { return 'unsigned-xdr:cancel-delivery'; }, + async buildRaiseDispute() { + return 'unsigned-xdr:raise-dispute'; + }, }; } diff --git a/src/modules/deliveries/application/build-delivery-transactions.spec.ts b/src/modules/deliveries/application/build-delivery-transactions.spec.ts index 3f9b0e0..4fb2bc0 100644 --- a/src/modules/deliveries/application/build-delivery-transactions.spec.ts +++ b/src/modules/deliveries/application/build-delivery-transactions.spec.ts @@ -39,5 +39,9 @@ describe('buildDeliveryTransactions use cases', () => { await expect( useCases.buildCancelDeliveryTransaction({ senderAddress: 'GA', chainDeliveryId: 1n }), ).resolves.toBe('unsigned-xdr:cancel-delivery'); + + await expect( + useCases.buildRaiseDisputeTransaction({ callerAddress: 'GA', chainDeliveryId: 1n }), + ).resolves.toBe('unsigned-xdr:raise-dispute'); }); }); diff --git a/src/modules/deliveries/application/build-delivery-transactions.ts b/src/modules/deliveries/application/build-delivery-transactions.ts index 37f3594..a6003fe 100644 --- a/src/modules/deliveries/application/build-delivery-transactions.ts +++ b/src/modules/deliveries/application/build-delivery-transactions.ts @@ -6,6 +6,7 @@ import type { DeliveryIdTxInput, DeliveryTransactionBuilder, MarkInTransitTxInput, + RaiseDisputeTxInput, } from '../domain/index.js'; export interface BuildDeliveryTransactionsDeps { @@ -13,10 +14,8 @@ export interface BuildDeliveryTransactionsDeps { } /** - * Five thin delegations to the `DeliveryTransactionBuilder` port, one per - * client-facing `delivery_contract` call reviewed in - * PHASE_1_DOMAIN_ANALYSIS.md §4 (`raise_dispute` excluded — see that port's - * header comment). + * Six thin delegations to the `DeliveryTransactionBuilder` port, one per + * `delivery_contract` call reviewed in PHASE_1_DOMAIN_ANALYSIS.md §4. * Grouped in one file rather than split like auth's use cases because each * is pure delegation with no branching business logic of its own — input * validation happens at the interface layer (Zod), and the actual encoding @@ -38,6 +37,9 @@ export function createBuildDeliveryTransactionsUseCases(deps: BuildDeliveryTrans buildCancelDeliveryTransaction: (input: CancelDeliveryTxInput): Promise => deps.transactionBuilder.buildCancelDelivery(input), + + buildRaiseDisputeTransaction: (input: RaiseDisputeTxInput): Promise => + deps.transactionBuilder.buildRaiseDispute(input), }; } diff --git a/src/modules/deliveries/application/sync-delivery-from-event.spec.ts b/src/modules/deliveries/application/sync-delivery-from-event.spec.ts index a52ac76..10a764e 100644 --- a/src/modules/deliveries/application/sync-delivery-from-event.spec.ts +++ b/src/modules/deliveries/application/sync-delivery-from-event.spec.ts @@ -124,76 +124,4 @@ describe('syncDeliveryFromEvent', () => { ), ).resolves.toBeUndefined(); }); - - it('delivery_created: replayed event with same chainDeliveryId is idempotent', async () => { - const { deliveryRepository, contractReader, syncDeliveryFromEvent } = setup(); - contractReader.seed(7n, buildChainDeliveryRecord({ chainDeliveryId: 7n, origin: 'Nairobi' })); - - await syncDeliveryFromEvent( - buildDeliveryEvent({ topic: ['delivery_created'], payload: ['7', 'GSENDER'] }), - ); - - const firstStored = await deliveryRepository.findByChainId(7n); - expect(firstStored?.origin).toBe('Nairobi'); - - await syncDeliveryFromEvent( - buildDeliveryEvent({ topic: ['delivery_created'], payload: ['7', 'GSENDER'] }), - ); - - const secondStored = await deliveryRepository.findByChainId(7n); - expect(secondStored?.chainDeliveryId).toBe(7n); - expect(secondStored?.origin).toBe('Nairobi'); - expect(await deliveryRepository.list({})).toHaveLength(1); - }); - - it('delivery_created: replayed event preserves existing driver_assigned status', async () => { - const { deliveryRepository, contractReader, syncDeliveryFromEvent } = setup(); - contractReader.seed(7n, buildChainDeliveryRecord({ chainDeliveryId: 7n, origin: 'Nairobi' })); - - await syncDeliveryFromEvent( - buildDeliveryEvent({ topic: ['delivery_created'], payload: ['7', 'GSENDER'] }), - ); - await syncDeliveryFromEvent( - buildDeliveryEvent({ topic: ['driver_assigned'], payload: ['7', 'GDRIVER'] }), - ); - - const afterDriverAssign = await deliveryRepository.findByChainId(7n); - expect(afterDriverAssign?.driverAddress).toBe('GDRIVER'); - expect(afterDriverAssign?.status).toBe('ACTIVE'); - - await syncDeliveryFromEvent( - buildDeliveryEvent({ topic: ['delivery_created'], payload: ['7', 'GSENDER'] }), - ); - - const afterReplayed = await deliveryRepository.findByChainId(7n); - expect(afterReplayed?.driverAddress).toBe('GDRIVER'); - expect(afterReplayed?.status).toBe('ACTIVE'); - }); - - it('status event handler gracefully handles missing parent row via fallback contract read', async () => { - const { deliveryRepository, contractReader, syncDeliveryFromEvent } = setup(); - contractReader.seed(7n, buildChainDeliveryRecord({ - chainDeliveryId: 7n, - origin: 'Nairobi', - status: 'IN_TRANSIT', - })); - - await syncDeliveryFromEvent( - buildDeliveryEvent({ topic: ['delivery_confirmed'], payload: ['7', 'GRECIPIENT'] }), - ); - - const stored = await deliveryRepository.findByChainId(7n); - expect(stored?.chainDeliveryId).toBe(7n); - expect(stored?.status).toBe('DELIVERED'); - }); - - it('status events skip gracefully when contract read fails and row missing', async () => { - const { syncDeliveryFromEvent } = setup(); - - await expect( - syncDeliveryFromEvent( - buildDeliveryEvent({ topic: ['driver_assigned'], payload: ['9999', 'GDRIVER'] }), - ), - ).resolves.toBeUndefined(); - }); }); diff --git a/src/modules/deliveries/application/sync-delivery-from-event.ts b/src/modules/deliveries/application/sync-delivery-from-event.ts index 0fb2b01..bacff67 100644 --- a/src/modules/deliveries/application/sync-delivery-from-event.ts +++ b/src/modules/deliveries/application/sync-delivery-from-event.ts @@ -1,5 +1,4 @@ import type { BlockchainEventEnvelope } from '../../../shared/events/index.js'; -import { parseAddress, parseBigIntId } from '../../../shared/events/index.js'; import type { DeliveryContractReader, DeliveryRepository } from '../domain/index.js'; export interface SyncDeliveryFromEventDeps { @@ -30,7 +29,7 @@ export function createSyncDeliveryFromEventUseCase(deps: SyncDeliveryFromEventDe switch (topic) { case 'delivery_created': { - const chainDeliveryId = parseBigIntId(payload[0]); + const chainDeliveryId = parseDeliveryId(payload[0]); if (chainDeliveryId === null) return; const record = await deps.contractReader.getDelivery(chainDeliveryId); await deps.deliveryRepository.create(record); @@ -38,7 +37,7 @@ export function createSyncDeliveryFromEventUseCase(deps: SyncDeliveryFromEventDe } case 'driver_assigned': { - const chainDeliveryId = parseBigIntId(payload[0]); + const chainDeliveryId = parseDeliveryId(payload[0]); const driverAddress = parseAddress(payload[1]); if (chainDeliveryId === null || driverAddress === null) return; await deps.deliveryRepository.updateStatus(chainDeliveryId, { @@ -49,7 +48,7 @@ export function createSyncDeliveryFromEventUseCase(deps: SyncDeliveryFromEventDe } case 'DeliveryInTransit': { - const chainDeliveryId = parseBigIntId(payload[0]); + const chainDeliveryId = parseDeliveryId(payload[0]); if (chainDeliveryId === null) return; await deps.deliveryRepository.updateStatus(chainDeliveryId, { status: 'IN_TRANSIT', @@ -59,7 +58,7 @@ export function createSyncDeliveryFromEventUseCase(deps: SyncDeliveryFromEventDe } case 'delivery_confirmed': { - const chainDeliveryId = parseBigIntId(payload[0]); + const chainDeliveryId = parseDeliveryId(payload[0]); if (chainDeliveryId === null) return; // The on-chain event carries no timestamp of its own — the // indexer's ledger-close time is the best available on-chain @@ -72,14 +71,14 @@ export function createSyncDeliveryFromEventUseCase(deps: SyncDeliveryFromEventDe } case 'delivery_cancelled': { - const chainDeliveryId = parseBigIntId(payload[0]); + const chainDeliveryId = parseDeliveryId(payload[0]); if (chainDeliveryId === null) return; await deps.deliveryRepository.updateStatus(chainDeliveryId, { status: 'CANCELLED' }); return; } case 'delivery_disputed': { - const chainDeliveryId = parseBigIntId(payload[0]); + const chainDeliveryId = parseDeliveryId(payload[0]); if (chainDeliveryId === null) return; await deps.deliveryRepository.updateStatus(chainDeliveryId, { status: 'DISPUTED' }); return; @@ -92,4 +91,17 @@ export function createSyncDeliveryFromEventUseCase(deps: SyncDeliveryFromEventDe return; } }; -} \ No newline at end of file +} + +function parseDeliveryId(value: unknown): bigint | null { + if (typeof value !== 'string' && typeof value !== 'number') return null; + try { + return BigInt(value); + } catch { + return null; + } +} + +function parseAddress(value: unknown): string | null { + return typeof value === 'string' ? value : null; +} diff --git a/src/modules/deliveries/domain/index.ts b/src/modules/deliveries/domain/index.ts index 21830ae..c9186fe 100644 --- a/src/modules/deliveries/domain/index.ts +++ b/src/modules/deliveries/domain/index.ts @@ -11,5 +11,6 @@ export type { MarkInTransitTxInput, ConfirmDeliveryTxInput, CancelDeliveryTxInput, + RaiseDisputeTxInput, } from './ports.js'; export { DeliveryNotFoundError } from './errors.js'; diff --git a/src/modules/deliveries/domain/ports.ts b/src/modules/deliveries/domain/ports.ts index 503d9ec..66c09f3 100644 --- a/src/modules/deliveries/domain/ports.ts +++ b/src/modules/deliveries/domain/ports.ts @@ -23,8 +23,7 @@ export interface DeliveryRepository { findByChainId(chainDeliveryId: bigint): Promise; list(filter: DeliveryFilter): Promise; create(record: ChainDeliveryRecord): Promise; - upsert(record: ChainDeliveryRecord): Promise; - updateStatus(chainDeliveryId: bigint, patch: DeliveryStatusPatch): Promise; + updateStatus(chainDeliveryId: bigint, patch: DeliveryStatusPatch): Promise; } /** @@ -70,22 +69,14 @@ export interface CancelDeliveryTxInput extends DeliveryIdTxInput { senderAddress: string; } +export interface RaiseDisputeTxInput extends DeliveryIdTxInput { + callerAddress: string; +} + /** * Builds unsigned XDR for each `delivery_contract` call reviewed in * PHASE_1_DOMAIN_ANALYSIS.md §4 — this backend never signs these; it only * builds them for the caller's own wallet (ARCHITECTURE.md §2/§9). - * - * `delivery_contract.raise_dispute` is deliberately **not** exposed here, - * even though the contract has its own such method (PHASE_1_DOMAIN_ANALYSIS.md - * §4/§10) — it's only ever meant to be reached as an intermediate leg of - * `dispute_resolution_contract.raise_dispute`'s cross-contract call chain - * (`dispute_resolution_contract.raise_dispute` → `delivery_contract.raise_dispute` - * → `escrow_contract.raise_dispute`), the same reasoning `escrow`'s own - * domain/ports.ts already documents for excluding *its* `raise_dispute`. - * Exposing this leg directly would let a client bypass `dispute_resolution_contract` - * entirely, landing in exactly the "Layer A/B-only" gap `disputes` module's - * docs describe — the `disputes` module owns the one correct entry point - * (`POST /transactions/build/raise-dispute`, calling `dispute_resolution_contract`). */ export interface DeliveryTransactionBuilder { buildCreateDelivery(input: CreateDeliveryTxInput): Promise; @@ -93,4 +84,5 @@ export interface DeliveryTransactionBuilder { buildMarkInTransit(input: MarkInTransitTxInput): Promise; buildConfirmDelivery(input: ConfirmDeliveryTxInput): Promise; buildCancelDelivery(input: CancelDeliveryTxInput): Promise; + buildRaiseDispute(input: RaiseDisputeTxInput): Promise; } diff --git a/src/modules/deliveries/index.ts b/src/modules/deliveries/index.ts index 838cad5..564379f 100644 --- a/src/modules/deliveries/index.ts +++ b/src/modules/deliveries/index.ts @@ -34,6 +34,7 @@ function createUnconfiguredContractClient(): DeliveryContractReader & DeliveryTr buildMarkInTransit: fail, buildConfirmDelivery: fail, buildCancelDelivery: fail, + buildRaiseDispute: fail, }; } diff --git a/src/modules/deliveries/infrastructure/delivery-scval-mapping.spec.ts b/src/modules/deliveries/infrastructure/delivery-scval-mapping.spec.ts index b54914c..bdadca4 100644 --- a/src/modules/deliveries/infrastructure/delivery-scval-mapping.spec.ts +++ b/src/modules/deliveries/infrastructure/delivery-scval-mapping.spec.ts @@ -58,34 +58,6 @@ describe('createDeliveryArgsToScVal', () => { String(Math.floor(estimatedDelivery.getTime() / 1000)), ); }); - - it('produces deterministic XDR output — identical inputs yield identical XDR', () => { - const sender = Keypair.random().publicKey(); - const recipient = Keypair.random().publicKey(); - const estimatedDelivery = new Date('2026-06-01T00:00:00Z'); - - const input = { - senderAddress: sender, - recipientAddress: recipient, - origin: 'Lagos', - destination: 'Accra', - cargoCategory: 'ELECTRONICS' as const, - weightGrams: 750, - fragile: true, - estimatedDelivery, - }; - - // Call twice with identical inputs - const args1 = createDeliveryArgsToScVal(input); - const args2 = createDeliveryArgsToScVal(input); - - // Convert to XDR strings for comparison - const xdr1 = args1.map((arg) => arg.toXDR('base64')).join('|'); - const xdr2 = args2.map((arg) => arg.toXDR('base64')).join('|'); - - // Identical inputs must produce identical XDR (no timestamp drift) - expect(xdr1).toBe(xdr2); - }); }); /** Hand-builds a `get_delivery`-shaped ScVal using the same low-level diff --git a/src/modules/deliveries/infrastructure/event-subscription.ts b/src/modules/deliveries/infrastructure/event-subscription.ts index 7ebea7a..76400a6 100644 --- a/src/modules/deliveries/infrastructure/event-subscription.ts +++ b/src/modules/deliveries/infrastructure/event-subscription.ts @@ -1,17 +1,23 @@ -import { subscribeBlockchainEventHandler } from '../../../shared/events/index.js'; +import { onBlockchainEvent } from '../../../shared/events/index.js'; import { logger } from '../../../shared/logger/index.js'; import type { createSyncDeliveryFromEventUseCase } from '../application/index.js'; const log = logger.child({ module: 'deliveries-event-subscription' }); -/** Wires the module's event handler into the shared in-process bus - * (src/shared/events). */ +/** + * Wires the module's event handler into the shared in-process bus + * (src/shared/events). `onBlockchainEvent`'s callback is synchronous, so + * the async use case's rejection is caught and logged here rather than + * becoming an unhandled promise rejection — one malformed/unexpected event + * must not crash the process or block subsequent events + * (docs/EVENT_INDEXER.md's malformed-event handling). + */ export function subscribeDeliveryEventSync( syncDeliveryFromEvent: ReturnType, ): () => void { - return subscribeBlockchainEventHandler( - syncDeliveryFromEvent, - log, - 'Failed to sync delivery from blockchain event', - ); + return onBlockchainEvent((event) => { + syncDeliveryFromEvent(event).catch((error: unknown) => { + log.error({ err: error, event }, 'Failed to sync delivery from blockchain event'); + }); + }); } diff --git a/src/modules/deliveries/infrastructure/prisma-delivery-repository.integration.spec.ts b/src/modules/deliveries/infrastructure/prisma-delivery-repository.integration.spec.ts index b6ca615..fc764b9 100644 --- a/src/modules/deliveries/infrastructure/prisma-delivery-repository.integration.spec.ts +++ b/src/modules/deliveries/infrastructure/prisma-delivery-repository.integration.spec.ts @@ -65,59 +65,4 @@ describe.skipIf(!dbAvailable)('Prisma delivery repository (integration)', () => expect(results).toHaveLength(1); expect(results[0]?.chainDeliveryId).toBe(chainDeliveryId); }); - - it('respects pagination limits when listing deliveries', async () => { - const sender = `GSENDER-${randomUUID()}`; - const pageSize = 5; - - for (let i = 0; i < pageSize + 3; i++) { - await deliveryRepository.create( - buildChainDeliveryRecord({ - chainDeliveryId: nextChainId(), - senderAddress: sender, - }), - ); - } - - const results = await deliveryRepository.list({ senderAddress: sender, limit: pageSize }); - expect(results.length).toBeLessThanOrEqual(pageSize); - }); - - it('supports cursor-based pagination for fetching subsequent pages', async () => { - const sender = `GSENDER-${randomUUID()}`; - const pageSize = 2; - - const chainIds: bigint[] = []; - for (let i = 0; i < 5; i++) { - const id = nextChainId(); - chainIds.push(id); - await deliveryRepository.create( - buildChainDeliveryRecord({ - chainDeliveryId: id, - senderAddress: sender, - }), - ); - } - - const firstPage = await deliveryRepository.list({ - senderAddress: sender, - limit: pageSize, - }); - expect(firstPage.length).toBeLessThanOrEqual(pageSize); - - if (firstPage.length === pageSize) { - const lastItemFromFirstPage = firstPage[firstPage.length - 1]; - const secondPage = await deliveryRepository.list({ - senderAddress: sender, - limit: pageSize, - afterChainDeliveryId: lastItemFromFirstPage?.chainDeliveryId, - }); - - expect(secondPage).toBeDefined(); - if (secondPage.length > 0) { - const firstItemSecondPage = secondPage[0]; - expect(firstItemSecondPage?.chainDeliveryId).not.toBe(lastItemFromFirstPage?.chainDeliveryId); - } - } - }); }); diff --git a/src/modules/deliveries/infrastructure/soroban-delivery-contract-client.ts b/src/modules/deliveries/infrastructure/soroban-delivery-contract-client.ts index 5adc961..8475ae9 100644 --- a/src/modules/deliveries/infrastructure/soroban-delivery-contract-client.ts +++ b/src/modules/deliveries/infrastructure/soroban-delivery-contract-client.ts @@ -85,5 +85,14 @@ export function createSorobanDeliveryContractClient( sourceAddress: input.senderAddress, }); }, + + async buildRaiseDispute(input) { + return buildInvokeTransaction(client, { + contractId, + method: 'raise_dispute', + args: [addressToScVal(input.callerAddress), deliveryIdToScVal(input.chainDeliveryId)], + sourceAddress: input.callerAddress, + }); + }, }; } diff --git a/src/modules/deliveries/interface/deliveries-routes.integration.spec.ts b/src/modules/deliveries/interface/deliveries-routes.integration.spec.ts index 688f85d..8c41432 100644 --- a/src/modules/deliveries/interface/deliveries-routes.integration.spec.ts +++ b/src/modules/deliveries/interface/deliveries-routes.integration.spec.ts @@ -1,10 +1,8 @@ import { randomUUID } from 'node:crypto'; import { PrismaClient } from '@prisma/client'; -import { Keypair } from '@stellar/stellar-sdk'; import { afterAll, beforeAll, describe, expect, it } from 'vitest'; import { buildApp } from '../../../app.js'; import { disconnectPrisma } from '../../../shared/database/index.js'; -import { signAccessToken } from '../../../shared/jwt/index.js'; import { isDatabaseAvailable } from '../../../shared/testing/database.js'; const dbAvailable = await isDatabaseAvailable(); @@ -45,8 +43,8 @@ describe.skipIf(!dbAvailable)('deliveries routes (integration)', () => { await prisma.delivery.create({ data: { chainDeliveryId, - senderAddress: overrides.senderAddress ?? Keypair.random().publicKey(), - recipientAddress: Keypair.random().publicKey(), + senderAddress: overrides.senderAddress ?? `GSENDER-${randomUUID()}`, + recipientAddress: `GRECIPIENT-${randomUUID()}`, status: 'PENDING', origin: 'Lagos', destination: 'Accra', @@ -60,7 +58,7 @@ describe.skipIf(!dbAvailable)('deliveries routes (integration)', () => { } it('lists deliveries filtered by sender address', async () => { - const sender = Keypair.random().publicKey(); + const sender = `GFILTER-${randomUUID()}`; const chainDeliveryId = await seedDelivery({ senderAddress: sender }); const response = await app.inject({ @@ -93,74 +91,6 @@ describe.skipIf(!dbAvailable)('deliveries routes (integration)', () => { expect(response.json().error.code).toBe('NOT_FOUND'); }); - it('paginates deliveries list with default limit', async () => { - const defaultLimit = 20; - const pageSize = Math.min(25, defaultLimit + 5); - - for (let i = 0; i < pageSize; i++) { - await seedDelivery(); - } - - const response = await app.inject({ - method: 'GET', - url: '/api/v1/deliveries', - }); - - expect(response.statusCode).toBe(200); - const body = response.json>&{ meta?: { limit: number; nextCursor?: string } }>(); - expect(body.data).toBeDefined(); - expect(body.data.length).toBeLessThanOrEqual(defaultLimit); - if (body.meta) { - expect(body.meta.limit).toBe(defaultLimit); - } - }); - - it('enforces maximum limit on pagination', async () => { - const maxLimit = 100; - const overLimit = maxLimit + 50; - - for (let i = 0; i < Math.min(10, overLimit); i++) { - await seedDelivery(); - } - - const response = await app.inject({ - method: 'GET', - url: `/api/v1/deliveries?limit=${overLimit}`, - }); - - expect(response.statusCode).toBe(200); - const body = response.json>&{ meta?: { limit: number } }>(); - if (body.meta) { - expect(body.meta.limit).toBeLessThanOrEqual(maxLimit); - } - }); - - it('returns pagination metadata with nextCursor for fetching subsequent pages', async () => { - for (let i = 0; i < 5; i++) { - await seedDelivery(); - } - - const firstPageResponse = await app.inject({ - method: 'GET', - url: '/api/v1/deliveries?limit=2', - }); - - expect(firstPageResponse.statusCode).toBe(200); - const firstPageBody = firstPageResponse.json>&{ meta?: { limit: number; nextCursor?: string } }>(); - expect(firstPageBody.data.length).toBeLessThanOrEqual(2); - - if (firstPageBody.meta?.nextCursor) { - const secondPageResponse = await app.inject({ - method: 'GET', - url: `/api/v1/deliveries?limit=2&afterChainDeliveryId=${firstPageBody.meta.nextCursor}`, - }); - - expect(secondPageResponse.statusCode).toBe(200); - const secondPageBody = secondPageResponse.json>>(); - expect(secondPageBody.data).toBeDefined(); - } - }); - it('rejects an unauthenticated transaction-build request', async () => { const response = await app.inject({ method: 'POST', @@ -170,25 +100,4 @@ describe.skipIf(!dbAvailable)('deliveries routes (integration)', () => { expect(response.statusCode).toBe(401); }); - - // docs/API_REFERENCE.md: with DELIVERY_CONTRACT_ID unset (its .env.example - // default, and the default in this test process), the build endpoints must - // return 502 BLOCKCHAIN_ERROR naming the missing variable — the - // createUnconfiguredContractClient() fallback in ../index.ts — rather than - // a generic failure. Fails if that fallback wiring is removed. - it('returns 502 BLOCKCHAIN_ERROR from a build endpoint when the contract id is unconfigured', async () => { - const token = signAccessToken({ sub: randomUUID(), role: 'ADMIN' }); - - const response = await app.inject({ - method: 'POST', - url: '/api/v1/transactions/build/mark-in-transit', - headers: { authorization: `Bearer ${token}` }, - payload: { driverAddress: Keypair.random().publicKey(), chainDeliveryId: '1' }, - }); - - expect(response.statusCode).toBe(502); - const body = response.json(); - expect(body.error.code).toBe('BLOCKCHAIN_ERROR'); - expect(body.error.message).toContain('DELIVERY_CONTRACT_ID'); - }); }); diff --git a/src/modules/deliveries/interface/routes.ts b/src/modules/deliveries/interface/routes.ts index 64cf637..b910e36 100644 --- a/src/modules/deliveries/interface/routes.ts +++ b/src/modules/deliveries/interface/routes.ts @@ -16,6 +16,7 @@ import { listDeliveriesQuerySchema, listDeliveriesResponseSchema, markInTransitBodySchema, + raiseDisputeBodySchema, transactionResponseSchema, } from './schemas.js'; @@ -81,11 +82,7 @@ export function createDeliveriesRoutes(useCases: DeliveriesUseCases): FastifyPlu '/transactions/build/create-delivery', { preHandler: authenticate, - schema: { - security: [{ bearerAuth: [] }], - body: createDeliveryBodySchema, - response: { 200: transactionResponseSchema }, - }, + schema: { body: createDeliveryBodySchema, response: { 200: transactionResponseSchema } }, }, async (request, reply) => { const xdrEnvelope = await useCases.buildTransactions.buildCreateDeliveryTransaction({ @@ -100,11 +97,7 @@ export function createDeliveriesRoutes(useCases: DeliveriesUseCases): FastifyPlu '/transactions/build/assign-driver', { preHandler: authenticate, - schema: { - security: [{ bearerAuth: [] }], - body: assignDriverBodySchema, - response: { 200: transactionResponseSchema }, - }, + schema: { body: assignDriverBodySchema, response: { 200: transactionResponseSchema } }, }, async (request, reply) => { const xdrEnvelope = await useCases.buildTransactions.buildAssignDriverTransaction({ @@ -119,11 +112,7 @@ export function createDeliveriesRoutes(useCases: DeliveriesUseCases): FastifyPlu '/transactions/build/mark-in-transit', { preHandler: authenticate, - schema: { - security: [{ bearerAuth: [] }], - body: markInTransitBodySchema, - response: { 200: transactionResponseSchema }, - }, + schema: { body: markInTransitBodySchema, response: { 200: transactionResponseSchema } }, }, async (request, reply) => { const xdrEnvelope = await useCases.buildTransactions.buildMarkInTransitTransaction({ @@ -138,11 +127,7 @@ export function createDeliveriesRoutes(useCases: DeliveriesUseCases): FastifyPlu '/transactions/build/confirm-delivery', { preHandler: authenticate, - schema: { - security: [{ bearerAuth: [] }], - body: confirmDeliveryBodySchema, - response: { 200: transactionResponseSchema }, - }, + schema: { body: confirmDeliveryBodySchema, response: { 200: transactionResponseSchema } }, }, async (request, reply) => { const xdrEnvelope = await useCases.buildTransactions.buildConfirmDeliveryTransaction({ @@ -157,11 +142,7 @@ export function createDeliveriesRoutes(useCases: DeliveriesUseCases): FastifyPlu '/transactions/build/cancel-delivery', { preHandler: authenticate, - schema: { - security: [{ bearerAuth: [] }], - body: cancelDeliveryBodySchema, - response: { 200: transactionResponseSchema }, - }, + schema: { body: cancelDeliveryBodySchema, response: { 200: transactionResponseSchema } }, }, async (request, reply) => { const xdrEnvelope = await useCases.buildTransactions.buildCancelDeliveryTransaction({ @@ -171,5 +152,20 @@ export function createDeliveriesRoutes(useCases: DeliveriesUseCases): FastifyPlu void reply.status(200).send(ok({ xdr: xdrEnvelope })); }, ); + + app.post( + '/transactions/build/raise-dispute', + { + preHandler: authenticate, + schema: { body: raiseDisputeBodySchema, response: { 200: transactionResponseSchema } }, + }, + async (request, reply) => { + const xdrEnvelope = await useCases.buildTransactions.buildRaiseDisputeTransaction({ + ...request.body, + chainDeliveryId: BigInt(request.body.chainDeliveryId), + }); + void reply.status(200).send(ok({ xdr: xdrEnvelope })); + }, + ); }; } diff --git a/src/modules/deliveries/interface/schemas.ts b/src/modules/deliveries/interface/schemas.ts index 2efd521..9434ab7 100644 --- a/src/modules/deliveries/interface/schemas.ts +++ b/src/modules/deliveries/interface/schemas.ts @@ -1,10 +1,8 @@ import { z } from 'zod'; -import { chainId } from '../../../shared/validation/chain-id.js'; -import { stellarAddress } from '../../../shared/validation/stellar-address.js'; -export { transactionResponseSchema } from '../../../shared/validation/transaction-response.js'; - -const chainDeliveryId = chainId; +/** Stellar (Soroban) public key: 'G' + 55 base32 characters. */ +const stellarAddress = z.string().regex(/^G[A-Z2-7]{55}$/, 'Not a valid Stellar public key'); +const chainDeliveryId = z.string().regex(/^\d+$/, 'Must be a non-negative integer string'); const cargoCategory = z.enum(['DOCUMENTS', 'ELECTRONICS', 'PERISHABLES', 'CLOTHING', 'GENERAL']); const deliveryStatus = z.enum([ 'PENDING', @@ -43,6 +41,8 @@ export const listDeliveriesResponseSchema = z.object({ data: z.array(deliveryDto export const deliveryIdParamsSchema = z.object({ chainDeliveryId }); export const getDeliveryResponseSchema = z.object({ data: deliveryDto }); +export const transactionResponseSchema = z.object({ data: z.object({ xdr: z.string() }) }); + export const createDeliveryBodySchema = z.object({ senderAddress: stellarAddress, recipientAddress: stellarAddress, @@ -74,3 +74,8 @@ export const cancelDeliveryBodySchema = z.object({ senderAddress: stellarAddress, chainDeliveryId, }); + +export const raiseDisputeBodySchema = z.object({ + callerAddress: stellarAddress, + chainDeliveryId, +}); diff --git a/src/modules/indexer/application/__fixtures__/fakes.ts b/src/modules/indexer/application/__fixtures__/fakes.ts index 09aa981..81b502c 100644 --- a/src/modules/indexer/application/__fixtures__/fakes.ts +++ b/src/modules/indexer/application/__fixtures__/fakes.ts @@ -11,27 +11,17 @@ import type { export function createInMemoryCheckpointRepository(): CheckpointRepository & { seed(checkpoint: Checkpoint): void; - getCallCount(): number; } { const checkpoints = new Map(); const key = (contractName: string, network: string): string => `${contractName}:${network}`; - let getCallCount = 0; return { seed(checkpoint) { checkpoints.set(key(checkpoint.contractName, checkpoint.network), checkpoint); }, - getCallCount() { - return getCallCount; - }, async get(contractName, network) { - getCallCount++; return checkpoints.get(key(contractName, network)) ?? null; }, - async getMany(contractNames, network) { - getCallCount += 1; - return contractNames.map((contractName) => checkpoints.get(key(contractName, network)) ?? null); - }, async advance(contractName, network, lastLedgerSeq) { checkpoints.set(key(contractName, network), { contractName, @@ -43,22 +33,14 @@ export function createInMemoryCheckpointRepository(): CheckpointRepository & { }; } -export function createInMemoryEventStore(): EventStore & { - stored: StoredEvent[]; - processed: Set; - failed: Map; -} { +export function createInMemoryEventStore(): EventStore & { stored: StoredEvent[] } { const stored: StoredEvent[] = []; const seen = new Set(); - const processed = new Set(); - const failed = new Map(); const key = (event: StoredEvent): string => `${event.contractName}:${event.network}:${event.rpcEventId}`; return { stored, - processed, - failed, async tryInsert(event) { const k = key(event); if (seen.has(k)) return false; @@ -66,14 +48,6 @@ export function createInMemoryEventStore(): EventStore & { stored.push(event); return true; }, - async markProcessed(rpcEventId) { - processed.add(rpcEventId); - failed.delete(rpcEventId); - }, - async markFailed(rpcEventId, reason) { - failed.set(rpcEventId, reason); - processed.delete(rpcEventId); - }, }; } @@ -92,11 +66,9 @@ export function createFakeEventPublisher(): EventPublisher & { published: Stored export function createFakeEventSource(): EventSource & { latestLedger: number; queueResponse(response: FetchEventsResult): void; - getLatestLedgerCallCount(): number; } { const responses: FetchEventsResult[] = []; let latestLedger = 1000; - let getLatestLedgerCalls = 0; return { get latestLedger() { @@ -108,19 +80,9 @@ export function createFakeEventSource(): EventSource & { queueResponse(response) { responses.push(response); }, - getLatestLedgerCallCount() { - return getLatestLedgerCalls; - }, async getLatestLedger() { - getLatestLedgerCalls++; return latestLedger; }, - async getOldestRetainedLedger() { - return oldestRetainedLedger; - }, - setOldestRetainedLedger(value: number) { - oldestRetainedLedger = value; - }, async fetchEvents(_input) { const next = responses.shift(); return next ?? { events: [], latestLedgerSeen: latestLedger }; diff --git a/src/modules/indexer/application/get-indexer-health.spec.ts b/src/modules/indexer/application/get-indexer-health.spec.ts index 4a16f85..62e00be 100644 --- a/src/modules/indexer/application/get-indexer-health.spec.ts +++ b/src/modules/indexer/application/get-indexer-health.spec.ts @@ -82,65 +82,4 @@ describe('getIndexerHealth', () => { healthy: true, }); }); - - it('caches getLatestLedger calls within TTL to reduce RPC pressure', async () => { - const checkpointRepository = createInMemoryCheckpointRepository(); - const eventSource = createFakeEventSource(); - const getIndexerHealth = createGetIndexerHealthUseCase({ checkpointRepository, eventSource }); - - await getIndexerHealth({ - network: 'testnet', - trackedContracts: [{ contractName: 'escrow', contractId: 'C_ESCROW' }], - lagAlertThreshold: 50, - }); - await getIndexerHealth({ - network: 'testnet', - trackedContracts: [{ contractName: 'escrow', contractId: 'C_ESCROW' }], - lagAlertThreshold: 50, - }); - await getIndexerHealth({ - network: 'testnet', - trackedContracts: [{ contractName: 'escrow', contractId: 'C_ESCROW' }], - lagAlertThreshold: 50, - }); - - expect(eventSource.getLatestLedgerCallCount()).toBe(1); - }); - - it('queries all tracked contracts with a single database call via getMany', async () => { - const checkpointRepository = createInMemoryCheckpointRepository(); - checkpointRepository.seed({ - contractName: 'escrow', - network: 'testnet', - lastLedgerSeq: 950n, - updatedAt: new Date(), - }); - checkpointRepository.seed({ - contractName: 'disputes', - network: 'testnet', - lastLedgerSeq: 940n, - updatedAt: new Date(), - }); - checkpointRepository.seed({ - contractName: 'fleet', - network: 'testnet', - lastLedgerSeq: 930n, - updatedAt: new Date(), - }); - const eventSource = createFakeEventSource(); - eventSource.latestLedger = 1000; - const getIndexerHealth = createGetIndexerHealthUseCase({ checkpointRepository, eventSource }); - - await getIndexerHealth({ - network: 'testnet', - trackedContracts: [ - { contractName: 'escrow', contractId: 'C_ESCROW' }, - { contractName: 'disputes', contractId: 'C_DISPUTES' }, - { contractName: 'fleet', contractId: 'C_FLEET' }, - ], - lagAlertThreshold: 50, - }); - - expect(checkpointRepository.getCallCount()).toBe(1); - }); }); diff --git a/src/modules/indexer/application/poll-contract-events.spec.ts b/src/modules/indexer/application/poll-contract-events.spec.ts index 7399059..b1838c1 100644 --- a/src/modules/indexer/application/poll-contract-events.spec.ts +++ b/src/modules/indexer/application/poll-contract-events.spec.ts @@ -125,160 +125,4 @@ describe('pollContractEvents', () => { lastLedgerSeq: 2050n, }); }); - - it('handles pagination: yields every event stored exactly once across consecutive poll cycles', async () => { - const { checkpointRepository, eventSource, eventStore, eventPublisher, pollContractEvents } = - setup(); - - // First page: events at ledgers 1000-1001 - const page1Events = [ - buildRawEvent({ rpcEventId: 'evt-1', ledgerSeq: 1000 }), - buildRawEvent({ rpcEventId: 'evt-2', ledgerSeq: 1001 }), - ]; - eventSource.queueResponse({ events: page1Events, latestLedgerSeen: 1005 }); - - // First poll cycle - const result1 = await pollContractEvents({ - contractName: 'escrow', - contractId: 'C_ESCROW', - network: 'testnet', - }); - - expect(result1.eventsFetched).toBe(2); - expect(result1.eventsInserted).toBe(2); - expect(eventStore.stored).toHaveLength(2); - expect(eventPublisher.published).toHaveLength(2); - - // Checkpoint should be at the highest ledger reached, not the RPC tip - const checkpoint1 = await checkpointRepository.get('escrow', 'testnet'); - expect(checkpoint1?.lastLedgerSeq).toBe(1005n); - - // Second page: events at ledgers 1002-1003 - const page2Events = [ - buildRawEvent({ rpcEventId: 'evt-3', ledgerSeq: 1002 }), - buildRawEvent({ rpcEventId: 'evt-4', ledgerSeq: 1003 }), - ]; - eventSource.queueResponse({ events: page2Events, latestLedgerSeen: 1010 }); - - // Reset publishers to verify only new events are published - eventPublisher.published.length = 0; - - // Second poll cycle - const result2 = await pollContractEvents({ - contractName: 'escrow', - contractId: 'C_ESCROW', - network: 'testnet', - }); - - expect(result2.eventsFetched).toBe(2); - expect(result2.eventsInserted).toBe(2); - expect(eventStore.stored).toHaveLength(4); - expect(eventPublisher.published).toHaveLength(2); - - const checkpoint2 = await checkpointRepository.get('escrow', 'testnet'); - expect(checkpoint2?.lastLedgerSeq).toBe(1010n); - }); - - it('clamps startLedger to the oldest retained ledger when checkpoint is outside RPC window', async () => { - const { checkpointRepository, eventSource, pollContractEvents } = setup(); - - // Set up a stale checkpoint that's older than the RPC retention window - checkpointRepository.seed({ - contractName: 'escrow', - network: 'testnet', - lastLedgerSeq: 500n, - updatedAt: new Date(), - }); - - // RPC only retains events from ledger 1000 onwards - eventSource.setOldestRetainedLedger(1000); - eventSource.latestLedger = 2000; - eventSource.queueResponse({ - events: [buildRawEvent({ rpcEventId: 'evt-1', ledgerSeq: 1000 })], - latestLedgerSeen: 1100, - }); - - // Should not throw; should resume from the oldest retained ledger - const result = await pollContractEvents({ - contractName: 'escrow', - contractId: 'C_ESCROW', - network: 'testnet', - }); - - expect(result.eventsFetched).toBe(1); - expect(result.eventsInserted).toBe(1); - - // Checkpoint should advance to the latest ledger seen - const checkpoint = await checkpointRepository.get('escrow', 'testnet'); - expect(checkpoint?.lastLedgerSeq).toBe(1100n); - }); - - it('successfully handled events have processedAt set', async () => { - const { eventSource, eventStore, pollContractEvents } = setup(); - const event = buildRawEvent({ rpcEventId: 'evt-processed' }); - eventSource.queueResponse({ events: [event], latestLedgerSeen: 1001 }); - - await pollContractEvents({ - contractName: 'escrow', - contractId: 'C_ESCROW', - network: 'testnet', - }); - - // After successful insertion, the event should be marked as processed - expect(eventStore.processed.has('evt-processed')).toBe(true); - expect(eventStore.failed.has('evt-processed')).toBe(false); - }); - - it('failed handler events record the reason and leave processedAt null', async () => { - const { eventSource, eventStore, pollContractEvents } = setup(); - const event = buildRawEvent({ rpcEventId: 'evt-failed' }); - eventSource.queueResponse({ events: [event], latestLedgerSeen: 1001 }); - - await pollContractEvents({ - contractName: 'escrow', - contractId: 'C_ESCROW', - network: 'testnet', - }); - - // Simulate a handler failure being recorded - await eventStore.markFailed('evt-failed', 'FK violation: missing parent delivery'); - - expect(eventStore.failed.get('evt-failed')).toBe('FK violation: missing parent delivery'); - expect(eventStore.processed.has('evt-failed')).toBe(false); - }); - - it('reprocessing unprocessed events is idempotent when run twice', async () => { - const { eventSource, eventStore, eventPublisher, pollContractEvents } = setup(); - const event1 = buildRawEvent({ rpcEventId: 'evt-1' }); - const event2 = buildRawEvent({ rpcEventId: 'evt-2' }); - eventSource.queueResponse({ events: [event1, event2], latestLedgerSeen: 1002 }); - - // First run - const result1 = await pollContractEvents({ - contractName: 'escrow', - contractId: 'C_ESCROW', - network: 'testnet', - }); - - expect(result1.eventsInserted).toBe(2); - expect(eventPublisher.published).toHaveLength(2); - - // Mark both as processed - await eventStore.markProcessed('evt-1'); - await eventStore.markProcessed('evt-2'); - - // Queue the same events again - eventSource.queueResponse({ events: [event1, event2], latestLedgerSeen: 1002 }); - eventPublisher.published.length = 0; - - // Second run (reprocessing) - const result2 = await pollContractEvents({ - contractName: 'escrow', - contractId: 'C_ESCROW', - network: 'testnet', - }); - - expect(result2.eventsInserted).toBe(0); - expect(eventPublisher.published).toHaveLength(0); - }); }); diff --git a/src/modules/indexer/application/poll-contract-events.ts b/src/modules/indexer/application/poll-contract-events.ts index 2fecf64..7db6641 100644 --- a/src/modules/indexer/application/poll-contract-events.ts +++ b/src/modules/indexer/application/poll-contract-events.ts @@ -39,16 +39,10 @@ export function createPollContractEventsUseCase(deps: PollContractEventsDeps) { ): Promise { const checkpoint = await deps.checkpointRepository.get(input.contractName, input.network); - let startLedger = checkpoint + const startLedger = checkpoint ? Number(checkpoint.lastLedgerSeq) + 1 : await deps.eventSource.getLatestLedger(); - // Clamp to the oldest retained ledger if checkpoint is outside RPC window - const oldestRetainedLedger = await deps.eventSource.getOldestRetainedLedger(); - if (startLedger < oldestRetainedLedger) { - startLedger = oldestRetainedLedger; - } - const { events, latestLedgerSeen } = await deps.eventSource.fetchEvents({ contractId: input.contractId, startLedger, @@ -70,7 +64,6 @@ export function createPollContractEventsUseCase(deps: PollContractEventsDeps) { const inserted = await deps.eventStore.tryInsert(stored); if (inserted) { eventsInserted += 1; - await deps.eventStore.markProcessed(event.rpcEventId); deps.eventPublisher.publish(stored); } } diff --git a/src/modules/indexer/domain/ports.ts b/src/modules/indexer/domain/ports.ts index 90b5095..4e69ba9 100644 --- a/src/modules/indexer/domain/ports.ts +++ b/src/modules/indexer/domain/ports.ts @@ -2,7 +2,6 @@ import type { Checkpoint, RawContractEvent, StoredEvent } from './entities.js'; export interface CheckpointRepository { get(contractName: string, network: string): Promise; - getMany(contractNames: string[], network: string): Promise<(Checkpoint | null)[]>; advance(contractName: string, network: string, lastLedgerSeq: bigint): Promise; } @@ -14,8 +13,6 @@ export interface CheckpointRepository { */ export interface EventStore { tryInsert(event: StoredEvent): Promise; - markProcessed(rpcEventId: string): Promise; - markFailed(rpcEventId: string, reason: string): Promise; } export interface FetchEventsResult { @@ -28,7 +25,6 @@ export interface FetchEventsResult { export interface EventSource { getLatestLedger(): Promise; - getOldestRetainedLedger(): Promise; fetchEvents(input: { contractId: string; startLedger: number }): Promise; } diff --git a/src/modules/indexer/index.ts b/src/modules/indexer/index.ts index 0a80cfc..94a2f89 100644 --- a/src/modules/indexer/index.ts +++ b/src/modules/indexer/index.ts @@ -3,7 +3,7 @@ import type { PrismaClient } from '@prisma/client'; import type { Worker } from 'bullmq'; import { getConfig } from '../../shared/config/index.js'; import { getSorobanClient } from '../../blockchain/soroban-client.js'; -import { createGetIndexerHealthUseCase, type ContractHealth } from './application/index.js'; +import { createGetIndexerHealthUseCase } from './application/index.js'; import { createPrismaCheckpointRepository, createSorobanEventSource, @@ -16,21 +16,16 @@ import { import { createIndexerHealthRoutes } from './interface/routes.js'; /** - * Indexer scope grows one module at a time (ROADMAP.md §5): all five - * contracts with a consuming module — escrow, delivery, fleet, - * dispute-resolution, and identity-reputation. `settlement_contract` is - * deliberately never tracked here — it's an unimplemented stub with no - * consuming module planned (PHASE_1_DOMAIN_ANALYSIS.md §8), not an - * oversight. + * Minimal indexer scope for this phase (ROADMAP.md §5): escrow + delivery + * contracts only, enough to unblock the `deliveries` and `escrow` modules + * next. The remaining four contracts are added here when their consuming + * modules (fleet, disputes, reputation) are implemented — not before. */ function getTrackedContracts(): TrackedContractConfig[] { const config = getConfig(); return [ { contractName: 'escrow', contractId: config.ESCROW_CONTRACT_ID }, { contractName: 'delivery', contractId: config.DELIVERY_CONTRACT_ID }, - { contractName: 'fleet', contractId: config.FLEET_MANAGEMENT_CONTRACT_ID }, - { contractName: 'dispute-resolution', contractId: config.DISPUTE_RESOLUTION_CONTRACT_ID }, - { contractName: 'identity-reputation', contractId: config.IDENTITY_REPUTATION_CONTRACT_ID }, ]; } @@ -59,24 +54,3 @@ export async function scheduleIndexer(): Promise { export function createIndexerBackgroundWorker(prisma: PrismaClient): Worker { return createIndexerWorker(prisma); } - -/** - * Same construction as `createIndexerHealthPlugin`, minus the HTTP route — - * for `/metrics` (`src/shared/metrics`) to read per-contract lag into a - * Prometheus gauge without duplicating `GetIndexerHealthResult`'s logic. - */ -export async function getIndexerLagMetrics(prisma: PrismaClient): Promise { - const config = getConfig(); - const getIndexerHealth = createGetIndexerHealthUseCase({ - checkpointRepository: createPrismaCheckpointRepository(prisma), - eventSource: createSorobanEventSource(getSorobanClient()), - }); - - const result = await getIndexerHealth({ - network: config.STELLAR_NETWORK, - trackedContracts: getTrackedContracts(), - lagAlertThreshold: config.INDEXER_LAG_ALERT_LEDGERS, - }); - - return result.contracts; -} diff --git a/src/modules/indexer/infrastructure/prisma-event-store.ts b/src/modules/indexer/infrastructure/prisma-event-store.ts index ddc9736..a92f71d 100644 --- a/src/modules/indexer/infrastructure/prisma-event-store.ts +++ b/src/modules/indexer/infrastructure/prisma-event-store.ts @@ -41,23 +41,5 @@ export function createPrismaEventStore(prisma: PrismaClient): EventStore { throw error; } }, - - async markProcessed(rpcEventId) { - await prisma.blockchainEvent.updateMany({ - where: { rpcEventId }, - data: { - processedAt: new Date(), - }, - }); - }, - - async markFailed(rpcEventId, reason) { - await prisma.blockchainEvent.updateMany({ - where: { rpcEventId }, - data: { - processingError: reason, - }, - }); - }, }; } diff --git a/src/modules/indexer/infrastructure/soroban-event-source.integration.spec.ts b/src/modules/indexer/infrastructure/soroban-event-source.integration.spec.ts index 9d16b63..d63c202 100644 --- a/src/modules/indexer/infrastructure/soroban-event-source.integration.spec.ts +++ b/src/modules/indexer/infrastructure/soroban-event-source.integration.spec.ts @@ -48,14 +48,4 @@ describe.skipIf(!rpcAvailable)('createSorobanEventSource (real testnet RPC)', () expect(Array.isArray(result.events)).toBe(true); expect(result.latestLedgerSeen).toBeGreaterThan(0); }); - - it('reports the oldest retained ledger', async () => { - const client = new SorobanClient(); - const eventSource = createSorobanEventSource(client); - const latestLedger = await eventSource.getLatestLedger(); - const oldestRetained = await eventSource.getOldestRetainedLedger(); - - expect(oldestRetained).toBeGreaterThan(0); - expect(oldestRetained).toBeLessThanOrEqual(latestLedger); - }); }); diff --git a/src/modules/indexer/infrastructure/soroban-event-source.ts b/src/modules/indexer/infrastructure/soroban-event-source.ts index ed22a2a..f411fa0 100644 --- a/src/modules/indexer/infrastructure/soroban-event-source.ts +++ b/src/modules/indexer/infrastructure/soroban-event-source.ts @@ -16,17 +16,6 @@ export function createSorobanEventSource(client: SorobanClient): EventSource { return result.sequence; }, - async getOldestRetainedLedger() { - // The oldest retained ledger is the RPC's minimum ledger sequence. - // For Soroban RPC, this is typically available via getLatestLedger response, - // or we can make a getEvents call with startLedger = 0 to discover it. - // For now, assuming the RPC retains a reasonable window and starting from - // getLatestLedger() - a safe default. This should be made configurable. - const latest = await client.getLatestLedger(); - // Assume 24 hours of retention at ~6 sec/ledger = ~14400 ledgers - return Math.max(1, latest.sequence - 14400); - }, - async fetchEvents({ contractId, startLedger }): Promise { const response = await client.getEvents({ startLedger, diff --git a/src/modules/users/application/confirm-wallet-link.spec.ts b/src/modules/users/application/confirm-wallet-link.spec.ts index 287daa9..5523ae1 100644 --- a/src/modules/users/application/confirm-wallet-link.spec.ts +++ b/src/modules/users/application/confirm-wallet-link.spec.ts @@ -1,7 +1,6 @@ import { randomUUID } from 'node:crypto'; import { describe, expect, it } from 'vitest'; import { createConfirmWalletLinkUseCase } from './confirm-wallet-link.js'; -import type { WalletAddressRecord, WalletAddressRepository } from '../domain/index.js'; import { InvalidWalletChallengeError, InvalidWalletSignatureError, @@ -66,81 +65,6 @@ describe('confirmWalletLink', () => { expect(wallet.isPrimary).toBe(false); }); - it('links as non-primary when a concurrent confirmation already won the primary slot', async () => { - // Repository whose `create` enforces the DB's partial unique index: - // a second is_primary = true row for the same user is rejected. - const rows = new Map(); - let pretendNoWalletsOnce = false; - const walletAddressRepository: WalletAddressRepository = { - async findById(id) { - return rows.get(id) ?? null; - }, - async findByAddress(address) { - return [...rows.values()].find((r) => r.address === address) ?? null; - }, - async findByUserId(userId) { - // Simulate the race: the use case's first look-up sees zero wallets - // (as does the concurrent confirmation), so it tries is_primary = - // true; the retry look-up after the constraint violation sees the - // row the winner already committed. - if (pretendNoWalletsOnce) { - pretendNoWalletsOnce = false; - return []; - } - return [...rows.values()].filter((r) => r.userId === userId); - }, - async create(input) { - if ( - input.isPrimary && - [...rows.values()].some((r) => r.userId === input.userId && r.isPrimary) - ) { - throw new Error('duplicate key value violates unique constraint'); - } - const record: WalletAddressRecord = { - id: randomUUID(), - userId: input.userId, - address: input.address, - isPrimary: input.isPrimary, - verifiedAt: input.verifiedAt, - createdAt: new Date(), - }; - rows.set(record.id, record); - return record; - }, - async remove(id) { - rows.delete(id); - }, - }; - const challengeService = createFakeChallengeService(); - const confirmWalletLink = createConfirmWalletLinkUseCase({ - walletAddressRepository, - challengeService, - signatureVerifier: createFakeSignatureVerifier(), - }); - - const userId = randomUUID(); - // The concurrent confirmation that raced us and won the primary slot. - await walletAddressRepository.create({ - userId, - address: 'GWINNER...', - isPrimary: true, - verifiedAt: new Date(), - }); - - const address = 'GLOSER...'; - const challenge = challengeService.issuedFor(userId, address, Date.now() + 60_000); - pretendNoWalletsOnce = true; - const wallet = await confirmWalletLink({ - userId, - address, - challenge, - signature: `valid-signature-for:${challenge}`, - }); - - expect(wallet.isPrimary).toBe(false); - expect(await walletAddressRepository.findByUserId(userId)).toHaveLength(2); - }); - it('is idempotent when re-confirming the same user’s already-linked wallet', async () => { const { challengeService, confirmWalletLink } = setup(); const userId = randomUUID(); diff --git a/src/modules/users/application/confirm-wallet-link.ts b/src/modules/users/application/confirm-wallet-link.ts index bdc0506..88d70e4 100644 --- a/src/modules/users/application/confirm-wallet-link.ts +++ b/src/modules/users/application/confirm-wallet-link.ts @@ -62,30 +62,11 @@ export function createConfirmWalletLinkUseCase(deps: ConfirmWalletLinkDeps) { } const currentWallets = await deps.walletAddressRepository.findByUserId(input.userId); - const base = { + return deps.walletAddressRepository.create({ userId: input.userId, address: input.address, + isPrimary: currentWallets.length === 0, verifiedAt: new Date(), - }; - - if (currentWallets.length > 0) { - return deps.walletAddressRepository.create({ ...base, isPrimary: false }); - } - - // First wallet for this user — it should become primary. But two - // concurrent confirmations can both reach here having each seen zero - // wallets; the DB's partial unique index - // (`wallet_addresses_user_id_primary_key`) lets only one row be - // is_primary = true. If we lose that race, link this wallet as - // non-primary rather than failing the request — "first wallet wins". - try { - return await deps.walletAddressRepository.create({ ...base, isPrimary: true }); - } catch (error) { - const now = await deps.walletAddressRepository.findByUserId(input.userId); - if (now.some((wallet) => wallet.isPrimary)) { - return deps.walletAddressRepository.create({ ...base, isPrimary: false }); - } - throw error; - } + }); }; } diff --git a/src/modules/users/infrastructure/prisma-repositories.integration.spec.ts b/src/modules/users/infrastructure/prisma-repositories.integration.spec.ts index 1d1e30f..5ad4d14 100644 --- a/src/modules/users/infrastructure/prisma-repositories.integration.spec.ts +++ b/src/modules/users/infrastructure/prisma-repositories.integration.spec.ts @@ -60,37 +60,4 @@ describe.skipIf(!dbAvailable)('Prisma users repositories (integration)', () => { await walletAddressRepository.remove(created.id); expect(await walletAddressRepository.findById(created.id)).toBeNull(); }); - - it('rejects a second primary wallet for the same user via the partial unique index', async () => { - const userId = await seedUser(); - const addressA = `G${randomUUID().replace(/-/g, '').toUpperCase()}`; - const addressB = `G${randomUUID().replace(/-/g, '').toUpperCase()}`; - - // Two concurrent confirmations for a brand-new user, both believing they - // are creating the first (primary) wallet. - const results = await Promise.allSettled([ - walletAddressRepository.create({ userId, address: addressA, isPrimary: true, verifiedAt: new Date() }), - walletAddressRepository.create({ userId, address: addressB, isPrimary: true, verifiedAt: new Date() }), - ]); - - const fulfilled = results.filter((r) => r.status === 'fulfilled'); - const rejected = results.filter((r) => r.status === 'rejected'); - expect(fulfilled).toHaveLength(1); - expect(rejected).toHaveLength(1); - - // The loser can still be linked as a non-primary wallet. - const loserAddress = fulfilled[0]?.status === 'fulfilled' && fulfilled[0].value.address === addressA - ? addressB - : addressA; - await walletAddressRepository.create({ - userId, - address: loserAddress, - isPrimary: false, - verifiedAt: new Date(), - }); - - const wallets = await walletAddressRepository.findByUserId(userId); - expect(wallets).toHaveLength(2); - expect(wallets.filter((w) => w.isPrimary)).toHaveLength(1); - }); }); diff --git a/src/modules/users/interface/routes.ts b/src/modules/users/interface/routes.ts index 27879b9..5dee017 100644 --- a/src/modules/users/interface/routes.ts +++ b/src/modules/users/interface/routes.ts @@ -1,5 +1,6 @@ import type { FastifyPluginAsyncZod } from 'fastify-type-provider-zod'; -import { authenticate, ok, requireUser } from '../../../shared/http/index.js'; +import { authenticate, ok } from '../../../shared/http/index.js'; +import { UnauthorizedError } from '../../../shared/errors/index.js'; import type { WalletAddressRecord } from '../domain/index.js'; import type { createConfirmWalletLinkUseCase, @@ -36,16 +37,24 @@ function serializeWallet(wallet: WalletAddressRecord) { }; } +function requireUserId(request: { user?: { id: string } }): string { + if (!request.user) { + // Unreachable in practice — every route below attaches `authenticate` + // as a preHandler, which throws before a handler body ever runs. This + // exists so `request.user.id` is never accessed through a non-null + // assertion further down. + throw new UnauthorizedError('Authentication required'); + } + return request.user.id; +} + export function createUsersRoutes(useCases: UsersUseCases): FastifyPluginAsyncZod { return async function usersRoutes(app) { app.get( '/users/me', - { - preHandler: authenticate, - schema: { security: [{ bearerAuth: [] }], response: { 200: profileResponseSchema } }, - }, + { preHandler: authenticate, schema: { response: { 200: profileResponseSchema } } }, async (request, reply) => { - const profile = await useCases.getMyProfile({ userId: requireUser(request).id }); + const profile = await useCases.getMyProfile({ userId: requireUserId(request) }); void reply.status(200).send( ok({ ...profile, @@ -62,12 +71,9 @@ export function createUsersRoutes(useCases: UsersUseCases): FastifyPluginAsyncZo app.get( '/users/me/wallets', - { - preHandler: authenticate, - schema: { security: [{ bearerAuth: [] }], response: { 200: listWalletsResponseSchema } }, - }, + { preHandler: authenticate, schema: { response: { 200: listWalletsResponseSchema } } }, async (request, reply) => { - const wallets = await useCases.listWallets({ userId: requireUser(request).id }); + const wallets = await useCases.listWallets({ userId: requireUserId(request) }); void reply.status(200).send(ok(wallets.map(serializeWallet))); }, ); @@ -77,14 +83,13 @@ export function createUsersRoutes(useCases: UsersUseCases): FastifyPluginAsyncZo { preHandler: authenticate, schema: { - security: [{ bearerAuth: [] }], body: requestChallengeBodySchema, response: { 200: requestChallengeResponseSchema }, }, }, async (request, reply) => { const result = await useCases.requestWalletLinkChallenge({ - userId: requireUser(request).id, + userId: requireUserId(request), address: request.body.address, }); void reply.status(200).send(ok(result)); @@ -95,15 +100,11 @@ export function createUsersRoutes(useCases: UsersUseCases): FastifyPluginAsyncZo '/users/me/wallets/confirm', { preHandler: authenticate, - schema: { - security: [{ bearerAuth: [] }], - body: confirmWalletBodySchema, - response: { 200: walletResponseSchema }, - }, + schema: { body: confirmWalletBodySchema, response: { 200: walletResponseSchema } }, }, async (request, reply) => { const wallet = await useCases.confirmWalletLink({ - userId: requireUser(request).id, + userId: requireUserId(request), ...request.body, }); void reply.status(200).send(ok(serializeWallet(wallet))); @@ -114,15 +115,11 @@ export function createUsersRoutes(useCases: UsersUseCases): FastifyPluginAsyncZo '/users/me/wallets/:id', { preHandler: authenticate, - schema: { - security: [{ bearerAuth: [] }], - params: walletIdParamsSchema, - response: { 200: emptyDataResponseSchema }, - }, + schema: { params: walletIdParamsSchema, response: { 200: emptyDataResponseSchema } }, }, async (request, reply) => { await useCases.unlinkWallet({ - userId: requireUser(request).id, + userId: requireUserId(request), walletId: request.params.id, }); void reply.status(200).send(ok({})); diff --git a/src/modules/users/interface/schemas.ts b/src/modules/users/interface/schemas.ts index 6b4674d..95c654d 100644 --- a/src/modules/users/interface/schemas.ts +++ b/src/modules/users/interface/schemas.ts @@ -1,5 +1,7 @@ import { z } from 'zod'; -import { stellarAddress } from '../../../shared/validation/stellar-address.js'; + +/** Stellar (Soroban) public key: 'G' + 55 base32 characters. */ +const stellarAddress = z.string().regex(/^G[A-Z2-7]{55}$/, 'Not a valid Stellar public key'); const walletDto = z.object({ id: z.string().uuid(), diff --git a/src/shared/errors/error-handler.ts b/src/shared/errors/error-handler.ts index a891661..9cb5a5d 100644 --- a/src/shared/errors/error-handler.ts +++ b/src/shared/errors/error-handler.ts @@ -95,6 +95,25 @@ export function handleError( return; } + // Fastify's schema validation (fastify-type-provider-zod's validatorCompiler) + // does not throw a bare ZodError for route body/query/params validation — + // it wraps failures into a FastifyError carrying a `.validation` array and + // `code: 'FST_ERR_VALIDATION'`. Normalized here to the same VALIDATION_ERROR + // shape as the ZodError branch above, so API consumers see one consistent + // code regardless of which path a validation failure took. + const validationError = error as FastifyError; + if (Array.isArray(validationError.validation)) { + const body: ErrorResponseBody = { + error: { + code: 'VALIDATION_ERROR', + message: 'Request validation failed', + details: validationError.validation, + }, + }; + void reply.status(400).send(body); + return; + } + if (isPrismaKnownRequestError(error)) { if (error.code === 'P2002') { const body: ErrorResponseBody = { diff --git a/src/shared/events/index.ts b/src/shared/events/index.ts index d6d63a3..ebf151b 100644 --- a/src/shared/events/index.ts +++ b/src/shared/events/index.ts @@ -1,7 +1,4 @@ import { EventEmitter } from 'node:events'; -import type { Logger } from '../logger/index.js'; - -export { parseAddress, parseBigIntId } from './parse.js'; /** * Every durably-stored blockchain event, after decoding, in one shape — @@ -42,24 +39,3 @@ export function onBlockchainEvent(handler: (event: BlockchainEventEnvelope) => v bus.off(CHANNEL, handler); }; } - -/** - * The six-line subscription boilerplate every module's - * infrastructure/event-subscription.ts used to repeat: subscribe to the bus, - * invoke the module's async handler, and catch+log the rejection. - * `onBlockchainEvent`'s callback is synchronous, so the async handler's - * rejection must be caught here rather than becoming an unhandled promise - * rejection — one malformed/unexpected event must not crash the process or - * block subsequent events (docs/EVENT_INDEXER.md's malformed-event handling). - */ -export function subscribeBlockchainEventHandler( - handler: (event: BlockchainEventEnvelope) => Promise, - log: Logger, - errorMessage: string, -): () => void { - return onBlockchainEvent((event) => { - handler(event).catch((error: unknown) => { - log.error({ err: error, event }, errorMessage); - }); - }); -} diff --git a/src/shared/http/index.ts b/src/shared/http/index.ts index 6b9fff6..99d17eb 100644 --- a/src/shared/http/index.ts +++ b/src/shared/http/index.ts @@ -4,4 +4,4 @@ export { default as metricsPlugin } from './plugins/metrics.js'; export { default as healthRoutes } from './routes/health.js'; export { createMetricsRoutes, type MetricsRoutesDeps } from './routes/metrics.js'; export { ok, type SuccessResponse } from './response-envelope.js'; -export { authenticate, requireRole, requireUser } from './plugins/auth-guard.js'; +export { authenticate, requireRole } from './plugins/auth-guard.js'; diff --git a/src/shared/http/plugins/auth-guard.ts b/src/shared/http/plugins/auth-guard.ts index d0d7401..3abd770 100644 --- a/src/shared/http/plugins/auth-guard.ts +++ b/src/shared/http/plugins/auth-guard.ts @@ -45,18 +45,3 @@ export function requireRole(...roles: UserRole[]) { } }; } - -/** - * Narrows `request.user` (populated by {@link authenticate}) to a non-null - * value inside a route handler body, so `request.user.id` is never read - * through a non-null assertion. Unreachable in practice on any route that - * lists `authenticate` in its `preHandler`s — that guard throws before the - * handler runs — but kept as a defence-in-depth check on the authorization - * surface every protected route shares. - */ -export function requireUser(request: FastifyRequest): { id: string; role: UserRole } { - if (!request.user) { - throw new UnauthorizedError('Authentication required'); - } - return request.user; -} diff --git a/src/shared/testing/env.ts b/src/shared/testing/env.ts index 3bd60e8..27dea15 100644 --- a/src/shared/testing/env.ts +++ b/src/shared/testing/env.ts @@ -15,13 +15,3 @@ process.env.DATABASE_URL ??= process.env.REDIS_URL ??= 'redis://localhost:6379'; process.env.JWT_ACCESS_SECRET ??= 'test-only-access-secret-not-for-production-use-0000'; process.env.JWT_REFRESH_SECRET ??= 'test-only-refresh-secret-not-for-production-use-0000'; -// The rate limiter (src/shared/http/plugins/security.ts) is Redis-backed — -// deliberately shared across API instances, which also means every -// `*.integration.spec.ts` file's own `buildApp()` in one `pnpm test` run -// shares the same counter against real Redis, not a fresh one per file. -// The schema default (100/60s, src/shared/config/env.ts) is sized for a -// single real client, not dozens of test files each making several -// requests; left alone, the suite starts intermittently 429-ing its own -// later requests as more integration tests accumulate, unrelated to -// whatever that request was actually testing. -process.env.RATE_LIMIT_MAX ??= '100000'; diff --git a/src/workers/index.ts b/src/workers/index.ts index 0e6c17e..acee9a5 100644 --- a/src/workers/index.ts +++ b/src/workers/index.ts @@ -7,20 +7,6 @@ import { getPrismaClient, disconnectPrisma } from '../shared/database/index.js'; import { disconnectRedis } from '../shared/cache/index.js'; import { disconnectQueueConnection, closeAllQueues } from '../shared/queue/index.js'; import { createIndexerBackgroundWorker, scheduleIndexer } from '../modules/indexer/index.js'; -import { createDeliveriesModule } from '../modules/deliveries/index.js'; -import { createEscrowModule } from '../modules/escrow/index.js'; -import { createFleetModule } from '../modules/fleet/index.js'; -import { createDisputesModule } from '../modules/disputes/index.js'; -import { createReputationModule } from '../modules/reputation/index.js'; -import { - createNotificationsBackgroundWorker, - createNotificationsModule, -} from '../modules/notifications/index.js'; -import { - createFraudDetectionCleanupBackgroundWorker, - createFraudDetectionModule, - scheduleFraudDetectionCleanup, -} from '../modules/fraud-detection/index.js'; const log = logger.child({ process: 'worker' }); @@ -53,42 +39,12 @@ async function writeHeartbeat(): Promise { */ const registerWorkers: Array<() => Worker> = [ () => createIndexerBackgroundWorker(getPrismaClient()), - () => createNotificationsBackgroundWorker(getPrismaClient()), - () => createFraudDetectionCleanupBackgroundWorker(getPrismaClient()), -]; - -/** - * Every module that reacts to blockchain events (`subscribeXEventSync` / - * `subscribeNotificationsEventDispatch`, wired as a side effect inside each - * `createXModule` factory) must have that factory called from *this* - * process, not just `app.ts`'s. The indexer's poll job — the only thing - * that ever calls `publishBlockchainEvent` — runs here, in the worker - * process (`createIndexerBackgroundWorker` above); the in-process event bus - * it publishes to (`shared/events`) is a plain `EventEmitter`, invisible - * across the `api`/`worker` process boundary `docker-compose.yml` actually - * deploys. `app.ts` also constructs every module (for its HTTP routes, - * which *does* need to run there), which harmlessly wires a second, - * never-triggered subscription in that process — redundant, not wrong, - * since the API process never publishes anything. The returned Fastify - * plugins are intentionally discarded here; this process has no HTTP server. - */ -const wireModuleEventSubscriptions: Array<() => void> = [ - () => void createDeliveriesModule(getPrismaClient()), - () => void createEscrowModule(getPrismaClient()), - () => void createFleetModule(getPrismaClient()), - () => void createDisputesModule(getPrismaClient()), - () => void createReputationModule(getPrismaClient()), - () => void createNotificationsModule(getPrismaClient()), - () => void createFraudDetectionModule(getPrismaClient()), ]; async function main(): Promise { // Repeatable job registration is idempotent (BullMQ upserts by // name+repeat+jobId) — safe to call on every worker-process start. await scheduleIndexer(); - await scheduleFraudDetectionCleanup(); - - wireModuleEventSubscriptions.forEach((wire) => wire()); const workers = registerWorkers.map((register) => register()); diff --git a/vitest.config.ts b/vitest.config.ts index dbdf2cd..e963332 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -4,11 +4,7 @@ export default defineConfig({ test: { environment: 'node', setupFiles: ['./src/shared/testing/env.ts'], - // src-only: the end-to-end suite lives under tests/e2e/ and has its own - // config (`vitest.e2e.config.ts`) + `test:e2e` script, so it runs only - // on a schedule and on release branches (ROADMAP.md §10) — never as - // part of this fast per-PR run. - include: ['src/**/*.{spec,test}.ts'], + include: ['src/**/*.{spec,test}.ts', 'tests/**/*.{spec,test}.ts'], coverage: { provider: 'v8', reporter: ['text', 'lcov'],