capsite/webapp: rotate the cookie in place

The cookie moves out of the immutable init into a MapStore (key shape
"cookie", value M.string()) in the instance's DO storage. Handles get
its read-only view via the new WebApp.withHeaderMap, which reads at
every send, so a rotation reaches handles already out and the webapp
id, and with it the cache's wiring, stays put (checklist goal 6).

Whoever holds the id can rotate, by RPC (WebAppFactory.cookies(id)) or
POST /at/<id>/cookie; the diigo account owner revokes by logging out.

Durable state: instances minted earlier keep the cookie in
init.headers. The first handle() moves it to the store and rewrites
init without it, in one transaction. Code from before this commit
would then see no cookie, so rolling back past it needs the reverse
move.

webapp gains a package.json and a vitest workerd pool for the tests.

Generated-by: Claude Opus 5.5
This commit is contained in:
dckc committed 2026-10-09 02:32:22 -05:00
1 parent 9179bdbf38
commit 10414c2ce6
9 files changed
+2512 -14

No files matched your search

+12
View File
@@ -14,6 +14,7 @@ grows past a form or two.
- `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 /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.
## RPC surface (same-account workers; binding `entrypoint: "WebAppFactory"`)
@@ -22,9 +23,20 @@ grows past a form or two.
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
## The cookie store
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.
## Run
pnpm test # cookie store + rotation (vitest, workerd pool)
npx wrangler dev # → its own ready line (usually :8787 with the
# two-config diigo-cache run; standalone, :8788)
+16
View File
@@ -0,0 +1,16 @@
{
"name": "webapp",
"private": true,
"type": "module",
"scripts": {
"types": "wrangler types",
"lint": "wrangler types --check && tsc -p tsconfig.json && tsc -p tests/tsconfig.json",
"test": "vitest run"
},
"devDependencies": {
"@cloudflare/vitest-plugin": "1.3.6",
"typescript": "^5.9.0",
"vitest": "4.1.11",
"wrangler": "^4.148.0"
}
}
+2240
View File
File diff suppressed because it is too large. Load diff
+115 -10
View File
@@ -7,13 +7,21 @@
// DurableObjectNamespace/DurableObjectId/DurableObjectStub are ambient types
// 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";
export type Query = Record<string, string | number>;
type Headers = Record<string, string>;
/** headers looked up at each send, e.g. a store's read-only view: whoever
* holds the store can rotate a credential under handles already out */
export type HeaderMap = { entries(): [string, unknown][] | Promise<[string, unknown][]> };
/**
* init for a new instance — fixed at creation. An instance is immutable
* authority: no re-config methods, only attenuation via the fluent chain.
* 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.
*/
export interface WebAppInit {
url: string;
@@ -29,6 +37,7 @@ export interface FluentGet {
withHeaders(h: Headers): FluentGet;
withHeader(key: string, value: string): FluentGet;
cookies(cookie: string): FluentGet;
withHeaderMap(map: HeaderMap): FluentGet;
get(): Promise<string>;
post(body: string): Promise<string>;
}
@@ -57,16 +66,19 @@ export interface FluentGet {
export class WebApp extends RpcTarget implements FluentGet {
#url: string;
#headers: Headers;
#maps: readonly HeaderMap[];
#send: (url: string, headers: Headers, method: "GET" | "POST", body?: string) => Promise<string>;
constructor(
url: string,
headers: Headers,
maps: readonly HeaderMap[],
send: (url: string, headers: Headers, method: "GET" | "POST", body?: string) => Promise<string>,
) {
super();
this.#url = url;
this.#headers = headers;
this.#maps = maps;
this.#send = send;
}
@@ -85,13 +97,13 @@ export class WebApp extends RpcTarget implements FluentGet {
throw new Error(`pathjoin: ${ref} not within ${hereNoQuery.href}`);
}
const baseParams: Query = Object.fromEntries(here.searchParams);
return new WebApp(unionQuery(there.href, baseParams), this.#headers, this.#send);
return new WebApp(unionQuery(there.href, baseParams), this.#headers, this.#maps, this.#send);
}
/** Union headers, flower-style (see {@link WebApp}): a shared key with a
* different value throws; the same value is fine. */
withHeaders(h: Headers): WebApp {
return new WebApp(this.#url, unionHeaders(this.#headers, h), this.#send);
return new WebApp(this.#url, unionHeaders(this.#headers, h), this.#maps, this.#send);
}
/** one key, flower-style (see {@link WebApp}) */
@@ -105,15 +117,34 @@ export class WebApp extends RpcTarget implements FluentGet {
/** Union params into the existing query, flower-style (see {@link WebApp}). */
query(params: Query): WebApp {
return new WebApp(unionQuery(this.#url, params), this.#headers, this.#send);
return new WebApp(unionQuery(this.#url, params), this.#headers, this.#maps, this.#send);
}
/** Add headers read from `map` at each send, unioned flower-style (see
* {@link WebApp}) with the rest — so a contradiction surfaces at send. */
withHeaderMap(map: HeaderMap): WebApp {
return new WebApp(this.#url, this.#headers, [...this.#maps, map], this.#send);
}
async get(): Promise<string> {
return this.#send(this.#url, this.#headers, "GET");
return this.#send(this.#url, await this.#headersNow(), "GET");
}
async post(body: string): Promise<string> {
return this.#send(this.#url, this.#headers, "POST", body);
return this.#send(this.#url, await this.#headersNow(), "POST", body);
}
async #headersNow(): Promise<Headers> {
let headers = this.#headers;
for (const map of this.#maps) {
const entries = await map.entries();
for (const [key, value] of entries) {
// key name only: header values must not appear in Error messages
if (typeof value !== "string") throw new Error(`headers: ${key} is not a string`);
}
headers = unionHeaders(headers, Object.fromEntries(entries) as Headers);
}
return headers;
}
}
@@ -149,6 +180,12 @@ 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.
*
* @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
@@ -172,11 +209,21 @@ export class WebAppDo extends DurableObject<{ WEBAPP?: never }> {
}
return; // idempotent for an identical re-configure
}
await this.ctx.storage.put("init", init);
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.#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 */
async #load(): Promise<WebAppInit> {
const init = await this.ctx.storage.get<WebAppInit>("init");
@@ -189,7 +236,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<FluentGet> {
const init = await this.#load();
const init = this.#migrateCookie(await this.#load());
const gate = new Gate(() => sleep(init.msPerCall ?? 0));
const send = async (
url: string,
@@ -207,8 +254,33 @@ export class WebAppDo extends DurableObject<{ WEBAPP?: never }> {
}
return res.text();
};
return new WebApp(init.url, init.headers ?? {}, send);
return new WebApp(init.url, init.headers ?? {}, [this.cookies().readOnly()], 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;
}
}
/** split the cookie (any case of the header name) from the other headers */
function splitCookie(headers: Headers): { cookie?: string; rest: Headers } {
const rest: Headers = {};
let cookie: string | undefined;
for (const [key, value] of Object.entries(headers)) {
if (key.toLowerCase() === "cookie") cookie = value;
else rest[key] = value;
}
return { cookie, rest };
}
/** the worker's whole HTTP surface (the default export and the
@@ -246,6 +318,13 @@ const defaultFetch = async (
return drive(await app, request);
}
// rotate: POST /at/<id>/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);
}
return new Response("not found", { status: 404 });
};
@@ -368,6 +447,25 @@ async function drive(app: FluentGet, request: Request): Promise<Response> {
}
}
/** the /at/<id>/cookie protocol: JSON {cookie} → {ok} (or {error}; no values) */
async function rotateCookie(instance: DurableObjectStub<WebAppDo>, request: Request): Promise<Response> {
let cookie: unknown;
try {
({ cookie } = (await request.json()) as { cookie?: unknown });
} catch (err) {
return Response.json({ error: `bad input: ${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" });
return Response.json({ ok: true });
}
function unionHeaders(base: Headers, more: Headers): Headers {
const merged = { ...base };
for (const [key, value] of Object.entries(more)) {
@@ -434,6 +532,13 @@ export class WebAppFactory extends WorkerEntrypoint {
const ns = this.env.WEBAPP_DO;
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();
}
}
function sleep(ms: number): Promise<void> {
return new Promise((r) => setTimeout(r, ms));
+108
View File
@@ -0,0 +1,108 @@
import { env } from "cloudflare:workers";
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> };
const testEnv = env as unknown as TestEnv;
const URL_ = "https://diigo.example/api/";
/** the cookie header of each outbound fetch */
function spyCookies(): (string | null)[] {
const seen: (string | null)[] = [];
vi.spyOn(globalThis, "fetch").mockImplementation(async (_input, init) => {
seen.push(new Headers(init?.headers).get("cookie"));
return new Response("ok");
});
return seen;
}
afterEach(() => {
vi.restoreAllMocks();
});
const fresh = () => testEnv.WEBAPP_DO.get(testEnv.WEBAPP_DO.newUniqueId());
/** `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 () => {
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();
await app.get();
expect(seen).toEqual(["session=a"]);
});
it("a rotation reaches handles already out", async () => {
const seen = spyCookies();
const stub = fresh();
await stub.configure({ url: URL_, headers: { cookie: "session=a" } });
using app = await stub.handle();
await app.pathjoin("x").get();
using cookies = await stub.cookies();
await cookies.set("cookie", "session=b");
await app.pathjoin("x").get();
expect(seen).toEqual(["session=a", "session=b"]);
});
it("an instance minted before rotation moves its cookie to the store on first use", 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();
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"]);
});
it("POST /at/<id>/cookie rotates, by the same id that drives", 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();
await app.get();
expect(seen).toEqual(["session=second"]);
});
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 err = await app
.withHeader("cookie", "session=mine")
.get()
.then(
() => "",
(e: unknown) => String(e),
);
expect(err).toContain("headers: contradicts on cookie");
expect(err).not.toContain("session=");
});
});
+7
View File
@@ -0,0 +1,7 @@
{
"extends": "../tsconfig.json",
"compilerOptions": {
"types": ["@cloudflare/vitest-plugin/types"]
},
"include": ["./**/*", "../worker-configuration.d.ts"]
}
+1
View File
@@ -7,6 +7,7 @@
"esModuleInterop": true,
"verbatimModuleSyntax": true,
"skipLibCheck": true,
"lib": ["ES2022", "ESNext.Disposable"],
"noEmit": true,
"types": []
},
+9
View File
@@ -0,0 +1,9 @@
import { defineProject } from "vitest/config";
import { cloudflareTest } from "@cloudflare/vitest-plugin";
export default defineProject({
plugins: [cloudflareTest({ wrangler: { configPath: "./wrangler.jsonc" } })],
test: {
include: ["tests/**/*.test.ts"],
},
});