|
1 | 1 | ## Summary |
2 | 2 |
|
3 | | -This PR hardens the shared query parsing middleware used by list endpoints against unsafe query input. |
| 3 | +Adds bounded pagination and per-row error isolation to the `replayForVault` function, preventing a single replay request from holding an HTTP connection open indefinitely on high-activity vaults. |
4 | 4 |
|
5 | | -## What changed |
| 5 | +Closes #1118 |
6 | 6 |
|
7 | | -- Added explicit validation to reject prototype-pollution style keys such as `__proto__`, `constructor`, and `prototype`. |
8 | | -- Rejected malformed or unsupported query parameters before they can be parsed into filter/sort/pagination state. |
9 | | -- Tightened pagination and sort parsing to fail fast on invalid numeric values or unsupported sort order values. |
10 | | -- Added regression tests covering rejection of unsafe query keys and acceptance of valid filters/sort/pagination input. |
| 7 | +## Problem |
11 | 8 |
|
12 | | -## Why |
| 9 | +`replayForVault` in `src/services/outboxRelay.ts` (the admin-triggered vault outbox replay path, invoked from `POST /api/admin/vaults/:id/replay-events`) queried every matching historical outbox row with **no `.limit()` clause**: |
13 | 10 |
|
14 | | -The previous middleware accepted a broad set of query keys and did not guard against polluted or malformed input. That made it possible for unexpected query parameters to influence request handling and could create edge cases in downstream filtering logic. |
| 11 | +```typescript |
| 12 | +const rows = await db('vault_outbox') |
| 13 | + .whereRaw("payload->'data'->>'vaultId' = ?", [vaultId]) |
| 14 | + .orderBy('created_at', 'asc') // ← no .limit() |
| 15 | +``` |
15 | 16 |
|
16 | | -## Impact |
| 17 | +For a long-lived, high-activity vault with thousands of historical outbox rows, a single replay request could: |
| 18 | +1. Hold the HTTP response open for an unbounded duration |
| 19 | +2. Hammer the target webhook endpoint sequentially with no rate limiting or batching |
| 20 | +3. Abort the entire batch if any single dispatch threw (the original code had no per-row error handling) |
17 | 21 |
|
18 | | -- Improves resilience of list/search endpoints against malformed or hostile query payloads. |
19 | | -- Preserves existing supported behavior for valid pagination, filtering, and sorting. |
20 | | -- Adds regression coverage to prevent future regressions in the query parser contract. |
| 22 | +This contrasts with `relayOutboxBatch` in the same file, which processes work in bounded `batchSize` chunks via `SKIP LOCKED` with proper per-row error handling and dead-letter routing. |
| 23 | + |
| 24 | +## Changes |
| 25 | + |
| 26 | +### `src/services/outboxRelay.ts` |
| 27 | + |
| 28 | +- **Added `DEFAULT_REPLAY_BATCH_SIZE`** (200) and **`MAX_REPLAY_BATCH_SIZE`** (500) constants |
| 29 | +- **Added `ReplayForVaultResult`** interface returning `{ count, hasMore }` instead of a bare number |
| 30 | +- **Refactored `replayForVault`** to accept `limit` and `offset` parameters (with sensible defaults): |
| 31 | + - Uses `limit + 1` query pattern to detect whether more pages exist without a separate `COUNT` query |
| 32 | + - Clamps inputs: `limit` to `[1, 500]`, `offset` to `[0, ∞)` |
| 33 | + - Isolates each dispatch in a `try/catch` so one failing delivery does not abort the remaining events in the batch |
| 34 | + - Logs failures via `console.error` but continues processing |
| 35 | + - Returns `{ count: number, hasMore: boolean }` |
| 36 | + |
| 37 | +### `src/routes/adminWebhooks.ts` |
| 38 | + |
| 39 | +- **Imported `DEFAULT_REPLAY_BATCH_SIZE` and `MAX_REPLAY_BATCH_SIZE`** from `outboxRelay` |
| 40 | +- **Updated `POST /:id/replay-events`** handler: |
| 41 | + - Accepts optional `limit` (default 200, max 500) and `offset` (default 0) in the request body |
| 42 | + - Validates `limit` does not exceed `MAX_REPLAY_BATCH_SIZE` |
| 43 | + - Passes pagination params through to `replayForVault` |
| 44 | + - Returns pagination metadata (`count`, `has_more`, `limit`, `offset`) in the response |
| 45 | + - Updated audit log metadata to include pagination context |
| 46 | + |
| 47 | +## Usage |
| 48 | + |
| 49 | +**Request** (with pagination): |
| 50 | +```json |
| 51 | +POST /api/admin/vaults/:id/replay-events |
| 52 | +{ |
| 53 | + "subscriber_id": "optional-subscriber-uuid", |
| 54 | + "limit": 200, |
| 55 | + "offset": 0 |
| 56 | +} |
| 57 | +``` |
| 58 | + |
| 59 | +**Response**: |
| 60 | +```json |
| 61 | +{ |
| 62 | + "replayed": true, |
| 63 | + "count": 200, |
| 64 | + "has_more": true, |
| 65 | + "limit": 200, |
| 66 | + "offset": 0 |
| 67 | +} |
| 68 | +``` |
| 69 | + |
| 70 | +Clients should iterate by incrementing `offset` by `limit` until `has_more` is `false`. |
21 | 71 |
|
22 | 72 | ## Testing |
23 | 73 |
|
24 | | -- Added targeted regression tests in `src/tests/queryParser.injection.test.ts`. |
25 | | -- Verified there are no TypeScript/editor errors in the touched files. |
26 | | -- Attempted to run the Jest regression suite, but local execution is currently blocked by the environment’s npm/Node setup. |
| 74 | +- All existing tests continue to pass (`notifications.pagination.test.ts`, `membership.pagination.test.ts`, etc.) |
| 75 | +- The `replayForVault` return type changed from `Promise<number>` to `Promise<ReplayForVaultResult>`; the only caller in `adminWebhooks.ts` has been updated accordingly |
| 76 | +- Per-row error handling prevents one bad dispatch from aborting the entire batch |
0 commit comments