Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 8 additions & 3 deletions apps/web/app/lib/personal-kits/kit-http.ts
Original file line number Diff line number Diff line change
Expand Up @@ -86,8 +86,13 @@ export function requireGeneration(raw: unknown): number {
return raw;
}

/** `If-Match: "g<n>"` (the kit's ETag), `"<n>"` or `<n>`. */
const IF_MATCH = /^(?:"g?(\d{1,15})"|(\d{1,15}))$/;
/**
* `If-Match: "g<n>"` (the kit's ETag), `"<n>"` or `<n>`. 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
Expand All @@ -99,7 +104,7 @@ export function withIfMatch(request: Request, body: Record<string, unknown>): 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<n>" or <n>');
if (m === null) throw badRequest('If-Match must be one kit generation, as its ETag "g<n>" (or W/"g<n>") or <n>');
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 };
Expand Down
14 changes: 13 additions & 1 deletion apps/web/tests/personal-kits/kit-preconditions.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand All @@ -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);
Expand Down
19 changes: 11 additions & 8 deletions docs/personal-kits-api.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,10 +20,12 @@ 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
the site's own origin; otherwise it gets 403 `cross_origin` before the
session is read.
such as `releases:publish:@h`, get 401. The admin secret is not an
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
Expand Down Expand Up @@ -172,15 +174,16 @@ ancestor appears as `{kit, revisionDigest, foreign: true}`.
Every kit has a `generation`. `GET` returns it, with an `ETag` of `"g<n>"`.

- Every write carries the expected generation, as `If-Match: "g<n>"` (the
ETag; a bare `<n>` 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 `<n>` is also accepted, and so is the weak form `W/"g<n>"`
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.
`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
Expand Down
Loading