capsite/webapp: the cookie's store is the owner's, apart from the instance

Letting the instance id rotate the cookie let its holder (the cache, or
whoever learns the id) swap in another account's session, which the
owner's logout would not stop. Now mint makes two things: the instance,
and a header store (lib's MapStoreDo, shaped ("cookie", { keyShape:
"cookie", valueShape: M.string() })). The instance id is authority to
use the cookie; the store id, for the account owner, to rotate (PUT
/store/<storeId>/cookie, or WebAppFactory.store) or revoke (DELETE).
Instances read the store's read-only view at every send, via init's new
headerStore.

This replaces the in-instance cookie store and its migration: instances
minted earlier keep their cookie in init.headers, untouched, and simply
cannot rotate. The MapStoreDo class arrives as wrangler migration v2
(additive).

Generated-by: Claude Opus 5.5
This commit is contained in:
dckc committed 2026-10-09 02:32:22 -05:00
1 parent 357e4561c4
commit 3f711a5f4b
5 files changed
+219 -151

No files matched your search

+71 -48
View File
@@ -3,7 +3,10 @@ import { runInDurableObject } from "cloudflare:test";
import { afterEach, describe, expect, it, vi } from "vitest";
import worker, { type WebAppInit } from "../src/index.js";
type TestEnv = { WEBAPP_DO: DurableObjectNamespace<import("../src/index.js").WebAppDo> };
type TestEnv = {
WEBAPP_DO: DurableObjectNamespace<import("../src/index.js").WebAppDo>;
MAP_STORE_DO: DurableObjectNamespace<import("../src/index.js").MapStoreDo>;
};
const testEnv = env as unknown as TestEnv;
const URL_ = "https://diigo.example/api/";
@@ -22,79 +25,99 @@ afterEach(() => {
vi.restoreAllMocks();
});
const fresh = () => testEnv.WEBAPP_DO.get(testEnv.WEBAPP_DO.newUniqueId());
/** the worker's HTTP surface, as a JSON client */
const call = (path: string, method: string, body?: unknown) =>
worker.fetch(
new Request(`https://webapp.example${path}`, {
method,
body: body === undefined ? undefined : JSON.stringify(body),
headers: { "content-type": "application/json" },
}),
testEnv,
);
const mint = async (init: WebAppInit) => {
const { id, storeId } = (await (await call("/", "POST", init)).json()) as { id: string; storeId: string };
const instance = testEnv.WEBAPP_DO.get(testEnv.WEBAPP_DO.idFromString(id));
return { id, storeId, instance };
};
/** the failure of a call: its error status, or what it threw; "" if it succeeds */
async function errorOf(response: Promise<Response>): Promise<string> {
try {
const res = await response;
return res.ok ? "" : `${res.status} ${await res.text()}`;
} catch (err) {
return String(err);
}
}
/** `init` as stored */
const storedInit = (stub: DurableObjectStub) =>
runInDurableObject(stub, (_obj, state) => state.storage.kv.get<WebAppInit>("init"));
describe("cookie rotation", () => {
it("mint keeps the cookie out of init; sends carry it", async () => {
it("mint puts the cookie in the header store, not init; sends carry it", async () => {
const seen = spyCookies();
const stub = fresh();
await stub.configure({ url: URL_, headers: { Cookie: "session=a", accept: "application/json" } });
expect(await storedInit(stub)).toEqual({ url: URL_, headers: { accept: "application/json" } });
using app = await stub.handle();
const { storeId, instance } = await mint({
url: URL_,
headers: { Cookie: "session=a", accept: "application/json" },
});
expect(await storedInit(instance)).toEqual({
url: URL_,
headers: { accept: "application/json" },
headerStore: storeId,
});
using app = await instance.handle();
await app.get();
expect(seen).toEqual(["session=a"]);
});
it("a rotation reaches handles already out", async () => {
it("the store id rotates under handles already out, and revokes", async () => {
const seen = spyCookies();
const stub = fresh();
await stub.configure({ url: URL_, headers: { cookie: "session=a" } });
using app = await stub.handle();
const { storeId, instance } = await mint({ url: URL_, headers: { cookie: "session=a" } });
using app = await instance.handle();
await app.pathjoin("x").get();
using cookies = await stub.cookies();
await cookies.set("cookie", "session=b");
expect(await (await call(`/store/${storeId}/cookie`, "PUT", "session=b")).json()).toEqual({ ok: true });
await app.pathjoin("x").get();
expect(seen).toEqual(["session=a", "session=b"]);
expect(await (await call(`/store/${storeId}/cookie`, "DELETE")).json()).toEqual({ ok: true });
await app.get();
expect(seen).toEqual(["session=a", "session=b", null]);
});
it("an instance minted before rotation moves its cookie to the store on first use", async () => {
it("the instance id reaches no store: its holder cannot swap in another session", async () => {
const seen = spyCookies();
const stub = fresh();
// the shape configure() stored until the cookie store
await runInDurableObject(stub, (_obj, state) =>
state.storage.put("init", { url: URL_, headers: { cookie: "session=old" }, msPerCall: 0 }),
);
using app = await stub.handle();
const { id, instance } = await mint({ url: URL_, headers: { cookie: "session=alice" } });
expect(await errorOf(call(`/store/${id}/cookie`, "PUT", "session=mallory"))).not.toBe("");
expect((await call(`/at/${id}/cookie`, "POST", { cookie: "session=mallory" })).status).toBe(404);
using app = await instance.handle();
await app.get();
expect(await storedInit(stub)).toEqual({ url: URL_, headers: {}, msPerCall: 0 });
using cookies = await stub.cookies();
expect(await cookies.get("cookie")).toBe("session=old");
await cookies.set("cookie", "session=new");
using again = await stub.handle();
await again.get();
expect(seen).toEqual(["session=old", "session=new"]);
expect(seen).toEqual(["session=alice"]);
});
it("POST /at/<id>/cookie rotates, by the same id that drives", async () => {
it("the store's shapes admit only the cookie; errors carry no values", async () => {
const { storeId } = await mint({ url: URL_ });
const err = await errorOf(call(`/store/${storeId}/authorization`, "PUT", "Bearer s3kr1t"));
expect(err).toContain('cookie: key: must match \\"cookie\\"');
expect(err).not.toContain("s3kr1t");
expect(await errorOf(call(`/store/${storeId}/cookie`, "PUT", 42))).toContain("must match M.string()");
});
it("an instance minted before header stores keeps its cookie in init, untouched", async () => {
const seen = spyCookies();
const id = testEnv.WEBAPP_DO.newUniqueId();
const stub = testEnv.WEBAPP_DO.get(id);
await stub.configure({ url: URL_ });
const rotate = (cookie: string) =>
worker.fetch(
new Request(`https://webapp.example/at/${id}/cookie`, {
method: "POST",
body: JSON.stringify({ cookie }),
headers: { "content-type": "application/json" },
}),
testEnv,
);
expect(await (await rotate("session=first")).json()).toEqual({ ok: true });
expect(await (await rotate("session=second")).json()).toEqual({ ok: true });
using app = await stub.handle();
const instance = testEnv.WEBAPP_DO.get(testEnv.WEBAPP_DO.newUniqueId());
const old = { url: URL_, headers: { cookie: "session=old" }, msPerCall: 0 };
await runInDurableObject(instance, (_obj, state) => state.storage.put("init", old));
using app = await instance.handle();
await app.get();
expect(seen).toEqual(["session=second"]);
expect(seen).toEqual(["session=old"]);
expect(await storedInit(instance)).toEqual(old);
});
it("a fixed header contradicting the stored cookie fails at send, naming no values", async () => {
spyCookies();
const stub = fresh();
await stub.configure({ url: URL_, headers: { cookie: "session=a" } });
using app = await stub.handle();
const { instance } = await mint({ url: URL_, headers: { cookie: "session=a" } });
using app = await instance.handle();
const err = await app
.withHeader("cookie", "session=mine")
.get()