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