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
24 changed files with 5575 additions and 59 deletions

No files matched your search

+5 -4
View File
@@ -109,10 +109,11 @@ 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), by the store id
a new webapp id (keeping its cache id and public username), or minted alongside it and kept by the account owner: `PUT
does re-mint mean a new cache? Prefer re-wire — durable ids. /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).
+36 -17
View File
@@ -174,11 +174,13 @@ async function proxyMint(request: Request, env: Env): Promise<Response> {
const detail = (await res.text()).slice(0, 200); const detail = (await res.text()).slice(0, 200);
return home(env, { error: `mint said ${res.status}: ${detail}` }, null); return home(env, { error: `mint said ${res.status}: ${detail}` }, null);
} }
// webapp answers {id} (+ x-webapp-id); the id rides the header, the // webapp answers {id, storeId} (+ x-webapp-id, x-webapp-store-id);
// wire page is ours // the ids ride the headers, the wire page is ours
const id = res.headers.get("x-webapp-id"); const id = res.headers.get("x-webapp-id");
if (!id) return home(env, { error: "mint produced no id" }, null); if (!id) return home(env, { error: "mint produced no id" }, null);
return await wirePage(id); const storeId = res.headers.get("x-webapp-store-id");
if (!storeId) console.warn({ event: "mint", outcome: "no store id: this session cannot be rotated" });
return await wirePage(id, storeId);
} catch (err) { } catch (err) {
console.error({ event: "mint", error: String(err) }); console.error({ event: "mint", error: String(err) });
return home(env, { error: `mint failed: ${String(err)}` }, null); return home(env, { error: `mint failed: ${String(err)}` }, null);
@@ -188,22 +190,37 @@ async function proxyMint(request: Request, env: Env): Promise<Response> {
/** the credential-save helper, once per page (see ./pm-save.ts) */ /** the credential-save helper, once per page (see ./pm-save.ts) */
const PM_SAVE_JS = pmSave.toString(); const PM_SAVE_JS = pmSave.toString();
/** the suggested petname gets the holder's local hh:mm (the worker only /** the suggested petname (in the input with id `petnameId`) gets the
* knows UTC), so two saves the same day are told apart at a glance */ * holder's local hh:mm (the worker only knows UTC), so two saves the same
const PETNAME_TIME_JS = `{ * day are told apart at a glance */
const p = document.getElementById("petname"); function petnameTimeJs(petnameId: string): string {
return `{
const p = document.getElementById(${JSON.stringify(petnameId)});
const d = new Date(); const d = new Date();
p.value += " " + String(d.getHours()).padStart(2, "0") + ":" + String(d.getMinutes()).padStart(2, "0"); p.value += " " + String(d.getHours()).padStart(2, "0") + ":" + String(d.getMinutes()).padStart(2, "0");
}`; }`;
/** the onclick that hands pmSave its browser powers */
function pmSaveClick(value: string): string {
return `pmSave(this, ${JSON.stringify(value)}, { petname: document.getElementById("petname"), credentials: navigator.credentials, PasswordCredential: window.PasswordCredential, log: console })`;
} }
/** the wire step, with the freshly-minted webapp id pre-filled */ /** the onclick that hands pmSave its browser powers, saving under the
async function wirePage(webappId: string): Promise<Response> { * petname in the input with id `petnameId` */
function pmSaveClick(value: string, petnameId: string): string {
return `pmSave(this, ${JSON.stringify(value)}, { petname: document.getElementById(${JSON.stringify(petnameId)}), credentials: navigator.credentials, PasswordCredential: window.PasswordCredential, log: console })`;
}
/** the wire step, with the freshly-minted webapp id pre-filled, and the
* store id (rotating the cookie) for the account owner to keep */
async function wirePage(webappId: string, storeId: string | null): Promise<Response> {
const fingerprint = await nick(webappId); const fingerprint = await nick(webappId);
const storeSection = storeId
? `<h2>keep: the cookie's store id</h2>
<p>This one is yours alone, not the cache's: with it you can rotate the
diigo session (log in again, paste the new cookie) or revoke it. Fingerprint
<code>${esc(await nick(storeId))}</code>
<button type="button" onclick='navigator.clipboard.writeText(${JSON.stringify(storeId)}).then(() => this.textContent = "copied ✓")'>copy</button></p>
<p><label>your petname for it <input id="store-petname" autocomplete="off" size="30" value="${esc(`diigo cookie store ${await nick(storeId)}`)}"></label></p>
<script>${petnameTimeJs("store-petname")}</script>
<p><button type="button" onclick='${pmSaveClick(storeId, "store-petname")}'>save to password manager</button></p>`
: `<p><b>note:</b> webapp sent no store id, so this session's cookie cannot be rotated; when it expires, mint again.</p>`;
const body = `<!doctype html> const body = `<!doctype html>
<html><head><meta charset="utf-8"><title>diigo cache: step 2 — wire the cache</title></head> <html><head><meta charset="utf-8"><title>diigo cache: step 2 — wire the cache</title></head>
<body> <body>
@@ -212,8 +229,10 @@ async function wirePage(webappId: string): Promise<Response> {
<button type="button" onclick='navigator.clipboard.writeText(${JSON.stringify(webappId)}).then(() => this.textContent = "copied ✓")'>copy</button></p> <button type="button" onclick='navigator.clipboard.writeText(${JSON.stringify(webappId)}).then(() => this.textContent = "copied ✓")'>copy</button></p>
<p><label>your petname for it <input id="petname" autocomplete="off" size="30" value="${esc(`diigo webapp ${fingerprint}`)}"></label> <p><label>your petname for it <input id="petname" autocomplete="off" size="30" value="${esc(`diigo webapp ${fingerprint}`)}"></label>
<small>(private to you: the password-manager username; never sent to the server)</small></p> <small>(private to you: the password-manager username; never sent to the server)</small></p>
<script>${PETNAME_TIME_JS}</script> <script>${petnameTimeJs("petname")}</script>
<p><button type="button" onclick='${pmSaveClick(webappId)}'>save to password manager</button></p> <p><button type="button" onclick='${pmSaveClick(webappId, "petname")}'>save to password manager</button></p>
${storeSection}
<h2>wire</h2>
<p>choose the cache mode, then <b>wire</b> — the next page shows the <p>choose the cache mode, then <b>wire</b> — the next page shows the
<b>cache id</b> (copy that too), after which you land back on home:</p> <b>cache id</b> (copy that too), after which you land back on home:</p>
<form method="post" action="/_wire"> <form method="post" action="/_wire">
@@ -320,8 +339,8 @@ async function wiredPage(cacheId: string, note: string, hasPublic: boolean): Pro
<button type="button" onclick='navigator.clipboard.writeText(${JSON.stringify(cacheId)}).then(() => this.textContent = "copied ✓")'>copy</button></p> <button type="button" onclick='navigator.clipboard.writeText(${JSON.stringify(cacheId)}).then(() => this.textContent = "copied ✓")'>copy</button></p>
<p><label>your petname for it <input id="petname" autocomplete="off" size="30" value="${esc(`diigo cache ${fingerprint}`)}"></label> <p><label>your petname for it <input id="petname" autocomplete="off" size="30" value="${esc(`diigo cache ${fingerprint}`)}"></label>
<small>(private to you: the password-manager username; never sent to the server)</small></p> <small>(private to you: the password-manager username; never sent to the server)</small></p>
<script>${PETNAME_TIME_JS}</script> <script>${petnameTimeJs("petname")}</script>
<p><button type="button" onclick='${pmSaveClick(cacheId)}'>save to password manager</button></p> <p><button type="button" onclick='${pmSaveClick(cacheId, "petname")}'>save to password manager</button></p>
<p>${note}</p> <p>${note}</p>
<p><a href="/cache/#${esc(cacheId)}">open this cache</a> — its <b>webkey</b> <p><a href="/cache/#${esc(cacheId)}">open this cache</a> — its <b>webkey</b>
(bookmark it, or save the id above): fill it, watch progress${hasPublic ? ", fill its public companion" : ""}.</p> (bookmark it, or save the id above): fill it, watch progress${hasPublic ? ", fill its public companion" : ""}.</p>
+146
View File
@@ -0,0 +1,146 @@
<!doctype html>
<html><head><meta charset="utf-8">
<title>webapp cookie rotation: who holds what</title>
<!-- drawn after the style of MarkM's ERTP slides; cookie-rotation-wiring.png is a headless-Chromium screenshot of this page -->
<style>
html, body { margin: 0; background: #fff; }
.sans { font-family: "Helvetica Neue", "DejaVu Sans", sans-serif; }
.obj { font-family: "DejaVu Serif", serif; font-style: italic; }
.mono { font-family: "DejaVu Sans Mono", monospace; }
</style></head>
<body>
<svg id="c" xmlns="http://www.w3.org/2000/svg" width="1120" height="820" viewBox="0 0 1120 820"
role="img" aria-labelledby="title desc">
<title id="title">webapp cookie rotation: who holds what</title>
<desc id="desc">Three actors and two makers. Alice holds a reference to a store made by
makeMapStore, and to diigo-cache, which she wired up. She rotates the cookie
by saying: await E(store).set("cookie", freshCookie). The store, and its
read-only facets, both refer to the store's kv table, whose one row maps
"cookie" to the session; its shapes are keyShape "cookie" and valueShape
M.string(). makeWebApp makes access objects; each refers to its init
table: url, headers, and headerStore. headerStore refers to the store's
read-only facet, which access reads at every send. diigo-cache holds a
reference to access. Mallory, who learned the instance id, also holds a
reference to access, so she can use the cookie, but she has no reference
to the store, so she cannot rotate it or swap in her own session.</desc>
<defs>
<radialGradient id="ball" cx="40%" cy="35%" r="70%">
<stop offset="0%" stop-color="#7cc8f8"/>
<stop offset="100%" stop-color="#3aa6ee"/>
</radialGradient>
<linearGradient id="objfill" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stop-color="#6cbcf4"/>
<stop offset="100%" stop-color="#4aa8ec"/>
</linearGradient>
<marker id="head" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="9" markerHeight="9" orient="auto-start-reverse">
<path d="M0,0 L10,5 L0,10 z" fill="#1d4f7a"/>
</marker>
</defs>
</svg>
<script>
const svg = document.getElementById("c");
const NS = "http://www.w3.org/2000/svg";
const el = (name, attrs, parent = svg) => {
const e = document.createElementNS(NS, name);
for (const [k, v] of Object.entries(attrs)) e.setAttribute(k, v);
parent.appendChild(e);
return e;
};
const text = (x, y, s, cls, attrs = {}) => {
const t = el("text", { x, y, class: cls, "text-anchor": "middle", fill: "#1d3550", ...attrs });
t.textContent = s;
return t;
};
const INK = "#1d4f7a";
const dot = (x, y) => el("circle", { cx: x, cy: y, r: 4.5, fill: INK });
/** a reference: dot at the holder, arrowhead at the target */
const ref = (x1, y1, x2, y2, bend = 0, extra = {}) => {
dot(x1, y1);
const mx = (x1 + x2) / 2 - (y2 - y1) * bend, my = (y1 + y2) / 2 + (x2 - x1) * bend;
el("path", { d: `M${x1},${y1} Q${mx},${my} ${x2},${y2}`, fill: "none", stroke: INK, "stroke-width": 2, "marker-end": "url(#head)", ...extra });
};
// maker box: a pale rounded region, with stacked instances inside
function maker(x, y, w, h, label) {
el("rect", { x, y, width: w, height: h, rx: 26, fill: "#d6ecfb", stroke: "#b5dbf5", "stroke-width": 1.5 });
text(x + 18, y + 30, label, "obj", { "text-anchor": "start", "font-size": 20 });
for (let i = 2; i >= 0; i -= 1) {
el("rect", {
x: x + 18 + i * 9, y: y + 48 + i * 9, width: w - 52, height: h - 82, rx: 22,
fill: "#c8e5fa", stroke: "#8ec9f2", "stroke-width": 1.5, "fill-opacity": 0.9,
});
}
}
// an object: rounded square, optionally stacked (several made)
function obj(x, y, w, h, label, stacked = false) {
const n = stacked ? 3 : 1;
for (let i = n - 1; i >= 0; i -= 1) {
el("rect", { x: x + i * 7, y: y - i * 7, width: w, height: h, rx: 12, fill: "url(#objfill)", stroke: "#2f8fd6", "stroke-width": 1.5 });
}
text(x + w / 2, y + h / 2 + 7, label, "obj", { "font-size": 19 });
}
function actor(cx, cy, label) {
el("circle", { cx, cy, r: 52, fill: "url(#ball)", stroke: "#2f8fd6", "stroke-width": 1.5 });
text(cx, cy + 6, label, "sans", { "font-size": label.length > 8 ? 15 : 17 });
}
// a table like the ERTP ledger
function table(x, y, title, rows, colW) {
const rowH = 28, w = colW[0] + colW[1];
el("rect", { x, y, width: w, height: 36 + rowH * rows.length, fill: "#5fb5f2", stroke: "#2f8fd6", "stroke-width": 1.5 });
text(x + w / 2, y + 25, title, "obj", { "font-size": 18 });
rows.forEach(([k, v], i) => {
const ry = y + 36 + i * rowH;
el("line", { x1: x, y1: ry, x2: x + w, y2: ry, stroke: "#2f8fd6", "stroke-width": 1.2 });
text(x + 10, ry + 19, k, "mono", { "text-anchor": "start", "font-size": 14 });
if (v) text(x + colW[0] + 10, ry + 19, v, "mono", { "text-anchor": "start", "font-size": 14 });
});
el("line", { x1: x + colW[0], y1: y + 36, x2: x + colW[0], y2: y + 36 + rowH * rows.length, stroke: "#1d4f7a", "stroke-width": 1.5 });
return { rowY: (i) => y + 36 + i * rowH + rowH / 2, x, w, colX: x + colW[0] };
}
// speech bubble (Alice rotates after logging in again)
el("path", {
d: "M30,22 h470 a16,16 0 0 1 16,16 v34 a16,16 0 0 1 -16,16 h-330 l-38,48 l6,-48 h-102 a16,16 0 0 1 -16,-16 v-34 a16,16 0 0 1 16,-16 z",
fill: "#d9d9d9", stroke: "#555", "stroke-width": 2,
});
const code = el("text", { x: 46, y: 62, class: "mono", "font-size": 17, fill: "#222" });
code.innerHTML = '<tspan fill="#b0177b">await</tspan> E(store).set(<tspan fill="#1a7f37">"cookie"</tspan>, freshCookie);';
// actors
actor(150, 200, "Alice");
actor(560, 200, "diigo-cache");
actor(930, 200, "Mallory");
// makers
maker(30, 320, 470, 470, "makeMapStore");
maker(560, 320, 530, 470, "makeWebApp");
// MapStoreDo insides
obj(80, 420, 120, 80, "store");
obj(80, 600, 120, 80, "readOnly", true);
const kv = table(260, 430, "kv", [["cookie", "session=…"], ["", ""], ["", ""], ["", ""], ["", ""]], [90, 110]);
// WebAppDo insides
obj(620, 560, 140, 90, "access", true);
const init = table(830, 420, "init", [["url", "diigo…"], ["headers", "{…}"], ["headerStore", ""], ["", ""]], [130, 85]);
// references
ref(140, 228, 125, 418, 0.05); // Alice → store
ref(172, 224, 508, 210, 0.04); // Alice → diigo-cache (she wired it)
ref(560 - 6, 200 + 18, 660, 553, 0.12); // cache → access
ref(930 - 14, 200 + 20, 720, 553, -0.08); // Mallory → access
ref(160, 470, kv.x - 2, kv.rowY(0), 0.05); // store → kv
ref(150, 666, kv.x - 2, kv.rowY(0) + 6, -0.25); // readOnly → kv (read)
ref(760, 600, init.x - 2, init.rowY(2), -0.1); // access → init
ref(init.colX + 40, init.rowY(2), 208, 640, -0.28); // headerStore → readOnly
// captions
const cap = (x, y, s, attrs = {}) => text(x, y, s, "sans", { "font-size": 14, fill: "#33536f", ...attrs });
cap(930, 118, "holds the instance id:", {});
cap(930, 136, "uses the cookie, can't reach store", {});
cap(330, 300, "storeId: rotate · revoke", {});
cap(760, 806, "init.headerStore → the store's read-only facet, read at every send", {});
cap(265, 806, "shapes: keyShape \"cookie\", valueShape M.string()", {});
</script>
</body></html>
Binary file not shown.

After

Width:  |  Height:  |  Size: 113 KiB

+16
View File
@@ -0,0 +1,16 @@
{
"name": "capsite-lib",
"private": true,
"type": "module",
"scripts": {
"types": "wrangler types -c wrangler.tests.jsonc",
"lint": "wrangler types -c wrangler.tests.jsonc --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
+129
View File
@@ -0,0 +1,129 @@
/**
* @file a work-alike for enough of @endo/patterns: `M.string()`, `M.any()`,
* and literal patterns (a string, number, boolean, or null matches only
* itself, so `"cookie"` as a key shape admits exactly one key). Matchers
* are the same data endo makes: tagged records, as from @endo/pass-style's
* `makeTagged` (frozen here, where endo would harden).
*
* Structured clone (DO storage, Workers RPC) drops a tagged record's
* symbol-keyed and non-enumerable properties, so patterns cross those
* boundaries in endo's smallcaps encoding: see {@link toSmallcaps}.
* @see https://github.com/endojs/endo/tree/master/packages/patterns
* @see https://github.com/endojs/endo/blob/master/packages/marshal/src/encodeToSmallcaps.js
*/
const PASS_STYLE = Symbol.for("passStyle");
export type Tagged = Readonly<{ [Symbol.toStringTag]: string; payload: unknown }>;
export type Pattern = Tagged | string | number | boolean | null;
/** @endo/pass-style's `makeTagged` */
export function makeTagged(tag: string, payload: unknown): Tagged {
return Object.freeze(
Object.create(Object.prototype, {
[PASS_STYLE]: { value: "tagged" },
[Symbol.toStringTag]: { value: tag },
payload: { value: payload, enumerable: true },
}),
) as Tagged;
}
const isTagged = (x: unknown): x is Tagged =>
typeof x === "object" && x !== null && (x as Record<symbol, unknown>)[PASS_STYLE] === "tagged";
const StringShape = makeTagged("match:string", Object.freeze([]));
const AnyShape = makeTagged("match:any", undefined);
export const M = Object.freeze({
string: (): Tagged => StringShape,
any: (): Tagged => AnyShape,
});
export function matches(specimen: unknown, pattern: Pattern): boolean {
if (typeof pattern === "object" && pattern !== null) {
const tag = isTagged(pattern) ? pattern[Symbol.toStringTag] : undefined;
switch (tag) {
case "match:string":
return typeof specimen === "string";
case "match:any":
return true;
default:
throw new TypeError("not a pattern");
}
}
return Object.is(specimen, pattern);
}
/** the pattern as written in code, e.g. `M.string()` or `"cookie"` */
export function describe(pattern: Pattern): string {
if (typeof pattern === "object" && pattern !== null) {
return `M.${pattern[Symbol.toStringTag].replace(/^match:/, "")}()`;
}
return JSON.stringify(pattern);
}
/**
* throw unless `specimen` matches `pattern`. Unlike endo's, the message
* names the pattern and `label` but never the specimen: specimens here
* can be credentials, and messages cross RPC and land in logs.
*/
export function mustMatch(specimen: unknown, pattern: Pattern, label: string): void {
if (!matches(specimen, pattern)) {
throw new TypeError(`${label}: must match ${describe(pattern)}`);
}
}
/** a smallcaps-encoded passable, as JSON-able data */
export type Smallcaps = null | boolean | number | string | Smallcaps[] | { "#tag": string; payload: Smallcaps };
/** smallcaps quotes strings that start with one of these with `!` */
const startsSpecial = (s: string) => /^[!-\-]/.test(s);
/**
* the subset of @endo/marshal's smallcaps encoding that patterns here use:
* primitives, arrays, and tagged records (not a whole CapData: no `#`
* body prefix, no slots)
*/
export function toSmallcaps(passable: unknown): Smallcaps {
if (passable === undefined) return "#undefined";
if (passable === null || typeof passable === "boolean") return passable;
if (typeof passable === "number") {
if (Number.isNaN(passable)) return "#NaN";
if (passable === Infinity) return "#Infinity";
if (passable === -Infinity) return "#-Infinity";
return Object.is(passable, -0) ? 0 : passable;
}
if (typeof passable === "string") return startsSpecial(passable) ? `!${passable}` : passable;
if (Array.isArray(passable)) return passable.map(toSmallcaps);
if (isTagged(passable)) {
return { "#tag": toSmallcaps(passable[Symbol.toStringTag]) as string, payload: toSmallcaps(passable.payload) };
}
throw new TypeError("toSmallcaps: unsupported passable");
}
/** the inverse of {@link toSmallcaps} */
export function fromSmallcaps(encoding: Smallcaps): unknown {
if (encoding === null || typeof encoding === "boolean" || typeof encoding === "number") return encoding;
if (typeof encoding === "string") {
if (encoding.startsWith("!")) return encoding.slice(1);
switch (encoding) {
case "#undefined":
return undefined;
case "#NaN":
return NaN;
case "#Infinity":
return Infinity;
case "#-Infinity":
return -Infinity;
}
if (startsSpecial(encoding)) throw new TypeError("fromSmallcaps: unsupported special string");
return encoding;
}
if (Array.isArray(encoding)) return Object.freeze(encoding.map(fromSmallcaps));
if (typeof encoding === "object" && "#tag" in encoding) {
const { "#tag": tag, payload, ...rest } = encoding;
if (Object.keys(rest).length > 0) throw new TypeError("fromSmallcaps: extra properties on a tagged record");
return makeTagged(fromSmallcaps(tag) as string, fromSmallcaps(payload));
}
throw new TypeError("fromSmallcaps: unsupported encoding");
}
+60
View File
@@ -0,0 +1,60 @@
/**
* @file a MapStore as a Durable Object of its own, so a store can be held
* apart from whoever reads it: the DO id is the store's full authority
* (e.g. the account owner's, to rotate a credential), while a reader is
* handed only {@link MapStoreDo.readOnly}.
*/
import { DurableObject } from "cloudflare:workers";
import { fromSmallcaps, matches, toSmallcaps, type Pattern, type Smallcaps } from "./patterns.js";
import { makeMapStore, type MapStore, type ReadonlyMapStore, type StoreShapes } from "./store.js";
/** store shapes as they cross RPC and sit in storage: smallcaps-encoded
* (structured clone would strip a matcher down to its payload) */
export type EncodedShapes = { keyShape?: Smallcaps; valueShape?: Smallcaps };
export const encodeShapes = ({ keyShape, valueShape }: StoreShapes): EncodedShapes => ({
...(keyShape === undefined ? {} : { keyShape: toSmallcaps(keyShape) }),
...(valueShape === undefined ? {} : { valueShape: toSmallcaps(valueShape) }),
});
const decodeShapes = ({ keyShape, valueShape }: EncodedShapes): StoreShapes => ({
...(keyShape === undefined ? {} : { keyShape: fromSmallcaps(keyShape) as Pattern }),
...(valueShape === undefined ? {} : { valueShape: fromSmallcaps(valueShape) as Pattern }),
});
type Config = { label: string; shapes: EncodedShapes };
/** a store whose label and shapes its creator fixes, once */
export class MapStoreDo extends DurableObject {
/** one-shot: fix the label and shapes (see {@link encodeShapes}); an
* identical re-configure is a no-op */
configure(label: string, shapes: EncodedShapes): void {
const decoded = decodeShapes(shapes);
for (const shape of [decoded.keyShape, decoded.valueShape]) {
if (shape !== undefined) matches(undefined, shape); // throws on a non-pattern
}
makeMapStore(label, decoded, this.ctx.storage.kv); // throws on a bad label
const config: Config = { label, shapes: encodeShapes(decoded) };
const existing = this.ctx.storage.kv.get<Config>("config");
if (existing) {
if (JSON.stringify(existing) !== JSON.stringify(config)) {
throw new Error("already configured with a different label or shapes");
}
return;
}
this.ctx.storage.kv.put("config", config);
}
/** the store, with full authority */
store(): MapStore<unknown> {
const config = this.ctx.storage.kv.get<Config>("config");
if (!config) throw new Error("not configured: this store was never set up");
return makeMapStore(config.label, decodeShapes(config.shapes), this.ctx.storage.kv);
}
/** the store's queries alone */
readOnly(): ReadonlyMapStore<unknown> {
return this.store().readOnly();
}
}
+133
View File
@@ -0,0 +1,133 @@
/**
* @file a work-alike for enough of @agoric/store's MapStore: string keys,
* shapes checked with ./patterns.js, entries kept in a Durable Object's
* synchronous KV storage. The facets are RpcTargets (workerd's / capnweb's,
* in place of endo's `Far`), so a store or its read-only view can be
* handed out by reference.
* @see https://github.com/Agoric/agoric-sdk/tree/master/packages/store
*/
import { RpcTarget } from "cloudflare:workers";
import { M, mustMatch, type Pattern } from "./patterns.js";
/** the storage a store needs: a DO's `ctx.storage.kv`, or a stand-in */
export type Kv = Pick<SyncKvStorage, "get" | "put" | "delete" | "list">;
export type StoreShapes = { keyShape?: Pattern; valueShape?: Pattern };
/**
* Entries live under `store:<label>:<key>`, so the label also names the
* storage area (as a baggage key does for endo's durable stores): two
* stores with one label on one Kv are the same store.
*
* Error messages name the label and shapes, never keys or values.
*/
export function makeMapStore<V>(label: string, { keyShape = M.string(), valueShape = M.any() }: StoreShapes, kv: Kv) {
if (label.includes(":")) throw new TypeError("store label must not contain ':'");
const prefix = `store:${label}:`;
const core: Core<V> = {
label,
has: (key) => kv.get(prefix + key) !== undefined,
get: (key) => kv.get<V>(prefix + key),
put: (key, value) => kv.put(prefix + key, value),
delete: (key) => {
kv.delete(prefix + key);
},
entries: () => [...kv.list<V>({ prefix })].map(([k, v]): [string, V] => [k.slice(prefix.length), v]),
checkKey: (key) => mustMatch(key, keyShape, `${label}: key`),
checkValue: (value) => mustMatch(value, valueShape, `${label}: value`),
};
return new MapStore<V>(core);
}
type Core<V> = {
label: string;
has(key: string): boolean;
get(key: string): V | undefined;
put(key: string, value: V): void;
delete(key: string): void;
entries(): [string, V][];
checkKey(key: unknown): void;
checkValue(value: unknown): void;
};
/** the queries */
function reads<V>(core: Core<V>) {
return {
has: (key: string): boolean => core.has(key),
get: (key: string): V => {
const value = core.get(key);
if (value === undefined) throw new RangeError(`${core.label}: key not found`);
return value;
},
keys: (): string[] => core.entries().map(([k]) => k),
values: (): V[] => core.entries().map(([, v]) => v),
entries: (): [string, V][] => core.entries(),
getSize: (): number => core.entries().length,
};
}
/** a store's queries only: what a reader of a rotating credential holds */
export class ReadonlyMapStore<V> extends RpcTarget {
#reads: ReturnType<typeof reads<V>>;
constructor(core: Core<V>) {
super();
this.#reads = reads(core);
}
has(key: string): boolean {
return this.#reads.has(key);
}
get(key: string): V {
return this.#reads.get(key);
}
keys(): string[] {
return this.#reads.keys();
}
values(): V[] {
return this.#reads.values();
}
entries(): [string, V][] {
return this.#reads.entries();
}
getSize(): number {
return this.#reads.getSize();
}
}
/** a store's full authority: the queries plus `init`/`set`/`delete`, and
* {@link MapStore.readOnly} to hand out the queries alone */
export class MapStore<V> extends ReadonlyMapStore<V> {
#core: Core<V>;
constructor(core: Core<V>) {
super(core);
this.#core = core;
}
/** add a key that is not yet present */
init(key: string, value: V): void {
this.#core.checkKey(key);
this.#core.checkValue(value);
if (this.#core.has(key)) throw new RangeError(`${this.#core.label}: key already registered`);
this.#core.put(key, value);
}
/** replace the value of a key that is present */
set(key: string, value: V): void {
this.#core.checkKey(key);
this.#core.checkValue(value);
if (!this.#core.has(key)) throw new RangeError(`${this.#core.label}: key not found`);
this.#core.put(key, value);
}
delete(key: string): void {
if (!this.#core.has(key)) throw new RangeError(`${this.#core.label}: key not found`);
this.#core.delete(key);
}
readOnly(): ReadonlyMapStore<V> {
return new ReadonlyMapStore(this.#core);
}
}
+142
View File
@@ -0,0 +1,142 @@
import { env } from "cloudflare:workers";
import { evictDurableObject } from "cloudflare:test";
import { describe, expect, it } from "vitest";
import { M, fromSmallcaps, makeTagged, matches, mustMatch, toSmallcaps } from "../src/patterns.js";
import { encodeShapes } from "../src/store-do.js";
type TestEnv = {
STORE_DO: DurableObjectNamespace<import("./test-worker.js").StoreHost>;
MAP_STORE_DO: DurableObjectNamespace<import("../src/store-do.js").MapStoreDo>;
};
const testEnv = env as unknown as TestEnv;
const SECRET = "session=s3kr1t";
/** the message an RPC call fails with, or "" if it succeeds */
async function errorOf(call: Promise<unknown>): Promise<string> {
try {
await call;
return "";
} catch (err) {
return String(err);
}
}
describe("patterns", () => {
it("M.string() matches strings only; a literal matches only itself", () => {
expect(matches("x", M.string())).toBe(true);
expect(matches(1, M.string())).toBe(false);
expect(matches(undefined, M.any())).toBe(true);
expect(matches("cookie", "cookie")).toBe(true);
expect(matches("authorization", "cookie")).toBe(false);
});
it("matchers are endo's tagged records", () => {
const s = M.string() as unknown as Record<string | symbol, unknown>;
expect(s[Symbol.for("passStyle")]).toBe("tagged");
expect(s[Symbol.toStringTag]).toBe("match:string");
expect(Object.keys(s)).toEqual(["payload"]);
expect(s.payload).toEqual([]);
expect((M.any() as unknown as Record<string, unknown>).payload).toBeUndefined();
expect(Object.isFrozen(s)).toBe(true);
});
it("smallcaps: endo's encoding, round-tripped", () => {
expect(toSmallcaps(M.string())).toEqual({ "#tag": "match:string", payload: [] });
expect(toSmallcaps(M.any())).toEqual({ "#tag": "match:any", payload: "#undefined" });
expect(toSmallcaps("#cookie")).toBe("!#cookie");
expect(toSmallcaps([NaN, -0, Infinity])).toEqual(["#NaN", 0, "#Infinity"]);
const back = fromSmallcaps(toSmallcaps(M.string())) as Parameters<typeof matches>[1];
expect(matches("x", back)).toBe(true);
expect(matches(1, back)).toBe(false);
expect(fromSmallcaps("!#cookie")).toBe("#cookie");
expect(() => matches("x", makeTagged("match:nope", []))).toThrow("not a pattern");
});
it("mustMatch names the shape but not the specimen", () => {
expect(() => mustMatch(SECRET, "cookie", "jar: key")).toThrow('jar: key: must match "cookie"');
expect(() => mustMatch(SECRET, "cookie", "jar: key")).not.toThrow(SECRET);
});
});
describe("MapStore over RPC", () => {
const fresh = () => testEnv.STORE_DO.get(testEnv.STORE_DO.newUniqueId());
it("rotates: the admin's set is what the read-only view sees next", async () => {
const store = await fresh().cookies();
const rd = await store.readOnly();
await store.init("cookie", "session=old");
expect(await rd.get("cookie")).toBe("session=old");
await store.set("cookie", "session=new");
expect(await rd.entries()).toEqual([["cookie", "session=new"]]);
});
it("the read-only view has no write methods", async () => {
const store = await fresh().cookies();
const rd = (await store.readOnly()) as unknown as { set(k: string, v: string): Promise<void> };
expect(await errorOf(rd.set("cookie", SECRET))).not.toBe("");
expect(await store.has("cookie")).toBe(false);
});
it("the key shape admits only `cookie`; errors carry no values", async () => {
const store = await fresh().cookies();
const err = await errorOf(store.init("authorization", SECRET));
expect(err).toContain('cookie: key: must match "cookie"');
expect(err).not.toContain(SECRET);
expect(await errorOf(store.init("cookie", 42 as unknown as string))).toContain("cookie: value: must match M.string()");
});
it("init refuses a present key; set and get refuse a missing one", async () => {
const store = await fresh().cookies();
expect(await errorOf(store.set("cookie", SECRET))).toContain("cookie: key not found");
expect(await errorOf(store.get("cookie"))).toContain("cookie: key not found");
await store.init("cookie", SECRET);
const err = await errorOf(store.init("cookie", SECRET));
expect(err).toContain("cookie: key already registered");
expect(err).not.toContain(SECRET);
});
it("survives eviction: entries live in DO storage", async () => {
const stub = fresh();
{
// disposed before eviction: a live store stub holds the DO open
using store = await stub.cookies();
await store.init("cookie", "session=durable");
}
await evictDurableObject(stub);
using again = await stub.cookies();
expect(await again.get("cookie")).toBe("session=durable");
expect(await again.getSize()).toBe(1);
await again.delete("cookie");
expect(await again.keys()).toEqual([]);
});
});
describe("MapStoreDo", () => {
const COOKIE_SHAPES = encodeShapes({ keyShape: "cookie", valueShape: M.string() });
const fresh = () => testEnv.MAP_STORE_DO.get(testEnv.MAP_STORE_DO.newUniqueId());
it("keeps the shapes its creator chose, across eviction", async () => {
const stub = fresh();
await stub.configure("cookie", COOKIE_SHAPES);
await evictDurableObject(stub);
{
using store = await stub.store();
expect(await errorOf(store.init("authorization", SECRET))).toContain('cookie: key: must match "cookie"');
expect(await errorOf(store.init("cookie", 42))).toContain("cookie: value: must match M.string()");
await store.init("cookie", "session=a");
}
using rd = await stub.readOnly();
expect(await rd.entries()).toEqual([["cookie", "session=a"]]);
});
it("configures once: the same shapes again is a no-op, others are refused", async () => {
const stub = fresh();
await stub.configure("cookie", COOKIE_SHAPES);
await stub.configure("cookie", encodeShapes({ keyShape: "cookie", valueShape: M.string() }));
expect(await errorOf(stub.configure("cookie", encodeShapes({ keyShape: M.string() })))).toContain(
"already configured",
);
expect(await errorOf(fresh().store())).toContain("not configured");
});
});
+17
View File
@@ -0,0 +1,17 @@
/** @file the tests' main module: a DO that hosts stores on its own storage. */
import { DurableObject } from "cloudflare:workers";
import { M } from "../src/patterns.js";
import { makeMapStore, type MapStore } from "../src/store.js";
export { MapStoreDo } from "../src/store-do.js";
/** hosts the store from the credential-rotation sketch: one key, `cookie` */
export class StoreHost extends DurableObject {
cookies(): MapStore<string> {
return makeMapStore<string>("cookie", { keyShape: "cookie", valueShape: M.string() }, this.ctx.storage.kv);
}
}
export default {
fetch: () => new Response("store tests", { status: 404 }),
};
+7
View File
@@ -0,0 +1,7 @@
{
"extends": "../tsconfig.json",
"compilerOptions": {
"types": ["@cloudflare/vitest-plugin/types"]
},
"include": ["./**/*", "../src/**/*", "../worker-configuration.d.ts"]
}
+15
View File
@@ -0,0 +1,15 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "preserve",
"moduleResolution": "bundler",
"strict": true,
"esModuleInterop": true,
"verbatimModuleSyntax": true,
"skipLibCheck": true,
"lib": ["ES2022", "ESNext.Disposable"],
"noEmit": true,
"types": []
},
"include": ["src/**/*", "worker-configuration.d.ts"]
}
+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.tests.jsonc" } })],
test: {
include: ["tests/**/*.test.ts"],
},
});
+17
View File
@@ -0,0 +1,17 @@
{
"$schema": "node_modules/wrangler/config-schema.json",
// a host DO for the stores under test (lib ships no worker of its own)
"name": "capsite-lib-tests",
"main": "./tests/test-worker.ts",
"compatibility_date": "2026-09-29",
"durable_objects": {
"bindings": [
{ "name": "STORE_DO", "class_name": "StoreHost" },
{ "name": "MAP_STORE_DO", "class_name": "MapStoreDo" }
]
},
"exports": {
"StoreHost": { "type": "durable-object", "storage": "sqlite" },
"MapStoreDo": { "type": "durable-object", "storage": "sqlite" }
}
}
+28 -2
View File
@@ -12,19 +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).
- `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
- `store(storeId)` — a header store, with full authority:
`set("cookie", …)` rotates the cookie under every handle already out
## The header store: two ids, two authorities
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 ## Run
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)
+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
+163 -34
View File
@@ -7,19 +7,31 @@
// 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 { 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>;
/** 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. An instance is immutable
* authority: no re-config methods, only attenuation via the fluent chain. * 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 { 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 */
@@ -29,6 +41,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 +70,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 +101,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 +121,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,11 +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).
* *
* 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 * @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 */
@@ -207,16 +247,42 @@ export class WebAppDo extends DurableObject<{ WEBAPP?: never }> {
} }
return res.text(); return res.text();
}; };
return new WebApp(init.url, init.headers ?? {}, send); const maps = init.headerStore === undefined ? [] : [this.#readHeaderStore(init.headerStore)];
return new WebApp(init.url, init.headers ?? {}, maps, send);
}
/** 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();
},
};
} }
} }
/** 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 };
}
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
@@ -227,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
@@ -246,6 +312,14 @@ const defaultFetch = async (
return drive(await app, request); return drive(await app, 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 }); return new Response("not found", { status: 404 });
}; };
@@ -305,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 {
@@ -335,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
@@ -368,6 +461,35 @@ async function drive(app: FluentGet, request: Request): Promise<Response> {
} }
} }
/** 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 {
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 });
}
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)) {
@@ -396,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 */
@@ -434,6 +556,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();
} }
/** 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> { function sleep(ms: number): Promise<void> {
return new Promise((r) => setTimeout(r, ms)); return new Promise((r) => setTimeout(r, ms));
+131
View File
@@ -0,0 +1,131 @@
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>;
MAP_STORE_DO: DurableObjectNamespace<import("../src/index.js").MapStoreDo>;
};
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();
});
/** 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 puts the cookie in the header store, not init; sends carry it", async () => {
const seen = spyCookies();
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("the store id rotates under handles already out, and revokes", async () => {
const seen = spyCookies();
const { storeId, instance } = await mint({ url: URL_, headers: { cookie: "session=a" } });
using app = await instance.handle();
await app.pathjoin("x").get();
expect(await (await call(`/store/${storeId}/cookie`, "PUT", "session=b")).json()).toEqual({ ok: true });
await app.pathjoin("x").get();
expect(await (await call(`/store/${storeId}/cookie`, "DELETE")).json()).toEqual({ ok: true });
await app.get();
expect(seen).toEqual(["session=a", "session=b", null]);
});
it("the instance id reaches no store: its holder cannot swap in another session", async () => {
const seen = spyCookies();
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(seen).toEqual(["session=alice"]);
});
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 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=old"]);
expect(await storedInit(instance)).toEqual(old);
});
it("a fixed header contradicting the stored cookie fails at send, naming no values", async () => {
spyCookies();
const { instance } = await mint({ url: URL_, headers: { cookie: "session=a" } });
using app = await instance.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"],
},
});
+8 -2
View File
@@ -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"] }
]
} }