capsite/webapp: the cookie's store is the owner's, apart from the instance
Letting the instance id rotate the cookie let its holder (the cache, or
whoever learns the id) swap in another account's session, which the
owner's logout would not stop. Now mint makes two things: the instance,
and a header store (lib's MapStoreDo, shaped ("cookie", { keyShape:
"cookie", valueShape: M.string() })). The instance id is authority to
use the cookie; the store id, for the account owner, to rotate (PUT
/store/<storeId>/cookie, or WebAppFactory.store) or revoke (DELETE).
Instances read the store's read-only view at every send, via init's new
headerStore.
This replaces the in-instance cookie store and its migration: instances
minted earlier keep their cookie in init.headers, untouched, and simply
cannot rotate. The MapStoreDo class arrives as wrangler migration v2
(additive).
Generated-by: Claude Opus 5.5
This commit is contained in:
1 parent
357e4561c4
commit
3f711a5f4b
5 files changed
+219
-151
No files matched your search
+110
-86
@@ -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 {
|
||||
value = await request.json();
|
||||
} catch (err) {
|
||||
return Response.json({ error: `bad input: ${String(err)}` }, { status: 400 });
|
||||
}
|
||||
}
|
||||
try {
|
||||
({ cookie } = (await request.json()) as { cookie?: unknown });
|
||||
using full = await store.store();
|
||||
if (request.method === "DELETE") {
|
||||
if (await full.has(key)) await full.delete(key);
|
||||
} else if (await full.has(key)) {
|
||||
await full.set(key, value);
|
||||
} else {
|
||||
await full.init(key, value);
|
||||
}
|
||||
} catch (err) {
|
||||
return Response.json({ error: `bad input: ${String(err)}` }, { status: 400 });
|
||||
// store errors name shapes, never keys or values (see lib/src/store.ts)
|
||||
return Response.json({ error: String(err) }, { status: 400 });
|
||||
}
|
||||
if (typeof cookie !== "string" || !cookie) {
|
||||
return Response.json({ error: "bad input: cookie must be a non-empty string" }, { status: 400 });
|
||||
}
|
||||
using cookies = await instance.cookies();
|
||||
if (await cookies.has("cookie")) await cookies.set("cookie", cookie);
|
||||
else await cookies.init("cookie", cookie);
|
||||
// note: neither id nor cookie logged
|
||||
console.log({ event: "rotate" });
|
||||
// note: neither id nor value logged
|
||||
console.log({ event: "store", method: request.method });
|
||||
return Response.json({ ok: true });
|
||||
}
|
||||
|
||||
@@ -494,37 +518,37 @@ function unionQuery(base: string, params: Query): string {
|
||||
/**
|
||||
* the RPC service of the webapp worker. Flow:
|
||||
*
|
||||
* const { id, app } = await env.WEBAPP.newWebApp({
|
||||
* url: "https://api.github.com", headers: { authorization: `Bearer ${t}` },
|
||||
* const { id, storeId, app } = await env.WEBAPP.newWebApp({
|
||||
* url: "https://diigo.example/", headers: { cookie: c },
|
||||
* msPerCall: 250,
|
||||
* });
|
||||
* // hold id (persist it, hand it to workers you trust), then e.g.:
|
||||
* const text = await app.query({ per_page: 5 }).get();
|
||||
* // later, from another worker (same account) holding the id:
|
||||
* const app2 = env.WEBAPP.at(id);
|
||||
* // the account owner, holding storeId, after logging in again:
|
||||
* await (await env.WEBAPP.store(storeId)).set("cookie", c2);
|
||||
*/
|
||||
export class WebAppFactory extends WorkerEntrypoint {
|
||||
/** the same HTTP surface as the default export, over the entrypoint
|
||||
* (for service-binding callers that proxy pages, e.g. diigo-cache's
|
||||
* /_connect → this form) */
|
||||
async fetch(request: Request): Promise<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> {
|
||||
|
||||
Reference in new issue
Block a user