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
9 changed files with 2512 additions and 14 deletions
Showing only changes of commit 10414c2ce6 - Show all commits

No files matched your search

+4 -4
View File
@@ -109,10 +109,10 @@ narrative.
weekly page renders against prod. weekly page renders against prod.
- [ ] **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 manual: re-mint via page, don't fail silently). Rotation is in place, keeping the
`/_connect`. Open question: can an existing cache be re-wired to webapp id (so the cache's wiring and ids hold): `POST
a new webapp id (keeping its cache id and public username), or /at/<id>/cookie` with the new cookie (webapp README, "The cookie
does re-mint mean a new cache? Prefer re-wire — durable ids. 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).
+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. - `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}` (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>` — 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"`) ## 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 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
[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 ## Run
pnpm test # cookie 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)
+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 // DurableObjectNamespace/DurableObjectId/DurableObjectStub are ambient types
// 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 { makeMapStore, type MapStore } from "../../lib/src/store.js";
export type Query = Record<string, string | number>; export type Query = Record<string, string | number>;
type Headers = Record<string, string>; 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 * init for a new instance — fixed at creation, except that a `cookie`
* authority: no re-config methods, only attenuation via the fluent chain. * 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 { export interface WebAppInit {
url: string; url: string;
@@ -29,6 +37,7 @@ export interface FluentGet {
withHeaders(h: Headers): FluentGet; withHeaders(h: Headers): FluentGet;
withHeader(key: string, value: string): FluentGet; withHeader(key: string, value: string): FluentGet;
cookies(cookie: string): FluentGet; cookies(cookie: string): FluentGet;
withHeaderMap(map: HeaderMap): FluentGet;
get(): Promise<string>; get(): Promise<string>;
post(body: string): Promise<string>; post(body: string): Promise<string>;
} }
@@ -57,16 +66,19 @@ export interface FluentGet {
export class WebApp extends RpcTarget implements FluentGet { export class WebApp extends RpcTarget implements FluentGet {
#url: string; #url: string;
#headers: Headers; #headers: Headers;
#maps: readonly HeaderMap[];
#send: (url: string, headers: Headers, method: "GET" | "POST", body?: string) => Promise<string>; #send: (url: string, headers: Headers, method: "GET" | "POST", body?: string) => Promise<string>;
constructor( constructor(
url: string, url: string,
headers: Headers, headers: Headers,
maps: readonly HeaderMap[],
send: (url: string, headers: Headers, method: "GET" | "POST", body?: string) => Promise<string>, send: (url: string, headers: Headers, method: "GET" | "POST", body?: string) => Promise<string>,
) { ) {
super(); super();
this.#url = url; this.#url = url;
this.#headers = headers; this.#headers = headers;
this.#maps = maps;
this.#send = send; this.#send = send;
} }
@@ -85,13 +97,13 @@ export class WebApp extends RpcTarget implements FluentGet {
throw new Error(`pathjoin: ${ref} not within ${hereNoQuery.href}`); throw new Error(`pathjoin: ${ref} not within ${hereNoQuery.href}`);
} }
const baseParams: Query = Object.fromEntries(here.searchParams); 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 /** Union headers, flower-style (see {@link WebApp}): a shared key with a
* different value throws; the same value is fine. */ * different value throws; the same value is fine. */
withHeaders(h: Headers): WebApp { 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}) */ /** 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}). */ /** Union params into the existing query, flower-style (see {@link WebApp}). */
query(params: Query): 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> { 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> { 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, 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
* (`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 * @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
@@ -172,11 +209,21 @@ export class WebAppDo extends DurableObject<{ WEBAPP?: never }> {
} }
return; // idempotent for an identical re-configure 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; 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");
@@ -189,7 +236,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 = await this.#load(); const init = this.#migrateCookie(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,
@@ -207,8 +254,33 @@ export class WebAppDo extends DurableObject<{ WEBAPP?: never }> {
} }
return res.text(); 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 /** the worker's whole HTTP surface (the default export and the
@@ -246,6 +318,13 @@ 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
// 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 }); 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 { function unionHeaders(base: Headers, more: Headers): Headers {
const merged = { ...base }; const merged = { ...base };
for (const [key, value] of Object.entries(more)) { for (const [key, value] of Object.entries(more)) {
@@ -434,6 +532,13 @@ export class WebAppFactory extends WorkerEntrypoint {
const ns = this.env.WEBAPP_DO; const ns = this.env.WEBAPP_DO;
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
* 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> { function sleep(ms: number): Promise<void> {
return new Promise((r) => setTimeout(r, ms)); 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, "esModuleInterop": true,
"verbatimModuleSyntax": true, "verbatimModuleSyntax": true,
"skipLibCheck": true, "skipLibCheck": true,
"lib": ["ES2022", "ESNext.Disposable"],
"noEmit": true, "noEmit": true,
"types": [] "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"],
},
});