Implements step 9 from the architecture gap-analysis. Closes out arch §13 (replay protection) and §15 (idempotent event handling, resilience to duplicate messages). Stacks on PR E.
What lands
src/middleware/idempotency.ts
Mounted on POST /api/plans and POST /api/plans/:planId/execute — the two write paths. Contract:
No Idempotency-Key header → pass-through.
Malformed key → 400idempotency_key_invalid. Accepted format ^[A-Za-z0-9_\-:.]{8,255}$.
Cache hit (same key, same (method, path), same body hash) → replays cached status + body with Idempotent-Replayed: true.
Same key, different body → 422idempotency_key_reused — catches client bugs where a key is accidentally reused across unrelated requests.
Only 2xx responses are cached. 4xx/5xx stays retryable (important: a transient DB failure during plan creation must not permanently poison the key).
Scoped by (method, path, key) so the same key can appear on POST /plans and POST /plans/:id/execute without collision.
res.json() is shimmed — route handlers need no changes.
Fails open if the dedup store is unreachable (warn log); availability of writes wins over perfect dedup.
Base: devin/1776875929-swift-gateway (PR E). Diff here is F-only.
Implements **step 9** from the architecture gap-analysis. Closes out arch §13 (replay protection) and §15 (idempotent event handling, resilience to duplicate messages). Stacks on PR E.
## What lands
### `src/middleware/idempotency.ts`
Mounted on `POST /api/plans` and `POST /api/plans/:planId/execute` — the two write paths. Contract:
- No `Idempotency-Key` header → pass-through.
- Malformed key → **400** `idempotency_key_invalid`. Accepted format `^[A-Za-z0-9_\-:.]{8,255}$`.
- Cache hit (same key, same `(method, path)`, same body hash) → replays cached `status` + `body` with `Idempotent-Replayed: true`.
- Same key, **different** body → **422** `idempotency_key_reused` — catches client bugs where a key is accidentally reused across unrelated requests.
- Only **2xx** responses are cached. 4xx/5xx stays retryable (important: a transient DB failure during plan creation must not permanently poison the key).
- Scoped by `(method, path, key)` so the same key can appear on `POST /plans` and `POST /plans/:id/execute` without collision.
- `res.json()` is shimmed — route handlers need no changes.
- Fails **open** if the dedup store is unreachable (warn log); availability of writes wins over perfect dedup.
### Migration `004_idempotency_keys.ts`
```sql
CREATE TABLE idempotency_keys (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
method VARCHAR(8) NOT NULL,
path VARCHAR(512) NOT NULL,
key VARCHAR(255) NOT NULL,
request_hash CHAR(64) NOT NULL,
status_code INTEGER NOT NULL,
response_body JSONB NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
expires_at TIMESTAMPTZ NOT NULL DEFAULT (CURRENT_TIMESTAMP + INTERVAL '24 hours'),
UNIQUE (method, path, key)
);
CREATE INDEX idx_idempotency_expires_at ON idempotency_keys(expires_at);
```
24h TTL — covers realistic retry windows, keeps the table bounded.
## Tests
`tests/unit/idempotency.test.ts` — 6 cases:
1. no header → next() called, nothing cached
2. malformed key → 400 with `idempotency_key_invalid`
3. first call + 201 response → cached; second call replays status+body with `Idempotent-Replayed: true`
4. reuse with divergent body → 422 `idempotency_key_reused`
5. non-2xx response → **not** cached, retry still passes through
6. same key on a different path → pass-through (correct scoping)
## Verification
```
$ npx tsc --noEmit # clean
$ npx jest # 80 passed, 7 suites
```
## Series order
A → B → C → D → E → **F** → G → H.
Base: `devin/1776875929-swift-gateway` (PR E). Diff here is F-only.
nsatoshi
changed target branch from devin/1776875929-swift-gateway to main2026-04-22 10:16:46 -07:00
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.
Implements step 9 from the architecture gap-analysis. Closes out arch §13 (replay protection) and §15 (idempotent event handling, resilience to duplicate messages). Stacks on PR E.
What lands
src/middleware/idempotency.tsMounted on
POST /api/plansandPOST /api/plans/:planId/execute— the two write paths. Contract:Idempotency-Keyheader → pass-through.idempotency_key_invalid. Accepted format^[A-Za-z0-9_\-:.]{8,255}$.(method, path), same body hash) → replays cachedstatus+bodywithIdempotent-Replayed: true.idempotency_key_reused— catches client bugs where a key is accidentally reused across unrelated requests.(method, path, key)so the same key can appear onPOST /plansandPOST /plans/:id/executewithout collision.res.json()is shimmed — route handlers need no changes.Migration
004_idempotency_keys.ts24h TTL — covers realistic retry windows, keeps the table bounded.
Tests
tests/unit/idempotency.test.ts— 6 cases:idempotency_key_invalidIdempotent-Replayed: trueidempotency_key_reusedVerification
Series order
A → B → C → D → E → F → G → H.
Base:
devin/1776875929-swift-gateway(PR E). Diff here is F-only.7bcc4e38c6tod3d77c9086d3d77c9086to3650415d02