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

Merged
nsatoshi merged 1 commits from devin/1776876189-idempotency into main 2026-04-22 17:18:26 +00: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 17:16:46 +00:00
nsatoshi added 6 commits 2026-04-22 17:16:46 +00:00
PR A: 12-state transaction machine + issueInstrument step + SoD matrix
Some checks failed
Code Quality / SonarQube Analysis (pull_request) Failing after 23s
Code Quality / Code Quality Checks (pull_request) Failing after 11s
Security Scan / Dependency Vulnerability Scan (pull_request) Failing after 4s
Security Scan / OWASP ZAP Scan (pull_request) Failing after 5s
b24a4df983
Architecture note steps 1, 2, 10 (data model).

- types/transactionState.ts: 12 states, allowed-transition table, SoD matrix
- types/plan.ts: add InstrumentTerms + 'issueInstrument' PlanStep type
- services/planValidation.ts: validate SBLC step (BIC, ISO-4217, sha256,
  YYYY-MM-DD expiry, >0 amount)
- services/stateMachine.ts: transition() enforces legality + SoD + appends
  to transaction_state_transitions
- db/migrations/002: plans.transaction_state (CHECK) +
  transaction_state_transitions append-only table
- tests/unit: 13 + 8 unit tests (31 total, all pass)

No behaviour change yet: coordinator still uses legacy status field.
PRs B-G will migrate execution paths onto the new machine.
- services/exceptionManager.ts: single taxonomy (timing/data/control/
  business/system) with §12 codes, deterministic route() table, and
  handle() dispatch to retry/DLQ/escalate
- services/execution.ts: refactor executePlan to drive the full 12-state
  machine (DRAFT -> INITIATED -> ... -> VALIDATING -> COMMITTED -> CLOSED)
  via stateMachine.transition(), with a new validatePhase() that
  reconciles DLT tx hash + bank message id + per-step amounts before
  COMMIT; SoD-gated edges use distinct synthetic actors by default
- api/plans.ts + index.ts: GET /api/plans/:planId/state returning
  current transaction_state + full audit trail of transitions
- tests/unit/exceptionManager.test.ts: 14 tests for classification +
  routing matrix

59 tests pass. tsc clean.
PR C: wire real NotaryRegistry contract on Chain 138 (arch step 4)
Some checks failed
Code Quality / SonarQube Analysis (pull_request) Failing after 20s
Code Quality / Code Quality Checks (pull_request) Failing after 8s
Security Scan / Dependency Vulnerability Scan (pull_request) Failing after 3s
Security Scan / OWASP ZAP Scan (pull_request) Failing after 4s
5bd6a200c3
- services/notaryChain.ts: new ethers-v6 adapter speaking to the
  deployed NotaryRegistry.sol via CHAIN_138_RPC_URL +
  NOTARY_REGISTRY_ADDRESS + ORCHESTRATOR_PRIVATE_KEY. Exposes
  anchorPlan(plan) -> { mode, txHash, planHash, blockNumber } and
  finalizeAnchor(planId, success) -> { mode, txHash, receiptHash }
  with deterministic mock fallback when envs are absent.
- services/notary.ts: refactored to delegate to notaryChain; preserves
  the prior signature and returns extra on-chain fields (mode, txHash,
  blockNumber, contractAddress) when the anchor lands.
- config/env.ts: add CHAIN_138_RPC_URL, CHAIN_138_CHAIN_ID,
  NOTARY_REGISTRY_ADDRESS, ORCHESTRATOR_PRIVATE_KEY (all optional,
  validated via regex where applicable).
- package.json: add ethers@^6.11.0 dependency.
- tests/unit/notaryChain.test.ts: 6 tests covering deterministic
  hashing helpers and the mock fallback path.

tsc clean. 51 tests pass (45 pre-existing + 6 new).
- db/migrations/003_events.ts: append-only events table with
  payload_hash, prev_hash, HMAC signature, indexed by plan_id + type
- services/eventBus.ts: EVENT_TYPES union (all 15 arch §7.2
  categories), publish() with hash-chain + HMAC signing, verifyChain()
  for tamper detection, subscribe() via in-process EventEmitter
- api/plans.ts:
    - GET /api/plans/:planId/events (?verify=1 returns chain_valid)
    - GET /api/plans/:planId/events/stream (SSE with history replay +
      live push, 15s keep-alive, clean unsubscribe on client disconnect)
- index.ts: register the two new endpoints
- tests/unit/eventBus.test.ts: 9 tests covering publish, hash chain,
  per-plan isolation, and three tamper-detection scenarios (payload,
  signature, prev_hash)

60 tests pass. tsc clean.
Outbound generators:
- swift/mt760.ts: SBLC issuance (FIN Cat-7). 12-tag message built from
  InstrumentTerms with deterministic messageHash() for planHash
  anchoring. URDG 758 / UCP 600 aware.
- swift/pacs009.ts: FI-to-FI credit transfer (ISO 20022 XML,
  pacs.009.001.08). Fixes the pacs.008 mis-routing flagged in the
  gap-analysis (pacs.008 is customer-to-bank; pacs.009 is bank-to-bank).
  BIC validation on all four agents.
- swift/mt202.ts: FIN equivalent of pacs.009 for non-migrated corridors.
  32A amount formatted with SWIFT decimal comma.

Inbound parsers:
- swift/camt.ts: parseCamt025 (receipt / status), parseCamt054
  (credit/debit notification), reconcileCamt054 (diffs amount, ccy,
  direction, endToEndId so VALIDATING can feed mismatches into
  Data.valueMismatch()), parseCamt dispatcher on xmlns marker.

Public surface in swift/index.ts documents channel selection:
pacs.008 stays on the PSP customer leg; pacs.009/MT202 is the
interbank leg; COMMIT requires camt.025 ACSC or camt.054 CRDT
(arch §9.2 accepted !== settled).

Tests: swift.test.ts — 14 cases covering the happy path, validation
errors (bad BIC, malformed date, negative amount, missing pay step),
determinism of messageHash, camt parser + reconciliation.

tsc clean. 74 tests pass across 6 suites.
Adds an Idempotency-Key middleware backed by a new idempotency_keys
table. Covers arch \u00a713 security (replay protection) and \u00a715
non-functional requirement (idempotent event handling, resilience to
duplicate messages).

Middleware (src/middleware/idempotency.ts):
  - Mounted on POST /api/plans and POST /api/plans/:planId/execute.
  - Key format /^[A-Za-z0-9_\-:.]{8,255}$/; malformed -> 400.
  - On hit, replays cached status+body with Idempotent-Replayed: true.
  - Reuse with a different body hash -> 422 idempotency_key_reused.
  - Scopes by (method, path, key) so the same key is safe across
    unrelated endpoints.
  - Only 2xx is cached. Non-2xx stays retryable.
  - res.json() is shimmed so handlers need no changes.
  - Fail-open on dedup-store unavailability (warn log).

Migration 004 (db/migrations/004_idempotency_keys.ts):
  - idempotency_keys(method, path, key, request_hash, status_code,
    response_body, created_at, expires_at) with UNIQUE(method,path,key)
    and an expires_at index. 24h TTL.

Tests: tests/unit/idempotency.test.ts \u2014 6 cases covering no-header
pass-through, malformed-key 400, replay on second call, 422 on body
divergence, retryable non-2xx, (method,path,key) scoping.

tsc clean. 80 tests pass across 7 suites.
nsatoshi force-pushed devin/1776876189-idempotency from 7bcc4e38c6 to d3d77c9086 2026-04-22 17:17:27 +00:00 Compare
nsatoshi force-pushed devin/1776876189-idempotency from d3d77c9086 to 3650415d02 2026-04-22 17:18:15 +00:00 Compare
nsatoshi merged commit 3ef71332dc into main 2026-04-22 17:18:26 +00:00
nsatoshi deleted branch devin/1776876189-idempotency 2026-04-22 17:18:27 +00:00
Sign in to join this conversation.
No Reviewers
No Label
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: d-bis/CurrenciCombo#10