Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2820c015ee | ||
|
|
7bf6eb44c7 | ||
|
|
88739d9495 |
No files matched your search
@@ -109,11 +109,10 @@ 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 in place, keeping the
|
page, don't fail silently). Rotation is manual: re-mint via
|
||||||
webapp id (so the cache's wiring and ids hold), by the store id
|
`/_connect`. Open question: can an existing cache be re-wired to
|
||||||
minted alongside it and kept by the account owner: `PUT
|
a new webapp id (keeping its cache id and public username), or
|
||||||
/store/<storeId>/cookie` (webapp README, "The header store").
|
does re-mint mean a new cache? Prefer re-wire — durable ids.
|
||||||
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).
|
||||||
|
|||||||
+17
-36
@@ -174,13 +174,11 @@ 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, storeId} (+ x-webapp-id, x-webapp-store-id);
|
// webapp answers {id} (+ x-webapp-id); the id rides the header, the
|
||||||
// the ids ride the headers, the wire page is ours
|
// 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);
|
||||||
const storeId = res.headers.get("x-webapp-store-id");
|
return await wirePage(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);
|
||||||
@@ -190,37 +188,22 @@ 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 (in the input with id `petnameId`) gets the
|
/** the suggested petname gets the holder's local hh:mm (the worker only
|
||||||
* holder's local hh:mm (the worker only knows UTC), so two saves the same
|
* knows UTC), so two saves the same day are told apart at a glance */
|
||||||
* day are told apart at a glance */
|
const PETNAME_TIME_JS = `{
|
||||||
function petnameTimeJs(petnameId: string): string {
|
const p = document.getElementById("petname");
|
||||||
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 onclick that hands pmSave its browser powers, saving under the
|
/** the wire step, with the freshly-minted webapp id pre-filled */
|
||||||
* petname in the input with id `petnameId` */
|
async function wirePage(webappId: string): Promise<Response> {
|
||||||
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>
|
||||||
@@ -229,10 +212,8 @@ diigo session (log in again, paste the new cookie) or revoke it. Fingerprint
|
|||||||
<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>${petnameTimeJs("petname")}</script>
|
<script>${PETNAME_TIME_JS}</script>
|
||||||
<p><button type="button" onclick='${pmSaveClick(webappId, "petname")}'>save to password manager</button></p>
|
<p><button type="button" onclick='${pmSaveClick(webappId)}'>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">
|
||||||
@@ -339,8 +320,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>${petnameTimeJs("petname")}</script>
|
<script>${PETNAME_TIME_JS}</script>
|
||||||
<p><button type="button" onclick='${pmSaveClick(cacheId, "petname")}'>save to password manager</button></p>
|
<p><button type="button" onclick='${pmSaveClick(cacheId)}'>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>
|
||||||
|
|||||||
@@ -1,146 +0,0 @@
|
|||||||
<!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.
|
Before Width: | Height: | Size: 113 KiB |
@@ -1,16 +0,0 @@
|
|||||||
{
|
|
||||||
"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
@@ -1,129 +0,0 @@
|
|||||||
/**
|
|
||||||
* @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");
|
|
||||||
}
|
|
||||||
@@ -1,60 +0,0 @@
|
|||||||
/**
|
|
||||||
* @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();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,133 +0,0 @@
|
|||||||
/**
|
|
||||||
* @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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,142 +0,0 @@
|
|||||||
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");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,17 +0,0 @@
|
|||||||
/** @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 }),
|
|
||||||
};
|
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
{
|
|
||||||
"extends": "../tsconfig.json",
|
|
||||||
"compilerOptions": {
|
|
||||||
"types": ["@cloudflare/vitest-plugin/types"]
|
|
||||||
},
|
|
||||||
"include": ["./**/*", "../src/**/*", "../worker-configuration.d.ts"]
|
|
||||||
}
|
|
||||||
@@ -1,15 +0,0 @@
|
|||||||
{
|
|
||||||
"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"]
|
|
||||||
}
|
|
||||||
@@ -1,9 +0,0 @@
|
|||||||
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"],
|
|
||||||
},
|
|
||||||
});
|
|
||||||
@@ -1,17 +0,0 @@
|
|||||||
{
|
|
||||||
"$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" }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
state/
|
||||||
|
admin.sock
|
||||||
|
switchboard.sock
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
# toybox
|
||||||
|
|
||||||
|
Vats whose code is installed at runtime: each vat is a Durable Object whose app runs as a [facet](https://developers.cloudflare.com/dynamic-workers/usage/durable-object-facets/) on code loaded through workerd's Worker Loader. Think `vatAdmin.createVat(bundle)`, with a supervisor per vat.
|
||||||
|
|
||||||
|
- [supervisor.js](supervisor.js): `Vat` stores code by SHA-256 and runs the app on it; `Public` and `Admin` are the two entrypoints; `Egress` is a grant of one host.
|
||||||
|
- [switchboard.js](switchboard.js): a separate workerd process, the vats' only way out: a forward proxy for HTTP and WebSockets.
|
||||||
|
- [config.capnp](config.capnp): the sockets have no addresses; whoever starts workerd hands them in, so holding a socket is the authority it grants.
|
||||||
|
|
||||||
|
| socket | reaches | what its holder can do |
|
||||||
|
|---|---|---|
|
||||||
|
| `http` | `Public` | `GET /<vat>/…`: talk to a vat's app |
|
||||||
|
| `admin` | `Admin` | `PUT /<vat>` with an ES module body: install it and (re)start the app; its storage carries over. `?egress=<host>` grants the app that one host. |
|
||||||
|
|
||||||
|
An app must export `class App extends DurableObject` (see [apps/](apps/)). Its global `fetch` always throws: nothing in a vat is ambient. A granted host arrives as `env.egress`, an `Egress` with the host in `ctx.props`: its `fetch` forwards matching requests, WebSocket upgrades included, to the switchboard and refuses the rest with a 403. It is an object, so the app can pass it to whichever of its parts should have it.
|
||||||
|
|
||||||
|
toybox reaches the switchboard over a unix socket (`-e switchboard=unix:…`), in HTTP proxy style so the full URL, scheme included, survives the hop.
|
||||||
|
|
||||||
|
## Run it
|
||||||
|
|
||||||
|
```sh
|
||||||
|
pnpm install
|
||||||
|
pnpm test # real workerds: install, upgrade, isolation, egress grants, fetch and WebSocket out, crash and restart
|
||||||
|
pnpm switchboard # systemd-socket-activate on ./switchboard.sock
|
||||||
|
pnpm start # likewise on 127.0.0.1:8787 and ./admin.sock; state in ./state
|
||||||
|
```
|
||||||
|
|
||||||
|
Then, in a third terminal:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
curl -X PUT --unix-socket admin.sock --data-binary @apps/counter-v1.js http://toybox/alice
|
||||||
|
curl localhost:8787/alice # v1 count 1
|
||||||
|
curl -X PUT --unix-socket admin.sock --data-binary @apps/counter-v2.js http://toybox/alice
|
||||||
|
curl localhost:8787/alice # v2 count 2
|
||||||
|
|
||||||
|
curl -X PUT --unix-socket admin.sock --data-binary @apps/egress-probe.js 'http://toybox/erin?egress=example.com'
|
||||||
|
curl 'localhost:8787/erin?url=https://example.com/' # got: <!doctype html>…
|
||||||
|
curl 'localhost:8787/erin?url=https://example.org/' # got: egress to example.org not granted
|
||||||
|
```
|
||||||
|
|
||||||
|
## Under systemd
|
||||||
|
|
||||||
|
[systemd/](systemd/) has socket and service pairs for toybox and its switchboard, assuming this directory is installed at `/opt/toybox`. toybox gets no network of its own (`PrivateNetwork=yes`): only the two sockets it is handed and the switchboard's socket, which only members of the `toybox` group can open. The switchboard has the network, fenced by `IPAddressDeny=`; `switchboard.capnp` allows `local` addresses only so the tests can reach a local echo server.
|
||||||
|
|
||||||
|
Neither unit has been run under the system manager yet.
|
||||||
|
|
||||||
|
`PrivateNetwork=` needs the system manager; as a `systemd-run --user` unit, systemd logs "lack privileges for network namespace, proceeding without".
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
import { DurableObject } from "cloudflare:workers";
|
||||||
|
export class App extends DurableObject {
|
||||||
|
fetch() {
|
||||||
|
const n = (this.ctx.storage.kv.get("n") ?? 0) + 1;
|
||||||
|
this.ctx.storage.kv.put("n", n);
|
||||||
|
return new Response(`v1 count ${n}\n`);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
import { DurableObject } from "cloudflare:workers";
|
||||||
|
export class App extends DurableObject {
|
||||||
|
fetch() {
|
||||||
|
const n = (this.ctx.storage.kv.get("n") ?? 0) + 1;
|
||||||
|
this.ctx.storage.kv.put("n", n);
|
||||||
|
return new Response(`v2 count ${n}\n`);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
import { DurableObject } from "cloudflare:workers";
|
||||||
|
|
||||||
|
/** `?url=<target>` fetches it through `env.egress`; with `&ws=1`, opens a WebSocket there and reports the echo of "hello". */
|
||||||
|
export class App extends DurableObject {
|
||||||
|
async fetch(request) {
|
||||||
|
const params = new URL(request.url).searchParams;
|
||||||
|
const target = params.get("url");
|
||||||
|
const { egress } = this.env;
|
||||||
|
if (!egress) return new Response("no egress granted\n");
|
||||||
|
try {
|
||||||
|
if (!params.has("ws")) return new Response(`got: ${await (await egress.fetch(target)).text()}\n`);
|
||||||
|
const resp = await egress.fetch(target, { headers: { Upgrade: "websocket" } });
|
||||||
|
const ws = resp.webSocket;
|
||||||
|
if (!ws) return new Response(`no websocket: ${resp.status} ${await resp.text()}`);
|
||||||
|
ws.accept();
|
||||||
|
const reply = new Promise((resolve) => ws.addEventListener("message", (e) => resolve(e.data)));
|
||||||
|
ws.send("hello");
|
||||||
|
const got = await reply;
|
||||||
|
ws.close();
|
||||||
|
return new Response(`echo: ${got}\n`);
|
||||||
|
} catch (e) {
|
||||||
|
return new Response(`refused: ${e.message}\n`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
import { DurableObject } from "cloudflare:workers";
|
||||||
|
export class App extends DurableObject {
|
||||||
|
async fetch() {
|
||||||
|
try { await fetch("https://example.com/"); return new Response("fetch ALLOWED\n"); }
|
||||||
|
catch (e) { return new Response(`fetch refused: ${e.message}\n`); }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
# toybox: vats whose code is installed at runtime, each a Durable Object facet.
|
||||||
|
#
|
||||||
|
# Sockets have no address here: whoever starts workerd hands them in
|
||||||
|
# (-S name=fd from systemd, or -s name=addr), so holding a socket is the
|
||||||
|
# authority it grants. `state` is likewise overridden with -d state=<dir>,
|
||||||
|
# and the switchboard's address with -e switchboard=<addr>.
|
||||||
|
using Workerd = import "/workerd/workerd.capnp";
|
||||||
|
|
||||||
|
const config :Workerd.Config = (
|
||||||
|
services = [
|
||||||
|
(name = "main", worker = .supervisor),
|
||||||
|
(name = "nowhere", network = (allow = [])),
|
||||||
|
# the way out: see switchboard.capnp
|
||||||
|
(name = "switchboard", external = (address = "unix:switchboard.sock", http = (style = proxy))),
|
||||||
|
(name = "state", disk = (path = "state", writable = true)),
|
||||||
|
],
|
||||||
|
sockets = [
|
||||||
|
# reach a running vat by name
|
||||||
|
(name = "http", http = (), service = (name = "main", entrypoint = "Public")),
|
||||||
|
# install code into a vat and (re)start it
|
||||||
|
(name = "admin", http = (), service = (name = "main", entrypoint = "Admin")),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
const supervisor :Workerd.Worker = (
|
||||||
|
compatibilityDate = "2026-10-01",
|
||||||
|
modules = [(name = "supervisor.js", esModule = embed "supervisor.js")],
|
||||||
|
durableObjectNamespaces = [(className = "Vat", uniqueKey = "toybox-vat", enableSql = true)],
|
||||||
|
durableObjectStorage = (localDisk = "state"),
|
||||||
|
bindings = [
|
||||||
|
(name = "VATS", durableObjectNamespace = "Vat"),
|
||||||
|
(name = "LOADER", workerLoader = ()),
|
||||||
|
(name = "SWITCHBOARD", service = "switchboard"),
|
||||||
|
],
|
||||||
|
globalOutbound = "nowhere",
|
||||||
|
);
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
{
|
||||||
|
"name": "toybox",
|
||||||
|
"version": "0.0.1",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"start": "mkdir -p state && rm -f admin.sock && systemd-socket-activate -l 127.0.0.1:8787 -l ./admin.sock -- node_modules/workerd/bin/workerd serve --experimental config.capnp -S http=3 -S admin=4 -d state=state -e switchboard=unix:./switchboard.sock",
|
||||||
|
"switchboard": "rm -f switchboard.sock && systemd-socket-activate -l ./switchboard.sock -- node_modules/workerd/bin/workerd serve --experimental switchboard.capnp -S http=3",
|
||||||
|
"test": "node --test"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"workerd": "1.20261006.1",
|
||||||
|
"ws": "8.22.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
Generated
+92
@@ -0,0 +1,92 @@
|
|||||||
|
lockfileVersion: '9.0'
|
||||||
|
|
||||||
|
settings:
|
||||||
|
autoInstallPeers: true
|
||||||
|
excludeLinksFromLockfile: false
|
||||||
|
|
||||||
|
importers:
|
||||||
|
|
||||||
|
.:
|
||||||
|
devDependencies:
|
||||||
|
workerd:
|
||||||
|
specifier: 1.20261006.1
|
||||||
|
version: 1.20261006.1
|
||||||
|
ws:
|
||||||
|
specifier: 8.22.0
|
||||||
|
version: 8.22.0
|
||||||
|
|
||||||
|
packages:
|
||||||
|
|
||||||
|
'@cloudflare/workerd-darwin-64@1.20261006.1':
|
||||||
|
resolution: {integrity: sha512-lCX6avJO2It9AyDEYitjmOtJRVp/xmaW0TziyoLAnfxlzozyyRbSFfIIMMCiDx/sTP1++E4VLbV+7BHSD33f0Q==}
|
||||||
|
engines: {node: '>=16'}
|
||||||
|
cpu: [x64]
|
||||||
|
os: [darwin]
|
||||||
|
|
||||||
|
'@cloudflare/workerd-darwin-arm64@1.20261006.1':
|
||||||
|
resolution: {integrity: sha512-PyFGjOaldtppnLuKsdkoYWXfLVpDjuf8u+uoykX+1Xvxo2EPmCVViq0iDKm+U7c7PRno85ehS3/MPVXyk2HwHw==}
|
||||||
|
engines: {node: '>=16'}
|
||||||
|
cpu: [arm64]
|
||||||
|
os: [darwin]
|
||||||
|
|
||||||
|
'@cloudflare/workerd-linux-64@1.20261006.1':
|
||||||
|
resolution: {integrity: sha512-Ntf9S8eesmVufdjaT2kzWlz6Ng9XGYTc6cHQVRKAg59lFufwsIabGeMT6U4KkrpEeSIxgHq8aPtuLbZXE19tCA==}
|
||||||
|
engines: {node: '>=16'}
|
||||||
|
cpu: [x64]
|
||||||
|
os: [linux]
|
||||||
|
|
||||||
|
'@cloudflare/workerd-linux-arm64@1.20261006.1':
|
||||||
|
resolution: {integrity: sha512-JyeDBymVWw5tVRebSsVE8ojDYxd6UlZrAseHU8G+NpIyd5wF61tXlF/axLeCUqu0pOhBFHb8I5QKi0jwrCK41Q==}
|
||||||
|
engines: {node: '>=16'}
|
||||||
|
cpu: [arm64]
|
||||||
|
os: [linux]
|
||||||
|
|
||||||
|
'@cloudflare/workerd-windows-64@1.20261006.1':
|
||||||
|
resolution: {integrity: sha512-nWP0URNqOBN9fa/gZ7eh/w051DwjOXN8j4lDZDYVXyQPsux6sV3DfyYZT+CjR/rt9nKBSmJT9QE9/bWT18+84w==}
|
||||||
|
engines: {node: '>=16'}
|
||||||
|
cpu: [x64]
|
||||||
|
os: [win32]
|
||||||
|
|
||||||
|
workerd@1.20261006.1:
|
||||||
|
resolution: {integrity: sha512-8MadBuffZ5NdpLwZjUmsqT5QlaKXq4gjgT6wzcKeynZTICNXyePhc+5QVoKiK7+MMhFTLNrRO2KXLlpk8Q6BkA==}
|
||||||
|
engines: {node: '>=16'}
|
||||||
|
hasBin: true
|
||||||
|
|
||||||
|
ws@8.22.0:
|
||||||
|
resolution: {integrity: sha512-Ydggc987+RO0AnWtZ/7Wq9FtNvcrL1b/RO0ud9mWjUPgDrsAAwQSF51sm2hm1XofbU/4jkpGEsLFsZZxU+1DOg==}
|
||||||
|
engines: {node: '>=10.0.0'}
|
||||||
|
peerDependencies:
|
||||||
|
bufferutil: ^4.0.1
|
||||||
|
utf-8-validate: '>=5.0.2'
|
||||||
|
peerDependenciesMeta:
|
||||||
|
bufferutil:
|
||||||
|
optional: true
|
||||||
|
utf-8-validate:
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
snapshots:
|
||||||
|
|
||||||
|
'@cloudflare/workerd-darwin-64@1.20261006.1':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@cloudflare/workerd-darwin-arm64@1.20261006.1':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@cloudflare/workerd-linux-64@1.20261006.1':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@cloudflare/workerd-linux-arm64@1.20261006.1':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@cloudflare/workerd-windows-64@1.20261006.1':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
workerd@1.20261006.1:
|
||||||
|
optionalDependencies:
|
||||||
|
'@cloudflare/workerd-darwin-64': 1.20261006.1
|
||||||
|
'@cloudflare/workerd-darwin-arm64': 1.20261006.1
|
||||||
|
'@cloudflare/workerd-linux-64': 1.20261006.1
|
||||||
|
'@cloudflare/workerd-linux-arm64': 1.20261006.1
|
||||||
|
'@cloudflare/workerd-windows-64': 1.20261006.1
|
||||||
|
|
||||||
|
ws@8.22.0: {}
|
||||||
@@ -0,0 +1,102 @@
|
|||||||
|
/** @file toybox supervisor: one Vat per name; its app runs as a facet on installed code. */
|
||||||
|
import { DurableObject, WorkerEntrypoint } from "cloudflare:workers";
|
||||||
|
|
||||||
|
const hex = (buf) => [...new Uint8Array(buf)].map((b) => b.toString(16).padStart(2, "0")).join("");
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Holds the vat's installed code and egress grant; the app facet holds the app's state.
|
||||||
|
*
|
||||||
|
* Code is stored by hash so the facet can be rebuilt after hibernation or a
|
||||||
|
* restart. The app's global `fetch` throws; a granted host arrives as
|
||||||
|
* `env.egress`, an {@link Egress} the app can pass along like any object.
|
||||||
|
*
|
||||||
|
* Each `start` begins a new incarnation, which names the loaded Worker: the
|
||||||
|
* same code with a different grant must not share a Worker.
|
||||||
|
*/
|
||||||
|
export class Vat extends DurableObject {
|
||||||
|
constructor(ctx, env) {
|
||||||
|
super(ctx, env);
|
||||||
|
ctx.storage.sql.exec("CREATE TABLE IF NOT EXISTS bundle (hash TEXT PRIMARY KEY, src TEXT)");
|
||||||
|
ctx.storage.sql.exec("CREATE TABLE IF NOT EXISTS current (k INTEGER PRIMARY KEY CHECK (k = 0), hash TEXT)");
|
||||||
|
ctx.storage.sql.exec(
|
||||||
|
"CREATE TABLE IF NOT EXISTS incarnation (k INTEGER PRIMARY KEY CHECK (k = 0), n INTEGER, egress TEXT)",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Install `src` and run the app on it; the app's storage carries over.
|
||||||
|
*
|
||||||
|
* @param {string} src an ES module exporting `class App extends DurableObject`
|
||||||
|
* @param {string | null} egress the one host (`name[:port]`) the app may fetch, or none
|
||||||
|
* @returns {Promise<string>} the code's SHA-256, hex
|
||||||
|
*/
|
||||||
|
async start(src, egress) {
|
||||||
|
const hash = hex(await crypto.subtle.digest("SHA-256", new TextEncoder().encode(src)));
|
||||||
|
const { sql } = this.ctx.storage;
|
||||||
|
sql.exec("INSERT OR IGNORE INTO bundle VALUES (?, ?)", hash, src);
|
||||||
|
sql.exec("INSERT OR REPLACE INTO current VALUES (0, ?)", hash);
|
||||||
|
const [prev] = sql.exec("SELECT n FROM incarnation").toArray();
|
||||||
|
sql.exec("INSERT OR REPLACE INTO incarnation VALUES (0, ?, ?)", (prev?.n ?? 0) + 1, egress);
|
||||||
|
this.ctx.facets.abort("app", new Error("upgraded"));
|
||||||
|
return hash;
|
||||||
|
}
|
||||||
|
|
||||||
|
#app({ hash, src, n, egress }) {
|
||||||
|
return this.ctx.facets.get("app", () => {
|
||||||
|
const worker = this.env.LOADER.get(`${this.ctx.id}:${n}`, () => ({
|
||||||
|
compatibilityDate: "2026-10-01",
|
||||||
|
mainModule: "app.js",
|
||||||
|
modules: { "app.js": src },
|
||||||
|
env: egress ? { egress: this.ctx.exports.Egress({ props: { host: egress } }) } : {},
|
||||||
|
globalOutbound: null,
|
||||||
|
}));
|
||||||
|
return { class: worker.getDurableObjectClass("App") };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
fetch(request) {
|
||||||
|
const [row] = this.ctx.storage.sql
|
||||||
|
.exec(
|
||||||
|
`SELECT b.hash, b.src, coalesce(i.n, 0) AS n, i.egress
|
||||||
|
FROM current c JOIN bundle b USING (hash) LEFT JOIN incarnation i ON i.k = 0`,
|
||||||
|
)
|
||||||
|
.toArray();
|
||||||
|
if (!row) return new Response("no code installed\n", { status: 404 });
|
||||||
|
return this.#app(row).fetch(request);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A grant of one host: `fetch`, WebSocket upgrades included, reaches the
|
||||||
|
* switchboard only for the host in `ctx.props`, which no holder can change.
|
||||||
|
*/
|
||||||
|
export class Egress extends WorkerEntrypoint {
|
||||||
|
fetch(request) {
|
||||||
|
const { host } = new URL(request.url);
|
||||||
|
if (host !== this.ctx.props.host) return new Response(`egress to ${host} not granted\n`, { status: 403 });
|
||||||
|
return this.env.SWITCHBOARD.fetch(request);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const vatOf = (env, url) => env.VATS.getByName(new URL(url).pathname.split("/")[1]);
|
||||||
|
|
||||||
|
/** The http socket: `/<vat>/...` goes to that vat's app. */
|
||||||
|
export class Public extends WorkerEntrypoint {
|
||||||
|
fetch(request) {
|
||||||
|
return vatOf(this.env, request.url).fetch(request);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The admin socket: `PUT /<vat>` with an ES module body installs and starts
|
||||||
|
* it; `?egress=<host>` grants it that one host.
|
||||||
|
*/
|
||||||
|
export class Admin extends WorkerEntrypoint {
|
||||||
|
async fetch(request) {
|
||||||
|
if (request.method !== "PUT") return new Response("PUT code to /<vat>\n", { status: 405 });
|
||||||
|
const egress = new URL(request.url).searchParams.get("egress");
|
||||||
|
return new Response((await vatOf(this.env, request.url).start(await request.text(), egress)) + "\n");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default Public;
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
# switchboard: toybox's only way out. A forward proxy (HTTP and WebSocket)
|
||||||
|
# that toybox's Egress gateway reaches over a socket handed in with -S/-s.
|
||||||
|
#
|
||||||
|
# The network service is the switchboard's outer fence; under systemd,
|
||||||
|
# IPAddressAllow=/IPAddressDeny= narrow it further (see systemd/).
|
||||||
|
using Workerd = import "/workerd/workerd.capnp";
|
||||||
|
|
||||||
|
const config :Workerd.Config = (
|
||||||
|
services = [
|
||||||
|
(name = "main", worker = .switchboard),
|
||||||
|
(name = "internet", network = (allow = ["public", "local"], tlsOptions = (trustBrowserCas = true))),
|
||||||
|
],
|
||||||
|
sockets = [
|
||||||
|
# proxy style: the request line carries the full URL, scheme included
|
||||||
|
(name = "http", http = (style = proxy), service = "main"),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
const switchboard :Workerd.Worker = (
|
||||||
|
compatibilityDate = "2026-10-01",
|
||||||
|
modules = [(name = "switchboard.js", esModule = embed "switchboard.js")],
|
||||||
|
durableObjectNamespaces = [(className = "Switchboard", uniqueKey = "toybox-switchboard")],
|
||||||
|
durableObjectStorage = (inMemory = void),
|
||||||
|
bindings = [(name = "BOARD", durableObjectNamespace = "Switchboard")],
|
||||||
|
globalOutbound = "internet",
|
||||||
|
);
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
/** @file switchboard: forwards each proxied request, WebSocket upgrades included. */
|
||||||
|
import { DurableObject } from "cloudflare:workers";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The one place egress happens. A 101 response from `fetch` carries its
|
||||||
|
* `webSocket`; returning it passes the socket through to the caller.
|
||||||
|
*
|
||||||
|
* Which vat may reach which host is decided before a request gets here
|
||||||
|
* (toybox's Egress gateway); this process's network service and its
|
||||||
|
* systemd unit are the outer fence.
|
||||||
|
*/
|
||||||
|
export class Switchboard extends DurableObject {
|
||||||
|
fetch(request) {
|
||||||
|
return fetch(request);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default {
|
||||||
|
fetch(request, env) {
|
||||||
|
return env.BOARD.getByName("switchboard").fetch(request);
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
[Unit]
|
||||||
|
Description=toybox switchboard (workerd forward proxy)
|
||||||
|
Requires=toybox-switchboard.socket
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
ExecStart=/opt/toybox/node_modules/workerd/bin/workerd serve --experimental /opt/toybox/switchboard.capnp -S http=3
|
||||||
|
DynamicUser=yes
|
||||||
|
# the outer fence: switchboard.capnp allows "local" for tests; deny it here
|
||||||
|
IPAddressDeny=localhost link-local multicast
|
||||||
|
ProtectSystem=strict
|
||||||
|
ProtectHome=yes
|
||||||
|
PrivateTmp=yes
|
||||||
|
SystemCallFilter=@system-service
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
[Unit]
|
||||||
|
Description=toybox switchboard: the vats' only way out
|
||||||
|
|
||||||
|
[Socket]
|
||||||
|
ListenStream=/run/toybox/switchboard.sock
|
||||||
|
# only toybox may dial out: its service joins this group
|
||||||
|
SocketGroup=toybox
|
||||||
|
SocketMode=0660
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=sockets.target
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
[Unit]
|
||||||
|
Description=toybox vats (workerd)
|
||||||
|
Requires=toybox.socket toybox-switchboard.socket
|
||||||
|
After=toybox-switchboard.socket
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
ExecStart=/opt/toybox/node_modules/workerd/bin/workerd serve --experimental /opt/toybox/config.capnp -S http=3 -S admin=4 -d state=/var/lib/toybox -e switchboard=unix:/run/toybox/switchboard.sock
|
||||||
|
DynamicUser=yes
|
||||||
|
# to open the switchboard socket
|
||||||
|
SupplementaryGroups=toybox
|
||||||
|
StateDirectory=toybox
|
||||||
|
# no network but the sockets handed in, and the switchboard's
|
||||||
|
PrivateNetwork=yes
|
||||||
|
RestrictAddressFamilies=AF_UNIX
|
||||||
|
ProtectSystem=strict
|
||||||
|
ProtectHome=yes
|
||||||
|
PrivateTmp=yes
|
||||||
|
SystemCallFilter=@system-service
|
||||||
|
# not MemoryDenyWriteExecute: V8's JIT needs W+X
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
[Unit]
|
||||||
|
Description=toybox vats: public port and admin socket
|
||||||
|
|
||||||
|
[Socket]
|
||||||
|
# order sets the fds: http=3, admin=4 (see toybox.service)
|
||||||
|
ListenStream=127.0.0.1:8787
|
||||||
|
ListenStream=/run/toybox/admin.sock
|
||||||
|
# whoever can open admin.sock can install code
|
||||||
|
SocketUser=root
|
||||||
|
SocketMode=0600
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=sockets.target
|
||||||
@@ -0,0 +1,190 @@
|
|||||||
|
/** @file toybox end to end: a real workerd, an admin socket, a public port, a switchboard. */
|
||||||
|
import { spawn } from "node:child_process";
|
||||||
|
import { mkdir, mkdtemp, readFile, rm } from "node:fs/promises";
|
||||||
|
import { createServer as createHttpServer, request } from "node:http";
|
||||||
|
import { connect, createServer } from "node:net";
|
||||||
|
import { tmpdir } from "node:os";
|
||||||
|
import { join } from "node:path";
|
||||||
|
import { after, before, test } from "node:test";
|
||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { WebSocketServer } from "ws";
|
||||||
|
|
||||||
|
const here = new URL("..", import.meta.url).pathname;
|
||||||
|
// the package binary, not the .bin shim: the shim forks it, so a kill would orphan workerd
|
||||||
|
const workerd = join(here, "node_modules/workerd/bin/workerd");
|
||||||
|
const app = (name) => readFile(join(here, "apps", name), "utf8");
|
||||||
|
|
||||||
|
const freePort = () =>
|
||||||
|
new Promise((resolve) => {
|
||||||
|
const s = createServer().listen(0, "127.0.0.1", () => {
|
||||||
|
const { port } = s.address();
|
||||||
|
s.close(() => resolve(port));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
/** Resolves once something accepts connections at `opts` (a port or a socket path). */
|
||||||
|
const accepting = (opts) =>
|
||||||
|
new Promise((resolve) => {
|
||||||
|
const s = connect(opts, () => (s.end(), resolve(true)));
|
||||||
|
s.on("error", () => resolve(false));
|
||||||
|
});
|
||||||
|
|
||||||
|
/** @param {string} socketPath the admin socket; holding it is the authority to install */
|
||||||
|
const makeAdmin = (socketPath) => ({
|
||||||
|
install: (vat, src, egress) =>
|
||||||
|
new Promise((resolve, reject) => {
|
||||||
|
const path = egress ? `/${vat}?egress=${encodeURIComponent(egress)}` : `/${vat}`;
|
||||||
|
const req = request({ socketPath, path, method: "PUT", agent: false }, (res) => {
|
||||||
|
let body = "";
|
||||||
|
res.on("data", (c) => (body += c));
|
||||||
|
res.on("end", () => resolve(body.trim()));
|
||||||
|
});
|
||||||
|
req.on("error", reject);
|
||||||
|
req.end(src);
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
/** @param {string} base the public origin */
|
||||||
|
const makePublic = (base) => ({
|
||||||
|
get: async (path) => (await fetch(`${base}/${path}`)).text(),
|
||||||
|
put: async (vat, src) => (await fetch(`${base}/${vat}`, { method: "PUT", body: src })).status,
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Run workerd on `config` until `stop()`; resolves once `listening` accepts.
|
||||||
|
*
|
||||||
|
* @param {string} config
|
||||||
|
* @param {string[]} args socket, directory, and external overrides
|
||||||
|
* @param {{ port: number } | { path: string }} listening
|
||||||
|
* @param {string[]} stale socket files a crash leaves behind; bind() would fail on them
|
||||||
|
*/
|
||||||
|
const startWorkerd = async (config, args, listening, stale = []) => {
|
||||||
|
for (const f of stale) await rm(f, { force: true });
|
||||||
|
const child = spawn(workerd, ["serve", "--experimental", join(here, config), ...args], {
|
||||||
|
stdio: ["ignore", "ignore", "pipe"],
|
||||||
|
});
|
||||||
|
let log = "";
|
||||||
|
child.stderr.on("data", (c) => (log += c));
|
||||||
|
const exited = new Promise((resolve) => child.on("exit", resolve));
|
||||||
|
for (let i = 0; i < 100; i++) {
|
||||||
|
// SIGKILL: a crash is the harder durability test
|
||||||
|
if (await accepting(listening)) return { stop: () => (child.kill("SIGKILL"), exited) };
|
||||||
|
await new Promise((r) => setTimeout(r, 50));
|
||||||
|
}
|
||||||
|
child.kill("SIGKILL");
|
||||||
|
throw new Error(`workerd did not start: ${log}`);
|
||||||
|
};
|
||||||
|
|
||||||
|
/** An HTTP server that answers GET with "plain ok" and echoes WebSocket messages. */
|
||||||
|
const startEcho = async () => {
|
||||||
|
const server = createHttpServer((_req, res) => res.end("plain ok"));
|
||||||
|
const wss = new WebSocketServer({ server });
|
||||||
|
wss.on("connection", (ws) => ws.on("message", (m) => ws.send(String(m))));
|
||||||
|
await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve));
|
||||||
|
return {
|
||||||
|
host: `127.0.0.1:${server.address().port}`,
|
||||||
|
stop: () => new Promise((resolve) => (wss.close(), server.closeAllConnections(), server.close(resolve))),
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
let dir, port, server, switchboard, echo, admin, pub;
|
||||||
|
|
||||||
|
const startToybox = () =>
|
||||||
|
startWorkerd(
|
||||||
|
"config.capnp",
|
||||||
|
[
|
||||||
|
"-s", `http=127.0.0.1:${port}`, "-s", `admin=unix:${join(dir, "admin.sock")}`,
|
||||||
|
"-d", `state=${join(dir, "state")}`, "-e", `switchboard=unix:${join(dir, "switchboard.sock")}`,
|
||||||
|
],
|
||||||
|
{ port, host: "127.0.0.1" },
|
||||||
|
[join(dir, "admin.sock")],
|
||||||
|
);
|
||||||
|
|
||||||
|
before(async () => {
|
||||||
|
dir = await mkdtemp(join(tmpdir(), "toybox-"));
|
||||||
|
await mkdir(join(dir, "state"));
|
||||||
|
port = await freePort();
|
||||||
|
echo = await startEcho();
|
||||||
|
switchboard = await startWorkerd(
|
||||||
|
"switchboard.capnp",
|
||||||
|
["-s", `http=unix:${join(dir, "switchboard.sock")}`],
|
||||||
|
{ path: join(dir, "switchboard.sock") },
|
||||||
|
[join(dir, "switchboard.sock")],
|
||||||
|
);
|
||||||
|
server = await startToybox();
|
||||||
|
admin = makeAdmin(join(dir, "admin.sock"));
|
||||||
|
pub = makePublic(`http://127.0.0.1:${port}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
after(async () => {
|
||||||
|
await server?.stop();
|
||||||
|
await switchboard?.stop();
|
||||||
|
await echo?.stop();
|
||||||
|
await rm(dir, { recursive: true, force: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a vat with no code is 404", async () => {
|
||||||
|
assert.equal(await pub.get("nobody"), "no code installed\n");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("install, run, upgrade: the app's state carries over", async () => {
|
||||||
|
assert.match(await admin.install("alice", await app("counter-v1.js")), /^[0-9a-f]{64}$/);
|
||||||
|
assert.equal(await pub.get("alice"), "v1 count 1\n");
|
||||||
|
assert.equal(await pub.get("alice"), "v1 count 2\n");
|
||||||
|
await admin.install("alice", await app("counter-v2.js"));
|
||||||
|
assert.equal(await pub.get("alice"), "v2 count 3\n");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("vats don't share state", async () => {
|
||||||
|
await admin.install("carol", await app("counter-v1.js"));
|
||||||
|
assert.equal(await pub.get("carol"), "v1 count 1\n");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("an app gets no fetch", async () => {
|
||||||
|
await admin.install("bob", await app("try-fetch.js"));
|
||||||
|
assert.match(await pub.get("bob"), /^fetch refused: This worker is not permitted to access the internet/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("the public port can't install code", async () => {
|
||||||
|
assert.equal(await pub.put("mallory", "export class App {}"), 404);
|
||||||
|
assert.equal(await pub.get("mallory"), "no code installed\n");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("code and state survive a restart", async () => {
|
||||||
|
await admin.install("dave", await app("counter-v1.js"));
|
||||||
|
assert.equal(await pub.get("dave"), "v1 count 1\n");
|
||||||
|
await server.stop();
|
||||||
|
server = await startToybox();
|
||||||
|
assert.equal(await pub.get("dave"), "v1 count 2\n");
|
||||||
|
});
|
||||||
|
|
||||||
|
const probe = (vat, target, ws) =>
|
||||||
|
pub.get(`${vat}?url=${encodeURIComponent(`http://${target}/`)}${ws ? "&ws=1" : ""}`);
|
||||||
|
|
||||||
|
test("a vat granted a host reaches it through the switchboard: fetch and WebSocket", async () => {
|
||||||
|
await admin.install("erin", await app("egress-probe.js"), echo.host);
|
||||||
|
assert.equal(await probe("erin", echo.host), "got: plain ok\n");
|
||||||
|
assert.equal(await probe("erin", echo.host, true), "echo: hello\n");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("same code, no grant: no egress", async () => {
|
||||||
|
await admin.install("frank", await app("egress-probe.js"));
|
||||||
|
assert.equal(await probe("frank", echo.host), "no egress granted\n");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a grant is an object, not ambient: the global fetch still throws", async () => {
|
||||||
|
await admin.install("heidi", await app("try-fetch.js"), echo.host);
|
||||||
|
assert.match(await pub.get("heidi"), /^fetch refused: This worker is not permitted to access the internet/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a grant covers one host only", async () => {
|
||||||
|
await admin.install("grace", await app("egress-probe.js"), "127.0.0.1:1");
|
||||||
|
assert.equal(await probe("grace", echo.host), `got: egress to ${echo.host} not granted\n\n`);
|
||||||
|
assert.match(await probe("grace", echo.host, true), /^no websocket: 403 egress to .* not granted/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a grant survives a restart", async () => {
|
||||||
|
await server.stop();
|
||||||
|
server = await startToybox();
|
||||||
|
assert.equal(await probe("erin", echo.host, true), "echo: hello\n");
|
||||||
|
});
|
||||||
+2
-37
@@ -12,54 +12,19 @@ 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, storeId}` (and `x-webapp-id`, `x-webapp-store-id`). Neither id is logged: possession is authority.
|
- `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 /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, storeId, app}` — the fluent stub;
|
- `newWebApp({url, headers?, msPerCall?})` → `{id, 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
|
||||||
- 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
|
## 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)
|
||||||
|
|
||||||
|
|||||||
@@ -1,16 +0,0 @@
|
|||||||
{
|
|
||||||
"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
+34
-253
@@ -7,52 +7,21 @@
|
|||||||
// 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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* 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 */
|
/** structural type of a stub handle: chain to narrow, then fire */
|
||||||
export interface FluentGet {
|
export interface FluentGet {
|
||||||
pathjoin(ref: string): FluentGet;
|
pathjoin(ref: string): FluentGet;
|
||||||
@@ -60,10 +29,8 @@ 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>;
|
||||||
postForm(fields?: Record<string, string>, echoCookies?: Record<string, string>): Promise<Reply>;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -90,23 +57,17 @@ 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>;
|
||||||
#exchange: Exchange;
|
|
||||||
|
|
||||||
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>,
|
||||||
exchange: Exchange,
|
|
||||||
) {
|
) {
|
||||||
super();
|
super();
|
||||||
this.#url = url;
|
this.#url = url;
|
||||||
this.#headers = headers;
|
this.#headers = headers;
|
||||||
this.#maps = maps;
|
|
||||||
this.#send = send;
|
this.#send = send;
|
||||||
this.#exchange = exchange;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Join a relative path, merging the base's query.
|
/** Join a relative path, merging the base's query.
|
||||||
@@ -124,13 +85,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.#maps, this.#send, this.#exchange);
|
return new WebApp(unionQuery(there.href, baseParams), this.#headers, 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.#maps, this.#send, this.#exchange);
|
return new WebApp(this.#url, unionHeaders(this.#headers, h), this.#send);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** one key, flower-style (see {@link WebApp}) */
|
/** one key, flower-style (see {@link WebApp}) */
|
||||||
@@ -144,72 +105,15 @@ 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.#maps, this.#send, this.#exchange);
|
return new WebApp(unionQuery(this.#url, params), this.#headers, 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, this.#exchange);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async get(): Promise<string> {
|
async get(): Promise<string> {
|
||||||
return this.#send(this.#url, await this.#headersNow(), "GET");
|
return this.#send(this.#url, this.#headers, "GET");
|
||||||
}
|
}
|
||||||
|
|
||||||
async post(body: string): Promise<string> {
|
async post(body: string): Promise<string> {
|
||||||
return this.#send(this.#url, await this.#headersNow(), "POST", body);
|
return this.#send(this.#url, this.#headers, "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;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -245,16 +149,11 @@ 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<WebAppEnv> {
|
export class WebAppDo extends DurableObject<{ WEBAPP?: never }> {
|
||||||
#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 */
|
||||||
@@ -308,46 +207,16 @@ export class WebAppDo extends DurableObject<WebAppEnv> {
|
|||||||
}
|
}
|
||||||
return res.text();
|
return res.text();
|
||||||
};
|
};
|
||||||
const exchange: Exchange = async (url, headers, method, body) => {
|
return new WebApp(init.url, init.headers ?? {}, send);
|
||||||
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
|
/** the worker's whole HTTP surface (the default export and the
|
||||||
* WebAppFactory entrypoint share it) */
|
* WebAppFactory entrypoint share it) */
|
||||||
const defaultFetch = async (request: Request, env: WebAppEnv): Promise<Response> => {
|
const defaultFetch = async (
|
||||||
|
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
|
||||||
@@ -358,7 +227,7 @@ const defaultFetch = async (request: Request, env: WebAppEnv): Promise<Response>
|
|||||||
|
|
||||||
// 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);
|
return mint(request, env.WEBAPP_DO);
|
||||||
}
|
}
|
||||||
|
|
||||||
// the mint form, renderable for a proxying worker: ?render=1 keeps the
|
// the mint form, renderable for a proxying worker: ?render=1 keeps the
|
||||||
@@ -377,14 +246,6 @@ const defaultFetch = async (request: Request, env: WebAppEnv): Promise<Response>
|
|||||||
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 });
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -444,7 +305,10 @@ 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(request: Request, env: WebAppEnv): Promise<Response> {
|
async function mint(
|
||||||
|
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 {
|
||||||
@@ -471,35 +335,13 @@ async function mint(request: Request, env: WebAppEnv): Promise<Response> {
|
|||||||
return Response.json({ error: "bad url" }, { status: 400 });
|
return Response.json({ error: "bad url" }, { status: 400 });
|
||||||
}
|
}
|
||||||
|
|
||||||
const { id, storeId } = await mintInstance(env, init);
|
const id = ns.newUniqueId();
|
||||||
// note: ids intentionally NOT logged — they are bearer capabilities
|
const instance = ns.get(id);
|
||||||
|
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, storeId }, { headers: { "x-webapp-id": id, "x-webapp-store-id": storeId } });
|
return Response.json({ id: id.toString() }, { headers: { "x-webapp-id": id.toString() } });
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 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
|
||||||
@@ -526,60 +368,6 @@ 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 {
|
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)) {
|
||||||
@@ -608,37 +396,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, storeId, app } = await env.WEBAPP.newWebApp({
|
* const { id, app } = await env.WEBAPP.newWebApp({
|
||||||
* url: "https://diigo.example/", headers: { cookie: c },
|
* url: "https://api.github.com", headers: { authorization: `Bearer ${t}` },
|
||||||
* 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> {
|
||||||
return defaultFetch(request, this.env);
|
const env = this.env as { WEBAPP_DO: DurableObjectNamespace<WebAppDo> };
|
||||||
|
return defaultFetch(request, env);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** mint a fresh instance: unforgeable uniqueId + configured fluent stub,
|
/** mint a fresh instance: unforgeable uniqueId + configured fluent stub.
|
||||||
* plus the id of its header store (see {@link mintInstance}). The
|
* The caller decides who else ever learns the id. (If the caller only
|
||||||
* 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; storeId: string; app: FluentGet }> {
|
async newWebApp(init: WebAppInit): Promise<{ id: 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 app = await ns.get(ns.idFromString(id)).handle();
|
const id = ns.newUniqueId();
|
||||||
return { id, storeId, app };
|
const instance = ns.get(id);
|
||||||
|
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 */
|
||||||
@@ -646,13 +434,6 @@ 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));
|
||||||
|
|||||||
@@ -1,131 +0,0 @@
|
|||||||
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=");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,117 +0,0 @@
|
|||||||
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"]);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
{
|
|
||||||
"extends": "../tsconfig.json",
|
|
||||||
"compilerOptions": {
|
|
||||||
"types": ["@cloudflare/vitest-plugin/types"]
|
|
||||||
},
|
|
||||||
"include": ["./**/*", "../worker-configuration.d.ts"]
|
|
||||||
}
|
|
||||||
@@ -7,7 +7,6 @@
|
|||||||
"esModuleInterop": true,
|
"esModuleInterop": true,
|
||||||
"verbatimModuleSyntax": true,
|
"verbatimModuleSyntax": true,
|
||||||
"skipLibCheck": true,
|
"skipLibCheck": true,
|
||||||
"lib": ["ES2022", "ESNext.Disposable"],
|
|
||||||
"noEmit": true,
|
"noEmit": true,
|
||||||
"types": []
|
"types": []
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,9 +0,0 @@
|
|||||||
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,13 +5,7 @@
|
|||||||
"compatibility_date": "2026-09-29",
|
"compatibility_date": "2026-09-29",
|
||||||
"observability": { "enabled": true },
|
"observability": { "enabled": true },
|
||||||
"durable_objects": {
|
"durable_objects": {
|
||||||
"bindings": [
|
"bindings": [{ "name": "WEBAPP_DO", "class_name": "WebAppDo" }]
|
||||||
{ "name": "WEBAPP_DO", "class_name": "WebAppDo" },
|
|
||||||
{ "name": "MAP_STORE_DO", "class_name": "MapStoreDo" }
|
|
||||||
]
|
|
||||||
},
|
},
|
||||||
"migrations": [
|
"migrations": [{ "tag": "v1", "new_sqlite_classes": ["WebAppDo"] }]
|
||||||
{ "tag": "v1", "new_sqlite_classes": ["WebAppDo"] },
|
|
||||||
{ "tag": "v2", "new_sqlite_classes": ["MapStoreDo"] }
|
|
||||||
]
|
|
||||||
}
|
}
|
||||||
Reference in new issue
Block a user