WIP: webapp: rotate the diigo cookie in place #1
No files matched your search
@@ -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/<id>/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/<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
|
||||
read-access need (webapp mints for any origin; the cache's
|
||||
item shape is diigo-specific): [the zotero goal tree](zotero-checklist.md).
|
||||
|
||||
+26
-12
@@ -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 /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>/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"`)
|
||||
|
||||
- `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)
|
||||
|
||||
|
||||
+108
-84
@@ -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<string, string | number>;
|
||||
type Headers = Record<string, string>;
|
||||
@@ -18,16 +20,18 @@ type Headers = Record<string, string>;
|
||||
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<WebAppEnv> {
|
||||
#ready?: Promise<void>;
|
||||
|
||||
/** 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<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");
|
||||
@@ -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<FluentGet> {
|
||||
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<WebAppDo>;
|
||||
MAP_STORE_DO: DurableObjectNamespace<MapStoreDo>;
|
||||
};
|
||||
|
||||
/** the worker's whole HTTP surface (the default export and the
|
||||
* WebAppFactory entrypoint share it) */
|
||||
const defaultFetch = async (
|
||||
request: Request,
|
||||
env: { WEBAPP_DO: DurableObjectNamespace<WebAppDo> },
|
||||
): Promise<Response> => {
|
||||
const defaultFetch = async (request: Request, env: WebAppEnv): Promise<Response> => {
|
||||
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/<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);
|
||||
// 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<WebAppDo>,
|
||||
): Promise<Response> {
|
||||
async function mint(request: Request, env: WebAppEnv): Promise<Response> {
|
||||
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/<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) */
|
||||
async function rotateCookie(instance: DurableObjectStub<WebAppDo>, request: Request): Promise<Response> {
|
||||
let cookie: unknown;
|
||||
/** the /store/<id>/<key> protocol: PUT a JSON value (set, or init if
|
||||
* absent), or DELETE → {ok} (or {error}; messages carry no values) */
|
||||
async function storeEntry(store: DurableObjectStub<MapStoreDo>, key: string, request: Request): Promise<Response> {
|
||||
let value: unknown;
|
||||
if (request.method === "PUT") {
|
||||
try {
|
||||
({ cookie } = (await request.json()) as { cookie?: unknown });
|
||||
value = await request.json();
|
||||
} 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" });
|
||||
try {
|
||||
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) {
|
||||
// store errors name shapes, never keys or values (see lib/src/store.ts)
|
||||
return Response.json({ error: String(err) }, { status: 400 });
|
||||
}
|
||||
// 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<Response> {
|
||||
const env = this.env as { WEBAPP_DO: DurableObjectNamespace<WebAppDo> };
|
||||
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/<id>` 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<void> {
|
||||
|
||||
+71
-48
@@ -3,7 +3,10 @@ import { runInDurableObject } from "cloudflare:test";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import worker, { type WebAppInit } from "../src/index.js";
|
||||
|
||||
type TestEnv = { WEBAPP_DO: DurableObjectNamespace<import("../src/index.js").WebAppDo> };
|
||||
type TestEnv = {
|
||||
WEBAPP_DO: DurableObjectNamespace<import("../src/index.js").WebAppDo>;
|
||||
MAP_STORE_DO: DurableObjectNamespace<import("../src/index.js").MapStoreDo>;
|
||||
};
|
||||
const testEnv = env as unknown as TestEnv;
|
||||
|
||||
const URL_ = "https://diigo.example/api/";
|
||||
@@ -22,79 +25,99 @@ afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
const fresh = () => testEnv.WEBAPP_DO.get(testEnv.WEBAPP_DO.newUniqueId());
|
||||
/** the worker's HTTP surface, as a JSON client */
|
||||
const call = (path: string, method: string, body?: unknown) =>
|
||||
worker.fetch(
|
||||
new Request(`https://webapp.example${path}`, {
|
||||
method,
|
||||
body: body === undefined ? undefined : JSON.stringify(body),
|
||||
headers: { "content-type": "application/json" },
|
||||
}),
|
||||
testEnv,
|
||||
);
|
||||
|
||||
const mint = async (init: WebAppInit) => {
|
||||
const { id, storeId } = (await (await call("/", "POST", init)).json()) as { id: string; storeId: string };
|
||||
const instance = testEnv.WEBAPP_DO.get(testEnv.WEBAPP_DO.idFromString(id));
|
||||
return { id, storeId, instance };
|
||||
};
|
||||
|
||||
/** the failure of a call: its error status, or what it threw; "" if it succeeds */
|
||||
async function errorOf(response: Promise<Response>): Promise<string> {
|
||||
try {
|
||||
const res = await response;
|
||||
return res.ok ? "" : `${res.status} ${await res.text()}`;
|
||||
} catch (err) {
|
||||
return String(err);
|
||||
}
|
||||
}
|
||||
|
||||
/** `init` as stored */
|
||||
const storedInit = (stub: DurableObjectStub) =>
|
||||
runInDurableObject(stub, (_obj, state) => state.storage.kv.get<WebAppInit>("init"));
|
||||
|
||||
describe("cookie rotation", () => {
|
||||
it("mint keeps the cookie out of init; sends carry it", async () => {
|
||||
it("mint puts the cookie in the header store, not init; sends carry it", async () => {
|
||||
const seen = spyCookies();
|
||||
const stub = fresh();
|
||||
await stub.configure({ url: URL_, headers: { Cookie: "session=a", accept: "application/json" } });
|
||||
expect(await storedInit(stub)).toEqual({ url: URL_, headers: { accept: "application/json" } });
|
||||
using app = await stub.handle();
|
||||
const { storeId, instance } = await mint({
|
||||
url: URL_,
|
||||
headers: { Cookie: "session=a", accept: "application/json" },
|
||||
});
|
||||
expect(await storedInit(instance)).toEqual({
|
||||
url: URL_,
|
||||
headers: { accept: "application/json" },
|
||||
headerStore: storeId,
|
||||
});
|
||||
using app = await instance.handle();
|
||||
await app.get();
|
||||
expect(seen).toEqual(["session=a"]);
|
||||
});
|
||||
|
||||
it("a rotation reaches handles already out", async () => {
|
||||
it("the store id rotates under handles already out, and revokes", async () => {
|
||||
const seen = spyCookies();
|
||||
const stub = fresh();
|
||||
await stub.configure({ url: URL_, headers: { cookie: "session=a" } });
|
||||
using app = await stub.handle();
|
||||
const { storeId, instance } = await mint({ url: URL_, headers: { cookie: "session=a" } });
|
||||
using app = await instance.handle();
|
||||
await app.pathjoin("x").get();
|
||||
using cookies = await stub.cookies();
|
||||
await cookies.set("cookie", "session=b");
|
||||
expect(await (await call(`/store/${storeId}/cookie`, "PUT", "session=b")).json()).toEqual({ ok: true });
|
||||
await app.pathjoin("x").get();
|
||||
expect(seen).toEqual(["session=a", "session=b"]);
|
||||
expect(await (await call(`/store/${storeId}/cookie`, "DELETE")).json()).toEqual({ ok: true });
|
||||
await app.get();
|
||||
expect(seen).toEqual(["session=a", "session=b", null]);
|
||||
});
|
||||
|
||||
it("an instance minted before rotation moves its cookie to the store on first use", async () => {
|
||||
it("the instance id reaches no store: its holder cannot swap in another session", async () => {
|
||||
const seen = spyCookies();
|
||||
const stub = fresh();
|
||||
// the shape configure() stored until the cookie store
|
||||
await runInDurableObject(stub, (_obj, state) =>
|
||||
state.storage.put("init", { url: URL_, headers: { cookie: "session=old" }, msPerCall: 0 }),
|
||||
);
|
||||
using app = await stub.handle();
|
||||
const { id, instance } = await mint({ url: URL_, headers: { cookie: "session=alice" } });
|
||||
expect(await errorOf(call(`/store/${id}/cookie`, "PUT", "session=mallory"))).not.toBe("");
|
||||
expect((await call(`/at/${id}/cookie`, "POST", { cookie: "session=mallory" })).status).toBe(404);
|
||||
using app = await instance.handle();
|
||||
await app.get();
|
||||
expect(await storedInit(stub)).toEqual({ url: URL_, headers: {}, msPerCall: 0 });
|
||||
using cookies = await stub.cookies();
|
||||
expect(await cookies.get("cookie")).toBe("session=old");
|
||||
await cookies.set("cookie", "session=new");
|
||||
using again = await stub.handle();
|
||||
await again.get();
|
||||
expect(seen).toEqual(["session=old", "session=new"]);
|
||||
expect(seen).toEqual(["session=alice"]);
|
||||
});
|
||||
|
||||
it("POST /at/<id>/cookie rotates, by the same id that drives", async () => {
|
||||
it("the store's shapes admit only the cookie; errors carry no values", async () => {
|
||||
const { storeId } = await mint({ url: URL_ });
|
||||
const err = await errorOf(call(`/store/${storeId}/authorization`, "PUT", "Bearer s3kr1t"));
|
||||
expect(err).toContain('cookie: key: must match \\"cookie\\"');
|
||||
expect(err).not.toContain("s3kr1t");
|
||||
expect(await errorOf(call(`/store/${storeId}/cookie`, "PUT", 42))).toContain("must match M.string()");
|
||||
});
|
||||
|
||||
it("an instance minted before header stores keeps its cookie in init, untouched", async () => {
|
||||
const seen = spyCookies();
|
||||
const id = testEnv.WEBAPP_DO.newUniqueId();
|
||||
const stub = testEnv.WEBAPP_DO.get(id);
|
||||
await stub.configure({ url: URL_ });
|
||||
const rotate = (cookie: string) =>
|
||||
worker.fetch(
|
||||
new Request(`https://webapp.example/at/${id}/cookie`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ cookie }),
|
||||
headers: { "content-type": "application/json" },
|
||||
}),
|
||||
testEnv,
|
||||
);
|
||||
expect(await (await rotate("session=first")).json()).toEqual({ ok: true });
|
||||
expect(await (await rotate("session=second")).json()).toEqual({ ok: true });
|
||||
using app = await stub.handle();
|
||||
const instance = testEnv.WEBAPP_DO.get(testEnv.WEBAPP_DO.newUniqueId());
|
||||
const old = { url: URL_, headers: { cookie: "session=old" }, msPerCall: 0 };
|
||||
await runInDurableObject(instance, (_obj, state) => state.storage.put("init", old));
|
||||
using app = await instance.handle();
|
||||
await app.get();
|
||||
expect(seen).toEqual(["session=second"]);
|
||||
expect(seen).toEqual(["session=old"]);
|
||||
expect(await storedInit(instance)).toEqual(old);
|
||||
});
|
||||
|
||||
it("a fixed header contradicting the stored cookie fails at send, naming no values", async () => {
|
||||
spyCookies();
|
||||
const stub = fresh();
|
||||
await stub.configure({ url: URL_, headers: { cookie: "session=a" } });
|
||||
using app = await stub.handle();
|
||||
const { instance } = await mint({ url: URL_, headers: { cookie: "session=a" } });
|
||||
using app = await instance.handle();
|
||||
const err = await app
|
||||
.withHeader("cookie", "session=mine")
|
||||
.get()
|
||||
|
||||
@@ -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"] }
|
||||
]
|
||||
}
|
||||
Reference in new issue
Block a user