diff --git a/capsite-checklist.md b/capsite-checklist.md index 726511c..edc9f4a 100644 --- a/capsite-checklist.md +++ b/capsite-checklist.md @@ -110,9 +110,10 @@ narrative. - [ ] **6. Cookie expiry**: when diigo's session ends, head refreshes fail; the public page keeps serving what's stored (say so on the page, don't fail silently). Rotation is in place, keeping the - webapp id (so the cache's wiring and ids hold): `POST - /at//cookie` with the new cookie (webapp README, "The cookie - store"). Still open: a rotate form in diigo-cache's UI. + webapp id (so the cache's wiring and ids hold), by the store id + minted alongside it and kept by the account owner: `PUT + /store//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 read-access need (webapp mints for any origin; the cache's item shape is diigo-specific): [the zotero goal tree](zotero-checklist.md). diff --git a/webapp/README.md b/webapp/README.md index b16be13..644a41d 100644 --- a/webapp/README.md +++ b/webapp/README.md @@ -12,31 +12,45 @@ grows past a form or two. - `GET /` — the mint form. Prefill via query string: `?url=…&headers=&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. -- `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/` — 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//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//` — set a header-store entry (JSON value body), e.g. `PUT /store//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"`) - `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 single round trip - `at(id)` — the fluent stub for a previously-minted instance -- `cookies(id)` — the instance's cookie store (a MapStore from - [the capsite lib](../lib/src/store.ts) with key shape `"cookie"`): - `set("cookie", …)` rotates it under every handle already out +- `store(storeId)` — a header store, with full authority: + `set("cookie", …)` rotates the cookie 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 -store rather than in `init`; every send reads it. Instances minted before -the store held their cookie in `init.headers`; the first `handle()` moves -it over in one transaction. +Mint makes two things: the instance, and a header store (a `MapStoreDo` +from [the capsite lib](../lib/src/store-do.ts), shaped +`("cookie", { keyShape: "cookie", valueShape: M.string() })`). A `cookie` +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 - 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 # two-config diigo-cache run; standalone, :8788) diff --git a/webapp/src/index.ts b/webapp/src/index.ts index 58ac670..ab79ab9 100644 --- a/webapp/src/index.ts +++ b/webapp/src/index.ts @@ -8,7 +8,9 @@ // in worker-configuration.d.ts (not exports of "cloudflare:workers") import { RpcTarget, DurableObject, WorkerEntrypoint } from "cloudflare:workers"; 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; type Headers = Record; @@ -18,16 +20,18 @@ type Headers = Record; export type HeaderMap = { entries(): [string, unknown][] | Promise<[string, unknown][]> }; /** - * init for a new instance — fixed at creation, except that a `cookie` - * header goes to the instance's rotatable cookie store (see - * {@link WebAppDo}). Otherwise immutable authority: no re-config methods, - * only attenuation via the fluent chain. + * init for a new instance — fixed at creation. An instance is immutable + * authority: no re-config methods, only attenuation via the fluent chain. + * The one thing that changes under it is its header store's contents. */ export interface WebAppInit { url: string; headers?: Headers; /** min spacing between outbound calls, ms (0 = unthrottled) */ 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 */ @@ -180,17 +184,16 @@ class Gate { * storage, surviving eviction (instance memory evaporates when the DO naps; * storage is where "this instance means this URL" durably lives). * - * The cookie is the exception to "fixed at creation": it lives in a store - * (`store:cookie:cookie`) rather than in `init`, and every send reads it, - * so whoever holds the instance id can rotate it (see {@link cookies}) - * without a re-mint. Instances minted before that kept it in - * `init.headers`; {@link handle} moves it over on first use. + * Its cookie, when minted by {@link mintInstance}, is not in `init` but in + * the header store, whose id goes to the account owner and not to whoever + * holds the instance id: the owner can rotate it, and the holder cannot + * swap in another account's session. * * @see https://developers.cloudflare.com/durable-objects/ — newUniqueId is * unforgeable (128-bit, inderivable); idFromName by contrast is ambient: * any worker with the namespace binding can recompute it */ -export class WebAppDo extends DurableObject<{ WEBAPP?: never }> { +export class WebAppDo extends DurableObject { #ready?: Promise; /** 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 } - const { cookie, rest } = splitCookie(init.headers ?? {}); - this.ctx.storage.transactionSync(() => { - this.ctx.storage.kv.put("init", { ...init, headers: rest }); - if (cookie !== undefined) this.cookies().init("cookie", cookie); - }); + await this.ctx.storage.put("init", init); })(); await this.#ready; } - /** the instance's cookie, with full authority: `set` rotates it under - * every handle already out */ - cookies(): MapStore { - return makeMapStore("cookie", { keyShape: "cookie", valueShape: M.string() }, this.ctx.storage.kv); - } - /** load init from storage; rejects while unconfigured */ async #load(): Promise { const init = await this.ctx.storage.get("init"); @@ -236,7 +229,7 @@ export class WebAppDo extends DurableObject<{ WEBAPP?: never }> { /** the fluent stub handed to callers, wired to a fresh gate per * activation (handles within one activation share it) */ async handle(): Promise { - const init = this.#migrateCookie(await this.#load()); + const init = await this.#load(); const gate = new Gate(() => sleep(init.msPerCall ?? 0)); const send = async ( url: string, @@ -254,21 +247,20 @@ export class WebAppDo extends DurableObject<{ WEBAPP?: never }> { } 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 - * transaction; returns init without it */ - #migrateCookie(init: WebAppInit): WebAppInit { - const { cookie, rest } = splitCookie(init.headers ?? {}); - if (cookie === undefined) return init; - const migrated = { ...init, headers: rest }; - this.ctx.storage.transactionSync(() => { - const cookies = this.cookies(); - if (!cookies.has("cookie")) cookies.init("cookie", cookie); - this.ctx.storage.kv.put("init", migrated); - }); - return migrated; + /** a header map reading the store's read-only view, afresh at each send */ + #readHeaderStore(storeId: string): HeaderMap { + const ns = this.env.MAP_STORE_DO; + const store = ns.get(ns.idFromString(storeId)); + return { + async entries() { + using rd = await store.readOnly(); + return await rd.entries(); + }, + }; } } @@ -283,12 +275,14 @@ function splitCookie(headers: Headers): { cookie?: string; rest: Headers } { return { cookie, rest }; } +type WebAppEnv = { + WEBAPP_DO: DurableObjectNamespace; + MAP_STORE_DO: DurableObjectNamespace; +}; + /** the worker's whole HTTP surface (the default export and the * WebAppFactory entrypoint share it) */ -const defaultFetch = async ( - request: Request, - env: { WEBAPP_DO: DurableObjectNamespace }, -): Promise => { +const defaultFetch = async (request: Request, env: WebAppEnv): Promise => { const url = new URL(request.url); // 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) 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 @@ -318,11 +312,12 @@ const defaultFetch = async ( return drive(await app, request); } - // rotate: POST /at//cookie with JSON {cookie}; the id that drives - // an instance can also replace its cookie - const rotate = url.pathname.match(/^\/at\/([A-Za-z0-9-]+)\/cookie$/); - if (rotate && request.method === "POST") { - return rotateCookie(env.WEBAPP_DO.get(env.WEBAPP_DO.idFromString(rotate[1])), request); + // a header store, by its id (the owner's, not the instance's): PUT + // JSON value sets a key (e.g. rotates the cookie), DELETE removes it + const entry = url.pathname.match(/^\/store\/([A-Za-z0-9-]+)\/([^/]+)$/); + if (entry && (request.method === "PUT" || request.method === "DELETE")) { + 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 }); @@ -384,10 +379,7 @@ function withCookie(init: WebAppInit, cookie: string): WebAppInit { } /** mint a fresh instance from form-encoded or JSON input */ -async function mint( - request: Request, - ns: DurableObjectNamespace, -): Promise { +async function mint(request: Request, env: WebAppEnv): Promise { const ct = request.headers.get("content-type") ?? ""; let init: WebAppInit; try { @@ -414,13 +406,35 @@ async function mint( return Response.json({ error: "bad url" }, { status: 400 }); } - const id = ns.newUniqueId(); - const instance = ns.get(id); - await instance.configure(init); - const app = await instance.handle(); - // note: id intentionally NOT logged — it is a bearer capability + const { id, storeId } = await mintInstance(env, init); + // note: ids intentionally NOT logged — they are bearer capabilities 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/ JSON protocol (also used by the @@ -447,22 +461,32 @@ async function drive(app: FluentGet, request: Request): Promise { } } -/** the /at//cookie protocol: JSON {cookie} → {ok} (or {error}; no values) */ -async function rotateCookie(instance: DurableObjectStub, request: Request): Promise { - let cookie: unknown; +/** the /store// protocol: PUT a JSON value (set, or init if + * absent), or DELETE → {ok} (or {error}; messages carry no values) */ +async function storeEntry(store: DurableObjectStub, key: string, request: Request): Promise { + 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 { - ({ 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) { - 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) { - return Response.json({ error: "bad input: cookie must be a non-empty string" }, { status: 400 }); - } - 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" }); + // note: neither id nor value logged + console.log({ event: "store", method: request.method }); return Response.json({ ok: true }); } @@ -494,37 +518,37 @@ function unionQuery(base: string, params: Query): string { /** * the RPC service of the webapp worker. Flow: * - * const { id, app } = await env.WEBAPP.newWebApp({ - * url: "https://api.github.com", headers: { authorization: `Bearer ${t}` }, + * const { id, storeId, app } = await env.WEBAPP.newWebApp({ + * url: "https://diigo.example/", headers: { cookie: c }, * msPerCall: 250, * }); * // hold id (persist it, hand it to workers you trust), then e.g.: * const text = await app.query({ per_page: 5 }).get(); * // later, from another worker (same account) holding the 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 { /** the same HTTP surface as the default export, over the entrypoint * (for service-binding callers that proxy pages, e.g. diigo-cache's * /_connect → this form) */ async fetch(request: Request): Promise { - const env = this.env as { WEBAPP_DO: DurableObjectNamespace }; - return defaultFetch(request, env); + return defaultFetch(request, this.env); } - /** mint a fresh instance: unforgeable uniqueId + configured fluent stub. - * The caller decides who else ever learns the id. (If the caller only + /** mint a fresh instance: unforgeable uniqueId + configured fluent stub, + * 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 * undisposed stubs; see the `at()` alternative + `/at/` route for * 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 + const { id, storeId } = await mintInstance(this.env, init); const ns = this.env.WEBAPP_DO; - const id = ns.newUniqueId(); - const instance = ns.get(id); - await instance.configure(init); - const app = await instance.handle(); - return { id: id.toString(), app }; + const app = await ns.get(ns.idFromString(id)).handle(); + return { id, storeId, app }; } /** reconnect by a previously-obtained id */ @@ -533,11 +557,11 @@ export class WebAppFactory extends WorkerEntrypoint { return ns.get(ns.idFromString(id)).handle(); } - /** the cookie store of a previously-obtained id: holding the id is - * authority to rotate the cookie, not just to use it */ - async cookies(id: string) { - const ns = this.env.WEBAPP_DO; - return ns.get(ns.idFromString(id)).cookies(); + /** a header store by its id, with full authority (the instance id + * reaches none: see {@link mintInstance}) */ + async store(storeId: string) { + const ns = this.env.MAP_STORE_DO; + return ns.get(ns.idFromString(storeId)).store(); } } function sleep(ms: number): Promise { diff --git a/webapp/tests/cookie.test.ts b/webapp/tests/cookie.test.ts index 9861892..b91ca5d 100644 --- a/webapp/tests/cookie.test.ts +++ b/webapp/tests/cookie.test.ts @@ -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 }; +type TestEnv = { + WEBAPP_DO: DurableObjectNamespace; + MAP_STORE_DO: DurableObjectNamespace; +}; 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): Promise { + 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("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//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() diff --git a/webapp/wrangler.jsonc b/webapp/wrangler.jsonc index 82eee11..78d43d1 100644 --- a/webapp/wrangler.jsonc +++ b/webapp/wrangler.jsonc @@ -5,7 +5,13 @@ "compatibility_date": "2026-09-29", "observability": { "enabled": true }, "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"] } + ] } \ No newline at end of file