From 5a2f143b1f1b79bae99c41acab216fb68924a158 Mon Sep 17 00:00:00 2001 From: SteveBot <1153461+unbraind@users.noreply.github.com> Date: Sun, 4 Oct 2026 20:16:25 +0200 Subject: [PATCH 1/6] Bind offline mutations to their account, make keyed retries exactly-once, and pin the rate-limit budgets pm-web-ccie: queued mutations carry the broadcast account, target workspace and an idempotency key created before the first network attempt. Foreign, unknown-owner and logged-out records are never replayed or deleted; they surface MUTATIONS_BLOCKED with a visible explicit recovery action that adopts unknown-owner records for the current account (assigning a key first). The session is re-sent on controllerchange and its IndexedDB write runs under event.waitUntil. Server side, pm_idempotency_keys claims (account, key) atomically, replays stored outcomes, rejects a key reused for a different request, never stores transient 425/429 refusals, answers 409 outcome-unknown instead of re-executing a stale pending key, catches outcome-persist failures, and indexes created_at with a throttled retention sweep. sql/schema.sql and initSchema define the identical table. pm-web-s552 / pm-web-25nv: real-app proofs that one nested request counts once, published budgets hold exactly at the boundary and under concurrent multi-account load, and rotating forged forwarded headers never buys a fresh bucket. Rate limiting runs before the idempotency guard. Every fix was written test-first with real PostgreSQL and real HTTP; review round 1 (Greptile, CodeQL) is addressed. This single commit replaces the earlier branch history so no tracked file records a local filesystem path. pm items: pm-web-ccie, pm-web-s552, pm-web-25nv --- .agents/pm/history/pm-web-25nv.jsonl | 6 + .agents/pm/history/pm-web-ccie.jsonl | 6 + .agents/pm/history/pm-web-s552.jsonl | 6 + .agents/pm/issues/pm-web-25nv.toon | 20 +- .agents/pm/issues/pm-web-ccie.toon | 30 +- .agents/pm/issues/pm-web-s552.toon | 21 +- README.md | 32 ++ dist/app.js | 13 +- dist/app.js.map | 2 +- dist/db.d.ts | 4 +- dist/db.js | 27 +- dist/db.js.map | 2 +- dist/idempotency.d.ts | 57 +++ dist/idempotency.js | 315 +++++++++++++++ dist/idempotency.js.map | 1 + public/src/api.ts | 46 +++ public/src/app.ts | 4 +- public/src/offline-recovery.ts | 40 ++ public/src/sw.ts | 299 +++++++++++++- public/src/views/auth.ts | 4 +- sql/schema.sql | 22 + src/app.ts | 13 +- src/db.ts | 29 +- src/idempotency.ts | 384 ++++++++++++++++++ test/frontend-api.test.ts | 69 +++- test/helpers/sw-queue-preload.ts | 110 ++++- test/idempotency.test.ts | 585 +++++++++++++++++++++++++++ test/oidc.test.ts | 3 +- test/rate-limit.test.ts | 167 ++++++++ test/sw-idempotency-http.test.ts | 67 +++ test/sw-queue.test.ts | 417 ++++++++++++++++++- 31 files changed, 2727 insertions(+), 74 deletions(-) create mode 100644 dist/idempotency.d.ts create mode 100644 dist/idempotency.js create mode 100644 dist/idempotency.js.map create mode 100644 public/src/offline-recovery.ts create mode 100644 src/idempotency.ts create mode 100644 test/idempotency.test.ts create mode 100644 test/sw-idempotency-http.test.ts diff --git a/.agents/pm/history/pm-web-25nv.jsonl b/.agents/pm/history/pm-web-25nv.jsonl index abfc76fd..9eb4e01f 100644 --- a/.agents/pm/history/pm-web-25nv.jsonl +++ b/.agents/pm/history/pm-web-25nv.jsonl @@ -3,3 +3,9 @@ {"ts":"2026-08-28T14:55:08.821Z","author":"claude","author_source":"asserted","agent_harness":"claude-code","agent_instance":"7405dc9b453c8154184ac8fe","agent_provenance":{"model":null,"effort":{"value":"xhigh","source":"environment"},"role":null,"topic":null},"op":"docs_add","patch":[{"op":"add","path":"/metadata/docs/1","value":{"path":"test/rate-limit.test.ts","scope":"project","note":"the header-rotation regression test"}},{"op":"replace","path":"/metadata/updated_at","value":"2026-08-28T14:55:08.821Z"}],"before_hash":"846441eb191ec08ac9f328cc5554f3142f833215921f03f4e87ada6da6aa5f81","after_hash":"5595c324c072d58b67082cd0b8adde3592b6ffeaf400b6df22f44d16ae9c465f","item_hash_version":2,"context":{"agent_provenance_outcomes":{"model":{"status":"failed","reason":"resolver_failed","resolver":"claude_session_file","rule_version":"v1"},"version":{"status":"failed","reason":"resolver_failed","resolver":"claude_session_file","rule_version":"v1"}}}} {"ts":"2026-08-28T14:55:13.653Z","author":"claude","author_source":"asserted","agent_harness":"claude-code","agent_instance":"7405dc9b453c8154184ac8fe","agent_provenance":{"model":null,"effort":{"value":"xhigh","source":"environment"},"role":null,"topic":null},"op":"comment_add","patch":[{"op":"replace","path":"/metadata/updated_at","value":"2026-08-28T14:55:13.653Z"},{"op":"add","path":"/metadata/comments","value":[{"created_at":"2026-08-28T14:55:13.653Z","author":"claude","text":"Revert-checked: restoring the one-hop default drops the new test from passing to failing, so it measures the fix rather than describing it. The complementary test that opts into one hop and asserts the limiter follows the real client behind a proxy still passes, so the proxied behaviour is unchanged. Fifteen tests in the suite pass. Raised by Greptile as a P1 security finding on PR 110."}]}],"before_hash":"5595c324c072d58b67082cd0b8adde3592b6ffeaf400b6df22f44d16ae9c465f","after_hash":"4710256aaf96aa7ab61fbbcad2261ae0915a95581b40b234931b67a5887fa9d5","item_hash_version":2,"context":{"agent_provenance_outcomes":{"model":{"status":"failed","reason":"resolver_failed","resolver":"claude_session_file","rule_version":"v1"},"version":{"status":"failed","reason":"resolver_failed","resolver":"claude_session_file","rule_version":"v1"}}}} {"ts":"2026-08-28T15:15:10.229Z","author":"claude","author_source":"asserted","agent_harness":"claude-code","agent_instance":"7405dc9b453c8154184ac8fe","agent_provenance":{"model":null,"effort":{"value":"xhigh","source":"environment"},"role":null,"topic":null},"op":"tests_add","patch":[{"op":"replace","path":"/metadata/updated_at","value":"2026-08-28T15:15:10.229Z"},{"op":"add","path":"/metadata/tests","value":[{"command":"PM_PATH=/tmp/pmweb-25nv PM_GLOBAL_PATH=/tmp/pmweb-25nv-g node --test --test-name-pattern \"rotated X-Forwarded-For\" test/rate-limit.test.ts","scope":"project","assert_stdout_regex":["pass 1"],"note":"the default must resist header rotation"}]}],"before_hash":"4710256aaf96aa7ab61fbbcad2261ae0915a95581b40b234931b67a5887fa9d5","after_hash":"2c541e922d8a6cc441403ae4d59f8881c75f5a0e05cc2bdb42efcb28ab627ba5","item_hash_version":2,"context":{"agent_provenance_outcomes":{"model":{"status":"failed","reason":"resolver_failed","resolver":"claude_session_file","rule_version":"v1"},"version":{"status":"failed","reason":"resolver_failed","resolver":"claude_session_file","rule_version":"v1"}}}} +{"hash_algorithm":"sha256","ts":"2026-10-04T18:15:53.956Z","author":"claude-orchestrator","author_source":"asserted","agent_harness":"claude-code","agent_model":"claude-opus-5-5","agent_model_source":"probe","agent_instance":"4b273145923a68729ab394ab","agent_provenance":{"model":{"value":"claude-opus-5-5","source":"probe"},"effort":{"value":"high","source":"environment"},"role":{"value":"implementer","source":"argv"},"topic":null,"version":{"value":"2.1.289","source":"probe"}},"op":"claim","patch":[{"op":"replace","path":"/metadata/updated_at","value":"2026-10-04T18:15:53.956Z"},{"op":"add","path":"/metadata/assignee","value":"claude-orchestrator"},{"op":"add","path":"/metadata/claim_principal","value":"claude-orchestrator"}],"before_hash":"2c541e922d8a6cc441403ae4d59f8881c75f5a0e05cc2bdb42efcb28ab627ba5","after_hash":"690fba14b3f322248f331fe362d85db47aaa7ed2c5cef8d872f9ce2ea4594254","item_hash_version":3,"context":{"agent_provenance_outcomes":{"model":{"status":"resolved","resolver":"claude_session_file","rule_version":"v1"},"version":{"status":"resolved","resolver":"claude_session_file","rule_version":"v1"}}},"event_class":"substantive","record_hash_version":1,"record_hash":"90ed463e967d758fc59586658a67519a2a0f4d5cbfa05e60ef812124a5402196"} +{"hash_algorithm":"sha256","ts":"2026-10-04T18:15:55.172Z","author":"claude-orchestrator","author_source":"asserted","agent_harness":"claude-code","agent_model":"claude-opus-5-5","agent_model_source":"probe","agent_instance":"4b273145923a68729ab394ab","agent_provenance":{"model":{"value":"claude-opus-5-5","source":"probe"},"effort":{"value":"high","source":"environment"},"role":{"value":"implementer","source":"argv"},"topic":null,"version":{"value":"2.1.289","source":"probe"}},"op":"update","patch":[{"op":"replace","path":"/metadata/updated_at","value":"2026-10-04T18:15:55.172Z"},{"op":"replace","path":"/metadata/status","value":"in_progress"}],"before_hash":"690fba14b3f322248f331fe362d85db47aaa7ed2c5cef8d872f9ce2ea4594254","after_hash":"1ee1307b11f659fed6dd49e30f768ee3032d5bfe22307d2ff38f0a1cc7555f80","item_hash_version":3,"context":{"agent_provenance_outcomes":{"model":{"status":"resolved","resolver":"claude_session_file","rule_version":"v1"},"version":{"status":"resolved","resolver":"claude_session_file","rule_version":"v1"}}},"event_class":"substantive","record_hash_version":1,"record_hash":"bd00ffa4aa4f35185c24a03dd31baabcd953afa5e63e27c1b3c6776fa7b6893e"} +{"hash_algorithm":"sha256","ts":"2026-10-04T18:15:58.120Z","author":"claude-orchestrator","author_source":"asserted","agent_harness":"claude-code","agent_model":"claude-opus-5-5","agent_model_source":"probe","agent_instance":"4b273145923a68729ab394ab","agent_provenance":{"model":{"value":"claude-opus-5-5","source":"probe"},"effort":{"value":"high","source":"environment"},"role":null,"topic":null,"version":{"value":"2.1.289","source":"probe"}},"op":"tests_add","patch":[{"op":"add","path":"/metadata/tests/1","value":{"command":"node scripts/with-test-db.ts node --test test/rate-limit.test.ts","scope":"project","timeout_seconds":600,"provenance":{"author":"claude-orchestrator","created_at":"2026-10-04T18:15:58.063Z","source_kind":"local_mutation","source_ref":"fix/bind-offline-queue-and-rate-limit-identity"}}},{"op":"replace","path":"/metadata/updated_at","value":"2026-10-04T18:15:58.120Z"}],"before_hash":"1ee1307b11f659fed6dd49e30f768ee3032d5bfe22307d2ff38f0a1cc7555f80","after_hash":"1ebfddaae843cca40e842ea418fd4db2dffb16b9fc98877bdd03603909263a90","item_hash_version":3,"context":{"agent_provenance_outcomes":{"model":{"status":"resolved","resolver":"claude_session_file","rule_version":"v1"},"version":{"status":"resolved","resolver":"claude_session_file","rule_version":"v1"}}},"event_class":"maintenance","record_hash_version":1,"record_hash":"337a99340c56aa7167bc02c0b49025c84976817e26d86f8fda2845d0557bc19f"} +{"hash_algorithm":"sha256","ts":"2026-10-04T18:15:59.275Z","author":"claude-orchestrator","author_source":"asserted","agent_harness":"claude-code","agent_model":"claude-opus-5-5","agent_model_source":"probe","agent_instance":"4b273145923a68729ab394ab","agent_provenance":{"model":{"value":"claude-opus-5-5","source":"probe"},"effort":{"value":"high","source":"environment"},"role":null,"topic":null,"version":{"value":"2.1.289","source":"probe"}},"op":"files_add","patch":[{"op":"replace","path":"/metadata/updated_at","value":"2026-10-04T18:15:59.275Z"},{"op":"add","path":"/metadata/files","value":[{"path":"src/rate-limit.ts","scope":"project"},{"path":"test/rate-limit.test.ts","scope":"project"}]}],"before_hash":"1ebfddaae843cca40e842ea418fd4db2dffb16b9fc98877bdd03603909263a90","after_hash":"084cd8d8a65fd9bd4e20e87aef2f18e342207b6507866726a8ee44cf816a6518","item_hash_version":3,"context":{"agent_provenance_outcomes":{"model":{"status":"resolved","resolver":"claude_session_file","rule_version":"v1"},"version":{"status":"resolved","resolver":"claude_session_file","rule_version":"v1"}}},"event_class":"maintenance","record_hash_version":1,"record_hash":"29746ad1c0a93bb2a9581c0bfd2b9e149ac0fbfecef9e19d486b5ad11344c34e"} +{"hash_algorithm":"sha256","ts":"2026-10-04T18:16:01.098Z","author":"claude-orchestrator","author_source":"asserted","agent_harness":"claude-code","agent_model":"claude-opus-5-5","agent_model_source":"probe","agent_instance":"4b273145923a68729ab394ab","agent_provenance":{"model":{"value":"claude-opus-5-5","source":"probe"},"effort":{"value":"high","source":"environment"},"role":null,"topic":null,"version":{"value":"2.1.289","source":"probe"}},"op":"comment_add","patch":[{"op":"add","path":"/metadata/comments/1","value":{"created_at":"2026-10-04T18:16:01.098Z","author":"claude-orchestrator","text":"PR #170: four accounts fire 20 parallel POSTs with rotating forged X-Forwarded-For, X-Real-IP and Forwarded headers; exactly the 6-request budget is admitted with the default trust-nothing proxy setting; revert-checked against the one-hop default. Tests restore PM_WEB_TRUST_PROXY after themselves."}},{"op":"replace","path":"/metadata/updated_at","value":"2026-10-04T18:16:01.098Z"}],"before_hash":"084cd8d8a65fd9bd4e20e87aef2f18e342207b6507866726a8ee44cf816a6518","after_hash":"7c97e4589f9f50248e80b64c807892a13524cc3180b5a04e5a95c5d1c76c6703","item_hash_version":3,"context":{"agent_provenance_outcomes":{"model":{"status":"resolved","resolver":"claude_session_file","rule_version":"v1"},"version":{"status":"resolved","resolver":"claude_session_file","rule_version":"v1"}}},"event_class":"substantive","record_hash_version":1,"record_hash":"6d992f5a77917ffdaa6abde4e01318436e19037c4149ca7a2691f208c9560de9"} +{"hash_algorithm":"sha256","ts":"2026-10-04T18:16:02.799Z","author":"claude-orchestrator","author_source":"asserted","agent_harness":"claude-code","agent_model":"claude-opus-5-5","agent_model_source":"probe","agent_instance":"4b273145923a68729ab394ab","agent_provenance":{"model":{"value":"claude-opus-5-5","source":"probe"},"effort":{"value":"high","source":"environment"},"role":{"value":"implementer","source":"argv"},"topic":null,"version":{"value":"2.1.289","source":"probe"}},"op":"release","patch":[{"op":"remove","path":"/metadata/claim_principal"},{"op":"remove","path":"/metadata/assignee"},{"op":"replace","path":"/metadata/updated_at","value":"2026-10-04T18:16:02.799Z"}],"before_hash":"7c97e4589f9f50248e80b64c807892a13524cc3180b5a04e5a95c5d1c76c6703","after_hash":"0b709030673b911dc42fbe99e9068ac40a48e4bbd20c2a6035e97ea7a8394fb1","item_hash_version":3,"context":{"agent_provenance_outcomes":{"model":{"status":"resolved","resolver":"claude_session_file","rule_version":"v1"},"version":{"status":"resolved","resolver":"claude_session_file","rule_version":"v1"}}},"event_class":"maintenance","record_hash_version":1,"record_hash":"8f440645226559c4792135fe5fa69fec0657d1a7c0793e306a9f9de578a91423"} diff --git a/.agents/pm/history/pm-web-ccie.jsonl b/.agents/pm/history/pm-web-ccie.jsonl index d17efdcb..0cdfefe1 100644 --- a/.agents/pm/history/pm-web-ccie.jsonl +++ b/.agents/pm/history/pm-web-ccie.jsonl @@ -1,2 +1,8 @@ {"ts":"2026-08-30T21:39:59.317Z","author":"codex","author_source":"configured","agent_harness":"codex","agent_instance":"ea5856882c73d923eca2015a","agent_provenance":{"model":null,"effort":null,"role":{"value":"implementer","source":"argv"},"topic":null},"op":"create","patch":[{"op":"add","path":"/metadata/id","value":"pm-web-ccie"},{"op":"add","path":"/metadata/title","value":"Offline mutation queue is not bound to the originating account or idempotency key"},{"op":"add","path":"/metadata/description","value":"Offline API mutations are stored with method, path, and body but no authenticated user identity or idempotency key. If a browser changes accounts before reconnecting, replay uses the current session; if a response is lost after the server commits, retry can repeat a non-idempotent mutation. The CSRF work in pm-web-413z deliberately repairs token freshness without pretending to solve account ownership or exactly-once delivery."},{"op":"add","path":"/metadata/type","value":"Issue"},{"op":"add","path":"/metadata/status","value":"open"},{"op":"add","path":"/metadata/priority","value":0},{"op":"add","path":"/metadata/tags","value":["idempotency","multi-user","offline","security"]},{"op":"add","path":"/metadata/created_at","value":"2026-08-30T21:39:59.317Z"},{"op":"add","path":"/metadata/updated_at","value":"2026-08-30T21:39:59.317Z"},{"op":"add","path":"/metadata/author","value":"codex"},{"op":"add","path":"/metadata/estimated_minutes","value":240},{"op":"add","path":"/metadata/acceptance_criteria","value":"Every queued mutation is durably bound to the authenticated user/session that created it; a different account never replays it; unknown legacy ownership is preserved and surfaced for explicit recovery without blocking unrelated owned records; every mutation carries a server-enforced idempotency key so retry after an ambiguous response cannot duplicate state; real browser/service-worker/PostgreSQL tests cover logout/login, account switching, lost responses, restart, queue ordering, and zero data loss."},{"op":"add","path":"/metadata/why_now","value":"The CSRF review proved queue records survive browser sessions and surfaced the adjacent cross-account and ambiguous-retry risks; multi-user production readiness requires explicit ownership and exactly-once semantics."},{"op":"add","path":"/metadata/reviewer","value":"security-review"},{"op":"add","path":"/metadata/risk","value":"high"},{"op":"add","path":"/metadata/confidence","value":"high"},{"op":"add","path":"/metadata/reporter","value":"codex"},{"op":"add","path":"/metadata/severity","value":"high"},{"op":"add","path":"/metadata/environment","value":"pm-web 2026.8.29 offline service-worker queue"},{"op":"add","path":"/metadata/repro_steps","value":"Queue a mutation while authenticated as user A, change the browser session to user B before reconnecting, then flush; the request is authenticated as B. Separately, let the server commit a non-idempotent mutation but drop the response; the retained record retries without an idempotency key."},{"op":"add","path":"/metadata/expected_result","value":"Queued work remains owned by its creator and ambiguous retries are exactly-once."},{"op":"add","path":"/metadata/actual_result","value":"Replay uses whichever session is current and has no deduplication identity."},{"op":"add","path":"/metadata/affected_version","value":"2026.8.29"},{"op":"add","path":"/metadata/component","value":"Offline mutation queue"},{"op":"add","path":"/metadata/customer_impact","value":"A shared browser can attribute pending work to the wrong user, and a network ambiguity can duplicate mutations."}],"before_hash":"3cc22dff72be7b14824654a7a64ea62b04799939b2fee54c1b5f52ca60bf6df0","after_hash":"29cd8a4b05536868e1268113d1b11a0891af96eb17d7808f644133eb2df11c94","item_hash_version":2,"message":"Track account binding and exactly-once replay separately from CSRF token freshness"} {"ts":"2026-08-30T21:40:06.180Z","author":"codex","author_source":"configured","agent_harness":"codex","agent_instance":"ea5856882c73d923eca2015a","agent_provenance":{"model":null,"effort":null,"role":null,"topic":null},"op":"files_add","patch":[{"op":"replace","path":"/metadata/updated_at","value":"2026-08-30T21:40:06.180Z"},{"op":"add","path":"/metadata/files","value":[{"path":"public/src/sw.ts","scope":"project","note":"Offline persistence and replay boundary"},{"path":"src/services/pm-runner.ts","scope":"project","note":"Mutation execution and idempotency integration surface"},{"path":"test/sw-queue.test.ts","scope":"project","note":"Queue lifecycle regression suite"}]}],"before_hash":"29cd8a4b05536868e1268113d1b11a0891af96eb17d7808f644133eb2df11c94","after_hash":"e338acb6727c2c6d03ff9d0cd743688cc17097c6415d7d88553f0e8f1618640e","item_hash_version":2,"message":"Link queue ownership and exactly-once implementation surfaces"} +{"hash_algorithm":"sha256","ts":"2026-10-04T18:15:51.124Z","author":"claude-orchestrator","author_source":"asserted","agent_harness":"claude-code","agent_model":"claude-opus-5-5","agent_model_source":"probe","agent_instance":"4b273145923a68729ab394ab","agent_provenance":{"model":{"value":"claude-opus-5-5","source":"probe"},"effort":{"value":"high","source":"environment"},"role":{"value":"implementer","source":"argv"},"topic":null,"version":{"value":"2.1.289","source":"probe"}},"op":"claim","patch":[{"op":"replace","path":"/metadata/updated_at","value":"2026-10-04T18:15:51.124Z"},{"op":"add","path":"/metadata/assignee","value":"claude-orchestrator"},{"op":"add","path":"/metadata/claim_principal","value":"claude-orchestrator"}],"before_hash":"e338acb6727c2c6d03ff9d0cd743688cc17097c6415d7d88553f0e8f1618640e","after_hash":"6a880c18d112f0f1761c415560996fb4913a3f79122eee4c19371e6fcee5d208","item_hash_version":3,"context":{"agent_provenance_outcomes":{"model":{"status":"resolved","resolver":"claude_session_file","rule_version":"v1"},"version":{"status":"resolved","resolver":"claude_session_file","rule_version":"v1"}}},"event_class":"substantive","record_hash_version":1,"record_hash":"b63545002f33ff8aab637469f6b2111bb8a1106555e795ad3920927d48242a1e"} +{"hash_algorithm":"sha256","ts":"2026-10-04T18:15:51.725Z","author":"claude-orchestrator","author_source":"asserted","agent_harness":"claude-code","agent_model":"claude-opus-5-5","agent_model_source":"probe","agent_instance":"4b273145923a68729ab394ab","agent_provenance":{"model":{"value":"claude-opus-5-5","source":"probe"},"effort":{"value":"high","source":"environment"},"role":{"value":"implementer","source":"argv"},"topic":null,"version":{"value":"2.1.289","source":"probe"}},"op":"update","patch":[{"op":"replace","path":"/metadata/updated_at","value":"2026-10-04T18:15:51.725Z"},{"op":"replace","path":"/metadata/status","value":"in_progress"}],"before_hash":"6a880c18d112f0f1761c415560996fb4913a3f79122eee4c19371e6fcee5d208","after_hash":"2d4e297b8f5f166491e9374e801e0dfe0bfca33de6aae9b65c28d9133a6a91d5","item_hash_version":3,"context":{"agent_provenance_outcomes":{"model":{"status":"resolved","resolver":"claude_session_file","rule_version":"v1"},"version":{"status":"resolved","resolver":"claude_session_file","rule_version":"v1"}}},"event_class":"substantive","record_hash_version":1,"record_hash":"409b5835a4271ed0a51844da3ccb5c7b815555026f429f4dd75e4e48c26d3792"} +{"hash_algorithm":"sha256","ts":"2026-10-04T18:15:56.335Z","author":"claude-orchestrator","author_source":"asserted","agent_harness":"claude-code","agent_model":"claude-opus-5-5","agent_model_source":"probe","agent_instance":"4b273145923a68729ab394ab","agent_provenance":{"model":{"value":"claude-opus-5-5","source":"probe"},"effort":{"value":"high","source":"environment"},"role":null,"topic":null,"version":{"value":"2.1.289","source":"probe"}},"op":"files_add","patch":[{"op":"remove","path":"/metadata/files/2/note"},{"op":"replace","path":"/metadata/files/2/path","value":"sql/schema.sql"},{"op":"replace","path":"/metadata/files/1/note","value":"Offline persistence and replay boundary"},{"op":"replace","path":"/metadata/files/1/path","value":"public/src/sw.ts"},{"op":"remove","path":"/metadata/files/0/note"},{"op":"replace","path":"/metadata/files/0/path","value":"public/src/api.ts"},{"op":"add","path":"/metadata/files/3","value":{"path":"src/db.ts","scope":"project"}},{"op":"add","path":"/metadata/files/4","value":{"path":"src/idempotency.ts","scope":"project"}},{"op":"add","path":"/metadata/files/5","value":{"path":"src/services/pm-runner.ts","scope":"project","note":"Mutation execution and idempotency integration surface"}},{"op":"add","path":"/metadata/files/6","value":{"path":"test/sw-queue.test.ts","scope":"project","note":"Queue lifecycle regression suite"}},{"op":"replace","path":"/metadata/updated_at","value":"2026-10-04T18:15:56.335Z"}],"before_hash":"2d4e297b8f5f166491e9374e801e0dfe0bfca33de6aae9b65c28d9133a6a91d5","after_hash":"d9a972e03b6b271d6e747e5b98428d4d7ff47e2c95224407dd12bb9c86c43e5f","item_hash_version":3,"context":{"agent_provenance_outcomes":{"model":{"status":"resolved","resolver":"claude_session_file","rule_version":"v1"},"version":{"status":"resolved","resolver":"claude_session_file","rule_version":"v1"}}},"event_class":"maintenance","record_hash_version":1,"record_hash":"1912e6669c8e0dc5b79cd7abfe00cad2b007e7a80e277397bf61dc5612432a5e"} +{"hash_algorithm":"sha256","ts":"2026-10-04T18:15:56.961Z","author":"claude-orchestrator","author_source":"asserted","agent_harness":"claude-code","agent_model":"claude-opus-5-5","agent_model_source":"probe","agent_instance":"4b273145923a68729ab394ab","agent_provenance":{"model":{"value":"claude-opus-5-5","source":"probe"},"effort":{"value":"high","source":"environment"},"role":null,"topic":null,"version":{"value":"2.1.289","source":"probe"}},"op":"tests_add","patch":[{"op":"replace","path":"/metadata/updated_at","value":"2026-10-04T18:15:56.961Z"},{"op":"add","path":"/metadata/tests","value":[{"command":"node scripts/with-test-db.ts node --test test/idempotency.test.ts test/sw-queue.test.ts test/sw-idempotency-http.test.ts test/frontend-api.test.ts","scope":"project","timeout_seconds":600,"provenance":{"author":"claude-orchestrator","created_at":"2026-10-04T18:15:56.922Z","source_kind":"local_mutation","source_ref":"fix/bind-offline-queue-and-rate-limit-identity"}}]}],"before_hash":"d9a972e03b6b271d6e747e5b98428d4d7ff47e2c95224407dd12bb9c86c43e5f","after_hash":"1202a771d5125ac984ae5bb6e2705d57a6adff8bcc37c6c75a445888e1693802","item_hash_version":3,"context":{"agent_provenance_outcomes":{"model":{"status":"resolved","resolver":"claude_session_file","rule_version":"v1"},"version":{"status":"resolved","resolver":"claude_session_file","rule_version":"v1"}}},"event_class":"maintenance","record_hash_version":1,"record_hash":"84d410e2c317da8b1bb3cb34b1cc20eb86d4a7a4a74be5964ac242a00a5a40c0"} +{"hash_algorithm":"sha256","ts":"2026-10-04T18:15:59.802Z","author":"claude-orchestrator","author_source":"asserted","agent_harness":"claude-code","agent_model":"claude-opus-5-5","agent_model_source":"probe","agent_instance":"4b273145923a68729ab394ab","agent_provenance":{"model":{"value":"claude-opus-5-5","source":"probe"},"effort":{"value":"high","source":"environment"},"role":null,"topic":null,"version":{"value":"2.1.289","source":"probe"}},"op":"comment_add","patch":[{"op":"replace","path":"/metadata/updated_at","value":"2026-10-04T18:15:59.802Z"},{"op":"add","path":"/metadata/comments","value":[{"created_at":"2026-10-04T18:15:59.802Z","author":"claude-orchestrator","text":"PR #170: queued mutations are stamped with the broadcast account (persisted session store), target workspace and an idempotency key generated before the first network attempt; foreign, unknown-owner and logged-out records are never replayed or deleted, and surface MUTATIONS_BLOCKED with a visible explicit recovery action that adopts unknown-owner records for the current account (assigning a key first). Server: pm_idempotency_keys claims (account, key) atomically, replays stored outcomes, rejects a key reused for a different request (422), never stores transient 425/429 refusals, answers 409 outcome-unknown instead of re-executing a stale pending key, catches outcome-persist failures, indexes created_at and throttles the retention sweep. Implemented by the pi (GLM-5.3) and codex (gpt-6.1-sol) agents; review round 1 (12 Greptile + CodeQL findings) fixed test-first. Local release:check: 451/451 tests pass."}]}],"before_hash":"1202a771d5125ac984ae5bb6e2705d57a6adff8bcc37c6c75a445888e1693802","after_hash":"4252fdce7191aeba1f93c7977fffeee02e3aa55e57557d7b16a17b6f03148801","item_hash_version":3,"context":{"agent_provenance_outcomes":{"model":{"status":"resolved","resolver":"claude_session_file","rule_version":"v1"},"version":{"status":"resolved","resolver":"claude_session_file","rule_version":"v1"}}},"event_class":"substantive","record_hash_version":1,"record_hash":"f6b06a9af835f9b4cef143e0a2e8522ec36c48211e3bfefdeddb7d6661002ce9"} +{"hash_algorithm":"sha256","ts":"2026-10-04T18:16:01.566Z","author":"claude-orchestrator","author_source":"asserted","agent_harness":"claude-code","agent_model":"claude-opus-5-5","agent_model_source":"probe","agent_instance":"4b273145923a68729ab394ab","agent_provenance":{"model":{"value":"claude-opus-5-5","source":"probe"},"effort":{"value":"high","source":"environment"},"role":{"value":"implementer","source":"argv"},"topic":null,"version":{"value":"2.1.289","source":"probe"}},"op":"release","patch":[{"op":"remove","path":"/metadata/claim_principal"},{"op":"remove","path":"/metadata/assignee"},{"op":"replace","path":"/metadata/updated_at","value":"2026-10-04T18:16:01.566Z"}],"before_hash":"4252fdce7191aeba1f93c7977fffeee02e3aa55e57557d7b16a17b6f03148801","after_hash":"d9dc1c3e448640e28c0cf3e6366f8e1bffcd0ba92a120b1f5c569d4c63feecb2","item_hash_version":3,"context":{"agent_provenance_outcomes":{"model":{"status":"resolved","resolver":"claude_session_file","rule_version":"v1"},"version":{"status":"resolved","resolver":"claude_session_file","rule_version":"v1"}}},"event_class":"maintenance","record_hash_version":1,"record_hash":"3b4b7748c76253fbbd87fa03f0a78cdeea4d73a7f56bc618bc9827528084a53b"} diff --git a/.agents/pm/history/pm-web-s552.jsonl b/.agents/pm/history/pm-web-s552.jsonl index 4514e956..4de5219b 100644 --- a/.agents/pm/history/pm-web-s552.jsonl +++ b/.agents/pm/history/pm-web-s552.jsonl @@ -2,3 +2,9 @@ {"ts":"2026-08-28T14:55:11.390Z","author":"claude","author_source":"asserted","agent_harness":"claude-code","agent_instance":"7405dc9b453c8154184ac8fe","agent_provenance":{"model":null,"effort":{"value":"xhigh","source":"environment"},"role":null,"topic":null},"op":"docs_add","patch":[{"op":"replace","path":"/metadata/updated_at","value":"2026-08-28T14:55:11.390Z"},{"op":"add","path":"/metadata/docs","value":[{"path":"src/app.ts","scope":"project","note":"router and limiter mounting"}]}],"before_hash":"48c3f15ab5f87327e00a16d132484df4ea4b1015396c7a3814ffa7c9542ff23f","after_hash":"3ebc85d1de1d06c56e0401ddab41e5393c945ad3afdb9a1f601206d346a7f07c","item_hash_version":2,"context":{"agent_provenance_outcomes":{"model":{"status":"failed","reason":"resolver_failed","resolver":"claude_session_file","rule_version":"v1"},"version":{"status":"failed","reason":"resolver_failed","resolver":"claude_session_file","rule_version":"v1"}}}} {"ts":"2026-08-28T14:55:15.721Z","author":"claude","author_source":"asserted","agent_harness":"claude-code","agent_instance":"7405dc9b453c8154184ac8fe","agent_provenance":{"model":null,"effort":{"value":"xhigh","source":"environment"},"role":null,"topic":null},"op":"comment_add","patch":[{"op":"replace","path":"/metadata/updated_at","value":"2026-08-28T14:55:15.721Z"},{"op":"add","path":"/metadata/comments","value":[{"created_at":"2026-08-28T14:55:15.721Z","author":"claude","text":"Verified against Express prefix matching rather than assumed: a request to slash api slash projects slash ID slash pm matches the slash api slash projects mount before reaching its own, so both traversals incremented the same store. Raised by Greptile as a P1 finding on PR 110."}]}],"before_hash":"3ebc85d1de1d06c56e0401ddab41e5393c945ad3afdb9a1f601206d346a7f07c","after_hash":"02052b58de94c06bc5329e88d6204c773a314cabfd7ca1a9000d1317ab59f00b","item_hash_version":2,"context":{"agent_provenance_outcomes":{"model":{"status":"failed","reason":"resolver_failed","resolver":"claude_session_file","rule_version":"v1"},"version":{"status":"failed","reason":"resolver_failed","resolver":"claude_session_file","rule_version":"v1"}}}} {"ts":"2026-08-28T15:15:10.697Z","author":"claude","author_source":"asserted","agent_harness":"claude-code","agent_instance":"7405dc9b453c8154184ac8fe","agent_provenance":{"model":null,"effort":{"value":"xhigh","source":"environment"},"role":null,"topic":null},"op":"tests_add","patch":[{"op":"replace","path":"/metadata/updated_at","value":"2026-08-28T15:15:10.697Z"},{"op":"add","path":"/metadata/tests","value":[{"command":"PM_PATH=/tmp/pmweb-s552 PM_GLOBAL_PATH=/tmp/pmweb-s552-g node --test --test-name-pattern \"general schema includes the idempotent\" test/oidc.test.ts","scope":"project","assert_stdout_regex":["pass 1"],"note":"no limiter guards a prefix and a path nested under it"}]}],"before_hash":"02052b58de94c06bc5329e88d6204c773a314cabfd7ca1a9000d1317ab59f00b","after_hash":"1fb44562542be8b1450a415851c4c005c6a571e6952c746d58e86843758d3904","item_hash_version":2,"context":{"agent_provenance_outcomes":{"model":{"status":"failed","reason":"resolver_failed","resolver":"claude_session_file","rule_version":"v1"},"version":{"status":"failed","reason":"resolver_failed","resolver":"claude_session_file","rule_version":"v1"}}}} +{"hash_algorithm":"sha256","ts":"2026-10-04T18:15:52.372Z","author":"claude-orchestrator","author_source":"asserted","agent_harness":"claude-code","agent_model":"claude-opus-5-5","agent_model_source":"probe","agent_instance":"4b273145923a68729ab394ab","agent_provenance":{"model":{"value":"claude-opus-5-5","source":"probe"},"effort":{"value":"high","source":"environment"},"role":{"value":"implementer","source":"argv"},"topic":null,"version":{"value":"2.1.289","source":"probe"}},"op":"claim","patch":[{"op":"replace","path":"/metadata/updated_at","value":"2026-10-04T18:15:52.372Z"},{"op":"add","path":"/metadata/assignee","value":"claude-orchestrator"},{"op":"add","path":"/metadata/claim_principal","value":"claude-orchestrator"}],"before_hash":"1fb44562542be8b1450a415851c4c005c6a571e6952c746d58e86843758d3904","after_hash":"ee09e301b499caec5ec5e8080cf74a9eedca701c68504247124cd6a3315c8947","item_hash_version":3,"context":{"agent_provenance_outcomes":{"model":{"status":"resolved","resolver":"claude_session_file","rule_version":"v1"},"version":{"status":"resolved","resolver":"claude_session_file","rule_version":"v1"}}},"event_class":"substantive","record_hash_version":1,"record_hash":"d189c4ee9ee1ac7bf492f486d5924f1a1ad058a1842471f491631e95bbfc1f5c"} +{"hash_algorithm":"sha256","ts":"2026-10-04T18:15:53.135Z","author":"claude-orchestrator","author_source":"asserted","agent_harness":"claude-code","agent_model":"claude-opus-5-5","agent_model_source":"probe","agent_instance":"4b273145923a68729ab394ab","agent_provenance":{"model":{"value":"claude-opus-5-5","source":"probe"},"effort":{"value":"high","source":"environment"},"role":{"value":"implementer","source":"argv"},"topic":null,"version":{"value":"2.1.289","source":"probe"}},"op":"update","patch":[{"op":"replace","path":"/metadata/updated_at","value":"2026-10-04T18:15:53.135Z"},{"op":"replace","path":"/metadata/status","value":"in_progress"}],"before_hash":"ee09e301b499caec5ec5e8080cf74a9eedca701c68504247124cd6a3315c8947","after_hash":"2822b76cf71dc6181ed673f32a325b42593ca71f2ebfb99b1fb7714e35f701d0","item_hash_version":3,"context":{"agent_provenance_outcomes":{"model":{"status":"resolved","resolver":"claude_session_file","rule_version":"v1"},"version":{"status":"resolved","resolver":"claude_session_file","rule_version":"v1"}}},"event_class":"substantive","record_hash_version":1,"record_hash":"75954606fae29c4538ba7f39f873fc19d41f57e1f3bceef80f36099c1cd49b39"} +{"hash_algorithm":"sha256","ts":"2026-10-04T18:15:57.544Z","author":"claude-orchestrator","author_source":"asserted","agent_harness":"claude-code","agent_model":"claude-opus-5-5","agent_model_source":"probe","agent_instance":"4b273145923a68729ab394ab","agent_provenance":{"model":{"value":"claude-opus-5-5","source":"probe"},"effort":{"value":"high","source":"environment"},"role":null,"topic":null,"version":{"value":"2.1.289","source":"probe"}},"op":"tests_add","patch":[{"op":"add","path":"/metadata/tests/1","value":{"command":"node scripts/with-test-db.ts node --test test/rate-limit.test.ts","scope":"project","timeout_seconds":600,"provenance":{"author":"claude-orchestrator","created_at":"2026-10-04T18:15:57.514Z","source_kind":"local_mutation","source_ref":"fix/bind-offline-queue-and-rate-limit-identity"}}},{"op":"replace","path":"/metadata/updated_at","value":"2026-10-04T18:15:57.544Z"}],"before_hash":"2822b76cf71dc6181ed673f32a325b42593ca71f2ebfb99b1fb7714e35f701d0","after_hash":"bf1c81b28ed7b7fb5fd4f4d337fbb63baaf22857151cfad580e700888859f72a","item_hash_version":3,"context":{"agent_provenance_outcomes":{"model":{"status":"resolved","resolver":"claude_session_file","rule_version":"v1"},"version":{"status":"resolved","resolver":"claude_session_file","rule_version":"v1"}}},"event_class":"maintenance","record_hash_version":1,"record_hash":"0e3427f335d328dd5e43be1ad5f7a9925e1f872c5ba7aa79fd912a5c4543d273"} +{"hash_algorithm":"sha256","ts":"2026-10-04T18:15:58.684Z","author":"claude-orchestrator","author_source":"asserted","agent_harness":"claude-code","agent_model":"claude-opus-5-5","agent_model_source":"probe","agent_instance":"4b273145923a68729ab394ab","agent_provenance":{"model":{"value":"claude-opus-5-5","source":"probe"},"effort":{"value":"high","source":"environment"},"role":null,"topic":null,"version":{"value":"2.1.289","source":"probe"}},"op":"files_add","patch":[{"op":"replace","path":"/metadata/updated_at","value":"2026-10-04T18:15:58.684Z"},{"op":"add","path":"/metadata/files","value":[{"path":"src/app.ts","scope":"project"},{"path":"src/rate-limit.ts","scope":"project"},{"path":"test/rate-limit.test.ts","scope":"project"}]}],"before_hash":"bf1c81b28ed7b7fb5fd4f4d337fbb63baaf22857151cfad580e700888859f72a","after_hash":"1fec16212dd70a41b704786dc5ceb310866f9d148b1c687b2f294f6e8746afee","item_hash_version":3,"context":{"agent_provenance_outcomes":{"model":{"status":"resolved","resolver":"claude_session_file","rule_version":"v1"},"version":{"status":"resolved","resolver":"claude_session_file","rule_version":"v1"}}},"event_class":"maintenance","record_hash_version":1,"record_hash":"e30d4500ed7945216f77e33b94e59572f50f98b7fd7dff81df4443f0e119708e"} +{"hash_algorithm":"sha256","ts":"2026-10-04T18:16:00.482Z","author":"claude-orchestrator","author_source":"asserted","agent_harness":"claude-code","agent_model":"claude-opus-5-5","agent_model_source":"probe","agent_instance":"4b273145923a68729ab394ab","agent_provenance":{"model":{"value":"claude-opus-5-5","source":"probe"},"effort":{"value":"high","source":"environment"},"role":null,"topic":null,"version":{"value":"2.1.289","source":"probe"}},"op":"comment_add","patch":[{"op":"add","path":"/metadata/comments/1","value":{"created_at":"2026-10-04T18:16:00.482Z","author":"claude-orchestrator","text":"PR #170: proof against the real app that a nested project request is counted once (5-request write budget: 5 admitted, 6th refused), the auth budget holds exactly (20 admitted, 21st refused), and 20 concurrent POSTs from four accounts admit exactly the 12-request budget; revert-checked against the historical double mount. Rate limiting now runs before the idempotency guard so refusals never claim a key."}},{"op":"replace","path":"/metadata/updated_at","value":"2026-10-04T18:16:00.482Z"}],"before_hash":"1fec16212dd70a41b704786dc5ceb310866f9d148b1c687b2f294f6e8746afee","after_hash":"498fcf801de081170f347cf28a1fbd0c1b6a73a1866ea8d3399c0a4528837fe8","item_hash_version":3,"context":{"agent_provenance_outcomes":{"model":{"status":"resolved","resolver":"claude_session_file","rule_version":"v1"},"version":{"status":"resolved","resolver":"claude_session_file","rule_version":"v1"}}},"event_class":"substantive","record_hash_version":1,"record_hash":"2f4f7ef51771b2cf70829471457e341ff616b4541e74561279f4b32e7778242a"} +{"hash_algorithm":"sha256","ts":"2026-10-04T18:16:02.186Z","author":"claude-orchestrator","author_source":"asserted","agent_harness":"claude-code","agent_model":"claude-opus-5-5","agent_model_source":"probe","agent_instance":"4b273145923a68729ab394ab","agent_provenance":{"model":{"value":"claude-opus-5-5","source":"probe"},"effort":{"value":"high","source":"environment"},"role":{"value":"implementer","source":"argv"},"topic":null,"version":{"value":"2.1.289","source":"probe"}},"op":"release","patch":[{"op":"remove","path":"/metadata/claim_principal"},{"op":"remove","path":"/metadata/assignee"},{"op":"replace","path":"/metadata/updated_at","value":"2026-10-04T18:16:02.186Z"}],"before_hash":"498fcf801de081170f347cf28a1fbd0c1b6a73a1866ea8d3399c0a4528837fe8","after_hash":"f364572f0e0e031f62d8592e2f01d07f8f01b3bca57d2bec2267491a0afbc52e","item_hash_version":3,"context":{"agent_provenance_outcomes":{"model":{"status":"resolved","resolver":"claude_session_file","rule_version":"v1"},"version":{"status":"resolved","resolver":"claude_session_file","rule_version":"v1"}}},"event_class":"maintenance","record_hash_version":1,"record_hash":"bed89703003c2056ca0b0c9e2614cc7aba053cc55ae3dd9b0e6070c6a4c17c30"} diff --git a/.agents/pm/issues/pm-web-25nv.toon b/.agents/pm/issues/pm-web-25nv.toon index f854a4c9..cae2281a 100644 --- a/.agents/pm/issues/pm-web-25nv.toon +++ b/.agents/pm/issues/pm-web-25nv.toon @@ -2,19 +2,31 @@ id: pm-web-25nv title: "The per-IP rate limiter trusted a client-supplied forwarded address by default, so any caller could rotate a header to a fresh bucket" description: "resolveTrustProxy returned one trusted hop when PM_WEB_TRUST_PROXY was unset. Every launch path this package documents exposes Express directly with no proxy: a docker run publishing the port, and npm start. Under that default Express takes the client address from the caller's own X-Forwarded-For header, so an attacker rotating the header draws a new bucket for every request and every per-route limit added by this change enforces nothing, while still reporting itself as enforcing them. The default is now to trust nothing, which fails in the visible direction instead: a genuinely proxied deployment sees all clients share the proxy address and an operator notices immediately. The hosted deployment behind Caddy declares its single hop explicitly in the compose file, and the variable is documented." type: Issue -status: open +status: in_progress priority: 1 tags[3]: "area:http","area:security","type:defect" created_at: "2026-08-28T14:54:28.347Z" -updated_at: "2026-08-28T15:15:10.229Z" +updated_at: "2026-10-04T18:16:02.799Z" author: claude -comments[1]{created_at,author,text}: +comments[2]{created_at,author,text}: "2026-08-28T14:55:13.653Z",claude,"Revert-checked: restoring the one-hop default drops the new test from passing to failing, so it measures the fix rather than describing it. The complementary test that opts into one hop and asserts the limiter follows the real client behind a proxy still passes, so the proxied behaviour is unchanged. Fifteen tests in the suite pass. Raised by Greptile as a P1 security finding on PR 110." -tests[1]: + "2026-10-04T18:16:01.098Z",claude-orchestrator,"PR #170: four accounts fire 20 parallel POSTs with rotating forged X-Forwarded-For, X-Real-IP and Forwarded headers; exactly the 6-request budget is admitted with the default trust-nothing proxy setting; revert-checked against the one-hop default. Tests restore PM_WEB_TRUST_PROXY after themselves." +files[2]{path,scope}: + src/rate-limit.ts,project + test/rate-limit.test.ts,project +tests[2]: - command: "PM_PATH=/tmp/pmweb-25nv PM_GLOBAL_PATH=/tmp/pmweb-25nv-g node --test --test-name-pattern \"rotated X-Forwarded-For\" test/rate-limit.test.ts" scope: project assert_stdout_regex[1]: pass 1 note: the default must resist header rotation + - command: node scripts/with-test-db.ts node --test test/rate-limit.test.ts + scope: project + timeout_seconds: 600 + provenance: + author: claude-orchestrator + created_at: "2026-10-04T18:15:58.063Z" + source_kind: local_mutation + source_ref: fix/bind-offline-queue-and-rate-limit-identity docs[2]{path,scope,note}: src/rate-limit.ts,project,resolveTrustProxy default test/rate-limit.test.ts,project,the header-rotation regression test diff --git a/.agents/pm/issues/pm-web-ccie.toon b/.agents/pm/issues/pm-web-ccie.toon index f17890e7..eab92089 100644 --- a/.agents/pm/issues/pm-web-ccie.toon +++ b/.agents/pm/issues/pm-web-ccie.toon @@ -2,11 +2,11 @@ id: pm-web-ccie title: Offline mutation queue is not bound to the originating account or idempotency key description: "Offline API mutations are stored with method, path, and body but no authenticated user identity or idempotency key. If a browser changes accounts before reconnecting, replay uses the current session; if a response is lost after the server commits, retry can repeat a non-idempotent mutation. The CSRF work in pm-web-413z deliberately repairs token freshness without pretending to solve account ownership or exactly-once delivery." type: Issue -status: open +status: in_progress priority: 0 tags[4]: idempotency,multi-user,offline,security created_at: "2026-08-30T21:39:59.317Z" -updated_at: "2026-08-30T21:40:06.180Z" +updated_at: "2026-10-04T18:16:01.566Z" author: codex estimated_minutes: 240 acceptance_criteria: "Every queued mutation is durably bound to the authenticated user/session that created it; a different account never replays it; unknown legacy ownership is preserved and surfaced for explicit recovery without blocking unrelated owned records; every mutation carries a server-enforced idempotency key so retry after an ambiguous response cannot duplicate state; real browser/service-worker/PostgreSQL tests cover logout/login, account switching, lost responses, restart, queue ordering, and zero data loss." @@ -23,8 +23,26 @@ actual_result: Replay uses whichever session is current and has no deduplication affected_version: 2026.8.29 component: Offline mutation queue customer_impact: "A shared browser can attribute pending work to the wrong user, and a network ambiguity can duplicate mutations." -files[3]{path,scope,note}: - public/src/sw.ts,project,Offline persistence and replay boundary - src/services/pm-runner.ts,project,Mutation execution and idempotency integration surface - test/sw-queue.test.ts,project,Queue lifecycle regression suite +comments[1]{created_at,author,text}: + "2026-10-04T18:15:59.802Z",claude-orchestrator,"PR #170: queued mutations are stamped with the broadcast account (persisted session store), target workspace and an idempotency key generated before the first network attempt; foreign, unknown-owner and logged-out records are never replayed or deleted, and surface MUTATIONS_BLOCKED with a visible explicit recovery action that adopts unknown-owner records for the current account (assigning a key first). Server: pm_idempotency_keys claims (account, key) atomically, replays stored outcomes, rejects a key reused for a different request (422), never stores transient 425/429 refusals, answers 409 outcome-unknown instead of re-executing a stale pending key, catches outcome-persist failures, indexes created_at and throttles the retention sweep. Implemented by the pi (GLM-5.3) and codex (gpt-6.1-sol) agents; review round 1 (12 Greptile + CodeQL findings) fixed test-first. Local release:check: 451/451 tests pass." +files[7]: + - path: public/src/api.ts + scope: project + - path: public/src/sw.ts + scope: project + note: Offline persistence and replay boundary + - path: sql/schema.sql + scope: project + - path: src/db.ts + scope: project + - path: src/idempotency.ts + scope: project + - path: src/services/pm-runner.ts + scope: project + note: Mutation execution and idempotency integration surface + - path: test/sw-queue.test.ts + scope: project + note: Queue lifecycle regression suite +tests[1]{command,scope,timeout_seconds,provenance{author,created_at,source_kind,source_ref}}: + node scripts/with-test-db.ts node --test test/idempotency.test.ts test/sw-queue.test.ts test/sw-idempotency-http.test.ts test/frontend-api.test.ts,project,600,claude-orchestrator,"2026-10-04T18:15:56.922Z",local_mutation,fix/bind-offline-queue-and-rate-limit-identity body: "" diff --git a/.agents/pm/issues/pm-web-s552.toon b/.agents/pm/issues/pm-web-s552.toon index 0a1eb8e0..53a5a7bc 100644 --- a/.agents/pm/issues/pm-web-s552.toon +++ b/.agents/pm/issues/pm-web-s552.toon @@ -2,19 +2,32 @@ id: pm-web-s552 title: "Two limiters counted one request twice, halving every published budget on the affected routes" description: "A limiter counts every request that traverses it, so mounting the same limiter on a prefix and again on a nested path charges one request twice. The auth limiter was mounted on slash api slash auth once for the OIDC router and again for the auth router, so any request the OIDC router did not handle fell through and was counted a second time, reducing twenty requests per minute to ten. The read and write limiters were mounted on slash api slash projects and again on each nested mount beneath it, so every nested project request was counted twice and the six hundred and three hundred per minute budgets became three hundred and one hundred and fifty. Legitimate collaboration would meet a premature 429 at half the documented limit. Each shared prefix now carries its limiters exactly once and the routers mount behind them." type: Issue -status: open +status: in_progress priority: 1 tags[2]: "area:http","type:defect" created_at: "2026-08-28T14:54:43.995Z" -updated_at: "2026-08-28T15:15:10.697Z" +updated_at: "2026-10-04T18:16:02.186Z" author: claude -comments[1]{created_at,author,text}: +comments[2]{created_at,author,text}: "2026-08-28T14:55:15.721Z",claude,"Verified against Express prefix matching rather than assumed: a request to slash api slash projects slash ID slash pm matches the slash api slash projects mount before reaching its own, so both traversals incremented the same store. Raised by Greptile as a P1 finding on PR 110." -tests[1]: + "2026-10-04T18:16:00.482Z",claude-orchestrator,"PR #170: proof against the real app that a nested project request is counted once (5-request write budget: 5 admitted, 6th refused), the auth budget holds exactly (20 admitted, 21st refused), and 20 concurrent POSTs from four accounts admit exactly the 12-request budget; revert-checked against the historical double mount. Rate limiting now runs before the idempotency guard so refusals never claim a key." +files[3]{path,scope}: + src/app.ts,project + src/rate-limit.ts,project + test/rate-limit.test.ts,project +tests[2]: - command: "PM_PATH=/tmp/pmweb-s552 PM_GLOBAL_PATH=/tmp/pmweb-s552-g node --test --test-name-pattern \"general schema includes the idempotent\" test/oidc.test.ts" scope: project assert_stdout_regex[1]: pass 1 note: no limiter guards a prefix and a path nested under it + - command: node scripts/with-test-db.ts node --test test/rate-limit.test.ts + scope: project + timeout_seconds: 600 + provenance: + author: claude-orchestrator + created_at: "2026-10-04T18:15:57.514Z" + source_kind: local_mutation + source_ref: fix/bind-offline-queue-and-rate-limit-identity docs[1]{path,scope,note}: src/app.ts,project,router and limiter mounting body: "" diff --git a/README.md b/README.md index 2bb9f8d5..81313bb7 100644 --- a/README.md +++ b/README.md @@ -301,3 +301,35 @@ itself lists each affected stream in its output; `pm history --verify ` spot content, so `reconcile` only re-greens the hash chain (no data loss) — see the authoritative [pm-cli merge-safety guide](https://github.com/unbraind/pm-cli/blob/main/docs/MERGE_SAFETY.md). The older blunt `pm history-repair --all` remains available as a lower-level primitive. + + +### Offline mutation identity and recovery + +Each service-worker write gets an `Idempotency-Key` before its first network +attempt. If that response is lost, the queued record keeps the same key and +body. The session is resent when a worker first controls the page or changes; +session writes and explicit adoption extend the worker event lifetime. + +The SPA displays blocked offline records. For unknown-owner records, the +signed-in user may click **These changes are mine — adopt for my signed-in +account** after reviewing the listed paths. Adoption assigns any missing key +in the same IndexedDB transaction as ownership, then retries the queue. +Records owned by another account remain blocked until that account signs in. + +API rate limits apply before idempotency, including to replay attempts. Explicit +pre-mutation refusals (425 and 429) release the intent and are never replayed. +A pending intent may represent a committed mutation whose response was lost +before it could be stored. Pending intents and ambiguous server failures are +never taken over or automatically expired: duplicates wait up to +`PM_WEB_IDEMPOTENCY_WAIT_MS` (default 30 seconds), then receive 409. After +`PM_WEB_IDEMPOTENCY_PENDING_TIMEOUT_MS` (default five minutes), they receive +409 with **outcome unknown** immediately. Reconcile the original mutation +before submitting new work with a new key; automatic retry must reuse its key. +Outcome persistence failures are caught and logged without request contents. + +Completed responses are retained for `PM_WEB_IDEMPOTENCY_RETENTION_MS` (default +seven days). Deduplication of completed requests is bounded by that window; +a retry after expiry may execute again. Cleanup uses a partial `created_at` +index and runs at most once per minute per guard instance. Unknown outcomes +remain retained until explicitly reconciled, so operators must account for +these records when managing database growth. diff --git a/dist/app.js b/dist/app.js index e1e803e1..be2c9b3e 100644 --- a/dist/app.js +++ b/dist/app.js @@ -15,6 +15,7 @@ import { githubRouter } from "./routes/github.js"; import { adminRouter } from "./routes/admin.js"; import { createTierLimiters, resolveTrustProxy } from "./rate-limit.js"; import { csrfProtection } from "./csrf.js"; +import { idempotencyGuard } from "./idempotency.js"; const __dirname = path.dirname(fileURLToPath(import.meta.url)); const PUBLIC_DIR = path.resolve(__dirname, "..", "public"); /** @@ -215,18 +216,20 @@ export function createApp(deps) { // /api/projects/... request matched the /api/projects prefix before its own // nested mount. Each shared prefix therefore carries its limiters once, and // the routers mount behind them. - app.use("/api/auth", limiters.auth); + // Charge every attempt (including replays) before touching the idempotency store. + const keyedWrites = idempotencyGuard(); + app.use("/api/auth", limiters.auth, keyedWrites); app.use("/api/auth", oidcRouter); app.use("/api/auth", authRouter); - app.use("/api/projects", limiters.read, limiters.write); + app.use("/api/projects", limiters.read, limiters.write, keyedWrites); app.use("/api/projects", projectsRouter); app.use("/api/projects/:projectId/pm", pmRouter); app.use("/api/projects/:projectId/extensions", extensionsRouter); app.use("/api/projects/:id/shares", sharesRouter); app.use("/api/projects/:id/github", githubRouter); - app.use("/api/groups", limiters.read, limiters.write, groupsRouter); - app.use("/api/shared", limiters.read, limiters.write, sharedWithMeRouter); - app.use("/api/admin", limiters.admin, adminRouter); + app.use("/api/groups", limiters.read, limiters.write, keyedWrites, groupsRouter); + app.use("/api/shared", limiters.read, limiters.write, keyedWrites, sharedWithMeRouter); + app.use("/api/admin", limiters.admin, keyedWrites, adminRouter); // Unknown API routes get a JSON 404 instead of falling through to the SPA // shell, which would hand HTML to API clients expecting JSON. app.all("/api/{*splat}", (_req, res) => { diff --git a/dist/app.js.map b/dist/app.js.map index 80f7d227..2e4c7b03 100644 --- a/dist/app.js.map +++ b/dist/app.js.map @@ -1 +1 @@ -{"version":3,"file":"app.js","sourceRoot":"","sources":["../src/app.ts"],"names":[],"mappings":"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"} \ No newline at end of file +{"version":3,"file":"app.js","sourceRoot":"","sources":["../src/app.ts"],"names":[],"mappings":"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"} \ No newline at end of file diff --git a/dist/db.d.ts b/dist/db.d.ts index e80b7c8d..34c69d07 100644 --- a/dist/db.d.ts +++ b/dist/db.d.ts @@ -22,8 +22,8 @@ export declare const pool: import("pg").Pool; * * Issues `CREATE TABLE IF NOT EXISTS` for users, projects, groups, group * members, project shares, external (OIDC) identities, the admin audit log, - * and GitHub item links, plus their indexes; runs idempotent `ADD COLUMN IF - * NOT EXISTS` migrations for later-added columns; and, when + * GitHub item links and idempotency keys, plus their indexes; runs idempotent + * `ADD COLUMN IF NOT EXISTS` migrations for later-added columns; and, when * `PM_WEB_BOOTSTRAP_ADMIN_EMAIL` is set, promotes that (lower-cased) user to * admin. Safe to call on every boot. */ diff --git a/dist/db.js b/dist/db.js index 1c196bc2..35a7403c 100644 --- a/dist/db.js +++ b/dist/db.js @@ -67,8 +67,8 @@ const bootstrapAdminEmail = (process.env.PM_WEB_BOOTSTRAP_ADMIN_EMAIL || "") * * Issues `CREATE TABLE IF NOT EXISTS` for users, projects, groups, group * members, project shares, external (OIDC) identities, the admin audit log, - * and GitHub item links, plus their indexes; runs idempotent `ADD COLUMN IF - * NOT EXISTS` migrations for later-added columns; and, when + * GitHub item links and idempotency keys, plus their indexes; runs idempotent + * `ADD COLUMN IF NOT EXISTS` migrations for later-added columns; and, when * `PM_WEB_BOOTSTRAP_ADMIN_EMAIL` is set, promotes that (lower-cased) user to * admin. Safe to call on every boot. */ @@ -173,6 +173,29 @@ export async function initSchema() { ); `); await pool.query(`CREATE INDEX IF NOT EXISTS pm_github_item_links_project ON pm_github_item_links(project_id)`); + // Idempotency keys: one row per (account, key) recording the outcome of a + // mutating request that carried that key, so a retry after an ambiguous + // (lost) response replays the stored outcome instead of re-executing. A row + // with a NULL status_code marks an execution still in flight (or crashed + // before responding); see src/idempotency.ts for the unknown-outcome policy. + await pool.query(` + CREATE TABLE IF NOT EXISTS pm_idempotency_keys ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id UUID NOT NULL REFERENCES pm_users(id) ON DELETE CASCADE, + idempotency_key TEXT NOT NULL, + method TEXT NOT NULL, + path TEXT NOT NULL, + request_fingerprint TEXT NOT NULL, + status_code INTEGER, + response_body TEXT, + response_content_type TEXT, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + UNIQUE (user_id, idempotency_key) + ); + `); + await pool.query("ALTER TABLE pm_idempotency_keys ADD COLUMN IF NOT EXISTS response_content_type TEXT"); + await pool.query("CREATE INDEX IF NOT EXISTS idx_pm_idempotency_created_at ON pm_idempotency_keys (created_at) WHERE status_code IS NOT NULL"); if (bootstrapAdminEmail) { await pool.query(`UPDATE pm_users SET is_admin = TRUE, updated_at = NOW() WHERE lower(email) = lower($1)`, [bootstrapAdminEmail]); } diff --git a/dist/db.js.map b/dist/db.js.map index d6e5872a..8cbdb2b6 100644 --- a/dist/db.js.map +++ b/dist/db.js.map @@ -1 +1 @@ -{"version":3,"file":"db.js","sourceRoot":"","sources":["../src/db.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,MAAM,IAAI,CAAC;AAEpB,MAAM,EAAE,IAAI,EAAE,GAAG,EAAE,CAAC;AAEpB;;;;;;;GAOG;AACH,SAAS,iBAAiB;IACxB,IAAI,OAAO,CAAC,GAAG,CAAC,YAAY,EAAE,CAAC;QAC7B,OAAO,EAAE,gBAAgB,EAAE,OAAO,CAAC,GAAG,CAAC,YAAY,EAAE,CAAC;IACxD,CAAC;IAED,oEAAoE;IACpE,IAAI,OAAO,CAAC,GAAG,CAAC,aAAa,IAAI,OAAO,CAAC,GAAG,CAAC,WAAW,EAAE,CAAC;QACzD,OAAO;YACL,IAAI,EAAE,OAAO,CAAC,GAAG,CAAC,aAAa;YAC/B,IAAI,EAAE,QAAQ,CAAC,OAAO,CAAC,GAAG,CAAC,aAAa,IAAI,MAAM,EAAE,EAAE,CAAC;YACvD,IAAI,EAAE,OAAO,CAAC,GAAG,CAAC,aAAa;YAC/B,QAAQ,EAAE,OAAO,CAAC,GAAG,CAAC,iBAAiB;YACvC,QAAQ,EAAE,OAAO,CAAC,GAAG,CAAC,WAAW;SAClC,CAAC;IACJ,CAAC;IAED,OAAO,EAAE,CAAC;AACZ,CAAC;AAED;;;;;;;GAOG;AACH,MAAM,UAAU,kBAAkB;IAChC,MAAM,UAAU,GACd,OAAO,CAAC,OAAO,CAAC,GAAG,CAAC,YAAY,CAAC;QACjC,OAAO,CAAC,OAAO,CAAC,GAAG,CAAC,aAAa,IAAI,OAAO,CAAC,GAAG,CAAC,WAAW,CAAC,CAAC;IAChE,IAAI,UAAU;QAAE,OAAO;IACvB,MAAM,IAAI,KAAK,CACb,mEAAmE;QACjE,mCAAmC;QACnC,qEAAqE;QACrE,yEAAyE,CAC5E,CAAC;AACJ,CAAC;AAED;;;;;;;;GAQG;AACH,MAAM,CAAC,MAAM,IAAI,GAAG,IAAI,IAAI,CAAC;IAC3B,GAAG,iBAAiB,EAAE;IACtB,2EAA2E;IAC3E,wEAAwE;IACxE,GAAG,EAAE,IAAI,CAAC,GAAG,CAAC,CAAC,EAAE,QAAQ,CAAC,OAAO,CAAC,GAAG,CAAC,kBAAkB,IAAI,IAAI,EAAE,EAAE,CAAC,IAAI,EAAE,CAAC;IAC5E,iBAAiB,EAAE,MAAM;IACzB,uBAAuB,EAAE,KAAK;CAC/B,CAAC,CAAC;AAEH,MAAM,mBAAmB,GAAG,CAAC,OAAO,CAAC,GAAG,CAAC,4BAA4B,IAAI,EAAE,CAAC;KACzE,IAAI,EAAE;KACN,WAAW,EAAE,CAAC;AAEjB;;;;;;;;;GASG;AACH,MAAM,CAAC,KAAK,UAAU,UAAU;IAC9B,MAAM,IAAI,CAAC,KAAK,CAAC;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAyDhB,CAAC,CAAC;IAEH,0CAA0C;IAC1C,MAAM,IAAI,CAAC,KAAK,CAAC;;;;;;GAMhB,CAAC,CAAC;IAEH,MAAM,IAAI,CAAC,KAAK,CAAC;;;;;;;;;;;;;GAahB,CAAC,CAAC;IAEH,MAAM,IAAI,CAAC,KAAK,CACd;;;;;;MAME,CACH,CAAC;IAEF,MAAM,IAAI,CAAC,KAAK,CACd,yFAAyF,CAC1F,CAAC;IAEF,MAAM,IAAI,CAAC,KAAK,CAAC;;;;;;;;;;GAUhB,CAAC,CAAC;IAEH,MAAM,IAAI,CAAC,KAAK,CACd,6FAA6F,CAC9F,CAAC;IAEF,IAAI,mBAAmB,EAAE,CAAC;QACxB,MAAM,IAAI,CAAC,KAAK,CACd,wFAAwF,EACxF,CAAC,mBAAmB,CAAC,CACtB,CAAC;IACJ,CAAC;AACH,CAAC"} \ No newline at end of file +{"version":3,"file":"db.js","sourceRoot":"","sources":["../src/db.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,MAAM,IAAI,CAAC;AAEpB,MAAM,EAAE,IAAI,EAAE,GAAG,EAAE,CAAC;AAEpB;;;;;;;GAOG;AACH,SAAS,iBAAiB;IACxB,IAAI,OAAO,CAAC,GAAG,CAAC,YAAY,EAAE,CAAC;QAC7B,OAAO,EAAE,gBAAgB,EAAE,OAAO,CAAC,GAAG,CAAC,YAAY,EAAE,CAAC;IACxD,CAAC;IAED,oEAAoE;IACpE,IAAI,OAAO,CAAC,GAAG,CAAC,aAAa,IAAI,OAAO,CAAC,GAAG,CAAC,WAAW,EAAE,CAAC;QACzD,OAAO;YACL,IAAI,EAAE,OAAO,CAAC,GAAG,CAAC,aAAa;YAC/B,IAAI,EAAE,QAAQ,CAAC,OAAO,CAAC,GAAG,CAAC,aAAa,IAAI,MAAM,EAAE,EAAE,CAAC;YACvD,IAAI,EAAE,OAAO,CAAC,GAAG,CAAC,aAAa;YAC/B,QAAQ,EAAE,OAAO,CAAC,GAAG,CAAC,iBAAiB;YACvC,QAAQ,EAAE,OAAO,CAAC,GAAG,CAAC,WAAW;SAClC,CAAC;IACJ,CAAC;IAED,OAAO,EAAE,CAAC;AACZ,CAAC;AAED;;;;;;;GAOG;AACH,MAAM,UAAU,kBAAkB;IAChC,MAAM,UAAU,GACd,OAAO,CAAC,OAAO,CAAC,GAAG,CAAC,YAAY,CAAC;QACjC,OAAO,CAAC,OAAO,CAAC,GAAG,CAAC,aAAa,IAAI,OAAO,CAAC,GAAG,CAAC,WAAW,CAAC,CAAC;IAChE,IAAI,UAAU;QAAE,OAAO;IACvB,MAAM,IAAI,KAAK,CACb,mEAAmE;QACjE,mCAAmC;QACnC,qEAAqE;QACrE,yEAAyE,CAC5E,CAAC;AACJ,CAAC;AAED;;;;;;;;GAQG;AACH,MAAM,CAAC,MAAM,IAAI,GAAG,IAAI,IAAI,CAAC;IAC3B,GAAG,iBAAiB,EAAE;IACtB,2EAA2E;IAC3E,wEAAwE;IACxE,GAAG,EAAE,IAAI,CAAC,GAAG,CAAC,CAAC,EAAE,QAAQ,CAAC,OAAO,CAAC,GAAG,CAAC,kBAAkB,IAAI,IAAI,EAAE,EAAE,CAAC,IAAI,EAAE,CAAC;IAC5E,iBAAiB,EAAE,MAAM;IACzB,uBAAuB,EAAE,KAAK;CAC/B,CAAC,CAAC;AAEH,MAAM,mBAAmB,GAAG,CAAC,OAAO,CAAC,GAAG,CAAC,4BAA4B,IAAI,EAAE,CAAC;KACzE,IAAI,EAAE;KACN,WAAW,EAAE,CAAC;AAEjB;;;;;;;;;GASG;AACH,MAAM,CAAC,KAAK,UAAU,UAAU;IAC9B,MAAM,IAAI,CAAC,KAAK,CAAC;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAyDhB,CAAC,CAAC;IAEH,0CAA0C;IAC1C,MAAM,IAAI,CAAC,KAAK,CAAC;;;;;;GAMhB,CAAC,CAAC;IAEH,MAAM,IAAI,CAAC,KAAK,CAAC;;;;;;;;;;;;;GAahB,CAAC,CAAC;IAEH,MAAM,IAAI,CAAC,KAAK,CACd;;;;;;MAME,CACH,CAAC;IAEF,MAAM,IAAI,CAAC,KAAK,CACd,yFAAyF,CAC1F,CAAC;IAEF,MAAM,IAAI,CAAC,KAAK,CAAC;;;;;;;;;;GAUhB,CAAC,CAAC;IAEH,MAAM,IAAI,CAAC,KAAK,CACd,6FAA6F,CAC9F,CAAC;IAEF,0EAA0E;IAC1E,wEAAwE;IACxE,4EAA4E;IAC5E,yEAAyE;IACzE,6EAA6E;IAC7E,MAAM,IAAI,CAAC,KAAK,CAAC;;;;;;;;;;;;;;;GAehB,CAAC,CAAC;IAEH,MAAM,IAAI,CAAC,KAAK,CAAC,qFAAqF,CAAC,CAAC;IACxG,MAAM,IAAI,CAAC,KAAK,CAAC,4HAA4H,CAAC,CAAC;IAE/I,IAAI,mBAAmB,EAAE,CAAC;QACxB,MAAM,IAAI,CAAC,KAAK,CACd,wFAAwF,EACxF,CAAC,mBAAmB,CAAC,CACtB,CAAC;IACJ,CAAC;AACH,CAAC"} \ No newline at end of file diff --git a/dist/idempotency.d.ts b/dist/idempotency.d.ts new file mode 100644 index 00000000..7e94fbf0 --- /dev/null +++ b/dist/idempotency.d.ts @@ -0,0 +1,57 @@ +import type { RequestHandler } from "express"; +/** + * Minimum length an `Idempotency-Key` header value may have. + * + * Keys are generated by the offline mutation queue as 128-bit UUID-shaped + * strings, so anything shorter than this is a client bug rather than a key, + * and accepting it would let a truncated or hand-typed value collide with a + * future real key. + */ +export declare const IDEMPOTENCY_KEY_MIN_LENGTH = 8; +/** + * Maximum length an `Idempotency-Key` header value may have. + * + * Bounds the stored key so a hostile client cannot stuff multi-kilobyte + * strings into the unique index; real keys are 36-character UUID strings. + */ +export declare const IDEMPOTENCY_KEY_MAX_LENGTH = 200; +/** + * Compute the fingerprint that binds one idempotency key to one request. + * + * The fingerprint covers the method, the full path with query string, and the + * parsed JSON body, so a key can only ever be replayed for the identical + * request it was created for. Re-serializing the parsed body is stable here + * because a retry sends the same bytes (the offline queue persists the exact + * body string), which parses to the same property order; a different body — + * different properties or different values — produces a different fingerprint + * and is rejected rather than silently deduplicated. + * + * @param method - The HTTP method of the request. + * @param path - The request path including any query string. + * @param body - The parsed request body, or `null` when the request has none. + * @returns A hex SHA-256 digest over the request identity. + */ +export declare function requestFingerprint(method: string, path: string, body: unknown): string; +/** + * Build the middleware that makes keyed mutating requests at-most-once. + * + * Scope: every mutating (`POST`/`PATCH`/`PUT`/`DELETE`…) `/api` request that + * carries an `Idempotency-Key` header. Requests without a key are untouched — + * the header is opt-in, so ordinary clients keep the plain behaviour — and + * safe methods never touch the store. + * + * Exactly-once per account: the key is stored together with the authenticated + * user, so two accounts using the same key never interfere. The first + * execution atomically claims the (account, key) pair, stores its outcome + * when the response finishes, and any duplicate — a retry after an ambiguous + * response the client never saw, or a concurrent duplicate of an in-flight + * request — is answered from that stored outcome without re-executing. A key + * reused for a *different* request is rejected with 422 rather than silently + * deduplicated; an unfinished record older than the pending timeout is + * answered with 409 outcome unknown without re-execution; an in-flight + * duplicate waits for it to settle and answers 409 only at the + * deadline. + * + * @returns An Express middleware enforcing the contract on its mount. + */ +export declare function idempotencyGuard(): RequestHandler; diff --git a/dist/idempotency.js b/dist/idempotency.js new file mode 100644 index 00000000..85ea6e73 --- /dev/null +++ b/dist/idempotency.js @@ -0,0 +1,315 @@ +import crypto from "node:crypto"; +import { pool } from "./db.js"; +import { extractToken, verifyToken } from "./auth.js"; +import { isUnsafeMethod } from "./rate-limit.js"; +/** + * Minimum length an `Idempotency-Key` header value may have. + * + * Keys are generated by the offline mutation queue as 128-bit UUID-shaped + * strings, so anything shorter than this is a client bug rather than a key, + * and accepting it would let a truncated or hand-typed value collide with a + * future real key. + */ +export const IDEMPOTENCY_KEY_MIN_LENGTH = 8; +/** + * Maximum length an `Idempotency-Key` header value may have. + * + * Bounds the stored key so a hostile client cannot stuff multi-kilobyte + * strings into the unique index; real keys are 36-character UUID strings. + */ +export const IDEMPOTENCY_KEY_MAX_LENGTH = 200; +/** + * How long a duplicate waits for the in-flight original to settle, in + * milliseconds, before answering 409. Override: `PM_WEB_IDEMPOTENCY_WAIT_MS`. + * + * The default covers the longest mutating route (pm CLI commands time out at + * 30 s), so a concurrent duplicate normally observes the original's stored + * outcome instead of being told to retry. + */ +const DEFAULT_WAIT_MS = 30_000; +/** How often a waiting duplicate re-checks the store, in milliseconds. */ +const DEFAULT_POLL_MS = 25; +/** Age after which an unfinished execution is reported as outcome unknown. */ +const DEFAULT_PENDING_TIMEOUT_MS = 300_000; +/** + * How long a settled idempotency record is kept before it is swept, in + * milliseconds. Override: `PM_WEB_IDEMPOTENCY_RETENTION_MS`. + * + * Completed outcomes are deduplicated within this window. A retry after expiry + * can execute again. Pending intents have no expiry because their mutations + * may have committed without a captured outcome. + */ +const DEFAULT_RETENTION_MS = 7 * 24 * 60 * 60 * 1000; +/** + * Compute the fingerprint that binds one idempotency key to one request. + * + * The fingerprint covers the method, the full path with query string, and the + * parsed JSON body, so a key can only ever be replayed for the identical + * request it was created for. Re-serializing the parsed body is stable here + * because a retry sends the same bytes (the offline queue persists the exact + * body string), which parses to the same property order; a different body — + * different properties or different values — produces a different fingerprint + * and is rejected rather than silently deduplicated. + * + * @param method - The HTTP method of the request. + * @param path - The request path including any query string. + * @param body - The parsed request body, or `null` when the request has none. + * @returns A hex SHA-256 digest over the request identity. + */ +export function requestFingerprint(method, path, body) { + return crypto + .createHash("sha256") + .update(`${method}\n${path}\n${JSON.stringify(body ?? null)}`) + .digest("hex"); +} +/** + * Resolve a positive millisecond duration from the environment. + * + * Accepts only finite positive numbers; anything else (unset, empty, zero, + * negative, non-numeric) falls back to the shipped default so a typo in an + * environment variable degrades to the documented behaviour instead of + * disabling a timeout entirely. + * + * @param name - The environment variable to read. + * @param fallback - The default used when the variable is unusable. + * @returns The resolved duration in milliseconds. + */ +function envDurationMs(name, fallback) { + const parsed = Number(process.env[name]); + return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback; +} +/** + * Insert the intent to execute one keyed request, or report a conflict. + * + * `ON CONFLICT DO NOTHING` makes the insert atomic against a concurrent + * duplicate: exactly one request can own a (user, key) pair at a time, and + * the loser learns it lost by receiving no row rather than by an error it + * would have to disambiguate from a genuine failure. + * + * @returns The id of the inserted row when this call owns the execution, or + * `null` when the key is already held. + */ +async function insertPendingKey(userId, key, method, path, fingerprint) { + const result = await pool.query(`INSERT INTO pm_idempotency_keys (user_id, idempotency_key, method, path, request_fingerprint) + VALUES ($1, $2, $3, $4, $5) + ON CONFLICT (user_id, idempotency_key) DO NOTHING + RETURNING id`, [userId, key, method, path, fingerprint]); + const row = result.rows[0]; + return row?.id ?? null; +} +/** + * Read the current idempotency record for one (account, key) pair. + * + * @returns The record, or `null` when no row exists (which can only happen + * transiently: a settled record is swept only by retention, and a pending + * one only by an explicit transient refusal). + */ +async function selectKey(userId, key) { + const result = await pool.query(`SELECT id, request_fingerprint, status_code, response_body, response_content_type, created_at + FROM pm_idempotency_keys + WHERE user_id = $1 AND idempotency_key = $2`, [userId, key]); + return result.rows[0] ?? null; +} +/** + * Sweep idempotency records past the retention window. + * + * Called at most once per minute per guard instance. Pending records are never + * swept: they may represent committed mutations with an unknown outcome. + */ +async function deleteExpiredKeys() { + await pool.query(`DELETE FROM pm_idempotency_keys + WHERE status_code IS NOT NULL AND created_at < NOW() - ($1::double precision * interval '1 ms')`, [envDurationMs("PM_WEB_IDEMPOTENCY_RETENTION_MS", DEFAULT_RETENTION_MS)]); +} +/** + * Store a definitive outcome. Release only explicit pre-mutation refusals + * (425 Too Early and 429 Too Many Requests). Server failures retain the pending + * intent because a handler may have committed before failing. + */ +async function persistOutcome(rowId, statusCode, body, contentType) { + if (statusCode >= 500) + return; + if (statusCode === 425 || statusCode === 429) { + await pool.query(`DELETE FROM pm_idempotency_keys WHERE id = $1`, [rowId]); + return; + } + await pool.query(`UPDATE pm_idempotency_keys + SET status_code = $2, response_body = $3, response_content_type = $4, updated_at = NOW() + WHERE id = $1`, [rowId, statusCode, body, contentType]); +} +/** + * Answer a duplicate from the stored outcome of the original request. + * + * The `Idempotency-Replayed` header lets the client (and the tests) tell a + * replay from a fresh execution without diffing bodies. Responses that had no + * body keep having none; responses that had one are served with the content + * type they originally carried. + */ +function replayStored(record, res) { + res.setHeader("Idempotency-Replayed", "true"); + if (record.response_body === null) { + res.status(record.status_code ?? 500).end(); + return; + } + res.setHeader("Content-Type", record.response_content_type ?? "application/json"); + res.status(record.status_code ?? 500).send(record.response_body); +} +/** + * Answer a settled record, or reject a key recycled onto a different request. + * + * @param record - The settled record held under the client's key. + * @param fingerprint - The fingerprint of the request being answered. + * @param res - The response to answer on. + */ +function finishSettled(record, fingerprint, res) { + if (record.request_fingerprint !== fingerprint) { + res.status(422).json({ + error: "Idempotency-Key was already used for a different request", + }); + return; + } + replayStored(record, res); +} +/** + * Capture the executing request's response and persist it when it finishes. + * + * `res.json`/`res.send` are wrapped once to record the body the handler + * produced, and the `finish` listener persists whatever status the response + * left with — so handlers that never call `json`/`send` (204s, `end()`) are + * still memoized, just without a body. + * + * @param rowId - The id of this execution's idempotency record. + * @param res - The executing response. + */ +function captureOutcome(rowId, res) { + let capturedBody = null; + const originalJson = res.json.bind(res); + const originalSend = res.send.bind(res); + const capture = (body) => { + if (capturedBody !== null) + return; + capturedBody = typeof body === "string" ? body : JSON.stringify(body ?? null); + }; + res.json = (body) => { + capture(body); + return originalJson(body); + }; + res.send = (body) => { + capture(body); + return originalSend(body); + }; + res.on("finish", () => { + const contentType = res.getHeader("Content-Type"); + void persistOutcome(rowId, res.statusCode, capturedBody, typeof contentType === "string" ? contentType : null).catch((error) => { + // Do not log response bodies, tokens or database error details. + console.error("Failed to persist idempotency outcome; key remains pending", error instanceof Error ? error.name : "unknown error"); + }); + }); +} +/** + * Build the middleware that makes keyed mutating requests at-most-once. + * + * Scope: every mutating (`POST`/`PATCH`/`PUT`/`DELETE`…) `/api` request that + * carries an `Idempotency-Key` header. Requests without a key are untouched — + * the header is opt-in, so ordinary clients keep the plain behaviour — and + * safe methods never touch the store. + * + * Exactly-once per account: the key is stored together with the authenticated + * user, so two accounts using the same key never interfere. The first + * execution atomically claims the (account, key) pair, stores its outcome + * when the response finishes, and any duplicate — a retry after an ambiguous + * response the client never saw, or a concurrent duplicate of an in-flight + * request — is answered from that stored outcome without re-executing. A key + * reused for a *different* request is rejected with 422 rather than silently + * deduplicated; an unfinished record older than the pending timeout is + * answered with 409 outcome unknown without re-execution; an in-flight + * duplicate waits for it to settle and answers 409 only at the + * deadline. + * + * @returns An Express middleware enforcing the contract on its mount. + */ +export function idempotencyGuard() { + let nextSweepAt = 0; + return async (req, res, next) => { + if (!isUnsafeMethod(req.method)) { + next(); + return; + } + const header = req.get("idempotency-key"); + if (header === undefined) { + next(); + return; + } + const key = header.trim(); + if (key.length < IDEMPOTENCY_KEY_MIN_LENGTH || key.length > IDEMPOTENCY_KEY_MAX_LENGTH) { + res.status(400).json({ + error: `Idempotency-Key must be between ${IDEMPOTENCY_KEY_MIN_LENGTH} and ${IDEMPOTENCY_KEY_MAX_LENGTH} characters`, + }); + return; + } + // Only record requests we can attribute to an account; an unauthenticated + // or invalid-session request is passed through for the route to refuse, + // so an anonymous probe can never occupy a key. + let userId = null; + const token = extractToken(req); + if (token) { + try { + userId = verifyToken(token).userId; + } + catch { + userId = null; + } + } + if (userId === null) { + next(); + return; + } + const fingerprint = requestFingerprint(req.method, req.originalUrl, req.body); + const deadline = Date.now() + envDurationMs("PM_WEB_IDEMPOTENCY_WAIT_MS", DEFAULT_WAIT_MS); + let rowId = await insertPendingKey(userId, key, req.method, req.originalUrl, fingerprint); + while (rowId === null) { + const record = await selectKey(userId, key); + if (record && record.status_code !== null) { + // The original settled before this duplicate arrived: replay it. + finishSettled(record, fingerprint, res); + return; + } + if (record && record.request_fingerprint !== fingerprint) { + res.status(422).json({ + error: "Idempotency-Key was already used for a different request", + }); + return; + } + if (record === null) { + rowId = await insertPendingKey(userId, key, req.method, req.originalUrl, fingerprint); + continue; + } + if (isStalePending(record)) { + res.status(409).json({ error: "Idempotency outcome unknown; reconcile the original mutation before submitting new work" }); + return; + } + if (Date.now() >= deadline) { + res.status(409).json({ error: "The request with this Idempotency-Key is still executing or its outcome is unknown; retry later" }); + return; + } + await new Promise((resolve) => { setTimeout(resolve, envDurationMs("PM_WEB_IDEMPOTENCY_POLL_MS", DEFAULT_POLL_MS)); }); + } + if (Date.now() >= nextSweepAt) { + // Reserve the interval before awaiting so concurrent writes share one sweep. + nextSweepAt = Date.now() + 60_000; + await deleteExpiredKeys(); + } + captureOutcome(rowId, res); + next(); + }; +} +/** + * Report whether a pending record is old enough to be a crashed execution. + * + * @param record - The still-pending record to age-check. + * @returns `true` when the record predates the pending timeout. + */ +function isStalePending(record) { + const timeoutMs = envDurationMs("PM_WEB_IDEMPOTENCY_PENDING_TIMEOUT_MS", DEFAULT_PENDING_TIMEOUT_MS); + return Date.now() - record.created_at.getTime() > timeoutMs; +} +//# sourceMappingURL=idempotency.js.map \ No newline at end of file diff --git a/dist/idempotency.js.map b/dist/idempotency.js.map new file mode 100644 index 00000000..0489ed2b --- /dev/null +++ b/dist/idempotency.js.map @@ -0,0 +1 @@ +{"version":3,"file":"idempotency.js","sourceRoot":"","sources":["../src/idempotency.ts"],"names":[],"mappings":"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"} \ No newline at end of file diff --git a/public/src/api.ts b/public/src/api.ts index eab17bdd..09e1592e 100644 --- a/public/src/api.ts +++ b/public/src/api.ts @@ -1,3 +1,5 @@ +import { showOfflineRecovery } from './offline-recovery.js'; + // ═══════════════════════════════════════════════════════════════ // API CLIENT // ═══════════════════════════════════════════════════════════════ @@ -44,6 +46,50 @@ export async function api(method: string, path: string, body?: unkn return data; } +/** Session bridge state belongs to one worker container and its current page. */ +const workerSessions = new WeakMap(); + +/** + * Tell the service worker which account is signed in, so offline-queued + * mutations are durably bound to it. + * + * The worker cannot read cookies, so the page is the only component that + * knows the session. It broadcasts the account on boot, on login and on + * logout; the worker persists the value and stamps every queued mutation + * with it, so a queued mutation can never be replayed under a different + * signed-in account (see public/src/sw.ts). Pass `null` when nobody is + * signed in. A controllerchange listener resends the latest identity after + * first activation or a worker replacement. + * + * @param user - The signed-in user, or `null` after logout or a failed probe. + */ +export function syncServiceWorkerSession(user: { id: string } | null): void { + if (!('serviceWorker' in navigator)) return; + const workers = navigator.serviceWorker; + let session = workerSessions.get(workers); + if (!session) { + session = { userId: null, recovery: null }; + workerSessions.set(workers, session); + const current = session; + workers.addEventListener('controllerchange', () => { + workers.controller?.postMessage({ type: 'AUTH_SESSION', userId: current.userId }); + }); + workers.addEventListener('message', (event: MessageEvent) => { + const data = event.data as { type?: string; blocked?: unknown } | null; + if (data?.type !== 'MUTATIONS_BLOCKED' || !Array.isArray(data.blocked)) return; + current.recovery?.remove(); + current.recovery = showOfflineRecovery(data.blocked, current.userId, (ids, ownerId) => { + if (current.userId !== ownerId) return; + workers.controller?.postMessage({ type: 'REBIND_RECORDS', ids }); + }); + }); + } + session.recovery?.remove(); + session.recovery = null; + session.userId = user?.id ?? null; + workers.controller?.postMessage({ type: 'AUTH_SESSION', userId: session.userId }); +} + /** Response from `GET /api/projects/:projectId/pm/guide` — see src/routes/pm.ts. */ export async function getGuide(projectId: string): Promise { return api('GET', `/projects/${projectId}/pm/guide`); diff --git a/public/src/app.ts b/public/src/app.ts index 12d828d4..162da49e 100644 --- a/public/src/app.ts +++ b/public/src/app.ts @@ -2,7 +2,7 @@ // APP — Main entry point // ═══════════════════════════════════════════════════════════════ import { state } from './state.js'; -import { api, csrfTokenFromCookie } from './api.js'; +import { api, csrfTokenFromCookie, syncServiceWorkerSession } from './api.js'; import type { AuthMeResponse, SearchResponse } from './api-types.js'; import { showView, setOnViewChange, getViewForPath } from './views/router.js'; import { loadProjects, onProjectSelect, loadItemsBadge, renderProjectsView, selectProject, deleteProject, buildCreateProjectModal, submitCreateProject, submitCreateProject2 } from './views/projects.js'; @@ -807,8 +807,10 @@ async function init(): Promise { try { const data = await api('GET','/auth/me'); state.user = data.user; + syncServiceWorkerSession(data.user); await bootApp(); } catch(_) { + syncServiceWorkerSession(null); showAuth(); } } diff --git a/public/src/offline-recovery.ts b/public/src/offline-recovery.ts new file mode 100644 index 00000000..8b569a64 --- /dev/null +++ b/public/src/offline-recovery.ts @@ -0,0 +1,40 @@ +/** + * Show preserved offline work and offer adoption only for records without an + * owner. Clicking explicitly asserts that the listed work belongs to this + * signed-in account. Account changes invalidate the callback in the bridge. + */ +export function showOfflineRecovery( + blocked: unknown[], + userId: string | null, + adopt: (ids: number[], ownerId: string) => void, +): HTMLElement { + const notice = document.createElement('div'); + notice.id = 'offline-recovery'; + notice.className = 'offline-recovery'; + notice.style.cssText = 'position:fixed;bottom:20px;right:20px;z-index:1000;max-width:min(460px,calc(100vw - 40px));max-height:60vh;overflow:auto;overflow-wrap:anywhere;padding:16px;border:1px solid var(--accent,#2dd4bf);border-radius:8px;background:var(--bg-card,#0f172a);color:var(--text-primary,#f1f5f9);box-shadow:0 4px 20px #0004'; + notice.setAttribute('role', 'status'); + const description = document.createElement('p'); + description.textContent = 'Some offline changes are blocked. Sign in to their original account to replay them. Unknown-owner changes can be adopted only if they are yours.'; + const list = document.createElement('ul'); + const unknownIds: number[] = []; + for (const value of blocked) { + if (typeof value !== 'object' || value === null) continue; + const record = value as { id?: unknown; method?: unknown; path?: unknown; reason?: unknown }; + const item = document.createElement('li'); + item.textContent = `${String(record.method ?? '')} ${String(record.path ?? '')} (${String(record.reason ?? '')})`; + list.append(item); + if (record.reason === 'unknown-owner' && typeof record.id === 'number' && Number.isSafeInteger(record.id)) { + unknownIds.push(record.id); + } + } + notice.append(description, list); + if (userId !== null && unknownIds.length > 0) { + const button = document.createElement('button'); + button.className = 'btn btn-primary'; + button.textContent = 'These changes are mine — adopt for my signed-in account'; + button.onclick = () => { adopt(unknownIds, userId); }; + notice.append(button); + } + document.body.append(notice); + return notice; +} diff --git a/public/src/sw.ts b/public/src/sw.ts index dee14549..81b734db 100644 --- a/public/src/sw.ts +++ b/public/src/sw.ts @@ -24,6 +24,11 @@ const BUILD_TIMESTAMP: string = '__BUILD_TIME__'; const CACHE_NAME = 'pm-web-' + (BUILD_TIMESTAMP !== '__BUILD_TIME__' ? BUILD_TIMESTAMP : Date.now().toString(36)); const MUTATION_DB = 'pm-web-offline'; const MUTATION_STORE = 'mutations'; +// The signed-in account the page broadcasts lives in its own store so the +// mutation records and the session can be written and read independently, +// exactly like a real browser would keep them. +const SESSION_STORE = 'session'; +const SESSION_KEY = 'current'; const STATIC_ASSETS: readonly string[] = [ '/', @@ -42,6 +47,7 @@ const STATIC_ASSETS: readonly string[] = [ '/src/i18n.js', '/src/i18n/de.json', '/src/i18n/en.json', + '/src/offline-recovery.js', '/src/state.js', '/src/theme.js', '/src/types.js', @@ -113,6 +119,12 @@ interface QueuedMutation { path: string; body: string | null; timestamp: number; + /** Account that queued the mutation. Absent on legacy records. */ + ownerId?: string | null; + /** Workspace (project id) the queued path targets. Absent on legacy records. */ + workspace?: string | null; + /** Server-enforced exactly-once identity for the replay. */ + idempotencyKey?: string; } interface StoredMutation { @@ -120,6 +132,72 @@ interface StoredMutation { path: string; body: string | null; timestamp: number; + /** Account that queued the mutation; `null` when no session was known. */ + ownerId: string | null; + /** Workspace (project id) the queued path targets; `null` when account-level. */ + workspace: string | null; + /** Server-enforced exactly-once identity for the replay. */ + idempotencyKey: string; +} + +/** The signed-in account as persisted in the `session` store. */ +interface StoredSession { + key: string; + userId: string | null; +} + +/** Shape of the `/api/auth/me` response body the flush bootstrap reads. */ +interface MeBody { + user?: { id?: string }; +} + +/** Why one queued mutation was refused during a flush. */ +type ReplayBlockReason = 'no-session' | 'unknown-owner' | 'owner-mismatch'; + +/** A queued mutation a flush refused, surfaced to the page for explicit recovery. */ +interface BlockedMutation { + id: number; + method: string; + path: string; + ownerId: string | null; + workspace: string | null; + reason: ReplayBlockReason; +} + +/** + * Extract the workspace (project id) a queued API path targets, or `null` for + * account-level mutations. + * + * Every project-scoped mutation the SPA issues lives under + * `/projects//…`, so the first path segment after `/projects/` is + * the workspace the record is bound to. The value is stamped at queue time + * and replayed verbatim in MUTATIONS_BLOCKED reports, so an operator can see + * which workspace a stranded record belongs to without re-deriving it. + * + * @param path - The queued API path (relative to `/api`). + * @returns The project id the path targets, or `null` when the path is account-level. + */ +function workspaceFromPath(path: string): string | null { + const match = /^\/projects\/([^/]+)/.exec(path); + return match ? match[1] : null; +} + +/** + * Generate a fresh idempotency key for one queued mutation. + * + * The key is a random UUID-shaped string so a replayed record can be + * deduplicated server-side even when the response to the first attempt was + * lost after the server committed. It is generated before the first attempt and + * persisted with the record so every retry of the same record reuses the + * same key and a genuinely different mutation never collides with it. + * + * @returns A random 128-bit key formatted as a UUID string. + */ +function newIdempotencyKey(): string { + const bytes = new Uint8Array(16); + crypto.getRandomValues(bytes); + const hex = Array.from(bytes, (byte) => byte.toString(16).padStart(2, '0')).join(''); + return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`; } // Minimal Background Sync event typing. The `WebWorker` lib does not ship @@ -133,13 +211,18 @@ interface SyncEvent extends ExtendableEvent { * index exist. Resolves with the ready database handle. */ function openMutationDB(): Promise { return new Promise((resolve, reject) => { - const request = indexedDB.open(MUTATION_DB, 1); + const request = indexedDB.open(MUTATION_DB, 2); request.onupgradeneeded = () => { const db = request.result; if (!db.objectStoreNames.contains(MUTATION_STORE)) { const store = db.createObjectStore(MUTATION_STORE, { keyPath: 'id', autoIncrement: true }); store.createIndex('timestamp', 'timestamp', { unique: false }); } + // Version 2 added the session store; an existing version-1 database is + // upgraded in place so a returning browser keeps its queued mutations. + if (!db.objectStoreNames.contains(SESSION_STORE)) { + db.createObjectStore(SESSION_STORE, { keyPath: 'key' }); + } }; request.onsuccess = () => resolve(request.result); request.onerror = () => reject(request.error); @@ -160,18 +243,73 @@ function transactionDone(tx: IDBTransaction): Promise { }); } +/** + * Read the account the page last broadcast as signed in, or `null` when no + * session is known. + * + * The session lives in its own IndexedDB store so it survives service-worker + * restarts: a worker can be killed at any time, and a mutation queued after a + * restart must still be stamped with the account that is signed in, not with + * whatever in-memory state a freshly booted worker happens to have. + * + * @returns The persisted user id, or `null` when nothing was broadcast or the + * store cannot be read (both mean ownership is unknown, never guessed). + */ +async function readSession(): Promise { + try { + const db = await openMutationDB(); + const tx = db.transaction(SESSION_STORE, 'readonly'); + const store = tx.objectStore(SESSION_STORE); + const session = await new Promise((resolve, reject) => { + const request = store.get(SESSION_KEY); + request.onsuccess = () => resolve(request.result as StoredSession | undefined); + request.onerror = () => reject(request.error); + }); + return session?.userId ?? null; + } catch { + return null; + } +} + +/** + * Persist the account the page reports as signed in (or `null` on logout). + * + * Called from the AUTH_SESSION message so every later queueMutation stamps its + * records with this account. A storage failure leaves the previous value in + * place rather than silently clearing ownership. + * + * @param userId - The signed-in user id, or `null` when nobody is signed in. + */ +async function saveSession(userId: string | null): Promise { + const db = await openMutationDB(); + const tx = db.transaction(SESSION_STORE, 'readwrite'); + tx.objectStore(SESSION_STORE).put({ key: SESSION_KEY, userId }); + await transactionDone(tx); +} + /** * Queue a mutation for later replay. Returns `true` only when the mutation has * been durably persisted to IndexedDB; `false` when persistence failed so the * caller can respond with an explicit error instead of claiming it was queued. + * + * The record is durably bound to the account the page last broadcast as signed + * in and to the workspace its path targets, and carries a fresh idempotency + * key, so a flush can never replay it under a different signed-in user and a + * retried record can never be applied twice server-side. When no session is + * known the record is still queued, with `ownerId: null`: ownership is + * recorded as unknown and the record is surfaced for explicit recovery + * instead of being guessed (and replayed under whoever happens to be signed + * in next). */ async function queueMutation( method: string, path: string, body: unknown, + idempotencyKey = newIdempotencyKey(), ): Promise { try { const db = await openMutationDB(); + const ownerId = await readSession(); const tx = db.transaction(MUTATION_STORE, 'readwrite'); const store = tx.objectStore(MUTATION_STORE); const record: StoredMutation = { @@ -179,6 +317,9 @@ async function queueMutation( path, body: body !== undefined ? JSON.stringify(body) : null, timestamp: Date.now(), + ownerId, + workspace: workspaceFromPath(path), + idempotencyKey, }; store.add(record); // Await the transaction commit (not just the request dispatch) so the @@ -240,12 +381,49 @@ async function clearMutation(id: number): Promise { } } +/** + * Decide whether one queued mutation may be replayed under the current + * session, and if not, why. + * + * The three refusals are deliberately distinct so a surfaced report tells the + * user what to do: `no-session` means nobody is signed in (log in and flush + * again), `unknown-owner` means the record predates ownership tracking (adopt + * it explicitly), and `owner-mismatch` means the record belongs to a different + * account and must wait for that account — it is never reassigned silently. + * + * @param mut - The queued mutation as IndexedDB returns it. + * @param currentUserId - The account `/api/auth/me` reports as signed in, or + * `null` when nobody is. + * @returns The refusal reason, or `null` when the record may be replayed. + */ +function replayBlockReason( + mut: QueuedMutation, + currentUserId: string | null, +): ReplayBlockReason | null { + if (currentUserId === null) return 'no-session'; + // Legacy records queued before ownership tracking have no ownerId at all. + if (mut.ownerId === undefined || mut.ownerId === null) return 'unknown-owner'; + if (mut.ownerId !== currentUserId) return 'owner-mismatch'; + return null; +} + /** Replay queued mutations to the API in order, removing each on success and * stopping at the first failure so it can retry later, then post-message the * connected clients with how many were replayed or remain. A storage read * failure is never treated as a drained queue: the initial read failure * aborts the flush, and a final read failure reports a partial result rather - * than claiming all mutations were replayed. */ + * than claiming all mutations were replayed. + * + * Identity is authoritative from the server: `/api/auth/me` names the account + * that is signed in, and only records that account queued are replayed. + * Records owned by a different account, records whose ownership is unknown + * (legacy, or queued with no known session), and every record while logged + * out are never replayed and never deleted — they are kept and surfaced via + * MUTATIONS_BLOCKED so the page can offer explicit recovery. Skipping a + * blocked record never blocks the records behind it, so one stranded record + * cannot wedge an entire queue. Replayed records carry their idempotency key, + * so a retry after an ambiguous (lost) response is applied exactly once + * server-side. */ async function flushMutationQueue(): Promise { const read = await getQueuedMutations(); if (!read.ok) { @@ -256,6 +434,7 @@ async function flushMutationQueue(): Promise { if (mutations.length === 0) return; let replayCsrfToken: string | null; + let currentUserId: string | null = null; try { const bootstrap = await fetch('/api/auth/me', { method: 'GET', @@ -267,19 +446,42 @@ async function flushMutationQueue(): Promise { console.warn('Cannot replay offline mutations without a CSRF bootstrap token'); return; } + // The bootstrap response is also the authoritative identity check: the + // signed-in account decides which records may replay. A 401 (or any + // unparseable body) means nobody is signed in, so every record is kept. + const me = await bootstrap.json().catch(() => null) as MeBody | null; + const bootstrapUserId = me?.user?.id; + currentUserId = typeof bootstrapUserId === 'string' ? bootstrapUserId : null; } catch { // The network failed during token bootstrap; preserve the entire queue. return; } + const blocked: BlockedMutation[] = []; let replayed = 0; for (const mut of mutations) { + const reason = replayBlockReason(mut, currentUserId); + if (reason) { + // Never replayed, never deleted: kept for its owner and surfaced so the + // page can offer explicit recovery. Skipping does not stop the loop, so + // blocked records cannot wedge unrelated owned records behind them. + blocked.push({ + id: mut.id, + method: mut.method, + path: mut.path, + ownerId: mut.ownerId ?? null, + workspace: mut.workspace ?? null, + reason, + }); + continue; + } try { const opts: RequestInit = { method: mut.method, headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': replayCsrfToken, + ...(mut.idempotencyKey ? { 'Idempotency-Key': mut.idempotencyKey } : {}), }, credentials: 'include', }; @@ -307,6 +509,12 @@ async function flushMutationQueue(): Promise { // Notify clients about replayed mutations const remainingRead = await getQueuedMutations(); const clients = await sw.clients.matchAll(); + if (blocked.length > 0) { + // Surface refused records for explicit recovery — they are never dropped. + clients.forEach((client) => { + client.postMessage({ type: 'MUTATIONS_BLOCKED', blocked }); + }); + } if (!remainingRead.ok) { // Could not re-read the queue — do NOT claim all mutations were replayed. // Report a partial result with the known replayed count so unreplayed @@ -332,13 +540,45 @@ async function flushMutationQueue(): Promise { clients.forEach((client) => { client.postMessage({ type: 'MUTATIONS_PARTIAL', - replayed: mutations.length - remaining.length, + replayed, remaining: remaining.length, }); }); } } +/** + * Explicitly rebind unknown-owner records to the signed-in account. + * + * The page offers this after a flush surfaces records with the `unknown-owner` + * reason: the signed-in user states that this queued work is theirs. Only + * records whose ownership is unknown are adoptable — a record already owned by + * another account is never reassigned, because its true owner may still + * return to replay it. + * + * @param ids - When given, only the records with these ids are adopted; + * otherwise every unknown-owner record is. + */ +async function rebindUnknownOwnerRecords(ids?: number[]): Promise { + const ownerId = await readSession(); + if (ownerId === null) return; // nobody to adopt the records for + const read = await getQueuedMutations(); + if (!read.ok) return; + const targets = read.mutations.filter( + (mut) => + (mut.ownerId === undefined || mut.ownerId === null) + && (ids === undefined || ids.includes(mut.id)), + ); + if (targets.length === 0) return; + const db = await openMutationDB(); + const tx = db.transaction(MUTATION_STORE, 'readwrite'); + const store = tx.objectStore(MUTATION_STORE); + for (const mut of targets) { + store.put({ ...mut, ownerId, idempotencyKey: mut.idempotencyKey || newIdempotencyKey() }); + } + await transactionDone(tx); +} + // ── Install ── sw.addEventListener('install', (event: ExtendableEvent) => { event.waitUntil( @@ -367,17 +607,23 @@ sw.addEventListener('fetch', (event: FetchEvent) => { if (url.pathname.startsWith('/api/') || url.pathname.startsWith('/healthz')) { // Queue write operations (POST, PUT, PATCH, DELETE) when offline if (event.request.method !== 'GET' && event.request.method !== 'HEAD') { + const headers = new Headers(event.request.headers); + if (!headers.has('Idempotency-Key')) headers.set('Idempotency-Key', newIdempotencyKey()); + const request = new Request(event.request, { headers }); + // Keep an unread copy even if the first network attempt consumes its body. + const queuedRequest = request.clone(); event.respondWith( - fetch(event.request).catch(async () => { + fetch(request).catch(async () => { // Network failed — queue the mutation for later. let body: unknown = undefined; try { - body = await event.request.clone().json(); + body = await queuedRequest.json(); } catch { /* no body */ } const queued = await queueMutation( event.request.method, - url.pathname.replace('/api', ''), + url.pathname.replace('/api', '') + url.search, body, + headers.get('Idempotency-Key') ?? newIdempotencyKey(), ); if (queued) { return new Response( @@ -489,9 +735,14 @@ sw.addEventListener('fetch', (event: FetchEvent) => { ); }); +// Serialise session writes and recovery so adoption cannot read an older login. +let sessionUpdate: Promise = Promise.resolve(); + // ── Messages ── sw.addEventListener('message', (event: ExtendableMessageEvent) => { - const data = event.data as { type?: string; urls?: string[] } | null; + const data = event.data as + | { type?: string; urls?: string[]; userId?: unknown; ids?: unknown } + | null; if (data && data.type === 'SKIP_WAITING') { sw.skipWaiting(); } @@ -500,7 +751,30 @@ sw.addEventListener('message', (event: ExtendableMessageEvent) => { caches.open(CACHE_NAME).then((cache) => cache.addAll(urls).catch(() => {})); } if (data && data.type === 'FLUSH_QUEUE') { - void flushMutationQueue(); + event.waitUntil(sessionUpdate.then(() => flushMutationQueue()).catch(() => { + console.warn('Failed to flush offline mutations'); + })); + } + if (data && data.type === 'AUTH_SESSION') { + // The page names the signed-in account so queued mutations are bound to + // it. `null` (logout) is a valid broadcast: it means ownership of later + // queues is unknown, never the next signed-in account. + const userId = typeof data.userId === 'string' ? data.userId : null; + sessionUpdate = sessionUpdate.catch(() => {}).then(() => saveSession(userId)); + event.waitUntil(sessionUpdate.catch(() => { + console.warn('Failed to persist offline session'); + })); + } + if (data && data.type === 'REBIND_RECORDS') { + // Explicit recovery: adopt unknown-owner records for the signed-in + // account. The ids are optional; without them every unknown-owner record + // is adopted. + const ids = Array.isArray(data.ids) + ? data.ids.filter((id): id is number => typeof id === 'number') + : undefined; + event.waitUntil(sessionUpdate.then(() => rebindUnknownOwnerRecords(ids)).then(() => flushMutationQueue()).catch(() => { + console.warn('Failed to adopt offline mutations'); + })); } }); @@ -533,5 +807,12 @@ sw.addEventListener('online', () => { // rather than keeping both. const __testGlobals = globalThis as unknown as Record; if (__testGlobals.__swTestHarness) { - __testGlobals.__swInternals = { getQueuedMutations, flushMutationQueue, queueMutation, clearMutation }; + __testGlobals.__swInternals = { + getQueuedMutations, + flushMutationQueue, + queueMutation, + clearMutation, + saveSession, + rebindRecords: rebindUnknownOwnerRecords, + }; } diff --git a/public/src/views/auth.ts b/public/src/views/auth.ts index a08e7726..a6187d02 100644 --- a/public/src/views/auth.ts +++ b/public/src/views/auth.ts @@ -2,7 +2,7 @@ // AUTH VIEW // ═══════════════════════════════════════════════════════════════ import { state } from '../state.js'; -import { api } from '../api.js'; +import { api, syncServiceWorkerSession } from '../api.js'; import { bootApp } from '../app.js'; import { t, translateError } from '../i18n.js'; import type { User } from '../types.js'; @@ -97,6 +97,7 @@ export async function submitAuth(e: Event): Promise { data = await api('POST','/auth/register',{email,password,displayName:name||email.split('@')[0]}); } rememberSignedInUser(data.user); + syncServiceWorkerSession(data.user); await bootApp(); } catch(err: unknown) { errEl.textContent = translateError(err instanceof Error ? err.message : String(err)); @@ -115,6 +116,7 @@ export async function logout(): Promise { state.user = null; state.projects = []; state.currentProject = null; + syncServiceWorkerSession(null); showAuth(); } diff --git a/sql/schema.sql b/sql/schema.sql index 04495bc7..dafe30c4 100644 --- a/sql/schema.sql +++ b/sql/schema.sql @@ -101,6 +101,28 @@ CREATE TABLE IF NOT EXISTS pm_github_item_links ( ); CREATE INDEX IF NOT EXISTS pm_github_item_links_project ON pm_github_item_links(project_id); +-- Idempotency keys: one row per (account, key) recording the outcome of a +-- mutating request that carried that key, so a retry after an ambiguous (lost) +-- response replays the stored outcome instead of re-executing. A NULL +-- status_code marks an execution still in flight (or crashed before +-- responding); see src/idempotency.ts for the unknown-outcome policy. +CREATE TABLE IF NOT EXISTS pm_idempotency_keys ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id UUID NOT NULL REFERENCES pm_users(id) ON DELETE CASCADE, + idempotency_key TEXT NOT NULL, + method TEXT NOT NULL, + path TEXT NOT NULL, + request_fingerprint TEXT NOT NULL, + status_code INTEGER, + response_body TEXT, + response_content_type TEXT, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + UNIQUE (user_id, idempotency_key) +); + +CREATE INDEX IF NOT EXISTS idx_pm_idempotency_created_at ON pm_idempotency_keys (created_at) WHERE status_code IS NOT NULL; + -- Bootstrap admin promotion is now applied at runtime via PM_WEB_BOOTSTRAP_ADMIN_EMAIL (see src/db.ts). -- Update trigger diff --git a/src/app.ts b/src/app.ts index 939c54c2..efabf53e 100644 --- a/src/app.ts +++ b/src/app.ts @@ -15,6 +15,7 @@ import { githubRouter } from "./routes/github.ts"; import { adminRouter } from "./routes/admin.ts"; import { createTierLimiters, resolveTrustProxy } from "./rate-limit.ts"; import { csrfProtection } from "./csrf.ts"; +import { idempotencyGuard } from "./idempotency.ts"; const __dirname = path.dirname(fileURLToPath(import.meta.url)); const PUBLIC_DIR = path.resolve(__dirname, "..", "public"); @@ -255,18 +256,20 @@ export function createApp(deps?: CreateAppDeps): Express { // /api/projects/... request matched the /api/projects prefix before its own // nested mount. Each shared prefix therefore carries its limiters once, and // the routers mount behind them. - app.use("/api/auth", limiters.auth); + // Charge every attempt (including replays) before touching the idempotency store. + const keyedWrites = idempotencyGuard(); + app.use("/api/auth", limiters.auth, keyedWrites); app.use("/api/auth", oidcRouter); app.use("/api/auth", authRouter); - app.use("/api/projects", limiters.read, limiters.write); + app.use("/api/projects", limiters.read, limiters.write, keyedWrites); app.use("/api/projects", projectsRouter); app.use("/api/projects/:projectId/pm", pmRouter); app.use("/api/projects/:projectId/extensions", extensionsRouter); app.use("/api/projects/:id/shares", sharesRouter); app.use("/api/projects/:id/github", githubRouter); - app.use("/api/groups", limiters.read, limiters.write, groupsRouter); - app.use("/api/shared", limiters.read, limiters.write, sharedWithMeRouter); - app.use("/api/admin", limiters.admin, adminRouter); + app.use("/api/groups", limiters.read, limiters.write, keyedWrites, groupsRouter); + app.use("/api/shared", limiters.read, limiters.write, keyedWrites, sharedWithMeRouter); + app.use("/api/admin", limiters.admin, keyedWrites, adminRouter); // Unknown API routes get a JSON 404 instead of falling through to the SPA // shell, which would hand HTML to API clients expecting JSON. diff --git a/src/db.ts b/src/db.ts index b71fa212..b2814521 100644 --- a/src/db.ts +++ b/src/db.ts @@ -77,8 +77,8 @@ const bootstrapAdminEmail = (process.env.PM_WEB_BOOTSTRAP_ADMIN_EMAIL || "") * * Issues `CREATE TABLE IF NOT EXISTS` for users, projects, groups, group * members, project shares, external (OIDC) identities, the admin audit log, - * and GitHub item links, plus their indexes; runs idempotent `ADD COLUMN IF - * NOT EXISTS` migrations for later-added columns; and, when + * GitHub item links and idempotency keys, plus their indexes; runs idempotent + * `ADD COLUMN IF NOT EXISTS` migrations for later-added columns; and, when * `PM_WEB_BOOTSTRAP_ADMIN_EMAIL` is set, promotes that (lower-cased) user to * admin. Safe to call on every boot. */ @@ -196,6 +196,31 @@ export async function initSchema(): Promise { `CREATE INDEX IF NOT EXISTS pm_github_item_links_project ON pm_github_item_links(project_id)` ); + // Idempotency keys: one row per (account, key) recording the outcome of a + // mutating request that carried that key, so a retry after an ambiguous + // (lost) response replays the stored outcome instead of re-executing. A row + // with a NULL status_code marks an execution still in flight (or crashed + // before responding); see src/idempotency.ts for the unknown-outcome policy. + await pool.query(` + CREATE TABLE IF NOT EXISTS pm_idempotency_keys ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id UUID NOT NULL REFERENCES pm_users(id) ON DELETE CASCADE, + idempotency_key TEXT NOT NULL, + method TEXT NOT NULL, + path TEXT NOT NULL, + request_fingerprint TEXT NOT NULL, + status_code INTEGER, + response_body TEXT, + response_content_type TEXT, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + UNIQUE (user_id, idempotency_key) + ); + `); + + await pool.query("ALTER TABLE pm_idempotency_keys ADD COLUMN IF NOT EXISTS response_content_type TEXT"); + await pool.query("CREATE INDEX IF NOT EXISTS idx_pm_idempotency_created_at ON pm_idempotency_keys (created_at) WHERE status_code IS NOT NULL"); + if (bootstrapAdminEmail) { await pool.query( `UPDATE pm_users SET is_admin = TRUE, updated_at = NOW() WHERE lower(email) = lower($1)`, diff --git a/src/idempotency.ts b/src/idempotency.ts new file mode 100644 index 00000000..a9007b8c --- /dev/null +++ b/src/idempotency.ts @@ -0,0 +1,384 @@ +import crypto from "node:crypto"; +import type { Request, RequestHandler, Response } from "express"; +import { pool } from "./db.ts"; +import { extractToken, verifyToken } from "./auth.ts"; +import { isUnsafeMethod } from "./rate-limit.ts"; + +/** + * Minimum length an `Idempotency-Key` header value may have. + * + * Keys are generated by the offline mutation queue as 128-bit UUID-shaped + * strings, so anything shorter than this is a client bug rather than a key, + * and accepting it would let a truncated or hand-typed value collide with a + * future real key. + */ +export const IDEMPOTENCY_KEY_MIN_LENGTH = 8; + +/** + * Maximum length an `Idempotency-Key` header value may have. + * + * Bounds the stored key so a hostile client cannot stuff multi-kilobyte + * strings into the unique index; real keys are 36-character UUID strings. + */ +export const IDEMPOTENCY_KEY_MAX_LENGTH = 200; + +/** + * How long a duplicate waits for the in-flight original to settle, in + * milliseconds, before answering 409. Override: `PM_WEB_IDEMPOTENCY_WAIT_MS`. + * + * The default covers the longest mutating route (pm CLI commands time out at + * 30 s), so a concurrent duplicate normally observes the original's stored + * outcome instead of being told to retry. + */ +const DEFAULT_WAIT_MS = 30_000; + +/** How often a waiting duplicate re-checks the store, in milliseconds. */ +const DEFAULT_POLL_MS = 25; + +/** Age after which an unfinished execution is reported as outcome unknown. */ +const DEFAULT_PENDING_TIMEOUT_MS = 300_000; + +/** + * How long a settled idempotency record is kept before it is swept, in + * milliseconds. Override: `PM_WEB_IDEMPOTENCY_RETENTION_MS`. + * + * Completed outcomes are deduplicated within this window. A retry after expiry + * can execute again. Pending intents have no expiry because their mutations + * may have committed without a captured outcome. + */ +const DEFAULT_RETENTION_MS = 7 * 24 * 60 * 60 * 1000; + +/** + * One stored idempotency record, as the `pm_idempotency_keys` table holds it. + * + * `status_code` is `null` while the execution that owns the key is still in + * flight (or crashed before responding); every other field is immutable once + * written, which is what makes a replayed outcome trustworthy. + */ +interface IdempotencyRecord { + /** Row id of the record, used to target the outcome update. */ + id: string; + /** Fingerprint of the request the key was created for. */ + request_fingerprint: string; + /** Response status once settled; `null` while pending. */ + status_code: number | null; + /** Serialized response body once settled; `null` when the response had none. */ + response_body: string | null; + /** Content type the settled response carried; `null` when unknown. */ + response_content_type: string | null; + /** When the intent was inserted; drives the unknown-outcome deadline. */ + created_at: Date; +} + +/** + * Compute the fingerprint that binds one idempotency key to one request. + * + * The fingerprint covers the method, the full path with query string, and the + * parsed JSON body, so a key can only ever be replayed for the identical + * request it was created for. Re-serializing the parsed body is stable here + * because a retry sends the same bytes (the offline queue persists the exact + * body string), which parses to the same property order; a different body — + * different properties or different values — produces a different fingerprint + * and is rejected rather than silently deduplicated. + * + * @param method - The HTTP method of the request. + * @param path - The request path including any query string. + * @param body - The parsed request body, or `null` when the request has none. + * @returns A hex SHA-256 digest over the request identity. + */ +export function requestFingerprint(method: string, path: string, body: unknown): string { + return crypto + .createHash("sha256") + .update(`${method}\n${path}\n${JSON.stringify(body ?? null)}`) + .digest("hex"); +} + +/** + * Resolve a positive millisecond duration from the environment. + * + * Accepts only finite positive numbers; anything else (unset, empty, zero, + * negative, non-numeric) falls back to the shipped default so a typo in an + * environment variable degrades to the documented behaviour instead of + * disabling a timeout entirely. + * + * @param name - The environment variable to read. + * @param fallback - The default used when the variable is unusable. + * @returns The resolved duration in milliseconds. + */ +function envDurationMs(name: string, fallback: number): number { + const parsed = Number(process.env[name]); + return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback; +} + +/** + * Insert the intent to execute one keyed request, or report a conflict. + * + * `ON CONFLICT DO NOTHING` makes the insert atomic against a concurrent + * duplicate: exactly one request can own a (user, key) pair at a time, and + * the loser learns it lost by receiving no row rather than by an error it + * would have to disambiguate from a genuine failure. + * + * @returns The id of the inserted row when this call owns the execution, or + * `null` when the key is already held. + */ +async function insertPendingKey( + userId: string, + key: string, + method: string, + path: string, + fingerprint: string, +): Promise { + const result = await pool.query<{ id: string }>( + `INSERT INTO pm_idempotency_keys (user_id, idempotency_key, method, path, request_fingerprint) + VALUES ($1, $2, $3, $4, $5) + ON CONFLICT (user_id, idempotency_key) DO NOTHING + RETURNING id`, + [userId, key, method, path, fingerprint], + ); + const row = result.rows[0] as { id: string } | undefined; + return row?.id ?? null; +} + +/** + * Read the current idempotency record for one (account, key) pair. + * + * @returns The record, or `null` when no row exists (which can only happen + * transiently: a settled record is swept only by retention, and a pending + * one only by an explicit transient refusal). + */ +async function selectKey(userId: string, key: string): Promise { + const result = await pool.query( + `SELECT id, request_fingerprint, status_code, response_body, response_content_type, created_at + FROM pm_idempotency_keys + WHERE user_id = $1 AND idempotency_key = $2`, + [userId, key], + ); + return (result.rows[0] as IdempotencyRecord | undefined) ?? null; +} + +/** + * Sweep idempotency records past the retention window. + * + * Called at most once per minute per guard instance. Pending records are never + * swept: they may represent committed mutations with an unknown outcome. + */ +async function deleteExpiredKeys(): Promise { + await pool.query( + `DELETE FROM pm_idempotency_keys + WHERE status_code IS NOT NULL AND created_at < NOW() - ($1::double precision * interval '1 ms')`, + [envDurationMs("PM_WEB_IDEMPOTENCY_RETENTION_MS", DEFAULT_RETENTION_MS)], + ); +} + +/** + * Store a definitive outcome. Release only explicit pre-mutation refusals + * (425 Too Early and 429 Too Many Requests). Server failures retain the pending + * intent because a handler may have committed before failing. + */ +async function persistOutcome( + rowId: string, + statusCode: number, + body: string | null, + contentType: string | null, +): Promise { + if (statusCode >= 500) return; + if (statusCode === 425 || statusCode === 429) { + await pool.query(`DELETE FROM pm_idempotency_keys WHERE id = $1`, [rowId]); + return; + } + await pool.query( + `UPDATE pm_idempotency_keys + SET status_code = $2, response_body = $3, response_content_type = $4, updated_at = NOW() + WHERE id = $1`, + [rowId, statusCode, body, contentType], + ); +} + +/** + * Answer a duplicate from the stored outcome of the original request. + * + * The `Idempotency-Replayed` header lets the client (and the tests) tell a + * replay from a fresh execution without diffing bodies. Responses that had no + * body keep having none; responses that had one are served with the content + * type they originally carried. + */ +function replayStored(record: IdempotencyRecord, res: Response): void { + res.setHeader("Idempotency-Replayed", "true"); + if (record.response_body === null) { + res.status(record.status_code ?? 500).end(); + return; + } + res.setHeader("Content-Type", record.response_content_type ?? "application/json"); + res.status(record.status_code ?? 500).send(record.response_body); +} + +/** + * Answer a settled record, or reject a key recycled onto a different request. + * + * @param record - The settled record held under the client's key. + * @param fingerprint - The fingerprint of the request being answered. + * @param res - The response to answer on. + */ +function finishSettled(record: IdempotencyRecord, fingerprint: string, res: Response): void { + if (record.request_fingerprint !== fingerprint) { + res.status(422).json({ + error: "Idempotency-Key was already used for a different request", + }); + return; + } + replayStored(record, res); +} + +/** + * Capture the executing request's response and persist it when it finishes. + * + * `res.json`/`res.send` are wrapped once to record the body the handler + * produced, and the `finish` listener persists whatever status the response + * left with — so handlers that never call `json`/`send` (204s, `end()`) are + * still memoized, just without a body. + * + * @param rowId - The id of this execution's idempotency record. + * @param res - The executing response. + */ +function captureOutcome(rowId: string, res: Response): void { + let capturedBody: string | null = null; + const originalJson = res.json.bind(res); + const originalSend = res.send.bind(res); + const capture = (body: unknown): void => { + if (capturedBody !== null) return; + capturedBody = typeof body === "string" ? body : JSON.stringify(body ?? null); + }; + res.json = (body?: unknown): Response => { + capture(body); + return originalJson(body); + }; + res.send = (body?: unknown): Response => { + capture(body); + return originalSend(body); + }; + res.on("finish", () => { + const contentType = res.getHeader("Content-Type"); + void persistOutcome( + rowId, + res.statusCode, + capturedBody, + typeof contentType === "string" ? contentType : null, + ).catch((error: unknown) => { + // Do not log response bodies, tokens or database error details. + console.error("Failed to persist idempotency outcome; key remains pending", error instanceof Error ? error.name : "unknown error"); + }); + }); +} + +/** + * Build the middleware that makes keyed mutating requests at-most-once. + * + * Scope: every mutating (`POST`/`PATCH`/`PUT`/`DELETE`…) `/api` request that + * carries an `Idempotency-Key` header. Requests without a key are untouched — + * the header is opt-in, so ordinary clients keep the plain behaviour — and + * safe methods never touch the store. + * + * Exactly-once per account: the key is stored together with the authenticated + * user, so two accounts using the same key never interfere. The first + * execution atomically claims the (account, key) pair, stores its outcome + * when the response finishes, and any duplicate — a retry after an ambiguous + * response the client never saw, or a concurrent duplicate of an in-flight + * request — is answered from that stored outcome without re-executing. A key + * reused for a *different* request is rejected with 422 rather than silently + * deduplicated; an unfinished record older than the pending timeout is + * answered with 409 outcome unknown without re-execution; an in-flight + * duplicate waits for it to settle and answers 409 only at the + * deadline. + * + * @returns An Express middleware enforcing the contract on its mount. + */ +export function idempotencyGuard(): RequestHandler { + let nextSweepAt = 0; + return async (req: Request, res: Response, next) => { + if (!isUnsafeMethod(req.method)) { + next(); + return; + } + const header = req.get("idempotency-key"); + if (header === undefined) { + next(); + return; + } + const key = header.trim(); + if (key.length < IDEMPOTENCY_KEY_MIN_LENGTH || key.length > IDEMPOTENCY_KEY_MAX_LENGTH) { + res.status(400).json({ + error: `Idempotency-Key must be between ${IDEMPOTENCY_KEY_MIN_LENGTH} and ${IDEMPOTENCY_KEY_MAX_LENGTH} characters`, + }); + return; + } + // Only record requests we can attribute to an account; an unauthenticated + // or invalid-session request is passed through for the route to refuse, + // so an anonymous probe can never occupy a key. + let userId: string | null = null; + const token = extractToken(req); + if (token) { + try { + userId = verifyToken(token).userId; + } catch { + userId = null; + } + } + if (userId === null) { + next(); + return; + } + + const fingerprint = requestFingerprint(req.method, req.originalUrl, req.body); + const deadline = Date.now() + envDurationMs("PM_WEB_IDEMPOTENCY_WAIT_MS", DEFAULT_WAIT_MS); + let rowId = await insertPendingKey(userId, key, req.method, req.originalUrl, fingerprint); + while (rowId === null) { + const record = await selectKey(userId, key); + if (record && record.status_code !== null) { + // The original settled before this duplicate arrived: replay it. + finishSettled(record, fingerprint, res); + return; + } + if (record && record.request_fingerprint !== fingerprint) { + res.status(422).json({ + error: "Idempotency-Key was already used for a different request", + }); + return; + } + if (record === null) { + rowId = await insertPendingKey(userId, key, req.method, req.originalUrl, fingerprint); + continue; + } + if (isStalePending(record)) { + res.status(409).json({ error: "Idempotency outcome unknown; reconcile the original mutation before submitting new work" }); + return; + } + if (Date.now() >= deadline) { + res.status(409).json({ error: "The request with this Idempotency-Key is still executing or its outcome is unknown; retry later" }); + return; + } + await new Promise((resolve) => { setTimeout(resolve, envDurationMs("PM_WEB_IDEMPOTENCY_POLL_MS", DEFAULT_POLL_MS)); }); + } + + if (Date.now() >= nextSweepAt) { + // Reserve the interval before awaiting so concurrent writes share one sweep. + nextSweepAt = Date.now() + 60_000; + await deleteExpiredKeys(); + } + captureOutcome(rowId, res); + next(); + }; +} + +/** + * Report whether a pending record is old enough to be a crashed execution. + * + * @param record - The still-pending record to age-check. + * @returns `true` when the record predates the pending timeout. + */ +function isStalePending(record: IdempotencyRecord): boolean { + const timeoutMs = envDurationMs( + "PM_WEB_IDEMPOTENCY_PENDING_TIMEOUT_MS", + DEFAULT_PENDING_TIMEOUT_MS, + ); + return Date.now() - record.created_at.getTime() > timeoutMs; +} diff --git a/test/frontend-api.test.ts b/test/frontend-api.test.ts index 4857e5c5..803100f8 100644 --- a/test/frontend-api.test.ts +++ b/test/frontend-api.test.ts @@ -2,7 +2,7 @@ import assert from "node:assert/strict"; import test from "node:test"; -import { api, csrfTokenFromCookie } from "../public/src/api.ts"; +import { api, csrfTokenFromCookie, syncServiceWorkerSession } from "../public/src/api.ts"; test("csrfTokenFromCookie decodes the named cookie and rejects malformed encoding", () => { assert.equal( @@ -36,3 +36,70 @@ test("api replays the readable CSRF cookie in the request header", async () => { globals.fetch = originalFetch; } }); + + +test("session is resent on controllerchange with the latest login or logout", () => { + const originalNavigator = Object.getOwnPropertyDescriptor(globalThis, "navigator"); + const messages: unknown[] = []; + const workers = Object.assign(new EventTarget(), { controller: null as null | { postMessage(message: unknown): void } }); + Object.defineProperty(globalThis, "navigator", { configurable: true, value: { serviceWorker: workers } }); + try { + syncServiceWorkerSession({ id: "alice" }); + syncServiceWorkerSession({ id: "bob" }); + workers.controller = { postMessage: (message) => { messages.push(message); } }; + workers.dispatchEvent(new Event("controllerchange")); + assert.deepEqual(messages, [{ type: "AUTH_SESSION", userId: "bob" }]); + syncServiceWorkerSession(null); + workers.dispatchEvent(new Event("controllerchange")); + assert.deepEqual(messages.slice(1), [ + { type: "AUTH_SESSION", userId: null }, { type: "AUTH_SESSION", userId: null }, + ]); + } finally { + if (originalNavigator) Object.defineProperty(globalThis, "navigator", originalNavigator); + else Reflect.deleteProperty(globalThis, "navigator"); + } +}); + + +test("blocked legacy mutations have a visible explicit recovery action for the current account", () => { + const originalNavigator = Object.getOwnPropertyDescriptor(globalThis, "navigator"); + const globals = globalThis as unknown as Record; + const originalDocument = globals.document; + const workers = Object.assign(new EventTarget(), { controller: { postMessage: (message: unknown) => { messages.push(message); } } }); + const messages: unknown[] = []; + // DOM surface exercised by the session bridge: actual event dispatch and nodes retained in the body. + const nodes: Array<{ textContent: string; children: unknown[]; onclick?: () => void; remove(): void }> = []; + globals.document = { + getElementById: () => null, + createElement: () => { + const element = { textContent: "", style: { cssText: "" }, children: [] as unknown[], onclick: undefined as (() => void) | undefined, + append: (...children: unknown[]) => { element.children.push(...children); }, + setAttribute: () => {}, + remove: () => { const index = nodes.indexOf(element); if (index >= 0) nodes.splice(index, 1); }, + }; + return element; + }, + body: { append: (node: typeof nodes[number]) => { nodes.push(node); } }, + }; + Object.defineProperty(globalThis, "navigator", { configurable: true, value: { serviceWorker: workers } }); + try { + syncServiceWorkerSession({ id: "alice" }); + workers.dispatchEvent(new MessageEvent("message", { data: { type: "MUTATIONS_BLOCKED", blocked: [ + { id: 1, method: "POST", path: "/groups", reason: "unknown-owner" }, + { id: 2, method: "PATCH", path: "/items/foreign", reason: "owner-mismatch" }, + ] } })); + assert.equal(nodes.length, 1, "the SPA displays blocked work"); + const button = nodes[0].children.find((child) => (child as { onclick?: unknown }).onclick) as { onclick(): void; textContent: string }; + assert.match(button.textContent, /adopt/i); + assert.equal(messages.length, 1, "nothing is adopted until the user clicks"); + button.onclick(); + assert.deepEqual(messages[1], { type: "REBIND_RECORDS", ids: [1] }); + syncServiceWorkerSession(null); + button.onclick(); + assert.equal(messages.filter((msg) => (msg as { type: string }).type === "REBIND_RECORDS").length, 1, "an old action cannot run after logout"); + } finally { + globals.document = originalDocument; + if (originalNavigator) Object.defineProperty(globalThis, "navigator", originalNavigator); + else Reflect.deleteProperty(globalThis, "navigator"); + } +}); diff --git a/test/helpers/sw-queue-preload.ts b/test/helpers/sw-queue-preload.ts index 29c429ae..775cd3a8 100644 --- a/test/helpers/sw-queue-preload.ts +++ b/test/helpers/sw-queue-preload.ts @@ -4,16 +4,35 @@ * The worker reads `self`, IndexedDB and `caches` at module load. A static * import of this module from the suite runs first and publishes the same * mutable fixtures the tests drive. + * + * The IndexedDB mock models the queue's two object stores separately, because + * the ownership binding keeps the signed-in session in its own `session` store + * while mutation records live in `mutations`: `transaction(storeName)` routes + * to the matching store mock, so a test can seed records and the session + * independently exactly like a real browser would. */ -/** Mutable IndexedDB and client fixtures shared with the queue suite. */ +/** Mutable IndexedDB, session and client fixtures shared with the queue suite. */ export const swQueue = { openShouldFail: false, getAllResult: [] as unknown[], getAllShouldFail: false, deleteCallCount: 0, + /** How many records `add` accepted, for the concurrency assertions. */ + addCallCount: 0, + /** Every record `add` accepted, in arrival order. */ + addedValues: [] as unknown[], + /** The most recent record `add` accepted. */ lastAdded: undefined as unknown, + /** Every record `put` accepted (explicit owner rebinding writes). */ + putValues: [] as unknown[], + /** What the `session` store `get` resolves with (`undefined` = no session). */ + sessionRecord: undefined as unknown, + /** Every session record `put` accepted. */ + sessionPutValues: [] as unknown[], postedMessages: [] as { type: string; [key: string]: unknown }[], + /** Event listeners the worker registered, keyed by event type. */ + listeners: {} as Record void>, }; /** @@ -35,35 +54,86 @@ export function mockRequest(result: unknown, shouldFail: boolean): IDBRequest { return req; } +/** The mock for the `mutations` object store (auto-increment `id` keyPath). + * + * `put` and `delete` keep `swQueue.getAllResult` in sync the way a real + * IndexedDB store would, so a second flush within one test observes exactly + * the records that survived the first one instead of the seeded snapshot. */ export const mockStore: IDBObjectStore = { getAll: () => mockRequest(swQueue.getAllResult, swQueue.getAllShouldFail), add: (value: unknown) => { + swQueue.addCallCount += 1; swQueue.lastAdded = value; + swQueue.addedValues.push(value); return mockRequest(undefined, false); }, - delete: () => { + put: (value: unknown) => { + swQueue.putValues.push(value); + upsertRecord(value); + return mockRequest(undefined, false); + }, + delete: (key: IDBValidKey) => { swQueue.deleteCallCount += 1; + swQueue.getAllResult = swQueue.getAllResult.filter( + (existing) => (existing as { id?: unknown }).id !== key, + ); return mockRequest(undefined, false); }, } as unknown as IDBObjectStore; -const mockTransaction: IDBTransaction = { - objectStore: () => mockStore, - oncomplete: null, - onabort: null, - onerror: null, -} as unknown as IDBTransaction; +/** Replace the `getAllResult` entry with the same `id`, or append it. */ +function upsertRecord(value: unknown): void { + const id = (value as { id?: unknown }).id; + if (typeof id !== "number") return; + const index = swQueue.getAllResult.findIndex( + (existing) => (existing as { id?: unknown }).id === id, + ); + if (index >= 0) swQueue.getAllResult[index] = value; + else swQueue.getAllResult.push(value); +} + +/** + * The mock for the `session` object store (`key` keyPath, single row). + * + * `put` keeps `swQueue.sessionRecord` in sync the way a real store would, so + * a later `readSession` inside the worker observes the account the test + * broadcast instead of the value the fixture was seeded with. + * + * Kept unexported: tests drive it through `swQueue.sessionRecord` (the value + * `get` resolves with) and `swQueue.sessionPutValues` (the writes `put` + * accepted), so the fixture stays a plain data sink exactly like `mockStore`. + */ +const mockSessionStore: IDBObjectStore = { + get: () => mockRequest(swQueue.sessionRecord, false), + put: (value: unknown) => { + swQueue.sessionPutValues.push(value); + swQueue.sessionRecord = value; + return mockRequest(undefined, false); + }, +} as unknown as IDBObjectStore; -/** Schedule oncomplete after the caller assigns handlers. */ -function scheduleCommit(): void { - queueMicrotask(() => (mockTransaction.oncomplete as (() => void) | null)?.()); +/** + * Build one mock transaction over the store `name`. + * + * A fresh transaction object per call mirrors real IndexedDB, where concurrent + * `queueMutation` calls each get their own transaction with their own commit + * callback. Sharing one object would make the last caller's `oncomplete` + * assignment overwrite the earlier waiters', deadlocking every parallel + * queue operation. + */ +function openMockTransaction(name: string): IDBTransaction { + const tx: IDBTransaction = { + objectStore: () => (name === "session" ? mockSessionStore : mockStore), + oncomplete: null, + onabort: null, + onerror: null, + } as unknown as IDBTransaction; + queueMicrotask(() => (tx.oncomplete as (() => void) | null)?.()); + return tx; } const mockDB: IDBDatabase = { - transaction: () => { - scheduleCommit(); - return mockTransaction; - }, + transaction: (storeName: string) => openMockTransaction(storeName), objectStoreNames: { contains: () => true } as unknown as DOMStringList, close: () => {}, } as unknown as IDBDatabase; @@ -81,7 +151,9 @@ const mockClients = [ ]; const mockSelf = { - addEventListener: () => {}, + addEventListener: (type: string, handler: (event: never) => void) => { + swQueue.listeners[type] = handler as (event: unknown) => void; + }, skipWaiting: () => {}, clients: { matchAll: async () => mockClients, @@ -109,6 +181,10 @@ export interface SwQueueInternals { flushMutationQueue: () => Promise; clearMutation: (id: number) => Promise; queueMutation: (method: string, path: string, body: unknown) => Promise; + /** Persists the signed-in account the way the AUTH_SESSION message does. */ + saveSession?: (userId: string | null) => Promise; + /** Rebinds unknown-owner records to the persisted session, as the page asks. */ + rebindRecords?: (ids?: number[]) => Promise; } /** @@ -132,4 +208,4 @@ function isSwQueueInternals(value: unknown): value is SwQueueInternals { && typeof candidate.flushMutationQueue === "function" && typeof candidate.clearMutation === "function" && typeof candidate.queueMutation === "function"; -} +} \ No newline at end of file diff --git a/test/idempotency.test.ts b/test/idempotency.test.ts new file mode 100644 index 00000000..b525e6eb --- /dev/null +++ b/test/idempotency.test.ts @@ -0,0 +1,585 @@ +/** + * Real-Postgres, real-HTTP coverage for the server-side idempotency guard. + * + * Every mutating `/api` request that carries an `Idempotency-Key` header must + * be applied exactly once per account: the first execution stores its outcome, + * and any retry — including a retry after an ambiguous response the client + * never saw — replays the stored outcome instead of re-executing. A key reused + * for a different request is rejected, keys are scoped per account, and a + * request without a key keeps the plain, non-deduplicated behaviour. + * + * The tests run against the real Express application (and, for response-shape + * corner cases the shipped routes never produce, a minimal app that mounts the + * same guard), the real `pg.Pool`, and real HTTP listeners, with no stubs of + * the code under test: a wrong SQL statement or a missing ownership predicate + * fails the assertion instead of being masked by a fake. + */ +import assert from "node:assert/strict"; +import test from "node:test"; +import express, { type Express } from "express"; +import { readFileSync } from "node:fs"; +import { createApp } from "../src/app.ts"; +import { createTierLimiters } from "../src/rate-limit.ts"; + +import { pool } from "../src/db.ts"; +import { signToken } from "../src/auth.ts"; +import { idempotencyGuard, requestFingerprint } from "../src/idempotency.ts"; +import { + RUN_ID, + authedFetch, + ensureSchema, + seedUser, + setupOwnerAppTest, + type AppServer, + type SeedUser, +} from "./helpers/pg-harness.ts"; +import { startEphemeralServer } from "./helpers/ephemeral-server.ts"; + +/** A unique idempotency key, long enough to pass the length validation. */ +function uniqueKey(label: string): string { + return `key-${label}-${RUN_ID}-${Math.random().toString(36).slice(2)}`; +} + +/** Fingerprint the real groups route payload used by pending-key fixtures. */ +function groupsFingerprint(name: string): string { + return requestFingerprint("POST", "/api/groups", { name }); +} + +/** Build an isolated HTTP probe with the real limiter, guard and PostgreSQL user. */ +async function keyedProbe(): Promise<{ app: Express; owner: SeedUser }> { + await ensureSchema(); + const owner = await seedUser(); + const app = express(); + app.use(express.json()); + app.use(createTierLimiters(process.env).write); + app.use(idempotencyGuard()); + return { app, owner }; +} + +/** Count the groups one account owns — the side effect a retry must not double. */ +async function groupCount(ownerId: string): Promise { + const result = await pool.query<{ n: number }>( + "SELECT count(*)::int AS n FROM pm_groups WHERE owner_id = $1", + [ownerId], + ); + return (result.rows[0] as { n: number }).n; +} + +/** The stored idempotency rows for one (account, key) pair. */ +async function keyRows(userId: string, key: string): Promise< + Array<{ id: string; status_code: number | null; response_body: string | null }> +> { + const result = await pool.query<{ + id: string; + status_code: number | null; + response_body: string | null; + }>( + "SELECT id, status_code, response_body FROM pm_idempotency_keys WHERE user_id = $1 AND idempotency_key = $2", + [userId, key], + ); + return result.rows; +} + +/** Seed a pending idempotency row, optionally backdated to simulate a crash. */ +async function seedPendingKey(fields: { + userId: string; + key: string; + fingerprint: string; + ageMs?: number; +}): Promise { + await pool.query( + `INSERT INTO pm_idempotency_keys (user_id, idempotency_key, method, path, request_fingerprint, created_at) + VALUES ($1, $2, 'POST', '/api/groups', $3, NOW() - ($4::double precision * interval '1 ms'))`, + [fields.userId, fields.key, fields.fingerprint, fields.ageMs ?? 0], + ); +} + +/** Resolve after `ms` milliseconds, for driving the guard's settle window. */ +function sleep(ms: number): Promise { + return new Promise((resolve) => { setTimeout(resolve, ms); }); +} + +/** Issue a cookie-authenticated POST /api/groups with an idempotency key. */ +function keyedGroupPost( + server: AppServer, + user: SeedUser, + key: string, + name: string, +): Promise { + return authedFetch(server, user, "/api/groups", { + method: "POST", + headers: { "content-type": "application/json", "idempotency-key": key }, + body: JSON.stringify({ name }), + }); +} + +test("a retried mutation with the same idempotency key is applied exactly once and replays the stored response", async (t) => { + const { server, owner } = await setupOwnerAppTest(t); + const key = uniqueKey("retry"); + const name = `group-${RUN_ID}-retry`; + + const first = await keyedGroupPost(server, owner, key, name); + assert.equal(first.status, 201, "the original request creates the group"); + assert.equal(first.headers.get("idempotency-replayed"), null, "the first execution is not a replay"); + const firstBody = (await first.json()) as { group: { id: string } }; + + // The client never saw that response (lost connection after the commit); + // from the server's perspective an unread response and a lost one are the + // same thing, so it retries the identical request with the identical key. + const retry = await keyedGroupPost(server, owner, key, name); + assert.equal(retry.status, 201, "the retry succeeds"); + assert.deepEqual(await retry.json(), firstBody, "the retry replays the stored response body"); + assert.equal(retry.headers.get("idempotency-replayed"), "true", "the retry is marked as replayed"); + + assert.equal(await groupCount(owner.id), 1, "the mutation was applied exactly once"); + const rows = await keyRows(owner.id, key); + assert.equal(rows.length, 1, "exactly one record exists for the key"); + assert.equal((rows[0] as { status_code: number | null }).status_code, 201); +}); + +test("a key reused for a different request is rejected, not silently deduplicated", async (t) => { + const { server, owner } = await setupOwnerAppTest(t); + const key = uniqueKey("reuse"); + const firstName = `group-${RUN_ID}-first`; + + const first = await keyedGroupPost(server, owner, key, firstName); + assert.equal(first.status, 201); + + // Same key, different body: must not be treated as a retry of the first. + const otherBody = await keyedGroupPost(server, owner, key, `group-${RUN_ID}-second`); + assert.equal(otherBody.status, 422, "a key reused for a different body is rejected"); + assert.equal(await groupCount(owner.id), 1, "the rejected request created nothing"); + + // Same key, different route and method: also a different request. + const otherPath = await authedFetch(server, owner, "/api/auth/profile", { + method: "PATCH", + headers: { "content-type": "application/json", "idempotency-key": key }, + body: JSON.stringify({ displayName: "Someone Else" }), + }); + assert.equal(otherPath.status, 422, "a key reused on a different route is rejected"); +}); + +test("idempotency keys are scoped per account", async (t) => { + const { server, owner } = await setupOwnerAppTest(t); + const other = await seedUser(); + const key = uniqueKey("scoped"); + const ownerName = `group-${RUN_ID}-owner`; + const otherName = `group-${RUN_ID}-other`; + + const asOwner = await keyedGroupPost(server, owner, key, ownerName); + assert.equal(asOwner.status, 201); + // A second account sending the same key is a different request stream: the + // key must not silently swallow the second account's mutation. + const asOther = await keyedGroupPost(server, other, key, otherName); + assert.equal(asOther.status, 201, "the other account's request is applied, not replayed"); + assert.equal(asOther.headers.get("idempotency-replayed"), null); + + assert.equal(await groupCount(owner.id), 1); + assert.equal(await groupCount(other.id), 1); + assert.equal((await keyRows(owner.id, key)).length, 1); + assert.equal((await keyRows(other.id, key)).length, 1, "each account has its own record"); +}); + +test("requests without an idempotency key keep the plain non-deduplicated behaviour", async (t) => { + const { server, owner } = await setupOwnerAppTest(t); + const name = `group-${RUN_ID}-plain`; + + const first = await authedFetch(server, owner, "/api/groups", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ name }), + }); + assert.equal(first.status, 201); + const second = await authedFetch(server, owner, "/api/groups", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ name }), + }); + // Without a key there is nothing to deduplicate against, so the identical + // request executes a second time — exactly the double-application the + // keyed retry above is protected from. + assert.equal(second.status, 201, "without a key the duplicate executes again"); + assert.equal(second.headers.get("idempotency-replayed"), null); + assert.equal(await groupCount(owner.id), 2, "both executions applied"); +}); + +test("a malformed idempotency key is rejected without touching the store", async (t) => { + const { server, owner } = await setupOwnerAppTest(t); + + const tooShort = await keyedGroupPost(server, owner, "short", `group-${RUN_ID}-s`); + assert.equal(tooShort.status, 400, "a too-short key is rejected"); + assert.equal(await keyRows(owner.id, "short").then((rows) => rows.length), 0); + + const tooLong = await keyedGroupPost( + server, + owner, + "x".repeat(201), + `group-${RUN_ID}-l`, + ); + assert.equal(tooLong.status, 400, "a too-long key is rejected"); +}); + +test("unauthenticated or invalid sessions with a key are not recorded", async (t) => { + const { server } = await setupOwnerAppTest(t); + const key = uniqueKey("unauth"); + + const anonymous = await fetch(server.url("/api/groups"), { + method: "POST", + headers: { "content-type": "application/json", "idempotency-key": key }, + body: JSON.stringify({ name: "nope" }), + }); + assert.equal(anonymous.status, 401, "the route still enforces authentication"); + + const forged = await fetch(server.url("/api/groups"), { + method: "POST", + headers: { + "content-type": "application/json", + "idempotency-key": key, + authorization: "Bearer not-a-real-token", + }, + body: JSON.stringify({ name: "nope" }), + }); + assert.equal(forged.status, 401, "an invalid session is still refused by the route"); + + const rows = await pool.query<{ n: number }>( + "SELECT count(*)::int AS n FROM pm_idempotency_keys WHERE idempotency_key = $1", + [key], + ); + assert.equal((rows.rows[0] as { n: number }).n, 0, "nothing was recorded"); +}); + +test("a safe request carrying a key passes through untouched", async (t) => { + const { server, owner } = await setupOwnerAppTest(t); + const key = uniqueKey("safe"); + + const read = await authedFetch(server, owner, "/api/groups", { + headers: { "idempotency-key": key }, + }); + assert.equal(read.status, 200, "the read is served normally"); + assert.equal( + (await keyRows(owner.id, key)).length, + 0, + "safe methods are never recorded against a key", + ); +}); + +test("parallel requests from several accounts apply each account's mutation exactly once", async (t) => { + const { server } = await setupOwnerAppTest(t); + await ensureSchema(); + const accounts = [await seedUser(), await seedUser(), await seedUser(), await seedUser()]; + + // Every account fires the same keyed mutation five times, concurrently. + const responses = await Promise.all( + accounts.flatMap((account, accountIndex) => + Array.from({ length: 5 }, () => + keyedGroupPost( + server, + account, + `key-concurrent-${RUN_ID}-${accountIndex}`, + `group-${RUN_ID}-c${accountIndex}`, + ), + ), + ), + ); + + for (const [accountIndex, account] of accounts.entries()) { + const own = responses.slice(accountIndex * 5, accountIndex * 5 + 5); + for (const response of own) { + assert.equal(response.status, 201, "every concurrent duplicate succeeds"); + } + const bodies = await Promise.all(own.map((response) => response.json())); + for (const body of bodies.slice(1)) { + assert.deepEqual(body, bodies[0], "every duplicate replays the same stored response"); + } + assert.equal( + own.filter((response) => response.headers.get("idempotency-replayed") === "true").length, + 4, + "exactly one execution and four replays per account", + ); + assert.equal(await groupCount(account.id), 1, "each account's mutation was applied exactly once"); + } +}); + +test("a committed mutation with a stale pending key returns outcome unknown without re-execution", async (t) => { + const { server, owner } = await setupOwnerAppTest(t); + const key = uniqueKey("takeover"); + const name = `group-${RUN_ID}-takeover`; + // A previous execution inserted its intent and died before responding. + await seedPendingKey({ + userId: owner.id, + key, + fingerprint: groupsFingerprint(name), + ageMs: 10 * 60 * 1000, + }); + + await pool.query("INSERT INTO pm_groups (name, owner_id) VALUES ($1, $2)", [name, owner.id]); + const response = await keyedGroupPost(server, owner, key, name); + assert.equal(response.status, 409); + assert.match(JSON.stringify(await response.json()), /outcome unknown/i); + assert.equal(response.headers.get("idempotency-replayed"), null); + assert.equal(await groupCount(owner.id), 1); +}); + +test("a duplicate of an in-flight request waits and replays the stored outcome", async (t) => { + const { server, owner } = await setupOwnerAppTest(t); + const key = uniqueKey("inflight"); + const name = `group-${RUN_ID}-inflight`; + // Another execution holds the key right now (pending, fresh). + await seedPendingKey({ userId: owner.id, key, fingerprint: groupsFingerprint(name) }); + + const pending = keyedGroupPost(server, owner, key, name); + // The holder finishes while the duplicate is waiting. + await sleep(200); + await pool.query( + `UPDATE pm_idempotency_keys + SET status_code = 201, response_body = $3, updated_at = NOW() + WHERE user_id = $1 AND idempotency_key = $2`, + [owner.id, key, JSON.stringify({ group: { id: "stored", name } })], + ); + const response = await pending; + + assert.equal(response.status, 201); + assert.deepEqual( + await response.json(), + { group: { id: "stored", name } }, + "the waiting duplicate replays the holder's stored outcome", + ); + assert.equal(response.headers.get("idempotency-replayed"), "true"); + assert.equal(await groupCount(owner.id), 0, "the waiting duplicate executed nothing"); +}); + +test("a key recycled to a different request while a duplicate waits is rejected", async (t) => { + const { server, owner } = await setupOwnerAppTest(t); + const key = uniqueKey("recycled"); + const name = `group-${RUN_ID}-recycled`; + await seedPendingKey({ userId: owner.id, key, fingerprint: groupsFingerprint(name) }); + + const pending = keyedGroupPost(server, owner, key, name); + await sleep(200); + // The pending record is replaced by a completed record for a different + // request: the waiting duplicate must not be answered with it. + await pool.query( + `DELETE FROM pm_idempotency_keys WHERE user_id = $1 AND idempotency_key = $2`, + [owner.id, key], + ); + await pool.query( + `INSERT INTO pm_idempotency_keys (user_id, idempotency_key, method, path, request_fingerprint, status_code, response_body) + VALUES ($1, $2, 'POST', '/api/groups', $3, 201, '{}')`, + [owner.id, key, requestFingerprint("POST", "/api/groups", { name: "someone-elses-body" })], + ); + const response = await pending; + + assert.equal(response.status, 422, "the recycled key is not answered with a foreign outcome"); +}); + +test("a pending execution that never settles refuses the duplicate at the deadline", async (t) => { + const { server, owner } = await setupOwnerAppTest(t); + const key = uniqueKey("unsettled"); + const name = `group-${RUN_ID}-unsettled`; + await seedPendingKey({ userId: owner.id, key, fingerprint: groupsFingerprint(name) }); + + const previousWait = process.env.PM_WEB_IDEMPOTENCY_WAIT_MS; + process.env.PM_WEB_IDEMPOTENCY_WAIT_MS = "150"; + t.after(() => { + if (previousWait === undefined) delete process.env.PM_WEB_IDEMPOTENCY_WAIT_MS; + else process.env.PM_WEB_IDEMPOTENCY_WAIT_MS = previousWait; + }); + + const response = await keyedGroupPost(server, owner, key, name); + assert.equal(response.status, 409, "an unsettled duplicate is refused, not executed"); + assert.equal(await groupCount(owner.id), 0, "nothing was applied"); +}); + +test("an ambiguous 5xx after a commit never permits re-execution", async (t) => { + const { app, owner } = await keyedProbe(); + let attempts = 0; + app.post("/flaky", async (_req, res) => { + attempts += 1; + await pool.query("INSERT INTO pm_groups (name, owner_id) VALUES ($1, $2)", ["ambiguous", owner.id]); + if (attempts === 1) { + res.status(500).json({ error: "boom" }); + return; + } + res.status(200).json({ attempts }); + }); + const handle = await startEphemeralServer(app); + t.after(() => handle.close()); + + const headers = { + "content-type": "application/json", + authorization: `Bearer ${signToken({ userId: owner.id, email: owner.email })}`, + "idempotency-key": uniqueKey("flaky"), + }; + const failure = await fetch(handle.url("/flaky"), { method: "POST", headers, body: "{}" }); + assert.equal(failure.status, 500, "the first attempt fails"); + + const previousWait = process.env.PM_WEB_IDEMPOTENCY_WAIT_MS; + process.env.PM_WEB_IDEMPOTENCY_WAIT_MS = "50"; + t.after(() => { + if (previousWait === undefined) delete process.env.PM_WEB_IDEMPOTENCY_WAIT_MS; + else process.env.PM_WEB_IDEMPOTENCY_WAIT_MS = previousWait; + }); + const retry = await fetch(handle.url("/flaky"), { method: "POST", headers, body: "{}" }); + assert.equal(retry.status, 409); + assert.equal(attempts, 1, "an ambiguous failure retains its intent"); + assert.equal(await groupCount(owner.id), 1, "the committed mutation is not repeated"); +}); + +test("a response with no body and a plain-text response are replayed with their shape", async (t) => { + const { app, owner } = await keyedProbe(); + app.post("/no-content", (_req, res) => { + res.status(204).end(); + }); + app.post("/text", (_req, res) => { + res.send("plain outcome"); + }); + const handle = await startEphemeralServer(app); + t.after(() => handle.close()); + + const auth = `Bearer ${signToken({ userId: owner.id, email: owner.email })}`; + const noContentKey = uniqueKey("nocontent"); + const first = await fetch(handle.url("/no-content"), { + method: "POST", + headers: { authorization: auth, "idempotency-key": noContentKey }, + }); + assert.equal(first.status, 204); + const replayedNoContent = await fetch(handle.url("/no-content"), { + method: "POST", + headers: { authorization: auth, "idempotency-key": noContentKey }, + }); + assert.equal(replayedNoContent.status, 204, "the status without a body is replayed"); + assert.equal(replayedNoContent.headers.get("idempotency-replayed"), "true"); + + const textKey = uniqueKey("text"); + const text = await fetch(handle.url("/text"), { + method: "POST", + headers: { authorization: auth, "idempotency-key": textKey }, + }); + assert.equal(await text.text(), "plain outcome"); + const replayedText = await fetch(handle.url("/text"), { + method: "POST", + headers: { authorization: auth, "idempotency-key": textKey }, + }); + assert.equal(await replayedText.text(), "plain outcome", "a non-JSON body is replayed"); + assert.equal(replayedText.headers.get("idempotency-replayed"), "true"); +}); + +test("idempotency records older than the retention window are cleaned up", async (t) => { + const { server, owner } = await setupOwnerAppTest(t); + const oldKey = uniqueKey("old"); + const freshKey = uniqueKey("fresh"); + const oldName = `group-${RUN_ID}-old`; + + // A completed record from long before the retention window. + const first = await keyedGroupPost(server, owner, oldKey, oldName); + assert.equal(first.status, 201); + await pool.query( + `UPDATE pm_idempotency_keys + SET created_at = NOW() - (30::double precision * interval '1 day') + WHERE user_id = $1 AND idempotency_key = $2`, + [owner.id, oldKey], + ); + + // A second execution in the same middleware instance does not sweep again. + const fresh = await keyedGroupPost(server, owner, freshKey, `group-${RUN_ID}-fresh`); + assert.equal(fresh.status, 201); + + assert.equal((await keyRows(owner.id, oldKey)).length, 1, "hot-path sweeps are throttled"); + const pendingKey = uniqueKey("retained-pending"); + await seedPendingKey({ userId: owner.id, key: pendingKey, fingerprint: groupsFingerprint("pending"), ageMs: 30 * 86400000 }); + const newServer = await startEphemeralServer(createApp()); + t.after(() => newServer.close()); + await keyedGroupPost(newServer, owner, uniqueKey("sweep"), "sweep"); + assert.equal((await keyRows(owner.id, oldKey)).length, 0, "a new instance sweeps completed expired records"); + assert.equal((await keyRows(owner.id, pendingKey)).length, 1, "unknown outcomes never expire"); + assert.equal((await keyRows(owner.id, freshKey)).length, 1, "the fresh record remains"); +}); + +test("an idempotency store failure fails closed instead of double-applying", async (t) => { + const { server } = await setupOwnerAppTest(t); + // A syntactically valid JWT for a user id that is not a UUID makes the + // store insert fail; the guard must surface that rather than execute the + // mutation unrecorded (which would allow a duplicate on retry). + const forged = await fetch(server.url("/api/groups"), { + method: "POST", + headers: { + "content-type": "application/json", + "idempotency-key": uniqueKey("broken"), + authorization: `Bearer ${signToken({ userId: "not-a-uuid", email: "nobody@e.test" })}`, + }, + body: JSON.stringify({ name: "nope" }), + }); + assert.equal(forged.status, 500, "a store failure is surfaced, not swallowed"); +}); + +test("SQL bootstrap and runtime idempotency table definitions agree and retention is indexed", async () => { + const sql = readFileSync(new URL("../sql/schema.sql", import.meta.url), "utf8"); + const runtime = readFileSync(new URL("../src/db.ts", import.meta.url), "utf8"); + const table = /CREATE TABLE IF NOT EXISTS pm_idempotency_keys \([\s\S]*?\);/; + assert.equal(sql.match(table)?.[0].replace(/\s+/g, " "), runtime.match(table)?.[0].replace(/\s+/g, " ")); + const indexes = await pool.query<{ indexdef: string }>("SELECT indexdef FROM pg_indexes WHERE tablename = 'pm_idempotency_keys'"); + assert.ok(indexes.rows.some((row) => row.indexdef.includes("(created_at)")), "retention has an index"); +}); + +test("transient 425 and 429 refusals are not stored and the identical key can retry", async (t) => { + for (const status of [425, 429]) { + const { app, owner } = await keyedProbe(); + let attempts = 0; + app.post("/refusal", (_req, res) => { + attempts += 1; + res.status(attempts === 1 ? status : 200).json({ attempts }); + }); + const server = await startEphemeralServer(app); + t.after(() => server.close()); + const headers = { authorization: `Bearer ${signToken({ userId: owner.id, email: owner.email })}`, "idempotency-key": uniqueKey("transient") }; + const first = await fetch(server.url("/refusal"), { method: "POST", headers }); + assert.equal(first.status, status); + const retry = await fetch(server.url("/refusal"), { method: "POST", headers }); + assert.equal(retry.status, 200); + assert.deepEqual(await retry.json(), { attempts: 2 }); + } +}); + +test("production rate limiting precedes keyed replays and refusals never claim a key", async (t) => { + const previous = process.env.PM_WEB_RATE_LIMIT_WRITE; + process.env.PM_WEB_RATE_LIMIT_WRITE = "1"; + t.after(() => { + if (previous === undefined) delete process.env.PM_WEB_RATE_LIMIT_WRITE; + else process.env.PM_WEB_RATE_LIMIT_WRITE = previous; + }); + const { server, owner } = await setupOwnerAppTest(t); + const key = uniqueKey("limited"); + assert.equal((await keyedGroupPost(server, owner, key, "limited")).status, 201); + assert.equal((await keyedGroupPost(server, owner, key, "limited")).status, 429, "replays also consume the abuse budget"); + const refusedKey = uniqueKey("not-claimed"); + assert.equal((await keyedGroupPost(server, owner, refusedKey, "refused")).status, 429); + assert.equal((await keyRows(owner.id, refusedKey)).length, 0); +}); + + +test("a PostgreSQL outcome-write failure is caught and leaves a pending intent", async (t) => { + const { server, owner } = await setupOwnerAppTest(t); + const key = uniqueKey("persist-failure"); + const trigger = `outcome_failure_${RUN_ID}`; + await pool.query(`CREATE FUNCTION ${trigger}() RETURNS trigger LANGUAGE plpgsql AS $$ + BEGIN IF NEW.idempotency_key = '${key}' THEN RAISE EXCEPTION 'synthetic outcome storage outage'; END IF; RETURN NEW; END $$`); + await pool.query(`CREATE TRIGGER ${trigger} BEFORE UPDATE ON pm_idempotency_keys FOR EACH ROW EXECUTE FUNCTION ${trigger}()`); + t.after(async () => { + await pool.query(`DROP TRIGGER ${trigger} ON pm_idempotency_keys`); + await pool.query(`DROP FUNCTION ${trigger}()`); + }); + const previousWait = process.env.PM_WEB_IDEMPOTENCY_WAIT_MS; + process.env.PM_WEB_IDEMPOTENCY_WAIT_MS = "100"; + t.after(() => { + if (previousWait === undefined) delete process.env.PM_WEB_IDEMPOTENCY_WAIT_MS; + else process.env.PM_WEB_IDEMPOTENCY_WAIT_MS = previousWait; + }); + const errors: unknown[][] = []; + const originalError = console.error; + console.error = (...args: unknown[]) => { errors.push(args); }; + t.after(() => { console.error = originalError; }); + assert.equal((await keyedGroupPost(server, owner, key, "outage")).status, 201); + assert.equal((await keyedGroupPost(server, owner, key, "outage")).status, 409); + assert.equal(await groupCount(owner.id), 1); + assert.ok(errors.some((entry) => String(entry[0]).includes("persist idempotency outcome")), "persistence failure is recorded"); +}); diff --git a/test/oidc.test.ts b/test/oidc.test.ts index 6dfd8eee..faeade8e 100644 --- a/test/oidc.test.ts +++ b/test/oidc.test.ts @@ -166,7 +166,6 @@ test("general schema includes the idempotent external identity contract", () => // broke on a refactor that preserved it: the limiter now mounts on the prefix // once and the routers mount behind it, because repeating it on the prefix // and again on a nested path charged one request twice. - assert.match(app, /app\.use\("\/api\/auth", limiters\.auth\);/); assert.match(app, /app\.use\("\/api\/auth", oidcRouter\);/); // And the property that fix established: no path is given the same limiter // more than once, so every published budget is the enforced one. @@ -175,6 +174,8 @@ test("general schema includes the idempotent external identity contract", () => path, limiters: [...rest.matchAll(/limiters\.(\w+)/g)].map(([, tier]) => tier), })); + assert.ok(mounts.some((mount) => mount.path === "/api/auth" && mount.limiters.includes("auth")), + "OIDC's shared prefix is guarded by the auth tier even when other middleware follows it"); for (const tier of ["auth", "read", "write", "admin"]) { const paths = mounts.filter((m) => m.limiters.includes(tier)).map((m) => m.path); assert.equal( diff --git a/test/rate-limit.test.ts b/test/rate-limit.test.ts index bdec6afa..d4d750cb 100644 --- a/test/rate-limit.test.ts +++ b/test/rate-limit.test.ts @@ -10,11 +10,20 @@ * budgets, and that the CSRF guard blocks only provably cross-site * cookie-authenticated mutations. The pure helpers (`resolveTrustProxy`, * `isValidEmail`, `buildGitHubIssuesUrl`) are exercised directly. + * + * The multi-user tests at the bottom drive the *real* application built by + * `createApp` with several seeded accounts: they pin the exact published + * budgets at the boundary (N admitted, N+1 refused — the historical bug + * counted a nested-route request twice and halved every budget), and they + * prove that concurrent, multi-account load cannot rotate a forwarded header + * into a fresh bucket under the default (trust-nothing) proxy configuration. */ import assert from "node:assert/strict"; +import { randomUUID } from "node:crypto"; import http from "node:http"; import test from "node:test"; import { startEphemeralServer } from "./helpers/ephemeral-server.ts"; +import { authedFetch, seedUser, setupOwnerAppTest, type AppServer, type SeedUser } from "./helpers/pg-harness.ts"; import express, { type Express, type Request } from "express"; import cookieParser from "cookie-parser"; @@ -446,6 +455,164 @@ test("buildGitHubIssuesUrl encodes query parameters and whitelists state", () => ); }); +/** + * Override the rate-limit environment for the current test and restore it + * afterwards, so a boundary test can configure the exact budget it pins + * without leaking its limits into the other suites sharing this process. + * + * @param t - The test context whose `after` hook restores the environment. + * @param env - The `PM_WEB_RATE_LIMIT_*` overrides to apply. + */ +function setLimitEnv(t: test.TestContext, env: Record): void { + const previous = new Map(); + for (const [key, value] of Object.entries(env)) { + previous.set(key, process.env[key]); + if (value === undefined) delete process.env[key]; + else process.env[key] = value; + } + t.after(() => { + for (const [key, value] of previous) { + if (value === undefined) delete process.env[key]; + else process.env[key] = value; + } + }); +} + +test("a nested project route request is counted exactly once — the published write budget holds at the boundary", async (t) => { + // The write tier is mounted once on the /api/projects prefix, and the nested + // routers (…/pm, …/extensions, …/shares, …/github) sit behind it. A limiter + // counts every request that traverses it, so when the same tier was also + // mounted on the nested path one request was charged twice and the 300/min + // published budget silently became 150/min. Pin the exact boundary against + // the real app: N admitted, N+1 refused. + setLimitEnv(t, { + PM_WEB_RATE_LIMIT_WRITE: "5", + PM_WEB_RATE_LIMIT_READ: "5", + }); + const { server, owner } = await setupOwnerAppTest(t); + const nestedPath = `/api/projects/${randomUUID()}/pm/items`; + + // Authenticated so the request traverses the prefix tier *and* the nested + // router (the prefix router's own auth guard stops anonymous requests + // before the nested mount, which would hide a nested double-mount). + for (let i = 0; i < 5; i++) { + const res = await authedFetch(server, owner, nestedPath, { method: "POST" }); + assert.notEqual(res.status, 429, "each request within the budget is admitted"); + } + const refused = await authedFetch(server, owner, nestedPath, { method: "POST" }); + assert.equal(refused.status, 429, "the request past the budget is refused"); + assert.equal(refused.headers.get("ratelimit-limit"), "5", "the tier reports its configured budget"); +}); + +test("the published auth budget holds exactly: AUTH_LIMIT_PER_MINUTE admitted, one more refused", async (t) => { + // The auth tier guards /api/auth once, ahead of both mounted routers. Pin + // the production default at its boundary so a regression that halves (or + // doubles) the documented 20 requests per minute fails here. + setLimitEnv(t, { PM_WEB_RATE_LIMIT_AUTH: String(AUTH_LIMIT_PER_MINUTE) }); + const { server } = await setupOwnerAppTest(t); + + for (let i = 0; i < AUTH_LIMIT_PER_MINUTE; i++) { + const res = await fetch(server.url("/api/auth/login"), { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ email: "nobody@e.test", password: "wrong-password" }), + }); + assert.equal(res.status, 401, "a rejected login still consumes the auth budget"); + } + const refused = await fetch(server.url("/api/auth/login"), { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ email: "nobody@e.test", password: "wrong-password" }), + }); + assert.equal(refused.status, 429, "the request past the published budget is refused"); + assert.equal( + refused.headers.get("ratelimit-limit"), + String(AUTH_LIMIT_PER_MINUTE), + "the tier reports the published budget", + ); +}); + +/** + * Drive `count` concurrent authenticated POSTs from four seeded accounts at a + * nested project route and assert exactly `budget` of them are admitted. + * + * Shared by the two multi-user budget tests: the load, the seeding and the + * exact-split assertion are the experiment; what differs is the attack being + * disproven, which each caller describes in its own test comment. + * + * @param t - Test context used for env restore and server teardown. + * @param options - `budget` is the configured per-minute limit to pin, + * `count` the number of concurrent requests to fire, and + * `rotateForwardedHeaders` names a different forged forwarded client on + * every request (X-Forwarded-For, X-Real-IP and Forwarded in rotation). + */ +async function assertConcurrentBudget( + t: test.TestContext, + options: { budget: number; count: number; rotateForwardedHeaders?: boolean }, +): Promise { + setLimitEnv(t, { + PM_WEB_RATE_LIMIT_WRITE: String(options.budget), + PM_WEB_RATE_LIMIT_READ: String(options.budget), + }); + const { server } = await setupOwnerAppTest(t); + const accounts: SeedUser[] = [ + await seedUser(), + await seedUser(), + await seedUser(), + await seedUser(), + ]; + const nestedPath = `/api/projects/${randomUUID()}/pm/items`; + + const responses = await Promise.all( + Array.from({ length: options.count }, (_, i) => { + const headers: Record = {}; + if (options.rotateForwardedHeaders) { + if (i % 3 === 0) headers["x-forwarded-for"] = `10.0.0.${i}`; + else if (i % 3 === 1) headers["x-real-ip"] = `10.1.0.${i}`; + else headers["forwarded"] = `for=10.2.0.${i}`; + } + return authedFetch(server, accounts[i % accounts.length], nestedPath, { + method: "POST", + headers, + }); + }), + ); + const admitted = responses.filter((res) => res.status !== 429).length; + const refused = responses.filter((res) => res.status === 429).length; + assert.equal(admitted, options.budget, "exactly the budget was admitted, once per request"); + assert.equal(refused, options.count - options.budget, "the surplus was refused"); +} + +test("parallel requests from several accounts are each counted once against one shared budget", async (t) => { + // Several authenticated accounts hammer the same nested route concurrently. + // The tier keys on the client address, and every test request shares + // 127.0.0.1, so exactly the configured number of requests may pass — if any + // request were counted twice (the historical nested-mount bug), fewer than + // the budget would be admitted and the surplus would be refused early. + await assertConcurrentBudget(t, { budget: 12, count: 20 }); +}); + +test("concurrent accounts rotating X-Forwarded-For cannot draw a fresh bucket under the default configuration", async (t) => { + // The default trusts no proxy hop, so req.ip is the socket address no + // matter what the caller writes into X-Forwarded-For, Forwarded or + // X-Real-IP. Several accounts fire concurrent requests, each naming a + // different forwarded client: if any of those headers were trusted, every + // request would land in its own bucket and the limit would enforce + // nothing. Instead exactly the budget is admitted, all from the one real + // client address the requests actually share. + const previousTrust = process.env.PM_WEB_TRUST_PROXY; + process.env.PM_WEB_TRUST_PROXY = "loopback"; + t.after(() => { + if (previousTrust === undefined) delete process.env.PM_WEB_TRUST_PROXY; + else process.env.PM_WEB_TRUST_PROXY = previousTrust; + }); + await t.test("default configuration preserves the caller's proxy environment", async (inner) => { + setLimitEnv(inner, { PM_WEB_TRUST_PROXY: undefined }); + await assertConcurrentBudget(inner, { budget: 6, count: 20, rotateForwardedHeaders: true }); + }); + assert.equal(process.env.PM_WEB_TRUST_PROXY, "loopback"); +}); + test("the production app exposes rate-limit headers on an API route (wiring check)", async (t) => { const server = await start(createApp()); t.after(() => server.close()); diff --git a/test/sw-idempotency-http.test.ts b/test/sw-idempotency-http.test.ts new file mode 100644 index 00000000..44a8d7a4 --- /dev/null +++ b/test/sw-idempotency-http.test.ts @@ -0,0 +1,67 @@ +/** Real HTTP response loss after a PostgreSQL commit exercises worker queue replay. */ +import assert from "node:assert/strict"; +import test from "node:test"; +import express from "express"; +import { swQueue, swQueueInternals } from "./helpers/sw-queue-preload.ts"; +import "../public/src/sw.ts"; +import { setupOwnerAppTest } from "./helpers/pg-harness.ts"; +import { startEphemeralServer } from "./helpers/ephemeral-server.ts"; +import { createTierLimiters } from "../src/rate-limit.ts"; +import { signToken } from "../src/auth.ts"; +import { pool } from "../src/db.ts"; + +test("a real HTTP response lost after commit replays the queued write exactly once", async (t) => { + const { server, owner } = await setupOwnerAppTest(t); + const networkFetch = globalThis.fetch; + const auth = `Bearer ${signToken({ userId: owner.id, email: owner.email })}`; + const keys: Array = []; + let loseResponse = true; + const proxy = express(); + proxy.use(express.json()); + const limits = createTierLimiters(process.env); + proxy.use(limits.read, limits.write); + proxy.all("/api/{*path}", async (req, res) => { + const key = req.get("idempotency-key"); + if (req.method === "POST") keys.push(key); + const upstream = await networkFetch(server.url(req.originalUrl), { + method: req.method, + headers: { authorization: auth, "content-type": "application/json", ...(key ? { "idempotency-key": key } : {}) }, + ...(req.method === "POST" ? { body: JSON.stringify(req.body as unknown) } : {}), + }); + const body = await upstream.text(); // the real app has committed and finished its response + if (req.method === "POST" && loseResponse) { + loseResponse = false; + req.socket.destroy(); // caller gets a network failure, not the committed response + return; + } + upstream.headers.forEach((value, name) => { res.setHeader(name, value); }); + res.status(upstream.status).send(body); + }); + const transport = await startEphemeralServer(proxy); + t.after(() => transport.close()); + const worker = swQueueInternals(); + await worker.saveSession?.(owner.id); + let response: Promise | undefined; + swQueue.listeners.fetch({ + request: new Request(transport.url("/api/groups"), { + method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ name: "lost-http-response" }), + }), + respondWith: (promise: Promise) => { response = promise; }, + }); + assert.equal((await response)?.status, 202); + assert.ok(keys[0], "the committed first attempt carried a key"); + const queued = swQueue.lastAdded as Record; + assert.equal(queued.idempotencyKey, keys[0]); + swQueue.getAllResult = [{ ...queued, id: 1 }]; + // Resolve the worker's browser-relative URLs through the real loopback HTTP proxy. + globalThis.fetch = (input, init) => networkFetch(transport.url(String(input)), init); + try { + await worker.flushMutationQueue(); + } finally { + Object.assign(globalThis, { fetch: networkFetch }); + } + assert.deepEqual(keys, [queued.idempotencyKey, queued.idempotencyKey]); + const count = await pool.query<{ n: number }>("SELECT count(*)::int AS n FROM pm_groups WHERE owner_id = $1", [owner.id]); + assert.equal(count.rows[0]?.n, 1); + assert.deepEqual(swQueue.getAllResult, [], "the replay drained the queue"); +}); diff --git a/test/sw-queue.test.ts b/test/sw-queue.test.ts index dc1ce226..8069df3a 100644 --- a/test/sw-queue.test.ts +++ b/test/sw-queue.test.ts @@ -7,6 +7,12 @@ * surface (self, indexedDB, caches, clients) before importing the source, * then exercises the discriminated-result contract: a storage read failure * must never be indistinguishable from an empty queue. + * + * The ownership suite covers the multi-user contract: every queued mutation is + * bound to the account that created it (and the workspace its path targets), + * carries a server-enforced idempotency key, and can never replay under a + * different signed-in account. Unknown-owner (legacy) records are preserved + * and surfaced for explicit recovery without blocking owned records. */ import assert from "node:assert/strict"; import test from "node:test"; @@ -23,6 +29,44 @@ const { postedMessages, } = swQueue; +/** Shape of the `/api/auth/me` body the flush bootstrap reads the identity from. */ +interface MeBody { + user?: { id?: string }; +} + +/** Build the mocked `/api/auth/me` response the way the real route answers. */ +function meResponse(userId: string | null, csrfToken: string): Response { + const body = JSON.stringify( + userId === null ? { error: "Authentication required" } : { user: { id: userId } }, + ); + return new Response(body, { + status: userId === null ? 401 : 200, + headers: { "x-csrf-token": csrfToken, "content-type": "application/json" }, + }); +} + +/** A queued-mutation record as IndexedDB returns it, with test-chosen fields. */ +function queuedRecord(fields: { + id: number; + path: string; + ownerId?: string | null; + idempotencyKey?: string; + method?: string; +}): Record { + return { + id: fields.id, + method: fields.method ?? "PATCH", + path: fields.path, + body: null, + timestamp: 0, + ...(fields.ownerId === undefined ? {} : { ownerId: fields.ownerId }), + ...(fields.idempotencyKey === undefined ? {} : { idempotencyKey: fields.idempotencyKey }), + }; +} + +/** The UUID shape every generated idempotency key must have. */ +const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/; + /** Temporarily replaces `globalThis.fetch` and (optionally) `mockStore.getAll`, * runs `flushMutationQueue`, and restores both in a finally block. */ async function withMockedFetch( @@ -43,6 +87,19 @@ async function withMockedFetch( } } +/** Run a queue flush as the given signed-in account and return the mutation + * paths the flush replayed, in replay order. */ +async function flushAs(userId: string | null): Promise { + const replayedPaths: string[] = []; + await withMockedFetch(async (input: RequestInfo | URL) => { + const url = String(input); + if (url === "/api/auth/me") return meResponse(userId, "current-token"); + replayedPaths.push(url); + return new Response(null, { status: 204 }); + }); + return replayedPaths; +} + // ── Tests ── test("sw queue: getQueuedMutations returns ok:false on storage failure, not an empty array", async () => { @@ -98,18 +155,103 @@ test("sw queue: an empty queue flush is a no-op — the two outcomes do not coll assert.equal(postedMessages.length, 0, "empty queue flush must not post any messages"); }); -test("sw queue: an offline mutation is persisted without a stale session token", async () => { +test("sw queue: a queued mutation is bound to the broadcast account, its workspace and a fresh idempotency key", async () => { + assert.ok(internals.saveSession, "the worker must persist the session the page broadcasts"); + swQueue.sessionPutValues.length = 0; + await internals.saveSession("user-alice"); + + swQueue.lastAdded = undefined; + assert.equal( + await internals.queueMutation("POST", "/projects/proj-1/pm/items", { title: "new" }), + true, + ); + const added = swQueue.lastAdded as Record; + assert.equal(added.method, "POST"); + assert.equal(added.path, "/projects/proj-1/pm/items"); + assert.equal(added.body, JSON.stringify({ title: "new" })); + assert.equal(added.ownerId, "user-alice", "the record is bound to the account that queued it"); + assert.equal(added.workspace, "proj-1", "the record is bound to the workspace its path targets"); + assert.match( + String(added.idempotencyKey), + UUID_PATTERN, + "the record carries a generated idempotency key", + ); + assert.ok(!("csrfToken" in added), "no session token is persisted in the record"); + + // Every record gets its own key, so a retried record is deduplicated + // server-side without suppressing a genuinely different mutation. + await internals.queueMutation("POST", "/projects/proj-1/pm/items", { title: "second" }); + const second = swQueue.lastAdded as Record; + assert.notEqual(second.idempotencyKey, added.idempotencyKey, "each record gets a distinct key"); + + // Account-level mutations (no /projects/ segment) have no workspace. + await internals.queueMutation("PATCH", "/auth/profile", { displayName: "Alice" }); + const profile = swQueue.lastAdded as Record; + assert.equal(profile.ownerId, "user-alice"); + assert.equal(profile.workspace, null, "an account-level mutation has no workspace"); +}); + +test("sw queue: a mutation queued with no known session is preserved as unknown-owner", async () => { + assert.ok(internals.saveSession, "the worker must persist the session the page broadcasts"); + // No session was ever broadcast for this browser, so ownership is unknown. + await internals.saveSession(null); swQueue.lastAdded = undefined; assert.equal( await internals.queueMutation("PATCH", "/items/one", { title: "updated" }), true, ); - assert.deepEqual(swQueue.lastAdded, { - method: "PATCH", - path: "/items/one", - body: JSON.stringify({ title: "updated" }), - timestamp: (swQueue.lastAdded as { timestamp: number }).timestamp, - }); + const added = swQueue.lastAdded as Record; + assert.deepEqual( + Object.keys(added).sort(), + ["body", "idempotencyKey", "method", "ownerId", "path", "timestamp", "workspace"].sort(), + "the record persists exactly the queue contract fields", + ); + assert.equal(added.ownerId, null, "unknown ownership is recorded, not guessed"); + assert.match(String(added.idempotencyKey), UUID_PATTERN, "the record still carries an idempotency key"); +}); + +test("sw queue: concurrent queueing stamps every record with the same account and a distinct idempotency key", async () => { + assert.ok(internals.saveSession, "the worker must persist the session the page broadcasts"); + await internals.saveSession("user-alice"); + swQueue.addedValues.length = 0; + + const results = await Promise.all( + Array.from({ length: 8 }, (_, i) => + internals.queueMutation("POST", `/projects/proj-1/pm/items/${i}`, { seq: i }), + ), + ); + assert.deepEqual( + results, + Array.from({ length: 8 }, () => true), + "every concurrent mutation is durably queued", + ); + + assert.equal(swQueue.addedValues.length, 8, "no concurrent mutation is lost"); + const owners = new Set(); + const keys = new Set(); + for (const raw of swQueue.addedValues) { + const record = raw as Record; + owners.add(String(record.ownerId)); + keys.add(String(record.idempotencyKey)); + } + assert.deepEqual([...owners], ["user-alice"], "all concurrent records carry the same owner"); + assert.equal(keys.size, 8, "each concurrent record carries a distinct idempotency key"); +}); + +test("sw queue: the AUTH_SESSION message persists the account the page broadcasts", async () => { + const handler = swQueue.listeners["message"] as + | ((event: { data?: unknown; waitUntil(promise: Promise): void }) => void) + | undefined; + assert.ok(handler, "the worker must register a message listener"); + swQueue.sessionPutValues.length = 0; + const pending: Promise[] = []; + handler({ data: { type: "AUTH_SESSION", userId: "user-carol" }, waitUntil: (promise) => { pending.push(promise); } }); + await Promise.all(pending); + assert.deepEqual( + swQueue.sessionPutValues, + [{ key: "current", userId: "user-carol" }], + "the broadcast account is durably persisted for queue stamping", + ); }); test("sw queue: a stale mutation token is refreshed and replayed without data loss", async () => { @@ -120,6 +262,8 @@ test("sw queue: a stale mutation token is refreshed and replayed without data lo method: "PATCH", path: "/items/stale", body: null, + ownerId: "user-queue", + idempotencyKey: "idem-stale", csrfToken: "stale-session-token", timestamp: 0, }, @@ -138,7 +282,7 @@ test("sw queue: a stale mutation token is refreshed and replayed without data lo const url = String(input); requests.push({ input: url, token: new Headers(init?.headers).get("x-csrf-token") }); if (url === "/api/auth/me") { - return new Response(null, { status: 200, headers: { "x-csrf-token": "migrated-token" } }); + return meResponse("user-queue", "migrated-token"); } return new Response(null, { status: 204 }); }, @@ -156,9 +300,212 @@ test("sw queue: a stale mutation token is refreshed and replayed without data lo assert.deepEqual(postedMessages, [{ type: "MUTATIONS_REPLAYED", count: 1 }]); }); +test("sw queue: a record owned by another account is never replayed and is kept for its owner", async () => { + swQueue.openShouldFail = false; + swQueue.getAllResult = [ + queuedRecord({ id: 1, path: "/items/alice", ownerId: "user-alice", idempotencyKey: "key-alice", method: "POST" }), + queuedRecord({ id: 2, path: "/items/bob", ownerId: "user-bob", idempotencyKey: "key-bob", method: "POST" }), + ]; + swQueue.getAllShouldFail = false; + swQueue.deleteCallCount = 0; + postedMessages.length = 0; + + const replayedPaths = await flushAs("user-alice"); + + assert.deepEqual(replayedPaths, ["/api/items/alice"], "only the current account's record is replayed"); + assert.equal(swQueue.deleteCallCount, 1, "the owned record is cleared exactly once"); + + const blocked = postedMessages.find((msg) => msg.type === "MUTATIONS_BLOCKED"); + assert.ok(blocked, "the foreign-owned record is surfaced for explicit recovery"); + assert.deepEqual( + blocked.blocked as unknown[], + [ + { + id: 2, + method: "POST", + path: "/items/bob", + ownerId: "user-bob", + workspace: null, + reason: "owner-mismatch", + }, + ], + "the blocked report carries the record and why it was refused", + ); + const partial = postedMessages.find((msg) => msg.type === "MUTATIONS_PARTIAL"); + assert.ok(partial, "a flush with a kept record reports the partial outcome"); + assert.equal(partial.replayed, 1); + assert.equal(partial.remaining, 1, "the foreign record stays in the queue — zero data loss"); +}); + +test("sw queue: a record with unknown ownership is surfaced without blocking owned records", async () => { + swQueue.openShouldFail = false; + swQueue.getAllResult = [ + // Legacy record queued before ownership existed: no ownerId field at all. + { id: 1, method: "POST", path: "/items/legacy", body: null, timestamp: 0 }, + queuedRecord({ id: 2, path: "/items/mine", ownerId: "user-bob", idempotencyKey: "key-mine" }), + ]; + swQueue.getAllShouldFail = false; + swQueue.deleteCallCount = 0; + postedMessages.length = 0; + + const replayedPaths = await flushAs("user-bob"); + + assert.deepEqual(replayedPaths, ["/api/items/mine"], "the owned record still replays"); + assert.equal(swQueue.deleteCallCount, 1, "only the owned record is cleared"); + const blocked = postedMessages.find((msg) => msg.type === "MUTATIONS_BLOCKED"); + assert.ok(blocked, "the unknown-owner record is surfaced for explicit recovery"); + const surfaced = blocked.blocked as Array<{ id: number; reason: string; ownerId: string | null }>; + assert.equal(surfaced[0].id, 1); + assert.equal(surfaced[0].reason, "unknown-owner"); + assert.equal(surfaced[0].ownerId, null, "unknown ownership is reported as null, not guessed"); +}); + +test("sw queue: a flush with no signed-in session replays nothing and loses nothing", async () => { + swQueue.openShouldFail = false; + swQueue.getAllResult = [ + queuedRecord({ id: 1, path: "/items/alice", ownerId: "user-alice", idempotencyKey: "key-alice" }), + queuedRecord({ id: 2, path: "/items/bob", ownerId: "user-bob", idempotencyKey: "key-bob" }), + ]; + swQueue.getAllShouldFail = false; + swQueue.deleteCallCount = 0; + postedMessages.length = 0; + + const replayedPaths: string[] = []; + await withMockedFetch(async (input: RequestInfo | URL) => { + const url = String(input); + if (url === "/api/auth/me") return meResponse(null, "logged-out-token"); + replayedPaths.push(url); + return new Response(null, { status: 204 }); + }); + + assert.deepEqual(replayedPaths, [], "no mutation is replayed while logged out"); + assert.equal(swQueue.deleteCallCount, 0, "no queued mutation is deleted — zero data loss"); + const blocked = postedMessages.find((msg) => msg.type === "MUTATIONS_BLOCKED"); + assert.ok(blocked, "the records are surfaced rather than silently dropped"); + const surfaced = blocked.blocked as Array<{ reason: string }>; + assert.equal(surfaced.length, 2); + for (const record of surfaced) { + assert.equal(record.reason, "no-session", "each record names the missing session as the reason"); + } + const partial = postedMessages.find((msg) => msg.type === "MUTATIONS_PARTIAL"); + assert.ok(partial); + assert.equal(partial.replayed, 0); + assert.equal(partial.remaining, 2); +}); + +test("sw queue: a replay carries the record's idempotency key so an ambiguous retry is exactly-once", async () => { + swQueue.openShouldFail = false; + swQueue.getAllResult = [ + queuedRecord({ id: 1, path: "/items/alice", ownerId: "user-alice", idempotencyKey: "idem-alice-1", method: "POST" }), + ]; + swQueue.getAllShouldFail = false; + swQueue.deleteCallCount = 0; + postedMessages.length = 0; + + const mutationHeaders: Headers[] = []; + await withMockedFetch(async (input: RequestInfo | URL, init?: RequestInit) => { + if (String(input) === "/api/auth/me") return meResponse("user-alice", "current-token"); + mutationHeaders.push(new Headers(init?.headers)); + return new Response(null, { status: 204 }); + }); + + assert.equal(mutationHeaders.length, 1); + assert.equal( + mutationHeaders[0].get("idempotency-key"), + "idem-alice-1", + "the replay echoes the record's idempotency key for server-side deduplication", + ); + assert.equal(mutationHeaders[0].get("x-csrf-token"), "current-token"); +}); + +test("sw queue: owned records replay in insertion order", async () => { + swQueue.openShouldFail = false; + swQueue.getAllResult = [ + queuedRecord({ id: 1, path: "/items/first", ownerId: "user-alice", idempotencyKey: "key-1", method: "POST" }), + queuedRecord({ id: 2, path: "/items/second", ownerId: "user-alice", idempotencyKey: "key-2", method: "POST" }), + ]; + swQueue.getAllShouldFail = false; + postedMessages.length = 0; + + const replayedPaths = await flushAs("user-alice"); + + assert.deepEqual(replayedPaths, ["/api/items/first", "/api/items/second"]); + assert.ok( + postedMessages.some((msg) => msg.type === "MUTATIONS_REPLAYED"), + "a fully drained queue reports the replayed count", + ); +}); + +test("sw queue: explicit recovery rebinds unknown-owner records to the current session", async () => { + assert.ok(internals.rebindRecords, "the worker must expose the explicit recovery operation"); + assert.ok(internals.saveSession); + await internals.saveSession("user-bob"); + + swQueue.openShouldFail = false; + swQueue.getAllResult = [ + { id: 1, method: "POST", path: "/items/legacy", body: null, timestamp: 0 }, + ]; + swQueue.getAllShouldFail = false; + swQueue.deleteCallCount = 0; + postedMessages.length = 0; + swQueue.putValues.length = 0; + + // First flush: the legacy record is blocked, not replayed. + await withMockedFetch(async (input: RequestInfo | URL) => { + if (String(input) === "/api/auth/me") return meResponse("user-bob", "current-token"); + return new Response(null, { status: 204 }); + }); + assert.equal(swQueue.deleteCallCount, 0, "the blocked record is not cleared"); + + // Explicit recovery: the signed-in user adopts the unknown-owner record. + await internals.rebindRecords(); + assert.match(String((swQueue.putValues[0] as Record).idempotencyKey), UUID_PATTERN); + assert.deepEqual( + swQueue.putValues, + [{ + idempotencyKey: (swQueue.putValues[0] as Record).idempotencyKey, + id: 1, + method: "POST", + path: "/items/legacy", + body: null, + timestamp: 0, + ownerId: "user-bob", + }], + "the record is rebound to the account that explicitly adopted it", + ); + + // Second flush: the adopted record is now owned and replays. + swQueue.deleteCallCount = 0; + postedMessages.length = 0; + const replayedPaths = await flushAs("user-bob"); + assert.deepEqual(replayedPaths, ["/api/items/legacy"], "the adopted record replays under its new owner"); + assert.equal(swQueue.deleteCallCount, 1); +}); + +test("sw queue: explicit recovery never adopts a record already owned by another account", async () => { + assert.ok(internals.rebindRecords, "the worker must expose the explicit recovery operation"); + assert.ok(internals.saveSession); + await internals.saveSession("user-bob"); + + swQueue.openShouldFail = false; + swQueue.getAllResult = [ + queuedRecord({ id: 1, path: "/items/alice", ownerId: "user-alice", idempotencyKey: "key-alice" }), + ]; + swQueue.getAllShouldFail = false; + swQueue.putValues.length = 0; + + await internals.rebindRecords(); + + assert.deepEqual(swQueue.putValues, [], "a foreign-owned record is never reassigned"); + const record = swQueue.getAllResult[0] as Record; + assert.equal(record.ownerId, "user-alice", "the original owner is preserved"); +}); + test("sw queue: a failed token bootstrap preserves every queued mutation", async () => { swQueue.openShouldFail = false; - swQueue.getAllResult = [{ id: 1, method: "PATCH", path: "/items/preserved", body: null, timestamp: 0 }]; + swQueue.getAllResult = [ + queuedRecord({ id: 1, path: "/items/preserved", ownerId: "user-queue", idempotencyKey: "key-preserved" }), + ]; swQueue.getAllShouldFail = false; swQueue.deleteCallCount = 0; postedMessages.length = 0; @@ -180,8 +527,8 @@ test("sw queue: a final read failure after replay reports the known replayed cou // rather than MUTATIONS_REPLAYED. swQueue.openShouldFail = false; swQueue.getAllResult = [ - { id: 1, method: "POST", path: "/items", body: null, csrfToken: "queued-csrf", timestamp: 0 }, - { id: 2, method: "POST", path: "/items", body: null, csrfToken: "queued-csrf", timestamp: 0 }, + queuedRecord({ id: 1, path: "/items", ownerId: "user-queue", idempotencyKey: "key-1", method: "POST" }), + queuedRecord({ id: 2, path: "/items", ownerId: "user-queue", idempotencyKey: "key-2", method: "POST" }), ]; swQueue.getAllShouldFail = false; postedMessages.length = 0; @@ -194,7 +541,7 @@ test("sw queue: a final read failure after replay reports the known replayed cou init?: RequestInit, ) => { if (String(input) === "/api/auth/me") { - return new Response(null, { status: 200, headers: { "x-csrf-token": "current-token" } }); + return meResponse("user-queue", "current-token"); } sent++; assert.equal(new Headers(init?.headers).get("x-csrf-token"), "current-token"); @@ -219,3 +566,49 @@ test("sw queue: a final read failure after replay reports the known replayed cou "a final read failure must never claim the queue drained", ); }); + +test("sw queue: the first network attempt and lost-response queue share one key", async () => { + await internals.saveSession?.("user-alice"); + const originalFetch = globalThis.fetch; + let firstKey: string | null = null; + globalThis.fetch = async (input) => { + const request = input as Request; + firstKey = request.headers.get("idempotency-key"); + await request.text(); // the network consumed the request before losing the response + throw new TypeError("response lost after commit"); + }; + try { + for (const suppliedKey of [undefined, "client-original-key"]) { + let response: Promise | undefined; + swQueue.listeners.fetch({ + request: new Request("https://pm.example/api/groups", { + method: "POST", body: JSON.stringify({ name: "lost" }), + headers: suppliedKey ? { "idempotency-key": suppliedKey } : {}, + }), + respondWith: (result: Promise) => { response = result; }, + }); + assert.equal((await response)?.status, 202); + assert.ok(firstKey, "the original network attempt is keyed"); + assert.equal((swQueue.lastAdded as Record).idempotencyKey, firstKey); + if (suppliedKey) assert.equal(firstKey, suppliedKey); + assert.equal((swQueue.lastAdded as Record).body, JSON.stringify({ name: "lost" })); + } + } finally { + globalThis.fetch = originalFetch; + } +}); + +test("sw queue: AUTH_SESSION extends event lifetime and handles storage failure", async () => { + for (const fail of [false, true]) { + swQueue.openShouldFail = fail; + const pending: Promise[] = []; + swQueue.listeners.message({ + data: { type: "AUTH_SESSION", userId: "user-lifetime" }, + waitUntil: (promise: Promise) => { pending.push(promise); }, + }); + assert.equal(pending.length, 1, "session persistence is held by waitUntil"); + await Promise.all(pending); + if (!fail) assert.deepEqual(swQueue.sessionRecord, { key: "current", userId: "user-lifetime" }); + } + Object.assign(swQueue, { openShouldFail: false }); +}); From 901b51194f0119fa7eda69cf06f637330d31a79f Mon Sep 17 00:00:00 2001 From: SteveBot <1153461+unbraind@users.noreply.github.com> Date: Sun, 4 Oct 2026 20:54:39 +0200 Subject: [PATCH 2/6] Close account races in offline replay and recovery --- .agents/pm/history/pm-web-ccie.jsonl | 10 ++ .agents/pm/issues/pm-web-ccie.toon | 40 ++++++- README.md | 20 +++- dist/idempotency.d.ts | 8 +- dist/idempotency.js | 66 +++++++---- dist/idempotency.js.map | 2 +- public/src/api.ts | 7 +- public/src/sw.ts | 151 ++++++++++++++++++-------- src/idempotency.ts | 66 +++++++---- test/frontend-api.test.ts | 29 ++++- test/helpers/pg-harness.ts | 8 ++ test/helpers/sw-queue-preload.ts | 26 +++-- test/idempotency.test.ts | 98 +++++++++++++++-- test/sw-idempotency-http.test.ts | 157 ++++++++++++++++++++++++++- test/sw-queue.test.ts | 67 +++++++++++- 15 files changed, 624 insertions(+), 131 deletions(-) diff --git a/.agents/pm/history/pm-web-ccie.jsonl b/.agents/pm/history/pm-web-ccie.jsonl index 0cdfefe1..11398440 100644 --- a/.agents/pm/history/pm-web-ccie.jsonl +++ b/.agents/pm/history/pm-web-ccie.jsonl @@ -6,3 +6,13 @@ {"hash_algorithm":"sha256","ts":"2026-10-04T18:15:56.961Z","author":"claude-orchestrator","author_source":"asserted","agent_harness":"claude-code","agent_model":"claude-opus-5-5","agent_model_source":"probe","agent_instance":"4b273145923a68729ab394ab","agent_provenance":{"model":{"value":"claude-opus-5-5","source":"probe"},"effort":{"value":"high","source":"environment"},"role":null,"topic":null,"version":{"value":"2.1.289","source":"probe"}},"op":"tests_add","patch":[{"op":"replace","path":"/metadata/updated_at","value":"2026-10-04T18:15:56.961Z"},{"op":"add","path":"/metadata/tests","value":[{"command":"node scripts/with-test-db.ts node --test test/idempotency.test.ts test/sw-queue.test.ts test/sw-idempotency-http.test.ts test/frontend-api.test.ts","scope":"project","timeout_seconds":600,"provenance":{"author":"claude-orchestrator","created_at":"2026-10-04T18:15:56.922Z","source_kind":"local_mutation","source_ref":"fix/bind-offline-queue-and-rate-limit-identity"}}]}],"before_hash":"d9a972e03b6b271d6e747e5b98428d4d7ff47e2c95224407dd12bb9c86c43e5f","after_hash":"1202a771d5125ac984ae5bb6e2705d57a6adff8bcc37c6c75a445888e1693802","item_hash_version":3,"context":{"agent_provenance_outcomes":{"model":{"status":"resolved","resolver":"claude_session_file","rule_version":"v1"},"version":{"status":"resolved","resolver":"claude_session_file","rule_version":"v1"}}},"event_class":"maintenance","record_hash_version":1,"record_hash":"84d410e2c317da8b1bb3cb34b1cc20eb86d4a7a4a74be5964ac242a00a5a40c0"} {"hash_algorithm":"sha256","ts":"2026-10-04T18:15:59.802Z","author":"claude-orchestrator","author_source":"asserted","agent_harness":"claude-code","agent_model":"claude-opus-5-5","agent_model_source":"probe","agent_instance":"4b273145923a68729ab394ab","agent_provenance":{"model":{"value":"claude-opus-5-5","source":"probe"},"effort":{"value":"high","source":"environment"},"role":null,"topic":null,"version":{"value":"2.1.289","source":"probe"}},"op":"comment_add","patch":[{"op":"replace","path":"/metadata/updated_at","value":"2026-10-04T18:15:59.802Z"},{"op":"add","path":"/metadata/comments","value":[{"created_at":"2026-10-04T18:15:59.802Z","author":"claude-orchestrator","text":"PR #170: queued mutations are stamped with the broadcast account (persisted session store), target workspace and an idempotency key generated before the first network attempt; foreign, unknown-owner and logged-out records are never replayed or deleted, and surface MUTATIONS_BLOCKED with a visible explicit recovery action that adopts unknown-owner records for the current account (assigning a key first). Server: pm_idempotency_keys claims (account, key) atomically, replays stored outcomes, rejects a key reused for a different request (422), never stores transient 425/429 refusals, answers 409 outcome-unknown instead of re-executing a stale pending key, catches outcome-persist failures, indexes created_at and throttles the retention sweep. Implemented by the pi (GLM-5.3) and codex (gpt-6.1-sol) agents; review round 1 (12 Greptile + CodeQL findings) fixed test-first. Local release:check: 451/451 tests pass."}]}],"before_hash":"1202a771d5125ac984ae5bb6e2705d57a6adff8bcc37c6c75a445888e1693802","after_hash":"4252fdce7191aeba1f93c7977fffeee02e3aa55e57557d7b16a17b6f03148801","item_hash_version":3,"context":{"agent_provenance_outcomes":{"model":{"status":"resolved","resolver":"claude_session_file","rule_version":"v1"},"version":{"status":"resolved","resolver":"claude_session_file","rule_version":"v1"}}},"event_class":"substantive","record_hash_version":1,"record_hash":"f6b06a9af835f9b4cef143e0a2e8522ec36c48211e3bfefdeddb7d6661002ce9"} {"hash_algorithm":"sha256","ts":"2026-10-04T18:16:01.566Z","author":"claude-orchestrator","author_source":"asserted","agent_harness":"claude-code","agent_model":"claude-opus-5-5","agent_model_source":"probe","agent_instance":"4b273145923a68729ab394ab","agent_provenance":{"model":{"value":"claude-opus-5-5","source":"probe"},"effort":{"value":"high","source":"environment"},"role":{"value":"implementer","source":"argv"},"topic":null,"version":{"value":"2.1.289","source":"probe"}},"op":"release","patch":[{"op":"remove","path":"/metadata/claim_principal"},{"op":"remove","path":"/metadata/assignee"},{"op":"replace","path":"/metadata/updated_at","value":"2026-10-04T18:16:01.566Z"}],"before_hash":"4252fdce7191aeba1f93c7977fffeee02e3aa55e57557d7b16a17b6f03148801","after_hash":"d9dc1c3e448640e28c0cf3e6366f8e1bffcd0ba92a120b1f5c569d4c63feecb2","item_hash_version":3,"context":{"agent_provenance_outcomes":{"model":{"status":"resolved","resolver":"claude_session_file","rule_version":"v1"},"version":{"status":"resolved","resolver":"claude_session_file","rule_version":"v1"}}},"event_class":"maintenance","record_hash_version":1,"record_hash":"3b4b7748c76253fbbd87fa03f0a78cdeea4d73a7f56bc618bc9827528084a53b"} +{"hash_algorithm":"sha256","ts":"2026-10-04T18:29:39.225Z","author":"codex-sol","author_source":"asserted","agent_harness":"codex","agent_model":"gpt-6.1-sol","agent_model_source":"probe","agent_instance":"edac964870aa73c4248d5ad0","agent_provenance":{"model":{"value":"gpt-6.1-sol","source":"probe"},"effort":{"value":"xhigh","source":"probe"},"role":{"value":"implementer","source":"argv"},"topic":null},"op":"claim","patch":[{"op":"replace","path":"/metadata/updated_at","value":"2026-10-04T18:29:39.225Z"},{"op":"add","path":"/metadata/assignee","value":"codex-sol"},{"op":"add","path":"/metadata/claim_principal","value":"codex-sol"}],"before_hash":"d9dc1c3e448640e28c0cf3e6366f8e1bffcd0ba92a120b1f5c569d4c63feecb2","after_hash":"09dbdeeda1151eba66cb02853c90a5bd79ecb1c68157272b89082dfd1c401c07","item_hash_version":3,"context":{"agent_provenance_outcomes":{"model":{"status":"resolved","resolver":"codex_session_file","rule_version":"v1"},"effort":{"status":"resolved","resolver":"codex_session_file","rule_version":"v1"},"version":{"status":"unavailable","reason":"harness_unavailable","resolver":"ai_agent_version","rule_version":"v1"}}},"event_class":"substantive","record_hash_version":1,"record_hash":"0f7ccf411ad6dd2cd2de990dfd8eccf762dcc0666d48cf1954cdc36acd1808b3"} +{"hash_algorithm":"sha256","ts":"2026-10-04T18:29:41.303Z","author":"codex-sol","author_source":"asserted","agent_harness":"codex","agent_model":"gpt-6.1-sol","agent_model_source":"probe","agent_instance":"edac964870aa73c4248d5ad0","agent_provenance":{"model":{"value":"gpt-6.1-sol","source":"probe"},"effort":{"value":"xhigh","source":"probe"},"role":null,"topic":null},"op":"comment_add","patch":[{"op":"add","path":"/metadata/comments/1","value":{"created_at":"2026-10-04T18:29:41.302Z","author":"codex-sol","text":"Read all seven PR #170 round-two findings. Plan: failing worker interleaving tests and real HTTP/PostgreSQL guard regressions, then bind request identity before sending, verify expected accounts on the server, serialize adoption and flush, exclude auth routes, and run retention before claims. Full release gates and explicit-path commit/push follow verification."}},{"op":"replace","path":"/metadata/updated_at","value":"2026-10-04T18:29:41.303Z"}],"before_hash":"09dbdeeda1151eba66cb02853c90a5bd79ecb1c68157272b89082dfd1c401c07","after_hash":"644db6ab4596066c4ab91a923a97c609c59723a9e69d854a6023f676023fd73f","item_hash_version":3,"context":{"agent_provenance_outcomes":{"model":{"status":"resolved","resolver":"codex_session_file","rule_version":"v1"},"effort":{"status":"resolved","resolver":"codex_session_file","rule_version":"v1"},"version":{"status":"unavailable","reason":"harness_unavailable","resolver":"ai_agent_version","rule_version":"v1"}}},"event_class":"substantive","record_hash_version":1,"record_hash":"9e9d34f37a2a894d255e3a08bdd689168ab60dc52c9c71e181019fd388a5c06e"} +{"hash_algorithm":"sha256","ts":"2026-10-04T18:39:28.448Z","author":"codex-sol","author_source":"asserted","agent_harness":"codex","agent_model":"gpt-6.1-sol","agent_model_source":"probe","agent_instance":"edac964870aa73c4248d5ad0","agent_provenance":{"model":{"value":"gpt-6.1-sol","source":"probe"},"effort":{"value":"xhigh","source":"probe"},"role":null,"topic":null},"op":"comment_add","patch":[{"op":"add","path":"/metadata/comments/2","value":{"created_at":"2026-10-04T18:39:28.448Z","author":"codex-sol","text":"Test-first receipt: focused real-PostgreSQL/HTTP and worker suites reported 55 tests, 44 pass, 11 fail before production fixes. The failures reproduced all seven findings, including two-account first-attempt, replay, recovery and overlapping-adoption interleavings. Added expected-account refusal before key claims, captured owner before sending, serialized recovery and flush, transactional adoption preserving keys, auth exclusions, existing-user checks, and pre-claim retention."}},{"op":"replace","path":"/metadata/updated_at","value":"2026-10-04T18:39:28.448Z"}],"before_hash":"644db6ab4596066c4ab91a923a97c609c59723a9e69d854a6023f676023fd73f","after_hash":"4e47d6f1a1af4f2f5bfe59c4365c42b15b8501b9cd002fd224118ca3a6922639","item_hash_version":3,"context":{"agent_provenance_outcomes":{"model":{"status":"resolved","resolver":"codex_session_file","rule_version":"v1"},"effort":{"status":"resolved","resolver":"codex_session_file","rule_version":"v1"},"version":{"status":"unavailable","reason":"harness_unavailable","resolver":"ai_agent_version","rule_version":"v1"}}},"event_class":"substantive","record_hash_version":1,"record_hash":"239a6287019a1218ba5dd076cff74273c9f1c7220a394e439fdc1d0db27c0b47"} +{"hash_algorithm":"sha256","ts":"2026-10-04T18:39:29.672Z","author":"codex-sol","author_source":"asserted","agent_harness":"codex","agent_model":"gpt-6.1-sol","agent_model_source":"probe","agent_instance":"edac964870aa73c4248d5ad0","agent_provenance":{"model":{"value":"gpt-6.1-sol","source":"probe"},"effort":{"value":"xhigh","source":"probe"},"role":null,"topic":null},"op":"docs_add","patch":[{"op":"replace","path":"/metadata/updated_at","value":"2026-10-04T18:39:29.672Z"},{"op":"add","path":"/metadata/docs","value":[{"path":"README.md","scope":"project"}]}],"before_hash":"4e47d6f1a1af4f2f5bfe59c4365c42b15b8501b9cd002fd224118ca3a6922639","after_hash":"acd0d540053f028de2ab1eed617f358f355047f296683f86ed3e7c6f57ba0e0e","item_hash_version":3,"context":{"agent_provenance_outcomes":{"model":{"status":"resolved","resolver":"codex_session_file","rule_version":"v1"},"effort":{"status":"resolved","resolver":"codex_session_file","rule_version":"v1"},"version":{"status":"unavailable","reason":"harness_unavailable","resolver":"ai_agent_version","rule_version":"v1"}}},"event_class":"maintenance","record_hash_version":1,"record_hash":"2313dfd9d795e4c0ba6d35a330a44230aaeef1a2470a6be1b77552e57c5440a3"} +{"hash_algorithm":"sha256","ts":"2026-10-04T18:39:30.261Z","author":"codex-sol","author_source":"asserted","agent_harness":"codex","agent_model":"gpt-6.1-sol","agent_model_source":"probe","agent_instance":"edac964870aa73c4248d5ad0","agent_provenance":{"model":{"value":"gpt-6.1-sol","source":"probe"},"effort":{"value":"xhigh","source":"probe"},"role":null,"topic":null},"op":"tests_add","patch":[{"op":"add","path":"/metadata/tests/1","value":{"command":"npm run release:check","scope":"project","provenance":{"author":"codex-sol","created_at":"2026-10-04T18:39:30.233Z","source_kind":"local_mutation","source_ref":"fix/bind-offline-queue-and-rate-limit-identity"}}},{"op":"replace","path":"/metadata/updated_at","value":"2026-10-04T18:39:30.261Z"}],"before_hash":"acd0d540053f028de2ab1eed617f358f355047f296683f86ed3e7c6f57ba0e0e","after_hash":"74e9fdad495b2d0646604ed64e85f2285373d5aa46336914355a14d8a63c3171","item_hash_version":3,"context":{"agent_provenance_outcomes":{"model":{"status":"resolved","resolver":"codex_session_file","rule_version":"v1"},"effort":{"status":"resolved","resolver":"codex_session_file","rule_version":"v1"},"version":{"status":"unavailable","reason":"harness_unavailable","resolver":"ai_agent_version","rule_version":"v1"}}},"event_class":"maintenance","record_hash_version":1,"record_hash":"0196393e587e2ec0662b73992fb13522efeb8549fadb7b0b9e0e7b125b1da324"} +{"hash_algorithm":"sha256","ts":"2026-10-04T18:41:04.855Z","author":"codex-sol","author_source":"asserted","agent_harness":"codex","agent_model":"gpt-6.1-sol","agent_model_source":"probe","agent_instance":"edac964870aa73c4248d5ad0","agent_provenance":{"model":{"value":"gpt-6.1-sol","source":"probe"},"effort":{"value":"xhigh","source":"probe"},"role":null,"topic":null},"op":"files_add","patch":[{"op":"remove","path":"/metadata/files/6/note"},{"op":"replace","path":"/metadata/files/6/path","value":"test/frontend-api.test.ts"},{"op":"add","path":"/metadata/files/7","value":{"path":"test/helpers/sw-queue-preload.ts","scope":"project"}},{"op":"add","path":"/metadata/files/8","value":{"path":"test/idempotency.test.ts","scope":"project"}},{"op":"add","path":"/metadata/files/9","value":{"path":"test/sw-idempotency-http.test.ts","scope":"project"}},{"op":"add","path":"/metadata/files/10","value":{"path":"test/sw-queue.test.ts","scope":"project","note":"Queue lifecycle regression suite"}},{"op":"replace","path":"/metadata/updated_at","value":"2026-10-04T18:41:04.855Z"}],"before_hash":"74e9fdad495b2d0646604ed64e85f2285373d5aa46336914355a14d8a63c3171","after_hash":"261114158e7ba090e30b646af2c01f84c12fa1316c2bb6d57a1ce9737afbb876","item_hash_version":3,"context":{"agent_provenance_outcomes":{"model":{"status":"resolved","resolver":"codex_session_file","rule_version":"v1"},"effort":{"status":"resolved","resolver":"codex_session_file","rule_version":"v1"},"version":{"status":"unavailable","reason":"harness_unavailable","resolver":"ai_agent_version","rule_version":"v1"}}},"event_class":"maintenance","record_hash_version":1,"record_hash":"3ee3e7b5435580123043682baa16267a0ca8150323f05e261c1245cc3bc7701b"} +{"hash_algorithm":"sha256","ts":"2026-10-04T18:44:12.615Z","author":"codex-sol","author_source":"asserted","agent_harness":"codex","agent_model":"gpt-6.1-sol","agent_model_source":"probe","agent_instance":"edac964870aa73c4248d5ad0","agent_provenance":{"model":{"value":"gpt-6.1-sol","source":"probe"},"effort":{"value":"xhigh","source":"probe"},"role":null,"topic":null},"op":"files_add","patch":[{"op":"remove","path":"/metadata/files/10/note"},{"op":"replace","path":"/metadata/files/10/path","value":"test/sw-idempotency-http.test.ts"},{"op":"replace","path":"/metadata/files/9/path","value":"test/idempotency.test.ts"},{"op":"replace","path":"/metadata/files/8/path","value":"test/helpers/sw-queue-preload.ts"},{"op":"replace","path":"/metadata/files/7/path","value":"test/helpers/pg-harness.ts"},{"op":"add","path":"/metadata/files/11","value":{"path":"test/sw-queue.test.ts","scope":"project","note":"Queue lifecycle regression suite"}},{"op":"replace","path":"/metadata/updated_at","value":"2026-10-04T18:44:12.615Z"}],"before_hash":"261114158e7ba090e30b646af2c01f84c12fa1316c2bb6d57a1ce9737afbb876","after_hash":"9e27d330270a1cbd03f827460776efee070ed8cf7bfd13299bd8c689782dafa9","item_hash_version":3,"context":{"agent_provenance_outcomes":{"model":{"status":"resolved","resolver":"codex_session_file","rule_version":"v1"},"effort":{"status":"resolved","resolver":"codex_session_file","rule_version":"v1"},"version":{"status":"unavailable","reason":"harness_unavailable","resolver":"ai_agent_version","rule_version":"v1"}}},"event_class":"maintenance","record_hash_version":1,"record_hash":"03b5b5282afbfed658639f6f266ad3913e9f608620e403d53244b4d6a0d7d6f0"} +{"hash_algorithm":"sha256","ts":"2026-10-04T18:48:46.525Z","author":"codex-sol","author_source":"asserted","agent_harness":"codex","agent_model":"gpt-6.1-sol","agent_model_source":"probe","agent_instance":"edac964870aa73c4248d5ad0","agent_provenance":{"model":{"value":"gpt-6.1-sol","source":"probe"},"effort":{"value":"xhigh","source":"probe"},"role":null,"topic":null},"op":"comment_add","patch":[{"op":"add","path":"/metadata/comments/3","value":{"created_at":"2026-10-04T18:48:46.525Z","author":"codex-sol","text":"Focused suites passed 57/57. Lint, zero-duplication and docstring gates passed. The first full release run used the implicit sibling directory as its fleet root and saw unrelated worktrees, causing three catalog/snapshot assertions to fail (459/462 pass). The supported PM_FLEET_ROOT override points at the canonical fleet; all 12 catalog assertions pass with that setting. Retry the full release gate with this repo-relative environment override. Hardened cleanup-failure injection with a connection-scoped PostgreSQL statement trigger to avoid dependence on concurrent retention sweeps."}},{"op":"replace","path":"/metadata/updated_at","value":"2026-10-04T18:48:46.525Z"}],"before_hash":"9e27d330270a1cbd03f827460776efee070ed8cf7bfd13299bd8c689782dafa9","after_hash":"0b14d7c98ed8a124afc8bd4fa3c30489d4dbb0a8a0ba3324be24154b4828edce","item_hash_version":3,"context":{"agent_provenance_outcomes":{"model":{"status":"resolved","resolver":"codex_session_file","rule_version":"v1"},"effort":{"status":"resolved","resolver":"codex_session_file","rule_version":"v1"},"version":{"status":"unavailable","reason":"harness_unavailable","resolver":"ai_agent_version","rule_version":"v1"}}},"event_class":"substantive","record_hash_version":1,"record_hash":"fbc78522b0b187b37fefb26872d5545736ceb454ca6e80870d55326033deadb6"} +{"hash_algorithm":"sha256","ts":"2026-10-04T18:53:44.872Z","author":"codex-sol","author_source":"asserted","agent_harness":"codex","agent_model":"gpt-6.1-sol","agent_model_source":"probe","agent_instance":"edac964870aa73c4248d5ad0","agent_provenance":{"model":{"value":"gpt-6.1-sol","source":"probe"},"effort":{"value":"xhigh","source":"probe"},"role":null,"topic":null},"op":"comment_add","patch":[{"op":"add","path":"/metadata/comments/4","value":{"created_at":"2026-10-04T18:53:44.872Z","author":"codex-sol","text":"PR #170 round two: all seven findings fixed. 4178787252: capture the tab account before sending and preserve it after network failure. 4178787257: every replay sends X-PM-Expected-Account; the server returns 409 before execution or key claiming on mismatch, keeping the record. 4178739144: REBIND_RECORDS carries the approving account and the server verifies recovery approval. 4178739147: adoption and flush serialize; adoption reads and writes in one IndexedDB transaction and keeps existing keys. 4178739152: auth routes never queue, key or replay stored responses, including legacy auth records. 4178787261: missing/deleted token users bypass keying; live login/logout work. 4178787264: retention runs before claims; a real PostgreSQL cleanup failure leaves no pending key and retry succeeds. Test-first reproduction: 55 tests, 44 pass, 11 fail; focused fixed suites: 57/57. Final npm run release:check passed through pm test with PM_FLEET_ROOT=../../fleet: 462/462 tests, zero failures/skips/cancellations; 33 source files reported; coverage 85.00% lines, 81.45% branches, 78.61% functions; thresholds unchanged at 79/79/75. Lint/typecheck/build, zero duplication, docstrings, production audit, packed npm/Bun acceptance, changelog and release verification passed. Whole-repository four-metric 100% coverage remains outside this review slice. README and test links updated; leave the item open for orchestrator verification."}},{"op":"replace","path":"/metadata/updated_at","value":"2026-10-04T18:53:44.872Z"}],"before_hash":"0b14d7c98ed8a124afc8bd4fa3c30489d4dbb0a8a0ba3324be24154b4828edce","after_hash":"3b037063508f5fece429f809afeeef079708fe6411f68e11e5f2abff273da6b8","item_hash_version":3,"context":{"agent_provenance_outcomes":{"model":{"status":"resolved","resolver":"codex_session_file","rule_version":"v1"},"effort":{"status":"resolved","resolver":"codex_session_file","rule_version":"v1"},"version":{"status":"unavailable","reason":"harness_unavailable","resolver":"ai_agent_version","rule_version":"v1"}}},"event_class":"substantive","record_hash_version":1,"record_hash":"e06e15cbbd09fe085328be383b6cb252265f9c518bd330cc18fac9908908940f"} +{"hash_algorithm":"sha256","ts":"2026-10-04T18:53:45.390Z","author":"codex-sol","author_source":"asserted","agent_harness":"codex","agent_model":"gpt-6.1-sol","agent_model_source":"probe","agent_instance":"edac964870aa73c4248d5ad0","agent_provenance":{"model":{"value":"gpt-6.1-sol","source":"probe"},"effort":{"value":"xhigh","source":"probe"},"role":{"value":"implementer","source":"argv"},"topic":null},"op":"release","patch":[{"op":"remove","path":"/metadata/claim_principal"},{"op":"remove","path":"/metadata/assignee"},{"op":"replace","path":"/metadata/updated_at","value":"2026-10-04T18:53:45.390Z"}],"before_hash":"3b037063508f5fece429f809afeeef079708fe6411f68e11e5f2abff273da6b8","after_hash":"cf623b87e48807a591d2a69d46459645f61ca327d3920bd22e5b4f3052411385","item_hash_version":3,"context":{"agent_provenance_outcomes":{"model":{"status":"resolved","resolver":"codex_session_file","rule_version":"v1"},"effort":{"status":"resolved","resolver":"codex_session_file","rule_version":"v1"},"version":{"status":"unavailable","reason":"harness_unavailable","resolver":"ai_agent_version","rule_version":"v1"}}},"event_class":"maintenance","record_hash_version":1,"record_hash":"66811a4b9b0789a29094ad7e3e9997ecde2e622532a8c6839955c7721d147506"} diff --git a/.agents/pm/issues/pm-web-ccie.toon b/.agents/pm/issues/pm-web-ccie.toon index eab92089..ac8487c1 100644 --- a/.agents/pm/issues/pm-web-ccie.toon +++ b/.agents/pm/issues/pm-web-ccie.toon @@ -6,7 +6,7 @@ status: in_progress priority: 0 tags[4]: idempotency,multi-user,offline,security created_at: "2026-08-30T21:39:59.317Z" -updated_at: "2026-10-04T18:16:01.566Z" +updated_at: "2026-10-04T18:53:45.390Z" author: codex estimated_minutes: 240 acceptance_criteria: "Every queued mutation is durably bound to the authenticated user/session that created it; a different account never replays it; unknown legacy ownership is preserved and surfaced for explicit recovery without blocking unrelated owned records; every mutation carries a server-enforced idempotency key so retry after an ambiguous response cannot duplicate state; real browser/service-worker/PostgreSQL tests cover logout/login, account switching, lost responses, restart, queue ordering, and zero data loss." @@ -23,9 +23,13 @@ actual_result: Replay uses whichever session is current and has no deduplication affected_version: 2026.8.29 component: Offline mutation queue customer_impact: "A shared browser can attribute pending work to the wrong user, and a network ambiguity can duplicate mutations." -comments[1]{created_at,author,text}: +comments[5]{created_at,author,text}: "2026-10-04T18:15:59.802Z",claude-orchestrator,"PR #170: queued mutations are stamped with the broadcast account (persisted session store), target workspace and an idempotency key generated before the first network attempt; foreign, unknown-owner and logged-out records are never replayed or deleted, and surface MUTATIONS_BLOCKED with a visible explicit recovery action that adopts unknown-owner records for the current account (assigning a key first). Server: pm_idempotency_keys claims (account, key) atomically, replays stored outcomes, rejects a key reused for a different request (422), never stores transient 425/429 refusals, answers 409 outcome-unknown instead of re-executing a stale pending key, catches outcome-persist failures, indexes created_at and throttles the retention sweep. Implemented by the pi (GLM-5.3) and codex (gpt-6.1-sol) agents; review round 1 (12 Greptile + CodeQL findings) fixed test-first. Local release:check: 451/451 tests pass." -files[7]: + "2026-10-04T18:29:41.302Z",codex-sol,"Read all seven PR #170 round-two findings. Plan: failing worker interleaving tests and real HTTP/PostgreSQL guard regressions, then bind request identity before sending, verify expected accounts on the server, serialize adoption and flush, exclude auth routes, and run retention before claims. Full release gates and explicit-path commit/push follow verification." + "2026-10-04T18:39:28.448Z",codex-sol,"Test-first receipt: focused real-PostgreSQL/HTTP and worker suites reported 55 tests, 44 pass, 11 fail before production fixes. The failures reproduced all seven findings, including two-account first-attempt, replay, recovery and overlapping-adoption interleavings. Added expected-account refusal before key claims, captured owner before sending, serialized recovery and flush, transactional adoption preserving keys, auth exclusions, existing-user checks, and pre-claim retention." + "2026-10-04T18:48:46.525Z",codex-sol,"Focused suites passed 57/57. Lint, zero-duplication and docstring gates passed. The first full release run used the implicit sibling directory as its fleet root and saw unrelated worktrees, causing three catalog/snapshot assertions to fail (459/462 pass). The supported PM_FLEET_ROOT override points at the canonical fleet; all 12 catalog assertions pass with that setting. Retry the full release gate with this repo-relative environment override. Hardened cleanup-failure injection with a connection-scoped PostgreSQL statement trigger to avoid dependence on concurrent retention sweeps." + "2026-10-04T18:53:44.872Z",codex-sol,"PR #170 round two: all seven findings fixed. 4178787252: capture the tab account before sending and preserve it after network failure. 4178787257: every replay sends X-PM-Expected-Account; the server returns 409 before execution or key claiming on mismatch, keeping the record. 4178739144: REBIND_RECORDS carries the approving account and the server verifies recovery approval. 4178739147: adoption and flush serialize; adoption reads and writes in one IndexedDB transaction and keeps existing keys. 4178739152: auth routes never queue, key or replay stored responses, including legacy auth records. 4178787261: missing/deleted token users bypass keying; live login/logout work. 4178787264: retention runs before claims; a real PostgreSQL cleanup failure leaves no pending key and retry succeeds. Test-first reproduction: 55 tests, 44 pass, 11 fail; focused fixed suites: 57/57. Final npm run release:check passed through pm test with PM_FLEET_ROOT=../../fleet: 462/462 tests, zero failures/skips/cancellations; 33 source files reported; coverage 85.00% lines, 81.45% branches, 78.61% functions; thresholds unchanged at 79/79/75. Lint/typecheck/build, zero duplication, docstrings, production audit, packed npm/Bun acceptance, changelog and release verification passed. Whole-repository four-metric 100% coverage remains outside this review slice. README and test links updated; leave the item open for orchestrator verification." +files[12]: - path: public/src/api.ts scope: project - path: public/src/sw.ts @@ -40,9 +44,35 @@ files[7]: - path: src/services/pm-runner.ts scope: project note: Mutation execution and idempotency integration surface + - path: test/frontend-api.test.ts + scope: project + - path: test/helpers/pg-harness.ts + scope: project + - path: test/helpers/sw-queue-preload.ts + scope: project + - path: test/idempotency.test.ts + scope: project + - path: test/sw-idempotency-http.test.ts + scope: project - path: test/sw-queue.test.ts scope: project note: Queue lifecycle regression suite -tests[1]{command,scope,timeout_seconds,provenance{author,created_at,source_kind,source_ref}}: - node scripts/with-test-db.ts node --test test/idempotency.test.ts test/sw-queue.test.ts test/sw-idempotency-http.test.ts test/frontend-api.test.ts,project,600,claude-orchestrator,"2026-10-04T18:15:56.922Z",local_mutation,fix/bind-offline-queue-and-rate-limit-identity +tests[2]: + - command: node scripts/with-test-db.ts node --test test/idempotency.test.ts test/sw-queue.test.ts test/sw-idempotency-http.test.ts test/frontend-api.test.ts + scope: project + timeout_seconds: 600 + provenance: + author: claude-orchestrator + created_at: "2026-10-04T18:15:56.922Z" + source_kind: local_mutation + source_ref: fix/bind-offline-queue-and-rate-limit-identity + - command: "npm run release:check" + scope: project + provenance: + author: codex-sol + created_at: "2026-10-04T18:39:30.233Z" + source_kind: local_mutation + source_ref: fix/bind-offline-queue-and-rate-limit-identity +docs[1]{path,scope}: + README.md,project body: "" diff --git a/README.md b/README.md index 81313bb7..e302f24e 100644 --- a/README.md +++ b/README.md @@ -305,16 +305,26 @@ older blunt `pm history-repair --all` remains available as a lower-level primiti ### Offline mutation identity and recovery -Each service-worker write gets an `Idempotency-Key` before its first network -attempt. If that response is lost, the queued record keeps the same key and -body. The session is resent when a worker first controls the page or changes; +Each service-worker write outside `/api/auth` gets an `Idempotency-Key` and +captures its originating account before its first network attempt. If that +response is lost, the queued record keeps the same key, body and account. +Every replay sends `X-PM-Expected-Account`; the server refuses a different or +missing account with 409 before executing or claiming a key, and the worker +keeps the record. The session is resent when a worker first controls the page or changes; session writes and explicit adoption extend the worker event lifetime. The SPA displays blocked offline records. For unknown-owner records, the signed-in user may click **These changes are mine — adopt for my signed-in -account** after reviewing the listed paths. Adoption assigns any missing key -in the same IndexedDB transaction as ownership, then retries the queue. +account** after reviewing the listed paths. The recovery message carries the +approving account and adoption verifies it against the current server session. +Adoption and flush share one operation chain. Adoption reads and updates +ownership in one IndexedDB transaction, preserves any existing key and assigns +only missing keys, then retries the queue. Records owned by another account remain blocked until that account signs in. +Auth routes always use live responses and are never keyed or queued; legacy +auth records remain blocked. Deleted or unknown token accounts bypass keying +and receive the route's normal authentication outcome. Retention cleanup runs +before key claims so a cleanup failure leaves the request retryable. API rate limits apply before idempotency, including to replay attempts. Explicit pre-mutation refusals (425 and 429) release the intent and are never replayed. diff --git a/dist/idempotency.d.ts b/dist/idempotency.d.ts index 7e94fbf0..91c446cf 100644 --- a/dist/idempotency.d.ts +++ b/dist/idempotency.d.ts @@ -35,10 +35,10 @@ export declare function requestFingerprint(method: string, path: string, body: u /** * Build the middleware that makes keyed mutating requests at-most-once. * - * Scope: every mutating (`POST`/`PATCH`/`PUT`/`DELETE`…) `/api` request that - * carries an `Idempotency-Key` header. Requests without a key are untouched — - * the header is opt-in, so ordinary clients keep the plain behaviour — and - * safe methods never touch the store. + * Scope: keyed mutating `/api` requests outside `/api/auth`. Auth responses + * always execute live so session cookies are never lost to body-only replay. + * An expected-account header is checked against an existing authenticated user + * before execution or claiming, including recovery probes to `/api/auth/me`. * * Exactly-once per account: the key is stored together with the authenticated * user, so two accounts using the same key never interfere. The first diff --git a/dist/idempotency.js b/dist/idempotency.js index 85ea6e73..59283e22 100644 --- a/dist/idempotency.js +++ b/dist/idempotency.js @@ -91,12 +91,19 @@ function envDurationMs(name, fallback) { */ async function insertPendingKey(userId, key, method, path, fingerprint) { const result = await pool.query(`INSERT INTO pm_idempotency_keys (user_id, idempotency_key, method, path, request_fingerprint) - VALUES ($1, $2, $3, $4, $5) + SELECT id, $2, $3, $4, $5 FROM pm_users WHERE id = $1 FOR KEY SHARE ON CONFLICT (user_id, idempotency_key) DO NOTHING RETURNING id`, [userId, key, method, path, fingerprint]); const row = result.rows[0]; return row?.id ?? null; } +/** Check that a verified token still names an existing account before keying. */ +async function accountExists(userId) { + if (typeof userId !== "string" || !/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(userId)) + return false; + const result = await pool.query("SELECT id FROM pm_users WHERE id = $1", [userId]); + return result.rows.length > 0; +} /** * Read the current idempotency record for one (account, key) pair. * @@ -208,10 +215,10 @@ function captureOutcome(rowId, res) { /** * Build the middleware that makes keyed mutating requests at-most-once. * - * Scope: every mutating (`POST`/`PATCH`/`PUT`/`DELETE`…) `/api` request that - * carries an `Idempotency-Key` header. Requests without a key are untouched — - * the header is opt-in, so ordinary clients keep the plain behaviour — and - * safe methods never touch the store. + * Scope: keyed mutating `/api` requests outside `/api/auth`. Auth responses + * always execute live so session cookies are never lost to body-only replay. + * An expected-account header is checked against an existing authenticated user + * before execution or claiming, including recovery probes to `/api/auth/me`. * * Exactly-once per account: the key is stored together with the authenticated * user, so two accounts using the same key never interfere. The first @@ -230,22 +237,16 @@ function captureOutcome(rowId, res) { export function idempotencyGuard() { let nextSweepAt = 0; return async (req, res, next) => { - if (!isUnsafeMethod(req.method)) { - next(); - return; - } + const authRoute = /^\/api\/auth(?:\/|$)/i.test(req.originalUrl.split("?")[0]); + const expectedAccount = req.get("x-pm-expected-account"); + const checkExpected = expectedAccount !== undefined + && (!authRoute || /^\/api\/auth\/me\/?(?:\?|$)/i.test(req.originalUrl)); const header = req.get("idempotency-key"); - if (header === undefined) { + const keyed = isUnsafeMethod(req.method) && !authRoute && header !== undefined; + if (!keyed && !checkExpected) { next(); return; } - const key = header.trim(); - if (key.length < IDEMPOTENCY_KEY_MIN_LENGTH || key.length > IDEMPOTENCY_KEY_MAX_LENGTH) { - res.status(400).json({ - error: `Idempotency-Key must be between ${IDEMPOTENCY_KEY_MIN_LENGTH} and ${IDEMPOTENCY_KEY_MAX_LENGTH} characters`, - }); - return; - } // Only record requests we can attribute to an account; an unauthenticated // or invalid-session request is passed through for the route to refuse, // so an anonymous probe can never occupy a key. @@ -259,10 +260,28 @@ export function idempotencyGuard() { userId = null; } } - if (userId === null) { + if (userId !== null && !await accountExists(userId)) + userId = null; + if (checkExpected && (userId === null || expectedAccount !== userId)) { + res.status(409).json({ code: "PM_EXPECTED_ACCOUNT_MISMATCH", error: "The signed-in account differs from the account that approved this work" }); + return; + } + if (!keyed || userId === null) { next(); return; } + const key = header.trim(); + if (key.length < IDEMPOTENCY_KEY_MIN_LENGTH || key.length > IDEMPOTENCY_KEY_MAX_LENGTH) { + res.status(400).json({ + error: `Idempotency-Key must be between ${IDEMPOTENCY_KEY_MIN_LENGTH} and ${IDEMPOTENCY_KEY_MAX_LENGTH} characters`, + }); + return; + } + if (Date.now() >= nextSweepAt) { + // Cleanup can fail only before an intent exists, leaving the key retryable. + nextSweepAt = Date.now() + 60_000; + await deleteExpiredKeys(); + } const fingerprint = requestFingerprint(req.method, req.originalUrl, req.body); const deadline = Date.now() + envDurationMs("PM_WEB_IDEMPOTENCY_WAIT_MS", DEFAULT_WAIT_MS); let rowId = await insertPendingKey(userId, key, req.method, req.originalUrl, fingerprint); @@ -280,6 +299,12 @@ export function idempotencyGuard() { return; } if (record === null) { + // Deletion can race token validation; the locked INSERT SELECT refuses + // a missing user without a foreign-key failure or a permanent wait. + if (!await accountExists(userId)) { + next(); + return; + } rowId = await insertPendingKey(userId, key, req.method, req.originalUrl, fingerprint); continue; } @@ -293,11 +318,6 @@ export function idempotencyGuard() { } await new Promise((resolve) => { setTimeout(resolve, envDurationMs("PM_WEB_IDEMPOTENCY_POLL_MS", DEFAULT_POLL_MS)); }); } - if (Date.now() >= nextSweepAt) { - // Reserve the interval before awaiting so concurrent writes share one sweep. - nextSweepAt = Date.now() + 60_000; - await deleteExpiredKeys(); - } captureOutcome(rowId, res); next(); }; diff --git a/dist/idempotency.js.map b/dist/idempotency.js.map index 0489ed2b..e218b3c4 100644 --- a/dist/idempotency.js.map +++ b/dist/idempotency.js.map @@ -1 +1 @@ -{"version":3,"file":"idempotency.js","sourceRoot":"","sources":["../src/idempotency.ts"],"names":[],"mappings":"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"} \ No newline at end of file +{"version":3,"file":"idempotency.js","sourceRoot":"","sources":["../src/idempotency.ts"],"names":[],"mappings":"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"} \ No newline at end of file diff --git a/public/src/api.ts b/public/src/api.ts index 09e1592e..4e1f8058 100644 --- a/public/src/api.ts +++ b/public/src/api.ts @@ -29,11 +29,16 @@ export function csrfTokenFromCookie(cookie = document.cookie): string | undefine */ export async function api(method: string, path: string, body?: unknown): Promise { const csrfToken = csrfTokenFromCookie(); + const session = typeof navigator !== 'undefined' && 'serviceWorker' in navigator + ? workerSessions.get(navigator.serviceWorker) : undefined; + const expectedAccount = session?.userId; const opts: RequestInit = { method, headers: { 'Content-Type': 'application/json', ...(csrfToken ? { 'X-CSRF-Token': csrfToken } : {}), + ...(expectedAccount && !['GET', 'HEAD'].includes(method.toUpperCase()) && !/^\/auth(?:\/|\?|$)/i.test(path) + ? { 'X-PM-Expected-Account': expectedAccount } : {}), }, credentials: 'include', }; @@ -80,7 +85,7 @@ export function syncServiceWorkerSession(user: { id: string } | null): void { current.recovery?.remove(); current.recovery = showOfflineRecovery(data.blocked, current.userId, (ids, ownerId) => { if (current.userId !== ownerId) return; - workers.controller?.postMessage({ type: 'REBIND_RECORDS', ids }); + workers.controller?.postMessage({ type: 'REBIND_RECORDS', ids, ownerId }); }); }); } diff --git a/public/src/sw.ts b/public/src/sw.ts index 81b734db..8be14257 100644 --- a/public/src/sw.ts +++ b/public/src/sw.ts @@ -152,7 +152,7 @@ interface MeBody { } /** Why one queued mutation was refused during a flush. */ -type ReplayBlockReason = 'no-session' | 'unknown-owner' | 'owner-mismatch'; +type ReplayBlockReason = 'no-session' | 'unknown-owner' | 'owner-mismatch' | 'auth-route'; /** A queued mutation a flush refused, surfaced to the page for explicit recovery. */ interface BlockedMutation { @@ -300,16 +300,21 @@ async function saveSession(userId: string | null): Promise { * recorded as unknown and the record is surfaced for explicit recovery * instead of being guessed (and replayed under whoever happens to be signed * in next). + * + * @param originatingAccount - Identity captured before the first attempt; + * direct queue calls without an attempt read the current stored session. */ async function queueMutation( method: string, path: string, body: unknown, idempotencyKey = newIdempotencyKey(), + originatingAccount?: string | null, ): Promise { + if (/^\/auth(?:\/|\?|$)/i.test(path)) return false; try { const db = await openMutationDB(); - const ownerId = await readSession(); + const ownerId = originatingAccount === undefined ? await readSession() : originatingAccount; const tx = db.transaction(MUTATION_STORE, 'readwrite'); const store = tx.objectStore(MUTATION_STORE); const record: StoredMutation = { @@ -400,6 +405,7 @@ function replayBlockReason( mut: QueuedMutation, currentUserId: string | null, ): ReplayBlockReason | null { + if (/^\/auth(?:\/|\?|$)/i.test(mut.path)) return 'auth-route'; if (currentUserId === null) return 'no-session'; // Legacy records queued before ownership tracking have no ownerId at all. if (mut.ownerId === undefined || mut.ownerId === null) return 'unknown-owner'; @@ -424,7 +430,7 @@ function replayBlockReason( * cannot wedge an entire queue. Replayed records carry their idempotency key, * so a retry after an ambiguous (lost) response is applied exactly once * server-side. */ -async function flushMutationQueue(): Promise { +async function replayQueuedMutations(): Promise { const read = await getQueuedMutations(); if (!read.ok) { console.warn('Failed to read mutation queue:', read.error); @@ -481,6 +487,7 @@ async function flushMutationQueue(): Promise { headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': replayCsrfToken, + 'X-PM-Expected-Account': mut.ownerId!, ...(mut.idempotencyKey ? { 'Idempotency-Key': mut.idempotencyKey } : {}), }, credentials: 'include', @@ -547,6 +554,24 @@ async function flushMutationQueue(): Promise { } } +/** Pending queue operations share one chain across messages, sync and online events. */ +let queueOperation: Promise = Promise.resolve(); + +/** Serialize queue adoption and replay, recovering the chain after an operation fails. */ +function runQueueOperation(operation: () => Promise): Promise { + const pending = queueOperation.then(async () => { + await sessionUpdate.catch(() => {}); + await operation(); + }); + queueOperation = pending.catch(() => {}); + return pending; +} + +/** Flush under the same single-flight chain used for explicit recovery. */ +function flushMutationQueue(): Promise { + return runQueueOperation(replayQueuedMutations); +} + /** * Explicitly rebind unknown-owner records to the signed-in account. * @@ -556,27 +581,40 @@ async function flushMutationQueue(): Promise { * another account is never reassigned, because its true owner may still * return to replay it. * - * @param ids - When given, only the records with these ids are adopted; - * otherwise every unknown-owner record is. + * @param ids - Only these record ids are adopted, or all unknown records when absent. + * @param ownerId - The approving page's account, verified against the server session. + * @returns Whether the current server session approved adoption. */ -async function rebindUnknownOwnerRecords(ids?: number[]): Promise { - const ownerId = await readSession(); - if (ownerId === null) return; // nobody to adopt the records for - const read = await getQueuedMutations(); - if (!read.ok) return; - const targets = read.mutations.filter( - (mut) => - (mut.ownerId === undefined || mut.ownerId === null) - && (ids === undefined || ids.includes(mut.id)), - ); - if (targets.length === 0) return; +async function adoptUnknownOwnerRecords(ids: number[] | undefined, ownerId: string): Promise { + const approval = await fetch('/api/auth/me', { + credentials: 'include', cache: 'no-store', headers: { 'X-PM-Expected-Account': ownerId }, + }); + if (!approval.ok) return false; + const me = await approval.json().catch(() => null) as MeBody | null; + if (me?.user?.id !== ownerId) return false; const db = await openMutationDB(); const tx = db.transaction(MUTATION_STORE, 'readwrite'); + const done = transactionDone(tx); const store = tx.objectStore(MUTATION_STORE); - for (const mut of targets) { - store.put({ ...mut, ownerId, idempotencyKey: mut.idempotencyKey || newIdempotencyKey() }); - } - await transactionDone(tx); + // Queue writes inside the read callback while this transaction is active. + // No pre-transaction snapshot can overwrite ownership or an existing key. + const request = store.getAll(); + request.onsuccess = () => { + for (const mut of request.result as QueuedMutation[]) { + if ((mut.ownerId === undefined || mut.ownerId === null) + && (ids === undefined || ids.includes(mut.id)) + && !/^\/auth(?:\/|\?|$)/i.test(mut.path)) { + store.put({ ...mut, ownerId, idempotencyKey: mut.idempotencyKey || newIdempotencyKey() }); + } + } + }; + await done; + return true; +} + +/** Adopt records through the common queue-operation chain used by flushes. */ +function rebindUnknownOwnerRecords(ids: number[] | undefined, ownerId: string): Promise { + return runQueueOperation(async () => { await adoptUnknownOwnerRecords(ids, ownerId); }); } // ── Install ── @@ -605,38 +643,53 @@ sw.addEventListener('fetch', (event: FetchEvent) => { // API calls: try network, queue mutations if offline if (url.pathname.startsWith('/api/') || url.pathname.startsWith('/healthz')) { + // Session responses must reach the browser with their live cookie changes. + // Remove caller keys too; failed auth requests are never queued. + if (/^\/api\/auth(?:\/|$)/i.test(url.pathname)) { + const headers = new Headers(event.request.headers); + headers.delete('Idempotency-Key'); + headers.delete('X-PM-Expected-Account'); + event.respondWith(fetch(new Request(event.request, { headers }))); + return; + } // Queue write operations (POST, PUT, PATCH, DELETE) when offline if (event.request.method !== 'GET' && event.request.method !== 'HEAD') { const headers = new Headers(event.request.headers); if (!headers.has('Idempotency-Key')) headers.set('Idempotency-Key', newIdempotencyKey()); - const request = new Request(event.request, { headers }); - // Keep an unread copy even if the first network attempt consumes its body. - const queuedRequest = request.clone(); event.respondWith( - fetch(request).catch(async () => { - // Network failed — queue the mutation for later. - let body: unknown = undefined; - try { - body = await queuedRequest.json(); - } catch { /* no body */ } - const queued = await queueMutation( - event.request.method, - url.pathname.replace('/api', '') + url.search, - body, - headers.get('Idempotency-Key') ?? newIdempotencyKey(), - ); - if (queued) { + (async () => { + await sessionUpdate.catch(() => {}); + // Capture identity before sending, never after a lost response. + const ownerId = headers.get('X-PM-Expected-Account') ?? await readSession(); + if (ownerId !== null) headers.set('X-PM-Expected-Account', ownerId); + const request = new Request(event.request, { headers }); + const queuedRequest = request.clone(); + return fetch(request).catch(async () => { + // Network failed — queue the mutation for later. + let body: unknown = undefined; + try { + body = await queuedRequest.json(); + } catch { /* no body */ } + const queued = await queueMutation( + event.request.method, + url.pathname.replace('/api', '') + url.search, + body, + headers.get('Idempotency-Key') ?? newIdempotencyKey(), + ownerId, + ); + if (queued) { + return new Response( + JSON.stringify({ queued: true, message: 'Request queued for when you are back online' }), + { status: 202, headers: { 'Content-Type': 'application/json' } }, + ); + } + // Persistence failed — do not claim the mutation was queued. return new Response( - JSON.stringify({ queued: true, message: 'Request queued for when you are back online' }), - { status: 202, headers: { 'Content-Type': 'application/json' } }, + JSON.stringify({ error: 'Offline and unable to queue mutation', queued: false }), + { status: 503, headers: { 'Content-Type': 'application/json' } }, ); - } - // Persistence failed — do not claim the mutation was queued. - return new Response( - JSON.stringify({ error: 'Offline and unable to queue mutation', queued: false }), - { status: 503, headers: { 'Content-Type': 'application/json' } }, - ); - }), + }); + })(), ); return; } @@ -741,7 +794,7 @@ let sessionUpdate: Promise = Promise.resolve(); // ── Messages ── sw.addEventListener('message', (event: ExtendableMessageEvent) => { const data = event.data as - | { type?: string; urls?: string[]; userId?: unknown; ids?: unknown } + | { type?: string; urls?: string[]; userId?: unknown; ids?: unknown; ownerId?: unknown } | null; if (data && data.type === 'SKIP_WAITING') { sw.skipWaiting(); @@ -772,7 +825,11 @@ sw.addEventListener('message', (event: ExtendableMessageEvent) => { const ids = Array.isArray(data.ids) ? data.ids.filter((id): id is number => typeof id === 'number') : undefined; - event.waitUntil(sessionUpdate.then(() => rebindUnknownOwnerRecords(ids)).then(() => flushMutationQueue()).catch(() => { + const ownerId = data.ownerId; + if (typeof ownerId !== 'string' || ownerId.length === 0) return; + event.waitUntil(runQueueOperation(async () => { + if (await adoptUnknownOwnerRecords(ids, ownerId)) await replayQueuedMutations(); + }).catch(() => { console.warn('Failed to adopt offline mutations'); })); } diff --git a/src/idempotency.ts b/src/idempotency.ts index a9007b8c..d551f5c3 100644 --- a/src/idempotency.ts +++ b/src/idempotency.ts @@ -130,7 +130,7 @@ async function insertPendingKey( ): Promise { const result = await pool.query<{ id: string }>( `INSERT INTO pm_idempotency_keys (user_id, idempotency_key, method, path, request_fingerprint) - VALUES ($1, $2, $3, $4, $5) + SELECT id, $2, $3, $4, $5 FROM pm_users WHERE id = $1 FOR KEY SHARE ON CONFLICT (user_id, idempotency_key) DO NOTHING RETURNING id`, [userId, key, method, path, fingerprint], @@ -139,6 +139,13 @@ async function insertPendingKey( return row?.id ?? null; } +/** Check that a verified token still names an existing account before keying. */ +async function accountExists(userId: string): Promise { + if (typeof userId !== "string" || !/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(userId)) return false; + const result = await pool.query("SELECT id FROM pm_users WHERE id = $1", [userId]); + return result.rows.length > 0; +} + /** * Read the current idempotency record for one (account, key) pair. * @@ -273,10 +280,10 @@ function captureOutcome(rowId: string, res: Response): void { /** * Build the middleware that makes keyed mutating requests at-most-once. * - * Scope: every mutating (`POST`/`PATCH`/`PUT`/`DELETE`…) `/api` request that - * carries an `Idempotency-Key` header. Requests without a key are untouched — - * the header is opt-in, so ordinary clients keep the plain behaviour — and - * safe methods never touch the store. + * Scope: keyed mutating `/api` requests outside `/api/auth`. Auth responses + * always execute live so session cookies are never lost to body-only replay. + * An expected-account header is checked against an existing authenticated user + * before execution or claiming, including recovery probes to `/api/auth/me`. * * Exactly-once per account: the key is stored together with the authenticated * user, so two accounts using the same key never interfere. The first @@ -295,22 +302,16 @@ function captureOutcome(rowId: string, res: Response): void { export function idempotencyGuard(): RequestHandler { let nextSweepAt = 0; return async (req: Request, res: Response, next) => { - if (!isUnsafeMethod(req.method)) { - next(); - return; - } + const authRoute = /^\/api\/auth(?:\/|$)/i.test(req.originalUrl.split("?")[0]); + const expectedAccount = req.get("x-pm-expected-account"); + const checkExpected = expectedAccount !== undefined + && (!authRoute || /^\/api\/auth\/me\/?(?:\?|$)/i.test(req.originalUrl)); const header = req.get("idempotency-key"); - if (header === undefined) { + const keyed = isUnsafeMethod(req.method) && !authRoute && header !== undefined; + if (!keyed && !checkExpected) { next(); return; } - const key = header.trim(); - if (key.length < IDEMPOTENCY_KEY_MIN_LENGTH || key.length > IDEMPOTENCY_KEY_MAX_LENGTH) { - res.status(400).json({ - error: `Idempotency-Key must be between ${IDEMPOTENCY_KEY_MIN_LENGTH} and ${IDEMPOTENCY_KEY_MAX_LENGTH} characters`, - }); - return; - } // Only record requests we can attribute to an account; an unauthenticated // or invalid-session request is passed through for the route to refuse, // so an anonymous probe can never occupy a key. @@ -323,10 +324,28 @@ export function idempotencyGuard(): RequestHandler { userId = null; } } - if (userId === null) { + if (userId !== null && !await accountExists(userId)) userId = null; + if (checkExpected && (userId === null || expectedAccount !== userId)) { + res.status(409).json({ code: "PM_EXPECTED_ACCOUNT_MISMATCH", error: "The signed-in account differs from the account that approved this work" }); + return; + } + if (!keyed || userId === null) { next(); return; } + const key = header!.trim(); + if (key.length < IDEMPOTENCY_KEY_MIN_LENGTH || key.length > IDEMPOTENCY_KEY_MAX_LENGTH) { + res.status(400).json({ + error: `Idempotency-Key must be between ${IDEMPOTENCY_KEY_MIN_LENGTH} and ${IDEMPOTENCY_KEY_MAX_LENGTH} characters`, + }); + return; + } + + if (Date.now() >= nextSweepAt) { + // Cleanup can fail only before an intent exists, leaving the key retryable. + nextSweepAt = Date.now() + 60_000; + await deleteExpiredKeys(); + } const fingerprint = requestFingerprint(req.method, req.originalUrl, req.body); const deadline = Date.now() + envDurationMs("PM_WEB_IDEMPOTENCY_WAIT_MS", DEFAULT_WAIT_MS); @@ -345,6 +364,12 @@ export function idempotencyGuard(): RequestHandler { return; } if (record === null) { + // Deletion can race token validation; the locked INSERT SELECT refuses + // a missing user without a foreign-key failure or a permanent wait. + if (!await accountExists(userId)) { + next(); + return; + } rowId = await insertPendingKey(userId, key, req.method, req.originalUrl, fingerprint); continue; } @@ -359,11 +384,6 @@ export function idempotencyGuard(): RequestHandler { await new Promise((resolve) => { setTimeout(resolve, envDurationMs("PM_WEB_IDEMPOTENCY_POLL_MS", DEFAULT_POLL_MS)); }); } - if (Date.now() >= nextSweepAt) { - // Reserve the interval before awaiting so concurrent writes share one sweep. - nextSweepAt = Date.now() + 60_000; - await deleteExpiredKeys(); - } captureOutcome(rowId, res); next(); }; diff --git a/test/frontend-api.test.ts b/test/frontend-api.test.ts index 803100f8..509ac3f4 100644 --- a/test/frontend-api.test.ts +++ b/test/frontend-api.test.ts @@ -93,7 +93,7 @@ test("blocked legacy mutations have a visible explicit recovery action for the c assert.match(button.textContent, /adopt/i); assert.equal(messages.length, 1, "nothing is adopted until the user clicks"); button.onclick(); - assert.deepEqual(messages[1], { type: "REBIND_RECORDS", ids: [1] }); + assert.deepEqual(messages[1], { type: "REBIND_RECORDS", ids: [1], ownerId: "alice" }); syncServiceWorkerSession(null); button.onclick(); assert.equal(messages.filter((msg) => (msg as { type: string }).type === "REBIND_RECORDS").length, 1, "an old action cannot run after logout"); @@ -103,3 +103,30 @@ test("blocked legacy mutations have a visible explicit recovery action for the c else Reflect.deleteProperty(globalThis, "navigator"); } }); + +test("the originating tab attaches its expected account before a worker reads shared session state", async () => { + const originalNavigator = Object.getOwnPropertyDescriptor(globalThis, "navigator"); + const globals = globalThis as unknown as Record; + const originalDocument = globals.document; + const originalFetch = globalThis.fetch; + const workers = Object.assign(new EventTarget(), { controller: { postMessage: (_message: unknown): void => {} } }); + Object.defineProperty(globalThis, "navigator", { configurable: true, value: { serviceWorker: workers } }); + globals.document = { cookie: "csrf_token=browser-token" }; + const expected: Array = []; + globalThis.fetch = async (_input, init) => { + expected.push(new Headers(init?.headers).get("x-pm-expected-account")); + return new Response("{}", { headers: { "content-type": "application/json" } }); + }; + try { + syncServiceWorkerSession({ id: "account-a" }); + await api("POST", "/groups", { name: "from-tab-a" }); + await api("POST", "/auth/logout"); + await api("GET", "/groups"); + assert.deepEqual(expected, ["account-a", null, null]); + } finally { + Object.assign(globalThis, { fetch: originalFetch }); + globals.document = originalDocument; + if (originalNavigator) Object.defineProperty(globalThis, "navigator", originalNavigator); + else Reflect.deleteProperty(globalThis, "navigator"); + } +}); diff --git a/test/helpers/pg-harness.ts b/test/helpers/pg-harness.ts index e553121f..57bc4fdc 100644 --- a/test/helpers/pg-harness.ts +++ b/test/helpers/pg-harness.ts @@ -23,6 +23,7 @@ import http from "node:http"; import assert from "node:assert/strict"; import type test from "node:test"; import type { Express } from "express"; +import bcrypt from "bcryptjs"; import { createApp } from "../../src/app.ts"; import { signToken } from "../../src/auth.ts"; import { startEphemeralServer } from "./ephemeral-server.ts"; @@ -238,6 +239,13 @@ export async function seedUser( return { id: row.id, email: row.email }; } +/** Seed an account with a real password for synthetic HTTP login interleavings. */ +export async function seedPasswordUser(password = "synthetic-password"): Promise { + const user = await seedUser(); + await pool.query("UPDATE pm_users SET password_hash = $2 WHERE id = $1", [user.id, await bcrypt.hash(password, 4)]); + return user; +} + /** * Inserts a `pm_projects` row owned by `ownerId` and returns its id and slug. * diff --git a/test/helpers/sw-queue-preload.ts b/test/helpers/sw-queue-preload.ts index 775cd3a8..090a2b6d 100644 --- a/test/helpers/sw-queue-preload.ts +++ b/test/helpers/sw-queue-preload.ts @@ -33,6 +33,8 @@ export const swQueue = { postedMessages: [] as { type: string; [key: string]: unknown }[], /** Event listeners the worker registered, keyed by event type. */ listeners: {} as Record void>, + /** Store operations grouped by transaction, proving adoption reads and writes atomically. */ + transactions: [] as Array<{ store: string; mode?: IDBTransactionMode; operations: string[] }>, }; /** @@ -60,7 +62,7 @@ export function mockRequest(result: unknown, shouldFail: boolean): IDBRequest { * IndexedDB store would, so a second flush within one test observes exactly * the records that survived the first one instead of the seeded snapshot. */ export const mockStore: IDBObjectStore = { - getAll: () => mockRequest(swQueue.getAllResult, swQueue.getAllShouldFail), + getAll: () => mockRequest(structuredClone(swQueue.getAllResult), swQueue.getAllShouldFail), add: (value: unknown) => { swQueue.addCallCount += 1; swQueue.lastAdded = value; @@ -121,19 +123,27 @@ const mockSessionStore: IDBObjectStore = { * assignment overwrite the earlier waiters', deadlocking every parallel * queue operation. */ -function openMockTransaction(name: string): IDBTransaction { +function openMockTransaction(name: string, mode?: IDBTransactionMode): IDBTransaction { + const entry = { store: name, mode, operations: [] as string[] }; + swQueue.transactions.push(entry); + const store = name === "session" ? mockSessionStore : Object.assign({}, mockStore, { + /** Log a transactional read without changing the fixture's request behavior. */ + getAll: (): IDBRequest => { entry.operations.push("getAll"); return mockStore.getAll(); }, + /** Log a transactional write without changing the fixture's persistence behavior. */ + put: (value: unknown): IDBRequest => { entry.operations.push("put"); return mockStore.put(value); }, + }); const tx: IDBTransaction = { - objectStore: () => (name === "session" ? mockSessionStore : mockStore), + objectStore: () => store, oncomplete: null, onabort: null, onerror: null, } as unknown as IDBTransaction; - queueMicrotask(() => (tx.oncomplete as (() => void) | null)?.()); + setTimeout(() => (tx.oncomplete as (() => void) | null)?.(), 0); return tx; } const mockDB: IDBDatabase = { - transaction: (storeName: string) => openMockTransaction(storeName), + transaction: (storeName: string, mode?: IDBTransactionMode) => openMockTransaction(storeName, mode), objectStoreNames: { contains: () => true } as unknown as DOMStringList, close: () => {}, } as unknown as IDBDatabase; @@ -183,8 +193,8 @@ export interface SwQueueInternals { queueMutation: (method: string, path: string, body: unknown) => Promise; /** Persists the signed-in account the way the AUTH_SESSION message does. */ saveSession?: (userId: string | null) => Promise; - /** Rebinds unknown-owner records to the persisted session, as the page asks. */ - rebindRecords?: (ids?: number[]) => Promise; + /** Rebinds unknown-owner records only after verifying the page's approving account. */ + rebindRecords?: (ids: number[] | undefined, ownerId: string) => Promise; } /** @@ -208,4 +218,4 @@ function isSwQueueInternals(value: unknown): value is SwQueueInternals { && typeof candidate.flushMutationQueue === "function" && typeof candidate.clearMutation === "function" && typeof candidate.queueMutation === "function"; -} \ No newline at end of file +} diff --git a/test/idempotency.test.ts b/test/idempotency.test.ts index b525e6eb..c0ad20a3 100644 --- a/test/idempotency.test.ts +++ b/test/idempotency.test.ts @@ -29,6 +29,7 @@ import { authedFetch, ensureSchema, seedUser, + seedPasswordUser, setupOwnerAppTest, type AppServer, type SeedUser, @@ -113,6 +114,21 @@ function keyedGroupPost( }); } +/** Exercise live session changes and assert cookie headers survive repeated caller keys. */ +async function assertLiveSessionChanges(server: AppServer, source: SeedUser, destination: SeedUser, key: string): Promise { + const login = await authedFetch(server, source, "/api/auth/login", { + method: "POST", headers: { "idempotency-key": key, "content-type": "application/json" }, + body: JSON.stringify({ email: destination.email, password: "synthetic-password" }), + }); + assert.equal(login.status, 200); + assert.ok(login.headers.getSetCookie().some((value) => value.startsWith("pm_token="))); + assert.equal(login.headers.get("idempotency-replayed"), null); + const logout = await authedFetch(server, source, "/api/auth/logout", { method: "POST", headers: { "idempotency-key": key } }); + assert.equal(logout.status, 200); + assert.ok(logout.headers.getSetCookie().some((value) => value.startsWith("pm_token=;"))); + assert.equal(logout.headers.get("idempotency-replayed"), null); +} + test("a retried mutation with the same idempotency key is applied exactly once and replays the stored response", async (t) => { const { server, owner } = await setupOwnerAppTest(t); const key = uniqueKey("retry"); @@ -144,6 +160,7 @@ test("a key reused for a different request is rejected, not silently deduplicate const first = await keyedGroupPost(server, owner, key, firstName); assert.equal(first.status, 201); + const firstGroup = await first.json() as { group: { id: string } }; // Same key, different body: must not be treated as a retry of the first. const otherBody = await keyedGroupPost(server, owner, key, `group-${RUN_ID}-second`); @@ -151,10 +168,10 @@ test("a key reused for a different request is rejected, not silently deduplicate assert.equal(await groupCount(owner.id), 1, "the rejected request created nothing"); // Same key, different route and method: also a different request. - const otherPath = await authedFetch(server, owner, "/api/auth/profile", { + const otherPath = await authedFetch(server, owner, `/api/groups/${firstGroup.group.id}`, { method: "PATCH", headers: { "content-type": "application/json", "idempotency-key": key }, - body: JSON.stringify({ displayName: "Someone Else" }), + body: JSON.stringify({ name: "Someone Else" }), }); assert.equal(otherPath.status, 422, "a key reused on a different route is rejected"); }); @@ -495,11 +512,10 @@ test("idempotency records older than the retention window are cleaned up", async assert.equal((await keyRows(owner.id, freshKey)).length, 1, "the fresh record remains"); }); -test("an idempotency store failure fails closed instead of double-applying", async (t) => { +test("an unknown token account bypasses keying and gets the route's normal outcome", async (t) => { const { server } = await setupOwnerAppTest(t); - // A syntactically valid JWT for a user id that is not a UUID makes the - // store insert fail; the guard must surface that rather than execute the - // mutation unrecorded (which would allow a duplicate on retry). + // A verified token with no usable account bypasses keying; the normal route + // outcome must not be replaced by an idempotency insert failure. const forged = await fetch(server.url("/api/groups"), { method: "POST", headers: { @@ -509,7 +525,8 @@ test("an idempotency store failure fails closed instead of double-applying", asy }, body: JSON.stringify({ name: "nope" }), }); - assert.equal(forged.status, 500, "a store failure is surfaced, not swallowed"); + assert.equal(forged.status, 500); + assert.deepEqual(await forged.json(), { error: "Failed to create group" }, "the route receives the unknown account"); }); test("SQL bootstrap and runtime idempotency table definitions agree and retention is indexed", async () => { @@ -583,3 +600,70 @@ test("a PostgreSQL outcome-write failure is caught and leaves a pending intent", assert.equal(await groupCount(owner.id), 1); assert.ok(errors.some((entry) => String(entry[0]).includes("persist idempotency outcome")), "persistence failure is recorded"); }); + +test("expected account mismatches are refused before execution or key claims, even without a key", async (t) => { + const { server, owner } = await setupOwnerAppTest(t); + const other = await seedUser(); + const key = uniqueKey("expected-account"); + for (const keyed of [true, false]) { + const response = await authedFetch(server, other, "/api/groups", { + method: "POST", headers: { "content-type": "application/json", "x-pm-expected-account": owner.id, + ...(keyed ? { "idempotency-key": key } : {}) }, body: JSON.stringify({ name: "refused" }), + }); + assert.equal(response.status, 409); + assert.equal((await response.json() as { code: string }).code, "PM_EXPECTED_ACCOUNT_MISMATCH"); + } + const approval = await authedFetch(server, other, "/api/auth/me", { headers: { "x-pm-expected-account": owner.id } }); + assert.equal(approval.status, 409, "server refuses recovery approval for a different session"); + assert.equal(await groupCount(other.id), 0); + assert.deepEqual(await keyRows(other.id, key), []); + assert.deepEqual(await keyRows(owner.id, key), []); +}); + +test("keyed login and repeated logout always deliver live cookie changes", async (t) => { + const { server, owner } = await setupOwnerAppTest(t); + const other = await seedPasswordUser(); + const key = uniqueKey("auth-cookie"); + for (let attempt = 0; attempt < 2; attempt++) { + await assertLiveSessionChanges(server, owner, other, key); + } + assert.deepEqual(await keyRows(owner.id, key), []); + assert.deepEqual(await keyRows(other.id, key), []); +}); + +test("deleted-user tokens bypass keying while login and logout remain usable", async (t) => { + const { server, owner } = await setupOwnerAppTest(t); + const other = await seedPasswordUser(); + await pool.query("DELETE FROM pm_users WHERE id = $1", [owner.id]); + const key = uniqueKey("deleted-token"); + const plain = await authedFetch(server, owner, `/api/groups/${other.id}`, { method: "DELETE" }); + const keyed = await authedFetch(server, owner, `/api/groups/${other.id}`, { method: "DELETE", headers: { "idempotency-key": key } }); + assert.equal(keyed.status, plain.status); + assert.deepEqual(await keyed.json(), await plain.json()); + await assertLiveSessionChanges(server, owner, other, key); + assert.deepEqual(await keyRows(owner.id, key), []); +}); + +test("a real retention cleanup failure leaves no pending claim and the same key can retry", async (t) => { + const { server, owner } = await setupOwnerAppTest(t); + const key = uniqueKey("cleanup-retry"); + const backend = await pool.query<{ pid: number }>("SELECT pg_backend_pid() AS pid"); + const trigger = `cleanup_failure_${RUN_ID}`; + // A statement trigger fails this pool connection's sweep even when another + // test swept all expired rows first; other test processes are unaffected. + await pool.query(`CREATE FUNCTION ${trigger}() RETURNS trigger LANGUAGE plpgsql AS $$ + BEGIN IF pg_backend_pid() = ${backend.rows[0]!.pid} THEN RAISE EXCEPTION 'synthetic retention outage'; END IF; RETURN NULL; END $$`); + await pool.query(`CREATE TRIGGER ${trigger} BEFORE DELETE ON pm_idempotency_keys FOR EACH STATEMENT EXECUTE FUNCTION ${trigger}()`); + t.after(async () => { + await pool.query(`DROP TRIGGER ${trigger} ON pm_idempotency_keys`); + await pool.query(`DROP FUNCTION ${trigger}()`); + }); + const first = await keyedGroupPost(server, owner, key, "cleanup-safe"); + assert.equal(first.status, 500); + assert.deepEqual(await keyRows(owner.id, key), [], "cleanup failed before any intent was claimed"); + assert.equal(await groupCount(owner.id), 0); + const retry = await keyedGroupPost(server, owner, key, "cleanup-safe"); + assert.equal(retry.status, 201); + assert.equal((await keyedGroupPost(server, owner, key, "cleanup-safe")).status, 201); + assert.equal(await groupCount(owner.id), 1); +}); diff --git a/test/sw-idempotency-http.test.ts b/test/sw-idempotency-http.test.ts index 44a8d7a4..33ef20fa 100644 --- a/test/sw-idempotency-http.test.ts +++ b/test/sw-idempotency-http.test.ts @@ -4,7 +4,7 @@ import test from "node:test"; import express from "express"; import { swQueue, swQueueInternals } from "./helpers/sw-queue-preload.ts"; import "../public/src/sw.ts"; -import { setupOwnerAppTest } from "./helpers/pg-harness.ts"; +import { authCookie, seedPasswordUser, setupOwnerAppTest, type AppServer, type SeedUser } from "./helpers/pg-harness.ts"; import { startEphemeralServer } from "./helpers/ephemeral-server.ts"; import { createTierLimiters } from "../src/rate-limit.ts"; import { signToken } from "../src/auth.ts"; @@ -25,7 +25,8 @@ test("a real HTTP response lost after commit replays the queued write exactly on if (req.method === "POST") keys.push(key); const upstream = await networkFetch(server.url(req.originalUrl), { method: req.method, - headers: { authorization: auth, "content-type": "application/json", ...(key ? { "idempotency-key": key } : {}) }, + headers: { authorization: auth, "content-type": "application/json", ...(key ? { "idempotency-key": key } : {}), + ...(req.get("x-pm-expected-account") ? { "x-pm-expected-account": req.get("x-pm-expected-account")! } : {}) }, ...(req.method === "POST" ? { body: JSON.stringify(req.body as unknown) } : {}), }); const body = await upstream.text(); // the real app has committed and finished its response @@ -65,3 +66,155 @@ test("a real HTTP response lost after commit replays the queued write exactly on assert.equal(count.rows[0]?.n, 1); assert.deepEqual(swQueue.getAllResult, [], "the replay drained the queue"); }); + +/** Browser cookie transport backed by the real HTTP app, with response interleaving hooks. */ +function browserTransport(server: AppServer, owner: SeedUser): { + requests: Array<{ path: string; method: string; expected: string | null; key: string | null; status: number }>; + setAfterResponse: (hook: (request: Request, response: Response) => Promise) => void; + signIn: (user: SeedUser) => Promise; + restore: () => void; +} { + const networkFetch = globalThis.fetch; + const cookies = new Map([["session", authCookie(owner)]]); + /** Current response hook runs after real HTTP responses arrive. */ + let afterResponse = async (_request: Request, _response: Response): Promise => {}; + const transport = { + requests: [] as Array<{ path: string; method: string; expected: string | null; key: string | null; status: number }>, + /** Replace the interleaving hook synchronously, without reading an old transport snapshot. */ + setAfterResponse: (hook: typeof afterResponse): void => { afterResponse = hook; }, + /** Sign another tab in through the real password endpoint and update shared browser cookies. */ + signIn: async (user: SeedUser): Promise => { + const response = await networkFetch(server.url("/api/auth/login"), { + method: "POST", headers: { cookie: cookies.get("session")!, "x-csrf-token": "pm-web-test-csrf", "content-type": "application/json" }, + body: JSON.stringify({ email: user.email, password: "synthetic-password" }), + }); + assert.equal(response.status, 200); + const session = response.headers.getSetCookie().find((value) => value.startsWith("pm_token="))?.split(";")[0]; + assert.ok(session); + cookies.set("session", `${session}; csrf_token=pm-web-test-csrf`); + }, + /** Restore the host transport after a test, including failing assertions. */ + restore: (): void => { globalThis.fetch = networkFetch; }, + }; + globalThis.fetch = async (input, init) => { + const request = input instanceof Request ? input : new Request(server.url(String(input)), init); + const headers = new Headers(request.headers); + headers.set("cookie", cookies.get("session")!); + if (request.method !== "GET") headers.set("x-csrf-token", "pm-web-test-csrf"); + const response = await networkFetch(new Request(request, { headers })); + transport.requests.push({ path: new URL(request.url).pathname, method: request.method, + expected: headers.get("x-pm-expected-account"), key: headers.get("idempotency-key"), status: response.status }); + await afterResponse(request, response); + return response; + }; + return transport; +} + +/** Start a real two-account browser session with worker identity and cleanup. */ +async function setupAccountInterleaving(t: test.TestContext): Promise<{ + server: AppServer; owner: SeedUser; other: SeedUser; + worker: ReturnType; browser: ReturnType; +}> { + const { server, owner } = await setupOwnerAppTest(t); + const other = await seedPasswordUser(); + const worker = swQueueInternals(); + await worker.saveSession?.(owner.id); + const browser = browserTransport(server, owner); + t.after(browser.restore); + return { server, owner, other, worker, browser }; +} + +/** Dispatch a browser mutation through the actual worker fetch listener. */ +async function submitGroup(server: AppServer): Promise { + let response: Promise | undefined; + swQueue.listeners.fetch({ + request: new Request(server.url("/api/groups"), { + method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ name: "interleaved-work" }), + }), + respondWith: (result: Promise) => { response = result; }, + }); + assert.ok(response); + return response; +} + +test("failed first attempt keeps account A when tab B signs in before the failure", async (t) => { + const { server, owner, other, worker, browser } = await setupAccountInterleaving(t); + browser.setAfterResponse(async (request) => { + if (request.method !== "POST") return; + browser.setAfterResponse(async () => {}); + await browser.signIn(other); + await worker.saveSession?.(other.id); + throw new TypeError("response lost after account switch"); + }); + assert.equal((await submitGroup(server)).status, 202); + const queued = swQueue.lastAdded as Record; + assert.equal(queued.ownerId, owner.id, "queue ownership is captured before the first attempt"); + swQueue.getAllResult = [{ ...queued, id: 1 }]; + await worker.flushMutationQueue(); + assert.equal(swQueue.getAllResult.length, 1, "B cannot drain A's work"); + const groups = await pool.query<{ owner_id: string }>("SELECT owner_id FROM pm_groups WHERE owner_id = ANY($1::uuid[])", [[owner.id, other.id]]); + assert.deepEqual(groups.rows.map((row) => row.owner_id), [owner.id]); +}); + +test("replay refuses changed browser cookies without claiming a key and keeps account A's record", async (t) => { + const { owner, other, worker, browser } = await setupAccountInterleaving(t); + await worker.queueMutation("POST", "/groups", { name: "account-bound" }); + const queued = swQueue.lastAdded as Record; + swQueue.getAllResult = [{ ...queued, id: 2 }]; + browser.setAfterResponse(async (request) => { + if (new URL(request.url).pathname !== "/api/auth/me") return; + browser.setAfterResponse(async () => {}); + await browser.signIn(other); + }); + await worker.flushMutationQueue(); + const replay = browser.requests.find((request) => request.method === "POST"); + assert.equal(replay?.status, 409); + assert.equal(replay?.expected, owner.id); + assert.equal(swQueue.getAllResult.length, 1); + const counts = await pool.query<{ groups: number; keys: number }>( + "SELECT (SELECT count(*)::int FROM pm_groups WHERE owner_id = ANY($1::uuid[])) AS groups, (SELECT count(*)::int FROM pm_idempotency_keys WHERE idempotency_key = $2) AS keys", + [[owner.id, other.id], queued.idempotencyKey], + ); + assert.deepEqual(counts.rows[0], { groups: 0, keys: 0 }); +}); + +test("recovery approved by account A is refused after tab B signs in", async (t) => { + const { owner, other, worker, browser } = await setupAccountInterleaving(t); + swQueue.getAllResult = [{ id: 3, method: "POST", path: "/groups", body: JSON.stringify({ name: "legacy" }), timestamp: 0 }]; + swQueue.putValues.length = 0; + await browser.signIn(other); + await worker.saveSession?.(other.id); + const pending: Promise[] = []; + swQueue.listeners.message({ data: { type: "REBIND_RECORDS", ids: [3], ownerId: owner.id }, + waitUntil: (promise: Promise) => { pending.push(promise); } }); + await Promise.all(pending); + assert.equal(swQueue.putValues.length, 0, "server session B cannot adopt work approved for A"); + assert.equal(swQueue.getAllResult.length, 1); + assert.ok(!browser.requests.some((request) => request.method === "POST")); +}); + +test("overlapping recovery and flush preserve one key when tab B signs in during adoption", async (t) => { + const { owner, other, worker, browser } = await setupAccountInterleaving(t); + swQueue.getAllResult = [{ id: 4, method: "POST", path: "/groups", body: JSON.stringify({ name: "overlap" }), timestamp: 0 }]; + swQueue.putValues.length = 0; + let switched = false; + browser.setAfterResponse(async (request) => { + if (switched || new URL(request.url).pathname !== "/api/auth/me") return; + switched = true; + await browser.signIn(other); + }); + const pending: Promise[] = []; + for (let click = 0; click < 2; click++) { + swQueue.listeners.message({ data: { type: "REBIND_RECORDS", ids: [4], ownerId: owner.id }, + waitUntil: (promise: Promise) => { pending.push(promise); } }); + } + pending.push(worker.flushMutationQueue()); + await Promise.all(pending); + assert.equal(swQueue.putValues.length, 1, "only one transaction assigns the key"); + const record = swQueue.getAllResult[0] as Record; + assert.equal(record.ownerId, owner.id); + assert.equal(record.idempotencyKey, (swQueue.putValues[0] as Record).idempotencyKey); + assert.ok(browser.requests.every((request) => request.method !== "POST" || request.status === 409)); + const groups = await pool.query<{ n: number }>("SELECT count(*)::int AS n FROM pm_groups WHERE owner_id = ANY($1::uuid[])", [[owner.id, other.id]]); + assert.equal(groups.rows[0]?.n, 0); +}); diff --git a/test/sw-queue.test.ts b/test/sw-queue.test.ts index 8069df3a..897182f1 100644 --- a/test/sw-queue.test.ts +++ b/test/sw-queue.test.ts @@ -72,6 +72,7 @@ const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{ async function withMockedFetch( fetchImpl: typeof globalThis.fetch, getAllImpl?: () => IDBRequest, + operation: () => Promise = internals.flushMutationQueue, ): Promise { const originalFetch = globalThis.fetch; const originalGetAll = mockStore.getAll; @@ -80,7 +81,7 @@ async function withMockedFetch( if (getAllImpl) { (mockStore as unknown as Record).getAll = getAllImpl; } - await internals.flushMutationQueue(); + await operation(); } finally { (mockStore as unknown as Record).getAll = originalGetAll; (globalThis as unknown as Record).fetch = originalFetch; @@ -185,7 +186,7 @@ test("sw queue: a queued mutation is bound to the broadcast account, its workspa assert.notEqual(second.idempotencyKey, added.idempotencyKey, "each record gets a distinct key"); // Account-level mutations (no /projects/ segment) have no workspace. - await internals.queueMutation("PATCH", "/auth/profile", { displayName: "Alice" }); + await internals.queueMutation("PATCH", "/groups/profile", { displayName: "Alice" }); const profile = swQueue.lastAdded as Record; assert.equal(profile.ownerId, "user-alice"); assert.equal(profile.workspace, null, "an account-level mutation has no workspace"); @@ -458,7 +459,8 @@ test("sw queue: explicit recovery rebinds unknown-owner records to the current s assert.equal(swQueue.deleteCallCount, 0, "the blocked record is not cleared"); // Explicit recovery: the signed-in user adopts the unknown-owner record. - await internals.rebindRecords(); + await withMockedFetch(async () => meResponse("user-bob", "current-token"), undefined, + () => internals.rebindRecords!(undefined, "user-bob")); assert.match(String((swQueue.putValues[0] as Record).idempotencyKey), UUID_PATTERN); assert.deepEqual( swQueue.putValues, @@ -494,7 +496,8 @@ test("sw queue: explicit recovery never adopts a record already owned by another swQueue.getAllShouldFail = false; swQueue.putValues.length = 0; - await internals.rebindRecords(); + await withMockedFetch(async () => meResponse("user-bob", "current-token"), undefined, + () => internals.rebindRecords!(undefined, "user-bob")); assert.deepEqual(swQueue.putValues, [], "a foreign-owned record is never reassigned"); const record = swQueue.getAllResult[0] as Record; @@ -612,3 +615,59 @@ test("sw queue: AUTH_SESSION extends event lifetime and handles storage failure" } Object.assign(swQueue, { openShouldFail: false }); }); + +test("sw queue: auth requests are never keyed, queued or replayed from legacy records", async () => { + const originalFetch = globalThis.fetch; + const attempts: Request[] = []; + globalThis.fetch = async (input) => { + attempts.push(input as Request); + throw new TypeError("offline auth request"); + }; + try { + swQueue.addedValues.length = 0; + for (const path of ["login", "logout", "register", "session"]) { + let response: Promise | undefined; + swQueue.listeners.fetch({ + request: new Request(`https://pm.example/api/auth/${path}`, { method: "POST", headers: { "idempotency-key": "caller-auth-key" } }), + respondWith: (promise: Promise) => { response = promise; }, + }); + assert.ok(response); + await assert.rejects(response, /offline auth request/); + } + assert.ok(attempts.every((request) => !request.headers.has("idempotency-key"))); + assert.equal(swQueue.addedValues.length, 0); + } finally { + globalThis.fetch = originalFetch; + } + swQueue.getAllResult = [queuedRecord({ id: 99, path: "/auth/logout", ownerId: "user-alice", idempotencyKey: "legacy-auth-key" })]; + assert.deepEqual(await flushAs("user-alice"), []); + assert.equal(swQueue.getAllResult.length, 1, "legacy auth work is preserved for manual recovery"); +}); + +test("sw queue: overlapping adoptions read and write in one transaction and keep existing keys", async () => { + swQueue.getAllResult = [ + queuedRecord({ id: 101, path: "/groups", idempotencyKey: "existing-original-key" }), + queuedRecord({ id: 102, path: "/groups" }), + queuedRecord({ id: 103, path: "/groups" }), + ]; + swQueue.putValues.length = 0; + swQueue.transactions.length = 0; + await withMockedFetch(async (_input, init) => { + assert.equal(new Headers(init?.headers).get("x-pm-expected-account"), "user-alice"); + return meResponse("user-alice", "current-token"); + }, undefined, async () => { + await Promise.all([ + internals.rebindRecords!([101, 102], "user-alice"), + internals.rebindRecords!([101, 102], "user-alice"), + ]); + }); + assert.equal(swQueue.putValues.length, 2, "one adoption write per selected record"); + const records = swQueue.getAllResult as Array>; + assert.equal(records[0].idempotencyKey, "existing-original-key"); + assert.equal(records[0].ownerId, "user-alice"); + assert.equal(records[1].ownerId, "user-alice"); + assert.equal(records[2].ownerId, undefined, "unselected work remains unknown"); + assert.match(String(records[1].idempotencyKey), UUID_PATTERN); + assert.ok(swQueue.transactions.some((transaction) => transaction.mode === "readwrite" + && transaction.operations.join(",") === "getAll,put,put")); +}); From 77bf925d074e5f591a420ce77febc861e2c617c6 Mon Sep 17 00:00:00 2001 From: SteveBot <1153461+unbraind@users.noreply.github.com> Date: Sun, 4 Oct 2026 22:29:08 +0200 Subject: [PATCH 3/6] Recover offline mutations after uncertain failures --- .agents/pm/history/pm-web-ccie.jsonl | 8 ++ .agents/pm/issues/pm-web-ccie.toon | 14 ++- README.md | 47 +++++--- dist/db.js | 4 + dist/db.js.map | 2 +- dist/idempotency.d.ts | 4 +- dist/idempotency.js | 66 ++++++++---- dist/idempotency.js.map | 2 +- dist/routes/groups.js | 11 +- dist/routes/groups.js.map | 2 +- public/src/api.ts | 25 ++++- public/src/offline-recovery.ts | 36 ++++++- public/src/sw.ts | 116 ++++++++++++++------ sql/schema.sql | 2 + src/db.ts | 4 + src/idempotency.ts | 74 +++++++++---- src/routes/groups.ts | 12 ++- test/frontend-api.test.ts | 104 +++++++++++++++++- test/helpers/pg-harness.ts | 10 ++ test/idempotency.test.ts | 50 +++++++-- test/sw-idempotency-http.test.ts | 154 ++++++++++++++++++++++++++- test/sw-queue.test.ts | 4 +- 22 files changed, 641 insertions(+), 110 deletions(-) diff --git a/.agents/pm/history/pm-web-ccie.jsonl b/.agents/pm/history/pm-web-ccie.jsonl index 11398440..1e412f14 100644 --- a/.agents/pm/history/pm-web-ccie.jsonl +++ b/.agents/pm/history/pm-web-ccie.jsonl @@ -16,3 +16,11 @@ {"hash_algorithm":"sha256","ts":"2026-10-04T18:48:46.525Z","author":"codex-sol","author_source":"asserted","agent_harness":"codex","agent_model":"gpt-6.1-sol","agent_model_source":"probe","agent_instance":"edac964870aa73c4248d5ad0","agent_provenance":{"model":{"value":"gpt-6.1-sol","source":"probe"},"effort":{"value":"xhigh","source":"probe"},"role":null,"topic":null},"op":"comment_add","patch":[{"op":"add","path":"/metadata/comments/3","value":{"created_at":"2026-10-04T18:48:46.525Z","author":"codex-sol","text":"Focused suites passed 57/57. Lint, zero-duplication and docstring gates passed. The first full release run used the implicit sibling directory as its fleet root and saw unrelated worktrees, causing three catalog/snapshot assertions to fail (459/462 pass). The supported PM_FLEET_ROOT override points at the canonical fleet; all 12 catalog assertions pass with that setting. Retry the full release gate with this repo-relative environment override. Hardened cleanup-failure injection with a connection-scoped PostgreSQL statement trigger to avoid dependence on concurrent retention sweeps."}},{"op":"replace","path":"/metadata/updated_at","value":"2026-10-04T18:48:46.525Z"}],"before_hash":"9e27d330270a1cbd03f827460776efee070ed8cf7bfd13299bd8c689782dafa9","after_hash":"0b14d7c98ed8a124afc8bd4fa3c30489d4dbb0a8a0ba3324be24154b4828edce","item_hash_version":3,"context":{"agent_provenance_outcomes":{"model":{"status":"resolved","resolver":"codex_session_file","rule_version":"v1"},"effort":{"status":"resolved","resolver":"codex_session_file","rule_version":"v1"},"version":{"status":"unavailable","reason":"harness_unavailable","resolver":"ai_agent_version","rule_version":"v1"}}},"event_class":"substantive","record_hash_version":1,"record_hash":"fbc78522b0b187b37fefb26872d5545736ceb454ca6e80870d55326033deadb6"} {"hash_algorithm":"sha256","ts":"2026-10-04T18:53:44.872Z","author":"codex-sol","author_source":"asserted","agent_harness":"codex","agent_model":"gpt-6.1-sol","agent_model_source":"probe","agent_instance":"edac964870aa73c4248d5ad0","agent_provenance":{"model":{"value":"gpt-6.1-sol","source":"probe"},"effort":{"value":"xhigh","source":"probe"},"role":null,"topic":null},"op":"comment_add","patch":[{"op":"add","path":"/metadata/comments/4","value":{"created_at":"2026-10-04T18:53:44.872Z","author":"codex-sol","text":"PR #170 round two: all seven findings fixed. 4178787252: capture the tab account before sending and preserve it after network failure. 4178787257: every replay sends X-PM-Expected-Account; the server returns 409 before execution or key claiming on mismatch, keeping the record. 4178739144: REBIND_RECORDS carries the approving account and the server verifies recovery approval. 4178739147: adoption and flush serialize; adoption reads and writes in one IndexedDB transaction and keeps existing keys. 4178739152: auth routes never queue, key or replay stored responses, including legacy auth records. 4178787261: missing/deleted token users bypass keying; live login/logout work. 4178787264: retention runs before claims; a real PostgreSQL cleanup failure leaves no pending key and retry succeeds. Test-first reproduction: 55 tests, 44 pass, 11 fail; focused fixed suites: 57/57. Final npm run release:check passed through pm test with PM_FLEET_ROOT=../../fleet: 462/462 tests, zero failures/skips/cancellations; 33 source files reported; coverage 85.00% lines, 81.45% branches, 78.61% functions; thresholds unchanged at 79/79/75. Lint/typecheck/build, zero duplication, docstrings, production audit, packed npm/Bun acceptance, changelog and release verification passed. Whole-repository four-metric 100% coverage remains outside this review slice. README and test links updated; leave the item open for orchestrator verification."}},{"op":"replace","path":"/metadata/updated_at","value":"2026-10-04T18:53:44.872Z"}],"before_hash":"0b14d7c98ed8a124afc8bd4fa3c30489d4dbb0a8a0ba3324be24154b4828edce","after_hash":"3b037063508f5fece429f809afeeef079708fe6411f68e11e5f2abff273da6b8","item_hash_version":3,"context":{"agent_provenance_outcomes":{"model":{"status":"resolved","resolver":"codex_session_file","rule_version":"v1"},"effort":{"status":"resolved","resolver":"codex_session_file","rule_version":"v1"},"version":{"status":"unavailable","reason":"harness_unavailable","resolver":"ai_agent_version","rule_version":"v1"}}},"event_class":"substantive","record_hash_version":1,"record_hash":"e06e15cbbd09fe085328be383b6cb252265f9c518bd330cc18fac9908908940f"} {"hash_algorithm":"sha256","ts":"2026-10-04T18:53:45.390Z","author":"codex-sol","author_source":"asserted","agent_harness":"codex","agent_model":"gpt-6.1-sol","agent_model_source":"probe","agent_instance":"edac964870aa73c4248d5ad0","agent_provenance":{"model":{"value":"gpt-6.1-sol","source":"probe"},"effort":{"value":"xhigh","source":"probe"},"role":{"value":"implementer","source":"argv"},"topic":null},"op":"release","patch":[{"op":"remove","path":"/metadata/claim_principal"},{"op":"remove","path":"/metadata/assignee"},{"op":"replace","path":"/metadata/updated_at","value":"2026-10-04T18:53:45.390Z"}],"before_hash":"3b037063508f5fece429f809afeeef079708fe6411f68e11e5f2abff273da6b8","after_hash":"cf623b87e48807a591d2a69d46459645f61ca327d3920bd22e5b4f3052411385","item_hash_version":3,"context":{"agent_provenance_outcomes":{"model":{"status":"resolved","resolver":"codex_session_file","rule_version":"v1"},"effort":{"status":"resolved","resolver":"codex_session_file","rule_version":"v1"},"version":{"status":"unavailable","reason":"harness_unavailable","resolver":"ai_agent_version","rule_version":"v1"}}},"event_class":"maintenance","record_hash_version":1,"record_hash":"66811a4b9b0789a29094ad7e3e9997ecde2e622532a8c6839955c7721d147506"} +{"hash_algorithm":"sha256","ts":"2026-10-04T20:06:35.533Z","author":"codex-sol","author_source":"asserted","agent_harness":"codex","agent_model":"gpt-6.1-sol","agent_model_source":"probe","agent_instance":"24e8fae678ef11598f66483d","agent_provenance":{"model":{"value":"gpt-6.1-sol","source":"probe"},"effort":{"value":"xhigh","source":"probe"},"role":{"value":"implementer","source":"argv"},"topic":null},"op":"claim","patch":[{"op":"replace","path":"/metadata/updated_at","value":"2026-10-04T20:06:35.533Z"},{"op":"add","path":"/metadata/assignee","value":"codex-sol"},{"op":"add","path":"/metadata/claim_principal","value":"codex-sol"}],"before_hash":"cf623b87e48807a591d2a69d46459645f61ca327d3920bd22e5b4f3052411385","after_hash":"1d9daaa20c4a387c0201336979183792c685895afdffc186c435276111d10375","item_hash_version":3,"context":{"agent_provenance_outcomes":{"model":{"status":"resolved","resolver":"codex_session_file","rule_version":"v1"},"effort":{"status":"resolved","resolver":"codex_session_file","rule_version":"v1"},"version":{"status":"unavailable","reason":"harness_unavailable","resolver":"ai_agent_version","rule_version":"v1"}}},"event_class":"substantive","record_hash_version":1,"record_hash":"dc6db5df61fb9f0dc80b1a5ce6f91a9459d245d76756590d75046cba05240f44"} +{"hash_algorithm":"sha256","ts":"2026-10-04T20:06:36.437Z","author":"codex-sol","author_source":"asserted","agent_harness":"codex","agent_model":"gpt-6.1-sol","agent_model_source":"probe","agent_instance":"24e8fae678ef11598f66483d","agent_provenance":{"model":{"value":"gpt-6.1-sol","source":"probe"},"effort":{"value":"xhigh","source":"probe"},"role":null,"topic":null},"op":"comment_add","patch":[{"op":"add","path":"/metadata/comments/5","value":{"created_at":"2026-10-04T20:06:36.437Z","author":"codex-sol","text":"Review round 3: read comments 4179117062 and 4179117055 in full. Add failing real PostgreSQL/HTTP and worker recovery tests first. Release only provably pre-commit claims; settle ambiguous outcomes explicitly, preserve workspace ordering, add confirmed retry-as-new/discard and persistent notice dismissal. Run full release gate before committing and pushing."}},{"op":"replace","path":"/metadata/updated_at","value":"2026-10-04T20:06:36.437Z"}],"before_hash":"1d9daaa20c4a387c0201336979183792c685895afdffc186c435276111d10375","after_hash":"347f74324ab5ec0b2af12e595412a1484d9f4af7ac0c40aa67adc1f664c45561","item_hash_version":3,"context":{"agent_provenance_outcomes":{"model":{"status":"resolved","resolver":"codex_session_file","rule_version":"v1"},"effort":{"status":"resolved","resolver":"codex_session_file","rule_version":"v1"},"version":{"status":"unavailable","reason":"harness_unavailable","resolver":"ai_agent_version","rule_version":"v1"}}},"event_class":"substantive","record_hash_version":1,"record_hash":"a196194f32dcd1e85a94d0af8c967669ad2c47a40c95307c0c179113bc790435"} +{"hash_algorithm":"sha256","ts":"2026-10-04T20:14:53.076Z","author":"codex-sol","author_source":"asserted","agent_harness":"codex","agent_model":"gpt-6.1-sol","agent_model_source":"probe","agent_instance":"24e8fae678ef11598f66483d","agent_provenance":{"model":{"value":"gpt-6.1-sol","source":"probe"},"effort":{"value":"xhigh","source":"probe"},"role":null,"topic":null},"op":"comment_add","patch":[{"op":"add","path":"/metadata/comments/6","value":{"created_at":"2026-10-04T20:14:53.076Z","author":"codex-sol","text":"Red tests reproduced three server failures (missing in-flight/unknown codes and same-key retry stuck after real rollback), two worker recovery/order failures, and two SPA recovery/dismiss failures. Implementing durable pending/completed/outcome_unknown states with settlement-based retention. A confirmed pre-COMMIT rollback releases the key; a COMMIT attempt remains ambiguous even if ROLLBACK later succeeds. FIFO applies per owned workspace, with account-level writes acting as cross-workspace barriers. Attention records require confirmed fresh-key retry or discard."}},{"op":"replace","path":"/metadata/updated_at","value":"2026-10-04T20:14:53.076Z"}],"before_hash":"347f74324ab5ec0b2af12e595412a1484d9f4af7ac0c40aa67adc1f664c45561","after_hash":"61b2a0ddb7c1551aa6921b0ab7b3d15761c6645a9f6cf20456c11e2ad46ce181","item_hash_version":3,"context":{"agent_provenance_outcomes":{"model":{"status":"resolved","resolver":"codex_session_file","rule_version":"v1"},"effort":{"status":"resolved","resolver":"codex_session_file","rule_version":"v1"},"version":{"status":"unavailable","reason":"harness_unavailable","resolver":"ai_agent_version","rule_version":"v1"}}},"event_class":"substantive","record_hash_version":1,"record_hash":"0742fc617ee81268d9a22530949a1e04daf611f6a534c5a5f21261982efc13b9"} +{"hash_algorithm":"sha256","ts":"2026-10-04T20:21:16.207Z","author":"codex-sol","author_source":"asserted","agent_harness":"codex","agent_model":"gpt-6.1-sol","agent_model_source":"probe","agent_instance":"24e8fae678ef11598f66483d","agent_provenance":{"model":{"value":"gpt-6.1-sol","source":"probe"},"effort":{"value":"xhigh","source":"probe"},"role":null,"topic":null},"op":"files_add","patch":[{"op":"remove","path":"/metadata/files/11/note"},{"op":"replace","path":"/metadata/files/11/path","value":"test/sw-idempotency-http.test.ts"},{"op":"replace","path":"/metadata/files/10/path","value":"test/idempotency.test.ts"},{"op":"replace","path":"/metadata/files/9/path","value":"test/helpers/sw-queue-preload.ts"},{"op":"replace","path":"/metadata/files/8/path","value":"test/helpers/pg-harness.ts"},{"op":"replace","path":"/metadata/files/7/path","value":"test/frontend-api.test.ts"},{"op":"add","path":"/metadata/files/6/note","value":"Mutation execution and idempotency integration surface"},{"op":"replace","path":"/metadata/files/6/path","value":"src/services/pm-runner.ts"},{"op":"remove","path":"/metadata/files/5/note"},{"op":"replace","path":"/metadata/files/5/path","value":"src/routes/groups.ts"},{"op":"add","path":"/metadata/files/12","value":{"path":"test/sw-queue.test.ts","scope":"project","note":"Queue lifecycle regression suite"}},{"op":"replace","path":"/metadata/updated_at","value":"2026-10-04T20:21:16.207Z"}],"before_hash":"61b2a0ddb7c1551aa6921b0ab7b3d15761c6645a9f6cf20456c11e2ad46ce181","after_hash":"0b7decff715321bc0cce795f3e672dea65b4efefc8df593d8697074ea9159737","item_hash_version":3,"context":{"agent_provenance_outcomes":{"model":{"status":"resolved","resolver":"codex_session_file","rule_version":"v1"},"effort":{"status":"resolved","resolver":"codex_session_file","rule_version":"v1"},"version":{"status":"unavailable","reason":"harness_unavailable","resolver":"ai_agent_version","rule_version":"v1"}}},"event_class":"maintenance","record_hash_version":1,"record_hash":"d550ff796c0e2d90861e23e5dd8b31da2df071781dfe4306728193850a2ae32f"} +{"hash_algorithm":"sha256","ts":"2026-10-04T20:21:17.680Z","author":"codex-sol","author_source":"asserted","agent_harness":"codex","agent_model":"gpt-6.1-sol","agent_model_source":"probe","agent_instance":"24e8fae678ef11598f66483d","agent_provenance":{"model":{"value":"gpt-6.1-sol","source":"probe"},"effort":{"value":"xhigh","source":"probe"},"role":null,"topic":null},"op":"files_add","patch":[{"op":"remove","path":"/metadata/files/12/note"},{"op":"replace","path":"/metadata/files/12/path","value":"test/sw-idempotency-http.test.ts"},{"op":"replace","path":"/metadata/files/11/path","value":"test/idempotency.test.ts"},{"op":"replace","path":"/metadata/files/10/path","value":"test/helpers/sw-queue-preload.ts"},{"op":"replace","path":"/metadata/files/9/path","value":"test/helpers/pg-harness.ts"},{"op":"replace","path":"/metadata/files/8/path","value":"test/frontend-api.test.ts"},{"op":"add","path":"/metadata/files/7/note","value":"Mutation execution and idempotency integration surface"},{"op":"replace","path":"/metadata/files/7/path","value":"src/services/pm-runner.ts"},{"op":"remove","path":"/metadata/files/6/note"},{"op":"replace","path":"/metadata/files/6/path","value":"src/routes/groups.ts"},{"op":"replace","path":"/metadata/files/5/path","value":"src/idempotency.ts"},{"op":"replace","path":"/metadata/files/4/path","value":"src/db.ts"},{"op":"replace","path":"/metadata/files/3/path","value":"sql/schema.sql"},{"op":"add","path":"/metadata/files/2/note","value":"Offline persistence and replay boundary"},{"op":"replace","path":"/metadata/files/2/path","value":"public/src/sw.ts"},{"op":"remove","path":"/metadata/files/1/note"},{"op":"replace","path":"/metadata/files/1/path","value":"public/src/offline-recovery.ts"},{"op":"add","path":"/metadata/files/13","value":{"path":"test/sw-queue.test.ts","scope":"project","note":"Queue lifecycle regression suite"}},{"op":"replace","path":"/metadata/updated_at","value":"2026-10-04T20:21:17.680Z"}],"before_hash":"0b7decff715321bc0cce795f3e672dea65b4efefc8df593d8697074ea9159737","after_hash":"3d286e8f646876152e51ddd0375f8505653027a9d76dc0560a6c356693149bb6","item_hash_version":3,"context":{"agent_provenance_outcomes":{"model":{"status":"resolved","resolver":"codex_session_file","rule_version":"v1"},"effort":{"status":"resolved","resolver":"codex_session_file","rule_version":"v1"},"version":{"status":"unavailable","reason":"harness_unavailable","resolver":"ai_agent_version","rule_version":"v1"}}},"event_class":"maintenance","record_hash_version":1,"record_hash":"5f8585459bca0e99a57ccc66ca1a8b9ef69f27309aed862b995791e3cd03cbed"} +{"hash_algorithm":"sha256","ts":"2026-10-04T20:22:59.067Z","author":"codex-sol","author_source":"asserted","agent_harness":"codex","agent_model":"gpt-6.1-sol","agent_model_source":"probe","agent_instance":"24e8fae678ef11598f66483d","agent_provenance":{"model":{"value":"gpt-6.1-sol","source":"probe"},"effort":{"value":"xhigh","source":"probe"},"role":null,"topic":null},"op":"comment_add","patch":[{"op":"add","path":"/metadata/comments/7","value":{"created_at":"2026-10-04T20:22:59.067Z","author":"codex-sol","text":"The first full release run exposed a test-environment mismatch: catalog checks treated sibling worktrees as the fleet. The documented PM_FLEET_ROOT override, set to ../../fleet, passes all 12 catalog checks against the actual fleet. Keep the catalog and committed snapshot unchanged; use this override for the full gate. Quota forecast remains within its weekly limit."}},{"op":"replace","path":"/metadata/updated_at","value":"2026-10-04T20:22:59.067Z"}],"before_hash":"3d286e8f646876152e51ddd0375f8505653027a9d76dc0560a6c356693149bb6","after_hash":"5f33bdba6b277b9a8e4c91d5a94bed2ccd3722e78199661343bd6848f338f3de","item_hash_version":3,"context":{"agent_provenance_outcomes":{"model":{"status":"resolved","resolver":"codex_session_file","rule_version":"v1"},"effort":{"status":"resolved","resolver":"codex_session_file","rule_version":"v1"},"version":{"status":"unavailable","reason":"harness_unavailable","resolver":"ai_agent_version","rule_version":"v1"}}},"event_class":"substantive","record_hash_version":1,"record_hash":"fcb454266083cdc102d56332ea824c76c1f4a3fc0955e1966098037908b08606"} +{"hash_algorithm":"sha256","ts":"2026-10-04T20:28:39.111Z","author":"codex-sol","author_source":"asserted","agent_harness":"codex","agent_model":"gpt-6.1-sol","agent_model_source":"probe","agent_instance":"24e8fae678ef11598f66483d","agent_provenance":{"model":{"value":"gpt-6.1-sol","source":"probe"},"effort":{"value":"xhigh","source":"probe"},"role":null,"topic":null},"op":"comment_add","patch":[{"op":"add","path":"/metadata/comments/8","value":{"created_at":"2026-10-04T20:28:39.111Z","author":"codex-sol","text":"PR #170 round three: 4179117062 fixed with explicit pending/completed/outcome_unknown PostgreSQL states, machine-readable 409 codes, handler-proven pre-commit claim release, stale-intent settlement, and retention from settlement. Worker unknown outcomes and three consecutive 5xx failures persist needs-attention; owned workspace FIFO and account-level barriers preserve ordering while other workspaces flush. Recovery offers owner-verified, user-confirmed Retry as a new request with a fresh key, and Discard. 4179117055 fixed with an aria-labelled native Dismiss button and suppression until the blocked set changes. Red regressions were observed before fixes. Final tracker-linked npm run release:check passed with PM_FLEET_ROOT=../../fleet: 469 tests, 469 pass, zero failures/skips/cancellations; 33 source files reported; 85.02% lines, 81.47% branches, 78.58% functions; thresholds unchanged at 79/79/75. Typecheck/build/lint, zero duplication, docstrings, production audit, packed npm/Bun acceptance, changelog and release verification passed. Test coverage includes real pre-commit rollback with same-key success, ambiguous commit without automatic re-execution, approved new-key execution once, other-workspace progress, same-workspace FIFO/account-level barriers, discard, confirmation cancellation, and dismissal persistence. Open limits: approved new requests can duplicate an unknown original commit; deduplication expires after the configured retention window; whole-repository four-metric 100% coverage is not established. Leave the item in progress for orchestrator verification."}},{"op":"replace","path":"/metadata/updated_at","value":"2026-10-04T20:28:39.111Z"}],"before_hash":"5f33bdba6b277b9a8e4c91d5a94bed2ccd3722e78199661343bd6848f338f3de","after_hash":"53bfe44f8cfe195579a9061351f6c310994e3f4f15b484896ae733ac520452fa","item_hash_version":3,"context":{"agent_provenance_outcomes":{"model":{"status":"resolved","resolver":"codex_session_file","rule_version":"v1"},"effort":{"status":"resolved","resolver":"codex_session_file","rule_version":"v1"},"version":{"status":"unavailable","reason":"harness_unavailable","resolver":"ai_agent_version","rule_version":"v1"}}},"event_class":"substantive","record_hash_version":1,"record_hash":"2331845998b57af70200e30c380e6af0415dabeb36bbe169f455b101d570d391"} +{"hash_algorithm":"sha256","ts":"2026-10-04T20:28:39.547Z","author":"codex-sol","author_source":"asserted","agent_harness":"codex","agent_model":"gpt-6.1-sol","agent_model_source":"probe","agent_instance":"24e8fae678ef11598f66483d","agent_provenance":{"model":{"value":"gpt-6.1-sol","source":"probe"},"effort":{"value":"xhigh","source":"probe"},"role":{"value":"implementer","source":"argv"},"topic":null},"op":"release","patch":[{"op":"remove","path":"/metadata/claim_principal"},{"op":"remove","path":"/metadata/assignee"},{"op":"replace","path":"/metadata/updated_at","value":"2026-10-04T20:28:39.547Z"}],"before_hash":"53bfe44f8cfe195579a9061351f6c310994e3f4f15b484896ae733ac520452fa","after_hash":"1a8dd3eca49a12bd72e782a91f4e8cf72770dfb647a6e946b20cfedc4a11ccd1","item_hash_version":3,"context":{"agent_provenance_outcomes":{"model":{"status":"resolved","resolver":"codex_session_file","rule_version":"v1"},"effort":{"status":"resolved","resolver":"codex_session_file","rule_version":"v1"},"version":{"status":"unavailable","reason":"harness_unavailable","resolver":"ai_agent_version","rule_version":"v1"}}},"event_class":"maintenance","record_hash_version":1,"record_hash":"66f00d93f3370530a83b314dc074748d5c05b86a8aa7acd8d92ece756e20cbc5"} diff --git a/.agents/pm/issues/pm-web-ccie.toon b/.agents/pm/issues/pm-web-ccie.toon index ac8487c1..80c5c2ae 100644 --- a/.agents/pm/issues/pm-web-ccie.toon +++ b/.agents/pm/issues/pm-web-ccie.toon @@ -6,7 +6,7 @@ status: in_progress priority: 0 tags[4]: idempotency,multi-user,offline,security created_at: "2026-08-30T21:39:59.317Z" -updated_at: "2026-10-04T18:53:45.390Z" +updated_at: "2026-10-04T20:28:39.547Z" author: codex estimated_minutes: 240 acceptance_criteria: "Every queued mutation is durably bound to the authenticated user/session that created it; a different account never replays it; unknown legacy ownership is preserved and surfaced for explicit recovery without blocking unrelated owned records; every mutation carries a server-enforced idempotency key so retry after an ambiguous response cannot duplicate state; real browser/service-worker/PostgreSQL tests cover logout/login, account switching, lost responses, restart, queue ordering, and zero data loss." @@ -23,15 +23,21 @@ actual_result: Replay uses whichever session is current and has no deduplication affected_version: 2026.8.29 component: Offline mutation queue customer_impact: "A shared browser can attribute pending work to the wrong user, and a network ambiguity can duplicate mutations." -comments[5]{created_at,author,text}: +comments[9]{created_at,author,text}: "2026-10-04T18:15:59.802Z",claude-orchestrator,"PR #170: queued mutations are stamped with the broadcast account (persisted session store), target workspace and an idempotency key generated before the first network attempt; foreign, unknown-owner and logged-out records are never replayed or deleted, and surface MUTATIONS_BLOCKED with a visible explicit recovery action that adopts unknown-owner records for the current account (assigning a key first). Server: pm_idempotency_keys claims (account, key) atomically, replays stored outcomes, rejects a key reused for a different request (422), never stores transient 425/429 refusals, answers 409 outcome-unknown instead of re-executing a stale pending key, catches outcome-persist failures, indexes created_at and throttles the retention sweep. Implemented by the pi (GLM-5.3) and codex (gpt-6.1-sol) agents; review round 1 (12 Greptile + CodeQL findings) fixed test-first. Local release:check: 451/451 tests pass." "2026-10-04T18:29:41.302Z",codex-sol,"Read all seven PR #170 round-two findings. Plan: failing worker interleaving tests and real HTTP/PostgreSQL guard regressions, then bind request identity before sending, verify expected accounts on the server, serialize adoption and flush, exclude auth routes, and run retention before claims. Full release gates and explicit-path commit/push follow verification." "2026-10-04T18:39:28.448Z",codex-sol,"Test-first receipt: focused real-PostgreSQL/HTTP and worker suites reported 55 tests, 44 pass, 11 fail before production fixes. The failures reproduced all seven findings, including two-account first-attempt, replay, recovery and overlapping-adoption interleavings. Added expected-account refusal before key claims, captured owner before sending, serialized recovery and flush, transactional adoption preserving keys, auth exclusions, existing-user checks, and pre-claim retention." "2026-10-04T18:48:46.525Z",codex-sol,"Focused suites passed 57/57. Lint, zero-duplication and docstring gates passed. The first full release run used the implicit sibling directory as its fleet root and saw unrelated worktrees, causing three catalog/snapshot assertions to fail (459/462 pass). The supported PM_FLEET_ROOT override points at the canonical fleet; all 12 catalog assertions pass with that setting. Retry the full release gate with this repo-relative environment override. Hardened cleanup-failure injection with a connection-scoped PostgreSQL statement trigger to avoid dependence on concurrent retention sweeps." "2026-10-04T18:53:44.872Z",codex-sol,"PR #170 round two: all seven findings fixed. 4178787252: capture the tab account before sending and preserve it after network failure. 4178787257: every replay sends X-PM-Expected-Account; the server returns 409 before execution or key claiming on mismatch, keeping the record. 4178739144: REBIND_RECORDS carries the approving account and the server verifies recovery approval. 4178739147: adoption and flush serialize; adoption reads and writes in one IndexedDB transaction and keeps existing keys. 4178739152: auth routes never queue, key or replay stored responses, including legacy auth records. 4178787261: missing/deleted token users bypass keying; live login/logout work. 4178787264: retention runs before claims; a real PostgreSQL cleanup failure leaves no pending key and retry succeeds. Test-first reproduction: 55 tests, 44 pass, 11 fail; focused fixed suites: 57/57. Final npm run release:check passed through pm test with PM_FLEET_ROOT=../../fleet: 462/462 tests, zero failures/skips/cancellations; 33 source files reported; coverage 85.00% lines, 81.45% branches, 78.61% functions; thresholds unchanged at 79/79/75. Lint/typecheck/build, zero duplication, docstrings, production audit, packed npm/Bun acceptance, changelog and release verification passed. Whole-repository four-metric 100% coverage remains outside this review slice. README and test links updated; leave the item open for orchestrator verification." -files[12]: + "2026-10-04T20:06:36.437Z",codex-sol,"Review round 3: read comments 4179117062 and 4179117055 in full. Add failing real PostgreSQL/HTTP and worker recovery tests first. Release only provably pre-commit claims; settle ambiguous outcomes explicitly, preserve workspace ordering, add confirmed retry-as-new/discard and persistent notice dismissal. Run full release gate before committing and pushing." + "2026-10-04T20:14:53.076Z",codex-sol,"Red tests reproduced three server failures (missing in-flight/unknown codes and same-key retry stuck after real rollback), two worker recovery/order failures, and two SPA recovery/dismiss failures. Implementing durable pending/completed/outcome_unknown states with settlement-based retention. A confirmed pre-COMMIT rollback releases the key; a COMMIT attempt remains ambiguous even if ROLLBACK later succeeds. FIFO applies per owned workspace, with account-level writes acting as cross-workspace barriers. Attention records require confirmed fresh-key retry or discard." + "2026-10-04T20:22:59.067Z",codex-sol,"The first full release run exposed a test-environment mismatch: catalog checks treated sibling worktrees as the fleet. The documented PM_FLEET_ROOT override, set to ../../fleet, passes all 12 catalog checks against the actual fleet. Keep the catalog and committed snapshot unchanged; use this override for the full gate. Quota forecast remains within its weekly limit." + "2026-10-04T20:28:39.111Z",codex-sol,"PR #170 round three: 4179117062 fixed with explicit pending/completed/outcome_unknown PostgreSQL states, machine-readable 409 codes, handler-proven pre-commit claim release, stale-intent settlement, and retention from settlement. Worker unknown outcomes and three consecutive 5xx failures persist needs-attention; owned workspace FIFO and account-level barriers preserve ordering while other workspaces flush. Recovery offers owner-verified, user-confirmed Retry as a new request with a fresh key, and Discard. 4179117055 fixed with an aria-labelled native Dismiss button and suppression until the blocked set changes. Red regressions were observed before fixes. Final tracker-linked npm run release:check passed with PM_FLEET_ROOT=../../fleet: 469 tests, 469 pass, zero failures/skips/cancellations; 33 source files reported; 85.02% lines, 81.47% branches, 78.58% functions; thresholds unchanged at 79/79/75. Typecheck/build/lint, zero duplication, docstrings, production audit, packed npm/Bun acceptance, changelog and release verification passed. Test coverage includes real pre-commit rollback with same-key success, ambiguous commit without automatic re-execution, approved new-key execution once, other-workspace progress, same-workspace FIFO/account-level barriers, discard, confirmation cancellation, and dismissal persistence. Open limits: approved new requests can duplicate an unknown original commit; deduplication expires after the configured retention window; whole-repository four-metric 100% coverage is not established. Leave the item in progress for orchestrator verification." +files[14]: - path: public/src/api.ts scope: project + - path: public/src/offline-recovery.ts + scope: project - path: public/src/sw.ts scope: project note: Offline persistence and replay boundary @@ -41,6 +47,8 @@ files[12]: scope: project - path: src/idempotency.ts scope: project + - path: src/routes/groups.ts + scope: project - path: src/services/pm-runner.ts scope: project note: Mutation execution and idempotency integration surface diff --git a/README.md b/README.md index e302f24e..fe9ef2d0 100644 --- a/README.md +++ b/README.md @@ -328,18 +328,39 @@ before key claims so a cleanup failure leaves the request retryable. API rate limits apply before idempotency, including to replay attempts. Explicit pre-mutation refusals (425 and 429) release the intent and are never replayed. -A pending intent may represent a committed mutation whose response was lost -before it could be stored. Pending intents and ambiguous server failures are -never taken over or automatically expired: duplicates wait up to -`PM_WEB_IDEMPOTENCY_WAIT_MS` (default 30 seconds), then receive 409. After -`PM_WEB_IDEMPOTENCY_PENDING_TIMEOUT_MS` (default five minutes), they receive -409 with **outcome unknown** immediately. Reconcile the original mutation -before submitting new work with a new key; automatic retry must reuse its key. +Handlers may call `markIdempotencyPreCommitFailure` only when they can prove +no mutation committed. Group creation does so on connection acquisition failure +or a successful rollback before any COMMIT attempt. A later rollback cannot +prove that an attempted COMMIT failed. Proven pre-commit 5xx failures release +the claim, allowing the same key to retry. Every other 5xx and premature response +close settles into terminal `outcome_unknown`, returning 409 with +`PM_IDEMPOTENCY_OUTCOME_UNKNOWN` on retries without re-executing. Live duplicates +wait up to `PM_WEB_IDEMPOTENCY_WAIT_MS` (default 30 seconds), then receive 409 +with `PM_IDEMPOTENCY_IN_FLIGHT`. After `PM_WEB_IDEMPOTENCY_PENDING_TIMEOUT_MS` +(default five minutes), pending rows settle as unknown on duplicate lookup or +the next retention sweep, including intents stranded by process termination. Outcome persistence failures are caught and logged without request contents. -Completed responses are retained for `PM_WEB_IDEMPOTENCY_RETENTION_MS` (default -seven days). Deduplication of completed requests is bounded by that window; -a retry after expiry may execute again. Cleanup uses a partial `created_at` -index and runs at most once per minute per guard instance. Unknown outcomes -remain retained until explicitly reconciled, so operators must account for -these records when managing database growth. +The worker durably marks unknown outcomes and three consecutive 5xx responses +as **needs attention**, then stops automatic attempts for that record. Replays +preserve FIFO within each owner's workspace; later records in other workspaces +may continue. Paths under `/projects//` define a workspace, including legacy +records. Account-level paths are barriers: they wait for earlier unresolved +owned work and block all later owned work until resolved. Other accounts have +separate ordering. Network or local persistence failures stop the flush. + +The recovery notice offers the originating owner **Retry as a new request** +and **Discard**. Retrying requires a confirmation that the original might +already have committed; it assigns a fresh key at the same queue position, +then resumes ordered replay. Each new request is deduplicated separately: +the user's approved retry can duplicate an unknown original commit. Discard +removes only the queued record and does not undo any server commit. Recovery +verifies the approving account against the server and updates IndexedDB in a +single transaction. The keyboard-accessible **Dismiss** control hides the +notice until the blocked record set changes; dismissing preserves all records. + +Completed responses and terminal unknown outcomes are retained for +`PM_WEB_IDEMPOTENCY_RETENTION_MS` (default seven days) from settlement. +Deduplication is bounded by that window; a retry after expiry may execute again. +Cleanup uses a partial `updated_at` index and runs at most once per minute per +guard instance. Pending intents are settled before retention can remove them. diff --git a/dist/db.js b/dist/db.js index 35a7403c..43635d2e 100644 --- a/dist/db.js +++ b/dist/db.js @@ -186,6 +186,7 @@ export async function initSchema() { method TEXT NOT NULL, path TEXT NOT NULL, request_fingerprint TEXT NOT NULL, + outcome_state TEXT NOT NULL DEFAULT 'pending' CHECK (outcome_state IN ('pending', 'completed', 'outcome_unknown')), status_code INTEGER, response_body TEXT, response_content_type TEXT, @@ -195,7 +196,10 @@ export async function initSchema() { ); `); await pool.query("ALTER TABLE pm_idempotency_keys ADD COLUMN IF NOT EXISTS response_content_type TEXT"); + await pool.query("ALTER TABLE pm_idempotency_keys ADD COLUMN IF NOT EXISTS outcome_state TEXT NOT NULL DEFAULT 'pending' CHECK (outcome_state IN ('pending', 'completed', 'outcome_unknown'))"); + await pool.query("UPDATE pm_idempotency_keys SET outcome_state = 'completed' WHERE outcome_state = 'pending' AND status_code IS NOT NULL"); await pool.query("CREATE INDEX IF NOT EXISTS idx_pm_idempotency_created_at ON pm_idempotency_keys (created_at) WHERE status_code IS NOT NULL"); + await pool.query("CREATE INDEX IF NOT EXISTS idx_pm_idempotency_settled_at ON pm_idempotency_keys (updated_at) WHERE status_code IS NOT NULL"); if (bootstrapAdminEmail) { await pool.query(`UPDATE pm_users SET is_admin = TRUE, updated_at = NOW() WHERE lower(email) = lower($1)`, [bootstrapAdminEmail]); } diff --git a/dist/db.js.map b/dist/db.js.map index 8cbdb2b6..30d1bcac 100644 --- a/dist/db.js.map +++ b/dist/db.js.map @@ -1 +1 @@ -{"version":3,"file":"db.js","sourceRoot":"","sources":["../src/db.ts"],"names":[],"mappings":"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"} \ No newline at end of file +{"version":3,"file":"db.js","sourceRoot":"","sources":["../src/db.ts"],"names":[],"mappings":"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"} \ No newline at end of file diff --git a/dist/idempotency.d.ts b/dist/idempotency.d.ts index 91c446cf..292c28e9 100644 --- a/dist/idempotency.d.ts +++ b/dist/idempotency.d.ts @@ -1,4 +1,4 @@ -import type { RequestHandler } from "express"; +import type { RequestHandler, Response } from "express"; /** * Minimum length an `Idempotency-Key` header value may have. * @@ -15,6 +15,8 @@ export declare const IDEMPOTENCY_KEY_MIN_LENGTH = 8; * strings into the unique index; real keys are 36-character UUID strings. */ export declare const IDEMPOTENCY_KEY_MAX_LENGTH = 200; +/** Mark a failure only after proving no mutation committed (for example, confirmed rollback before COMMIT was attempted). */ +export declare function markIdempotencyPreCommitFailure(res: Response): void; /** * Compute the fingerprint that binds one idempotency key to one request. * diff --git a/dist/idempotency.js b/dist/idempotency.js index 59283e22..5a6d06db 100644 --- a/dist/idempotency.js +++ b/dist/idempotency.js @@ -36,10 +36,21 @@ const DEFAULT_PENDING_TIMEOUT_MS = 300_000; * milliseconds. Override: `PM_WEB_IDEMPOTENCY_RETENTION_MS`. * * Completed outcomes are deduplicated within this window. A retry after expiry - * can execute again. Pending intents have no expiry because their mutations - * may have committed without a captured outcome. + * can execute again. Unknown outcomes have the same bounded retention, measured + * from settlement; stale pending intents must settle before they can expire. */ const DEFAULT_RETENTION_MS = 7 * 24 * 60 * 60 * 1000; +/** Handler assertions are process-local and cannot be forged through request headers. */ +const preCommitFailures = new WeakSet(); +/** Mark a failure only after proving no mutation committed (for example, confirmed rollback before COMMIT was attempted). */ +export function markIdempotencyPreCommitFailure(res) { + preCommitFailures.add(res); +} +/** Machine-readable refusal reused by failure settlement and stale-intent recovery. */ +const UNKNOWN_OUTCOME = JSON.stringify({ + code: "PM_IDEMPOTENCY_OUTCOME_UNKNOWN", + error: "Idempotency outcome unknown; reconcile the original mutation before submitting new work", +}); /** * Compute the fingerprint that binds one idempotency key to one request. * @@ -112,7 +123,7 @@ async function accountExists(userId) { * one only by an explicit transient refusal). */ async function selectKey(userId, key) { - const result = await pool.query(`SELECT id, request_fingerprint, status_code, response_body, response_content_type, created_at + const result = await pool.query(`SELECT id, request_fingerprint, status_code, response_body, response_content_type, created_at, outcome_state FROM pm_idempotency_keys WHERE user_id = $1 AND idempotency_key = $2`, [userId, key]); return result.rows[0] ?? null; @@ -120,28 +131,32 @@ async function selectKey(userId, key) { /** * Sweep idempotency records past the retention window. * - * Called at most once per minute per guard instance. Pending records are never - * swept: they may represent committed mutations with an unknown outcome. + * Called at most once per minute per guard instance. Stale pending records first + * become terminal unknown outcomes; retention starts at that settlement time. */ async function deleteExpiredKeys() { + await pool.query(`UPDATE pm_idempotency_keys SET outcome_state = 'outcome_unknown', status_code = 409, + response_body = $2, response_content_type = 'application/json', updated_at = NOW() + WHERE status_code IS NULL AND outcome_state = 'pending' + AND created_at < NOW() - ($1::double precision * interval '1 ms')`, [envDurationMs("PM_WEB_IDEMPOTENCY_PENDING_TIMEOUT_MS", DEFAULT_PENDING_TIMEOUT_MS), UNKNOWN_OUTCOME]); await pool.query(`DELETE FROM pm_idempotency_keys - WHERE status_code IS NOT NULL AND created_at < NOW() - ($1::double precision * interval '1 ms')`, [envDurationMs("PM_WEB_IDEMPOTENCY_RETENTION_MS", DEFAULT_RETENTION_MS)]); + WHERE status_code IS NOT NULL AND updated_at < NOW() - ($1::double precision * interval '1 ms')`, [envDurationMs("PM_WEB_IDEMPOTENCY_RETENTION_MS", DEFAULT_RETENTION_MS)]); } /** * Store a definitive outcome. Release only explicit pre-mutation refusals - * (425 Too Early and 429 Too Many Requests). Server failures retain the pending - * intent because a handler may have committed before failing. + * (425, 429, or handler-proven pre-commit failures). All other server failures + * settle as terminal unknown outcomes because they may follow a commit. */ -async function persistOutcome(rowId, statusCode, body, contentType) { - if (statusCode >= 500) - return; - if (statusCode === 425 || statusCode === 429) { - await pool.query(`DELETE FROM pm_idempotency_keys WHERE id = $1`, [rowId]); +async function persistOutcome(rowId, statusCode, body, contentType, preCommit = false) { + if (statusCode === 425 || statusCode === 429 || (statusCode >= 500 && preCommit)) { + await pool.query(`DELETE FROM pm_idempotency_keys WHERE id = $1 AND outcome_state = 'pending'`, [rowId]); return; } + const unknown = statusCode >= 500; await pool.query(`UPDATE pm_idempotency_keys - SET status_code = $2, response_body = $3, response_content_type = $4, updated_at = NOW() - WHERE id = $1`, [rowId, statusCode, body, contentType]); + SET status_code = $2, response_body = $3, response_content_type = $4, outcome_state = $5, updated_at = NOW() + WHERE id = $1 AND outcome_state = 'pending'`, [rowId, unknown ? 409 : statusCode, unknown ? UNKNOWN_OUTCOME : body, + unknown ? "application/json" : contentType, unknown ? "outcome_unknown" : "completed"]); } /** * Answer a duplicate from the stored outcome of the original request. @@ -191,6 +206,7 @@ function captureOutcome(rowId, res) { let capturedBody = null; const originalJson = res.json.bind(res); const originalSend = res.send.bind(res); + /** Capture the first response serialization without recording request content. */ const capture = (body) => { if (capturedBody !== null) return; @@ -204,12 +220,22 @@ function captureOutcome(rowId, res) { capture(body); return originalSend(body); }; - res.on("finish", () => { + let finished = false; + /** Finish and premature close share one persistence path, avoiding conflicting writes. */ + const settle = (statusCode) => { const contentType = res.getHeader("Content-Type"); - void persistOutcome(rowId, res.statusCode, capturedBody, typeof contentType === "string" ? contentType : null).catch((error) => { + void persistOutcome(rowId, statusCode, capturedBody, typeof contentType === "string" ? contentType : null, preCommitFailures.has(res)).catch((error) => { // Do not log response bodies, tokens or database error details. console.error("Failed to persist idempotency outcome; key remains pending", error instanceof Error ? error.name : "unknown error"); }); + }; + res.on("finish", () => { + finished = true; + settle(res.statusCode); + }); + res.on("close", () => { + if (!finished) + settle(500); }); } /** @@ -309,11 +335,11 @@ export function idempotencyGuard() { continue; } if (isStalePending(record)) { - res.status(409).json({ error: "Idempotency outcome unknown; reconcile the original mutation before submitting new work" }); - return; + await persistOutcome(record.id, 500, null, null); + continue; } if (Date.now() >= deadline) { - res.status(409).json({ error: "The request with this Idempotency-Key is still executing or its outcome is unknown; retry later" }); + res.status(409).json({ code: "PM_IDEMPOTENCY_IN_FLIGHT", error: "The request with this Idempotency-Key is still executing; retry later" }); return; } await new Promise((resolve) => { setTimeout(resolve, envDurationMs("PM_WEB_IDEMPOTENCY_POLL_MS", DEFAULT_POLL_MS)); }); diff --git a/dist/idempotency.js.map b/dist/idempotency.js.map index e218b3c4..11cad816 100644 --- a/dist/idempotency.js.map +++ b/dist/idempotency.js.map @@ -1 +1 @@ -{"version":3,"file":"idempotency.js","sourceRoot":"","sources":["../src/idempotency.ts"],"names":[],"mappings":"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"} \ No newline at end of file +{"version":3,"file":"idempotency.js","sourceRoot":"","sources":["../src/idempotency.ts"],"names":[],"mappings":"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"} \ No newline at end of file diff --git a/dist/routes/groups.js b/dist/routes/groups.js index 71140dc9..21933b65 100644 --- a/dist/routes/groups.js +++ b/dist/routes/groups.js @@ -3,6 +3,7 @@ import { pool } from "../db.js"; import { requireAuth } from "../middleware/auth.js"; import { routeParam, uuidParamGuard } from "./route-params.js"; import { findUserByEmail, notFoundWhenEmpty, parseGroupInput } from "./route-helpers.js"; +import { markIdempotencyPreCommitFailure } from "../idempotency.js"; const router = Router(); router.use(requireAuth); // Both group ids and the member :userId are UUIDs; a malformed one is a bad @@ -31,7 +32,11 @@ router.post("/", async (req, res) => { if (!input) return; const { name, description } = input; - const client = await pool.connect(); + const client = await pool.connect().catch((error) => { + markIdempotencyPreCommitFailure(res); + throw error; + }); + let commitAttempted = false; try { await client.query("BEGIN"); const groupResult = await client.query(`INSERT INTO pm_groups (owner_id, name, description) @@ -40,11 +45,15 @@ router.post("/", async (req, res) => { const group = groupResult.rows[0]; // Add owner as a member with role 'owner' await client.query(`INSERT INTO pm_group_members (group_id, user_id, role) VALUES ($1, $2, 'owner')`, [group.id, req.user.userId]); + commitAttempted = true; await client.query("COMMIT"); res.status(201).json({ group: { ...groupResult.rows[0], role: "owner", member_count: "1" } }); } catch (err) { await client.query("ROLLBACK"); + // A failed COMMIT can have succeeded remotely; later ROLLBACK cannot prove otherwise. + if (!commitAttempted) + markIdempotencyPreCommitFailure(res); console.error("Create group error:", err); res.status(500).json({ error: "Failed to create group" }); } diff --git a/dist/routes/groups.js.map b/dist/routes/groups.js.map index 29230272..bbb2b891 100644 --- a/dist/routes/groups.js.map +++ b/dist/routes/groups.js.map @@ -1 +1 @@ -{"version":3,"file":"groups.js","sourceRoot":"","sources":["../../src/routes/groups.ts"],"names":[],"mappings":"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"} \ No newline at end of file +{"version":3,"file":"groups.js","sourceRoot":"","sources":["../../src/routes/groups.ts"],"names":[],"mappings":"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"} \ No newline at end of file diff --git a/public/src/api.ts b/public/src/api.ts index 4e1f8058..c85b4eb6 100644 --- a/public/src/api.ts +++ b/public/src/api.ts @@ -52,7 +52,9 @@ export async function api(method: string, path: string, body?: unkn } /** Session bridge state belongs to one worker container and its current page. */ -const workerSessions = new WeakMap(); +const workerSessions = new WeakMap(); /** * Tell the service worker which account is signed in, so offline-queued @@ -73,7 +75,7 @@ export function syncServiceWorkerSession(user: { id: string } | null): void { const workers = navigator.serviceWorker; let session = workerSessions.get(workers); if (!session) { - session = { userId: null, recovery: null }; + session = { userId: null, recovery: null, dismissedSet: null }; workerSessions.set(workers, session); const current = session; workers.addEventListener('controllerchange', () => { @@ -83,9 +85,28 @@ export function syncServiceWorkerSession(user: { id: string } | null): void { const data = event.data as { type?: string; blocked?: unknown } | null; if (data?.type !== 'MUTATIONS_BLOCKED' || !Array.isArray(data.blocked)) return; current.recovery?.remove(); + current.recovery = null; + // Compare the set of actionable record identities, ignoring report order. + const blockedSet = JSON.stringify(data.blocked.map((value: unknown) => { + const record = typeof value === 'object' && value !== null ? value as Record : {}; + return JSON.stringify(['id', 'method', 'path', 'reason', 'ownerId', 'workspace'].map((field) => { + const entry = record[field]; + return typeof entry === 'string' || typeof entry === 'number' ? entry : null; + })); + }).sort()); + if (current.dismissedSet !== blockedSet) current.dismissedSet = null; + if (data.blocked.length === 0 || current.dismissedSet === blockedSet) return; current.recovery = showOfflineRecovery(data.blocked, current.userId, (ids, ownerId) => { if (current.userId !== ownerId) return; workers.controller?.postMessage({ type: 'REBIND_RECORDS', ids, ownerId }); + }, { + /** Carry explicit owner approval to the worker for one attention record. */ + recover: (id, ownerId, action) => { + if (current.userId !== ownerId) return; + workers.controller?.postMessage({ type: 'RECOVER_RECORD', id, ownerId, action }); + }, + /** Suppress repeated reports for this exact set until queued work changes. */ + dismiss: () => { current.dismissedSet = blockedSet; current.recovery = null; }, }); }); } diff --git a/public/src/offline-recovery.ts b/public/src/offline-recovery.ts index 8b569a64..00d21536 100644 --- a/public/src/offline-recovery.ts +++ b/public/src/offline-recovery.ts @@ -1,12 +1,14 @@ /** * Show preserved offline work and offer adoption only for records without an - * owner. Clicking explicitly asserts that the listed work belongs to this - * signed-in account. Account changes invalidate the callback in the bridge. + * owner. Unknown outcomes and repeated failures require explicit owner action; + * retry warns that a new request can duplicate an earlier unknown commit. + * Account changes invalidate callbacks in the bridge. Dismissal preserves work. */ export function showOfflineRecovery( blocked: unknown[], userId: string | null, adopt: (ids: number[], ownerId: string) => void, + actions: { recover: (id: number, ownerId: string, action: 'retry-new' | 'discard') => void; dismiss: () => void }, ): HTMLElement { const notice = document.createElement('div'); notice.id = 'offline-recovery'; @@ -14,18 +16,37 @@ export function showOfflineRecovery( notice.style.cssText = 'position:fixed;bottom:20px;right:20px;z-index:1000;max-width:min(460px,calc(100vw - 40px));max-height:60vh;overflow:auto;overflow-wrap:anywhere;padding:16px;border:1px solid var(--accent,#2dd4bf);border-radius:8px;background:var(--bg-card,#0f172a);color:var(--text-primary,#f1f5f9);box-shadow:0 4px 20px #0004'; notice.setAttribute('role', 'status'); const description = document.createElement('p'); - description.textContent = 'Some offline changes are blocked. Sign in to their original account to replay them. Unknown-owner changes can be adopted only if they are yours.'; + description.textContent = 'Some offline changes need attention. Later changes in the same workspace wait; independent workspaces can continue. Review the original result before retrying an unknown outcome, as a new request may duplicate a committed change. Sign in to the original account; adopt unknown-owner changes only if they are yours.'; const list = document.createElement('ul'); const unknownIds: number[] = []; for (const value of blocked) { if (typeof value !== 'object' || value === null) continue; - const record = value as { id?: unknown; method?: unknown; path?: unknown; reason?: unknown }; + const record = value as { id?: unknown; method?: unknown; path?: unknown; reason?: unknown; ownerId?: unknown }; const item = document.createElement('li'); item.textContent = `${String(record.method ?? '')} ${String(record.path ?? '')} (${String(record.reason ?? '')})`; list.append(item); if (record.reason === 'unknown-owner' && typeof record.id === 'number' && Number.isSafeInteger(record.id)) { unknownIds.push(record.id); } + if ((record.reason === 'outcome-unknown' || record.reason === 'repeated-5xx') + && typeof record.id === 'number' && Number.isSafeInteger(record.id) && userId !== null && record.ownerId === userId) { + const id = record.id; + const retry = document.createElement('button'); + retry.type = 'button'; + retry.className = 'btn btn-primary'; + retry.textContent = 'Retry as a new request'; + retry.onclick = () => { + if (confirm('The original request may already have committed. Retrying with a new key can duplicate it. Retry as a new request?')) { + actions.recover(id, userId, 'retry-new'); + } + }; + const discard = document.createElement('button'); + discard.type = 'button'; + discard.className = 'btn'; + discard.textContent = 'Discard'; + discard.onclick = () => { actions.recover(id, userId, 'discard'); }; + item.append(retry, discard); + } } notice.append(description, list); if (userId !== null && unknownIds.length > 0) { @@ -35,6 +56,13 @@ export function showOfflineRecovery( button.onclick = () => { adopt(unknownIds, userId); }; notice.append(button); } + const dismiss = document.createElement('button'); + dismiss.type = 'button'; + dismiss.className = 'btn'; + dismiss.textContent = 'Dismiss'; + dismiss.setAttribute('aria-label', 'Dismiss offline recovery notice'); + dismiss.onclick = () => { notice.remove(); actions.dismiss(); }; + notice.append(dismiss); document.body.append(notice); return notice; } diff --git a/public/src/sw.ts b/public/src/sw.ts index 8be14257..ca952ef2 100644 --- a/public/src/sw.ts +++ b/public/src/sw.ts @@ -125,6 +125,12 @@ interface QueuedMutation { workspace?: string | null; /** Server-enforced exactly-once identity for the replay. */ idempotencyKey?: string; + /** Durable recovery state; absence on legacy records means queued. */ + state?: 'queued' | 'needs-attention'; + /** Terminal replay problem requiring owner action. */ + attentionReason?: 'outcome-unknown' | 'repeated-5xx'; + /** Consecutive server failures survive worker restarts. */ + serverFailures?: number; } interface StoredMutation { @@ -152,7 +158,8 @@ interface MeBody { } /** Why one queued mutation was refused during a flush. */ -type ReplayBlockReason = 'no-session' | 'unknown-owner' | 'owner-mismatch' | 'auth-route'; +type ReplayBlockReason = 'no-session' | 'unknown-owner' | 'owner-mismatch' | 'auth-route' + | 'outcome-unknown' | 'repeated-5xx' | 'workspace-order'; /** A queued mutation a flush refused, surfaced to the page for explicit recovery. */ interface BlockedMutation { @@ -413,8 +420,7 @@ function replayBlockReason( return null; } -/** Replay queued mutations to the API in order, removing each on success and - * stopping at the first failure so it can retry later, then post-message the +/** Replay each workspace in FIFO order, removing successes, then post-message the * connected clients with how many were replayed or remain. A storage read * failure is never treated as a drained queue: the initial read failure * aborts the flush, and a final read failure reports a partial result rather @@ -425,9 +431,11 @@ function replayBlockReason( * Records owned by a different account, records whose ownership is unknown * (legacy, or queued with no known session), and every record while logged * out are never replayed and never deleted — they are kept and surfaced via - * MUTATIONS_BLOCKED so the page can offer explicit recovery. Skipping a - * blocked record never blocks the records behind it, so one stranded record - * cannot wedge an entire queue. Replayed records carry their idempotency key, + * MUTATIONS_BLOCKED so the page can offer explicit recovery. An owned failed + * or attention record blocks only later records in its workspace. Account-level + * work is a barrier across workspaces: it cannot overtake earlier unresolved + * owned work, and unresolved account-level work blocks all later owned work. + * Different owners have separate ordering. Replayed records carry their key, * so a retry after an ambiguous (lost) response is applied exactly once * server-side. */ async function replayQueuedMutations(): Promise { @@ -464,9 +472,15 @@ async function replayQueuedMutations(): Promise { } const blocked: BlockedMutation[] = []; + const blockedWorkspaces = new Set(); let replayed = 0; for (const mut of mutations) { - const reason = replayBlockReason(mut, currentUserId); + // Derive legacy scope from the path rather than assuming it is independent. + const workspace = workspaceFromPath(mut.path); + const reason = replayBlockReason(mut, currentUserId) + ?? (mut.state === 'needs-attention' ? mut.attentionReason ?? 'outcome-unknown' : null) + ?? (blockedWorkspaces.has(null) || (workspace === null ? blockedWorkspaces.size > 0 : blockedWorkspaces.has(workspace)) + ? 'workspace-order' : null); if (reason) { // Never replayed, never deleted: kept for its owner and surfaced so the // page can offer explicit recovery. Skipping does not stop the loop, so @@ -476,9 +490,10 @@ async function replayQueuedMutations(): Promise { method: mut.method, path: mut.path, ownerId: mut.ownerId ?? null, - workspace: mut.workspace ?? null, + workspace, reason, }); + if (mut.ownerId === currentUserId && currentUserId !== null) blockedWorkspaces.add(workspace); continue; } try { @@ -504,8 +519,27 @@ async function replayQueuedMutations(): Promise { replayed++; } else { console.warn('Offline mutation failed:', mut.method, mut.path, res.status); - // Stop processing on first failure — try again later - break; + const body = await res.json().catch(() => null) as { code?: unknown } | null; + let attention: QueuedMutation['attentionReason']; + if (body?.code === 'PM_IDEMPOTENCY_OUTCOME_UNKNOWN') attention = 'outcome-unknown'; + if (res.status >= 500) { + mut.serverFailures = (mut.serverFailures ?? 0) + 1; + if (mut.serverFailures >= 3) attention = 'repeated-5xx'; + } else if (!attention && mut.serverFailures) { + mut.serverFailures = 0; + } + if (attention) { + mut.state = 'needs-attention'; + mut.attentionReason = attention; + blocked.push({ id: mut.id, method: mut.method, path: mut.path, + ownerId: mut.ownerId ?? null, workspace, reason: attention }); + } + // Persist before allowing independent workspace replay; failure stops the flush. + const db = await openMutationDB(); + const tx = db.transaction(MUTATION_STORE, 'readwrite'); + tx.objectStore(MUTATION_STORE).put(mut); + await transactionDone(tx); + blockedWorkspaces.add(workspace); } } catch { // Network failed again — stop processing @@ -516,12 +550,10 @@ async function replayQueuedMutations(): Promise { // Notify clients about replayed mutations const remainingRead = await getQueuedMutations(); const clients = await sw.clients.matchAll(); - if (blocked.length > 0) { - // Surface refused records for explicit recovery — they are never dropped. - clients.forEach((client) => { - client.postMessage({ type: 'MUTATIONS_BLOCKED', blocked }); - }); - } + // Empty reports remove a previously rendered or dismissed recovery set. + clients.forEach((client) => { + client.postMessage({ type: 'MUTATIONS_BLOCKED', blocked }); + }); if (!remainingRead.ok) { // Could not re-read the queue — do NOT claim all mutations were replayed. // Report a partial result with the known replayed count so unreplayed @@ -585,7 +617,32 @@ function flushMutationQueue(): Promise { * @param ownerId - The approving page's account, verified against the server session. * @returns Whether the current server session approved adoption. */ -async function adoptUnknownOwnerRecords(ids: number[] | undefined, ownerId: string): Promise { +function adoptUnknownOwnerRecords(ids: number[] | undefined, ownerId: string): Promise { + return updateRecoveryRecords(ownerId, (store, mut) => { + if ((mut.ownerId === undefined || mut.ownerId === null) + && (ids === undefined || ids.includes(mut.id)) + && !/^\/auth(?:\/|\?|$)/i.test(mut.path)) { + store.put({ ...mut, ownerId, idempotencyKey: mut.idempotencyKey || newIdempotencyKey() }); + } + }); +} + +/** Adopt records through the common queue-operation chain used by flushes. */ +function rebindUnknownOwnerRecords(ids: number[] | undefined, ownerId: string): Promise { + return runQueueOperation(async () => { await adoptUnknownOwnerRecords(ids, ownerId); }); +} + +/** Apply owner-approved retry with a fresh key or discard atomically; never change another owner's work. */ +function recoverAttentionRecord(id: number, ownerId: string, action: 'retry-new' | 'discard'): Promise { + return updateRecoveryRecords(ownerId, (store, mut) => { + if (mut.id !== id || mut.ownerId !== ownerId || mut.state !== 'needs-attention') return; + if (action === 'discard') store.delete(id); + else store.put({ ...mut, state: 'queued', attentionReason: undefined, serverFailures: 0, idempotencyKey: newIdempotencyKey() }); + }); +} + +/** Verify the approving account, then read and update recovery records in one live IndexedDB transaction. */ +async function updateRecoveryRecords(ownerId: string, update: (store: IDBObjectStore, mut: QueuedMutation) => void): Promise { const approval = await fetch('/api/auth/me', { credentials: 'include', cache: 'no-store', headers: { 'X-PM-Expected-Account': ownerId }, }); @@ -596,27 +653,16 @@ async function adoptUnknownOwnerRecords(ids: number[] | undefined, ownerId: stri const tx = db.transaction(MUTATION_STORE, 'readwrite'); const done = transactionDone(tx); const store = tx.objectStore(MUTATION_STORE); - // Queue writes inside the read callback while this transaction is active. - // No pre-transaction snapshot can overwrite ownership or an existing key. const request = store.getAll(); request.onsuccess = () => { for (const mut of request.result as QueuedMutation[]) { - if ((mut.ownerId === undefined || mut.ownerId === null) - && (ids === undefined || ids.includes(mut.id)) - && !/^\/auth(?:\/|\?|$)/i.test(mut.path)) { - store.put({ ...mut, ownerId, idempotencyKey: mut.idempotencyKey || newIdempotencyKey() }); - } + update(store, mut); } }; await done; return true; } -/** Adopt records through the common queue-operation chain used by flushes. */ -function rebindUnknownOwnerRecords(ids: number[] | undefined, ownerId: string): Promise { - return runQueueOperation(async () => { await adoptUnknownOwnerRecords(ids, ownerId); }); -} - // ── Install ── sw.addEventListener('install', (event: ExtendableEvent) => { event.waitUntil( @@ -794,7 +840,7 @@ let sessionUpdate: Promise = Promise.resolve(); // ── Messages ── sw.addEventListener('message', (event: ExtendableMessageEvent) => { const data = event.data as - | { type?: string; urls?: string[]; userId?: unknown; ids?: unknown; ownerId?: unknown } + | { type?: string; urls?: string[]; userId?: unknown; ids?: unknown; ownerId?: unknown; id?: unknown; action?: unknown } | null; if (data && data.type === 'SKIP_WAITING') { sw.skipWaiting(); @@ -833,6 +879,16 @@ sw.addEventListener('message', (event: ExtendableMessageEvent) => { console.warn('Failed to adopt offline mutations'); })); } + if (data && data.type === 'RECOVER_RECORD') { + const { id, ownerId, action } = data; + if (typeof id !== 'number' || !Number.isSafeInteger(id) || typeof ownerId !== 'string' || ownerId.length === 0 + || (action !== 'retry-new' && action !== 'discard')) return; + event.waitUntil(runQueueOperation(async () => { + if (await recoverAttentionRecord(id, ownerId, action)) await replayQueuedMutations(); + }).catch(() => { + console.warn('Failed to recover offline mutation'); + })); + } }); // ── Background sync ── diff --git a/sql/schema.sql b/sql/schema.sql index dafe30c4..b4563da9 100644 --- a/sql/schema.sql +++ b/sql/schema.sql @@ -113,6 +113,7 @@ CREATE TABLE IF NOT EXISTS pm_idempotency_keys ( method TEXT NOT NULL, path TEXT NOT NULL, request_fingerprint TEXT NOT NULL, + outcome_state TEXT NOT NULL DEFAULT 'pending' CHECK (outcome_state IN ('pending', 'completed', 'outcome_unknown')), status_code INTEGER, response_body TEXT, response_content_type TEXT, @@ -122,6 +123,7 @@ CREATE TABLE IF NOT EXISTS pm_idempotency_keys ( ); CREATE INDEX IF NOT EXISTS idx_pm_idempotency_created_at ON pm_idempotency_keys (created_at) WHERE status_code IS NOT NULL; +CREATE INDEX IF NOT EXISTS idx_pm_idempotency_settled_at ON pm_idempotency_keys (updated_at) WHERE status_code IS NOT NULL; -- Bootstrap admin promotion is now applied at runtime via PM_WEB_BOOTSTRAP_ADMIN_EMAIL (see src/db.ts). diff --git a/src/db.ts b/src/db.ts index b2814521..3cbe3ffe 100644 --- a/src/db.ts +++ b/src/db.ts @@ -209,6 +209,7 @@ export async function initSchema(): Promise { method TEXT NOT NULL, path TEXT NOT NULL, request_fingerprint TEXT NOT NULL, + outcome_state TEXT NOT NULL DEFAULT 'pending' CHECK (outcome_state IN ('pending', 'completed', 'outcome_unknown')), status_code INTEGER, response_body TEXT, response_content_type TEXT, @@ -219,7 +220,10 @@ export async function initSchema(): Promise { `); await pool.query("ALTER TABLE pm_idempotency_keys ADD COLUMN IF NOT EXISTS response_content_type TEXT"); + await pool.query("ALTER TABLE pm_idempotency_keys ADD COLUMN IF NOT EXISTS outcome_state TEXT NOT NULL DEFAULT 'pending' CHECK (outcome_state IN ('pending', 'completed', 'outcome_unknown'))"); + await pool.query("UPDATE pm_idempotency_keys SET outcome_state = 'completed' WHERE outcome_state = 'pending' AND status_code IS NOT NULL"); await pool.query("CREATE INDEX IF NOT EXISTS idx_pm_idempotency_created_at ON pm_idempotency_keys (created_at) WHERE status_code IS NOT NULL"); + await pool.query("CREATE INDEX IF NOT EXISTS idx_pm_idempotency_settled_at ON pm_idempotency_keys (updated_at) WHERE status_code IS NOT NULL"); if (bootstrapAdminEmail) { await pool.query( diff --git a/src/idempotency.ts b/src/idempotency.ts index d551f5c3..4e28f9b7 100644 --- a/src/idempotency.ts +++ b/src/idempotency.ts @@ -43,8 +43,8 @@ const DEFAULT_PENDING_TIMEOUT_MS = 300_000; * milliseconds. Override: `PM_WEB_IDEMPOTENCY_RETENTION_MS`. * * Completed outcomes are deduplicated within this window. A retry after expiry - * can execute again. Pending intents have no expiry because their mutations - * may have committed without a captured outcome. + * can execute again. Unknown outcomes have the same bounded retention, measured + * from settlement; stale pending intents must settle before they can expire. */ const DEFAULT_RETENTION_MS = 7 * 24 * 60 * 60 * 1000; @@ -68,8 +68,24 @@ interface IdempotencyRecord { response_content_type: string | null; /** When the intent was inserted; drives the unknown-outcome deadline. */ created_at: Date; + /** Explicit terminal ambiguity is never automatically re-executed. */ + outcome_state: 'pending' | 'completed' | 'outcome_unknown'; } +/** Handler assertions are process-local and cannot be forged through request headers. */ +const preCommitFailures = new WeakSet(); + +/** Mark a failure only after proving no mutation committed (for example, confirmed rollback before COMMIT was attempted). */ +export function markIdempotencyPreCommitFailure(res: Response): void { + preCommitFailures.add(res); +} + +/** Machine-readable refusal reused by failure settlement and stale-intent recovery. */ +const UNKNOWN_OUTCOME = JSON.stringify({ + code: "PM_IDEMPOTENCY_OUTCOME_UNKNOWN", + error: "Idempotency outcome unknown; reconcile the original mutation before submitting new work", +}); + /** * Compute the fingerprint that binds one idempotency key to one request. * @@ -155,7 +171,7 @@ async function accountExists(userId: string): Promise { */ async function selectKey(userId: string, key: string): Promise { const result = await pool.query( - `SELECT id, request_fingerprint, status_code, response_body, response_content_type, created_at + `SELECT id, request_fingerprint, status_code, response_body, response_content_type, created_at, outcome_state FROM pm_idempotency_keys WHERE user_id = $1 AND idempotency_key = $2`, [userId, key], @@ -166,38 +182,47 @@ async function selectKey(userId: string, key: string): Promise { + await pool.query( + `UPDATE pm_idempotency_keys SET outcome_state = 'outcome_unknown', status_code = 409, + response_body = $2, response_content_type = 'application/json', updated_at = NOW() + WHERE status_code IS NULL AND outcome_state = 'pending' + AND created_at < NOW() - ($1::double precision * interval '1 ms')`, + [envDurationMs("PM_WEB_IDEMPOTENCY_PENDING_TIMEOUT_MS", DEFAULT_PENDING_TIMEOUT_MS), UNKNOWN_OUTCOME], + ); await pool.query( `DELETE FROM pm_idempotency_keys - WHERE status_code IS NOT NULL AND created_at < NOW() - ($1::double precision * interval '1 ms')`, + WHERE status_code IS NOT NULL AND updated_at < NOW() - ($1::double precision * interval '1 ms')`, [envDurationMs("PM_WEB_IDEMPOTENCY_RETENTION_MS", DEFAULT_RETENTION_MS)], ); } /** * Store a definitive outcome. Release only explicit pre-mutation refusals - * (425 Too Early and 429 Too Many Requests). Server failures retain the pending - * intent because a handler may have committed before failing. + * (425, 429, or handler-proven pre-commit failures). All other server failures + * settle as terminal unknown outcomes because they may follow a commit. */ async function persistOutcome( rowId: string, statusCode: number, body: string | null, contentType: string | null, + preCommit = false, ): Promise { - if (statusCode >= 500) return; - if (statusCode === 425 || statusCode === 429) { - await pool.query(`DELETE FROM pm_idempotency_keys WHERE id = $1`, [rowId]); + if (statusCode === 425 || statusCode === 429 || (statusCode >= 500 && preCommit)) { + await pool.query(`DELETE FROM pm_idempotency_keys WHERE id = $1 AND outcome_state = 'pending'`, [rowId]); return; } + const unknown = statusCode >= 500; await pool.query( `UPDATE pm_idempotency_keys - SET status_code = $2, response_body = $3, response_content_type = $4, updated_at = NOW() - WHERE id = $1`, - [rowId, statusCode, body, contentType], + SET status_code = $2, response_body = $3, response_content_type = $4, outcome_state = $5, updated_at = NOW() + WHERE id = $1 AND outcome_state = 'pending'`, + [rowId, unknown ? 409 : statusCode, unknown ? UNKNOWN_OUTCOME : body, + unknown ? "application/json" : contentType, unknown ? "outcome_unknown" : "completed"], ); } @@ -251,6 +276,7 @@ function captureOutcome(rowId: string, res: Response): void { let capturedBody: string | null = null; const originalJson = res.json.bind(res); const originalSend = res.send.bind(res); + /** Capture the first response serialization without recording request content. */ const capture = (body: unknown): void => { if (capturedBody !== null) return; capturedBody = typeof body === "string" ? body : JSON.stringify(body ?? null); @@ -263,17 +289,27 @@ function captureOutcome(rowId: string, res: Response): void { capture(body); return originalSend(body); }; - res.on("finish", () => { + let finished = false; + /** Finish and premature close share one persistence path, avoiding conflicting writes. */ + const settle = (statusCode: number): void => { const contentType = res.getHeader("Content-Type"); void persistOutcome( rowId, - res.statusCode, + statusCode, capturedBody, typeof contentType === "string" ? contentType : null, + preCommitFailures.has(res), ).catch((error: unknown) => { // Do not log response bodies, tokens or database error details. console.error("Failed to persist idempotency outcome; key remains pending", error instanceof Error ? error.name : "unknown error"); }); + }; + res.on("finish", () => { + finished = true; + settle(res.statusCode); + }); + res.on("close", () => { + if (!finished) settle(500); }); } @@ -374,11 +410,11 @@ export function idempotencyGuard(): RequestHandler { continue; } if (isStalePending(record)) { - res.status(409).json({ error: "Idempotency outcome unknown; reconcile the original mutation before submitting new work" }); - return; + await persistOutcome(record.id, 500, null, null); + continue; } if (Date.now() >= deadline) { - res.status(409).json({ error: "The request with this Idempotency-Key is still executing or its outcome is unknown; retry later" }); + res.status(409).json({ code: "PM_IDEMPOTENCY_IN_FLIGHT", error: "The request with this Idempotency-Key is still executing; retry later" }); return; } await new Promise((resolve) => { setTimeout(resolve, envDurationMs("PM_WEB_IDEMPOTENCY_POLL_MS", DEFAULT_POLL_MS)); }); diff --git a/src/routes/groups.ts b/src/routes/groups.ts index 9af6b576..6ffa6bda 100644 --- a/src/routes/groups.ts +++ b/src/routes/groups.ts @@ -3,6 +3,7 @@ import { pool } from "../db.ts"; import { requireAuth, type AuthRequest } from "../middleware/auth.ts"; import { routeParam, uuidParamGuard } from "./route-params.ts"; import { findUserByEmail, notFoundWhenEmpty, parseGroupInput } from "./route-helpers.ts"; +import { markIdempotencyPreCommitFailure } from "../idempotency.ts"; const router = Router(); router.use(requireAuth); @@ -37,7 +38,11 @@ router.post("/", async (req: AuthRequest, res) => { if (!input) return; const { name, description } = input; - const client = await pool.connect(); + const client = await pool.connect().catch((error: unknown) => { + markIdempotencyPreCommitFailure(res); + throw error; + }); + let commitAttempted = false; try { await client.query("BEGIN"); const groupResult = await client.query( @@ -53,10 +58,13 @@ router.post("/", async (req: AuthRequest, res) => { `INSERT INTO pm_group_members (group_id, user_id, role) VALUES ($1, $2, 'owner')`, [group.id, req.user!.userId] ); + commitAttempted = true; await client.query("COMMIT"); res.status(201).json({ group: { ...groupResult.rows[0], role: "owner", member_count: "1" } }); } catch (err) { await client.query("ROLLBACK"); + // A failed COMMIT can have succeeded remotely; later ROLLBACK cannot prove otherwise. + if (!commitAttempted) markIdempotencyPreCommitFailure(res); console.error("Create group error:", err); res.status(500).json({ error: "Failed to create group" }); } finally { @@ -223,4 +231,4 @@ router.delete("/:id/members/:userId", async (req: AuthRequest, res) => { } }); -export { router as groupsRouter }; \ No newline at end of file +export { router as groupsRouter }; diff --git a/test/frontend-api.test.ts b/test/frontend-api.test.ts index 509ac3f4..feaf0d3c 100644 --- a/test/frontend-api.test.ts +++ b/test/frontend-api.test.ts @@ -89,7 +89,8 @@ test("blocked legacy mutations have a visible explicit recovery action for the c { id: 2, method: "PATCH", path: "/items/foreign", reason: "owner-mismatch" }, ] } })); assert.equal(nodes.length, 1, "the SPA displays blocked work"); - const button = nodes[0].children.find((child) => (child as { onclick?: unknown }).onclick) as { onclick(): void; textContent: string }; + const button = nodes[0].children.find((child) => (child as { onclick?: unknown }).onclick + && /adopt/i.test((child as { textContent: string }).textContent)) as { onclick(): void; textContent: string }; assert.match(button.textContent, /adopt/i); assert.equal(messages.length, 1, "nothing is adopted until the user clicks"); button.onclick(); @@ -104,6 +105,107 @@ test("blocked legacy mutations have a visible explicit recovery action for the c } }); +/** Browser nodes retain attributes, children and native button properties for recovery assertions. */ +interface RecoveryNode { + tagName: string; + textContent: string; + type: string; + tabIndex: number; + attributes: Record; + children: RecoveryNode[]; + onclick?: () => void; + remove(): void; +} + +/** Install a browser event/DOM boundary while executing the real session bridge and notice code. */ +function recoveryPage(t: test.TestContext): { + nodes: RecoveryNode[]; messages: unknown[]; report: (blocked: unknown[]) => void; confirm: (answer: boolean) => void; +} { + const globals = globalThis as unknown as Record; + const originalDocument = globals.document; + const originalConfirm = globals.confirm; + const originalNavigator = Object.getOwnPropertyDescriptor(globalThis, "navigator"); + const nodes: RecoveryNode[] = []; + const messages: unknown[] = []; + const workers = Object.assign(new EventTarget(), { controller: { postMessage: (message: unknown) => { messages.push(message); } } }); + globals.document = { + /** Model native buttons as keyboard focusable without changing application behavior. */ + createElement: (tagName: string): RecoveryNode => { + const node = { tagName, textContent: "", type: "", tabIndex: tagName === "button" ? 0 : -1, + style: { cssText: "" }, attributes: {} as Record, children: [] as RecoveryNode[], + /** Retain the rendered child tree for accessible control assertions. */ + append: (...children: RecoveryNode[]) => { node.children.push(...children); }, + /** Retain ARIA and native attributes as a browser would. */ + setAttribute: (name: string, value: string) => { node.attributes[name] = value; }, + /** Remove the notice from the rendered body. */ + remove: () => { const index = nodes.indexOf(node); if (index >= 0) nodes.splice(index, 1); }, + }; + return node; + }, + body: { append: (node: RecoveryNode) => { nodes.push(node); } }, + }; + Object.defineProperty(globalThis, "navigator", { configurable: true, value: { serviceWorker: workers } }); + t.after(() => { + globals.document = originalDocument; + if (originalConfirm === undefined) delete globals.confirm; + else globals.confirm = originalConfirm; + if (originalNavigator) Object.defineProperty(globalThis, "navigator", originalNavigator); + else Reflect.deleteProperty(globalThis, "navigator"); + }); + syncServiceWorkerSession({ id: "account-a" }); + return { nodes, messages, + /** Dispatch worker reports through the real browser listener. */ + report: (blocked) => { workers.dispatchEvent(new MessageEvent("message", { data: { type: "MUTATIONS_BLOCKED", blocked } })); }, + /** Supply the user's answer to the browser confirmation dialog. */ + confirm: (answer) => { globals.confirm = () => answer; }, + }; +} + +test("recovery dismissal is accessible and lasts until the blocked set changes", (t) => { + const page = recoveryPage(t); + const blocked = [{ id: 1, method: "POST", path: "/groups", reason: "owner-mismatch" }]; + page.report(blocked); + const dismiss = page.nodes[0].children.find((node) => node.textContent === "Dismiss"); + assert.ok(dismiss, "the recovery overlay has a dismiss button"); + assert.equal(dismiss.tagName, "button"); + assert.equal(dismiss.attributes["aria-label"], "Dismiss offline recovery notice"); + assert.equal(dismiss.tabIndex, 0, "native button is keyboard reachable"); + dismiss.onclick?.(); + assert.equal(page.nodes.length, 0); + page.report(structuredClone(blocked)); + syncServiceWorkerSession({ id: "account-a" }); + page.report(blocked); + assert.equal(page.nodes.length, 0, "repeated flushes and session sync do not reopen a dismissed notice"); + page.report([...blocked, { id: 2, method: "PATCH", path: "/projects/a/pm/update", reason: "outcome-unknown" }]); + assert.equal(page.nodes.length, 1, "changed blocked work reopens the notice"); + page.report([]); + assert.equal(page.nodes.length, 0, "resolved work removes the notice"); + page.report(blocked); + assert.equal(page.nodes.length, 1, "a cleared set resets dismissal for later blocked work"); +}); + +test("needs-attention recovery offers confirmed retry-as-new and discard only to its owner", (t) => { + const page = recoveryPage(t); + page.report([{ id: 4, method: "POST", path: "/projects/a/pm/create", ownerId: "account-a", reason: "outcome-unknown" }]); + const row = page.nodes[0].children.find((node) => node.tagName === "ul")?.children[0]; + const retry = row?.children.find((node) => node.textContent === "Retry as a new request"); + const discard = row?.children.find((node) => node.textContent === "Discard"); + assert.ok(retry); + assert.ok(discard); + page.confirm(false); + retry.onclick?.(); + assert.equal(page.messages.length, 1, "cancelling confirmation sends no recovery request"); + page.confirm(true); + retry.onclick?.(); + assert.deepEqual(page.messages[1], { type: "RECOVER_RECORD", action: "retry-new", id: 4, ownerId: "account-a" }); + discard.onclick?.(); + assert.deepEqual(page.messages[2], { type: "RECOVER_RECORD", action: "discard", id: 4, ownerId: "account-a" }); + syncServiceWorkerSession({ id: "account-b" }); + retry.onclick?.(); + discard.onclick?.(); + assert.equal(page.messages.length, 4, "stale controls cannot approve another account's recovery"); +}); + test("the originating tab attaches its expected account before a worker reads shared session state", async () => { const originalNavigator = Object.getOwnPropertyDescriptor(globalThis, "navigator"); const globals = globalThis as unknown as Record; diff --git a/test/helpers/pg-harness.ts b/test/helpers/pg-harness.ts index 57bc4fdc..debcf699 100644 --- a/test/helpers/pg-harness.ts +++ b/test/helpers/pg-harness.ts @@ -41,6 +41,16 @@ import { initSchema, pool } from "../../src/db.ts"; export const RUN_ID = process.hrtime.bigint().toString(36) + Math.random().toString(36).slice(2); +/** Bound duplicate polling in failure tests and restore the process configuration after each test. */ +export function configureIdempotencyWait(t: test.TestContext, milliseconds: number): void { + const previous = process.env.PM_WEB_IDEMPOTENCY_WAIT_MS; + process.env.PM_WEB_IDEMPOTENCY_WAIT_MS = String(milliseconds); + t.after(() => { + if (previous === undefined) delete process.env.PM_WEB_IDEMPOTENCY_WAIT_MS; + else process.env.PM_WEB_IDEMPOTENCY_WAIT_MS = previous; + }); +} + /** A seeded user, carrying the fields the tests need to build requests. */ export interface SeedUser { /** UUID primary key of the `pm_users` row. */ diff --git a/test/idempotency.test.ts b/test/idempotency.test.ts index c0ad20a3..5f03dae6 100644 --- a/test/idempotency.test.ts +++ b/test/idempotency.test.ts @@ -26,6 +26,7 @@ import { signToken } from "../src/auth.ts"; import { idempotencyGuard, requestFingerprint } from "../src/idempotency.ts"; import { RUN_ID, + configureIdempotencyWait, authedFetch, ensureSchema, seedUser, @@ -333,7 +334,7 @@ test("a committed mutation with a stale pending key returns outcome unknown with const response = await keyedGroupPost(server, owner, key, name); assert.equal(response.status, 409); assert.match(JSON.stringify(await response.json()), /outcome unknown/i); - assert.equal(response.headers.get("idempotency-replayed"), null); + assert.equal(response.headers.get("idempotency-replayed"), "true", "stale ambiguity becomes a stored terminal outcome"); assert.equal(await groupCount(owner.id), 1); }); @@ -404,6 +405,7 @@ test("a pending execution that never settles refuses the duplicate at the deadli const response = await keyedGroupPost(server, owner, key, name); assert.equal(response.status, 409, "an unsettled duplicate is refused, not executed"); + assert.equal((await response.json() as { code: string }).code, "PM_IDEMPOTENCY_IN_FLIGHT"); assert.equal(await groupCount(owner.id), 0, "nothing was applied"); }); @@ -430,18 +432,40 @@ test("an ambiguous 5xx after a commit never permits re-execution", async (t) => const failure = await fetch(handle.url("/flaky"), { method: "POST", headers, body: "{}" }); assert.equal(failure.status, 500, "the first attempt fails"); - const previousWait = process.env.PM_WEB_IDEMPOTENCY_WAIT_MS; - process.env.PM_WEB_IDEMPOTENCY_WAIT_MS = "50"; - t.after(() => { - if (previousWait === undefined) delete process.env.PM_WEB_IDEMPOTENCY_WAIT_MS; - else process.env.PM_WEB_IDEMPOTENCY_WAIT_MS = previousWait; - }); + configureIdempotencyWait(t, 50); const retry = await fetch(handle.url("/flaky"), { method: "POST", headers, body: "{}" }); assert.equal(retry.status, 409); + assert.equal((await retry.json() as { code: string }).code, "PM_IDEMPOTENCY_OUTCOME_UNKNOWN"); + const state = await pool.query<{ outcome_state: string; status_code: number }>( + "SELECT outcome_state, status_code FROM pm_idempotency_keys WHERE user_id = $1 AND idempotency_key = $2", + [owner.id, headers["idempotency-key"]], + ); + assert.deepEqual(state.rows[0], { outcome_state: "outcome_unknown", status_code: 409 }); assert.equal(attempts, 1, "an ambiguous failure retains its intent"); assert.equal(await groupCount(owner.id), 1, "the committed mutation is not repeated"); }); +test("a proven pre-commit PostgreSQL rollback releases the key and retry succeeds exactly once", async (t) => { + const { server, owner } = await setupOwnerAppTest(t); + const key = uniqueKey("pre-commit"); + const trigger = `pre_commit_${RUN_ID}`; + await pool.query(`CREATE FUNCTION ${trigger}() RETURNS trigger LANGUAGE plpgsql AS $$ + BEGIN IF NEW.user_id = '${owner.id}' THEN RAISE EXCEPTION 'synthetic pre-commit failure'; END IF; RETURN NEW; END $$`); + await pool.query(`CREATE TRIGGER ${trigger} BEFORE INSERT ON pm_group_members FOR EACH ROW EXECUTE FUNCTION ${trigger}()`); + t.after(async () => { + await pool.query(`DROP TRIGGER IF EXISTS ${trigger} ON pm_group_members`); + await pool.query(`DROP FUNCTION ${trigger}()`); + }); + configureIdempotencyWait(t, 100); + assert.equal((await keyedGroupPost(server, owner, key, "rollback-safe")).status, 500); + assert.equal(await groupCount(owner.id), 0, "the inserted group rolled back with its failed member write"); + await pool.query(`DROP TRIGGER ${trigger} ON pm_group_members`); + const retry = await keyedGroupPost(server, owner, key, "rollback-safe"); + assert.equal(retry.status, 201, "the same key may retry after a proven rollback"); + assert.equal((await keyedGroupPost(server, owner, key, "rollback-safe")).status, 201); + assert.equal(await groupCount(owner.id), 1, "only the successful retry committed"); +}); + test("a response with no body and a plain-text response are replayed with their shape", async (t) => { const { app, owner } = await keyedProbe(); app.post("/no-content", (_req, res) => { @@ -492,7 +516,7 @@ test("idempotency records older than the retention window are cleaned up", async assert.equal(first.status, 201); await pool.query( `UPDATE pm_idempotency_keys - SET created_at = NOW() - (30::double precision * interval '1 day') + SET created_at = NOW() - (30::double precision * interval '1 day'), updated_at = NOW() - interval '30 days' WHERE user_id = $1 AND idempotency_key = $2`, [owner.id, oldKey], ); @@ -508,8 +532,16 @@ test("idempotency records older than the retention window are cleaned up", async t.after(() => newServer.close()); await keyedGroupPost(newServer, owner, uniqueKey("sweep"), "sweep"); assert.equal((await keyRows(owner.id, oldKey)).length, 0, "a new instance sweeps completed expired records"); - assert.equal((await keyRows(owner.id, pendingKey)).length, 1, "unknown outcomes never expire"); + const unknown = await pool.query<{ outcome_state: string; status_code: number }>( + "SELECT outcome_state, status_code FROM pm_idempotency_keys WHERE user_id = $1 AND idempotency_key = $2", [owner.id, pendingKey], + ); + assert.deepEqual(unknown.rows[0], { outcome_state: "outcome_unknown", status_code: 409 }, "stale pending rows settle before retention starts"); assert.equal((await keyRows(owner.id, freshKey)).length, 1, "the fresh record remains"); + await pool.query("UPDATE pm_idempotency_keys SET updated_at = NOW() - interval '30 days' WHERE user_id = $1 AND idempotency_key = $2", [owner.id, pendingKey]); + const sweepServer = await startEphemeralServer(createApp()); + t.after(() => sweepServer.close()); + await keyedGroupPost(sweepServer, owner, uniqueKey("sweep-unknown"), "sweep-unknown"); + assert.deepEqual(await keyRows(owner.id, pendingKey), [], "terminal unknown outcomes are eligible for retention sweep"); }); test("an unknown token account bypasses keying and gets the route's normal outcome", async (t) => { diff --git a/test/sw-idempotency-http.test.ts b/test/sw-idempotency-http.test.ts index 33ef20fa..c460a126 100644 --- a/test/sw-idempotency-http.test.ts +++ b/test/sw-idempotency-http.test.ts @@ -2,13 +2,16 @@ import assert from "node:assert/strict"; import test from "node:test"; import express from "express"; +import cookieParser from "cookie-parser"; import { swQueue, swQueueInternals } from "./helpers/sw-queue-preload.ts"; import "../public/src/sw.ts"; -import { authCookie, seedPasswordUser, setupOwnerAppTest, type AppServer, type SeedUser } from "./helpers/pg-harness.ts"; +import { authCookie, configureIdempotencyWait, ensureSchema, RUN_ID, seedPasswordUser, seedUser, setupOwnerAppTest, type AppServer, type SeedUser } from "./helpers/pg-harness.ts"; import { startEphemeralServer } from "./helpers/ephemeral-server.ts"; import { createTierLimiters } from "../src/rate-limit.ts"; import { signToken } from "../src/auth.ts"; import { pool } from "../src/db.ts"; +import { idempotencyGuard } from "../src/idempotency.ts"; +import { groupsRouter } from "../src/routes/groups.ts"; test("a real HTTP response lost after commit replays the queued write exactly once", async (t) => { const { server, owner } = await setupOwnerAppTest(t); @@ -218,3 +221,152 @@ test("overlapping recovery and flush preserve one key when tab B signs in during const groups = await pool.query<{ n: number }>("SELECT count(*)::int AS n FROM pm_groups WHERE owner_id = ANY($1::uuid[])", [[owner.id, other.id]]); assert.equal(groups.rows[0]?.n, 0); }); + +/** Serve real keyed mutations and account bootstrap for workspace recovery tests. */ +async function setupWorkspaceRecovery(t: test.TestContext): Promise<{ + owner: SeedUser; worker: ReturnType; browser: ReturnType; +}> { + await ensureSchema(); + const owner = await seedUser(); + configureIdempotencyWait(t, 50); + const app = express(); + app.use(express.json()); + app.use(cookieParser()); + app.use(idempotencyGuard()); + app.get("/api/auth/me", (_req, res) => { + res.setHeader("x-csrf-token", "pm-web-test-csrf"); + res.json({ user: { id: owner.id } }); + }); + app.use("/api/projects/:workspace/groups", groupsRouter); + let ambiguousAttempts = 0; + app.post("/api/projects/:workspace/mutate", async (req, res) => { + const name = (req.body as { name: string }).name; + await pool.query("INSERT INTO pm_groups (name, owner_id) VALUES ($1, $2)", [name, owner.id]); + if (name === "ambiguous") ambiguousAttempts++; + res.status(name === "ambiguous" && ambiguousAttempts === 1 ? 500 : 201).json({ name }); + }); + const server = await startEphemeralServer(app); + t.after(() => server.close()); + const browser = browserTransport(server, owner); + t.after(browser.restore); + const worker = swQueueInternals(); + await worker.saveSession?.(owner.id); + swQueue.getAllResult = []; + swQueue.postedMessages.length = 0; + return { owner, worker, browser }; +} + +/** Queue independent workspaces and retain the durable records accepted by the worker. */ +async function queueWorkspaceRecords(worker: ReturnType, route: string, first: string): Promise { + for (const [id, workspace, name] of [[1, "a", first], [2, "a", "after-a"], [3, "b", "other-b"]] as const) { + await worker.queueMutation("POST", `/projects/${workspace}/${route}`, { name }); + swQueue.getAllResult.push({ ...(swQueue.lastAdded as object), id }); + } +} + +/** Send an explicit recovery approval through the actual worker message listener. */ +async function recoverRecord(ownerId: string, action: string, id = 1): Promise { + const pending: Promise[] = []; + swQueue.listeners.message({ data: { type: "RECOVER_RECORD", action, id, ownerId }, + waitUntil: (promise: Promise) => { pending.push(promise); } }); + await Promise.all(pending); +} + +test("ambiguous HTTP failure needs attention, isolates workspace ordering, and confirmed retry-as-new executes once", async (t) => { + const { owner, worker, browser } = await setupWorkspaceRecovery(t); + await queueWorkspaceRecords(worker, "mutate", "ambiguous"); + const originalKey = (swQueue.getAllResult[0] as { idempotencyKey: string }).idempotencyKey; + await worker.flushMutationQueue(); + await worker.flushMutationQueue(); + const first = swQueue.getAllResult[0] as { state: string; attentionReason: string }; + assert.equal(first.state, "needs-attention"); + assert.equal(first.attentionReason, "outcome-unknown"); + assert.deepEqual(swQueue.getAllResult.map((value) => (value as { id: number }).id), [1, 2]); + assert.ok(browser.requests.some((request) => request.path.includes("/b/") && request.status === 201)); + assert.ok(!browser.requests.some((request) => request.path.includes("/a/") && request.key !== originalKey)); + const before = browser.requests.length; + await worker.flushMutationQueue(); + assert.equal(browser.requests.slice(before).filter((request) => request.method === "POST").length, 0, "unknown work is never replayed automatically"); + await recoverRecord(owner.id, "retry-new"); + const retry = browser.requests.find((request) => request.path.includes("/a/") && request.method === "POST" && request.status === 201); + assert.ok(retry); + assert.notEqual(retry.key, originalKey, "user approval assigned a fresh key"); + await worker.flushMutationQueue(); + await worker.flushMutationQueue(); + const writes = await pool.query<{ name: string; n: number }>( + "SELECT name, count(*)::int AS n FROM pm_groups WHERE owner_id = $1 GROUP BY name", [owner.id], + ); + assert.equal(writes.rows.find((row) => row.name === "ambiguous")?.n, 2, "one original commit plus one approved new request"); + assert.deepEqual(swQueue.getAllResult, [], "successful approved retry unblocks later work in the same workspace"); + assert.equal(browser.requests.filter((request) => request.method === "POST" && request.key === retry.key).length, 1); + const ordered = await pool.query<{ n: number }>("SELECT count(*)::int AS n FROM pm_groups WHERE owner_id = $1 AND name = 'after-a'", [owner.id]); + assert.equal(ordered.rows[0]?.n, 1); +}); + +test("repeated pre-commit HTTP 5xx needs attention without starving other workspaces", async (t) => { + const { owner, worker, browser } = await setupWorkspaceRecovery(t); + const trigger = `worker_failure_${RUN_ID}`; + await pool.query(`CREATE FUNCTION ${trigger}() RETURNS trigger LANGUAGE plpgsql AS $$ + BEGIN IF NEW.owner_id = '${owner.id}' AND NEW.name = 'repeated-5xx' THEN RAISE EXCEPTION 'synthetic rollback'; END IF; RETURN NEW; END $$`); + await pool.query(`CREATE TRIGGER ${trigger} BEFORE INSERT ON pm_groups FOR EACH ROW EXECUTE FUNCTION ${trigger}()`); + t.after(async () => { + await pool.query(`DROP TRIGGER ${trigger} ON pm_groups`); + await pool.query(`DROP FUNCTION ${trigger}()`); + }); + await queueWorkspaceRecords(worker, "groups", "repeated-5xx"); + for (let attempt = 0; attempt < 3; attempt++) await worker.flushMutationQueue(); + const first = swQueue.getAllResult[0] as { state: string; attentionReason: string }; + assert.equal(first.state, "needs-attention"); + assert.equal(first.attentionReason, "repeated-5xx"); + assert.equal(browser.requests.filter((request) => request.method === "POST" && request.status === 500).length, 3); + assert.equal(browser.requests.filter((request) => request.path.includes("/b/") && request.status === 201).length, 1); + const before = browser.requests.length; + await worker.flushMutationQueue(); + assert.equal(browser.requests.slice(before).filter((request) => request.method === "POST").length, 0); + await recoverRecord("different-account", "discard"); + assert.equal(swQueue.getAllResult.length, 2, "another account cannot discard owned work"); + await recoverRecord(owner.id, "discard"); + assert.deepEqual(swQueue.getAllResult, []); +}); + +test("pre-commit HTTP failure retries the same worker key once and preserves workspace FIFO", async (t) => { + const { owner, worker, browser } = await setupWorkspaceRecovery(t); + const trigger = `worker_once_${RUN_ID}`; + await pool.query(`CREATE SEQUENCE ${trigger}`); + await pool.query(`CREATE FUNCTION ${trigger}() RETURNS trigger LANGUAGE plpgsql AS $$ + BEGIN IF NEW.user_id = '${owner.id}' AND nextval('${trigger}') = 1 THEN RAISE EXCEPTION 'synthetic one-time rollback'; END IF; RETURN NEW; END $$`); + await pool.query(`CREATE TRIGGER ${trigger} BEFORE INSERT ON pm_group_members FOR EACH ROW EXECUTE FUNCTION ${trigger}()`); + t.after(async () => { + await pool.query(`DROP TRIGGER ${trigger} ON pm_group_members`); + await pool.query(`DROP FUNCTION ${trigger}()`); + await pool.query(`DROP SEQUENCE ${trigger}`); + }); + await queueWorkspaceRecords(worker, "groups", "one-time-rollback"); + const key = (swQueue.getAllResult[0] as { idempotencyKey: string }).idempotencyKey; + await worker.flushMutationQueue(); + assert.deepEqual(swQueue.getAllResult.map((value) => (value as { id: number }).id), [1, 2]); + await worker.flushMutationQueue(); + await worker.flushMutationQueue(); + assert.deepEqual(swQueue.getAllResult, []); + assert.deepEqual(browser.requests.filter((request) => request.key === key).map((request) => request.status), [500, 201]); + const groups = await pool.query<{ name: string }>("SELECT name FROM pm_groups WHERE owner_id = $1 ORDER BY created_at", [owner.id]); + assert.deepEqual(groups.rows.map((row) => row.name), ["other-b", "one-time-rollback", "after-a"]); +}); + +test("account-level records cannot overtake unresolved workspace work or let later workspaces overtake them", async (t) => { + const { owner, worker, browser } = await setupWorkspaceRecovery(t); + for (const [id, path, name] of [[1, "/projects/a/mutate", "ambiguous"], [2, "/groups", "barrier"], [3, "/projects/b/mutate", "later-b"]] as const) { + await worker.queueMutation("POST", path, { name }); + swQueue.getAllResult.push({ ...(swQueue.lastAdded as object), id }); + } + await worker.flushMutationQueue(); + await worker.flushMutationQueue(); + assert.ok(browser.requests.filter((request) => request.method === "POST").every((request) => request.path === "/api/projects/a/mutate")); + const blocked = [...swQueue.postedMessages].reverse().find((message) => message.type === "MUTATIONS_BLOCKED")?.blocked as Array<{ id: number; reason: string }>; + assert.deepEqual(blocked.map(({ id, reason }) => ({ id, reason })), [ + { id: 1, reason: "outcome-unknown" }, { id: 2, reason: "workspace-order" }, { id: 3, reason: "workspace-order" }, + ]); + await recoverRecord(owner.id, "discard"); + // The probe intentionally has no account-level route: its 404 is a normal failure and keeps the barrier. + assert.ok(!browser.requests.some((request) => request.path.includes("/b/") && request.method === "POST")); +}); diff --git a/test/sw-queue.test.ts b/test/sw-queue.test.ts index 897182f1..37ccd5f2 100644 --- a/test/sw-queue.test.ts +++ b/test/sw-queue.test.ts @@ -298,7 +298,9 @@ test("sw queue: a stale mutation token is refreshed and replayed without data lo { input: "/api/items/stale", token: "migrated-token" }, ]); assert.equal(swQueue.deleteCallCount, 1, "the successfully replayed stale record is cleared once"); - assert.deepEqual(postedMessages, [{ type: "MUTATIONS_REPLAYED", count: 1 }]); + assert.deepEqual(postedMessages, [ + { type: "MUTATIONS_BLOCKED", blocked: [] }, { type: "MUTATIONS_REPLAYED", count: 1 }, + ]); }); test("sw queue: a record owned by another account is never replayed and is kept for its owner", async () => { From 0f320507c0f995d367c280a9f3cbef65cf000fa6 Mon Sep 17 00:00:00 2001 From: SteveBot <1153461+unbraind@users.noreply.github.com> Date: Sun, 4 Oct 2026 22:40:02 +0200 Subject: [PATCH 4/6] Run the workspace-recovery harness through the production CSRF and rate-limit middleware CodeQL (js/missing-token-validation, js/missing-rate-limiting) flagged the test-only Express app in test/sw-idempotency-http.test.ts. Instead of suppressing the alerts, the harness now mounts csrfProtection() and the write-tier limiter ahead of idempotencyGuard(), the same order as the hosted app, so the recovery flows are exercised through the real request path. pm item: pm-web-ccie --- .agents/pm/history/pm-web-ccie.jsonl | 1 + .agents/pm/issues/pm-web-ccie.toon | 5 +++-- test/sw-idempotency-http.test.ts | 6 ++++++ 3 files changed, 10 insertions(+), 2 deletions(-) diff --git a/.agents/pm/history/pm-web-ccie.jsonl b/.agents/pm/history/pm-web-ccie.jsonl index 1e412f14..54c52b69 100644 --- a/.agents/pm/history/pm-web-ccie.jsonl +++ b/.agents/pm/history/pm-web-ccie.jsonl @@ -24,3 +24,4 @@ {"hash_algorithm":"sha256","ts":"2026-10-04T20:22:59.067Z","author":"codex-sol","author_source":"asserted","agent_harness":"codex","agent_model":"gpt-6.1-sol","agent_model_source":"probe","agent_instance":"24e8fae678ef11598f66483d","agent_provenance":{"model":{"value":"gpt-6.1-sol","source":"probe"},"effort":{"value":"xhigh","source":"probe"},"role":null,"topic":null},"op":"comment_add","patch":[{"op":"add","path":"/metadata/comments/7","value":{"created_at":"2026-10-04T20:22:59.067Z","author":"codex-sol","text":"The first full release run exposed a test-environment mismatch: catalog checks treated sibling worktrees as the fleet. The documented PM_FLEET_ROOT override, set to ../../fleet, passes all 12 catalog checks against the actual fleet. Keep the catalog and committed snapshot unchanged; use this override for the full gate. Quota forecast remains within its weekly limit."}},{"op":"replace","path":"/metadata/updated_at","value":"2026-10-04T20:22:59.067Z"}],"before_hash":"3d286e8f646876152e51ddd0375f8505653027a9d76dc0560a6c356693149bb6","after_hash":"5f33bdba6b277b9a8e4c91d5a94bed2ccd3722e78199661343bd6848f338f3de","item_hash_version":3,"context":{"agent_provenance_outcomes":{"model":{"status":"resolved","resolver":"codex_session_file","rule_version":"v1"},"effort":{"status":"resolved","resolver":"codex_session_file","rule_version":"v1"},"version":{"status":"unavailable","reason":"harness_unavailable","resolver":"ai_agent_version","rule_version":"v1"}}},"event_class":"substantive","record_hash_version":1,"record_hash":"fcb454266083cdc102d56332ea824c76c1f4a3fc0955e1966098037908b08606"} {"hash_algorithm":"sha256","ts":"2026-10-04T20:28:39.111Z","author":"codex-sol","author_source":"asserted","agent_harness":"codex","agent_model":"gpt-6.1-sol","agent_model_source":"probe","agent_instance":"24e8fae678ef11598f66483d","agent_provenance":{"model":{"value":"gpt-6.1-sol","source":"probe"},"effort":{"value":"xhigh","source":"probe"},"role":null,"topic":null},"op":"comment_add","patch":[{"op":"add","path":"/metadata/comments/8","value":{"created_at":"2026-10-04T20:28:39.111Z","author":"codex-sol","text":"PR #170 round three: 4179117062 fixed with explicit pending/completed/outcome_unknown PostgreSQL states, machine-readable 409 codes, handler-proven pre-commit claim release, stale-intent settlement, and retention from settlement. Worker unknown outcomes and three consecutive 5xx failures persist needs-attention; owned workspace FIFO and account-level barriers preserve ordering while other workspaces flush. Recovery offers owner-verified, user-confirmed Retry as a new request with a fresh key, and Discard. 4179117055 fixed with an aria-labelled native Dismiss button and suppression until the blocked set changes. Red regressions were observed before fixes. Final tracker-linked npm run release:check passed with PM_FLEET_ROOT=../../fleet: 469 tests, 469 pass, zero failures/skips/cancellations; 33 source files reported; 85.02% lines, 81.47% branches, 78.58% functions; thresholds unchanged at 79/79/75. Typecheck/build/lint, zero duplication, docstrings, production audit, packed npm/Bun acceptance, changelog and release verification passed. Test coverage includes real pre-commit rollback with same-key success, ambiguous commit without automatic re-execution, approved new-key execution once, other-workspace progress, same-workspace FIFO/account-level barriers, discard, confirmation cancellation, and dismissal persistence. Open limits: approved new requests can duplicate an unknown original commit; deduplication expires after the configured retention window; whole-repository four-metric 100% coverage is not established. Leave the item in progress for orchestrator verification."}},{"op":"replace","path":"/metadata/updated_at","value":"2026-10-04T20:28:39.111Z"}],"before_hash":"5f33bdba6b277b9a8e4c91d5a94bed2ccd3722e78199661343bd6848f338f3de","after_hash":"53bfe44f8cfe195579a9061351f6c310994e3f4f15b484896ae733ac520452fa","item_hash_version":3,"context":{"agent_provenance_outcomes":{"model":{"status":"resolved","resolver":"codex_session_file","rule_version":"v1"},"effort":{"status":"resolved","resolver":"codex_session_file","rule_version":"v1"},"version":{"status":"unavailable","reason":"harness_unavailable","resolver":"ai_agent_version","rule_version":"v1"}}},"event_class":"substantive","record_hash_version":1,"record_hash":"2331845998b57af70200e30c380e6af0415dabeb36bbe169f455b101d570d391"} {"hash_algorithm":"sha256","ts":"2026-10-04T20:28:39.547Z","author":"codex-sol","author_source":"asserted","agent_harness":"codex","agent_model":"gpt-6.1-sol","agent_model_source":"probe","agent_instance":"24e8fae678ef11598f66483d","agent_provenance":{"model":{"value":"gpt-6.1-sol","source":"probe"},"effort":{"value":"xhigh","source":"probe"},"role":{"value":"implementer","source":"argv"},"topic":null},"op":"release","patch":[{"op":"remove","path":"/metadata/claim_principal"},{"op":"remove","path":"/metadata/assignee"},{"op":"replace","path":"/metadata/updated_at","value":"2026-10-04T20:28:39.547Z"}],"before_hash":"53bfe44f8cfe195579a9061351f6c310994e3f4f15b484896ae733ac520452fa","after_hash":"1a8dd3eca49a12bd72e782a91f4e8cf72770dfb647a6e946b20cfedc4a11ccd1","item_hash_version":3,"context":{"agent_provenance_outcomes":{"model":{"status":"resolved","resolver":"codex_session_file","rule_version":"v1"},"effort":{"status":"resolved","resolver":"codex_session_file","rule_version":"v1"},"version":{"status":"unavailable","reason":"harness_unavailable","resolver":"ai_agent_version","rule_version":"v1"}}},"event_class":"maintenance","record_hash_version":1,"record_hash":"66f00d93f3370530a83b314dc074748d5c05b86a8aa7acd8d92ece756e20cbc5"} +{"hash_algorithm":"sha256","ts":"2026-10-04T20:40:02.315Z","author":"claude-orchestrator","author_source":"asserted","agent_harness":"claude-code","agent_model":"claude-opus-5-5","agent_model_source":"probe","agent_instance":"4b273145923a68729ab394ab","agent_provenance":{"model":{"value":"claude-opus-5-5","source":"probe"},"effort":{"value":"high","source":"environment"},"role":null,"topic":null,"version":{"value":"2.1.289","source":"probe"}},"op":"comment_add","patch":[{"op":"add","path":"/metadata/comments/9","value":{"created_at":"2026-10-04T20:40:02.315Z","author":"claude-orchestrator","text":"CodeQL on PR #170 flagged the workspace-recovery test harness (cookie middleware without CSRF, handlers without rate limiting). The harness now mounts the production csrfProtection and write-tier limiter before the idempotency guard, matching the hosted middleware order; all 9 HTTP recovery tests pass through them."}},{"op":"replace","path":"/metadata/updated_at","value":"2026-10-04T20:40:02.315Z"}],"before_hash":"1a8dd3eca49a12bd72e782a91f4e8cf72770dfb647a6e946b20cfedc4a11ccd1","after_hash":"11d6d5236b730cf04c8d58b79278e6cf97af743d30153ad5e25cacfb8a83e436","item_hash_version":3,"context":{"agent_provenance_outcomes":{"model":{"status":"resolved","resolver":"claude_session_file","rule_version":"v1"},"version":{"status":"resolved","resolver":"claude_session_file","rule_version":"v1"}}},"event_class":"substantive","record_hash_version":1,"record_hash":"e28f655c4e5645152b0fc5dfa09912666008da38a3245b6d588c7e93242cc9ac"} diff --git a/.agents/pm/issues/pm-web-ccie.toon b/.agents/pm/issues/pm-web-ccie.toon index 80c5c2ae..b2299741 100644 --- a/.agents/pm/issues/pm-web-ccie.toon +++ b/.agents/pm/issues/pm-web-ccie.toon @@ -6,7 +6,7 @@ status: in_progress priority: 0 tags[4]: idempotency,multi-user,offline,security created_at: "2026-08-30T21:39:59.317Z" -updated_at: "2026-10-04T20:28:39.547Z" +updated_at: "2026-10-04T20:40:02.315Z" author: codex estimated_minutes: 240 acceptance_criteria: "Every queued mutation is durably bound to the authenticated user/session that created it; a different account never replays it; unknown legacy ownership is preserved and surfaced for explicit recovery without blocking unrelated owned records; every mutation carries a server-enforced idempotency key so retry after an ambiguous response cannot duplicate state; real browser/service-worker/PostgreSQL tests cover logout/login, account switching, lost responses, restart, queue ordering, and zero data loss." @@ -23,7 +23,7 @@ actual_result: Replay uses whichever session is current and has no deduplication affected_version: 2026.8.29 component: Offline mutation queue customer_impact: "A shared browser can attribute pending work to the wrong user, and a network ambiguity can duplicate mutations." -comments[9]{created_at,author,text}: +comments[10]{created_at,author,text}: "2026-10-04T18:15:59.802Z",claude-orchestrator,"PR #170: queued mutations are stamped with the broadcast account (persisted session store), target workspace and an idempotency key generated before the first network attempt; foreign, unknown-owner and logged-out records are never replayed or deleted, and surface MUTATIONS_BLOCKED with a visible explicit recovery action that adopts unknown-owner records for the current account (assigning a key first). Server: pm_idempotency_keys claims (account, key) atomically, replays stored outcomes, rejects a key reused for a different request (422), never stores transient 425/429 refusals, answers 409 outcome-unknown instead of re-executing a stale pending key, catches outcome-persist failures, indexes created_at and throttles the retention sweep. Implemented by the pi (GLM-5.3) and codex (gpt-6.1-sol) agents; review round 1 (12 Greptile + CodeQL findings) fixed test-first. Local release:check: 451/451 tests pass." "2026-10-04T18:29:41.302Z",codex-sol,"Read all seven PR #170 round-two findings. Plan: failing worker interleaving tests and real HTTP/PostgreSQL guard regressions, then bind request identity before sending, verify expected accounts on the server, serialize adoption and flush, exclude auth routes, and run retention before claims. Full release gates and explicit-path commit/push follow verification." "2026-10-04T18:39:28.448Z",codex-sol,"Test-first receipt: focused real-PostgreSQL/HTTP and worker suites reported 55 tests, 44 pass, 11 fail before production fixes. The failures reproduced all seven findings, including two-account first-attempt, replay, recovery and overlapping-adoption interleavings. Added expected-account refusal before key claims, captured owner before sending, serialized recovery and flush, transactional adoption preserving keys, auth exclusions, existing-user checks, and pre-claim retention." @@ -33,6 +33,7 @@ comments[9]{created_at,author,text}: "2026-10-04T20:14:53.076Z",codex-sol,"Red tests reproduced three server failures (missing in-flight/unknown codes and same-key retry stuck after real rollback), two worker recovery/order failures, and two SPA recovery/dismiss failures. Implementing durable pending/completed/outcome_unknown states with settlement-based retention. A confirmed pre-COMMIT rollback releases the key; a COMMIT attempt remains ambiguous even if ROLLBACK later succeeds. FIFO applies per owned workspace, with account-level writes acting as cross-workspace barriers. Attention records require confirmed fresh-key retry or discard." "2026-10-04T20:22:59.067Z",codex-sol,"The first full release run exposed a test-environment mismatch: catalog checks treated sibling worktrees as the fleet. The documented PM_FLEET_ROOT override, set to ../../fleet, passes all 12 catalog checks against the actual fleet. Keep the catalog and committed snapshot unchanged; use this override for the full gate. Quota forecast remains within its weekly limit." "2026-10-04T20:28:39.111Z",codex-sol,"PR #170 round three: 4179117062 fixed with explicit pending/completed/outcome_unknown PostgreSQL states, machine-readable 409 codes, handler-proven pre-commit claim release, stale-intent settlement, and retention from settlement. Worker unknown outcomes and three consecutive 5xx failures persist needs-attention; owned workspace FIFO and account-level barriers preserve ordering while other workspaces flush. Recovery offers owner-verified, user-confirmed Retry as a new request with a fresh key, and Discard. 4179117055 fixed with an aria-labelled native Dismiss button and suppression until the blocked set changes. Red regressions were observed before fixes. Final tracker-linked npm run release:check passed with PM_FLEET_ROOT=../../fleet: 469 tests, 469 pass, zero failures/skips/cancellations; 33 source files reported; 85.02% lines, 81.47% branches, 78.58% functions; thresholds unchanged at 79/79/75. Typecheck/build/lint, zero duplication, docstrings, production audit, packed npm/Bun acceptance, changelog and release verification passed. Test coverage includes real pre-commit rollback with same-key success, ambiguous commit without automatic re-execution, approved new-key execution once, other-workspace progress, same-workspace FIFO/account-level barriers, discard, confirmation cancellation, and dismissal persistence. Open limits: approved new requests can duplicate an unknown original commit; deduplication expires after the configured retention window; whole-repository four-metric 100% coverage is not established. Leave the item in progress for orchestrator verification." + "2026-10-04T20:40:02.315Z",claude-orchestrator,"CodeQL on PR #170 flagged the workspace-recovery test harness (cookie middleware without CSRF, handlers without rate limiting). The harness now mounts the production csrfProtection and write-tier limiter before the idempotency guard, matching the hosted middleware order; all 9 HTTP recovery tests pass through them." files[14]: - path: public/src/api.ts scope: project diff --git a/test/sw-idempotency-http.test.ts b/test/sw-idempotency-http.test.ts index c460a126..bcc1b5f3 100644 --- a/test/sw-idempotency-http.test.ts +++ b/test/sw-idempotency-http.test.ts @@ -11,6 +11,7 @@ import { createTierLimiters } from "../src/rate-limit.ts"; import { signToken } from "../src/auth.ts"; import { pool } from "../src/db.ts"; import { idempotencyGuard } from "../src/idempotency.ts"; +import { csrfProtection } from "../src/csrf.ts"; import { groupsRouter } from "../src/routes/groups.ts"; test("a real HTTP response lost after commit replays the queued write exactly once", async (t) => { @@ -232,6 +233,11 @@ async function setupWorkspaceRecovery(t: test.TestContext): Promise<{ const app = express(); app.use(express.json()); app.use(cookieParser()); + // The production middleware order: CSRF and the write-tier limiter run + // before the idempotency guard, so the recovery flows below are exercised + // through the same request path as the hosted app. + app.use(csrfProtection()); + app.use(createTierLimiters(process.env).write); app.use(idempotencyGuard()); app.get("/api/auth/me", (_req, res) => { res.setHeader("x-csrf-token", "pm-web-test-csrf"); From f50115d588e3b5a2d88926c3a3b8772ff2d0e0fa Mon Sep 17 00:00:00 2001 From: SteveBot <1153461+unbraind@users.noreply.github.com> Date: Sun, 4 Oct 2026 23:25:31 +0200 Subject: [PATCH 5/6] Clear resolved offline recovery notices and index pending sweeps --- .agents/pm/history/pm-web-ccie.jsonl | 7 ++++++ .agents/pm/issues/pm-web-ccie.toon | 9 +++++-- dist/db.js | 1 + dist/db.js.map | 2 +- public/src/api.ts | 13 +++++++---- public/src/sw.ts | 6 ++++- sql/schema.sql | 1 + src/db.ts | 1 + test/frontend-api.test.ts | 27 +++++++++++++++++++++ test/idempotency.test.ts | 35 +++++++++++++++++++++++++--- test/sw-idempotency-http.test.ts | 20 ++++++++++++++++ test/sw-queue.test.ts | 4 ++-- 12 files changed, 113 insertions(+), 13 deletions(-) diff --git a/.agents/pm/history/pm-web-ccie.jsonl b/.agents/pm/history/pm-web-ccie.jsonl index 54c52b69..4b83f23c 100644 --- a/.agents/pm/history/pm-web-ccie.jsonl +++ b/.agents/pm/history/pm-web-ccie.jsonl @@ -25,3 +25,10 @@ {"hash_algorithm":"sha256","ts":"2026-10-04T20:28:39.111Z","author":"codex-sol","author_source":"asserted","agent_harness":"codex","agent_model":"gpt-6.1-sol","agent_model_source":"probe","agent_instance":"24e8fae678ef11598f66483d","agent_provenance":{"model":{"value":"gpt-6.1-sol","source":"probe"},"effort":{"value":"xhigh","source":"probe"},"role":null,"topic":null},"op":"comment_add","patch":[{"op":"add","path":"/metadata/comments/8","value":{"created_at":"2026-10-04T20:28:39.111Z","author":"codex-sol","text":"PR #170 round three: 4179117062 fixed with explicit pending/completed/outcome_unknown PostgreSQL states, machine-readable 409 codes, handler-proven pre-commit claim release, stale-intent settlement, and retention from settlement. Worker unknown outcomes and three consecutive 5xx failures persist needs-attention; owned workspace FIFO and account-level barriers preserve ordering while other workspaces flush. Recovery offers owner-verified, user-confirmed Retry as a new request with a fresh key, and Discard. 4179117055 fixed with an aria-labelled native Dismiss button and suppression until the blocked set changes. Red regressions were observed before fixes. Final tracker-linked npm run release:check passed with PM_FLEET_ROOT=../../fleet: 469 tests, 469 pass, zero failures/skips/cancellations; 33 source files reported; 85.02% lines, 81.47% branches, 78.58% functions; thresholds unchanged at 79/79/75. Typecheck/build/lint, zero duplication, docstrings, production audit, packed npm/Bun acceptance, changelog and release verification passed. Test coverage includes real pre-commit rollback with same-key success, ambiguous commit without automatic re-execution, approved new-key execution once, other-workspace progress, same-workspace FIFO/account-level barriers, discard, confirmation cancellation, and dismissal persistence. Open limits: approved new requests can duplicate an unknown original commit; deduplication expires after the configured retention window; whole-repository four-metric 100% coverage is not established. Leave the item in progress for orchestrator verification."}},{"op":"replace","path":"/metadata/updated_at","value":"2026-10-04T20:28:39.111Z"}],"before_hash":"5f33bdba6b277b9a8e4c91d5a94bed2ccd3722e78199661343bd6848f338f3de","after_hash":"53bfe44f8cfe195579a9061351f6c310994e3f4f15b484896ae733ac520452fa","item_hash_version":3,"context":{"agent_provenance_outcomes":{"model":{"status":"resolved","resolver":"codex_session_file","rule_version":"v1"},"effort":{"status":"resolved","resolver":"codex_session_file","rule_version":"v1"},"version":{"status":"unavailable","reason":"harness_unavailable","resolver":"ai_agent_version","rule_version":"v1"}}},"event_class":"substantive","record_hash_version":1,"record_hash":"2331845998b57af70200e30c380e6af0415dabeb36bbe169f455b101d570d391"} {"hash_algorithm":"sha256","ts":"2026-10-04T20:28:39.547Z","author":"codex-sol","author_source":"asserted","agent_harness":"codex","agent_model":"gpt-6.1-sol","agent_model_source":"probe","agent_instance":"24e8fae678ef11598f66483d","agent_provenance":{"model":{"value":"gpt-6.1-sol","source":"probe"},"effort":{"value":"xhigh","source":"probe"},"role":{"value":"implementer","source":"argv"},"topic":null},"op":"release","patch":[{"op":"remove","path":"/metadata/claim_principal"},{"op":"remove","path":"/metadata/assignee"},{"op":"replace","path":"/metadata/updated_at","value":"2026-10-04T20:28:39.547Z"}],"before_hash":"53bfe44f8cfe195579a9061351f6c310994e3f4f15b484896ae733ac520452fa","after_hash":"1a8dd3eca49a12bd72e782a91f4e8cf72770dfb647a6e946b20cfedc4a11ccd1","item_hash_version":3,"context":{"agent_provenance_outcomes":{"model":{"status":"resolved","resolver":"codex_session_file","rule_version":"v1"},"effort":{"status":"resolved","resolver":"codex_session_file","rule_version":"v1"},"version":{"status":"unavailable","reason":"harness_unavailable","resolver":"ai_agent_version","rule_version":"v1"}}},"event_class":"maintenance","record_hash_version":1,"record_hash":"66f00d93f3370530a83b314dc074748d5c05b86a8aa7acd8d92ece756e20cbc5"} {"hash_algorithm":"sha256","ts":"2026-10-04T20:40:02.315Z","author":"claude-orchestrator","author_source":"asserted","agent_harness":"claude-code","agent_model":"claude-opus-5-5","agent_model_source":"probe","agent_instance":"4b273145923a68729ab394ab","agent_provenance":{"model":{"value":"claude-opus-5-5","source":"probe"},"effort":{"value":"high","source":"environment"},"role":null,"topic":null,"version":{"value":"2.1.289","source":"probe"}},"op":"comment_add","patch":[{"op":"add","path":"/metadata/comments/9","value":{"created_at":"2026-10-04T20:40:02.315Z","author":"claude-orchestrator","text":"CodeQL on PR #170 flagged the workspace-recovery test harness (cookie middleware without CSRF, handlers without rate limiting). The harness now mounts the production csrfProtection and write-tier limiter before the idempotency guard, matching the hosted middleware order; all 9 HTTP recovery tests pass through them."}},{"op":"replace","path":"/metadata/updated_at","value":"2026-10-04T20:40:02.315Z"}],"before_hash":"1a8dd3eca49a12bd72e782a91f4e8cf72770dfb647a6e946b20cfedc4a11ccd1","after_hash":"11d6d5236b730cf04c8d58b79278e6cf97af743d30153ad5e25cacfb8a83e436","item_hash_version":3,"context":{"agent_provenance_outcomes":{"model":{"status":"resolved","resolver":"claude_session_file","rule_version":"v1"},"version":{"status":"resolved","resolver":"claude_session_file","rule_version":"v1"}}},"event_class":"substantive","record_hash_version":1,"record_hash":"e28f655c4e5645152b0fc5dfa09912666008da38a3245b6d588c7e93242cc9ac"} +{"hash_algorithm":"sha256","ts":"2026-10-04T21:12:26.793Z","author":"codex-sol","author_source":"asserted","agent_harness":"codex","agent_model":"gpt-6.1-sol","agent_model_source":"probe","agent_instance":"037642d796dfdc2ae0a77461","agent_provenance":{"model":{"value":"gpt-6.1-sol","source":"probe"},"effort":{"value":"high","source":"probe"},"role":{"value":"implementer","source":"argv"},"topic":null},"op":"claim","patch":[{"op":"replace","path":"/metadata/updated_at","value":"2026-10-04T21:12:26.793Z"},{"op":"add","path":"/metadata/assignee","value":"codex-sol"},{"op":"add","path":"/metadata/claim_principal","value":"codex-sol"}],"before_hash":"11d6d5236b730cf04c8d58b79278e6cf97af743d30153ad5e25cacfb8a83e436","after_hash":"3612765d5c0c33fae33cf68dd7c45c4bb0bdc6060d314d3b74898b65e6124f77","item_hash_version":3,"context":{"agent_provenance_outcomes":{"model":{"status":"resolved","resolver":"codex_session_file","rule_version":"v1"},"effort":{"status":"resolved","resolver":"codex_session_file","rule_version":"v1"},"version":{"status":"unavailable","reason":"harness_unavailable","resolver":"ai_agent_version","rule_version":"v1"}}},"event_class":"substantive","record_hash_version":1,"record_hash":"08102d7c34d298d7ae0683d41e7ef6dc2c8a1dc0944618972dd2199149503982"} +{"hash_algorithm":"sha256","ts":"2026-10-04T21:12:27.646Z","author":"codex-sol","author_source":"asserted","agent_harness":"codex","agent_model":"gpt-6.1-sol","agent_model_source":"probe","agent_instance":"037642d796dfdc2ae0a77461","agent_provenance":{"model":{"value":"gpt-6.1-sol","source":"probe"},"effort":{"value":"high","source":"probe"},"role":null,"topic":null},"op":"comment_add","patch":[{"op":"add","path":"/metadata/comments/10","value":{"created_at":"2026-10-04T21:12:27.646Z","author":"codex-sol","text":"PR #170 review round 4: read findings 4179226598 and 4179226601 in full. Plan: reproduce stale recovery notices and missing pending-sweep index with failing tests; fix worker reporting and identical schema setup paths; preserve thresholds; verify release:check before commit and push. No thread replies or merge."}},{"op":"replace","path":"/metadata/updated_at","value":"2026-10-04T21:12:27.646Z"}],"before_hash":"3612765d5c0c33fae33cf68dd7c45c4bb0bdc6060d314d3b74898b65e6124f77","after_hash":"23740b536904320381babd7352a6cec14e2211f71812ee572429b8178df7d013","item_hash_version":3,"context":{"agent_provenance_outcomes":{"model":{"status":"resolved","resolver":"codex_session_file","rule_version":"v1"},"effort":{"status":"resolved","resolver":"codex_session_file","rule_version":"v1"},"version":{"status":"unavailable","reason":"harness_unavailable","resolver":"ai_agent_version","rule_version":"v1"}}},"event_class":"substantive","record_hash_version":1,"record_hash":"e6ee9807939dfb883a1be89a901d64a18e775fba447b7da407a6f555e331797f"} +{"hash_algorithm":"sha256","ts":"2026-10-04T21:15:10.409Z","author":"codex-sol","author_source":"asserted","agent_harness":"codex","agent_model":"gpt-6.1-sol","agent_model_source":"probe","agent_instance":"037642d796dfdc2ae0a77461","agent_provenance":{"model":{"value":"gpt-6.1-sol","source":"probe"},"effort":{"value":"high","source":"probe"},"role":null,"topic":null},"op":"comment_add","patch":[{"op":"add","path":"/metadata/comments/11","value":{"created_at":"2026-10-04T21:15:10.409Z","author":"codex-sol","text":"Test-first red evidence: targeted run executed five checks, all failed as expected. Last discard and empty flush emitted no MUTATIONS_BLOCKED; cleared notice callbacks still sent recovery actions; pending index was absent and EXPLAIN of the real sweep UPDATE used Seq Scan despite enable_seqscan=off. Schema parity will compare all partial age indexes and planner checks cover both UPDATE and DELETE."}},{"op":"replace","path":"/metadata/updated_at","value":"2026-10-04T21:15:10.409Z"}],"before_hash":"23740b536904320381babd7352a6cec14e2211f71812ee572429b8178df7d013","after_hash":"7ba52438d5e8f2b8e5e01048d6dd1600b73b1fdbf343bda834f6128f86e96058","item_hash_version":3,"context":{"agent_provenance_outcomes":{"model":{"status":"resolved","resolver":"codex_session_file","rule_version":"v1"},"effort":{"status":"resolved","resolver":"codex_session_file","rule_version":"v1"},"version":{"status":"unavailable","reason":"harness_unavailable","resolver":"ai_agent_version","rule_version":"v1"}}},"event_class":"substantive","record_hash_version":1,"record_hash":"aa24dd76432beeb12c1ab20934a6f8c488d8d7775e8cdd1fb5d818f8e26d6a78"} +{"hash_algorithm":"sha256","ts":"2026-10-04T21:16:05.171Z","author":"codex-sol","author_source":"asserted","agent_harness":"codex","agent_model":"gpt-6.1-sol","agent_model_source":"probe","agent_instance":"037642d796dfdc2ae0a77461","agent_provenance":{"model":{"value":"gpt-6.1-sol","source":"probe"},"effort":{"value":"high","source":"probe"},"role":null,"topic":null},"op":"comment_add","patch":[{"op":"add","path":"/metadata/comments/12","value":{"created_at":"2026-10-04T21:16:05.171Z","author":"codex-sol","text":"Green targeted evidence: five regression checks passed after the fixes. Empty flush and last discard now clear the blocked report; replaced or cleared notices cannot send stale adoption/recovery actions or dismiss their replacement. Both real sweep queries use their partial age indexes with enable_seqscan=off, and schema-parity checks cover every partial age index. Typecheck, test compilation, lint, zero-duplication and docstring gates passed. Full release:check is next; thresholds unchanged."}},{"op":"replace","path":"/metadata/updated_at","value":"2026-10-04T21:16:05.171Z"}],"before_hash":"7ba52438d5e8f2b8e5e01048d6dd1600b73b1fdbf343bda834f6128f86e96058","after_hash":"c4c8545e7778055c5765b89e1a730085f112561b7b9a968523e5c799e282ef9f","item_hash_version":3,"context":{"agent_provenance_outcomes":{"model":{"status":"resolved","resolver":"codex_session_file","rule_version":"v1"},"effort":{"status":"resolved","resolver":"codex_session_file","rule_version":"v1"},"version":{"status":"unavailable","reason":"harness_unavailable","resolver":"ai_agent_version","rule_version":"v1"}}},"event_class":"substantive","record_hash_version":1,"record_hash":"a2f75901b147df242a7557cee98045c910f6cba241e3ca3d851ccef8da7a537f"} +{"hash_algorithm":"sha256","ts":"2026-10-04T21:19:59.993Z","author":"codex-sol","author_source":"asserted","agent_harness":"codex","agent_model":"gpt-6.1-sol","agent_model_source":"probe","agent_instance":"037642d796dfdc2ae0a77461","agent_provenance":{"model":{"value":"gpt-6.1-sol","source":"probe"},"effort":{"value":"high","source":"probe"},"role":null,"topic":null},"op":"comment_add","patch":[{"op":"add","path":"/metadata/comments/13","value":{"created_at":"2026-10-04T21:19:59.993Z","author":"codex-sol","text":"First full release run failed only three catalog checks because the default fleet root resolves to sibling worktrees. This environment requirement was already documented on this item. Rerun the same unchanged gate with PM_FLEET_ROOT=../../fleet; do not change the catalog, committed snapshot, or thresholds."}},{"op":"replace","path":"/metadata/updated_at","value":"2026-10-04T21:19:59.993Z"}],"before_hash":"c4c8545e7778055c5765b89e1a730085f112561b7b9a968523e5c799e282ef9f","after_hash":"0e2b9b48c40fe4edbf0d80ce3338e31b6cc095e2cf06bad69594602709ab7ff9","item_hash_version":3,"context":{"agent_provenance_outcomes":{"model":{"status":"resolved","resolver":"codex_session_file","rule_version":"v1"},"effort":{"status":"resolved","resolver":"codex_session_file","rule_version":"v1"},"version":{"status":"unavailable","reason":"harness_unavailable","resolver":"ai_agent_version","rule_version":"v1"}}},"event_class":"substantive","record_hash_version":1,"record_hash":"33ede8db942d718e04dff07280ea2e323572fea40592b8ecfc608148e3ea4ff7"} +{"hash_algorithm":"sha256","ts":"2026-10-04T21:25:30.828Z","author":"codex-sol","author_source":"asserted","agent_harness":"codex","agent_model":"gpt-6.1-sol","agent_model_source":"probe","agent_instance":"037642d796dfdc2ae0a77461","agent_provenance":{"model":{"value":"gpt-6.1-sol","source":"probe"},"effort":{"value":"high","source":"probe"},"role":null,"topic":null},"op":"comment_add","patch":[{"op":"add","path":"/metadata/comments/14","value":{"created_at":"2026-10-04T21:25:30.828Z","author":"codex-sol","text":"PR #170 round 4 verified: 4179226598 fixed by posting an empty MUTATIONS_BLOCKED report on an empty flush and invalidating callbacks belonging to cleared/replaced notices. Real HTTP/PostgreSQL worker recovery tests cover last-record discard, empty reports, and stale actions without writes or record recreation; browser tests cover notice removal and stale retry/discard/adoption/dismissal. 4179226601 fixed by identical idx_pm_idempotency_pending_at partial created_at indexes WHERE status_code IS NULL in sql/schema.sql and initSchema. Expanded schema parity/catalog assertions and real-query EXPLAIN tests prove both pending UPDATE and settled DELETE use their partial age indexes with enable_seqscan=off. Targeted red: 5 fail; green: 5 pass. Final tracker-linked npm run release:check with PM_FLEET_ROOT=../../fleet: 472 tests, 472 pass, zero failures/cancellations/skips; coverage-gate: 33 source file(s) reported, thresholds met. Coverage 84.96% lines, 81.42% branches, 78.58% functions; thresholds unchanged at 79/79/75. duplication-gate: 0% duplicated lines (0/43642), 146 source(s), 0 clone pair(s), threshold 0%. docstring-gate: 94 file(s), 622 declaration(s) documented. Typecheck/build/lint, production audit (found 0 vulnerabilities), packed npm/Bun acceptance, changelog:check, release date and publish attestation passed. Changelog regeneration was unnecessary. Open limits: whole-repository 100/100/100/100 is not established; existing recovery/deduplication boundaries remain documented. Leave item in progress for orchestrator verification; no thread replies or merge."}},{"op":"replace","path":"/metadata/updated_at","value":"2026-10-04T21:25:30.828Z"}],"before_hash":"0e2b9b48c40fe4edbf0d80ce3338e31b6cc095e2cf06bad69594602709ab7ff9","after_hash":"abb029a3f2aa30fb1c76ac8b274f239fe23f3e7e9c929db6346e88dfd1775c10","item_hash_version":3,"context":{"agent_provenance_outcomes":{"model":{"status":"resolved","resolver":"codex_session_file","rule_version":"v1"},"effort":{"status":"resolved","resolver":"codex_session_file","rule_version":"v1"},"version":{"status":"unavailable","reason":"harness_unavailable","resolver":"ai_agent_version","rule_version":"v1"}}},"event_class":"substantive","record_hash_version":1,"record_hash":"f18043d72fc6ab7ad1b21ec67b3c589f6fc2708aecff4c9f724bbfc6b9033063"} +{"hash_algorithm":"sha256","ts":"2026-10-04T21:25:31.306Z","author":"codex-sol","author_source":"asserted","agent_harness":"codex","agent_model":"gpt-6.1-sol","agent_model_source":"probe","agent_instance":"037642d796dfdc2ae0a77461","agent_provenance":{"model":{"value":"gpt-6.1-sol","source":"probe"},"effort":{"value":"high","source":"probe"},"role":{"value":"implementer","source":"argv"},"topic":null},"op":"release","patch":[{"op":"remove","path":"/metadata/claim_principal"},{"op":"remove","path":"/metadata/assignee"},{"op":"replace","path":"/metadata/updated_at","value":"2026-10-04T21:25:31.306Z"}],"before_hash":"abb029a3f2aa30fb1c76ac8b274f239fe23f3e7e9c929db6346e88dfd1775c10","after_hash":"0d9b9c860b7ba061dcf3d4ae582ad1b131a010e95c4170864cd29eed752246bc","item_hash_version":3,"context":{"agent_provenance_outcomes":{"model":{"status":"resolved","resolver":"codex_session_file","rule_version":"v1"},"effort":{"status":"resolved","resolver":"codex_session_file","rule_version":"v1"},"version":{"status":"unavailable","reason":"harness_unavailable","resolver":"ai_agent_version","rule_version":"v1"}}},"event_class":"maintenance","record_hash_version":1,"record_hash":"39d16ec4099f2127cde0e6b1850a98451dd68ae0d49b40ef4fc5c55934a2442a"} diff --git a/.agents/pm/issues/pm-web-ccie.toon b/.agents/pm/issues/pm-web-ccie.toon index b2299741..5370ed2c 100644 --- a/.agents/pm/issues/pm-web-ccie.toon +++ b/.agents/pm/issues/pm-web-ccie.toon @@ -6,7 +6,7 @@ status: in_progress priority: 0 tags[4]: idempotency,multi-user,offline,security created_at: "2026-08-30T21:39:59.317Z" -updated_at: "2026-10-04T20:40:02.315Z" +updated_at: "2026-10-04T21:25:31.306Z" author: codex estimated_minutes: 240 acceptance_criteria: "Every queued mutation is durably bound to the authenticated user/session that created it; a different account never replays it; unknown legacy ownership is preserved and surfaced for explicit recovery without blocking unrelated owned records; every mutation carries a server-enforced idempotency key so retry after an ambiguous response cannot duplicate state; real browser/service-worker/PostgreSQL tests cover logout/login, account switching, lost responses, restart, queue ordering, and zero data loss." @@ -23,7 +23,7 @@ actual_result: Replay uses whichever session is current and has no deduplication affected_version: 2026.8.29 component: Offline mutation queue customer_impact: "A shared browser can attribute pending work to the wrong user, and a network ambiguity can duplicate mutations." -comments[10]{created_at,author,text}: +comments[15]{created_at,author,text}: "2026-10-04T18:15:59.802Z",claude-orchestrator,"PR #170: queued mutations are stamped with the broadcast account (persisted session store), target workspace and an idempotency key generated before the first network attempt; foreign, unknown-owner and logged-out records are never replayed or deleted, and surface MUTATIONS_BLOCKED with a visible explicit recovery action that adopts unknown-owner records for the current account (assigning a key first). Server: pm_idempotency_keys claims (account, key) atomically, replays stored outcomes, rejects a key reused for a different request (422), never stores transient 425/429 refusals, answers 409 outcome-unknown instead of re-executing a stale pending key, catches outcome-persist failures, indexes created_at and throttles the retention sweep. Implemented by the pi (GLM-5.3) and codex (gpt-6.1-sol) agents; review round 1 (12 Greptile + CodeQL findings) fixed test-first. Local release:check: 451/451 tests pass." "2026-10-04T18:29:41.302Z",codex-sol,"Read all seven PR #170 round-two findings. Plan: failing worker interleaving tests and real HTTP/PostgreSQL guard regressions, then bind request identity before sending, verify expected accounts on the server, serialize adoption and flush, exclude auth routes, and run retention before claims. Full release gates and explicit-path commit/push follow verification." "2026-10-04T18:39:28.448Z",codex-sol,"Test-first receipt: focused real-PostgreSQL/HTTP and worker suites reported 55 tests, 44 pass, 11 fail before production fixes. The failures reproduced all seven findings, including two-account first-attempt, replay, recovery and overlapping-adoption interleavings. Added expected-account refusal before key claims, captured owner before sending, serialized recovery and flush, transactional adoption preserving keys, auth exclusions, existing-user checks, and pre-claim retention." @@ -34,6 +34,11 @@ comments[10]{created_at,author,text}: "2026-10-04T20:22:59.067Z",codex-sol,"The first full release run exposed a test-environment mismatch: catalog checks treated sibling worktrees as the fleet. The documented PM_FLEET_ROOT override, set to ../../fleet, passes all 12 catalog checks against the actual fleet. Keep the catalog and committed snapshot unchanged; use this override for the full gate. Quota forecast remains within its weekly limit." "2026-10-04T20:28:39.111Z",codex-sol,"PR #170 round three: 4179117062 fixed with explicit pending/completed/outcome_unknown PostgreSQL states, machine-readable 409 codes, handler-proven pre-commit claim release, stale-intent settlement, and retention from settlement. Worker unknown outcomes and three consecutive 5xx failures persist needs-attention; owned workspace FIFO and account-level barriers preserve ordering while other workspaces flush. Recovery offers owner-verified, user-confirmed Retry as a new request with a fresh key, and Discard. 4179117055 fixed with an aria-labelled native Dismiss button and suppression until the blocked set changes. Red regressions were observed before fixes. Final tracker-linked npm run release:check passed with PM_FLEET_ROOT=../../fleet: 469 tests, 469 pass, zero failures/skips/cancellations; 33 source files reported; 85.02% lines, 81.47% branches, 78.58% functions; thresholds unchanged at 79/79/75. Typecheck/build/lint, zero duplication, docstrings, production audit, packed npm/Bun acceptance, changelog and release verification passed. Test coverage includes real pre-commit rollback with same-key success, ambiguous commit without automatic re-execution, approved new-key execution once, other-workspace progress, same-workspace FIFO/account-level barriers, discard, confirmation cancellation, and dismissal persistence. Open limits: approved new requests can duplicate an unknown original commit; deduplication expires after the configured retention window; whole-repository four-metric 100% coverage is not established. Leave the item in progress for orchestrator verification." "2026-10-04T20:40:02.315Z",claude-orchestrator,"CodeQL on PR #170 flagged the workspace-recovery test harness (cookie middleware without CSRF, handlers without rate limiting). The harness now mounts the production csrfProtection and write-tier limiter before the idempotency guard, matching the hosted middleware order; all 9 HTTP recovery tests pass through them." + "2026-10-04T21:12:27.646Z",codex-sol,"PR #170 review round 4: read findings 4179226598 and 4179226601 in full. Plan: reproduce stale recovery notices and missing pending-sweep index with failing tests; fix worker reporting and identical schema setup paths; preserve thresholds; verify release:check before commit and push. No thread replies or merge." + "2026-10-04T21:15:10.409Z",codex-sol,"Test-first red evidence: targeted run executed five checks, all failed as expected. Last discard and empty flush emitted no MUTATIONS_BLOCKED; cleared notice callbacks still sent recovery actions; pending index was absent and EXPLAIN of the real sweep UPDATE used Seq Scan despite enable_seqscan=off. Schema parity will compare all partial age indexes and planner checks cover both UPDATE and DELETE." + "2026-10-04T21:16:05.171Z",codex-sol,"Green targeted evidence: five regression checks passed after the fixes. Empty flush and last discard now clear the blocked report; replaced or cleared notices cannot send stale adoption/recovery actions or dismiss their replacement. Both real sweep queries use their partial age indexes with enable_seqscan=off, and schema-parity checks cover every partial age index. Typecheck, test compilation, lint, zero-duplication and docstring gates passed. Full release:check is next; thresholds unchanged." + "2026-10-04T21:19:59.993Z",codex-sol,"First full release run failed only three catalog checks because the default fleet root resolves to sibling worktrees. This environment requirement was already documented on this item. Rerun the same unchanged gate with PM_FLEET_ROOT=../../fleet; do not change the catalog, committed snapshot, or thresholds." + "2026-10-04T21:25:30.828Z",codex-sol,"PR #170 round 4 verified: 4179226598 fixed by posting an empty MUTATIONS_BLOCKED report on an empty flush and invalidating callbacks belonging to cleared/replaced notices. Real HTTP/PostgreSQL worker recovery tests cover last-record discard, empty reports, and stale actions without writes or record recreation; browser tests cover notice removal and stale retry/discard/adoption/dismissal. 4179226601 fixed by identical idx_pm_idempotency_pending_at partial created_at indexes WHERE status_code IS NULL in sql/schema.sql and initSchema. Expanded schema parity/catalog assertions and real-query EXPLAIN tests prove both pending UPDATE and settled DELETE use their partial age indexes with enable_seqscan=off. Targeted red: 5 fail; green: 5 pass. Final tracker-linked npm run release:check with PM_FLEET_ROOT=../../fleet: 472 tests, 472 pass, zero failures/cancellations/skips; coverage-gate: 33 source file(s) reported, thresholds met. Coverage 84.96% lines, 81.42% branches, 78.58% functions; thresholds unchanged at 79/79/75. duplication-gate: 0% duplicated lines (0/43642), 146 source(s), 0 clone pair(s), threshold 0%. docstring-gate: 94 file(s), 622 declaration(s) documented. Typecheck/build/lint, production audit (found 0 vulnerabilities), packed npm/Bun acceptance, changelog:check, release date and publish attestation passed. Changelog regeneration was unnecessary. Open limits: whole-repository 100/100/100/100 is not established; existing recovery/deduplication boundaries remain documented. Leave item in progress for orchestrator verification; no thread replies or merge." files[14]: - path: public/src/api.ts scope: project diff --git a/dist/db.js b/dist/db.js index 43635d2e..a630ed14 100644 --- a/dist/db.js +++ b/dist/db.js @@ -200,6 +200,7 @@ export async function initSchema() { await pool.query("UPDATE pm_idempotency_keys SET outcome_state = 'completed' WHERE outcome_state = 'pending' AND status_code IS NOT NULL"); await pool.query("CREATE INDEX IF NOT EXISTS idx_pm_idempotency_created_at ON pm_idempotency_keys (created_at) WHERE status_code IS NOT NULL"); await pool.query("CREATE INDEX IF NOT EXISTS idx_pm_idempotency_settled_at ON pm_idempotency_keys (updated_at) WHERE status_code IS NOT NULL"); + await pool.query("CREATE INDEX IF NOT EXISTS idx_pm_idempotency_pending_at ON pm_idempotency_keys (created_at) WHERE status_code IS NULL"); if (bootstrapAdminEmail) { await pool.query(`UPDATE pm_users SET is_admin = TRUE, updated_at = NOW() WHERE lower(email) = lower($1)`, [bootstrapAdminEmail]); } diff --git a/dist/db.js.map b/dist/db.js.map index 30d1bcac..3ca3433e 100644 --- a/dist/db.js.map +++ b/dist/db.js.map @@ -1 +1 @@ -{"version":3,"file":"db.js","sourceRoot":"","sources":["../src/db.ts"],"names":[],"mappings":"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"} \ No newline at end of file +{"version":3,"file":"db.js","sourceRoot":"","sources":["../src/db.ts"],"names":[],"mappings":"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"} \ No newline at end of file diff --git a/public/src/api.ts b/public/src/api.ts index c85b4eb6..b2bd5ca7 100644 --- a/public/src/api.ts +++ b/public/src/api.ts @@ -96,18 +96,23 @@ export function syncServiceWorkerSession(user: { id: string } | null): void { }).sort()); if (current.dismissedSet !== blockedSet) current.dismissedSet = null; if (data.blocked.length === 0 || current.dismissedSet === blockedSet) return; - current.recovery = showOfflineRecovery(data.blocked, current.userId, (ids, ownerId) => { - if (current.userId !== ownerId) return; + const recovery = showOfflineRecovery(data.blocked, current.userId, (ids, ownerId) => { + if (current.userId !== ownerId || current.recovery !== recovery) return; workers.controller?.postMessage({ type: 'REBIND_RECORDS', ids, ownerId }); }, { /** Carry explicit owner approval to the worker for one attention record. */ recover: (id, ownerId, action) => { - if (current.userId !== ownerId) return; + if (current.userId !== ownerId || current.recovery !== recovery) return; workers.controller?.postMessage({ type: 'RECOVER_RECORD', id, ownerId, action }); }, /** Suppress repeated reports for this exact set until queued work changes. */ - dismiss: () => { current.dismissedSet = blockedSet; current.recovery = null; }, + dismiss: () => { + if (current.recovery !== recovery) return; + current.dismissedSet = blockedSet; + current.recovery = null; + }, }); + current.recovery = recovery; }); } session.recovery?.remove(); diff --git a/public/src/sw.ts b/public/src/sw.ts index ca952ef2..f9a99550 100644 --- a/public/src/sw.ts +++ b/public/src/sw.ts @@ -445,7 +445,11 @@ async function replayQueuedMutations(): Promise { return; } const mutations = read.mutations; - if (mutations.length === 0) return; + if (mutations.length === 0) { + const clients = await sw.clients.matchAll(); + clients.forEach((client) => { client.postMessage({ type: 'MUTATIONS_BLOCKED', blocked: [] }); }); + return; + } let replayCsrfToken: string | null; let currentUserId: string | null = null; diff --git a/sql/schema.sql b/sql/schema.sql index b4563da9..95d30373 100644 --- a/sql/schema.sql +++ b/sql/schema.sql @@ -124,6 +124,7 @@ CREATE TABLE IF NOT EXISTS pm_idempotency_keys ( CREATE INDEX IF NOT EXISTS idx_pm_idempotency_created_at ON pm_idempotency_keys (created_at) WHERE status_code IS NOT NULL; CREATE INDEX IF NOT EXISTS idx_pm_idempotency_settled_at ON pm_idempotency_keys (updated_at) WHERE status_code IS NOT NULL; +CREATE INDEX IF NOT EXISTS idx_pm_idempotency_pending_at ON pm_idempotency_keys (created_at) WHERE status_code IS NULL; -- Bootstrap admin promotion is now applied at runtime via PM_WEB_BOOTSTRAP_ADMIN_EMAIL (see src/db.ts). diff --git a/src/db.ts b/src/db.ts index 3cbe3ffe..4f5ac624 100644 --- a/src/db.ts +++ b/src/db.ts @@ -224,6 +224,7 @@ export async function initSchema(): Promise { await pool.query("UPDATE pm_idempotency_keys SET outcome_state = 'completed' WHERE outcome_state = 'pending' AND status_code IS NOT NULL"); await pool.query("CREATE INDEX IF NOT EXISTS idx_pm_idempotency_created_at ON pm_idempotency_keys (created_at) WHERE status_code IS NOT NULL"); await pool.query("CREATE INDEX IF NOT EXISTS idx_pm_idempotency_settled_at ON pm_idempotency_keys (updated_at) WHERE status_code IS NOT NULL"); + await pool.query("CREATE INDEX IF NOT EXISTS idx_pm_idempotency_pending_at ON pm_idempotency_keys (created_at) WHERE status_code IS NULL"); if (bootstrapAdminEmail) { await pool.query( diff --git a/test/frontend-api.test.ts b/test/frontend-api.test.ts index feaf0d3c..d782e5e1 100644 --- a/test/frontend-api.test.ts +++ b/test/frontend-api.test.ts @@ -206,6 +206,33 @@ test("needs-attention recovery offers confirmed retry-as-new and discard only to assert.equal(page.messages.length, 4, "stale controls cannot approve another account's recovery"); }); +test("cleared or replaced recovery notices cannot act on removed records", (t) => { + const page = recoveryPage(t); + page.report([ + { id: 4, method: "POST", path: "/groups", ownerId: "account-a", reason: "outcome-unknown" }, + { id: 5, method: "POST", path: "/groups", reason: "unknown-owner" }, + ]); + const notice = page.nodes[0]; + const controls = notice.children.find((node) => node.tagName === "ul")?.children[0].children; + assert.equal(controls?.length, 2); + const adopt = notice.children.find((node) => /adopt/.test(node.textContent)); + const dismiss = notice.children.find((node) => node.textContent === "Dismiss"); + assert.ok(adopt); + assert.ok(dismiss); + page.confirm(true); + for (const blocked of [[], [{ id: 6, method: "POST", path: "/groups", reason: "owner-mismatch" }]]) { + page.report(blocked); + assert.equal(page.nodes.length, blocked.length === 0 ? 0 : 1, "the notice reflects remaining work"); + const messageCount = page.messages.length; + for (const control of controls ?? []) control.onclick?.(); + adopt.onclick?.(); + dismiss.onclick?.(); + assert.equal(page.messages.length, messageCount, "stale buttons cannot send recovery actions"); + page.report(blocked); + assert.equal(page.nodes.length, blocked.length === 0 ? 0 : 1, "stale dismissal cannot suppress a replacement notice"); + } +}); + test("the originating tab attaches its expected account before a worker reads shared session state", async () => { const originalNavigator = Object.getOwnPropertyDescriptor(globalThis, "navigator"); const globals = globalThis as unknown as Record; diff --git a/test/idempotency.test.ts b/test/idempotency.test.ts index 5f03dae6..a91d4347 100644 --- a/test/idempotency.test.ts +++ b/test/idempotency.test.ts @@ -561,13 +561,42 @@ test("an unknown token account bypasses keying and gets the route's normal outco assert.deepEqual(await forged.json(), { error: "Failed to create group" }, "the route receives the unknown account"); }); -test("SQL bootstrap and runtime idempotency table definitions agree and retention is indexed", async () => { +test("SQL bootstrap and runtime idempotency definitions agree and both sweep ages are indexed", async () => { + await ensureSchema(); const sql = readFileSync(new URL("../sql/schema.sql", import.meta.url), "utf8"); const runtime = readFileSync(new URL("../src/db.ts", import.meta.url), "utf8"); const table = /CREATE TABLE IF NOT EXISTS pm_idempotency_keys \([\s\S]*?\);/; assert.equal(sql.match(table)?.[0].replace(/\s+/g, " "), runtime.match(table)?.[0].replace(/\s+/g, " ")); - const indexes = await pool.query<{ indexdef: string }>("SELECT indexdef FROM pg_indexes WHERE tablename = 'pm_idempotency_keys'"); - assert.ok(indexes.rows.some((row) => row.indexdef.includes("(created_at)")), "retention has an index"); + const indexPattern = /CREATE INDEX IF NOT EXISTS idx_pm_idempotency_\w+ ON pm_idempotency_keys \(\w+\) WHERE status_code IS (?:NOT )?NULL/g; + assert.deepEqual(sql.match(indexPattern)?.sort(), runtime.match(indexPattern)?.sort(), "every partial sweep index is identical in both setup paths"); + const indexes = await pool.query<{ indexname: string; indexdef: string }>("SELECT indexname, indexdef FROM pg_indexes WHERE tablename = 'pm_idempotency_keys'"); + assert.match(indexes.rows.find((row) => row.indexname === "idx_pm_idempotency_pending_at")?.indexdef ?? "", /\(created_at\) WHERE \(status_code IS NULL\)/); + assert.match(indexes.rows.find((row) => row.indexname === "idx_pm_idempotency_settled_at")?.indexdef ?? "", /\(updated_at\) WHERE \(status_code IS NOT NULL\)/); +}); + +test("PostgreSQL can serve each real idempotency sweep query with its partial age index", async () => { + await ensureSchema(); + const source = readFileSync(new URL("../src/idempotency.ts", import.meta.url), "utf8"); + const sweep = source.match(/async function deleteExpiredKeys\(\): Promise \{([\s\S]*?)\n\}/)?.[1]; + assert.ok(sweep); + const queries = [...sweep.matchAll(/`([\s\S]*?)`/g)].map((match) => match[1]); + assert.equal(queries.length, 2, "check the actual pending UPDATE and settled DELETE"); + const client = await pool.connect(); + try { + await client.query("BEGIN"); + await client.query("SET LOCAL enable_seqscan = off"); + for (const [index, query] of queries.entries()) { + const result = await client.query<{ "QUERY PLAN": string }>(`EXPLAIN ${query}`, index === 0 ? [300_000, "{}"] : [604_800_000]); + const plan = result.rows.map((row) => row["QUERY PLAN"]).join("\n"); + const expected = index === 0 ? "idx_pm_idempotency_pending_at" : "idx_pm_idempotency_settled_at"; + assert.ok(plan.includes(expected), `${expected} must serve the sweep: ${plan}`); + assert.doesNotMatch(plan, /Seq Scan/); + assert.match(plan, /Index Cond:.*(?:created_at|updated_at) { diff --git a/test/sw-idempotency-http.test.ts b/test/sw-idempotency-http.test.ts index bcc1b5f3..ae734b44 100644 --- a/test/sw-idempotency-http.test.ts +++ b/test/sw-idempotency-http.test.ts @@ -309,6 +309,26 @@ test("ambiguous HTTP failure needs attention, isolates workspace ordering, and c assert.equal(ordered.rows[0]?.n, 1); }); +test("discarding the last attention record reports an empty recovery set and stale actions cannot recreate it", async (t) => { + const { owner, worker, browser } = await setupWorkspaceRecovery(t); + await worker.queueMutation("POST", "/projects/a/mutate", { name: "ambiguous" }); + swQueue.getAllResult.push({ ...(swQueue.lastAdded as object), id: 1 }); + await worker.flushMutationQueue(); + await worker.flushMutationQueue(); + assert.equal((swQueue.getAllResult[0] as { state: string }).state, "needs-attention"); + swQueue.postedMessages.length = 0; + await recoverRecord(owner.id, "discard"); + assert.deepEqual(swQueue.getAllResult, []); + assert.deepEqual(swQueue.postedMessages, [{ type: "MUTATIONS_BLOCKED", blocked: [] }], "the page is told to remove its recovery notice"); + const writes = browser.requests.filter((request) => request.method === "POST").length; + const deletes = swQueue.deleteCallCount; + await recoverRecord(owner.id, "retry-new"); + await recoverRecord(owner.id, "discard"); + assert.deepEqual(swQueue.getAllResult, [], "stale recovery does not recreate discarded records"); + assert.equal(swQueue.deleteCallCount, deletes); + assert.equal(browser.requests.filter((request) => request.method === "POST").length, writes); +}); + test("repeated pre-commit HTTP 5xx needs attention without starving other workspaces", async (t) => { const { owner, worker, browser } = await setupWorkspaceRecovery(t); const trigger = `worker_failure_${RUN_ID}`; diff --git a/test/sw-queue.test.ts b/test/sw-queue.test.ts index 37ccd5f2..c883f09b 100644 --- a/test/sw-queue.test.ts +++ b/test/sw-queue.test.ts @@ -145,7 +145,7 @@ test("sw queue: an empty queue is reported as ok:true with an empty array, not a assert.deepEqual(result.mutations, [], "empty queue must carry an empty mutations array"); }); -test("sw queue: an empty queue flush is a no-op — the two outcomes do not collapse", async () => { +test("sw queue: an empty queue flush clears blocked work without claiming a replay", async () => { swQueue.openShouldFail = false; swQueue.getAllResult = []; swQueue.getAllShouldFail = false; @@ -153,7 +153,7 @@ test("sw queue: an empty queue flush is a no-op — the two outcomes do not coll await internals.flushMutationQueue(); - assert.equal(postedMessages.length, 0, "empty queue flush must not post any messages"); + assert.deepEqual(postedMessages, [{ type: "MUTATIONS_BLOCKED", blocked: [] }]); }); test("sw queue: a queued mutation is bound to the broadcast account, its workspace and a fresh idempotency key", async () => { From d8ba1683750c452b51704b9f7267041e36f9f9d5 Mon Sep 17 00:00:00 2001 From: SteveBot <1153461+unbraind@users.noreply.github.com> Date: Sun, 4 Oct 2026 23:33:15 +0200 Subject: [PATCH 6/6] Close pm-web-ccie, pm-web-s552 and pm-web-25nv with review and gate evidence pm items: pm-web-ccie, pm-web-s552, pm-web-25nv --- .agents/pm/history/pm-web-25nv.jsonl | 3 +++ .agents/pm/history/pm-web-ccie.jsonl | 3 +++ .agents/pm/history/pm-web-s552.jsonl | 3 +++ .agents/pm/issues/pm-web-25nv.toon | 8 ++++++-- .agents/pm/issues/pm-web-ccie.toon | 8 ++++++-- .agents/pm/issues/pm-web-s552.toon | 8 ++++++-- CHANGELOG.md | 9 +++++++++ 7 files changed, 36 insertions(+), 6 deletions(-) diff --git a/.agents/pm/history/pm-web-25nv.jsonl b/.agents/pm/history/pm-web-25nv.jsonl index 9eb4e01f..55bc3658 100644 --- a/.agents/pm/history/pm-web-25nv.jsonl +++ b/.agents/pm/history/pm-web-25nv.jsonl @@ -9,3 +9,6 @@ {"hash_algorithm":"sha256","ts":"2026-10-04T18:15:59.275Z","author":"claude-orchestrator","author_source":"asserted","agent_harness":"claude-code","agent_model":"claude-opus-5-5","agent_model_source":"probe","agent_instance":"4b273145923a68729ab394ab","agent_provenance":{"model":{"value":"claude-opus-5-5","source":"probe"},"effort":{"value":"high","source":"environment"},"role":null,"topic":null,"version":{"value":"2.1.289","source":"probe"}},"op":"files_add","patch":[{"op":"replace","path":"/metadata/updated_at","value":"2026-10-04T18:15:59.275Z"},{"op":"add","path":"/metadata/files","value":[{"path":"src/rate-limit.ts","scope":"project"},{"path":"test/rate-limit.test.ts","scope":"project"}]}],"before_hash":"1ebfddaae843cca40e842ea418fd4db2dffb16b9fc98877bdd03603909263a90","after_hash":"084cd8d8a65fd9bd4e20e87aef2f18e342207b6507866726a8ee44cf816a6518","item_hash_version":3,"context":{"agent_provenance_outcomes":{"model":{"status":"resolved","resolver":"claude_session_file","rule_version":"v1"},"version":{"status":"resolved","resolver":"claude_session_file","rule_version":"v1"}}},"event_class":"maintenance","record_hash_version":1,"record_hash":"29746ad1c0a93bb2a9581c0bfd2b9e149ac0fbfecef9e19d486b5ad11344c34e"} {"hash_algorithm":"sha256","ts":"2026-10-04T18:16:01.098Z","author":"claude-orchestrator","author_source":"asserted","agent_harness":"claude-code","agent_model":"claude-opus-5-5","agent_model_source":"probe","agent_instance":"4b273145923a68729ab394ab","agent_provenance":{"model":{"value":"claude-opus-5-5","source":"probe"},"effort":{"value":"high","source":"environment"},"role":null,"topic":null,"version":{"value":"2.1.289","source":"probe"}},"op":"comment_add","patch":[{"op":"add","path":"/metadata/comments/1","value":{"created_at":"2026-10-04T18:16:01.098Z","author":"claude-orchestrator","text":"PR #170: four accounts fire 20 parallel POSTs with rotating forged X-Forwarded-For, X-Real-IP and Forwarded headers; exactly the 6-request budget is admitted with the default trust-nothing proxy setting; revert-checked against the one-hop default. Tests restore PM_WEB_TRUST_PROXY after themselves."}},{"op":"replace","path":"/metadata/updated_at","value":"2026-10-04T18:16:01.098Z"}],"before_hash":"084cd8d8a65fd9bd4e20e87aef2f18e342207b6507866726a8ee44cf816a6518","after_hash":"7c97e4589f9f50248e80b64c807892a13524cc3180b5a04e5a95c5d1c76c6703","item_hash_version":3,"context":{"agent_provenance_outcomes":{"model":{"status":"resolved","resolver":"claude_session_file","rule_version":"v1"},"version":{"status":"resolved","resolver":"claude_session_file","rule_version":"v1"}}},"event_class":"substantive","record_hash_version":1,"record_hash":"6d992f5a77917ffdaa6abde4e01318436e19037c4149ca7a2691f208c9560de9"} {"hash_algorithm":"sha256","ts":"2026-10-04T18:16:02.799Z","author":"claude-orchestrator","author_source":"asserted","agent_harness":"claude-code","agent_model":"claude-opus-5-5","agent_model_source":"probe","agent_instance":"4b273145923a68729ab394ab","agent_provenance":{"model":{"value":"claude-opus-5-5","source":"probe"},"effort":{"value":"high","source":"environment"},"role":{"value":"implementer","source":"argv"},"topic":null,"version":{"value":"2.1.289","source":"probe"}},"op":"release","patch":[{"op":"remove","path":"/metadata/claim_principal"},{"op":"remove","path":"/metadata/assignee"},{"op":"replace","path":"/metadata/updated_at","value":"2026-10-04T18:16:02.799Z"}],"before_hash":"7c97e4589f9f50248e80b64c807892a13524cc3180b5a04e5a95c5d1c76c6703","after_hash":"0b709030673b911dc42fbe99e9068ac40a48e4bbd20c2a6035e97ea7a8394fb1","item_hash_version":3,"context":{"agent_provenance_outcomes":{"model":{"status":"resolved","resolver":"claude_session_file","rule_version":"v1"},"version":{"status":"resolved","resolver":"claude_session_file","rule_version":"v1"}}},"event_class":"maintenance","record_hash_version":1,"record_hash":"8f440645226559c4792135fe5fa69fec0657d1a7c0793e306a9f9de578a91423"} +{"hash_algorithm":"sha256","ts":"2026-10-04T21:33:00.894Z","author":"claude-orchestrator","author_source":"asserted","agent_harness":"claude-code","agent_model":"claude-opus-5-5","agent_model_source":"probe","agent_instance":"4b273145923a68729ab394ab","agent_provenance":{"model":{"value":"claude-opus-5-5","source":"probe"},"effort":{"value":"high","source":"environment"},"role":{"value":"implementer","source":"argv"},"topic":null,"version":{"value":"2.1.289","source":"probe"}},"op":"update","patch":[{"op":"replace","path":"/metadata/updated_at","value":"2026-10-04T21:33:00.894Z"},{"op":"add","path":"/metadata/resolution","value":"completed"}],"before_hash":"0b709030673b911dc42fbe99e9068ac40a48e4bbd20c2a6035e97ea7a8394fb1","after_hash":"c89515565bd1ad0479244848d2cb9891ef7d8afe2f28296fdd6ee6747bc35251","item_hash_version":3,"context":{"agent_provenance_outcomes":{"model":{"status":"resolved","resolver":"claude_session_file","rule_version":"v1"},"version":{"status":"resolved","resolver":"claude_session_file","rule_version":"v1"}}},"event_class":"maintenance","record_hash_version":1,"record_hash":"24ff46b2f5666b0897ca2e87ddae4a47d35d497cacb82755f85fd2081348bbe9"} +{"hash_algorithm":"sha256","ts":"2026-10-04T21:33:02.402Z","author":"claude-orchestrator","author_source":"asserted","agent_harness":"claude-code","agent_model":"claude-opus-5-5","agent_model_source":"probe","agent_instance":"4b273145923a68729ab394ab","agent_provenance":{"model":{"value":"claude-opus-5-5","source":"probe"},"effort":{"value":"high","source":"environment"},"role":{"value":"implementer","source":"argv"},"topic":null,"version":{"value":"2.1.289","source":"probe"}},"op":"close","patch":[{"op":"replace","path":"/metadata/updated_at","value":"2026-10-04T21:33:02.402Z"},{"op":"replace","path":"/metadata/status","value":"closed"},{"op":"add","path":"/metadata/closed_at","value":"2026-10-04T21:33:02.386Z"},{"op":"add","path":"/metadata/completed_at","value":"2026-10-04T21:33:02.386Z"},{"op":"add","path":"/metadata/close_reason","value":"PR #170: rotating forged X-Forwarded-For / X-Real-IP / Forwarded headers across four accounts never buys a fresh bucket under the default trust-nothing proxy setting; revert-checked."}],"before_hash":"c89515565bd1ad0479244848d2cb9891ef7d8afe2f28296fdd6ee6747bc35251","after_hash":"87ad7b94defe8e6c8c959026ae4ff48b1fc321e9b1ba8df40c8d1219b402938b","item_hash_version":3,"context":{"agent_provenance_outcomes":{"model":{"status":"resolved","resolver":"claude_session_file","rule_version":"v1"},"version":{"status":"resolved","resolver":"claude_session_file","rule_version":"v1"}}},"event_class":"substantive","record_hash_version":1,"record_hash":"2768794ba67e92ba03a3a344c74d8e1158bd58e3bf46ba692cd6cd36362e7b63"} +{"hash_algorithm":"sha256","ts":"2026-10-04T21:33:08.698Z","author":"codex","author_source":"configured","agent_harness":"claude-code","agent_model":"claude-opus-5-5","agent_model_source":"probe","agent_instance":"4b273145923a68729ab394ab","agent_provenance":{"model":{"value":"claude-opus-5-5","source":"probe"},"effort":{"value":"high","source":"environment"},"role":{"value":"implementer","source":"argv"},"topic":null,"version":{"value":"2.1.289","source":"probe"}},"op":"release","patch":[{"op":"replace","path":"/metadata/updated_at","value":"2026-10-04T21:33:08.698Z"}],"before_hash":"87ad7b94defe8e6c8c959026ae4ff48b1fc321e9b1ba8df40c8d1219b402938b","after_hash":"b3754afeba7e60b366ba6b9a53ef3207b7467888777813625636049a8837c18a","item_hash_version":3,"context":{"agent_provenance_outcomes":{"model":{"status":"resolved","resolver":"claude_session_file","rule_version":"v1"},"version":{"status":"resolved","resolver":"claude_session_file","rule_version":"v1"}}},"event_class":"maintenance","record_hash_version":1,"record_hash":"a61db9ac9df975de915bf44393c29a2806ef9c99f19199777d27703c11957760"} diff --git a/.agents/pm/history/pm-web-ccie.jsonl b/.agents/pm/history/pm-web-ccie.jsonl index 4b83f23c..f996f9e1 100644 --- a/.agents/pm/history/pm-web-ccie.jsonl +++ b/.agents/pm/history/pm-web-ccie.jsonl @@ -32,3 +32,6 @@ {"hash_algorithm":"sha256","ts":"2026-10-04T21:19:59.993Z","author":"codex-sol","author_source":"asserted","agent_harness":"codex","agent_model":"gpt-6.1-sol","agent_model_source":"probe","agent_instance":"037642d796dfdc2ae0a77461","agent_provenance":{"model":{"value":"gpt-6.1-sol","source":"probe"},"effort":{"value":"high","source":"probe"},"role":null,"topic":null},"op":"comment_add","patch":[{"op":"add","path":"/metadata/comments/13","value":{"created_at":"2026-10-04T21:19:59.993Z","author":"codex-sol","text":"First full release run failed only three catalog checks because the default fleet root resolves to sibling worktrees. This environment requirement was already documented on this item. Rerun the same unchanged gate with PM_FLEET_ROOT=../../fleet; do not change the catalog, committed snapshot, or thresholds."}},{"op":"replace","path":"/metadata/updated_at","value":"2026-10-04T21:19:59.993Z"}],"before_hash":"c4c8545e7778055c5765b89e1a730085f112561b7b9a968523e5c799e282ef9f","after_hash":"0e2b9b48c40fe4edbf0d80ce3338e31b6cc095e2cf06bad69594602709ab7ff9","item_hash_version":3,"context":{"agent_provenance_outcomes":{"model":{"status":"resolved","resolver":"codex_session_file","rule_version":"v1"},"effort":{"status":"resolved","resolver":"codex_session_file","rule_version":"v1"},"version":{"status":"unavailable","reason":"harness_unavailable","resolver":"ai_agent_version","rule_version":"v1"}}},"event_class":"substantive","record_hash_version":1,"record_hash":"33ede8db942d718e04dff07280ea2e323572fea40592b8ecfc608148e3ea4ff7"} {"hash_algorithm":"sha256","ts":"2026-10-04T21:25:30.828Z","author":"codex-sol","author_source":"asserted","agent_harness":"codex","agent_model":"gpt-6.1-sol","agent_model_source":"probe","agent_instance":"037642d796dfdc2ae0a77461","agent_provenance":{"model":{"value":"gpt-6.1-sol","source":"probe"},"effort":{"value":"high","source":"probe"},"role":null,"topic":null},"op":"comment_add","patch":[{"op":"add","path":"/metadata/comments/14","value":{"created_at":"2026-10-04T21:25:30.828Z","author":"codex-sol","text":"PR #170 round 4 verified: 4179226598 fixed by posting an empty MUTATIONS_BLOCKED report on an empty flush and invalidating callbacks belonging to cleared/replaced notices. Real HTTP/PostgreSQL worker recovery tests cover last-record discard, empty reports, and stale actions without writes or record recreation; browser tests cover notice removal and stale retry/discard/adoption/dismissal. 4179226601 fixed by identical idx_pm_idempotency_pending_at partial created_at indexes WHERE status_code IS NULL in sql/schema.sql and initSchema. Expanded schema parity/catalog assertions and real-query EXPLAIN tests prove both pending UPDATE and settled DELETE use their partial age indexes with enable_seqscan=off. Targeted red: 5 fail; green: 5 pass. Final tracker-linked npm run release:check with PM_FLEET_ROOT=../../fleet: 472 tests, 472 pass, zero failures/cancellations/skips; coverage-gate: 33 source file(s) reported, thresholds met. Coverage 84.96% lines, 81.42% branches, 78.58% functions; thresholds unchanged at 79/79/75. duplication-gate: 0% duplicated lines (0/43642), 146 source(s), 0 clone pair(s), threshold 0%. docstring-gate: 94 file(s), 622 declaration(s) documented. Typecheck/build/lint, production audit (found 0 vulnerabilities), packed npm/Bun acceptance, changelog:check, release date and publish attestation passed. Changelog regeneration was unnecessary. Open limits: whole-repository 100/100/100/100 is not established; existing recovery/deduplication boundaries remain documented. Leave item in progress for orchestrator verification; no thread replies or merge."}},{"op":"replace","path":"/metadata/updated_at","value":"2026-10-04T21:25:30.828Z"}],"before_hash":"0e2b9b48c40fe4edbf0d80ce3338e31b6cc095e2cf06bad69594602709ab7ff9","after_hash":"abb029a3f2aa30fb1c76ac8b274f239fe23f3e7e9c929db6346e88dfd1775c10","item_hash_version":3,"context":{"agent_provenance_outcomes":{"model":{"status":"resolved","resolver":"codex_session_file","rule_version":"v1"},"effort":{"status":"resolved","resolver":"codex_session_file","rule_version":"v1"},"version":{"status":"unavailable","reason":"harness_unavailable","resolver":"ai_agent_version","rule_version":"v1"}}},"event_class":"substantive","record_hash_version":1,"record_hash":"f18043d72fc6ab7ad1b21ec67b3c589f6fc2708aecff4c9f724bbfc6b9033063"} {"hash_algorithm":"sha256","ts":"2026-10-04T21:25:31.306Z","author":"codex-sol","author_source":"asserted","agent_harness":"codex","agent_model":"gpt-6.1-sol","agent_model_source":"probe","agent_instance":"037642d796dfdc2ae0a77461","agent_provenance":{"model":{"value":"gpt-6.1-sol","source":"probe"},"effort":{"value":"high","source":"probe"},"role":{"value":"implementer","source":"argv"},"topic":null},"op":"release","patch":[{"op":"remove","path":"/metadata/claim_principal"},{"op":"remove","path":"/metadata/assignee"},{"op":"replace","path":"/metadata/updated_at","value":"2026-10-04T21:25:31.306Z"}],"before_hash":"abb029a3f2aa30fb1c76ac8b274f239fe23f3e7e9c929db6346e88dfd1775c10","after_hash":"0d9b9c860b7ba061dcf3d4ae582ad1b131a010e95c4170864cd29eed752246bc","item_hash_version":3,"context":{"agent_provenance_outcomes":{"model":{"status":"resolved","resolver":"codex_session_file","rule_version":"v1"},"effort":{"status":"resolved","resolver":"codex_session_file","rule_version":"v1"},"version":{"status":"unavailable","reason":"harness_unavailable","resolver":"ai_agent_version","rule_version":"v1"}}},"event_class":"maintenance","record_hash_version":1,"record_hash":"39d16ec4099f2127cde0e6b1850a98451dd68ae0d49b40ef4fc5c55934a2442a"} +{"hash_algorithm":"sha256","ts":"2026-10-04T21:32:59.948Z","author":"claude-orchestrator","author_source":"asserted","agent_harness":"claude-code","agent_model":"claude-opus-5-5","agent_model_source":"probe","agent_instance":"4b273145923a68729ab394ab","agent_provenance":{"model":{"value":"claude-opus-5-5","source":"probe"},"effort":{"value":"high","source":"environment"},"role":{"value":"implementer","source":"argv"},"topic":null,"version":{"value":"2.1.289","source":"probe"}},"op":"update","patch":[{"op":"replace","path":"/metadata/updated_at","value":"2026-10-04T21:32:59.948Z"},{"op":"add","path":"/metadata/resolution","value":"completed"}],"before_hash":"0d9b9c860b7ba061dcf3d4ae582ad1b131a010e95c4170864cd29eed752246bc","after_hash":"fb4ce06a284f7eda77ce191ee87052fc325fedd009d7d7f74fed836a04f3eb90","item_hash_version":3,"context":{"agent_provenance_outcomes":{"model":{"status":"resolved","resolver":"claude_session_file","rule_version":"v1"},"version":{"status":"resolved","resolver":"claude_session_file","rule_version":"v1"}}},"event_class":"maintenance","record_hash_version":1,"record_hash":"c69f1ba362475090a040d6aac98d1189001b3e2101960ad79f275399677ed0e6"} +{"hash_algorithm":"sha256","ts":"2026-10-04T21:33:01.423Z","author":"claude-orchestrator","author_source":"asserted","agent_harness":"claude-code","agent_model":"claude-opus-5-5","agent_model_source":"probe","agent_instance":"4b273145923a68729ab394ab","agent_provenance":{"model":{"value":"claude-opus-5-5","source":"probe"},"effort":{"value":"high","source":"environment"},"role":{"value":"implementer","source":"argv"},"topic":null,"version":{"value":"2.1.289","source":"probe"}},"op":"close","patch":[{"op":"replace","path":"/metadata/updated_at","value":"2026-10-04T21:33:01.423Z"},{"op":"replace","path":"/metadata/status","value":"closed"},{"op":"add","path":"/metadata/closed_at","value":"2026-10-04T21:33:01.401Z"},{"op":"add","path":"/metadata/completed_at","value":"2026-10-04T21:33:01.401Z"},{"op":"add","path":"/metadata/close_reason","value":"PR #170: offline mutations bound to account and workspace; server-enforced idempotency (atomic claim, stored replay, 422 on key reuse, transient refusals never stored, outcome_unknown terminal state instead of re-execution, partial-index sweeps); cross-tab account races closed (expected-account replay check, serialized adoption, auth routes never keyed); needs-attention recovery with confirmed retry-as-new, discard and accessible dismissal; per-workspace FIFO. 472 tests; Greptile 5/5; CodeRabbit findings fixed; CodeQL 0 open."}],"before_hash":"fb4ce06a284f7eda77ce191ee87052fc325fedd009d7d7f74fed836a04f3eb90","after_hash":"8cb11ac7e01d27ef481402fb14dd7d65fdce7ded81521ded2a95b34aa2df325b","item_hash_version":3,"context":{"agent_provenance_outcomes":{"model":{"status":"resolved","resolver":"claude_session_file","rule_version":"v1"},"version":{"status":"resolved","resolver":"claude_session_file","rule_version":"v1"}}},"event_class":"substantive","record_hash_version":1,"record_hash":"f4cf88db2268442ef4295930085de00bb5c434f78ccdd6150757f31857516736"} +{"hash_algorithm":"sha256","ts":"2026-10-04T21:33:07.636Z","author":"codex","author_source":"configured","agent_harness":"claude-code","agent_model":"claude-opus-5-5","agent_model_source":"probe","agent_instance":"4b273145923a68729ab394ab","agent_provenance":{"model":{"value":"claude-opus-5-5","source":"probe"},"effort":{"value":"high","source":"environment"},"role":{"value":"implementer","source":"argv"},"topic":null,"version":{"value":"2.1.289","source":"probe"}},"op":"release","patch":[{"op":"replace","path":"/metadata/updated_at","value":"2026-10-04T21:33:07.636Z"}],"before_hash":"8cb11ac7e01d27ef481402fb14dd7d65fdce7ded81521ded2a95b34aa2df325b","after_hash":"34d1fa1c349faa9748e1529f2dbf50e8097224eb4abce63eebb107c31d752769","item_hash_version":3,"context":{"agent_provenance_outcomes":{"model":{"status":"resolved","resolver":"claude_session_file","rule_version":"v1"},"version":{"status":"resolved","resolver":"claude_session_file","rule_version":"v1"}}},"event_class":"maintenance","record_hash_version":1,"record_hash":"2e9f999275d6aa8eb020e85cd4d227cee01f44689539c4dd1c87f0a6575c0bd0"} diff --git a/.agents/pm/history/pm-web-s552.jsonl b/.agents/pm/history/pm-web-s552.jsonl index 4de5219b..efde56a2 100644 --- a/.agents/pm/history/pm-web-s552.jsonl +++ b/.agents/pm/history/pm-web-s552.jsonl @@ -8,3 +8,6 @@ {"hash_algorithm":"sha256","ts":"2026-10-04T18:15:58.684Z","author":"claude-orchestrator","author_source":"asserted","agent_harness":"claude-code","agent_model":"claude-opus-5-5","agent_model_source":"probe","agent_instance":"4b273145923a68729ab394ab","agent_provenance":{"model":{"value":"claude-opus-5-5","source":"probe"},"effort":{"value":"high","source":"environment"},"role":null,"topic":null,"version":{"value":"2.1.289","source":"probe"}},"op":"files_add","patch":[{"op":"replace","path":"/metadata/updated_at","value":"2026-10-04T18:15:58.684Z"},{"op":"add","path":"/metadata/files","value":[{"path":"src/app.ts","scope":"project"},{"path":"src/rate-limit.ts","scope":"project"},{"path":"test/rate-limit.test.ts","scope":"project"}]}],"before_hash":"bf1c81b28ed7b7fb5fd4f4d337fbb63baaf22857151cfad580e700888859f72a","after_hash":"1fec16212dd70a41b704786dc5ceb310866f9d148b1c687b2f294f6e8746afee","item_hash_version":3,"context":{"agent_provenance_outcomes":{"model":{"status":"resolved","resolver":"claude_session_file","rule_version":"v1"},"version":{"status":"resolved","resolver":"claude_session_file","rule_version":"v1"}}},"event_class":"maintenance","record_hash_version":1,"record_hash":"e30d4500ed7945216f77e33b94e59572f50f98b7fd7dff81df4443f0e119708e"} {"hash_algorithm":"sha256","ts":"2026-10-04T18:16:00.482Z","author":"claude-orchestrator","author_source":"asserted","agent_harness":"claude-code","agent_model":"claude-opus-5-5","agent_model_source":"probe","agent_instance":"4b273145923a68729ab394ab","agent_provenance":{"model":{"value":"claude-opus-5-5","source":"probe"},"effort":{"value":"high","source":"environment"},"role":null,"topic":null,"version":{"value":"2.1.289","source":"probe"}},"op":"comment_add","patch":[{"op":"add","path":"/metadata/comments/1","value":{"created_at":"2026-10-04T18:16:00.482Z","author":"claude-orchestrator","text":"PR #170: proof against the real app that a nested project request is counted once (5-request write budget: 5 admitted, 6th refused), the auth budget holds exactly (20 admitted, 21st refused), and 20 concurrent POSTs from four accounts admit exactly the 12-request budget; revert-checked against the historical double mount. Rate limiting now runs before the idempotency guard so refusals never claim a key."}},{"op":"replace","path":"/metadata/updated_at","value":"2026-10-04T18:16:00.482Z"}],"before_hash":"1fec16212dd70a41b704786dc5ceb310866f9d148b1c687b2f294f6e8746afee","after_hash":"498fcf801de081170f347cf28a1fbd0c1b6a73a1866ea8d3399c0a4528837fe8","item_hash_version":3,"context":{"agent_provenance_outcomes":{"model":{"status":"resolved","resolver":"claude_session_file","rule_version":"v1"},"version":{"status":"resolved","resolver":"claude_session_file","rule_version":"v1"}}},"event_class":"substantive","record_hash_version":1,"record_hash":"2f4f7ef51771b2cf70829471457e341ff616b4541e74561279f4b32e7778242a"} {"hash_algorithm":"sha256","ts":"2026-10-04T18:16:02.186Z","author":"claude-orchestrator","author_source":"asserted","agent_harness":"claude-code","agent_model":"claude-opus-5-5","agent_model_source":"probe","agent_instance":"4b273145923a68729ab394ab","agent_provenance":{"model":{"value":"claude-opus-5-5","source":"probe"},"effort":{"value":"high","source":"environment"},"role":{"value":"implementer","source":"argv"},"topic":null,"version":{"value":"2.1.289","source":"probe"}},"op":"release","patch":[{"op":"remove","path":"/metadata/claim_principal"},{"op":"remove","path":"/metadata/assignee"},{"op":"replace","path":"/metadata/updated_at","value":"2026-10-04T18:16:02.186Z"}],"before_hash":"498fcf801de081170f347cf28a1fbd0c1b6a73a1866ea8d3399c0a4528837fe8","after_hash":"f364572f0e0e031f62d8592e2f01d07f8f01b3bca57d2bec2267491a0afbc52e","item_hash_version":3,"context":{"agent_provenance_outcomes":{"model":{"status":"resolved","resolver":"claude_session_file","rule_version":"v1"},"version":{"status":"resolved","resolver":"claude_session_file","rule_version":"v1"}}},"event_class":"maintenance","record_hash_version":1,"record_hash":"bed89703003c2056ca0b0c9e2614cc7aba053cc55ae3dd9b0e6070c6a4c17c30"} +{"hash_algorithm":"sha256","ts":"2026-10-04T21:33:00.427Z","author":"claude-orchestrator","author_source":"asserted","agent_harness":"claude-code","agent_model":"claude-opus-5-5","agent_model_source":"probe","agent_instance":"4b273145923a68729ab394ab","agent_provenance":{"model":{"value":"claude-opus-5-5","source":"probe"},"effort":{"value":"high","source":"environment"},"role":{"value":"implementer","source":"argv"},"topic":null,"version":{"value":"2.1.289","source":"probe"}},"op":"update","patch":[{"op":"replace","path":"/metadata/updated_at","value":"2026-10-04T21:33:00.427Z"},{"op":"add","path":"/metadata/resolution","value":"completed"}],"before_hash":"f364572f0e0e031f62d8592e2f01d07f8f01b3bca57d2bec2267491a0afbc52e","after_hash":"02fc640510689bf87e3ee01b43fc309b1cdd5285dcea9ee806503f3abb47f023","item_hash_version":3,"context":{"agent_provenance_outcomes":{"model":{"status":"resolved","resolver":"claude_session_file","rule_version":"v1"},"version":{"status":"resolved","resolver":"claude_session_file","rule_version":"v1"}}},"event_class":"maintenance","record_hash_version":1,"record_hash":"658583b8dfd8094c59b0d0ee2ea0213d1828e152b6d8136f2f65e06c3dfd1d40"} +{"hash_algorithm":"sha256","ts":"2026-10-04T21:33:01.942Z","author":"claude-orchestrator","author_source":"asserted","agent_harness":"claude-code","agent_model":"claude-opus-5-5","agent_model_source":"probe","agent_instance":"4b273145923a68729ab394ab","agent_provenance":{"model":{"value":"claude-opus-5-5","source":"probe"},"effort":{"value":"high","source":"environment"},"role":{"value":"implementer","source":"argv"},"topic":null,"version":{"value":"2.1.289","source":"probe"}},"op":"close","patch":[{"op":"replace","path":"/metadata/updated_at","value":"2026-10-04T21:33:01.942Z"},{"op":"replace","path":"/metadata/status","value":"closed"},{"op":"add","path":"/metadata/closed_at","value":"2026-10-04T21:33:01.925Z"},{"op":"add","path":"/metadata/completed_at","value":"2026-10-04T21:33:01.925Z"},{"op":"add","path":"/metadata/close_reason","value":"PR #170: one request counts once; published budgets hold exactly at the boundary and under 20 concurrent POSTs from four accounts; revert-checked; rate limiting runs before the idempotency guard."}],"before_hash":"02fc640510689bf87e3ee01b43fc309b1cdd5285dcea9ee806503f3abb47f023","after_hash":"13e3f26bb0cc5037f738e87d78103c6d222c473c2dd69be7322ce0697cebad1a","item_hash_version":3,"context":{"agent_provenance_outcomes":{"model":{"status":"resolved","resolver":"claude_session_file","rule_version":"v1"},"version":{"status":"resolved","resolver":"claude_session_file","rule_version":"v1"}}},"event_class":"substantive","record_hash_version":1,"record_hash":"6a63a6c1408f6224068b04627cf69becf51b9ee6b0d3a3fac3df6014baad1e41"} +{"hash_algorithm":"sha256","ts":"2026-10-04T21:33:08.266Z","author":"codex","author_source":"configured","agent_harness":"claude-code","agent_model":"claude-opus-5-5","agent_model_source":"probe","agent_instance":"4b273145923a68729ab394ab","agent_provenance":{"model":{"value":"claude-opus-5-5","source":"probe"},"effort":{"value":"high","source":"environment"},"role":{"value":"implementer","source":"argv"},"topic":null,"version":{"value":"2.1.289","source":"probe"}},"op":"release","patch":[{"op":"replace","path":"/metadata/updated_at","value":"2026-10-04T21:33:08.266Z"}],"before_hash":"13e3f26bb0cc5037f738e87d78103c6d222c473c2dd69be7322ce0697cebad1a","after_hash":"05c819c6c5d8b7e119badaa827d37cfcc5987a1ded5261d967a7bbdf2a1b87d1","item_hash_version":3,"context":{"agent_provenance_outcomes":{"model":{"status":"resolved","resolver":"claude_session_file","rule_version":"v1"},"version":{"status":"resolved","resolver":"claude_session_file","rule_version":"v1"}}},"event_class":"maintenance","record_hash_version":1,"record_hash":"0b6de7ec0ad8c95fe3abab39966d73dbb50c22b99b403425e2a1497ab69a64f9"} diff --git a/.agents/pm/issues/pm-web-25nv.toon b/.agents/pm/issues/pm-web-25nv.toon index cae2281a..a496d17f 100644 --- a/.agents/pm/issues/pm-web-25nv.toon +++ b/.agents/pm/issues/pm-web-25nv.toon @@ -2,12 +2,15 @@ id: pm-web-25nv title: "The per-IP rate limiter trusted a client-supplied forwarded address by default, so any caller could rotate a header to a fresh bucket" description: "resolveTrustProxy returned one trusted hop when PM_WEB_TRUST_PROXY was unset. Every launch path this package documents exposes Express directly with no proxy: a docker run publishing the port, and npm start. Under that default Express takes the client address from the caller's own X-Forwarded-For header, so an attacker rotating the header draws a new bucket for every request and every per-route limit added by this change enforces nothing, while still reporting itself as enforcing them. The default is now to trust nothing, which fails in the visible direction instead: a genuinely proxied deployment sees all clients share the proxy address and an operator notices immediately. The hosted deployment behind Caddy declares its single hop explicitly in the compose file, and the variable is documented." type: Issue -status: in_progress +status: closed priority: 1 tags[3]: "area:http","area:security","type:defect" created_at: "2026-08-28T14:54:28.347Z" -updated_at: "2026-10-04T18:16:02.799Z" +updated_at: "2026-10-04T21:33:08.698Z" +closed_at: "2026-10-04T21:33:02.386Z" +completed_at: "2026-10-04T21:33:02.386Z" author: claude +resolution: completed comments[2]{created_at,author,text}: "2026-08-28T14:55:13.653Z",claude,"Revert-checked: restoring the one-hop default drops the new test from passing to failing, so it measures the fix rather than describing it. The complementary test that opts into one hop and asserts the limiter follows the real client behind a proxy still passes, so the proxied behaviour is unchanged. Fifteen tests in the suite pass. Raised by Greptile as a P1 security finding on PR 110." "2026-10-04T18:16:01.098Z",claude-orchestrator,"PR #170: four accounts fire 20 parallel POSTs with rotating forged X-Forwarded-For, X-Real-IP and Forwarded headers; exactly the 6-request budget is admitted with the default trust-nothing proxy setting; revert-checked against the one-hop default. Tests restore PM_WEB_TRUST_PROXY after themselves." @@ -30,4 +33,5 @@ tests[2]: docs[2]{path,scope,note}: src/rate-limit.ts,project,resolveTrustProxy default test/rate-limit.test.ts,project,the header-rotation regression test +close_reason: "PR #170: rotating forged X-Forwarded-For / X-Real-IP / Forwarded headers across four accounts never buys a fresh bucket under the default trust-nothing proxy setting; revert-checked." body: "" diff --git a/.agents/pm/issues/pm-web-ccie.toon b/.agents/pm/issues/pm-web-ccie.toon index 5370ed2c..116a713a 100644 --- a/.agents/pm/issues/pm-web-ccie.toon +++ b/.agents/pm/issues/pm-web-ccie.toon @@ -2,11 +2,13 @@ id: pm-web-ccie title: Offline mutation queue is not bound to the originating account or idempotency key description: "Offline API mutations are stored with method, path, and body but no authenticated user identity or idempotency key. If a browser changes accounts before reconnecting, replay uses the current session; if a response is lost after the server commits, retry can repeat a non-idempotent mutation. The CSRF work in pm-web-413z deliberately repairs token freshness without pretending to solve account ownership or exactly-once delivery." type: Issue -status: in_progress +status: closed priority: 0 tags[4]: idempotency,multi-user,offline,security created_at: "2026-08-30T21:39:59.317Z" -updated_at: "2026-10-04T21:25:31.306Z" +updated_at: "2026-10-04T21:33:07.636Z" +closed_at: "2026-10-04T21:33:01.401Z" +completed_at: "2026-10-04T21:33:01.401Z" author: codex estimated_minutes: 240 acceptance_criteria: "Every queued mutation is durably bound to the authenticated user/session that created it; a different account never replays it; unknown legacy ownership is preserved and surfaced for explicit recovery without blocking unrelated owned records; every mutation carries a server-enforced idempotency key so retry after an ambiguous response cannot duplicate state; real browser/service-worker/PostgreSQL tests cover logout/login, account switching, lost responses, restart, queue ordering, and zero data loss." @@ -18,6 +20,7 @@ reporter: codex severity: high environment: pm-web 2026.8.29 offline service-worker queue repro_steps: "Queue a mutation while authenticated as user A, change the browser session to user B before reconnecting, then flush; the request is authenticated as B. Separately, let the server commit a non-idempotent mutation but drop the response; the retained record retries without an idempotency key." +resolution: completed expected_result: Queued work remains owned by its creator and ambiguous retries are exactly-once. actual_result: Replay uses whichever session is current and has no deduplication identity. affected_version: 2026.8.29 @@ -89,4 +92,5 @@ tests[2]: source_ref: fix/bind-offline-queue-and-rate-limit-identity docs[1]{path,scope}: README.md,project +close_reason: "PR #170: offline mutations bound to account and workspace; server-enforced idempotency (atomic claim, stored replay, 422 on key reuse, transient refusals never stored, outcome_unknown terminal state instead of re-execution, partial-index sweeps); cross-tab account races closed (expected-account replay check, serialized adoption, auth routes never keyed); needs-attention recovery with confirmed retry-as-new, discard and accessible dismissal; per-workspace FIFO. 472 tests; Greptile 5/5; CodeRabbit findings fixed; CodeQL 0 open." body: "" diff --git a/.agents/pm/issues/pm-web-s552.toon b/.agents/pm/issues/pm-web-s552.toon index 53a5a7bc..2d83a454 100644 --- a/.agents/pm/issues/pm-web-s552.toon +++ b/.agents/pm/issues/pm-web-s552.toon @@ -2,12 +2,15 @@ id: pm-web-s552 title: "Two limiters counted one request twice, halving every published budget on the affected routes" description: "A limiter counts every request that traverses it, so mounting the same limiter on a prefix and again on a nested path charges one request twice. The auth limiter was mounted on slash api slash auth once for the OIDC router and again for the auth router, so any request the OIDC router did not handle fell through and was counted a second time, reducing twenty requests per minute to ten. The read and write limiters were mounted on slash api slash projects and again on each nested mount beneath it, so every nested project request was counted twice and the six hundred and three hundred per minute budgets became three hundred and one hundred and fifty. Legitimate collaboration would meet a premature 429 at half the documented limit. Each shared prefix now carries its limiters exactly once and the routers mount behind them." type: Issue -status: in_progress +status: closed priority: 1 tags[2]: "area:http","type:defect" created_at: "2026-08-28T14:54:43.995Z" -updated_at: "2026-10-04T18:16:02.186Z" +updated_at: "2026-10-04T21:33:08.266Z" +closed_at: "2026-10-04T21:33:01.925Z" +completed_at: "2026-10-04T21:33:01.925Z" author: claude +resolution: completed comments[2]{created_at,author,text}: "2026-08-28T14:55:15.721Z",claude,"Verified against Express prefix matching rather than assumed: a request to slash api slash projects slash ID slash pm matches the slash api slash projects mount before reaching its own, so both traversals incremented the same store. Raised by Greptile as a P1 finding on PR 110." "2026-10-04T18:16:00.482Z",claude-orchestrator,"PR #170: proof against the real app that a nested project request is counted once (5-request write budget: 5 admitted, 6th refused), the auth budget holds exactly (20 admitted, 21st refused), and 20 concurrent POSTs from four accounts admit exactly the 12-request budget; revert-checked against the historical double mount. Rate limiting now runs before the idempotency guard so refusals never claim a key." @@ -30,4 +33,5 @@ tests[2]: source_ref: fix/bind-offline-queue-and-rate-limit-identity docs[1]{path,scope,note}: src/app.ts,project,router and limiter mounting +close_reason: "PR #170: one request counts once; published budgets hold exactly at the boundary and under 20 concurrent POSTs from four accounts; revert-checked; rate limiting runs before the idempotency guard." body: "" diff --git a/CHANGELOG.md b/CHANGELOG.md index 71a0a4f7..cc8eb7d1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,15 @@ ## Unreleased +### Fixed + +- Two limiters counted one request twice, halving every published budget on the affected routes ([pm-web-s552](https://github.com/unbraind/pm-web/blob/main/.agents/pm/issues/pm-web-s552.toon)) + +### Security + +- The per-IP rate limiter trusted a client-supplied forwarded address by default, so any caller could rotate a header to a fresh bucket ([pm-web-25nv](https://github.com/unbraind/pm-web/blob/main/.agents/pm/issues/pm-web-25nv.toon)) +- Offline mutation queue is not bound to the originating account or idempotency key ([pm-web-ccie](https://github.com/unbraind/pm-web/blob/main/.agents/pm/issues/pm-web-ccie.toon)) + ### Other - Auto-merge green Dependabot updates and group the pm toolchain into one daily PR ([pm-web-56no](https://github.com/unbraind/pm-web/blob/main/.agents/pm/tasks/pm-web-56no.toon))