PR F: Idempotency-Key + replay protection on POST /plans and /execute #10

Merged
nsatoshi merged 0 commits from devin/1776876189-idempotency into main 2026-04-22 10:18:26 -07:00
Owner

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

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.

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 main 2026-04-22 10:16:46 -07:00
nsatoshi force-pushed devin/1776876189-idempotency from 7bcc4e38c6 to d3d77c9086 2026-04-22 10:17:27 -07:00 Compare
nsatoshi force-pushed devin/1776876189-idempotency from d3d77c9086 to 3650415d02 2026-04-22 10:18:15 -07:00 Compare
nsatoshi merged commit 3ef71332dc into main 2026-04-22 10:18:26 -07:00
nsatoshi deleted branch devin/1776876189-idempotency 2026-04-22 10:18:27 -07:00
Sign in to join this conversation.