From 3606fba579f9cee473d012664c652b5b1d2f3576 Mon Sep 17 00:00:00 2001 From: Duy Nguyen Date: Tue, 20 Oct 2026 09:50:01 +0700 Subject: [PATCH 1/2] fix(web): accept the weak kit ETag in If-Match Production answers GET /api/v1/kits/@h/n with ETag W/"g": Cloudflare weakens the worker's strong ETag when it compresses the response. Clients that echo the header they received got 400 invalid_request, so the documented If-Match flow failed in production. Accept the W/ prefix; the generation still has to match exactly, and a stale one is still 412. Also document that the admin secret on a kit read is anonymous (401) and only a write that presents it gets 404, as production answers. Co-Authored-By: Claude Opus 5.5 --- apps/web/app/lib/personal-kits/kit-http.ts | 9 +++++++-- .../tests/personal-kits/kit-preconditions.test.ts | 14 +++++++++++++- docs/personal-kits-api.md | 12 +++++++----- 3 files changed, 27 insertions(+), 8 deletions(-) diff --git a/apps/web/app/lib/personal-kits/kit-http.ts b/apps/web/app/lib/personal-kits/kit-http.ts index 545b36c..cab22ec 100644 --- a/apps/web/app/lib/personal-kits/kit-http.ts +++ b/apps/web/app/lib/personal-kits/kit-http.ts @@ -86,8 +86,13 @@ export function requireGeneration(raw: unknown): number { return raw; } -/** `If-Match: "g"` (the kit's ETag), `""` or ``. */ -const IF_MATCH = /^(?:"g?(\d{1,15})"|(\d{1,15}))$/; +/** + * `If-Match: "g"` (the kit's ETag), `""` or ``. A `W/` prefix is + * accepted because Cloudflare weakens the strong ETag when it compresses the + * `GET`, and clients echo the header they received; the generation still has + * to match exactly. + */ +const IF_MATCH = /^(?:(?:W\/)?"g?(\d{1,15})"|(\d{1,15}))$/; /** * The expected generation of a write, from `If-Match` or the body's diff --git a/apps/web/tests/personal-kits/kit-preconditions.test.ts b/apps/web/tests/personal-kits/kit-preconditions.test.ts index ed083dc..6f1de66 100644 --- a/apps/web/tests/personal-kits/kit-preconditions.test.ts +++ b/apps/web/tests/personal-kits/kit-preconditions.test.ts @@ -29,6 +29,16 @@ describe("If-Match on kit writes", () => { expect(activate.status).toBe(200); }); + it("accepts the weak form of the ETag that a compressing proxy hands back, and still checks the generation", async () => { + const first = await store(k.doc([await k.entry("@alpha/demo", "1.0.0", MINE)]), {}, { "If-Match": 'W/"g0"' }); + expect(first.status).toBe(201); + const stale = await store(k.doc([await k.entry("@alpha/demo", "1.0.0")]), {}, { "If-Match": 'W/"g0"' }); + expect(stale.status).toBe(412); + expect(stale.body.error.code).toBe("kit.generation_conflict"); + const second = await store(k.doc([await k.entry("@alpha/demo", "1.0.0")]), {}, { "If-Match": 'W/"g1"' }); + expect(second.status).toBe(201); + }); + it("answers a stale If-Match with 412 and writes nothing, while a stale body field stays 409", async () => { await store(k.doc([await k.entry("@alpha/demo", "1.0.0", MINE)]), { expectedGeneration: 0 }); const before = k.count("personal_kit_revisions") + k.count("personal_kit_audit"); @@ -53,12 +63,14 @@ describe("If-Match on kit writes", () => { it("refuses a malformed If-Match or one that disagrees with the body, before any write", async () => { const document = k.doc([await k.entry("@alpha/demo", "1.0.0", MINE)]); - for (const value of ["*", 'W/"g0"', '"g0", "g1"', "-1", "g"]) { + for (const value of ["*", 'W/g0', 'w/"g0"', '"g0", "g1"', "-1", "g"]) { const res = await store(document, {}, { "If-Match": value }); expect(res.status, value).toBe(400); expect(res.body.error.code).toBe("invalid_request"); } const split = await store(document, { expectedGeneration: 1 }, { "If-Match": '"g0"' }); + const weakSplit = await store(document, { expectedGeneration: 1 }, { "If-Match": 'W/"g0"' }); + expect(weakSplit.status).toBe(400); expect(split.status).toBe(400); const agree = await store(document, { expectedGeneration: 0 }, { "If-Match": '"g0"' }); expect(agree.status).toBe(201); diff --git a/docs/personal-kits-api.md b/docs/personal-kits-api.md index a7272fc..ab017c6 100644 --- a/docs/personal-kits-api.md +++ b/docs/personal-kits-api.md @@ -20,8 +20,9 @@ without an explicit user approval. ## Authentication, ownership and limits - Callers authenticate with a session or an **unscoped** API key. Scoped keys, - such as `releases:publish:@h`, get 401. The admin secret is not accepted - (404). A write in a web session (no `Authorization` header) must come from + such as `releases:publish:@h`, get 401. The admin secret is not an + identity here: a write that presents it gets 404, and a read with only the + admin secret is anonymous (401). A write in a web session (no `Authorization` header) must come from the site's own origin; otherwise it gets 403 `cross_origin` before the session is read. - A kit is visible only to its owner. Every other caller gets the JSON 404, @@ -172,9 +173,10 @@ ancestor appears as `{kit, revisionDigest, foreign: true}`. Every kit has a `generation`. `GET` returns it, with an `ETag` of `"g"`. - Every write carries the expected generation, as `If-Match: "g"` (the - ETag; a bare `` is also accepted) or as `expectedGeneration` in the body - (kept for compatibility). Both may be sent when they agree; a disagreement, - a list, `*` or a weak tag is 400 `invalid_request`. A stale generation + ETag; a bare `` is also accepted, and so is the weak form `W/"g"` + that Cloudflare substitutes when it compresses the `GET`) or as + `expectedGeneration` in the body (kept for compatibility). Both may be sent + when they agree; a disagreement, a list or `*` is 400 `invalid_request`. A stale generation writes nothing and fails with 412 `kit.generation_conflict` when it came with `If-Match`, or 409 `kit.generation_conflict` when it came in the body. A first write (a kit that does not exist yet) expects generation 0. From 88c45a56cb20ef51a27e825ec0b18d73cc8c2506 Mon Sep 17 00:00:00 2001 From: Duy Nguyen Date: Tue, 20 Oct 2026 09:52:07 +0700 Subject: [PATCH 2/2] docs(web): qualify admin-secret kit statuses and the If-Match message Co-Authored-By: Claude Opus 5.5 --- apps/web/app/lib/personal-kits/kit-http.ts | 2 +- docs/personal-kits-api.md | 11 ++++++----- 2 files changed, 7 insertions(+), 6 deletions(-) diff --git a/apps/web/app/lib/personal-kits/kit-http.ts b/apps/web/app/lib/personal-kits/kit-http.ts index cab22ec..6af200b 100644 --- a/apps/web/app/lib/personal-kits/kit-http.ts +++ b/apps/web/app/lib/personal-kits/kit-http.ts @@ -104,7 +104,7 @@ export function withIfMatch(request: Request, body: Record): Re const raw = request.headers.get("If-Match"); if (raw === null) return body; const m = IF_MATCH.exec(raw.trim()); - if (m === null) throw badRequest('If-Match must be one kit generation, as its ETag "g" or '); + if (m === null) throw badRequest('If-Match must be one kit generation, as its ETag "g" (or W/"g") or '); const generation = Number(m[1] ?? m[2]); if (body.expectedGeneration !== undefined && body.expectedGeneration !== generation) throw badRequest("If-Match and expectedGeneration name different generations"); return { ...body, expectedGeneration: generation }; diff --git a/docs/personal-kits-api.md b/docs/personal-kits-api.md index ab017c6..0361660 100644 --- a/docs/personal-kits-api.md +++ b/docs/personal-kits-api.md @@ -21,10 +21,11 @@ without an explicit user approval. - Callers authenticate with a session or an **unscoped** API key. Scoped keys, such as `releases:publish:@h`, get 401. The admin secret is not an - identity here: a write that presents it gets 404, and a read with only the - admin secret is anonymous (401). A write in a web session (no `Authorization` header) must come from - the site's own origin; otherwise it gets 403 `cross_origin` before the - session is read. + identity here: a write that presents a valid one gets 404 (an invalid one + gets 401 `invalid_admin_secret`), and a read with only the admin secret is + anonymous (401). A write in a web session (no `Authorization` header) must + come from the site's own origin; otherwise it gets 403 `cross_origin` + before the session is read. - A kit is visible only to its owner. Every other caller gets the JSON 404, never 403, so a kit's existence is not revealed. - Every write (create, store, rebase, resolve, activate, reject) requires @@ -182,7 +183,7 @@ Every kit has a `generation`. `GET` returns it, with an `ETag` of `"g"`. A first write (a kit that does not exist yet) expects generation 0. `proposals/validate` takes `If-Match` too and reports `generationConflict` instead of failing. -- The kit row, the revision or The kit row, the revision or +- The kit row, the revision or conflict, and the audit row commit in one D1 batch under the generation guard. - Creating writes (`revisions`, `rebase`, `conflicts/:id/resolve`) also carry an