diff --git a/.agents/pm/history/pm-web-25nv.jsonl b/.agents/pm/history/pm-web-25nv.jsonl index abfc76fd..55bc3658 100644 --- a/.agents/pm/history/pm-web-25nv.jsonl +++ b/.agents/pm/history/pm-web-25nv.jsonl @@ -3,3 +3,12 @@ {"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"} +{"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 d17efdcb..f996f9e1 100644 --- a/.agents/pm/history/pm-web-ccie.jsonl +++ b/.agents/pm/history/pm-web-ccie.jsonl @@ -1,2 +1,37 @@ {"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"} +{"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"} +{"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"} +{"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"} +{"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 4514e956..efde56a2 100644 --- a/.agents/pm/history/pm-web-s552.jsonl +++ b/.agents/pm/history/pm-web-s552.jsonl @@ -2,3 +2,12 @@ {"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"} +{"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 f854a4c9..a496d17f 100644 --- a/.agents/pm/issues/pm-web-25nv.toon +++ b/.agents/pm/issues/pm-web-25nv.toon @@ -2,20 +2,36 @@ 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: closed 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-04T21:33:08.698Z" +closed_at: "2026-10-04T21:33:02.386Z" +completed_at: "2026-10-04T21:33:02.386Z" author: claude -comments[1]{created_at,author,text}: +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." -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 +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 f17890e7..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: open +status: closed 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-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,13 +20,77 @@ 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 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[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." + "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." + "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." + "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 + - path: public/src/offline-recovery.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/routes/groups.ts + scope: project + - 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[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 +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 0a1eb8e0..2d83a454 100644 --- a/.agents/pm/issues/pm-web-s552.toon +++ b/.agents/pm/issues/pm-web-s552.toon @@ -2,19 +2,36 @@ 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: closed 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-04T21:33:08.266Z" +closed_at: "2026-10-04T21:33:01.925Z" +completed_at: "2026-10-04T21:33:01.925Z" author: claude -comments[1]{created_at,author,text}: +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." -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 +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)) diff --git a/README.md b/README.md index 2bb9f8d5..fe9ef2d0 100644 --- a/README.md +++ b/README.md @@ -301,3 +301,66 @@ 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 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. 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. +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. + +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/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..a630ed14 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,34 @@ 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, + 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, + 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("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"); + 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 d6e5872a..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":"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":"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"} \ No newline at end of file diff --git a/dist/idempotency.d.ts b/dist/idempotency.d.ts new file mode 100644 index 00000000..292c28e9 --- /dev/null +++ b/dist/idempotency.d.ts @@ -0,0 +1,59 @@ +import type { RequestHandler, Response } 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; +/** 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. + * + * 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: 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 + * 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..5a6d06db --- /dev/null +++ b/dist/idempotency.js @@ -0,0 +1,361 @@ +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. 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. + * + * 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) + 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. + * + * @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, outcome_state + 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. 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 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, 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, 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, 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. + * + * 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); + /** Capture the first response serialization without recording request content. */ + 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); + }; + 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, 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); + }); +} +/** + * Build the middleware that makes keyed mutating requests at-most-once. + * + * 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 + * 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) => { + 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"); + const keyed = isUnsafeMethod(req.method) && !authRoute && header !== undefined; + if (!keyed && !checkExpected) { + next(); + 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 && !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); + 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) { + // 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; + } + if (isStalePending(record)) { + await persistOutcome(record.id, 500, null, null); + continue; + } + if (Date.now() >= deadline) { + 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)); }); + } + 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..11cad816 --- /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/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 eab17bdd..b2bd5ca7 100644 --- a/public/src/api.ts +++ b/public/src/api.ts @@ -1,3 +1,5 @@ +import { showOfflineRecovery } from './offline-recovery.js'; + // ═══════════════════════════════════════════════════════════════ // API CLIENT // ═══════════════════════════════════════════════════════════════ @@ -27,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', }; @@ -44,6 +51,76 @@ 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, dismissedSet: 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 = 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; + 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 || 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: () => { + if (current.recovery !== recovery) return; + current.dismissedSet = blockedSet; + current.recovery = null; + }, + }); + current.recovery = recovery; + }); + } + 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..00d21536 --- /dev/null +++ b/public/src/offline-recovery.ts @@ -0,0 +1,68 @@ +/** + * Show preserved offline work and offer adoption only for records without an + * 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'; + 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 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; 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) { + 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); + } + 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 dee14549..f9a99550 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,18 @@ 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; + /** 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 { @@ -120,6 +138,73 @@ 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' | 'auth-route' + | 'outcome-unknown' | 'repeated-5xx' | 'workspace-order'; + +/** 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 +218,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 +250,78 @@ 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). + * + * @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 = originatingAccount === undefined ? await readSession() : originatingAccount; const tx = db.transaction(MUTATION_STORE, 'readwrite'); const store = tx.objectStore(MUTATION_STORE); const record: StoredMutation = { @@ -179,6 +329,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,22 +393,66 @@ async function clearMutation(id: number): Promise { } } -/** 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 +/** + * 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 (/^\/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'; + if (mut.ownerId !== currentUserId) return 'owner-mismatch'; + return null; +} + +/** 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 - * than claiming all mutations were replayed. */ -async function flushMutationQueue(): Promise { + * 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. 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 { const read = await getQueuedMutations(); if (!read.ok) { console.warn('Failed to read mutation queue:', read.error); 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; try { const bootstrap = await fetch('/api/auth/me', { method: 'GET', @@ -267,19 +464,50 @@ 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[] = []; + const blockedWorkspaces = new Set(); let replayed = 0; for (const mut of mutations) { + // 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 + // 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, + reason, + }); + if (mut.ownerId === currentUserId && currentUserId !== null) blockedWorkspaces.add(workspace); + continue; + } try { const opts: RequestInit = { method: mut.method, headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': replayCsrfToken, + 'X-PM-Expected-Account': mut.ownerId!, + ...(mut.idempotencyKey ? { 'Idempotency-Key': mut.idempotencyKey } : {}), }, credentials: 'include', }; @@ -295,8 +523,27 @@ async function flushMutationQueue(): 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 @@ -307,6 +554,10 @@ async function flushMutationQueue(): Promise { // Notify clients about replayed mutations const remainingRead = await getQueuedMutations(); const clients = await sw.clients.matchAll(); + // 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 @@ -332,13 +583,90 @@ async function flushMutationQueue(): Promise { clients.forEach((client) => { client.postMessage({ type: 'MUTATIONS_PARTIAL', - replayed: mutations.length - remaining.length, + replayed, remaining: remaining.length, }); }); } } +/** 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. + * + * 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 - 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. + */ +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 }, + }); + 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); + const request = store.getAll(); + request.onsuccess = () => { + for (const mut of request.result as QueuedMutation[]) { + update(store, mut); + } + }; + await done; + return true; +} + // ── Install ── sw.addEventListener('install', (event: ExtendableEvent) => { event.waitUntil( @@ -365,32 +693,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()); event.respondWith( - fetch(event.request).catch(async () => { - // Network failed — queue the mutation for later. - let body: unknown = undefined; - try { - body = await event.request.clone().json(); - } catch { /* no body */ } - const queued = await queueMutation( - event.request.method, - url.pathname.replace('/api', ''), - body, - ); - 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; } @@ -489,9 +838,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; ownerId?: unknown; id?: unknown; action?: unknown } + | null; if (data && data.type === 'SKIP_WAITING') { sw.skipWaiting(); } @@ -500,7 +854,44 @@ 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; + 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'); + })); + } + 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'); + })); } }); @@ -533,5 +924,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..95d30373 100644 --- a/sql/schema.sql +++ b/sql/schema.sql @@ -101,6 +101,31 @@ 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, + 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, + 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; +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). -- 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..4f5ac624 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,36 @@ 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, + 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, + 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("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"); + 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)`, diff --git a/src/idempotency.ts b/src/idempotency.ts new file mode 100644 index 00000000..4e28f9b7 --- /dev/null +++ b/src/idempotency.ts @@ -0,0 +1,440 @@ +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. 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; + +/** + * 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; + /** 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. + * + * 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) + 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] as { id: string } | undefined; + 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. + * + * @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, outcome_state + 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. Stale pending records first + * become terminal unknown outcomes; retention starts at that settlement time. + */ +async function deleteExpiredKeys(): 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 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, 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 === 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, 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. + * + * 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); + /** 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); + }; + res.json = (body?: unknown): Response => { + capture(body); + return originalJson(body); + }; + res.send = (body?: unknown): Response => { + capture(body); + return originalSend(body); + }; + 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, + 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); + }); +} + +/** + * Build the middleware that makes keyed mutating requests at-most-once. + * + * 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 + * 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) => { + 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"); + const keyed = isUnsafeMethod(req.method) && !authRoute && header !== undefined; + if (!keyed && !checkExpected) { + next(); + 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 && !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); + 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) { + // 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; + } + if (isStalePending(record)) { + await persistOutcome(record.id, 500, null, null); + continue; + } + if (Date.now() >= deadline) { + 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)); }); + } + + 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/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 4857e5c5..d782e5e1 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,226 @@ 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 + && /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(); + 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"); + } finally { + globals.document = originalDocument; + if (originalNavigator) Object.defineProperty(globalThis, "navigator", originalNavigator); + else Reflect.deleteProperty(globalThis, "navigator"); + } +}); + +/** 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("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; + 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..debcf699 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"; @@ -40,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. */ @@ -238,6 +249,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 29c429ae..090a2b6d 100644 --- a/test/helpers/sw-queue-preload.ts +++ b/test/helpers/sw-queue-preload.ts @@ -4,16 +4,37 @@ * 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>, + /** Store operations grouped by transaction, proving adoption reads and writes atomically. */ + transactions: [] as Array<{ store: string; mode?: IDBTransactionMode; operations: string[] }>, }; /** @@ -35,35 +56,94 @@ 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), + getAll: () => mockRequest(structuredClone(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, 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: () => store, + oncomplete: null, + onabort: null, + onerror: null, + } as unknown as IDBTransaction; + setTimeout(() => (tx.oncomplete as (() => void) | null)?.(), 0); + return tx; } const mockDB: IDBDatabase = { - transaction: () => { - scheduleCommit(); - return mockTransaction; - }, + transaction: (storeName: string, mode?: IDBTransactionMode) => openMockTransaction(storeName, mode), objectStoreNames: { contains: () => true } as unknown as DOMStringList, close: () => {}, } as unknown as IDBDatabase; @@ -81,7 +161,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 +191,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 only after verifying the page's approving account. */ + rebindRecords?: (ids: number[] | undefined, ownerId: string) => Promise; } /** diff --git a/test/idempotency.test.ts b/test/idempotency.test.ts new file mode 100644 index 00000000..a91d4347 --- /dev/null +++ b/test/idempotency.test.ts @@ -0,0 +1,730 @@ +/** + * 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, + configureIdempotencyWait, + authedFetch, + ensureSchema, + seedUser, + seedPasswordUser, + 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 }), + }); +} + +/** 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"); + 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); + 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`); + 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/groups/${firstGroup.group.id}`, { + method: "PATCH", + headers: { "content-type": "application/json", "idempotency-key": key }, + body: JSON.stringify({ name: "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"), "true", "stale ambiguity becomes a stored terminal outcome"); + 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 response.json() as { code: string }).code, "PM_IDEMPOTENCY_IN_FLIGHT"); + 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"); + + 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) => { + 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'), updated_at = NOW() - interval '30 days' + 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"); + 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) => { + const { server } = await setupOwnerAppTest(t); + // 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: { + "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); + assert.deepEqual(await forged.json(), { error: "Failed to create group" }, "the route receives the unknown account"); +}); + +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 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) { + 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"); +}); + +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/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..ae734b44 --- /dev/null +++ b/test/sw-idempotency-http.test.ts @@ -0,0 +1,398 @@ +/** 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 cookieParser from "cookie-parser"; +import { swQueue, swQueueInternals } from "./helpers/sw-queue-preload.ts"; +import "../public/src/sw.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 { 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) => { + 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.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 + 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"); +}); + +/** 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); +}); + +/** 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()); + // 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"); + 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("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}`; + 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 dc1ce226..c883f09b 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,11 +29,50 @@ 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( fetchImpl: typeof globalThis.fetch, getAllImpl?: () => IDBRequest, + operation: () => Promise = internals.flushMutationQueue, ): Promise { const originalFetch = globalThis.fetch; const originalGetAll = mockStore.getAll; @@ -36,13 +81,26 @@ 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; } } +/** 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 () => { @@ -87,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; @@ -95,21 +153,106 @@ 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: 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", "/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"); +}); + +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 +263,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 +283,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 }); }, @@ -153,12 +298,219 @@ 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 () => { + 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 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, + [{ + 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 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; + 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 +532,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 +546,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 +571,105 @@ 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 }); +}); + +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")); +});