Compare commits
9
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ff2e7841f2 | ||
|
|
7876938f3e | ||
|
|
a921d65021 | ||
|
|
055b572297 | ||
|
|
4dd3fe6bde | ||
|
|
3f711a5f4b | ||
|
|
357e4561c4 | ||
|
|
10414c2ce6 | ||
|
|
9179bdbf38 |
No files matched your search
@@ -109,10 +109,11 @@ narrative.
|
||||
weekly page renders against prod.
|
||||
- [ ] **6. Cookie expiry**: when diigo's session ends, head refreshes
|
||||
fail; the public page keeps serving what's stored (say so on the
|
||||
page, don't fail silently). Rotation is manual: re-mint via
|
||||
`/_connect`. Open question: can an existing cache be re-wired to
|
||||
a new webapp id (keeping its cache id and public username), or
|
||||
does re-mint mean a new cache? Prefer re-wire — durable ids.
|
||||
page, don't fail silently). Rotation is in place, keeping the
|
||||
webapp id (so the cache's wiring and ids hold), by the store id
|
||||
minted alongside it and kept by the account owner: `PUT
|
||||
/store/<storeId>/cookie` (webapp README, "The header store").
|
||||
Still open: a rotate form in diigo-cache's UI.
|
||||
- [ ] **7. bib sync #195**: repeat the pattern for the next
|
||||
read-access need (webapp mints for any origin; the cache's
|
||||
item shape is diigo-specific): [the zotero goal tree](zotero-checklist.md).
|
||||
|
||||
+36
-17
@@ -174,11 +174,13 @@ async function proxyMint(request: Request, env: Env): Promise<Response> {
|
||||
const detail = (await res.text()).slice(0, 200);
|
||||
return home(env, { error: `mint said ${res.status}: ${detail}` }, null);
|
||||
}
|
||||
// webapp answers {id} (+ x-webapp-id); the id rides the header, the
|
||||
// wire page is ours
|
||||
// webapp answers {id, storeId} (+ x-webapp-id, x-webapp-store-id);
|
||||
// the ids ride the headers, the wire page is ours
|
||||
const id = res.headers.get("x-webapp-id");
|
||||
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) {
|
||||
console.error({ event: "mint", error: String(err) });
|
||||
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) */
|
||||
const PM_SAVE_JS = pmSave.toString();
|
||||
|
||||
/** the suggested petname gets the holder's local hh:mm (the worker only
|
||||
* knows UTC), so two saves the same day are told apart at a glance */
|
||||
const PETNAME_TIME_JS = `{
|
||||
const p = document.getElementById("petname");
|
||||
/** the suggested petname (in the input with id `petnameId`) gets the
|
||||
* holder's local hh:mm (the worker only knows UTC), so two saves the same
|
||||
* day are told apart at a glance */
|
||||
function petnameTimeJs(petnameId: string): string {
|
||||
return `{
|
||||
const p = document.getElementById(${JSON.stringify(petnameId)});
|
||||
const d = new Date();
|
||||
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 */
|
||||
async function wirePage(webappId: string): Promise<Response> {
|
||||
/** the onclick that hands pmSave its browser powers, saving under the
|
||||
* 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 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>
|
||||
<html><head><meta charset="utf-8"><title>diigo cache: step 2 — wire the cache</title></head>
|
||||
<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>
|
||||
<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>
|
||||
<script>${PETNAME_TIME_JS}</script>
|
||||
<p><button type="button" onclick='${pmSaveClick(webappId)}'>save to password manager</button></p>
|
||||
<script>${petnameTimeJs("petname")}</script>
|
||||
<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
|
||||
<b>cache id</b> (copy that too), after which you land back on home:</p>
|
||||
<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>
|
||||
<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>
|
||||
<script>${PETNAME_TIME_JS}</script>
|
||||
<p><button type="button" onclick='${pmSaveClick(cacheId)}'>save to password manager</button></p>
|
||||
<script>${petnameTimeJs("petname")}</script>
|
||||
<p><button type="button" onclick='${pmSaveClick(cacheId, "petname")}'>save to password manager</button></p>
|
||||
<p>${note}</p>
|
||||
<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>
|
||||
|
||||
@@ -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 |
@@ -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"
|
||||
}
|
||||
}
|
||||
Generated
+2240
File diff suppressed because it is too large.
Load diff
@@ -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");
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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 }),
|
||||
};
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"extends": "../tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"types": ["@cloudflare/vitest-plugin/types"]
|
||||
},
|
||||
"include": ["./**/*", "../src/**/*", "../worker-configuration.d.ts"]
|
||||
}
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -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"],
|
||||
},
|
||||
});
|
||||
@@ -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" }
|
||||
}
|
||||
}
|
||||
+37
-2
@@ -12,19 +12,54 @@ grows past a form or two.
|
||||
|
||||
- `GET /` — the mint form. Prefill via query string: `?url=…&headers=<urlencoded JSON>&msPerCall=…&title=…`; secret header keys (cookie, authorization, proxy-authorization) render **blank** — paste them at mint time; never echoed back. A dedicated `cookie` field is sugar for the `cookie` header (if the JSON also has one, they must agree).
|
||||
- `GET /form?render=1&…` — the same form for a proxying worker (diigo-cache's `/_connect`): the action is left as an `ACTION` placeholder for the proxy to point at its own relay.
|
||||
- `POST /` — mint. Form-encoded (that page) or JSON `{url, headers, msPerCall}` (service-style). Responds `{id}` (and `x-webapp-id`). The id is *not* logged: possession is authority.
|
||||
- `POST /` — mint. Form-encoded (that page) or JSON `{url, headers, msPerCall}` (service-style). Responds `{id, storeId}` (and `x-webapp-id`, `x-webapp-store-id`). Neither id is logged: possession is authority.
|
||||
- `POST /at/<id>` — drive an instance by capability: JSON `{path?, query?, method?, body?}` → the fluent chain → `{status, text}` (or `{error}`; messages carry no header/param values).
|
||||
- `PUT /store/<storeId>/<key>` — set a header-store entry (JSON value body), e.g. `PUT /store/<storeId>/cookie` with `"session=…"` rotates the cookie; `DELETE` removes it. → `{ok: true}` (or `{error}`; messages carry no keys or values).
|
||||
|
||||
## RPC surface (same-account workers; binding `entrypoint: "WebAppFactory"`)
|
||||
|
||||
- `fetch(request)` — the HTTP surface above, over the binding
|
||||
- `newWebApp({url, headers?, msPerCall?})` → `{id, app}` — the fluent stub;
|
||||
- `newWebApp({url, headers?, msPerCall?})` → `{id, storeId, app}` — the fluent stub;
|
||||
promise pipelining makes a whole `pathjoin().query().get()` chain a
|
||||
single round trip
|
||||
- `at(id)` — the fluent stub for a previously-minted instance
|
||||
- on a stub, `postForm(fields?, echoCookies?)` → `{status, contentType,
|
||||
contentDisposition?, location?, body}`: a multipart/form-data POST.
|
||||
`echoCookies` maps a form field to a cookie name; the cookie's value
|
||||
(header store included) fills the field at send, so a double-submit
|
||||
CSRF token goes out without the holder seeing it. Redirects are not
|
||||
followed and a non-OK status is a reply, not an Error (a 3xx to a login
|
||||
page is how many sites say "session expired"). `location` comes back
|
||||
query-stripped; `set-cookie` never comes back. `body` is bytes: the
|
||||
caller knows its charset.
|
||||
- `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
|
||||
|
||||
pnpm test # header store + rotation (vitest, workerd pool)
|
||||
npx wrangler dev # → its own ready line (usually :8787 with the
|
||||
# two-config diigo-cache run; standalone, :8788)
|
||||
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
Generated
+2240
File diff suppressed because it is too large.
Load diff
+253
-34
@@ -7,21 +7,52 @@
|
||||
// DurableObjectNamespace/DurableObjectId/DurableObjectStub are ambient types
|
||||
// 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 { encodeShapes, MapStoreDo } from "../../lib/src/store-do.js";
|
||||
|
||||
export { MapStoreDo };
|
||||
|
||||
export type Query = Record<string, string | number>;
|
||||
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
|
||||
* 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;
|
||||
}
|
||||
|
||||
/**
|
||||
* what {@link WebApp.postForm} hands back: the status and the response
|
||||
* headers a caller needs to tell a result from a refusal, never
|
||||
* `set-cookie` (a credential stays where it is) and with any query
|
||||
* stripped from `location`
|
||||
*/
|
||||
export type Reply = {
|
||||
status: number;
|
||||
contentType: string;
|
||||
contentDisposition?: string;
|
||||
location?: string;
|
||||
/** the body as sent: the caller knows its charset (some formats name
|
||||
* it in their own header, not in content-type) */
|
||||
body: Uint8Array;
|
||||
};
|
||||
|
||||
/** one outbound request, redirects not followed; the DO's gate and fetch */
|
||||
export type Exchange = (url: string, headers: Headers, method: "POST", body: FormData) => Promise<Response>;
|
||||
|
||||
/** structural type of a stub handle: chain to narrow, then fire */
|
||||
export interface FluentGet {
|
||||
pathjoin(ref: string): FluentGet;
|
||||
@@ -29,8 +60,10 @@ export interface FluentGet {
|
||||
withHeaders(h: Headers): FluentGet;
|
||||
withHeader(key: string, value: string): FluentGet;
|
||||
cookies(cookie: string): FluentGet;
|
||||
withHeaderMap(map: HeaderMap): FluentGet;
|
||||
get(): Promise<string>;
|
||||
post(body: string): Promise<string>;
|
||||
postForm(fields?: Record<string, string>, echoCookies?: Record<string, string>): Promise<Reply>;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -57,17 +90,23 @@ export interface FluentGet {
|
||||
export class WebApp extends RpcTarget implements FluentGet {
|
||||
#url: string;
|
||||
#headers: Headers;
|
||||
#maps: readonly HeaderMap[];
|
||||
#send: (url: string, headers: Headers, method: "GET" | "POST", body?: string) => Promise<string>;
|
||||
#exchange: Exchange;
|
||||
|
||||
constructor(
|
||||
url: string,
|
||||
headers: Headers,
|
||||
maps: readonly HeaderMap[],
|
||||
send: (url: string, headers: Headers, method: "GET" | "POST", body?: string) => Promise<string>,
|
||||
exchange: Exchange,
|
||||
) {
|
||||
super();
|
||||
this.#url = url;
|
||||
this.#headers = headers;
|
||||
this.#maps = maps;
|
||||
this.#send = send;
|
||||
this.#exchange = exchange;
|
||||
}
|
||||
|
||||
/** Join a relative path, merging the base's query.
|
||||
@@ -85,13 +124,13 @@ export class WebApp extends RpcTarget implements FluentGet {
|
||||
throw new Error(`pathjoin: ${ref} not within ${hereNoQuery.href}`);
|
||||
}
|
||||
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, this.#exchange);
|
||||
}
|
||||
|
||||
/** Union headers, flower-style (see {@link WebApp}): a shared key with a
|
||||
* different value throws; the same value is fine. */
|
||||
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, this.#exchange);
|
||||
}
|
||||
|
||||
/** one key, flower-style (see {@link WebApp}) */
|
||||
@@ -105,15 +144,72 @@ export class WebApp extends RpcTarget implements FluentGet {
|
||||
|
||||
/** Union params into the existing query, flower-style (see {@link 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, this.#exchange);
|
||||
}
|
||||
|
||||
/** 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, this.#exchange);
|
||||
}
|
||||
|
||||
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> {
|
||||
return this.#send(this.#url, this.#headers, "POST", body);
|
||||
return this.#send(this.#url, await this.#headersNow(), "POST", body);
|
||||
}
|
||||
|
||||
/**
|
||||
* POST `fields` as multipart/form-data. `echoCookies` maps a form field
|
||||
* to a cookie name: at send, that cookie's value (from the headers,
|
||||
* header store included) goes in the field, so a double-submit CSRF
|
||||
* token can be sent by a holder who never sees it.
|
||||
*
|
||||
* Unlike `get`/`post`, a non-OK status is a {@link Reply}, not an
|
||||
* Error, and redirects are not followed: a 3xx to a login page is how
|
||||
* many sites say "session expired".
|
||||
*/
|
||||
async postForm(fields: Record<string, string> = {}, echoCookies: Record<string, string> = {}): Promise<Reply> {
|
||||
const headers = await this.#headersNow();
|
||||
if (Object.keys(headers).some((k) => k.toLowerCase() === "content-type")) {
|
||||
throw new Error("postForm: content-type is set by the form, not the headers");
|
||||
}
|
||||
const jar = parseCookies(
|
||||
Object.entries(headers).find(([k]) => k.toLowerCase() === "cookie")?.[1] ?? "",
|
||||
);
|
||||
const form = new FormData();
|
||||
for (const [name, value] of Object.entries(fields)) form.append(name, value);
|
||||
for (const [field, cookie] of Object.entries(echoCookies)) {
|
||||
// names only: values must not appear in Error messages
|
||||
if (field in fields) throw new Error(`postForm: contradicts on ${field}`);
|
||||
const value = jar.get(cookie);
|
||||
if (value === undefined) throw new Error(`postForm: no ${cookie} cookie to echo`);
|
||||
form.append(field, value);
|
||||
}
|
||||
const res = await this.#exchange(this.#url, headers, "POST", form);
|
||||
const location = res.headers.get("location");
|
||||
return {
|
||||
status: res.status,
|
||||
contentType: res.headers.get("content-type") ?? "",
|
||||
...withDefined("contentDisposition", res.headers.get("content-disposition")),
|
||||
...withDefined("location", location === null ? null : stripQuery(location, this.#url)),
|
||||
body: new Uint8Array(await res.arrayBuffer()),
|
||||
};
|
||||
}
|
||||
|
||||
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 +245,16 @@ class Gate {
|
||||
* storage, surviving eviction (instance memory evaporates when the DO naps;
|
||||
* 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
|
||||
* 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 */
|
||||
@@ -207,16 +308,46 @@ export class WebAppDo extends DurableObject<{ WEBAPP?: never }> {
|
||||
}
|
||||
return res.text();
|
||||
};
|
||||
return new WebApp(init.url, init.headers ?? {}, send);
|
||||
const exchange: Exchange = async (url, headers, method, body) => {
|
||||
await gate.waitMyTurn();
|
||||
return fetch(url, { method, headers, body, redirect: "manual" });
|
||||
};
|
||||
const maps = init.headerStore === undefined ? [] : [this.#readHeaderStore(init.headerStore)];
|
||||
return new WebApp(init.url, init.headers ?? {}, maps, send, exchange);
|
||||
}
|
||||
|
||||
/** 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
|
||||
* 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
|
||||
@@ -227,7 +358,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
|
||||
@@ -246,6 +377,14 @@ const defaultFetch = async (
|
||||
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 });
|
||||
};
|
||||
|
||||
@@ -305,10 +444,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 {
|
||||
@@ -335,13 +471,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
|
||||
@@ -368,6 +526,60 @@ 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 });
|
||||
}
|
||||
|
||||
/** a cookie header's name → value (the first of a repeated name wins) */
|
||||
function parseCookies(header: string): Map<string, string> {
|
||||
const jar = new Map<string, string>();
|
||||
for (const pair of header.split(";")) {
|
||||
const eq = pair.indexOf("=");
|
||||
if (eq < 0) continue;
|
||||
const name = pair.slice(0, eq).trim();
|
||||
if (name && !jar.has(name)) jar.set(name, pair.slice(eq + 1).trim());
|
||||
}
|
||||
return jar;
|
||||
}
|
||||
|
||||
/** `ref` resolved against `base`, query and fragment dropped: they can carry credentials */
|
||||
function stripQuery(ref: string, base: string): string {
|
||||
const url = new URL(ref, base);
|
||||
url.search = "";
|
||||
url.hash = "";
|
||||
return url.href;
|
||||
}
|
||||
|
||||
/** `{[key]: value}`, or `{}` when value is null: optional fields stay absent */
|
||||
function withDefined<K extends string>(key: K, value: string | null): { [P in K]?: string } {
|
||||
return (value === null ? {} : { [key]: value }) as { [P in K]?: string };
|
||||
}
|
||||
|
||||
function unionHeaders(base: Headers, more: Headers): Headers {
|
||||
const merged = { ...base };
|
||||
for (const [key, value] of Object.entries(more)) {
|
||||
@@ -396,37 +608,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 */
|
||||
@@ -434,6 +646,13 @@ export class WebAppFactory extends WorkerEntrypoint {
|
||||
const ns = this.env.WEBAPP_DO;
|
||||
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> {
|
||||
return new Promise((r) => setTimeout(r, ms));
|
||||
|
||||
@@ -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=");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,117 @@
|
||||
import { env } from "cloudflare:workers";
|
||||
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://site.example/app/export/7/data";
|
||||
|
||||
type Seen = { url: string; redirect?: string; contentType: string | null; form: Record<string, string> };
|
||||
|
||||
/** each outbound fetch, its form fields read back; answers with `res()` */
|
||||
function spyForms(res: () => Response): Seen[] {
|
||||
const seen: Seen[] = [];
|
||||
vi.spyOn(globalThis, "fetch").mockImplementation(async (input, init) => {
|
||||
const req = new Request(input as string, init as RequestInit);
|
||||
const form = Object.fromEntries([...(await req.formData()).entries()].map(([k, v]) => [k, String(v)]));
|
||||
seen.push({ url: req.url, redirect: init?.redirect, contentType: req.headers.get("content-type"), form });
|
||||
return res();
|
||||
});
|
||||
return seen;
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
const mint = async (init: WebAppInit) => {
|
||||
const res = await worker.fetch(
|
||||
new Request("https://webapp.example/", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(init),
|
||||
headers: { "content-type": "application/json" },
|
||||
}),
|
||||
testEnv,
|
||||
);
|
||||
const { id, storeId } = (await res.json()) as { id: string; storeId: string };
|
||||
return { id, storeId, instance: testEnv.WEBAPP_DO.get(testEnv.WEBAPP_DO.idFromString(id)) };
|
||||
};
|
||||
|
||||
/** the failure of a call: what it threw; "" if it succeeds */
|
||||
async function errorOf(p: Promise<unknown>): Promise<string> {
|
||||
try {
|
||||
await p;
|
||||
return "";
|
||||
} catch (err) {
|
||||
return String(err);
|
||||
}
|
||||
}
|
||||
|
||||
describe("postForm", () => {
|
||||
it("echoes a cookie from the header store into a form field; the holder never sees it", async () => {
|
||||
const seen = spyForms(
|
||||
() =>
|
||||
new Response("col1,col2", {
|
||||
headers: {
|
||||
"content-type": "text/csv",
|
||||
"content-disposition": 'attachment;filename="x.csv"',
|
||||
"set-cookie": "sess=next; HttpOnly",
|
||||
},
|
||||
}),
|
||||
);
|
||||
const { instance } = await mint({ url: URL_, headers: { cookie: "a=1; tok=s3cret; b=2" } });
|
||||
using app = await instance.handle();
|
||||
const reply = await app.query({ since: "2026-07-13\x1f" }).postForm({ kind: "all" }, { csrf: "tok" });
|
||||
expect(seen).toEqual([
|
||||
{
|
||||
url: `${URL_}?since=2026-07-13%1F`,
|
||||
redirect: "manual",
|
||||
contentType: expect.stringMatching(/^multipart\/form-data; boundary=/),
|
||||
form: { kind: "all", csrf: "s3cret" },
|
||||
},
|
||||
]);
|
||||
expect(reply).toEqual({
|
||||
status: 200,
|
||||
contentType: "text/csv",
|
||||
contentDisposition: 'attachment;filename="x.csv"',
|
||||
body: new TextEncoder().encode("col1,col2"),
|
||||
});
|
||||
});
|
||||
|
||||
it("does not follow a redirect: the 3xx comes back, location query-stripped", async () => {
|
||||
spyForms(() => new Response(null, { status: 302, headers: { location: "/login?next=s3cret#x" } }));
|
||||
const { instance } = await mint({ url: URL_, headers: { cookie: "tok=t" } });
|
||||
using app = await instance.handle();
|
||||
const reply = await app.postForm({}, { csrf: "tok" });
|
||||
expect(reply).toEqual({ status: 302, contentType: "", location: "https://site.example/login", body: new Uint8Array() });
|
||||
});
|
||||
|
||||
it("refuses, naming no values: a missing cookie, a field set twice, a content-type header", async () => {
|
||||
spyForms(() => new Response("ok"));
|
||||
const { instance } = await mint({ url: URL_, headers: { cookie: "tok=s3cret" } });
|
||||
using app = await instance.handle();
|
||||
expect(await errorOf(app.postForm({}, { csrf: "nope" }))).toMatch(/no nope cookie to echo/);
|
||||
const twice = await errorOf(app.postForm({ csrf: "mine" }, { csrf: "tok" }));
|
||||
expect(twice).toMatch(/contradicts on csrf/);
|
||||
expect(twice).not.toMatch(/s3cret|mine/);
|
||||
const typed = app.withHeader("content-type", "text/plain");
|
||||
expect(await errorOf(typed.postForm())).toMatch(/content-type is set by the form/);
|
||||
});
|
||||
|
||||
it("rotating the store's cookie changes what is echoed", async () => {
|
||||
const seen = spyForms(() => new Response("ok"));
|
||||
const { storeId, instance } = await mint({ url: URL_, headers: { cookie: "tok=one" } });
|
||||
using app = await instance.handle();
|
||||
await app.postForm({}, { csrf: "tok" });
|
||||
await worker.fetch(
|
||||
new Request(`https://webapp.example/store/${storeId}/cookie`, { method: "PUT", body: JSON.stringify("tok=two") }),
|
||||
testEnv,
|
||||
);
|
||||
await app.postForm({}, { csrf: "tok" });
|
||||
expect(seen.map((s) => s.form.csrf)).toEqual(["one", "two"]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"extends": "../tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"types": ["@cloudflare/vitest-plugin/types"]
|
||||
},
|
||||
"include": ["./**/*", "../worker-configuration.d.ts"]
|
||||
}
|
||||
@@ -7,6 +7,7 @@
|
||||
"esModuleInterop": true,
|
||||
"verbatimModuleSyntax": true,
|
||||
"skipLibCheck": true,
|
||||
"lib": ["ES2022", "ESNext.Disposable"],
|
||||
"noEmit": true,
|
||||
"types": []
|
||||
},
|
||||
|
||||
@@ -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"],
|
||||
},
|
||||
});
|
||||
@@ -5,7 +5,13 @@
|
||||
"compatibility_date": "2026-09-29",
|
||||
"observability": { "enabled": true },
|
||||
"durable_objects": {
|
||||
"bindings": [{ "name": "WEBAPP_DO", "class_name": "WebAppDo" }]
|
||||
"bindings": [
|
||||
{ "name": "WEBAPP_DO", "class_name": "WebAppDo" },
|
||||
{ "name": "MAP_STORE_DO", "class_name": "MapStoreDo" }
|
||||
]
|
||||
},
|
||||
"migrations": [{ "tag": "v1", "new_sqlite_classes": ["WebAppDo"] }]
|
||||
"migrations": [
|
||||
{ "tag": "v1", "new_sqlite_classes": ["WebAppDo"] },
|
||||
{ "tag": "v2", "new_sqlite_classes": ["MapStoreDo"] }
|
||||
]
|
||||
}
|
||||
Reference in new issue
Block a user