fix(api): include canonical query parameters in POST idempotency fingerprint - #196
fix(api): include canonical query parameters in POST idempotency fingerprint#196Ganador1 wants to merge 6 commits into
Conversation
…erprint Closes theam#187 - Canonicalize and include request.query alongside request.body in requestHash calculation in beginIdempotentRequest. - Detect key reuse when query parameters change (such as POST /entries?dry=1 vs POST /entries) and reject with 409 idempotency_key_reused. - Preserve replay on identical or permuted query parameter orderings. - Update API documentation to state query parameter equivalence contract. - Add comprehensive unit and integration regression tests.
|
Thanks for linking #187 and covering the KB dry/live reproduction. I had deliberately held off on implementation while waiting for maintainer confirmation of the intended query-equivalence contract. |
- Accept matching legacy `sha256(stableJson(body))` hashes on replay to prevent `409 idempotency_key_reused` during rolling deployments or against 24-hour records created prior to deployment. - Add regression unit test verifying legacy record replays.
adrian-lorenzo
left a comment
There was a problem hiding this comment.
Thanks for the contribution!
The legacy fallback still allows the original bug during a rolling deployment. A legacy record stores only the body hash, so it contains no information about the query that created it. existing.requestHash === legacyRequestHash therefore accepts any query with the same body. I reproduced this by retrying a legacy record with the same body plus ?dry=1; it was replayed instead of returning 409.
Please don’t treat a body-only legacy hash as proof that the query matches. Legacy records are inherently ambiguous, so the transition should fail safely or store explicit fingerprint version/query information. Please also add a regression test for a legacy record receiving the same body with a changed query.
With that fixed, we can approve and merge it!
- Restrict legacy body-only match to strictly query-less requests (stableQuery === '{}').
- Reject retries against legacy records that introduce or change query parameters (such as ?dry=1) with 409 idempotency_key_reused.
- Add regression unit test for legacy records receiving changed queries.
|
Thanks for the thorough review and catch, @adrian-lorenzo! I've updated the implementation in commit 9e1c536 to fail safely:
|
adrian-lorenzo
left a comment
There was a problem hiding this comment.
Thanks for following up on the review!
The legacy ambiguity remains in the opposite direction. A legacy record may have been created by a request containing ?dry=1; retrying that key and body without a query makes stableQuery === "{}", so isLegacyQuerylessMatch accepts the record and replays the dry response instead of performing the live write. That is the original failure mode.
Please fail closed for every unversioned legacy fingerprint or add explicit fingerprint-version/query information. Add a regression that seeds a legacy dry-run response, sends the queryless live request with the same key and body, and verifies that it is rejected rather than replayed.
Once that case is covered, we can approve it.
- Reject replays against unversioned legacy records (sha256(stableJson(body))) because they are inherently ambiguous and cannot prove whether the original request contained query parameters. - Fail closed with 409 idempotency_key_reused on any request attempting to replay an unversioned legacy record, preventing dry-run responses from masking live writes. - Add regression unit test verifying that a legacy dry-run record receiving a queryless live request is rejected with 409 rather than replayed. - Add regression unit test verifying that any unversioned legacy record is rejected with 409.
|
Thanks for clarifying the reverse ambiguity case, @adrian-lorenzo! That makes total sense: because legacy records stored only I've updated the implementation in commit fdf259b to fail closed for all unversioned legacy fingerprints:
|
Closes #187
Why
As reported in #187, Facility scopes authenticated POST idempotency by principal, method, query-stripped path, key hash, and request body hash.
However, some POST endpoints use query parameters to alter operation semantics (for example,
POST /v1/projects/:id/kb/entries?dry=1performs validation-only without persistence, whilePOST /v1/projects/:id/kb/entriesperforms live persistence).Because
requestHashonly hashedrequest.body, a dry-run validation request followed by a live persistent request reusing the sameIdempotency-Keywas falsely treated as a replay. The API returned a successful 200 response withidempotency-status: replayed, but the intended write never occurred.What it does
requestHashassha256(${stableQuery}|${stableBody}), wherestableQueryis a deterministic, key-sorted JSON serialization ofrequest.query.Idempotency-Keywith different query parameters is detected as a mismatch and rejected with409 idempotency_key_reused, matching the existing body-conflict behavior.?a=1&b=2vs?b=2&a=1) produce identical canonical representations and replay correctly without false conflicts.sha256(stableJson(body))) fail closed with409 idempotency_key_reusedbecause legacy records are inherently ambiguous and cannot prove whether the original request contained query parameters (such as?dry=1).apps/docs/docs/reference/api.mdthat canonical query parameters participate in idempotency equivalence.services/api/test/idempotency.unit.test.ts: Unit test suite validating initial claims, permuted query replays, key reuse rejections with different query parameters, and fail-closed rejections on legacy dry-run vs live writes.services/api/test/api.test.ts: Integration tests covering query permutation replays, query difference rejections, and the exact KB dry vs persistent create reproduction from POST idempotency ignores query parameters that change request behavior #187.Verification
Commands run: