WIP: webapp: rotate the diigo cookie in place #1

Draft
dckc wants to merge 8 commits from dckc/webapp-cookie-rotation AGit into main
pull from: dckc/webapp-cookie-rotation
5 changed files with 219 additions and 151 deletions
Showing only changes of commit 3f711a5f4b - Show all commits

No files matched your search

+4 -3
View File
@@ -110,9 +110,10 @@ narrative.
- [ ] **6. Cookie expiry**: when diigo's session ends, head refreshes - [ ] **6. Cookie expiry**: when diigo's session ends, head refreshes
fail; the public page keeps serving what's stored (say so on the fail; the public page keeps serving what's stored (say so on the
page, don't fail silently). Rotation is in place, keeping the page, don't fail silently). Rotation is in place, keeping the
webapp id (so the cache's wiring and ids hold): `POST webapp id (so the cache's wiring and ids hold), by the store id
/at/<id>/cookie` with the new cookie (webapp README, "The cookie minted alongside it and kept by the account owner: `PUT
store"). Still open: a rotate form in diigo-cache's UI. /store/<storeId>/cookie` (webapp README, "The header store").
Still open: a rotate form in diigo-cache's UI.
- [ ] **7. bib sync #195**: repeat the pattern for the next - [ ] **7. bib sync #195**: repeat the pattern for the next
read-access need (webapp mints for any origin; the cache's read-access need (webapp mints for any origin; the cache's
item shape is diigo-specific): [the zotero goal tree](zotero-checklist.md). item shape is diigo-specific): [the zotero goal tree](zotero-checklist.md).
+26 -12
View File
@@ -12,31 +12,45 @@ grows past a form or two.
- `GET /` — the mint form. Prefill via query string: `?url=…&headers=<urlencoded JSON>&msPerCall=…&title=…`; secret header keys (cookie, authorization, proxy-authorization) render **blank** — paste them at mint time; never echoed back. A dedicated `cookie` field is sugar for the `cookie` header (if the JSON also has one, they must agree). - `GET /` — the mint form. Prefill via query string: `?url=…&headers=<urlencoded JSON>&msPerCall=…&title=…`; secret header keys (cookie, authorization, proxy-authorization) render **blank** — paste them at mint time; never echoed back. A dedicated `cookie` field is sugar for the `cookie` header (if the JSON also has one, they must agree).
- `GET /form?render=1&…` — the same form for a proxying worker (diigo-cache's `/_connect`): the action is left as an `ACTION` placeholder for the proxy to point at its own relay. - `GET /form?render=1&…` — the same form for a proxying worker (diigo-cache's `/_connect`): the action is left as an `ACTION` placeholder for the proxy to point at its own relay.
- `POST /` — mint. Form-encoded (that page) or JSON `{url, headers, msPerCall}` (service-style). Responds `{id}` (and `x-webapp-id`). The id is *not* logged: possession is authority. - `POST /` — mint. Form-encoded (that page) or JSON `{url, headers, msPerCall}` (service-style). Responds `{id, storeId}` (and `x-webapp-id`, `x-webapp-store-id`). Neither id is logged: possession is authority.
- `POST /at/<id>` — drive an instance by capability: JSON `{path?, query?, method?, body?}` → the fluent chain → `{status, text}` (or `{error}`; messages carry no header/param values). - `POST /at/<id>` — drive an instance by capability: JSON `{path?, query?, method?, body?}` → the fluent chain → `{status, text}` (or `{error}`; messages carry no header/param values).
- `POST /at/<id>/cookie` — rotate the instance's cookie in place: JSON `{cookie}` → `{ok: true}`. The id that drives an instance can also replace its cookie; the account owner revokes by logging out. - `PUT /store/<storeId>/<key>` — set a header-store entry (JSON value body), e.g. `PUT /store/<storeId>/cookie` with `"session=…"` rotates the cookie; `DELETE` removes it. → `{ok: true}` (or `{error}`; messages carry no keys or values).
## RPC surface (same-account workers; binding `entrypoint: "WebAppFactory"`) ## RPC surface (same-account workers; binding `entrypoint: "WebAppFactory"`)
- `fetch(request)` — the HTTP surface above, over the binding - `fetch(request)` — the HTTP surface above, over the binding
- `newWebApp({url, headers?, msPerCall?})` → `{id, app}` — the fluent stub; - `newWebApp({url, headers?, msPerCall?})` → `{id, storeId, app}` — the fluent stub;
promise pipelining makes a whole `pathjoin().query().get()` chain a promise pipelining makes a whole `pathjoin().query().get()` chain a
single round trip single round trip
- `at(id)` — the fluent stub for a previously-minted instance - `at(id)` — the fluent stub for a previously-minted instance
- `cookies(id)` — the instance's cookie store (a MapStore from - `store(storeId)` — a header store, with full authority:
[the capsite lib](../lib/src/store.ts) with key shape `"cookie"`): `set("cookie", …)` rotates the cookie under every handle already out
`set("cookie", …)` rotates it under every handle already out
## The cookie store ## The header store: two ids, two authorities
An instance is fixed at creation except for its cookie, which lives in a Mint makes two things: the instance, and a header store (a `MapStoreDo`
store rather than in `init`; every send reads it. Instances minted before from [the capsite lib](../lib/src/store-do.ts), shaped
the store held their cookie in `init.headers`; the first `handle()` moves `("cookie", { keyShape: "cookie", valueShape: M.string() })`). A `cookie`
it over in one transaction. header goes into the store, not the instance's `init`; the instance reads
the store's read-only view at every send.
- the **instance id** is authority to *use* the cookie: it goes to the
cache's wiring.
- the **store id** is authority to *choose* it: it stays with the account
owner, who can rotate it (log in again, `PUT`) or revoke it (`DELETE`,
or log out).
Kept apart because whoever can choose the cookie can swap in another
account's session: if the cache's id could rotate, a leaked instance id
would let Mallory fill the cache (and its public page) with her own
bookmarks, and the owner's logout would not stop it.
Instances minted before header stores keep their cookie in
`init.headers`, unchanged; they work as before and cannot rotate.
## Run ## Run
pnpm test # cookie store + rotation (vitest, workerd pool) pnpm test # header store + rotation (vitest, workerd pool)
npx wrangler dev # → its own ready line (usually :8787 with the npx wrangler dev # → its own ready line (usually :8787 with the
# two-config diigo-cache run; standalone, :8788) # two-config diigo-cache run; standalone, :8788)
+110 -86
View File
@@ -8,7 +8,9 @@
// in worker-configuration.d.ts (not exports of "cloudflare:workers") // in worker-configuration.d.ts (not exports of "cloudflare:workers")
import { RpcTarget, DurableObject, WorkerEntrypoint } from "cloudflare:workers"; import { RpcTarget, DurableObject, WorkerEntrypoint } from "cloudflare:workers";
import { M } from "../../lib/src/patterns.js"; import { M } from "../../lib/src/patterns.js";
import { makeMapStore, type MapStore } from "../../lib/src/store.js"; import { encodeShapes, MapStoreDo } from "../../lib/src/store-do.js";
export { MapStoreDo };
export type Query = Record<string, string | number>; export type Query = Record<string, string | number>;
type Headers = Record<string, string>; type Headers = Record<string, string>;
@@ -18,16 +20,18 @@ type Headers = Record<string, string>;
export type HeaderMap = { entries(): [string, unknown][] | Promise<[string, unknown][]> }; export type HeaderMap = { entries(): [string, unknown][] | Promise<[string, unknown][]> };
/** /**
* init for a new instance — fixed at creation, except that a `cookie` * init for a new instance — fixed at creation. An instance is immutable
* header goes to the instance's rotatable cookie store (see * authority: no re-config methods, only attenuation via the fluent chain.
* {@link WebAppDo}). Otherwise immutable authority: no re-config methods, * The one thing that changes under it is its header store's contents.
* only attenuation via the fluent chain.
*/ */
export interface WebAppInit { export interface WebAppInit {
url: string; url: string;
headers?: Headers; headers?: Headers;
/** min spacing between outbound calls, ms (0 = unthrottled) */ /** min spacing between outbound calls, ms (0 = unthrottled) */
msPerCall?: number; msPerCall?: number;
/** id of a {@link MapStoreDo} whose entries are added as headers at
* each send (see {@link mintInstance}); the instance only reads it */
headerStore?: string;
} }
/** structural type of a stub handle: chain to narrow, then fire */ /** structural type of a stub handle: chain to narrow, then fire */
@@ -180,17 +184,16 @@ class Gate {
* storage, surviving eviction (instance memory evaporates when the DO naps; * storage, surviving eviction (instance memory evaporates when the DO naps;
* storage is where "this instance means this URL" durably lives). * storage is where "this instance means this URL" durably lives).
* *
* The cookie is the exception to "fixed at creation": it lives in a store * Its cookie, when minted by {@link mintInstance}, is not in `init` but in
* (`store:cookie:cookie`) rather than in `init`, and every send reads it, * the header store, whose id goes to the account owner and not to whoever
* so whoever holds the instance id can rotate it (see {@link cookies}) * holds the instance id: the owner can rotate it, and the holder cannot
* without a re-mint. Instances minted before that kept it in * swap in another account's session.
* `init.headers`; {@link handle} moves it over on first use.
* *
* @see https://developers.cloudflare.com/durable-objects/ — newUniqueId is * @see https://developers.cloudflare.com/durable-objects/ — newUniqueId is
* unforgeable (128-bit, inderivable); idFromName by contrast is ambient: * unforgeable (128-bit, inderivable); idFromName by contrast is ambient:
* any worker with the namespace binding can recompute it * any worker with the namespace binding can recompute it
*/ */
export class WebAppDo extends DurableObject<{ WEBAPP?: never }> { export class WebAppDo extends DurableObject<WebAppEnv> {
#ready?: Promise<void>; #ready?: Promise<void>;
/** one-shot configuration; a different-url re-configure is refused */ /** one-shot configuration; a different-url re-configure is refused */
@@ -209,21 +212,11 @@ export class WebAppDo extends DurableObject<{ WEBAPP?: never }> {
} }
return; // idempotent for an identical re-configure return; // idempotent for an identical re-configure
} }
const { cookie, rest } = splitCookie(init.headers ?? {}); await this.ctx.storage.put("init", init);
this.ctx.storage.transactionSync(() => {
this.ctx.storage.kv.put("init", { ...init, headers: rest });
if (cookie !== undefined) this.cookies().init("cookie", cookie);
});
})(); })();
await this.#ready; await this.#ready;
} }
/** the instance's cookie, with full authority: `set` rotates it under
* every handle already out */
cookies(): MapStore<string> {
return makeMapStore<string>("cookie", { keyShape: "cookie", valueShape: M.string() }, this.ctx.storage.kv);
}
/** load init from storage; rejects while unconfigured */ /** load init from storage; rejects while unconfigured */
async #load(): Promise<WebAppInit> { async #load(): Promise<WebAppInit> {
const init = await this.ctx.storage.get<WebAppInit>("init"); const init = await this.ctx.storage.get<WebAppInit>("init");
@@ -236,7 +229,7 @@ export class WebAppDo extends DurableObject<{ WEBAPP?: never }> {
/** the fluent stub handed to callers, wired to a fresh gate per /** the fluent stub handed to callers, wired to a fresh gate per
* activation (handles within one activation share it) */ * activation (handles within one activation share it) */
async handle(): Promise<FluentGet> { async handle(): Promise<FluentGet> {
const init = this.#migrateCookie(await this.#load()); const init = await this.#load();
const gate = new Gate(() => sleep(init.msPerCall ?? 0)); const gate = new Gate(() => sleep(init.msPerCall ?? 0));
const send = async ( const send = async (
url: string, url: string,
@@ -254,21 +247,20 @@ export class WebAppDo extends DurableObject<{ WEBAPP?: never }> {
} }
return res.text(); return res.text();
}; };
return new WebApp(init.url, init.headers ?? {}, [this.cookies().readOnly()], send); const maps = init.headerStore === undefined ? [] : [this.#readHeaderStore(init.headerStore)];
return new WebApp(init.url, init.headers ?? {}, maps, send);
} }
/** move a cookie stored in `init.headers` to the cookie store, in one /** a header map reading the store's read-only view, afresh at each send */
* transaction; returns init without it */ #readHeaderStore(storeId: string): HeaderMap {
#migrateCookie(init: WebAppInit): WebAppInit { const ns = this.env.MAP_STORE_DO;
const { cookie, rest } = splitCookie(init.headers ?? {}); const store = ns.get(ns.idFromString(storeId));
if (cookie === undefined) return init; return {
const migrated = { ...init, headers: rest }; async entries() {
this.ctx.storage.transactionSync(() => { using rd = await store.readOnly();
const cookies = this.cookies(); return await rd.entries();
if (!cookies.has("cookie")) cookies.init("cookie", cookie); },
this.ctx.storage.kv.put("init", migrated); };
});
return migrated;
} }
} }
@@ -283,12 +275,14 @@ function splitCookie(headers: Headers): { cookie?: string; rest: Headers } {
return { cookie, rest }; return { cookie, rest };
} }
type WebAppEnv = {
WEBAPP_DO: DurableObjectNamespace<WebAppDo>;
MAP_STORE_DO: DurableObjectNamespace<MapStoreDo>;
};
/** the worker's whole HTTP surface (the default export and the /** the worker's whole HTTP surface (the default export and the
* WebAppFactory entrypoint share it) */ * WebAppFactory entrypoint share it) */
const defaultFetch = async ( const defaultFetch = async (request: Request, env: WebAppEnv): Promise<Response> => {
request: Request,
env: { WEBAPP_DO: DurableObjectNamespace<WebAppDo> },
): Promise<Response> => {
const url = new URL(request.url); const url = new URL(request.url);
// mint form: site-agnostic; secret headers (cookie, authorization) are // mint form: site-agnostic; secret headers (cookie, authorization) are
@@ -299,7 +293,7 @@ const defaultFetch = async (
// mint: browser form POST (form-encoded) or JSON (service-style) // mint: browser form POST (form-encoded) or JSON (service-style)
if (url.pathname === "/" && request.method === "POST") { if (url.pathname === "/" && request.method === "POST") {
return mint(request, env.WEBAPP_DO); return mint(request, env);
} }
// the mint form, renderable for a proxying worker: ?render=1 keeps the // the mint form, renderable for a proxying worker: ?render=1 keeps the
@@ -318,11 +312,12 @@ const defaultFetch = async (
return drive(await app, request); return drive(await app, request);
} }
// rotate: POST /at/<id>/cookie with JSON {cookie}; the id that drives // a header store, by its id (the owner's, not the instance's): PUT
// an instance can also replace its cookie // JSON value sets a key (e.g. rotates the cookie), DELETE removes it
const rotate = url.pathname.match(/^\/at\/([A-Za-z0-9-]+)\/cookie$/); const entry = url.pathname.match(/^\/store\/([A-Za-z0-9-]+)\/([^/]+)$/);
if (rotate && request.method === "POST") { if (entry && (request.method === "PUT" || request.method === "DELETE")) {
return rotateCookie(env.WEBAPP_DO.get(env.WEBAPP_DO.idFromString(rotate[1])), request); const store = env.MAP_STORE_DO.get(env.MAP_STORE_DO.idFromString(entry[1]));
return storeEntry(store, decodeURIComponent(entry[2]), request);
} }
return new Response("not found", { status: 404 }); return new Response("not found", { status: 404 });
@@ -384,10 +379,7 @@ function withCookie(init: WebAppInit, cookie: string): WebAppInit {
} }
/** mint a fresh instance from form-encoded or JSON input */ /** mint a fresh instance from form-encoded or JSON input */
async function mint( async function mint(request: Request, env: WebAppEnv): Promise<Response> {
request: Request,
ns: DurableObjectNamespace<WebAppDo>,
): Promise<Response> {
const ct = request.headers.get("content-type") ?? ""; const ct = request.headers.get("content-type") ?? "";
let init: WebAppInit; let init: WebAppInit;
try { try {
@@ -414,13 +406,35 @@ async function mint(
return Response.json({ error: "bad url" }, { status: 400 }); return Response.json({ error: "bad url" }, { status: 400 });
} }
const id = ns.newUniqueId(); const { id, storeId } = await mintInstance(env, init);
const instance = ns.get(id); // note: ids intentionally NOT logged — they are bearer capabilities
await instance.configure(init);
const app = await instance.handle();
// note: id intentionally NOT logged — it is a bearer capability
console.log({ event: "mint", msPerCall: init.msPerCall }); console.log({ event: "mint", msPerCall: init.msPerCall });
return Response.json({ id: id.toString() }, { headers: { "x-webapp-id": id.toString() } }); return Response.json({ id, storeId }, { headers: { "x-webapp-id": id, "x-webapp-store-id": storeId } });
}
/**
* a configured instance plus its header store, shaped to hold just the
* cookie: the instance id is authority to use the cookie; the store id
* (for the account owner) is authority to rotate or remove it. Any
* `cookie` header in `init` goes to the store, not to the instance.
*/
async function mintInstance(env: WebAppEnv, init: WebAppInit): Promise<{ id: string; storeId: string }> {
const { cookie, rest } = splitCookie(init.headers ?? {});
const storeId = env.MAP_STORE_DO.newUniqueId();
const store = env.MAP_STORE_DO.get(storeId);
await store.configure("cookie", encodeShapes({ keyShape: "cookie", valueShape: M.string() }));
if (cookie !== undefined) {
using full = await store.store();
await full.init("cookie", cookie);
}
const id = env.WEBAPP_DO.newUniqueId();
await env.WEBAPP_DO.get(id).configure({
url: init.url,
headers: rest,
...(init.msPerCall === undefined ? {} : { msPerCall: init.msPerCall }),
headerStore: storeId.toString(),
});
return { id: id.toString(), storeId: storeId.toString() };
} }
/** drive a handle from the /at/<id> JSON protocol (also used by the /** drive a handle from the /at/<id> JSON protocol (also used by the
@@ -447,22 +461,32 @@ async function drive(app: FluentGet, request: Request): Promise<Response> {
} }
} }
/** the /at/<id>/cookie protocol: JSON {cookie} → {ok} (or {error}; no values) */ /** the /store/<id>/<key> protocol: PUT a JSON value (set, or init if
async function rotateCookie(instance: DurableObjectStub<WebAppDo>, request: Request): Promise<Response> { * absent), or DELETE → {ok} (or {error}; messages carry no values) */
let cookie: unknown; async function storeEntry(store: DurableObjectStub<MapStoreDo>, key: string, request: Request): Promise<Response> {
let value: unknown;
if (request.method === "PUT") {
try {
value = await request.json();
} catch (err) {
return Response.json({ error: `bad input: ${String(err)}` }, { status: 400 });
}
}
try { try {
({ cookie } = (await request.json()) as { cookie?: unknown }); using full = await store.store();
if (request.method === "DELETE") {
if (await full.has(key)) await full.delete(key);
} else if (await full.has(key)) {
await full.set(key, value);
} else {
await full.init(key, value);
}
} catch (err) { } catch (err) {
return Response.json({ error: `bad input: ${String(err)}` }, { status: 400 }); // store errors name shapes, never keys or values (see lib/src/store.ts)
return Response.json({ error: String(err) }, { status: 400 });
} }
if (typeof cookie !== "string" || !cookie) { // note: neither id nor value logged
return Response.json({ error: "bad input: cookie must be a non-empty string" }, { status: 400 }); console.log({ event: "store", method: request.method });
}
using cookies = await instance.cookies();
if (await cookies.has("cookie")) await cookies.set("cookie", cookie);
else await cookies.init("cookie", cookie);
// note: neither id nor cookie logged
console.log({ event: "rotate" });
return Response.json({ ok: true }); return Response.json({ ok: true });
} }
@@ -494,37 +518,37 @@ function unionQuery(base: string, params: Query): string {
/** /**
* the RPC service of the webapp worker. Flow: * the RPC service of the webapp worker. Flow:
* *
* const { id, app } = await env.WEBAPP.newWebApp({ * const { id, storeId, app } = await env.WEBAPP.newWebApp({
* url: "https://api.github.com", headers: { authorization: `Bearer ${t}` }, * url: "https://diigo.example/", headers: { cookie: c },
* msPerCall: 250, * msPerCall: 250,
* }); * });
* // hold id (persist it, hand it to workers you trust), then e.g.: * // hold id (persist it, hand it to workers you trust), then e.g.:
* const text = await app.query({ per_page: 5 }).get(); * const text = await app.query({ per_page: 5 }).get();
* // later, from another worker (same account) holding the id: * // later, from another worker (same account) holding the id:
* const app2 = env.WEBAPP.at(id); * const app2 = env.WEBAPP.at(id);
* // the account owner, holding storeId, after logging in again:
* await (await env.WEBAPP.store(storeId)).set("cookie", c2);
*/ */
export class WebAppFactory extends WorkerEntrypoint { export class WebAppFactory extends WorkerEntrypoint {
/** the same HTTP surface as the default export, over the entrypoint /** the same HTTP surface as the default export, over the entrypoint
* (for service-binding callers that proxy pages, e.g. diigo-cache's * (for service-binding callers that proxy pages, e.g. diigo-cache's
* /_connect → this form) */ * /_connect → this form) */
async fetch(request: Request): Promise<Response> { async fetch(request: Request): Promise<Response> {
const env = this.env as { WEBAPP_DO: DurableObjectNamespace<WebAppDo> }; return defaultFetch(request, this.env);
return defaultFetch(request, env);
} }
/** mint a fresh instance: unforgeable uniqueId + configured fluent stub. /** mint a fresh instance: unforgeable uniqueId + configured fluent stub,
* The caller decides who else ever learns the id. (If the caller only * plus the id of its header store (see {@link mintInstance}). The
* caller decides who else ever learns either id. (If the caller only
* wanted the id, it must dispose() the app stub — workerd warns about * wanted the id, it must dispose() the app stub — workerd warns about
* undisposed stubs; see the `at()` alternative + `/at/<id>` route for * undisposed stubs; see the `at()` alternative + `/at/<id>` route for
* id-only flows.) */ * id-only flows.) */
async newWebApp(init: WebAppInit): Promise<{ id: string; app: FluentGet }> { async newWebApp(init: WebAppInit): Promise<{ id: string; storeId: string; app: FluentGet }> {
new URL(init.url); // fail before minting on garbage new URL(init.url); // fail before minting on garbage
const { id, storeId } = await mintInstance(this.env, init);
const ns = this.env.WEBAPP_DO; const ns = this.env.WEBAPP_DO;
const id = ns.newUniqueId(); const app = await ns.get(ns.idFromString(id)).handle();
const instance = ns.get(id); return { id, storeId, app };
await instance.configure(init);
const app = await instance.handle();
return { id: id.toString(), app };
} }
/** reconnect by a previously-obtained id */ /** reconnect by a previously-obtained id */
@@ -533,11 +557,11 @@ export class WebAppFactory extends WorkerEntrypoint {
return ns.get(ns.idFromString(id)).handle(); return ns.get(ns.idFromString(id)).handle();
} }
/** the cookie store of a previously-obtained id: holding the id is /** a header store by its id, with full authority (the instance id
* authority to rotate the cookie, not just to use it */ * reaches none: see {@link mintInstance}) */
async cookies(id: string) { async store(storeId: string) {
const ns = this.env.WEBAPP_DO; const ns = this.env.MAP_STORE_DO;
return ns.get(ns.idFromString(id)).cookies(); return ns.get(ns.idFromString(storeId)).store();
} }
} }
function sleep(ms: number): Promise<void> { function sleep(ms: number): Promise<void> {
+71 -48
View File
@@ -3,7 +3,10 @@ import { runInDurableObject } from "cloudflare:test";
import { afterEach, describe, expect, it, vi } from "vitest"; import { afterEach, describe, expect, it, vi } from "vitest";
import worker, { type WebAppInit } from "../src/index.js"; 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 testEnv = env as unknown as TestEnv;
const URL_ = "https://diigo.example/api/"; const URL_ = "https://diigo.example/api/";
@@ -22,79 +25,99 @@ afterEach(() => {
vi.restoreAllMocks(); 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 */ /** `init` as stored */
const storedInit = (stub: DurableObjectStub) => const storedInit = (stub: DurableObjectStub) =>
runInDurableObject(stub, (_obj, state) => state.storage.kv.get<WebAppInit>("init")); runInDurableObject(stub, (_obj, state) => state.storage.kv.get<WebAppInit>("init"));
describe("cookie rotation", () => { 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 seen = spyCookies();
const stub = fresh(); const { storeId, instance } = await mint({
await stub.configure({ url: URL_, headers: { Cookie: "session=a", accept: "application/json" } }); url: URL_,
expect(await storedInit(stub)).toEqual({ url: URL_, headers: { accept: "application/json" } }); headers: { Cookie: "session=a", accept: "application/json" },
using app = await stub.handle(); });
expect(await storedInit(instance)).toEqual({
url: URL_,
headers: { accept: "application/json" },
headerStore: storeId,
});
using app = await instance.handle();
await app.get(); await app.get();
expect(seen).toEqual(["session=a"]); 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 seen = spyCookies();
const stub = fresh(); const { storeId, instance } = await mint({ url: URL_, headers: { cookie: "session=a" } });
await stub.configure({ url: URL_, headers: { cookie: "session=a" } }); using app = await instance.handle();
using app = await stub.handle();
await app.pathjoin("x").get(); await app.pathjoin("x").get();
using cookies = await stub.cookies(); expect(await (await call(`/store/${storeId}/cookie`, "PUT", "session=b")).json()).toEqual({ ok: true });
await cookies.set("cookie", "session=b");
await app.pathjoin("x").get(); 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 seen = spyCookies();
const stub = fresh(); const { id, instance } = await mint({ url: URL_, headers: { cookie: "session=alice" } });
// the shape configure() stored until the cookie store expect(await errorOf(call(`/store/${id}/cookie`, "PUT", "session=mallory"))).not.toBe("");
await runInDurableObject(stub, (_obj, state) => expect((await call(`/at/${id}/cookie`, "POST", { cookie: "session=mallory" })).status).toBe(404);
state.storage.put("init", { url: URL_, headers: { cookie: "session=old" }, msPerCall: 0 }), using app = await instance.handle();
);
using app = await stub.handle();
await app.get(); await app.get();
expect(await storedInit(stub)).toEqual({ url: URL_, headers: {}, msPerCall: 0 }); expect(seen).toEqual(["session=alice"]);
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"]);
}); });
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 seen = spyCookies();
const id = testEnv.WEBAPP_DO.newUniqueId(); const instance = testEnv.WEBAPP_DO.get(testEnv.WEBAPP_DO.newUniqueId());
const stub = testEnv.WEBAPP_DO.get(id); const old = { url: URL_, headers: { cookie: "session=old" }, msPerCall: 0 };
await stub.configure({ url: URL_ }); await runInDurableObject(instance, (_obj, state) => state.storage.put("init", old));
const rotate = (cookie: string) => using app = await instance.handle();
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();
await app.get(); 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 () => { it("a fixed header contradicting the stored cookie fails at send, naming no values", async () => {
spyCookies(); spyCookies();
const stub = fresh(); const { instance } = await mint({ url: URL_, headers: { cookie: "session=a" } });
await stub.configure({ url: URL_, headers: { cookie: "session=a" } }); using app = await instance.handle();
using app = await stub.handle();
const err = await app const err = await app
.withHeader("cookie", "session=mine") .withHeader("cookie", "session=mine")
.get() .get()
+8 -2
View File
@@ -5,7 +5,13 @@
"compatibility_date": "2026-09-29", "compatibility_date": "2026-09-29",
"observability": { "enabled": true }, "observability": { "enabled": true },
"durable_objects": { "durable_objects": {
"bindings": [{ "name": "WEBAPP_DO", "class_name": "WebAppDo" }] "bindings": [
{ "name": "WEBAPP_DO", "class_name": "WebAppDo" },
{ "name": "MAP_STORE_DO", "class_name": "MapStoreDo" }
]
}, },
"migrations": [{ "tag": "v1", "new_sqlite_classes": ["WebAppDo"] }] "migrations": [
{ "tag": "v1", "new_sqlite_classes": ["WebAppDo"] },
{ "tag": "v2", "new_sqlite_classes": ["MapStoreDo"] }
]
} }