Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,25 @@ RATE_LIMIT_GROUP=10
RATE_LIMIT_GROUP_WINDOW_MS=60000
RATE_LIMIT_HISTORY=30
RATE_LIMIT_HISTORY_WINDOW_MS=60000
# Session teardown. Keyed by IP, because POST /auth/logout deliberately answers
# `ok` for an absent or invalid token — it must not be an oracle for token
# validity — while still revoking refresh tokens and writing an audit row.
RATE_LIMIT_AUTH_LOGOUT_MAX=20
RATE_LIMIT_AUTH_LOGOUT_WINDOW_MS=60000
# Editing or deleting an expense rewrites every other participant's
# obligations. Its own bucket, so correcting a typo never exhausts the budget
# that guards opening new obligations.
RATE_LIMIT_EXPENSE_MUTATE_MAX=30
RATE_LIMIT_EXPENSE_MUTATE_WINDOW_MS=60000
# Treasury signer configuration (enable, validate-signers): each call reads
# live account state from Horizon, and both decide who may move group funds.
RATE_LIMIT_TREASURY_CONFIG_MAX=10
RATE_LIMIT_TREASURY_CONFIG_WINDOW_MS=60000
# Firing a test webhook turns one inbound request into an outbound delivery on
# the same retry schedule as a real event, so this is the tightest write
# budget — a test fire confirms wiring once, never in a loop.
RATE_LIMIT_WEBHOOK_TEST_MAX=5
RATE_LIMIT_WEBHOOK_TEST_WINDOW_MS=60000

# Inbound SEP-24 anchor callbacks (POST /api/webhooks/sep24).
#
Expand Down
14 changes: 14 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,20 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Added

- **Rate-limit tiers for the sensitive routes still on the global bucket (#682, #693)** — Four new policies in `src/lib/rate-limit.ts`, each with its own key prefix and env-tunable budget: `authLogout` (`POST /auth/logout`, IP-keyed — the route answers `ok` without a valid token, so the revoke-and-audit path is reachable anonymously), `expenseMutate` (`PATCH`/`DELETE /expenses/:id`, which rewrite every participant's obligations while only creation was budgeted), `treasuryConfig` (`POST /groups/:id/treasury/enable` and `.../validate-signers`, one Horizon account read per call), and `webhookTest` (`POST /groups/:groupId/webhooks/:webhookId/test`, which turns one inbound request into an outbound delivery with retries). `GET /withdraw/:id` now takes the existing `anchorPoll` budget — it was the one anchor-transaction read left unbudgeted.
- **Filtering on the group transaction-history endpoints (#692)** — `GET /groups/:id/settlements` takes `status` (the public settlement status, including the two values derived from the signing deadline rather than stored), `asset`, `startDate`/`endDate`, and `includeTotal`; `GET /groups/:id/ledger` takes `type` (which selects which of the three tables are read at all) and a date range; `GET /groups/:groupId/webhooks/:webhookId/deliveries` takes `status` and `event`. Every filter is pushed into the query rather than applied to a fetched page, so pages stay full and `hasMore` stays exact.
- **Non-throwing signed-XDR inspection (#694)** — `src/services/xdr-inspection.ts` reports which field of an envelope diverged from a stored intent (`{ ok: false, code, field, message }`) instead of raising, for callers that need to classify an envelope rather than reject a request. It composes the same primitives the submission path uses, so there is one description of what "matches the intent" means.

### Changed

- **Cursor pagination on the two unbounded list endpoints (#692)** — `GET /groups/:groupId/webhooks/:webhookId/deliveries` and `GET /groups/:groupId/webhooks` answered with unbounded `findMany` queries. Both now use the shared `(createdAt, id)` cursor contract and return standard `meta`. The delivery list also stops returning the stored request payload and the receiver's response body, which were never appropriate for a list response.

### Removed

- **The duplicate XDR intent validator in `src/utils/stellar-xdr.ts` (#694)** — `validateTransactionIntent` was a second, weaker copy of the submission path's rules: it never verified signatures or sequence numbers, compared amounts as raw strings (so `"10"` did not match `"10.0000000"`), and reported a fee-bump envelope as "must contain exactly one operation". It had no callers outside its own test. `isValidXdr`, which production code does use, is unchanged.

### Verified

- **Debt simplification test coverage (#685)** — Comprehensive test suites already verify greedy debt-simplification algorithm across circular debts (balanced/uneven rings, bidirectional netting), zero-balance members, unequal shares, and multi-asset segregation. 61 tests across `tests/settlement.test.ts` (lines 287-454) and `tests/multi-asset-settlement.test.ts` enforce mathematical completeness and (n-1) transfer bound.
Expand Down
45 changes: 42 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -320,18 +320,22 @@ traffic pattern or trust boundary replace that default with their own bucket:
| --- | --- | --- |
| `POST /auth/challenge` | `RATE_LIMIT_AUTH_CHALLENGE_MAX` / `_WINDOW_MS` | 20 / 1 min |
| `POST /auth/verify`, `POST /auth/refresh` | `RATE_LIMIT_AUTH_VERIFY_MAX` / `_WINDOW_MS` | 10 / 1 min |
| `POST /auth/logout` | `RATE_LIMIT_AUTH_LOGOUT_MAX` / `_WINDOW_MS` | 20 / 1 min |
| `POST /groups/:id/expenses` | `RATE_LIMIT_EXPENSE_CREATE_MAX` / `_WINDOW_MS` | 30 / 1 min |
| `PATCH /expenses/:id`, `DELETE /expenses/:id` | `RATE_LIMIT_EXPENSE_MUTATE_MAX` / `_WINDOW_MS` | 30 / 1 min |
| `POST /expenses/:id/settle`, `POST /groups/:id/settlements`, `POST /groups/:id/treasury/deposit`, `POST /groups/:id/treasury/withdraw` | `RATE_LIMIT_SETTLEMENT_CREATE_MAX` / `_WINDOW_MS` | 20 / 1 min |
| `POST /settlements/:id/confirm`, `POST /withdraw/:id/confirm` | `RATE_LIMIT_SETTLEMENT_CONFIRM_MAX` / `_WINDOW_MS` | 20 / 1 min |
| `POST /api/v1/settlements/execute` | `RATE_LIMIT_SETTLEMENT_EXECUTE_MAX` / `_WINDOW_MS` | 20 / 1 min |
| `POST /treasury-transactions/:id/confirm`, `POST /groups/:groupId/treasury/proposals/:proposalId/sign`, `POST /api/v1/treasury/proposals/:id/signatures` | `RATE_LIMIT_TREASURY_SUBMIT_MAX` / `_WINDOW_MS` | 30 / 1 min |
| `POST /groups/:groupId/treasury/proposals`, `POST /api/v1/treasury/proposals` | `RATE_LIMIT_TREASURY_PROPOSE_MAX` / `_WINDOW_MS` | 20 / 1 min |
| `POST /groups/:id/treasury/enable`, `POST /groups/:id/treasury/validate-signers` | `RATE_LIMIT_TREASURY_CONFIG_MAX` / `_WINDOW_MS` | 10 / 1 min |
| `POST /anchors/deposit`, `POST /anchors/withdraw`, `POST /anchors/sessions/:id/complete`, `POST /api/v1/sep24/deposit`, `POST /api/v1/sep24/withdraw`, `POST /withdraw` | `RATE_LIMIT_ANCHOR_INIT_MAX` / `_WINDOW_MS` | 10 / 1 min |
| `GET /anchors`, `GET /anchors/sessions`, `GET /anchors/sessions/:id` | `RATE_LIMIT_ANCHOR_POLL_MAX` / `_WINDOW_MS` | 60 / 1 min |
| `GET /anchors`, `GET /anchors/sessions`, `GET /anchors/sessions/:id`, `GET /api/v1/sep24/transaction`, `GET /api/v1/sep24/transactions`, `GET /withdraw/:id` | `RATE_LIMIT_ANCHOR_POLL_MAX` / `_WINDOW_MS` | 60 / 1 min |
| `POST /anchors/webhook` | `RATE_LIMIT_ANCHOR_WEBHOOK_MAX` / `_WINDOW_MS` | 50 / 1 min |
| `POST /api/v1/sep24/callback`, `POST /api/v1/webhooks/sep24` | `SEP24_RATE_LIMIT_MAX` / `_WINDOW_MS` | 10 / 1 min |
| `POST /groups` | `RATE_LIMIT_GROUP` / `_WINDOW_MS` | 10 / 1 min |
| `GET /history` | `RATE_LIMIT_HISTORY` / `_WINDOW_MS` | 30 / 1 min |
| `POST /groups/:groupId/webhooks/:webhookId/test` | `RATE_LIMIT_WEBHOOK_TEST_MAX` / `_WINDOW_MS` | 5 / 1 min |

**Tuning a deployment.** Every value above is an environment variable with a
safe default, so a deployment overrides only what it needs — for example a
Expand Down Expand Up @@ -631,11 +635,46 @@ ordering coordinates, never a group or user id: access is decided by each
query's own scope plus its membership check, so a cursor from one resource
replayed against another cannot widen what the caller can read.

Covers `GET /groups`, `/groups/:id/expenses`, `/groups/:id/ledger`,
`/groups/:id/treasury/history`, `/anchors/sessions`, and `/history` (which
Covers `GET /groups`, `/groups/:id`, `/groups/:id/expenses`,
`/groups/:id/settlements`, `/groups/:id/ledger`,
`/groups/:id/treasury/history`, `/groups/:groupId/treasury/proposals`,
`/groups/:groupId/webhooks`, `/groups/:groupId/webhooks/:webhookId/deliveries`,
`/anchors/sessions`, `/api/v1/sep24/transactions`, and `/history` (which
paginates its expense and settlement streams independently, via `cursor` and
`settlementCursor`).

### Filtering the transaction history

The history endpoints take filters alongside the pagination parameters above.
Filters are pushed into the database query, never applied to an already-fetched
page: a filter applied after the fact would return pages shorter than the
requested `limit` and make `hasMore` describe the unfiltered scan.

| Endpoint | Filters |
| --- | --- |
| `GET /groups/:id/expenses` | `asset` (`XLM`\|`USDC`), `status` (`PENDING`\|`SETTLED`), `startDate`, `endDate`, `includeTotal` |
| `GET /groups/:id/settlements` | `status` (`awaiting_signature`\|`submitted`\|`confirmed`\|`failed`\|`expired`), `asset`, `startDate`, `endDate`, `includeTotal` |
| `GET /groups/:id/ledger` | `type` (one or more of `expense`,`settlement`,`treasury`), `startDate`, `endDate` |
| `GET /groups/:groupId/webhooks/:webhookId/deliveries` | `status` (`pending`\|`delivered`\|`failed`), `event` |
| `GET /groups/:groupId/audit-logs` | `action`, `actorUserId`, `startDate`, `endDate` |

Notes that matter when integrating:

- **Dates** are ISO-8601 with an offset, and both bounds are inclusive. A
reversed range is `INVALID_RANGE`, not an empty page.
- **Unknown filter values** are `VALIDATION_ERROR`. A typo never silently
returns nothing.
- **`includeTotal=true`** adds `meta.total`, the count of the *filtered* set
ignoring the cursor. It is opt-in because counting is a second query over
every matching row, which the cursor contract otherwise never pays for.
- **`status` on settlements** filters by the same public status the settlement
status endpoint reports, including the two values derived from the signing
deadline rather than stored (`expired`, and `submitted` vs
`awaiting_signature`). The five values partition the history: every
settlement matches exactly one.
- **`type` on the ledger** decides which tables are read at all, so
`?type=expense` issues one query instead of three.

## TypeScript strict mode

The whole of `src/` compiles under TypeScript's [`strict`](https://www.typescriptlang.org/tsconfig/#strict) flag — see [tsconfig.json](tsconfig.json) for the exact configuration. `strict` turns on every strict type-checking family at once (`strictNullChecks`, `noImplicitAny`, `strictFunctionTypes`, `strictBindCallApply`, `strictPropertyInitialization`, `noImplicitThis`, `alwaysStrict`, and `useUnknownInCatchVariables`), so `null`/`undefined` flows, implicit `any`s, and unbound `this` are compile errors rather than production incidents.
Expand Down
75 changes: 73 additions & 2 deletions docs/api-contract.md
Original file line number Diff line number Diff line change
Expand Up @@ -82,8 +82,10 @@ name go to the log instead.

## Pagination

Applies to `GET /groups`, `/groups/:id/expenses`, `/groups/:id/settlements`, `/groups/:id/ledger`,
`/groups/:id/treasury/history`, `/anchors/sessions`, `/api/v1/sep24/transactions`, and `/history`.
Applies to `GET /groups`, `/groups/:id`, `/groups/:id/expenses`, `/groups/:id/settlements`,
`/groups/:id/ledger`, `/groups/:id/treasury/history`, `/groups/:groupId/treasury/proposals`,
`/groups/:groupId/webhooks`, `/groups/:groupId/webhooks/:webhookId/deliveries`,
`/anchors/sessions`, `/api/v1/sep24/transactions`, and `/history`.
Defined in [../src/lib/pagination.ts](../src/lib/pagination.ts).

### Query parameters
Expand Down Expand Up @@ -139,6 +141,61 @@ exactly like an unfiltered one.
The count behind `includeTotal` covers the filters but not the cursor, so the
total stays stable while a client pages through the result.

### Settlement list filters (`GET /groups/:id/settlements`)

Defined in [../src/services/settlement-history.ts](../src/services/settlement-history.ts).
Same shape as the expense filters above, and the same composition with the
cursor.

| Parameter | Type | Notes |
| --- | --- | --- |
| `status` | one of the five public settlement statuses | Filters by the value [`GET /settlements/:id/status`](#get-settlementsidstatus) reports for the same row, not by the internal column |
| `asset` | `"XLM"` \| `"USDC"` | Filter by asset code. Other codes are a `VALIDATION_ERROR` |
| `startDate` / `endDate` | ISO 8601 datetime | Inclusive `createdAt` bounds. A reversed range is `INVALID_RANGE` |
| `includeTotal` | `"true"` \| `"false"` | Opt-in `meta.total` over the filtered set, ignoring the cursor |

Two of the five public statuses are *derived* from the signing deadline rather
than stored — a row at `pending` or `submitted` past its `expiresAt` reports as
`expired` — so the filter is written as the exact inverse of that mapping,
including its clock-skew tolerance. Consequences a client can rely on:

- the five values **partition** the history: every settlement matches exactly
one, so no row is unreachable and none appears under two filters;
- a filtered row reports the **same status** when read individually through
`GET /settlements/:id/status`.

### Ledger filters (`GET /groups/:id/ledger`)

Defined in [../src/services/ledger.ts](../src/services/ledger.ts). The ledger
interleaves expenses, settlements, and treasury transactions into one
time-ordered feed.

| Parameter | Type | Notes |
| --- | --- | --- |
| `type` | one or more of `expense`, `settlement`, `treasury` | Comma-separated (`?type=expense,settlement`). Selects which tables are read at all, so one kind means one query instead of three. An unknown kind is a `VALIDATION_ERROR` |
| `startDate` / `endDate` | ISO 8601 datetime | Inclusive `createdAt` bounds, pushed into every table read. A reversed range is `INVALID_RANGE` |

Both filters are applied inside each table's query rather than to the merged
page. That is what keeps pages full: each table is read with its own
`limit + 1` window, so dropping rows after the merge would return short pages
and make `hasMore` describe the unfiltered scan.

### Webhook delivery filters (`GET /groups/:groupId/webhooks/:webhookId/deliveries`)

Defined in [../src/services/webhook-listing.ts](../src/services/webhook-listing.ts).

| Parameter | Type | Notes |
| --- | --- | --- |
| `status` | `"pending"` \| `"delivered"` \| `"failed"` | Delivery state recorded by the worker |
| `event` | a webhook event type | Filter to one event type |

A delivery row also stores the exact signed request body and the receiver's
response text. Neither is returned by this endpoint: the payload is the group's
own data re-served through a different authorization path, and the response body
is third-party text. The list returns `id`, `webhookId`, `eventType`, `status`,
`attempts`, `responseStatusCode`, `nextAttemptAt`, `lastAttemptAt`, `createdAt`,
and `updatedAt`.

---

## Idempotency
Expand Down Expand Up @@ -267,6 +324,20 @@ response or in logs. A rejected transaction is never submitted and never
advances the settlement's status — the settlement stays awaiting a signature and
the wallet can sign the correct envelope instead.

The same checks, in the same order, are available as a **non-throwing
inspection** in
[../src/services/xdr-inspection.ts](../src/services/xdr-inspection.ts), for
callers that want the diverging field reported rather than raised —
`inspectSignedTransaction` returns `{ ok: true, tx, hash, source, sequence }` or
`{ ok: false, code, field, message }`, where `code` is one of `xdr_malformed`,
`xdr_mismatch`, `intent_expired`, or `xdr_unsigned`, and `field` names the part
of the envelope that diverged. It adds no rules of its own: it composes the same
primitives the submission path uses, so there is exactly one description of what
"matches the intent" means. Signature verification is on by default and can only
be waived explicitly, for the paths where authorship is established elsewhere
(an unsigned intent read back, or a multisig proposal whose shared source
account never signs with its own key).

---

## `GET /settlements/:id/status`
Expand Down
28 changes: 28 additions & 0 deletions src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -414,6 +414,34 @@ const schema = z.object({
// they share with every read the same client makes.
RATE_LIMIT_UPLOAD_RECEIPT_MAX: z.coerce.number().int().positive().max(100000).default(20),
RATE_LIMIT_UPLOAD_RECEIPT_WINDOW_MS: z.coerce.number().int().positive().max(MAX_RATE_LIMIT_WINDOW_MS).default(60000),
// Issues #682 / #693 — the remaining sensitive routes that were still
// answering from the global bucket. Each is budgeted for the work *it*
// causes, not for the size of its response body.
//
// Logout revokes every refresh token a user holds, and the route answers
// `{ ok: true }` whether or not the bearer token was valid — deliberately,
// so it is not an oracle for token validity, but that also means an
// anonymous caller can drive the write path. Keyed by IP, like the other
// pre-session auth routes.
RATE_LIMIT_AUTH_LOGOUT_MAX: z.coerce.number().int().positive().max(100000).default(20),
RATE_LIMIT_AUTH_LOGOUT_WINDOW_MS: z.coerce.number().int().positive().max(MAX_RATE_LIMIT_WINDOW_MS).default(60000),
// Editing or deleting an expense rewrites the obligations of every other
// participant. `RATE_LIMIT_EXPENSE_CREATE_*` bounded only creation, so the
// mutations that follow it drew on the global allowance instead.
RATE_LIMIT_EXPENSE_MUTATE_MAX: z.coerce.number().int().positive().max(100000).default(30),
RATE_LIMIT_EXPENSE_MUTATE_WINDOW_MS: z.coerce.number().int().positive().max(MAX_RATE_LIMIT_WINDOW_MS).default(60000),
// Treasury signer configuration: enabling a treasury and validating a
// proposed signer set both read live account state from Horizon, so each
// call spends upstream budget, and both decide who may move group funds.
// Tight, because neither is an operation a client repeats in bursts.
RATE_LIMIT_TREASURY_CONFIG_MAX: z.coerce.number().int().positive().max(100000).default(10),
RATE_LIMIT_TREASURY_CONFIG_WINDOW_MS: z.coerce.number().int().positive().max(MAX_RATE_LIMIT_WINDOW_MS).default(60000),
// Firing a test webhook turns one inbound request into an outbound HTTP
// delivery to a caller-registered URL, on the same retry schedule as a real
// event. `RATE_LIMIT_WEBHOOK_CREATE_*` bounded registering an endpoint but
// not ringing it, which is the cheaper half of the same amplification.
RATE_LIMIT_WEBHOOK_TEST_MAX: z.coerce.number().int().positive().max(100000).default(5),
RATE_LIMIT_WEBHOOK_TEST_WINDOW_MS: z.coerce.number().int().positive().max(MAX_RATE_LIMIT_WINDOW_MS).default(60000),
// trusted proxies: only trust X-Forwarded-For if the direct peer is in this
// comma-separated list; otherwise Fastify falls back to req.ip = socket remote.
TRUSTED_PROXY_IPS: z.string().default(""),
Expand Down
Loading
Loading