Compare commits

..
Author SHA1 Message Date
dckc ff2e7841f2 capsite/webapp: postForm, a multipart POST that can echo a cookie into a field
Some sites check a double-submit token: the session value sent as a
cookie and again in the form body. postForm(fields, echoCookies) builds
a multipart body and copies the named cookies (from the header store,
at send time) into fields, so the caller never sees the value.

It returns a Reply instead of throwing on non-OK: status, content type,
content-disposition, the redirect location with its query stripped, and
the body as bytes (the caller decodes, e.g. per a charset the body
declares). Redirects are not followed, so an expired session shows up
as a 3xx. Set-Cookie is not passed back. Errors name keys, never values.

Generated-by: Claude Opus 5.5
2026-10-11 14:22:09 -05:00
dckc 7876938f3e capsite/docs: describe the wiring diagram for screen readers
The SVG gets role="img" with a <title> and a <desc> that says in prose
who refers to what, since the arrows carry the meaning and are invisible
to assistive tech. The rendered PNG is unchanged.

Generated-by: Claude Opus 5.5
2026-10-09 02:32:22 -05:00
dckc a921d65021 capsite/docs: cookie-rotation wiring diagram (PNG)
A headless-Chromium screenshot of docs/cookie-rotation-wiring.html.

Generated-by: Claude Opus 5.5
2026-10-09 02:32:22 -05:00
dckc 055b572297 capsite/docs: cookie-rotation wiring diagram (source)
Who holds what after mint: Alice holds the store id (rotate, revoke),
diigo-cache and a leaked-id Mallory hold only the instance, whose
init.headerStore reaches the store's read-only facet. Drawn as an SVG
page in the style of MarkM's ERTP slides.

Generated-by: Claude Opus 5.5
2026-10-09 02:32:22 -05:00
dckc 4dd3fe6bde capsite/diigo-cache: the wire page hands the owner the store id
webapp's mint now answers a store id (x-webapp-store-id) beside the
instance id: authority to rotate or revoke the diigo cookie. The wire
page shows it apart from the instance id, with its own copy button,
petname, and password-manager save; the cache never gets it. A webapp
that sends none gets a note on the page and a warning in the log.

Generated-by: Claude Opus 5.5
2026-10-09 02:32:22 -05:00
dckc 3f711a5f4b capsite/webapp: the cookie's store is the owner's, apart from the instance
Letting the instance id rotate the cookie let its holder (the cache, or
whoever learns the id) swap in another account's session, which the
owner's logout would not stop. Now mint makes two things: the instance,
and a header store (lib's MapStoreDo, shaped ("cookie", { keyShape:
"cookie", valueShape: M.string() })). The instance id is authority to
use the cookie; the store id, for the account owner, to rotate (PUT
/store/<storeId>/cookie, or WebAppFactory.store) or revoke (DELETE).
Instances read the store's read-only view at every send, via init's new
headerStore.

This replaces the in-instance cookie store and its migration: instances
minted earlier keep their cookie in init.headers, untouched, and simply
cannot rotate. The MapStoreDo class arrives as wrangler migration v2
(additive).

Generated-by: Claude Opus 5.5
2026-10-09 02:32:22 -05:00
dckc 357e4561c4 capsite/lib: endo's own matcher data, smallcaps, and MapStoreDo
Matchers are now exactly what @endo/patterns makes: tagged records as
from pass-style's makeTagged (M.string() is match:string with payload
[], M.any() is match:any with payload undefined). Structured clone drops
their symbol-keyed and non-enumerable properties, so they cross DO
storage and Workers RPC in marshal's smallcaps encoding (the subset
patterns need).

MapStoreDo is a MapStore as a DO of its own: its creator fixes label and
(encoded) shapes once; its id is full authority, and readOnly() hands
out the queries alone.

Generated-by: Claude Opus 5.5
2026-10-09 02:32:22 -05:00
dckc 10414c2ce6 capsite/webapp: rotate the cookie in place
The cookie moves out of the immutable init into a MapStore (key shape
"cookie", value M.string()) in the instance's DO storage. Handles get
its read-only view via the new WebApp.withHeaderMap, which reads at
every send, so a rotation reaches handles already out and the webapp
id, and with it the cache's wiring, stays put (checklist goal 6).

Whoever holds the id can rotate, by RPC (WebAppFactory.cookies(id)) or
POST /at/<id>/cookie; the diigo account owner revokes by logging out.

Durable state: instances minted earlier keep the cookie in
init.headers. The first handle() moves it to the store and rewrites
init without it, in one transaction. Code from before this commit
would then see no cookie, so rolling back past it needs the reverse
move.

webapp gains a package.json and a vitest workerd pool for the tests.

Generated-by: Claude Opus 5.5
2026-10-09 02:32:22 -05:00
dckc 9179bdbf38 capsite/lib: MapStore and patterns work-alikes
Enough of @agoric/store's MapStore and @endo/patterns' M for the
credential-rotation design: makeMapStore(label, { keyShape, valueShape },
kv) keeps entries in a DO's sync KV under store:<label>:, and readOnly()
hands out the queries alone. Facets are workerd RpcTargets in place of
endo's Far.

Unlike endo, error messages never include keys or values: the values
here are credentials, and messages cross RPC into logs.

Generated-by: Claude Opus 5.5
2026-10-09 02:32:22 -05:00
57 changed files with 5791 additions and 2471 deletions

No files matched your search

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

After

Width:  |  Height:  |  Size: 113 KiB

+16
View File
@@ -0,0 +1,16 @@
{
"name": "capsite-lib",
"private": true,
"type": "module",
"scripts": {
"types": "wrangler types -c wrangler.tests.jsonc",
"lint": "wrangler types -c wrangler.tests.jsonc --check && tsc -p tsconfig.json && tsc -p tests/tsconfig.json",
"test": "vitest run"
},
"devDependencies": {
"@cloudflare/vitest-plugin": "1.3.6",
"typescript": "^5.9.0",
"vitest": "4.1.11",
"wrangler": "^4.148.0"
}
}
+2240
View File
File diff suppressed because it is too large. Load diff
+129
View File
@@ -0,0 +1,129 @@
/**
* @file a work-alike for enough of @endo/patterns: `M.string()`, `M.any()`,
* and literal patterns (a string, number, boolean, or null matches only
* itself, so `"cookie"` as a key shape admits exactly one key). Matchers
* are the same data endo makes: tagged records, as from @endo/pass-style's
* `makeTagged` (frozen here, where endo would harden).
*
* Structured clone (DO storage, Workers RPC) drops a tagged record's
* symbol-keyed and non-enumerable properties, so patterns cross those
* boundaries in endo's smallcaps encoding: see {@link toSmallcaps}.
* @see https://github.com/endojs/endo/tree/master/packages/patterns
* @see https://github.com/endojs/endo/blob/master/packages/marshal/src/encodeToSmallcaps.js
*/
const PASS_STYLE = Symbol.for("passStyle");
export type Tagged = Readonly<{ [Symbol.toStringTag]: string; payload: unknown }>;
export type Pattern = Tagged | string | number | boolean | null;
/** @endo/pass-style's `makeTagged` */
export function makeTagged(tag: string, payload: unknown): Tagged {
return Object.freeze(
Object.create(Object.prototype, {
[PASS_STYLE]: { value: "tagged" },
[Symbol.toStringTag]: { value: tag },
payload: { value: payload, enumerable: true },
}),
) as Tagged;
}
const isTagged = (x: unknown): x is Tagged =>
typeof x === "object" && x !== null && (x as Record<symbol, unknown>)[PASS_STYLE] === "tagged";
const StringShape = makeTagged("match:string", Object.freeze([]));
const AnyShape = makeTagged("match:any", undefined);
export const M = Object.freeze({
string: (): Tagged => StringShape,
any: (): Tagged => AnyShape,
});
export function matches(specimen: unknown, pattern: Pattern): boolean {
if (typeof pattern === "object" && pattern !== null) {
const tag = isTagged(pattern) ? pattern[Symbol.toStringTag] : undefined;
switch (tag) {
case "match:string":
return typeof specimen === "string";
case "match:any":
return true;
default:
throw new TypeError("not a pattern");
}
}
return Object.is(specimen, pattern);
}
/** the pattern as written in code, e.g. `M.string()` or `"cookie"` */
export function describe(pattern: Pattern): string {
if (typeof pattern === "object" && pattern !== null) {
return `M.${pattern[Symbol.toStringTag].replace(/^match:/, "")}()`;
}
return JSON.stringify(pattern);
}
/**
* throw unless `specimen` matches `pattern`. Unlike endo's, the message
* names the pattern and `label` but never the specimen: specimens here
* can be credentials, and messages cross RPC and land in logs.
*/
export function mustMatch(specimen: unknown, pattern: Pattern, label: string): void {
if (!matches(specimen, pattern)) {
throw new TypeError(`${label}: must match ${describe(pattern)}`);
}
}
/** a smallcaps-encoded passable, as JSON-able data */
export type Smallcaps = null | boolean | number | string | Smallcaps[] | { "#tag": string; payload: Smallcaps };
/** smallcaps quotes strings that start with one of these with `!` */
const startsSpecial = (s: string) => /^[!-\-]/.test(s);
/**
* the subset of @endo/marshal's smallcaps encoding that patterns here use:
* primitives, arrays, and tagged records (not a whole CapData: no `#`
* body prefix, no slots)
*/
export function toSmallcaps(passable: unknown): Smallcaps {
if (passable === undefined) return "#undefined";
if (passable === null || typeof passable === "boolean") return passable;
if (typeof passable === "number") {
if (Number.isNaN(passable)) return "#NaN";
if (passable === Infinity) return "#Infinity";
if (passable === -Infinity) return "#-Infinity";
return Object.is(passable, -0) ? 0 : passable;
}
if (typeof passable === "string") return startsSpecial(passable) ? `!${passable}` : passable;
if (Array.isArray(passable)) return passable.map(toSmallcaps);
if (isTagged(passable)) {
return { "#tag": toSmallcaps(passable[Symbol.toStringTag]) as string, payload: toSmallcaps(passable.payload) };
}
throw new TypeError("toSmallcaps: unsupported passable");
}
/** the inverse of {@link toSmallcaps} */
export function fromSmallcaps(encoding: Smallcaps): unknown {
if (encoding === null || typeof encoding === "boolean" || typeof encoding === "number") return encoding;
if (typeof encoding === "string") {
if (encoding.startsWith("!")) return encoding.slice(1);
switch (encoding) {
case "#undefined":
return undefined;
case "#NaN":
return NaN;
case "#Infinity":
return Infinity;
case "#-Infinity":
return -Infinity;
}
if (startsSpecial(encoding)) throw new TypeError("fromSmallcaps: unsupported special string");
return encoding;
}
if (Array.isArray(encoding)) return Object.freeze(encoding.map(fromSmallcaps));
if (typeof encoding === "object" && "#tag" in encoding) {
const { "#tag": tag, payload, ...rest } = encoding;
if (Object.keys(rest).length > 0) throw new TypeError("fromSmallcaps: extra properties on a tagged record");
return makeTagged(fromSmallcaps(tag) as string, fromSmallcaps(payload));
}
throw new TypeError("fromSmallcaps: unsupported encoding");
}
+60
View File
@@ -0,0 +1,60 @@
/**
* @file a MapStore as a Durable Object of its own, so a store can be held
* apart from whoever reads it: the DO id is the store's full authority
* (e.g. the account owner's, to rotate a credential), while a reader is
* handed only {@link MapStoreDo.readOnly}.
*/
import { DurableObject } from "cloudflare:workers";
import { fromSmallcaps, matches, toSmallcaps, type Pattern, type Smallcaps } from "./patterns.js";
import { makeMapStore, type MapStore, type ReadonlyMapStore, type StoreShapes } from "./store.js";
/** store shapes as they cross RPC and sit in storage: smallcaps-encoded
* (structured clone would strip a matcher down to its payload) */
export type EncodedShapes = { keyShape?: Smallcaps; valueShape?: Smallcaps };
export const encodeShapes = ({ keyShape, valueShape }: StoreShapes): EncodedShapes => ({
...(keyShape === undefined ? {} : { keyShape: toSmallcaps(keyShape) }),
...(valueShape === undefined ? {} : { valueShape: toSmallcaps(valueShape) }),
});
const decodeShapes = ({ keyShape, valueShape }: EncodedShapes): StoreShapes => ({
...(keyShape === undefined ? {} : { keyShape: fromSmallcaps(keyShape) as Pattern }),
...(valueShape === undefined ? {} : { valueShape: fromSmallcaps(valueShape) as Pattern }),
});
type Config = { label: string; shapes: EncodedShapes };
/** a store whose label and shapes its creator fixes, once */
export class MapStoreDo extends DurableObject {
/** one-shot: fix the label and shapes (see {@link encodeShapes}); an
* identical re-configure is a no-op */
configure(label: string, shapes: EncodedShapes): void {
const decoded = decodeShapes(shapes);
for (const shape of [decoded.keyShape, decoded.valueShape]) {
if (shape !== undefined) matches(undefined, shape); // throws on a non-pattern
}
makeMapStore(label, decoded, this.ctx.storage.kv); // throws on a bad label
const config: Config = { label, shapes: encodeShapes(decoded) };
const existing = this.ctx.storage.kv.get<Config>("config");
if (existing) {
if (JSON.stringify(existing) !== JSON.stringify(config)) {
throw new Error("already configured with a different label or shapes");
}
return;
}
this.ctx.storage.kv.put("config", config);
}
/** the store, with full authority */
store(): MapStore<unknown> {
const config = this.ctx.storage.kv.get<Config>("config");
if (!config) throw new Error("not configured: this store was never set up");
return makeMapStore(config.label, decodeShapes(config.shapes), this.ctx.storage.kv);
}
/** the store's queries alone */
readOnly(): ReadonlyMapStore<unknown> {
return this.store().readOnly();
}
}
+133
View File
@@ -0,0 +1,133 @@
/**
* @file a work-alike for enough of @agoric/store's MapStore: string keys,
* shapes checked with ./patterns.js, entries kept in a Durable Object's
* synchronous KV storage. The facets are RpcTargets (workerd's / capnweb's,
* in place of endo's `Far`), so a store or its read-only view can be
* handed out by reference.
* @see https://github.com/Agoric/agoric-sdk/tree/master/packages/store
*/
import { RpcTarget } from "cloudflare:workers";
import { M, mustMatch, type Pattern } from "./patterns.js";
/** the storage a store needs: a DO's `ctx.storage.kv`, or a stand-in */
export type Kv = Pick<SyncKvStorage, "get" | "put" | "delete" | "list">;
export type StoreShapes = { keyShape?: Pattern; valueShape?: Pattern };
/**
* Entries live under `store:<label>:<key>`, so the label also names the
* storage area (as a baggage key does for endo's durable stores): two
* stores with one label on one Kv are the same store.
*
* Error messages name the label and shapes, never keys or values.
*/
export function makeMapStore<V>(label: string, { keyShape = M.string(), valueShape = M.any() }: StoreShapes, kv: Kv) {
if (label.includes(":")) throw new TypeError("store label must not contain ':'");
const prefix = `store:${label}:`;
const core: Core<V> = {
label,
has: (key) => kv.get(prefix + key) !== undefined,
get: (key) => kv.get<V>(prefix + key),
put: (key, value) => kv.put(prefix + key, value),
delete: (key) => {
kv.delete(prefix + key);
},
entries: () => [...kv.list<V>({ prefix })].map(([k, v]): [string, V] => [k.slice(prefix.length), v]),
checkKey: (key) => mustMatch(key, keyShape, `${label}: key`),
checkValue: (value) => mustMatch(value, valueShape, `${label}: value`),
};
return new MapStore<V>(core);
}
type Core<V> = {
label: string;
has(key: string): boolean;
get(key: string): V | undefined;
put(key: string, value: V): void;
delete(key: string): void;
entries(): [string, V][];
checkKey(key: unknown): void;
checkValue(value: unknown): void;
};
/** the queries */
function reads<V>(core: Core<V>) {
return {
has: (key: string): boolean => core.has(key),
get: (key: string): V => {
const value = core.get(key);
if (value === undefined) throw new RangeError(`${core.label}: key not found`);
return value;
},
keys: (): string[] => core.entries().map(([k]) => k),
values: (): V[] => core.entries().map(([, v]) => v),
entries: (): [string, V][] => core.entries(),
getSize: (): number => core.entries().length,
};
}
/** a store's queries only: what a reader of a rotating credential holds */
export class ReadonlyMapStore<V> extends RpcTarget {
#reads: ReturnType<typeof reads<V>>;
constructor(core: Core<V>) {
super();
this.#reads = reads(core);
}
has(key: string): boolean {
return this.#reads.has(key);
}
get(key: string): V {
return this.#reads.get(key);
}
keys(): string[] {
return this.#reads.keys();
}
values(): V[] {
return this.#reads.values();
}
entries(): [string, V][] {
return this.#reads.entries();
}
getSize(): number {
return this.#reads.getSize();
}
}
/** a store's full authority: the queries plus `init`/`set`/`delete`, and
* {@link MapStore.readOnly} to hand out the queries alone */
export class MapStore<V> extends ReadonlyMapStore<V> {
#core: Core<V>;
constructor(core: Core<V>) {
super(core);
this.#core = core;
}
/** add a key that is not yet present */
init(key: string, value: V): void {
this.#core.checkKey(key);
this.#core.checkValue(value);
if (this.#core.has(key)) throw new RangeError(`${this.#core.label}: key already registered`);
this.#core.put(key, value);
}
/** replace the value of a key that is present */
set(key: string, value: V): void {
this.#core.checkKey(key);
this.#core.checkValue(value);
if (!this.#core.has(key)) throw new RangeError(`${this.#core.label}: key not found`);
this.#core.put(key, value);
}
delete(key: string): void {
if (!this.#core.has(key)) throw new RangeError(`${this.#core.label}: key not found`);
this.#core.delete(key);
}
readOnly(): ReadonlyMapStore<V> {
return new ReadonlyMapStore(this.#core);
}
}
+142
View File
@@ -0,0 +1,142 @@
import { env } from "cloudflare:workers";
import { evictDurableObject } from "cloudflare:test";
import { describe, expect, it } from "vitest";
import { M, fromSmallcaps, makeTagged, matches, mustMatch, toSmallcaps } from "../src/patterns.js";
import { encodeShapes } from "../src/store-do.js";
type TestEnv = {
STORE_DO: DurableObjectNamespace<import("./test-worker.js").StoreHost>;
MAP_STORE_DO: DurableObjectNamespace<import("../src/store-do.js").MapStoreDo>;
};
const testEnv = env as unknown as TestEnv;
const SECRET = "session=s3kr1t";
/** the message an RPC call fails with, or "" if it succeeds */
async function errorOf(call: Promise<unknown>): Promise<string> {
try {
await call;
return "";
} catch (err) {
return String(err);
}
}
describe("patterns", () => {
it("M.string() matches strings only; a literal matches only itself", () => {
expect(matches("x", M.string())).toBe(true);
expect(matches(1, M.string())).toBe(false);
expect(matches(undefined, M.any())).toBe(true);
expect(matches("cookie", "cookie")).toBe(true);
expect(matches("authorization", "cookie")).toBe(false);
});
it("matchers are endo's tagged records", () => {
const s = M.string() as unknown as Record<string | symbol, unknown>;
expect(s[Symbol.for("passStyle")]).toBe("tagged");
expect(s[Symbol.toStringTag]).toBe("match:string");
expect(Object.keys(s)).toEqual(["payload"]);
expect(s.payload).toEqual([]);
expect((M.any() as unknown as Record<string, unknown>).payload).toBeUndefined();
expect(Object.isFrozen(s)).toBe(true);
});
it("smallcaps: endo's encoding, round-tripped", () => {
expect(toSmallcaps(M.string())).toEqual({ "#tag": "match:string", payload: [] });
expect(toSmallcaps(M.any())).toEqual({ "#tag": "match:any", payload: "#undefined" });
expect(toSmallcaps("#cookie")).toBe("!#cookie");
expect(toSmallcaps([NaN, -0, Infinity])).toEqual(["#NaN", 0, "#Infinity"]);
const back = fromSmallcaps(toSmallcaps(M.string())) as Parameters<typeof matches>[1];
expect(matches("x", back)).toBe(true);
expect(matches(1, back)).toBe(false);
expect(fromSmallcaps("!#cookie")).toBe("#cookie");
expect(() => matches("x", makeTagged("match:nope", []))).toThrow("not a pattern");
});
it("mustMatch names the shape but not the specimen", () => {
expect(() => mustMatch(SECRET, "cookie", "jar: key")).toThrow('jar: key: must match "cookie"');
expect(() => mustMatch(SECRET, "cookie", "jar: key")).not.toThrow(SECRET);
});
});
describe("MapStore over RPC", () => {
const fresh = () => testEnv.STORE_DO.get(testEnv.STORE_DO.newUniqueId());
it("rotates: the admin's set is what the read-only view sees next", async () => {
const store = await fresh().cookies();
const rd = await store.readOnly();
await store.init("cookie", "session=old");
expect(await rd.get("cookie")).toBe("session=old");
await store.set("cookie", "session=new");
expect(await rd.entries()).toEqual([["cookie", "session=new"]]);
});
it("the read-only view has no write methods", async () => {
const store = await fresh().cookies();
const rd = (await store.readOnly()) as unknown as { set(k: string, v: string): Promise<void> };
expect(await errorOf(rd.set("cookie", SECRET))).not.toBe("");
expect(await store.has("cookie")).toBe(false);
});
it("the key shape admits only `cookie`; errors carry no values", async () => {
const store = await fresh().cookies();
const err = await errorOf(store.init("authorization", SECRET));
expect(err).toContain('cookie: key: must match "cookie"');
expect(err).not.toContain(SECRET);
expect(await errorOf(store.init("cookie", 42 as unknown as string))).toContain("cookie: value: must match M.string()");
});
it("init refuses a present key; set and get refuse a missing one", async () => {
const store = await fresh().cookies();
expect(await errorOf(store.set("cookie", SECRET))).toContain("cookie: key not found");
expect(await errorOf(store.get("cookie"))).toContain("cookie: key not found");
await store.init("cookie", SECRET);
const err = await errorOf(store.init("cookie", SECRET));
expect(err).toContain("cookie: key already registered");
expect(err).not.toContain(SECRET);
});
it("survives eviction: entries live in DO storage", async () => {
const stub = fresh();
{
// disposed before eviction: a live store stub holds the DO open
using store = await stub.cookies();
await store.init("cookie", "session=durable");
}
await evictDurableObject(stub);
using again = await stub.cookies();
expect(await again.get("cookie")).toBe("session=durable");
expect(await again.getSize()).toBe(1);
await again.delete("cookie");
expect(await again.keys()).toEqual([]);
});
});
describe("MapStoreDo", () => {
const COOKIE_SHAPES = encodeShapes({ keyShape: "cookie", valueShape: M.string() });
const fresh = () => testEnv.MAP_STORE_DO.get(testEnv.MAP_STORE_DO.newUniqueId());
it("keeps the shapes its creator chose, across eviction", async () => {
const stub = fresh();
await stub.configure("cookie", COOKIE_SHAPES);
await evictDurableObject(stub);
{
using store = await stub.store();
expect(await errorOf(store.init("authorization", SECRET))).toContain('cookie: key: must match "cookie"');
expect(await errorOf(store.init("cookie", 42))).toContain("cookie: value: must match M.string()");
await store.init("cookie", "session=a");
}
using rd = await stub.readOnly();
expect(await rd.entries()).toEqual([["cookie", "session=a"]]);
});
it("configures once: the same shapes again is a no-op, others are refused", async () => {
const stub = fresh();
await stub.configure("cookie", COOKIE_SHAPES);
await stub.configure("cookie", encodeShapes({ keyShape: "cookie", valueShape: M.string() }));
expect(await errorOf(stub.configure("cookie", encodeShapes({ keyShape: M.string() })))).toContain(
"already configured",
);
expect(await errorOf(fresh().store())).toContain("not configured");
});
});
+17
View File
@@ -0,0 +1,17 @@
/** @file the tests' main module: a DO that hosts stores on its own storage. */
import { DurableObject } from "cloudflare:workers";
import { M } from "../src/patterns.js";
import { makeMapStore, type MapStore } from "../src/store.js";
export { MapStoreDo } from "../src/store-do.js";
/** hosts the store from the credential-rotation sketch: one key, `cookie` */
export class StoreHost extends DurableObject {
cookies(): MapStore<string> {
return makeMapStore<string>("cookie", { keyShape: "cookie", valueShape: M.string() }, this.ctx.storage.kv);
}
}
export default {
fetch: () => new Response("store tests", { status: 404 }),
};
+7
View File
@@ -0,0 +1,7 @@
{
"extends": "../tsconfig.json",
"compilerOptions": {
"types": ["@cloudflare/vitest-plugin/types"]
},
"include": ["./**/*", "../src/**/*", "../worker-configuration.d.ts"]
}
+15
View File
@@ -0,0 +1,15 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "preserve",
"moduleResolution": "bundler",
"strict": true,
"esModuleInterop": true,
"verbatimModuleSyntax": true,
"skipLibCheck": true,
"lib": ["ES2022", "ESNext.Disposable"],
"noEmit": true,
"types": []
},
"include": ["src/**/*", "worker-configuration.d.ts"]
}
+9
View File
@@ -0,0 +1,9 @@
import { defineProject } from "vitest/config";
import { cloudflareTest } from "@cloudflare/vitest-plugin";
export default defineProject({
plugins: [cloudflareTest({ wrangler: { configPath: "./wrangler.tests.jsonc" } })],
test: {
include: ["tests/**/*.test.ts"],
},
});
+17
View File
@@ -0,0 +1,17 @@
{
"$schema": "node_modules/wrangler/config-schema.json",
// a host DO for the stores under test (lib ships no worker of its own)
"name": "capsite-lib-tests",
"main": "./tests/test-worker.ts",
"compatibility_date": "2026-09-29",
"durable_objects": {
"bindings": [
{ "name": "STORE_DO", "class_name": "StoreHost" },
{ "name": "MAP_STORE_DO", "class_name": "MapStoreDo" }
]
},
"exports": {
"StoreHost": { "type": "durable-object", "storage": "sqlite" },
"MapStoreDo": { "type": "durable-object", "storage": "sqlite" }
}
}
-4
View File
@@ -1,4 +0,0 @@
state/
admin.sock
switchboard-*.sock
dist/
-68
View File
@@ -1,68 +0,0 @@
# 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. Vats have no names: creating one returns its id, which addresses it only on the admin socket. Everything else a vat can do or be reached by is granted explicitly: egress by labels it may fetch within, ingress by labels it is mounted at.
- [supervisor.js](supervisor.js): `Vat` stores code by SHA-256 and runs the app on it; `Directory` holds the mounts; `Public` (the ingress) and `Admin` are the two entrypoints; `Egress` is an egress grant.
- [switchboard.js](switchboard.js): a separate workerd process, the vats' only way out, after [Flower](https://github.com/NuxiNL/flower)'s switchboard: each of its sockets serves a Cap'n Web `Handle` with fixed labels, and `constrain(labels)` returns a narrower one.
- [labels.js](labels.js): Flower's label rule, shared by both: labels only accumulate, a missing key matches anything, and a key both sides set must agree. Ingress lookup is stricter (below).
- [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` | reach the vat mounted at labels the request carries |
| `admin` | `Admin` | `POST /` with an ES module body: create a vat and start it; the reply is `{"vat": <id>, "hash": <code hash>}`. `PUT /<id>` with a body: upgrade it; its storage carries over. `PUT /by-name/<name>`: the same, for a vat from before ids. |
Every start states the vat's grants, as JSON labels in query parameters; absent means none:
- `?egress=<labels>`: what the app may fetch (below).
- `?mount=<labels>`: where the ingress finds it. A mount must conflict with every other one (some shared key, different values), so no request can carry two mounts' labels; an overlapping mount is a 409.
## Ingress
A request's labels are its query parameters; a key given twice is a 400. Routing carries no secrets: a URL ends up in logs and `Referer`s. A secret travels in-band, as on a webkey page: the page keeps it in its fragment, reaches the vat by public labels, and sends it in the body of a call, which the vat checks like a swissnum.
The request reaches the vat whose mount labels it carries, every one with the same value. That is stricter than Flower's match (no conflicting key): an ingress client holds no constrained handle that supplies labels for it, so it reaches a vat only by naming every label of its mount. The path is the app's own. The app sees the mount's and the request's labels together in `Toybox-Connection-Labels` (JSON), as a Flower server gets its connection labels. Responses carry `Referrer-Policy: no-referrer`.
## Egress
An app must export `class App extends DurableObject` (see [apps/](apps/)). Its global `fetch` always throws: nothing in a vat is ambient. A grant arrives as `env.egress`, an `Egress` with the labels in `ctx.props`. Its `fetch`, WebSocket upgrades included, goes through a switchboard handle constrained to those labels; the switchboard refuses the rest, which comes back as a 403. `env.egress.constrain(labels)` returns a narrower `Egress`. It is an object, so the app can pass it to whichever of its parts should have it.
| label | constrains |
|---|---|
| `scheme` | `http` or `https` |
| `host` | host name or address, no port |
| `port` | explicit, or the scheme's default |
| `network` | set by the switchboard socket: `public` or `local` |
The switchboard has two sockets, so two root handles: `public` (no loopback or private addresses) and `local`. toybox holds one, as an external service (`-e switchboard=unix:…`): `pnpm start` and the systemd units give it `public`; the tests give theirs `local` to reach an echo server.
## Run it
```sh
pnpm install
pnpm test # real workerds: createVat, upgrade, mounts, egress grants and constrain, fetch and WebSocket out, crash and restart
pnpm switchboard # systemd-socket-activate on ./switchboard-public.sock and ./switchboard-local.sock
pnpm start # likewise on 127.0.0.1:8787 and ./admin.sock; state in ./state
```
Then, in a third terminal:
```sh
admin() { curl -sg --unix-socket admin.sock "$@"; }
alice=$(admin -X POST --data-binary @apps/counter-v1.js 'http://toybox/?mount={"vat":"alice"}' | jq -r .vat)
curl 'localhost:8787/?vat=alice' # v1 count 1
admin -X PUT --data-binary @apps/counter-v2.js "http://toybox/$alice?mount={\"vat\":\"alice\"}"
curl 'localhost:8787/?vat=alice' # v2 count 2
admin -X POST --data-binary @apps/egress-probe.js 'http://toybox/?mount={"vat":"erin"}&egress={"host":"example.com"}'
curl 'localhost:8787/?vat=erin&url=https://example.com/' # got: <!doctype html>…
curl 'localhost:8787/?vat=erin&url=https://example.org/' # got: egress refused: 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 `public` socket, which only members of the `toybox` group can open. The switchboard binds its `local` socket in its own 0700 runtime directory, so no other unit holds that handle.
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".
-8
View File
@@ -1,8 +0,0 @@
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`);
}
}
-8
View File
@@ -1,8 +0,0 @@
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`);
}
}
-27
View File
@@ -1,27 +0,0 @@
import { DurableObject } from "cloudflare:workers";
/** `?url=<target>` fetches it through `env.egress` (narrowed by `&constrain=<labels as JSON>`, if given); 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");
if (!this.env.egress) return new Response("no egress granted\n");
try {
const egress = params.has("constrain")
? await this.env.egress.constrain(JSON.parse(params.get("constrain")))
: this.env.egress;
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`);
}
}
}
-9
View File
@@ -1,9 +0,0 @@
import { DurableObject } from "cloudflare:workers";
/** Reports its path and its connection labels. */
export class App extends DurableObject {
fetch(request) {
const { pathname } = new URL(request.url);
return new Response(`${pathname} ${request.headers.get("Toybox-Connection-Labels")}\n`);
}
}
-7
View File
@@ -1,7 +0,0 @@
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`); }
}
}
-16
View File
@@ -1,16 +0,0 @@
/** @file Bundle the supervisor and the OCapN vat runtime for workerd (see config.capnp). */
import { build } from "esbuild";
const common = {
bundle: true,
format: "esm",
platform: "neutral",
mainFields: ["module", "main"],
conditions: ["workerd", "import"],
external: ["cloudflare:workers", "node:*"],
logLevel: "warning",
};
await build({ ...common, entryPoints: ["supervisor.js"], outfile: "dist/supervisor.js", external: [...common.external, "vat-runtime.txt"] });
// loaded as text, beside the vat's own module (vat.js)
await build({ ...common, entryPoints: ["vat-runtime.js"], outfile: "dist/vat-runtime.js", external: [...common.external, "./vat.js"] });
-33
View File
@@ -1,33 +0,0 @@
/**
* @file An OCapN client for the admin socket, from Node: it registers its
* own gateway with the router there and reaches every other peer through
* it. Run under SES (see test/lockdown.js).
*/
import { makeClient, swissnumFromBytes } from "@endo/ocapn";
import { newWebSocketRpcSession } from "capnweb";
import WebSocket from "ws";
import { locationOf, makeRpcNetLayer } from "./netlayer.js";
/**
* An OCapN client on the admin socket: registers its own gateway with the
* router there, and reaches every other peer through it.
*
* @param {string} adminSock
* @param {string} designator
* @param {{ verbose?: boolean }} [opts] OCapN logging
*/
export const connectClient = async (adminSock, designator, { verbose = false } = {}) => {
const ws = new WebSocket(`ws+unix:${adminSock}:/`);
await new Promise((resolve, reject) => (ws.once("open", resolve), ws.once("error", reject)));
const session = newWebSocketRpcSession(ws);
const ocapn = makeClient({ debugLabel: designator, verbose });
const netlayer = await ocapn.registerNetlayer(
makeRpcNetLayer({ designator, openGateway: (d) => session.gateway(d) }),
);
await session.register(designator, netlayer.gateway);
const vatAdmin = await ocapn.enlivenSturdyRef(
ocapn.makeSturdyRef(locationOf("vatAdmin"), swissnumFromBytes(new TextEncoder().encode("vatAdmin"))),
);
return { vatAdmin, close: () => (ocapn.shutdown(), ws.close()) };
};
-46
View File
@@ -1,46 +0,0 @@
# 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: one switchboard socket, i.e. one handle (see switchboard.capnp)
(name = "switchboard", external = (address = "unix:switchboard.sock", http = ())),
(name = "state", disk = (path = "state", writable = true)),
],
sockets = [
# the ingress: reach the vat mounted at a request's labels
(name = "http", http = (), service = (name = "main", entrypoint = "Public")),
# create vats, install code, grant egress and ingress
(name = "admin", http = (), service = (name = "main", entrypoint = "Admin")),
],
);
const supervisor :Workerd.Worker = (
compatibilityDate = "2026-10-01",
# bundled by build.js
modules = [
(name = "supervisor.js", esModule = embed "dist/supervisor.js"),
(name = "vat-runtime.txt", text = embed "dist/vat-runtime.js"),
],
durableObjectNamespaces = [
(className = "Vat", uniqueKey = "toybox-vat", enableSql = true),
(className = "Directory", uniqueKey = "toybox-directory", enableSql = true),
(className = "VatAdmin", uniqueKey = "toybox-vat-admin", enableSql = true),
],
durableObjectStorage = (localDisk = "state"),
bindings = [
(name = "VATS", durableObjectNamespace = "Vat"),
(name = "DIRECTORY", durableObjectNamespace = "Directory"),
(name = "VAT_ADMIN", durableObjectNamespace = "VatAdmin"),
(name = "LOADER", workerLoader = ()),
(name = "SWITCHBOARD", service = "switchboard"),
],
globalOutbound = "nowhere",
);
-53
View File
@@ -1,53 +0,0 @@
/**
* @file Egress labels, after [Flower](https://github.com/NuxiNL/flower)'s
* switchboard: string key-value pairs that only accumulate. A missing key
* matches anything; a key present on both sides must agree.
*
* Keys that constrain a URL: `scheme` (`http`, `https`), `host` (name or
* address, no port), `port` (explicit or the scheme's default). The
* switchboard sets `network` per socket; it constrains no URL.
*/
const DEFAULT_PORT = { http: "80", https: "443" };
/**
* Union of two label sets; throws on a key they give different values,
* naming the key but not the values (they may end up in logs).
*
* @param {Record<string, string>} held
* @param {Record<string, string>} more
*/
export const mergeLabels = (held, more) => {
const merged = { ...held };
for (const [k, v] of Object.entries(more)) {
if (typeof v !== "string") throw new TypeError(`label ${k} is not a string`);
if (k in merged && merged[k] !== v) throw new Error(`label ${k} is already set to a different value`);
merged[k] = v;
}
return Object.freeze(merged);
};
/**
* The labels a URL carries.
*
* @param {string} url
*/
export const urlLabels = (url) => {
const u = new URL(url);
const scheme = u.protocol.replace(/:$/, "");
return { scheme, host: u.hostname, port: u.port || DEFAULT_PORT[scheme] || "" };
};
/**
* Throws unless `url` is within `labels`.
*
* @param {Record<string, string>} labels
* @param {string} url
*/
export const checkUrl = (labels, url) => {
const actual = urlLabels(url);
for (const [k, v] of Object.entries(actual)) {
if (k in labels && labels[k] !== v) throw new Error(`egress to ${actual.host} not granted`);
}
};
-53
View File
@@ -1,53 +0,0 @@
/**
* @file The ingress's mounts, in SQL: which vat serves which label set.
*
* Labels sit one per row, so a lookup can use an index; `mount.labels`
* keeps the whole set as JSON. Request and mount labels arrive as JSON
* objects, split into rows by `json_each`.
*/
export const MOUNT_SCHEMA = [
"CREATE TABLE IF NOT EXISTS mount (vat TEXT PRIMARY KEY, labels TEXT)",
"CREATE TABLE IF NOT EXISTS mount_label (vat TEXT, key TEXT, value TEXT, PRIMARY KEY (vat, key))",
"CREATE INDEX IF NOT EXISTS mount_label_kv ON mount_label (key, value)",
// mounts made before mount_label existed
`INSERT OR IGNORE INTO mount_label
SELECT m.vat, j.key, j.value FROM mount m, json_each(m.labels) j
WHERE NOT EXISTS (SELECT 1 FROM mount_label l WHERE l.vat = m.vat)`,
];
/**
* Another mount (than ?2's) whose labels conflict with none of ?1's.
* Flower's registration rule: every mount must conflict with every other.
*/
export const OVERLAP = `
SELECT 1 FROM mount m WHERE m.vat != ?2 AND NOT EXISTS (
SELECT 1 FROM mount_label l JOIN json_each(?1) r ON r.key = l.key
WHERE l.vat = m.vat AND l.value != r.value)
LIMIT 1`;
export const MOUNT = "INSERT OR REPLACE INTO mount VALUES (?1, ?2)";
export const MOUNT_LABELS = "INSERT INTO mount_label SELECT ?1, key, value FROM json_each(?2)";
export const UNMOUNT = "DELETE FROM mount WHERE vat = ?1";
export const UNMOUNT_LABELS = "DELETE FROM mount_label WHERE vat = ?1";
/**
* The mount whose every label ?1 (the request's labels) carries, with the
* same value: the labels that match, counted per vat, against how many
* the vat has. A mount with no labels matches any request.
*
* Stricter than Flower's match (no conflict): an ingress client holds no
* constrained handle that supplies labels for it, so a request reaches a
* vat only by naming every label of its mount.
*/
export const LOOKUP = `
SELECT m.vat, m.labels FROM mount m JOIN (
SELECT l.vat, count(*) AS hits
FROM json_each(?1) r JOIN mount_label l ON l.key = r.key AND l.value = r.value
GROUP BY l.vat
) h ON h.vat = m.vat
WHERE h.hits = (SELECT count(*) FROM mount_label WHERE vat = m.vat)
UNION ALL
SELECT m.vat, m.labels FROM mount m
WHERE NOT EXISTS (SELECT 1 FROM mount_label l WHERE l.vat = m.vat)
LIMIT 1`;
-133
View File
@@ -1,133 +0,0 @@
/**
* @file An OCapN netlayer whose transport is an RPC session: Cap'n Web, or
* Workers RPC, or one proxied through the other.
*
* Each OCapN connection is a pair of {@link ByteInbox}es, one per
* direction, exchanged through a gateway; each `deliver` call carries
* exactly one OCapN message, so no framing is needed. The RPC session
* provides ordering and only-the-peer-can-inject; OCapN does the rest.
*
* After vattr97's ertp-clerk (`ocapn-capnweb-netlayer.ts`), with the stub
* methods only Cap'n Web has (`onRpcBroken`) made optional.
*
* @see https://github.com/ocapn/ocapn/blob/main/draft-specifications/Netlayers.md
*/
import { RpcTarget } from "capnweb";
export const TRANSPORT = "capnweb";
/**
* A peer's location, by designator alone: a router finds the peer.
*
* @param {string} designator
*/
export const locationOf = (designator) => ({ type: "ocapn-peer", transport: TRANSPORT, designator, hints: false });
/** Same as `locationToLocationId` in `@endo/ocapn`, which its exports map doesn't expose. */
const locationId = ({ designator, transport }) => `ocapn://${designator}.${transport}`;
/** Receives one direction of an OCapN connection, one message per call. */
export class ByteInbox extends RpcTarget {
#onMessage;
constructor(onMessage) {
super();
this.#onMessage = onMessage;
}
/** @param {Uint8Array} bytes */
deliver(bytes) {
// OCapN's syrup reader needs offset 0; a pooled Buffer view may not have it.
this.#onMessage(bytes.byteOffset === 0 ? bytes : new Uint8Array(bytes));
}
}
const dispose = (stub) => stub?.[Symbol.dispose]?.();
/** Write to a remote inbox, discarding each result at once so the session retains none. */
const makeSocket = (inbox, close) => ({
write: (bytes) => dispose(inbox.deliver(bytes)),
end: close,
});
/**
* A remote inbox reached through a gateway that is only a promise (a
* local async call, not a pipelining stub): writes queue in order behind it.
*/
const deferredInbox = (gatewayP, myInbox) => {
const inboxP = Promise.resolve(gatewayP).then((gateway) => gateway.connect(myInbox));
return {
deliver: (bytes) => void inboxP.then((inbox) => dispose(inbox.deliver(bytes))),
onRpcBroken: (cb) => void inboxP.then((inbox) => inbox.onRpcBroken?.(cb), cb),
};
};
/**
* Make an OCapN netlayer for `client.registerNetlayer`.
*
* @param {object} opts
* @param {string} opts.designator this peer's designator
* @param {(designator: string) => any} opts.openGateway the gateway of
* the peer with this designator: a stub or pipelining promise
* (`openGateway(d).connect(...)`), or a plain promise for one
* @returns a netlayer maker; the netlayer has a `gateway` that accepts
* incoming connections
*/
export const makeRpcNetLayer = ({ designator, openGateway }) => {
const location = locationOf(designator);
const connections = new Set();
return (handlers, logger) => {
const track = (connection, inbox) => {
connections.add(connection);
inbox.onRpcBroken?.((reason) => {
if (connections.delete(connection)) logger.log("rpc netlayer: connection broken", reason);
handlers.handleConnectionClose(connection, reason);
});
};
const netlayer = {
location,
locationId: locationId(location),
connect: (peer) => {
const gateway = openGateway(peer.designator);
let connection;
const myInbox = new ByteInbox((bytes) => handlers.handleMessageData(connection, bytes));
// Pipelined: writes before the peer answers queue in order.
const peerInbox =
typeof gateway.connect === "function" ? gateway.connect(myInbox) : deferredInbox(gateway, myInbox);
connection = handlers.makeConnection(
netlayer,
true,
makeSocket(peerInbox, () => {
connections.delete(connection);
dispose(gateway);
}),
);
track(connection, peerInbox);
return connection;
},
shutdown: () => {
for (const connection of [...connections]) connection.end();
},
};
class Gateway extends RpcTarget {
connect(peerInbox) {
const peer = peerInbox.dup?.() ?? peerInbox;
const connection = handlers.makeConnection(
netlayer,
false,
makeSocket(peer, () => {
connections.delete(connection);
dispose(peer);
}),
);
track(connection, peer);
return new ByteInbox((bytes) => handlers.handleMessageData(connection, bytes));
}
}
return Object.assign(netlayer, { gateway: new Gateway() });
};
};
-24
View File
@@ -1,24 +0,0 @@
{
"name": "toybox",
"version": "0.0.1",
"private": true,
"type": "module",
"scripts": {
"start": "node build.js && mkdir -p state && rm -f admin.sock && systemd-socket-activate -l 127.0.0.1:8787 -l \"$PWD/admin.sock\" -- node_modules/workerd/bin/workerd serve --experimental config.capnp -S http=3 -S admin=4 -d state=state -e \"switchboard=unix:$PWD/switchboard-public.sock\"",
"switchboard": "rm -f switchboard-public.sock switchboard-local.sock && systemd-socket-activate -l \"$PWD/switchboard-public.sock\" -l \"$PWD/switchboard-local.sock\" -- node_modules/workerd/bin/workerd serve --experimental switchboard.capnp -S public=3 -S local=4",
"test": "node build.js && node --import ./test/lockdown.js --test test/*.test.js",
"build": "node build.js",
"hello": "node --import ./test/lockdown.js scripts/hello.js"
},
"devDependencies": {
"esbuild": "^0.28.2",
"workerd": "1.20261006.1",
"ws": "8.22.0"
},
"dependencies": {
"@endo/far": "^1.1.14",
"@endo/init": "^1.1.13",
"@endo/ocapn": "1.1.1",
"capnweb": "0.12.0"
}
}
-578
View File
@@ -1,578 +0,0 @@
lockfileVersion: '9.0'
settings:
autoInstallPeers: true
excludeLinksFromLockfile: false
importers:
.:
dependencies:
'@endo/far':
specifier: ^1.1.14
version: 1.1.14
'@endo/init':
specifier: ^1.1.13
version: 1.1.13
'@endo/ocapn':
specifier: 1.1.1
version: 1.1.1
capnweb:
specifier: 0.12.0
version: 0.12.0
devDependencies:
esbuild:
specifier: ^0.28.2
version: 0.28.2
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]
'@endo/base64@1.1.1':
resolution: {integrity: sha512-6Aiy5oU/5/LYrHLbEPz0OWQ+CQsnAOC3qQYQ3nlwWi2JsU8fIZWjwfa+/+MSx8LTzITC250ZnMvtR0y/FIOQKw==}
'@endo/bytes@1.0.2':
resolution: {integrity: sha512-gKG8v4c7SD/QzULlFl1OKBuUCCy+9H78xsCL40hZn1q2/hVCNAHrxPJFO6VbA8Nk8UJP6P+NjvZY+xdrfwytMQ==}
'@endo/cache-map@1.1.0':
resolution: {integrity: sha512-owFGshs/97PDw9oguZqU/px8Lv1d0KjAUtDUiPwKHNXRVUE/jyettEbRoTbNJR1OaI8biMn6bHr9kVJsOh6dXw==}
'@endo/common@1.4.0':
resolution: {integrity: sha512-+d0CCzEYUP4PZDVGAUHMNfCEBn/GOyq/39y33N4FFUb6VPNJosfKFK5bI38mTaqwr6gxLGbzm8aeiElz+NaSDQ==}
'@endo/env-options@1.1.11':
resolution: {integrity: sha512-p9OnAPsdqoX4YJsE98e3NBVhIr2iW9gNZxHhAI2/Ul5TdRfoOViItzHzTqrgUVopw6XxA1u1uS6CykLMDUxarA==}
'@endo/errors@1.3.1':
resolution: {integrity: sha512-AmR2DSiahQR66SHlEczNWe0eUotMYE2uDwxPj9fAY2l2lovwHWGzZKXNXdQP/u62c8vetU8mG98Mh5vRZEvFTg==}
'@endo/eventual-send@1.5.0':
resolution: {integrity: sha512-mIRewS15mk+oV8y7kszyE0KWHZAlVoe98bEo/WJFJvBv1Rd83IjnofbuZcyWToWd70vgCxfkutWRJBK2Xtwd5A==}
'@endo/far@1.1.14':
resolution: {integrity: sha512-OlM+yCNWVqhwSZa6MyjkVB0oQSTAQ15KoiDGL1qowtnTnhuGUbOXuaUAsDf4eAJZ4L+h2KsJOUdyYEuhNsDPLg==}
'@endo/harden@1.1.0':
resolution: {integrity: sha512-d0CF7EVqymx9YUbkRI2sj10NrMLhrpmsaq5aBbEEbWDFxNe7F/mM8Gv8zT+iHErKEfeMCId90n5mk665SnxrRA==}
'@endo/hex@1.1.1':
resolution: {integrity: sha512-I2Q7roaHk0fE4JTj2GXfxHPR14qTFK9bOvp3nKf2Fk0r/CjgL4zFcHYolvqY15SRNmZDcywnQ+MBTW82b7BUpQ==}
'@endo/immutable-arraybuffer@2.0.0':
resolution: {integrity: sha512-xvfJvo4CgETh6UjSKwhNsFLZrqrTf2k4ZFpcprEN2UtCQRSOk+CcLP70kFbrOesB2Z/c1z9MaUZDS1JY6IlnUg==}
'@endo/init@1.1.13':
resolution: {integrity: sha512-7I94pHD0ax+WDj5dP1MKgzgSY6/GsdwCc/Ap2zFY4lzhCO944zQ4MChAW2yX28QKJK80VyJzqoW5BQ56ngEHHA==}
'@endo/lockdown@1.0.19':
resolution: {integrity: sha512-pcc6TqTBlx43422gHFzS+FrUfyJlbDoWUrynp78Mv+FrIZv4947FPcfjlXolgwvO00alMlSYxfXrrXUV93ywaA==}
'@endo/marshal@1.10.0':
resolution: {integrity: sha512-1ny6r0+XFpkpJNJEOApZQlFNB8FsDxzU2oCv1hpN1HjpszsKcTiyfXU/n/cJIs3KOOS06Tkt72pgPlasHpsVGA==}
'@endo/nat@5.2.0':
resolution: {integrity: sha512-yTNaysErNbrAWV8H2oC4e83nytG8Vzcu6VrxOkSIJQt+IVS9BUlSa1KkiKrVwu2IcIC67gJIkolPq3SgMGDjfQ==}
'@endo/ocapn@1.1.1':
resolution: {integrity: sha512-3VtHmyMsHwDPwcVusCTgtb8VXr539iZk+r8vzzOU6/cWyMhJuxRGOKR3r5EzDHVG4tY/R6tihoGt7QLnNDuceQ==}
'@endo/pass-style@1.8.2':
resolution: {integrity: sha512-63kbFNT+mropjQ0wzCkjSR5uEOT8FAIk16qHqrE+ciqSjMVd2hsW6vx10dbyo1s2xtmvXduUiTl+LqtnPa2oSw==}
'@endo/promise-kit@1.2.1':
resolution: {integrity: sha512-1nyXBOuUnVo/i47On8RCXPaT/1RIsSpJ1sGBClfZrcTtnsJfj8fElEVlJIE8NFsQiZPLNANYI+i/NYi837+s0Q==}
engines: {node: '>=11.0'}
'@endo/stream@1.3.1':
resolution: {integrity: sha512-v21VRzo5wjzw7XMHCWAmDS50hkUQi/m9C0MQTIAAqLm6w9+/Er9AEXl8FGIbz+VjtJWk5SNVbuqA51N7oBaLDQ==}
'@endo/syrup-frame@0.1.1':
resolution: {integrity: sha512-+uha+zI05RHPlSNVI7uaB2KD1EQ9gKAMcOzj1eT3y7Ng/vcW1bo7clCVVo3t+dOFOc6vYSsXgKufi40qxXRgYQ==}
'@esbuild/aix-ppc64@0.28.2':
resolution: {integrity: sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ==}
engines: {node: '>=18'}
cpu: [ppc64]
os: [aix]
'@esbuild/android-arm64@0.28.2':
resolution: {integrity: sha512-5YfKeeI8qWfBZIX+u2xZC3Zlb3Os/gLS2sbEKM+I4ZOcsWmHS2WLysCcQZDAFRslDUU5Oiq44gf6PYN1vGwG5A==}
engines: {node: '>=18'}
cpu: [arm64]
os: [android]
'@esbuild/android-arm@0.28.2':
resolution: {integrity: sha512-kXXoiPVVGQcnIYGOeaovwOURpniDBpSq4A03qkQ+BMQqtGG6HYap3xne9C1O1yo4TR3qxlCX5IqqmX6fFo2Lqg==}
engines: {node: '>=18'}
cpu: [arm]
os: [android]
'@esbuild/android-x64@0.28.2':
resolution: {integrity: sha512-O387ite7SzUyCcy3JQX4P4bLtEA7bLLkx+esve5JHnyYfNTxcVpXZo9jhdB0lTKN44gztELTdU7nS8Nr16Fs1Q==}
engines: {node: '>=18'}
cpu: [x64]
os: [android]
'@esbuild/darwin-arm64@0.28.2':
resolution: {integrity: sha512-n4KqkOQrraxHJcgjM1RvwbigfQKIKJVpM7xp+KsxiyUSrRdIXnt73VhrPAx0fV44hgfmIVKjxMN9J1t5jySVkw==}
engines: {node: '>=18'}
cpu: [arm64]
os: [darwin]
'@esbuild/darwin-x64@0.28.2':
resolution: {integrity: sha512-uq6suIWYP37qzGddBKPw5QEQPi6HiLGsO7UmkpfyaYNQ3D+rN6w6WfwH+nuqcGXWvawGwxOEroO4YGnFh95azw==}
engines: {node: '>=18'}
cpu: [x64]
os: [darwin]
'@esbuild/freebsd-arm64@0.28.2':
resolution: {integrity: sha512-n+I0BTSRIoy+d6RPKnEVwql5UwBJolytvY4mAOIEJorKlqgPII8ix6slVVrfZ5Tnj7glIZvloylbB/EJPMWEXw==}
engines: {node: '>=18'}
cpu: [arm64]
os: [freebsd]
'@esbuild/freebsd-x64@0.28.2':
resolution: {integrity: sha512-78XJTJkvPs0kz2w61301PJjXl4g7q3JqiYMZ/M/yVI73EHBrCRTgkhu9oqG7vPqq+a/yadEW8aD+agKlk5xrmg==}
engines: {node: '>=18'}
cpu: [x64]
os: [freebsd]
'@esbuild/linux-arm64@0.28.2':
resolution: {integrity: sha512-pW4AC0P3it8c7do9MVM4p51FzHzdM/TZrerurgRcHJ2WTa1VQ1CIq18xncfpBJw4ojkiZZrKW2yIBWBP92j6Ug==}
engines: {node: '>=18'}
cpu: [arm64]
os: [linux]
'@esbuild/linux-arm@0.28.2':
resolution: {integrity: sha512-XlDnu2q5yoqems+xay6wSAcg9DDD7K9RLKZEBOMZm3ckNpJBvOX20tSfby8KfrrhINDyv9V2YVZKY/SpoGJI8w==}
engines: {node: '>=18'}
cpu: [arm]
os: [linux]
'@esbuild/linux-ia32@0.28.2':
resolution: {integrity: sha512-CYbnj78HsIeA+DhgUKgFCfvNsTHFhMMrinUrMZpDXJXKN8T3XViTZ/+wtHeVxEWY8ewSzTFN+nRmSwO2tZaLUQ==}
engines: {node: '>=18'}
cpu: [ia32]
os: [linux]
'@esbuild/linux-loong64@0.28.2':
resolution: {integrity: sha512-buwkd8nsph4R+ajRvw0qM5Hja/TXQow3ptzWO2EbG/cqcIkHloRrdlBtQlshyYGTNFvfkfJ5tpPLVkY4DtsPfQ==}
engines: {node: '>=18'}
cpu: [loong64]
os: [linux]
'@esbuild/linux-mips64el@0.28.2':
resolution: {integrity: sha512-ZVykbDyk7519VwiNb9Lcj9m8XM6v5V9uKPvrEMkkEedVewf+0itkhahp4HDpgERXhwLRpWFypsGbG/J8s0QjJA==}
engines: {node: '>=18'}
cpu: [mips64el]
os: [linux]
'@esbuild/linux-ppc64@0.28.2':
resolution: {integrity: sha512-CAXl+Dtd9UUuJd8pKKdwh6MLm3MUMiqMPmhZ3tTSXPqfyQ3vDl6R5hZdZ/kYojK4ofXtdfSv1tFq8XzWx3heNQ==}
engines: {node: '>=18'}
cpu: [ppc64]
os: [linux]
'@esbuild/linux-riscv64@0.28.2':
resolution: {integrity: sha512-GeXCej4IQtU1B+QlDV8W/RRvbzI3O/Stss+/bCXv4lZls5WGRtu2a+3JkA3i4qIUlMXpcHebWpF8AkJhATowuA==}
engines: {node: '>=18'}
cpu: [riscv64]
os: [linux]
'@esbuild/linux-s390x@0.28.2':
resolution: {integrity: sha512-3H1weTYZPxt/WOhByszQZybS9w5lKzUn1FDMsgEChbHWQwHYQQRfBxgCcZvPhjHfKyJjIievvMmEUawJrdY9Dg==}
engines: {node: '>=18'}
cpu: [s390x]
os: [linux]
'@esbuild/linux-x64@0.28.2':
resolution: {integrity: sha512-4xTZr1FUmSoQW4XIWmit3tzQrUTZM+N3P0XV8xROKYF50XfI7xeO90+1bZvNwxIufQ9hDQVRJH5YhgPVF8A/HQ==}
engines: {node: '>=18'}
cpu: [x64]
os: [linux]
'@esbuild/netbsd-arm64@0.28.2':
resolution: {integrity: sha512-sSATRjPeDBg3pdgHoQfoYBob11Kk1FGa9lui5RIHZCoCkJa9QKlvl3/vKz2usCmYYjs7ymJR/2Nnsqe+Hjt5nw==}
engines: {node: '>=18'}
cpu: [arm64]
os: [netbsd]
'@esbuild/netbsd-x64@0.28.2':
resolution: {integrity: sha512-lqnzCV+mM0gIADaKihiCg6ifgfU2L3h5E33rNQBN1Y4MaVGnzryzmvvf7UHxprpQdE8hpqLolJ9Rl+SkIRDpyw==}
engines: {node: '>=18'}
cpu: [x64]
os: [netbsd]
'@esbuild/openbsd-arm64@0.28.2':
resolution: {integrity: sha512-AL2qJILH7lNjrDmCQDvdxMfAUIv8KMNZOvrwAQ8i8//ntL9FflhOyMJ8OZSMBb8/AWXe3/5v5S20y3zCoZWKoQ==}
engines: {node: '>=18'}
cpu: [arm64]
os: [openbsd]
'@esbuild/openbsd-x64@0.28.2':
resolution: {integrity: sha512-QtiuPytchRyC4rwUKhexJdQKvDuZ6hWloi3igqPQNUJCS1/v9EiO3UTOXR6A3FoMo4fnAKbWJdqaIwhOzh8qEw==}
engines: {node: '>=18'}
cpu: [x64]
os: [openbsd]
'@esbuild/openharmony-arm64@0.28.2':
resolution: {integrity: sha512-WkhYDmpTjLvGlScA1rwjRUmhl4k8oXR3cIbtqWmELgU/dFeHHlEllxDvdWcNJV9rbzCexB5vz8gtNewWLgCT7Q==}
engines: {node: '>=18'}
cpu: [arm64]
os: [openharmony]
'@esbuild/sunos-x64@0.28.2':
resolution: {integrity: sha512-GPMSkTOtMnv2U2F8gxe4Io6qmVs+YKyp832Etqqxr0hFngmXQ3rzwytelm3GIn7T4VviRUlf3sOgBOiTdvaf7g==}
engines: {node: '>=18'}
cpu: [x64]
os: [sunos]
'@esbuild/win32-arm64@0.28.2':
resolution: {integrity: sha512-PIhhEkE9uPBleRBrQEJpUn7MBnibZzbGzYWPmY3x+YoVg/95zbjB4CxPPOQ8l5tYYM4mMaCthF8/1DIfBQQyWQ==}
engines: {node: '>=18'}
cpu: [arm64]
os: [win32]
'@esbuild/win32-ia32@0.28.2':
resolution: {integrity: sha512-YmJbfTlvU7Sdn9BB+4PRES4oB6pxgS37MAONj+hBr/cpXS1aBPKXxNnDbu+QCWPj0o9dgyxeq79g6c5P8KeuYA==}
engines: {node: '>=18'}
cpu: [ia32]
os: [win32]
'@esbuild/win32-x64@0.28.2':
resolution: {integrity: sha512-5ebpxr3nWMzrL/rnUI755Jkuee0bHL/Gq0WTF9lvcpv73wAp5eu8MfBUgWK9bhWvZjj7yX8etf/8tI8Ney695g==}
engines: {node: '>=18'}
cpu: [x64]
os: [win32]
'@noble/curves@1.9.7':
resolution: {integrity: sha512-gbKGcRUYIjA3/zCCNaWDciTMFI0dCkvou3TL8Zmy5Nc7sJ47a0jtOeZoTaMxkuqRo9cRhjOdZJXegxYE5FN/xw==}
engines: {node: ^14.21.3 || >=16}
'@noble/hashes@1.8.0':
resolution: {integrity: sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==}
engines: {node: ^14.21.3 || >=16}
capnweb@0.12.0:
resolution: {integrity: sha512-jgZ/LMtMVTi+RVlooIslH78Gg23XbDTdvcLghWx3oz17D6u5GuJARt+uhZk/wcTo+f81eeabfnow4d3zLBj+JQ==}
esbuild@0.28.2:
resolution: {integrity: sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA==}
engines: {node: '>=18'}
hasBin: true
ses@2.3.0:
resolution: {integrity: sha512-qd3iWzDqGKllI2FmExKsWLrJwt+6COWb8jUdzaCn8Cq5OTeoXdQ62Gn/xLy0GWLORC3du6yBAVe/V0B0dLVKew==}
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
'@endo/base64@1.1.1': {}
'@endo/bytes@1.0.2':
dependencies:
'@endo/harden': 1.1.0
'@endo/immutable-arraybuffer': 2.0.0
'@endo/cache-map@1.1.0': {}
'@endo/common@1.4.0':
dependencies:
'@endo/errors': 1.3.1
'@endo/eventual-send': 1.5.0
'@endo/harden': 1.1.0
'@endo/promise-kit': 1.2.1
'@endo/env-options@1.1.11': {}
'@endo/errors@1.3.1':
dependencies:
'@endo/harden': 1.1.0
ses: 2.3.0
'@endo/eventual-send@1.5.0':
dependencies:
'@endo/env-options': 1.1.11
'@endo/harden': 1.1.0
'@endo/far@1.1.14':
dependencies:
'@endo/errors': 1.3.1
'@endo/eventual-send': 1.5.0
'@endo/pass-style': 1.8.2
'@endo/harden@1.1.0': {}
'@endo/hex@1.1.1':
dependencies:
'@endo/harden': 1.1.0
'@endo/immutable-arraybuffer@2.0.0': {}
'@endo/init@1.1.13':
dependencies:
'@endo/base64': 1.1.1
'@endo/eventual-send': 1.5.0
'@endo/harden': 1.1.0
'@endo/lockdown': 1.0.19
'@endo/promise-kit': 1.2.1
'@endo/lockdown@1.0.19':
dependencies:
ses: 2.3.0
'@endo/marshal@1.10.0':
dependencies:
'@endo/common': 1.4.0
'@endo/env-options': 1.1.11
'@endo/errors': 1.3.1
'@endo/eventual-send': 1.5.0
'@endo/harden': 1.1.0
'@endo/nat': 5.2.0
'@endo/pass-style': 1.8.2
'@endo/nat@5.2.0': {}
'@endo/ocapn@1.1.1':
dependencies:
'@endo/bytes': 1.0.2
'@endo/eventual-send': 1.5.0
'@endo/harden': 1.1.0
'@endo/hex': 1.1.1
'@endo/init': 1.1.13
'@endo/marshal': 1.10.0
'@endo/nat': 5.2.0
'@endo/pass-style': 1.8.2
'@endo/promise-kit': 1.2.1
'@endo/stream': 1.3.1
'@endo/syrup-frame': 0.1.1
'@noble/curves': 1.9.7
'@noble/hashes': 1.8.0
ws: 8.22.0
transitivePeerDependencies:
- bufferutil
- utf-8-validate
'@endo/pass-style@1.8.2':
dependencies:
'@endo/common': 1.4.0
'@endo/env-options': 1.1.11
'@endo/errors': 1.3.1
'@endo/eventual-send': 1.5.0
'@endo/harden': 1.1.0
'@endo/promise-kit': 1.2.1
'@endo/promise-kit@1.2.1':
dependencies:
'@endo/harden': 1.1.0
'@endo/stream@1.3.1':
dependencies:
'@endo/eventual-send': 1.5.0
'@endo/harden': 1.1.0
'@endo/promise-kit': 1.2.1
ses: 2.3.0
'@endo/syrup-frame@0.1.1':
dependencies:
'@endo/harden': 1.1.0
'@endo/init': 1.1.13
'@endo/promise-kit': 1.2.1
'@endo/stream': 1.3.1
'@esbuild/aix-ppc64@0.28.2':
optional: true
'@esbuild/android-arm64@0.28.2':
optional: true
'@esbuild/android-arm@0.28.2':
optional: true
'@esbuild/android-x64@0.28.2':
optional: true
'@esbuild/darwin-arm64@0.28.2':
optional: true
'@esbuild/darwin-x64@0.28.2':
optional: true
'@esbuild/freebsd-arm64@0.28.2':
optional: true
'@esbuild/freebsd-x64@0.28.2':
optional: true
'@esbuild/linux-arm64@0.28.2':
optional: true
'@esbuild/linux-arm@0.28.2':
optional: true
'@esbuild/linux-ia32@0.28.2':
optional: true
'@esbuild/linux-loong64@0.28.2':
optional: true
'@esbuild/linux-mips64el@0.28.2':
optional: true
'@esbuild/linux-ppc64@0.28.2':
optional: true
'@esbuild/linux-riscv64@0.28.2':
optional: true
'@esbuild/linux-s390x@0.28.2':
optional: true
'@esbuild/linux-x64@0.28.2':
optional: true
'@esbuild/netbsd-arm64@0.28.2':
optional: true
'@esbuild/netbsd-x64@0.28.2':
optional: true
'@esbuild/openbsd-arm64@0.28.2':
optional: true
'@esbuild/openbsd-x64@0.28.2':
optional: true
'@esbuild/openharmony-arm64@0.28.2':
optional: true
'@esbuild/sunos-x64@0.28.2':
optional: true
'@esbuild/win32-arm64@0.28.2':
optional: true
'@esbuild/win32-ia32@0.28.2':
optional: true
'@esbuild/win32-x64@0.28.2':
optional: true
'@noble/curves@1.9.7':
dependencies:
'@noble/hashes': 1.8.0
'@noble/hashes@1.8.0': {}
capnweb@0.12.0: {}
esbuild@0.28.2:
optionalDependencies:
'@esbuild/aix-ppc64': 0.28.2
'@esbuild/android-arm': 0.28.2
'@esbuild/android-arm64': 0.28.2
'@esbuild/android-x64': 0.28.2
'@esbuild/darwin-arm64': 0.28.2
'@esbuild/darwin-x64': 0.28.2
'@esbuild/freebsd-arm64': 0.28.2
'@esbuild/freebsd-x64': 0.28.2
'@esbuild/linux-arm': 0.28.2
'@esbuild/linux-arm64': 0.28.2
'@esbuild/linux-ia32': 0.28.2
'@esbuild/linux-loong64': 0.28.2
'@esbuild/linux-mips64el': 0.28.2
'@esbuild/linux-ppc64': 0.28.2
'@esbuild/linux-riscv64': 0.28.2
'@esbuild/linux-s390x': 0.28.2
'@esbuild/linux-x64': 0.28.2
'@esbuild/netbsd-arm64': 0.28.2
'@esbuild/netbsd-x64': 0.28.2
'@esbuild/openbsd-arm64': 0.28.2
'@esbuild/openbsd-x64': 0.28.2
'@esbuild/openharmony-arm64': 0.28.2
'@esbuild/sunos-x64': 0.28.2
'@esbuild/win32-arm64': 0.28.2
'@esbuild/win32-ia32': 0.28.2
'@esbuild/win32-x64': 0.28.2
ses@2.3.0:
dependencies:
'@endo/cache-map': 1.1.0
'@endo/env-options': 1.1.11
'@endo/immutable-arraybuffer': 2.0.0
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: {}
-20
View File
@@ -1,20 +0,0 @@
/**
* @file Create the hello-world vat on a running toybox and mount it.
*
* Usage: node --import ./test/lockdown.js scripts/hello.js [name]
* then visit http://127.0.0.1:8787/?vat=<name> (default name: hello).
*/
import { readFile } from "node:fs/promises";
import { E } from "@endo/far";
import { connectClient } from "../client.js";
const name = process.argv[2] ?? "hello";
const client = await connectClient("admin.sock", `hello-script-${process.pid}`);
try {
const ingress = await E(client.vatAdmin).makeIngress({ vat: name });
const { id, root } = await E(client.vatAdmin).createVat(await readFile("vats/hello.js", "utf8"), { ingress });
await root; // its handoff, settled before the client goes
console.log(`vat ${id}\nhttp://127.0.0.1:8787/?vat=${name}`);
} finally {
client.close();
}
-575
View File
@@ -1,575 +0,0 @@
/** @file toybox supervisor: vats with code installed at runtime, each app a facet; ingress by mount, egress by grant. */
import "@endo/init";
import { DurableObject, WorkerEntrypoint } from "cloudflare:workers";
import { RpcTarget, newWebSocketRpcSession, newWorkersRpcResponse } from "capnweb";
import { E, Far, passStyleOf } from "@endo/far";
import { makeClient, swissnumFromBytes } from "@endo/ocapn";
import { mergeLabels, urlLabels } from "./labels.js";
import { LOOKUP, MOUNT, MOUNT_LABELS, MOUNT_SCHEMA, OVERLAP, UNMOUNT, UNMOUNT_LABELS } from "./mounts-sql.js";
import { locationOf, makeRpcNetLayer } from "./netlayer.js";
import VAT_RUNTIME from "vat-runtime.txt";
/**
* A stored grant: labels as JSON, or (before labels) a bare `host[:port]`.
*
* @param {string} stored
*/
const grantLabels = (stored) => {
if (stored.startsWith("{")) return JSON.parse(stored);
const { host, port } = urlLabels(`http://${stored}`);
return stored.includes(":") ? { host, port } : { host };
};
const hex = (buf) => [...new Uint8Array(buf)].map((b) => b.toString(16).padStart(2, "0")).join("");
/**
* One vat, by DO id: holds its installed code and egress grant; the app
* facet holds the app's state. Its mount, if any, is in the {@link Directory}.
*
* 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)",
);
// absent: an app vat (exports class App), from before OCapN vats
ctx.storage.sql.exec("CREATE TABLE IF NOT EXISTS kind (k INTEGER PRIMARY KEY CHECK (k = 0), kind 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 app's grant: egress labels as JSON (see labels.js), or none
* @param {"app" | "ocapn"} kind `app`: `src` exports `class App`; `ocapn`:
* `src` exports `buildRootObject`, run by vat-runtime.js
* @returns {Promise<string>} the code's SHA-256, hex
*/
async start(src, egress, kind = "app") {
const hash = hex(await crypto.subtle.digest("SHA-256", encodeUtf8(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);
sql.exec("INSERT OR REPLACE INTO kind VALUES (0, ?)", kind);
this.ctx.facets.abort("app", new Error("upgraded"));
return hash;
}
#app({ src, n, egress, kind }) {
return this.ctx.facets.get("app", () => {
const env = egress ? { egress: this.ctx.exports.Egress({ props: { labels: grantLabels(egress) } }) } : {};
const code =
kind === "ocapn"
? {
mainModule: "runtime.js",
modules: { "runtime.js": VAT_RUNTIME, "vat.js": src },
env: { ...env, vatId: this.ctx.id.toString(), router: this.ctx.exports.Router({ props: {} }) },
}
: { mainModule: "app.js", modules: { "app.js": src }, env };
const worker = this.env.LOADER.get(`${this.ctx.id}:${n}`, () => ({
compatibilityDate: "2026-10-01",
...code,
globalOutbound: null,
}));
return { class: worker.getDurableObjectClass("App") };
});
}
#current() {
const [row] = this.ctx.storage.sql
.exec(
`SELECT b.hash, b.src, coalesce(i.n, 0) AS n, i.egress, coalesce(k.kind, 'app') AS kind
FROM current c JOIN bundle b USING (hash)
LEFT JOIN incarnation i ON i.k = 0 LEFT JOIN kind k ON k.k = 0`,
)
.toArray();
return row;
}
fetch(request) {
const row = this.#current();
if (!row) return new Response("no code installed\n", { status: 404 });
return this.#app(row).fetch(request);
}
/** An OCapN vat's gateway, for the router. */
gateway() {
const row = this.#current();
if (row?.kind !== "ocapn") throw new Error("not an OCapN vat");
return this.#app(row).gateway();
}
}
/**
* A vat's egress grant: a switchboard handle constrained to the labels in
* `ctx.props`, which no holder can change. The switchboard enforces them;
* a refusal comes back as a 403.
*/
export class Egress extends WorkerEntrypoint {
/** A narrower grant: these labels too (conflicting ones throw). */
constrain(more) {
return this.ctx.exports.Egress({ props: { labels: mergeLabels(this.ctx.props.labels, more) } });
}
async #handle() {
const resp = await this.env.SWITCHBOARD.fetch("http://switchboard/", { headers: { Upgrade: "websocket" } });
const ws = resp.webSocket;
if (!ws) throw new Error(`switchboard refused a session: ${resp.status}`);
ws.accept();
return newWebSocketRpcSession(ws).constrain(this.ctx.props.labels);
}
async fetch(request) {
try {
const handle = await this.#handle();
if (request.headers.get("Upgrade")?.toLowerCase() !== "websocket") return await handle.fetch(request);
const protocols = (request.headers.get("Sec-WebSocket-Protocol") ?? "").split(",").map((p) => p.trim()).filter(Boolean);
const { readable, writable, protocol } = await handle.webSocket(request.url, protocols);
return this.#bridge(readable, writable, protocol);
} catch (e) {
return new Response(`egress refused: ${e.message}\n`, { status: 403 });
}
}
/** A WebSocket for the vat whose messages travel the switchboard's streams. */
#bridge(readable, writable, protocol) {
const [client, server] = Object.values(new WebSocketPair());
server.accept();
const writer = writable.getWriter();
server.addEventListener("message", (e) => writer.write(e.data).catch(() => server.close(1011)));
server.addEventListener("close", () => writer.close().catch(() => {}));
this.ctx.waitUntil(
(async () => {
for await (const m of readable) server.send(m);
server.close();
})().catch(() => server.close(1011)),
);
const headers = protocol ? { "Sec-WebSocket-Protocol": protocol } : {};
return new Response(null, { status: 101, webSocket: client, headers });
}
}
/** The vatAdmin's designator, and the swissnum of its bootstrap object. */
const VAT_ADMIN = "vatAdmin";
/** The swissnum of an OCapN vat's bootstrap (see vat-runtime.js). */
const VAT_SWISSNUM = "vat";
/** UTF-8 bytes of a string: the method alone, so no encoder object is in module scope. */
const encodeUtf8 = harden(TextEncoder.prototype.encode.bind(new TextEncoder()));
/**
* The vatAdmin: an OCapN peer, and the router through which every OCapN
* peer here reaches every other. One instance, by name.
*
* Peers find each other by designator: the vatAdmin's is `vatAdmin`, a
* vat's is its id, and a client registers its own when it connects to
* the admin socket. Nothing here can dial out, so a handoff's receiver
* reaches the exporter through this router.
*/
export class VatAdmin extends DurableObject {
#clients = new Map();
/** mount key → the handler serving it: live, so lost when this object is evicted */
#handlers = new Map();
/** mount key → resolvers waiting for a handler */
#waiting = new Map();
/** mount key ↔ its ingress object, made again as needed */
#ingresses = new Map();
#ingressKeys = new WeakMap();
#reviving = new Map();
#ready;
constructor(ctx, env) {
super(ctx, env);
const { sql } = ctx.storage;
// a vat's params, encoded (see #encode); a vat absent here can't be restarted
sql.exec("CREATE TABLE IF NOT EXISTS vat_params (vat TEXT PRIMARY KEY, params TEXT)");
// which vat was given which ingress, so a request can restart it
sql.exec("CREATE TABLE IF NOT EXISTS ingress_vat (key TEXT PRIMARY KEY, vat TEXT)");
this.#ready = this.#boot();
}
/** @param {string} key */
#ingressFor(key) {
let ingress = this.#ingresses.get(key);
if (!ingress) {
ingress = Far("ingress", {
/** @param {{ handle: (request: object) => object }} handler */
serve: (handler) => {
this.#handlers.set(key, handler);
for (const resolve of this.#waiting.get(key) ?? []) resolve();
this.#waiting.delete(key);
},
});
this.#ingresses.set(key, ingress);
this.#ingressKeys.set(ingress, key);
}
return ingress;
}
/**
* `params` as JSON, an ingress by its key; `null` if anything in it can't
* be made again here (a remote object of another peer, say).
*/
#encode(params) {
const keys = [];
const walk = (v) => {
const style = passStyleOf(v);
if (style === "remotable") {
const key = this.#ingressKeys.get(v);
if (!key) throw new Error("not restartable");
keys.push(key);
return { "@ingress": key };
}
if (style === "copyArray") return v.map(walk);
if (style === "copyRecord") return Object.fromEntries(Object.entries(v).map(([k, x]) => [k, walk(x)]));
if (["string", "number", "boolean", "null"].includes(style)) return v;
throw new Error("not restartable");
};
try {
return { json: JSON.stringify(walk(params)), keys };
} catch {
return null;
}
}
#decode(json) {
const walk = (v) => {
if (Array.isArray(v)) return v.map(walk);
if (v && typeof v === "object") {
if ("@ingress" in v) return this.#ingressFor(v["@ingress"]);
return Object.fromEntries(Object.entries(v).map(([k, x]) => [k, walk(x)]));
}
return v;
};
return harden(walk(JSON.parse(json)));
}
/** (Re)start a vat's code with `params`: it rebuilds its root. */
async #startVat(vat, params) {
const vatBootstrap = await this.#enliven(vat, VAT_SWISSNUM);
return E(vatBootstrap).start(params);
}
/**
* The handler for a mount whose handler went with an evicted instance:
* restart the vat given its ingress, and wait for it to serve again.
*
* @param {string} key
*/
#revive(key) {
let reviving = this.#reviving.get(key);
if (!reviving) {
reviving = (async () => {
const [row] = this.ctx.storage.sql
.exec("SELECT i.vat, p.params FROM ingress_vat i JOIN vat_params p USING (vat) WHERE i.key = ?", key)
.toArray();
if (!row) return null;
const served = new Promise((resolve) => this.#waiting.set(key, [...(this.#waiting.get(key) ?? []), resolve]));
await this.#startVat(row.vat, this.#decode(row.params));
await Promise.race([served, new Promise((resolve) => setTimeout(resolve, 5000))]);
return this.#handlers.get(key) ?? null;
})().finally(() => this.#reviving.delete(key));
this.#reviving.set(key, reviving);
}
return reviving;
}
async #boot() {
const vatAdmin = Far("vatAdmin", {
/**
* Create an OCapN vat running `src`; `params` (any passable,
* remote objects included) reach its `buildRootObject`.
*
* If `params` holds only data and ingresses, they are kept, and the
* vat is restarted with them when a request needs it after an
* eviction or a restart.
*
* @param {string} src an ES module exporting `buildRootObject(vatPowers, params)`
*/
createVat: async (src, params = {}) => {
const id = this.env.VATS.newUniqueId();
const vat = id.toString();
await this.env.VATS.get(id).start(src, null, "ocapn");
const encoded = this.#encode(params);
if (encoded) {
const { sql } = this.ctx.storage;
sql.exec("INSERT INTO vat_params VALUES (?, ?)", vat, encoded.json);
for (const key of encoded.keys) sql.exec("INSERT OR REPLACE INTO ingress_vat VALUES (?, ?)", key, vat);
}
const root = await this.#startVat(vat, params);
return harden({ id: vat, root });
},
/**
* Reserve a mount at `labels` and return the ingress for it: whoever
* holds the ingress (a vat it is passed to, say) serves requests
* that carry those labels.
*
* @param {Record<string, string>} labels
*/
makeIngress: async (labels) => {
const key = `ingress:${crypto.randomUUID()}`;
await this.env.DIRECTORY.getByName("directory").mount(key, harden({ ...labels }));
return this.#ingressFor(key);
},
});
// workerd runs FinalizationRegistry callbacks outside any request, where
// sending the resulting GC message is forbidden I/O. So imports are
// never collected here; they go when their session does.
this.ocapn = makeClient({
debugLabel: VAT_ADMIN,
swissnumTable: new Map([[VAT_ADMIN, vatAdmin]]),
enableImportCollection: false,
});
const netlayer = await this.ocapn.registerNetlayer(
makeRpcNetLayer({ designator: VAT_ADMIN, openGateway: (designator) => this.gateway(designator) }),
);
this.own = netlayer.gateway;
}
#enliven(designator, swissnum) {
return this.ocapn.enlivenSturdyRef(
this.ocapn.makeSturdyRef(locationOf(designator), swissnumFromBytes(encodeUtf8(swissnum))),
);
}
/**
* The gateway of the peer with this designator.
*
* @param {string} designator
*/
async gateway(designator) {
await this.#ready;
if (designator === VAT_ADMIN) return this.own;
const client = this.#clients.get(designator);
if (client) return client;
let id;
try {
id = this.env.VATS.idFromString(designator);
} catch {
throw new Error("no such peer");
}
return this.env.VATS.get(id).gateway();
}
/**
* One HTTP request, as passable data, for the handler serving `key`;
* `null` when nothing serves it.
*
* @param {string} key
* @param {object} request
*/
async serveHttp(key, request) {
await this.#ready;
const handler = this.#handlers.get(key) ?? (await this.#revive(key));
return handler ? E(handler).handle(harden(request)) : null;
}
/** A WebSocket here starts a Cap'n Web session whose main object is an {@link AdminSession}. */
fetch(request) {
return newWorkersRpcResponse(request, new AdminSession(this, this.#clients));
}
}
/**
* What an OCapN client on the admin socket gets: register its own
* gateway under a designator, and reach any peer's.
*/
class AdminSession extends RpcTarget {
#vatAdmin;
#clients;
constructor(vatAdmin, clients) {
super();
this.#vatAdmin = vatAdmin;
this.#clients = clients;
}
/** @param {string} designator @param {any} gateway */
register(designator, gateway) {
if (designator === VAT_ADMIN || /^[0-9a-f]{64}$/.test(designator) || this.#clients.has(designator)) {
throw new Error("designator taken");
}
this.#clients.set(designator, gateway.dup?.() ?? gateway);
}
/** @param {string} designator */
gateway(designator) {
return this.#vatAdmin.gateway(designator);
}
}
/** A vat's way to the router: `env.router` in an OCapN vat. */
export class Router extends WorkerEntrypoint {
gateway(designator) {
return this.env.VAT_ADMIN.getByName(VAT_ADMIN).gateway(designator);
}
}
/**
* Mounts: which vat serves which label set. One instance, by name.
*
* Registration follows Flower: a mount must conflict with every other, so
* no request can carry two mounts' labels. Lookup is stricter than
* Flower's: the request must carry all of a mount's labels.
*/
export class Directory extends DurableObject {
constructor(ctx, env) {
super(ctx, env);
for (const statement of MOUNT_SCHEMA) ctx.storage.sql.exec(statement);
}
/**
* Mount `vat` at `labels`, replacing its mount; `null` unmounts it.
*
* @param {string} vat a Vat's DO id, hex
* @param {Record<string, string> | null} labels
*/
mount(vat, labels) {
const { sql } = this.ctx.storage;
this.ctx.storage.transactionSync(() => {
sql.exec(UNMOUNT_LABELS, vat);
if (!labels) return void sql.exec(UNMOUNT, vat);
const json = JSON.stringify(labels);
if (sql.exec(OVERLAP, json, vat).toArray().length) {
throw new Error("mount overlaps another: add a label whose value tells them apart");
}
sql.exec(MOUNT, vat, json);
sql.exec(MOUNT_LABELS, vat, json);
});
}
/**
* The mount whose labels `request` carries, if any. Registration keeps
* mounts pairwise conflicting, so at most one can match.
*
* @param {Record<string, string>} request
*/
lookup(request) {
const [row] = this.ctx.storage.sql.exec(LOOKUP, JSON.stringify(request)).toArray();
return row ? { vat: row.vat, labels: JSON.parse(row.labels) } : null;
}
}
/**
* A request's labels: its query parameters.
*
* Routing carries no secrets: a URL ends up in logs and `Referer`s. A
* secret travels in-band instead, as on a webkey page: the page keeps it
* in its fragment, reaches the vat by public labels, and sends it in the
* body of a call, which the vat checks like a swissnum.
*
* @param {Request} request
*/
const requestLabels = (request) => {
const labels = {};
for (const [k, v] of new URL(request.url).searchParams) {
if (k in labels) throw new Error(`label ${k} given twice`);
labels[k] = v;
}
return Object.freeze(labels);
};
/**
* The http socket: the ingress. A request reaches the vat mounted at labels
* it carries, which sees the mount's and the request's labels together in
* a `Toybox-Connection-Labels` header (JSON), as a Flower server gets its
* connection labels.
*/
export class Public extends WorkerEntrypoint {
async fetch(request) {
let labels;
try {
labels = requestLabels(request);
} catch (e) {
return new Response(`bad labels: ${e.message}\n`, { status: 400 });
}
const mount = await this.env.DIRECTORY.getByName("directory").lookup(labels);
if (!mount) return new Response("nothing mounted here\n", { status: 404 });
if (mount.vat.startsWith("ingress:")) return this.#serveOcapn(mount, labels, request);
const forwarded = new Request(request);
forwarded.headers.set("Toybox-Connection-Labels", JSON.stringify(mergeLabels(mount.labels, labels)));
const resp = await this.env.VATS.get(this.env.VATS.idFromString(mount.vat)).fetch(forwarded);
if (resp.webSocket) return resp;
const sealed = new Response(resp.body, resp);
sealed.headers.set("Referrer-Policy", "no-referrer");
return sealed;
}
/** A mount made by `makeIngress`: the request goes, as data, to whoever serves it. */
async #serveOcapn(mount, labels, request) {
const { pathname } = new URL(request.url);
const body = ["GET", "HEAD"].includes(request.method) ? "" : await request.text();
const req = { method: request.method, path: pathname, labels: mergeLabels(mount.labels, labels), body };
const res = await this.env.VAT_ADMIN.getByName(VAT_ADMIN).serveHttp(mount.vat, req);
if (!res) return new Response("mounted, but nothing serves it now\n", { status: 503 });
const resp = new Response(res.body ?? "", { status: res.status ?? 200, headers: res.headers ?? {} });
resp.headers.set("Referrer-Policy", "no-referrer");
return resp;
}
}
/**
* Grants in a query parameter, as JSON labels; absent means none.
*
* @param {URL} url
* @param {string} name
*/
const grantParam = (url, name) => {
const v = url.searchParams.get(name);
return v ? JSON.parse(v) : null;
};
/**
* The admin socket: a WebSocket upgrade reaches the {@link VatAdmin} (OCapN
* over Cap'n Web); otherwise, app vats over HTTP:
*
* - `POST /` with an ES module body creates a vat and starts it; the reply
* is `{"vat": <id>, "hash": <code hash>}`, and the id addresses it here.
* - `PUT /<id>` with a body upgrades it; its storage carries over.
* - `PUT /by-name/<name>` does the same for a vat from before ids, when
* vats were named.
*
* Every start states the vat's grants, absent meaning none:
* `?egress=<labels>` (see {@link Egress}) and `?mount=<labels>` (ingress:
* see {@link Public}).
*/
export class Admin extends WorkerEntrypoint {
async fetch(request) {
if (request.headers.get("Upgrade")?.toLowerCase() === "websocket") {
return this.env.VAT_ADMIN.getByName(VAT_ADMIN).fetch(request);
}
const url = new URL(request.url);
const [, first, second] = url.pathname.split("/");
let id;
if (request.method === "POST" && !first) id = this.env.VATS.newUniqueId();
else if (request.method === "PUT" && first === "by-name" && second) id = this.env.VATS.idFromName(second);
else if (request.method === "PUT" && first && !second) {
try {
id = this.env.VATS.idFromString(first);
} catch {
return new Response("no such vat\n", { status: 404 });
}
} else return new Response("POST / or PUT /<id> or PUT /by-name/<name>, with code\n", { status: 405 });
const vat = id.toString();
try {
await this.env.DIRECTORY.getByName("directory").mount(vat, grantParam(url, "mount"));
} catch (e) {
return new Response(`${e.message}\n`, { status: 409 });
}
const egress = url.searchParams.get("egress");
const hash = await this.env.VATS.get(id).start(await request.text(), egress);
return Response.json({ vat, hash });
}
}
export default Public;
-34
View File
@@ -1,34 +0,0 @@
# switchboard: toybox's only way out, after Flower's switchboard.
#
# Each socket is a handle with fixed labels: a WebSocket upgrade starts a
# Cap'n Web session whose main object is that handle (see switchboard.js).
# Whoever starts workerd hands the sockets in (-S/-s), so whoever holds a
# socket's fd holds that handle and no other.
using Workerd = import "/workerd/workerd.capnp";
const config :Workerd.Config = (
services = [
(name = "main", worker = .switchboard),
(name = "nowhere", network = (allow = [])),
(name = "public-net", network = (allow = ["public"], tlsOptions = (trustBrowserCas = true))),
(name = "local-net", network = (allow = ["local"], tlsOptions = (trustBrowserCas = true))),
],
sockets = [
(name = "public", http = (), service = (name = "main", props = (json = "{\"network\":\"public\"}"))),
(name = "local", http = (), service = (name = "main", props = (json = "{\"network\":\"local\"}"))),
],
);
const switchboard :Workerd.Worker = (
compatibilityDate = "2026-10-01",
modules = [
(name = "switchboard.js", esModule = embed "switchboard.js"),
(name = "labels.js", esModule = embed "labels.js"),
(name = "capnweb", esModule = embed "node_modules/capnweb/dist/index-workers.js"),
],
bindings = [
(name = "PUBLIC", service = "public-net"),
(name = "LOCAL", service = "local-net"),
],
globalOutbound = "nowhere",
);
-81
View File
@@ -1,81 +0,0 @@
/** @file switchboard: Flower-style egress handles, served over Cap'n Web. */
import { WorkerEntrypoint } from "cloudflare:workers";
import { RpcTarget, newWorkersRpcResponse } from "capnweb";
import { checkUrl, mergeLabels } from "./labels.js";
/**
* One egress handle: reaches only URLs within its labels, through the
* network its `network` label names. Like a Flower switchboard handle, it
* can only be narrowed; unlike one, it is a Cap'n Web stub, not an fd.
*/
export class Handle extends RpcTarget {
#labels;
#net;
/**
* @param {Record<string, string>} labels
* @param {Fetcher} net
*/
constructor(labels, net) {
super();
this.#labels = labels;
this.#net = net;
}
/** @param {Record<string, string>} more */
constrain(more) {
return new Handle(mergeLabels(this.#labels, more), this.#net);
}
labels() {
return this.#labels;
}
/** @param {Request} request */
fetch(request) {
checkUrl(this.#labels, request.url);
return this.#net.fetch(request);
}
/**
* Open a WebSocket to `url`; its messages cross the session as streams.
*
* @param {string} url `http:` or `https:`, as for a `fetch` upgrade
* @param {string[]} [protocols]
*/
async webSocket(url, protocols = []) {
checkUrl(this.#labels, url);
const headers = { Upgrade: "websocket" };
if (protocols.length) headers["Sec-WebSocket-Protocol"] = protocols.join(", ");
const resp = await this.#net.fetch(url, { headers });
const ws = resp.webSocket;
if (!ws) throw new Error(`no websocket: ${resp.status}`);
ws.accept();
const readable = new ReadableStream({
start(controller) {
ws.addEventListener("message", (e) => controller.enqueue(e.data));
ws.addEventListener("close", () => controller.close());
ws.addEventListener("error", () => controller.error(new Error("websocket error")));
},
});
const writable = new WritableStream({
write: (chunk) => ws.send(chunk),
close: () => ws.close(),
abort: () => ws.close(1011),
});
return { readable, writable, protocol: resp.headers.get("Sec-WebSocket-Protocol") ?? "" };
}
}
/**
* A switchboard socket: a WebSocket upgrade here starts a Cap'n Web
* session whose main object is a {@link Handle} with this socket's labels.
*/
export default class Root extends WorkerEntrypoint {
fetch(request) {
const labels = Object.freeze({ ...this.ctx.props });
const net = { public: this.env.PUBLIC, local: this.env.LOCAL }[labels.network];
if (!net) return new Response("socket has no network\n", { status: 500 });
return newWorkersRpcResponse(request, new Handle(labels, net));
}
}
-15
View File
@@ -1,15 +0,0 @@
[Unit]
Description=toybox switchboard (workerd, Cap'n Web egress handles)
Requires=toybox-switchboard.socket
[Service]
# the local (localhost) handle goes where no other unit can open it;
# hand it out deliberately, if ever
ExecStart=/opt/toybox/node_modules/workerd/bin/workerd serve --experimental /opt/toybox/switchboard.capnp -S public=3 -s local=unix:/run/toybox-switchboard/local.sock
RuntimeDirectory=toybox-switchboard
RuntimeDirectoryMode=0700
DynamicUser=yes
ProtectSystem=strict
ProtectHome=yes
PrivateTmp=yes
SystemCallFilter=@system-service
-11
View File
@@ -1,11 +0,0 @@
[Unit]
Description=toybox switchboard: the public egress handle
[Socket]
ListenStream=/run/toybox/switchboard-public.sock
# only toybox holds this handle: its service joins this group
SocketGroup=toybox
SocketMode=0660
[Install]
WantedBy=sockets.target
-19
View File
@@ -1,19 +0,0 @@
[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-public.sock
DynamicUser=yes
# to open the public 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
-13
View File
@@ -1,13 +0,0 @@
[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
-10
View File
@@ -1,10 +0,0 @@
// @endo/init/debug.js, but with `overrideTaming: 'moderate'`: Node 24's
// undici assigns `message` to its errors (ClientDestroyedError when a
// WebSocket closes), which 'min' makes a TypeError.
import '@endo/init/pre-remoting.js';
lockdown({
errorTaming: 'unsafe',
overrideTaming: 'moderate',
domainTaming: 'unsafe',
});
-53
View File
@@ -1,53 +0,0 @@
/** @file The ingress's mount SQL, on node:sqlite: lookup, overlap, and mounts stored before mount_label. */
import { DatabaseSync } from "node:sqlite";
import { test } from "node:test";
import assert from "node:assert/strict";
import { LOOKUP, MOUNT, MOUNT_LABELS, MOUNT_SCHEMA, OVERLAP } from "../mounts-sql.js";
const makeDb = () => {
const db = new DatabaseSync(":memory:");
for (const statement of MOUNT_SCHEMA) db.exec(statement);
const mount = (vat, labels) => {
const json = JSON.stringify(labels);
db.prepare(MOUNT).run(vat, json);
db.prepare(MOUNT_LABELS).run(vat, json);
};
const lookup = (request) => db.prepare(LOOKUP).get(JSON.stringify(request))?.vat ?? null;
const overlaps = (vat, labels) => !!db.prepare(OVERLAP).get(JSON.stringify(labels), vat);
return { db, mount, lookup, overlaps };
};
test("a request reaches the mount whose every label it carries", () => {
const { mount, lookup } = makeDb();
mount("a", { vat: "alice", flavor: "plain" });
mount("b", { vat: "bob" });
assert.equal(lookup({ vat: "alice", flavor: "plain", page: "2" }), "a");
assert.equal(lookup({ vat: "alice" }), null);
assert.equal(lookup({ vat: "alice", flavor: "fancy" }), null);
assert.equal(lookup({ vat: "bob" }), "b");
assert.equal(lookup({}), null);
});
test("a mount with no labels matches any request", () => {
const { mount, lookup } = makeDb();
mount("only", {});
assert.equal(lookup({ anything: "x" }), "only");
});
test("overlap: a mount must conflict with every other", () => {
const { mount, overlaps } = makeDb();
mount("a", { vat: "alice" });
assert.equal(overlaps("b", { vat: "alice", n: "2" }), true);
assert.equal(overlaps("b", { app: "x" }), true);
assert.equal(overlaps("b", { vat: "bob" }), false);
assert.equal(overlaps("a", { vat: "alice", n: "2" }), false, "a vat's own mount doesn't count");
});
test("mounts stored before mount_label are filled in when the schema runs", () => {
const db = new DatabaseSync(":memory:");
db.exec("CREATE TABLE mount (vat TEXT PRIMARY KEY, labels TEXT)");
db.prepare("INSERT INTO mount VALUES (?, ?)").run("old", JSON.stringify({ vat: "old" }));
for (const statement of MOUNT_SCHEMA) db.exec(statement);
assert.equal(db.prepare(LOOKUP).get(JSON.stringify({ vat: "old" }))?.vat, "old");
assert.equal(db.prepare(LOOKUP).get(JSON.stringify({ vat: "other" })), undefined);
});
-105
View File
@@ -1,105 +0,0 @@
/** @file OCapN vats: a client on the admin socket creates a vat and passes it a remote object. */
import { mkdir, mkdtemp, readFile, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { after, before, test } from "node:test";
import assert from "node:assert/strict";
import { E, Far } from "@endo/far";
import { connectClient } from "../client.js";
import { freePort, here, startWorkerd } from "./workerd.js";
const vatSrc = (name) => readFile(join(here, "vats", name), "utf8");
let dir, port, server, client;
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, "no-switchboard.sock")}`,
],
{ port, host: "127.0.0.1" },
[join(dir, "admin.sock")],
);
before(async () => {
dir = await mkdtemp(join(tmpdir(), "toybox-ocapn-"));
await mkdir(join(dir, "state"));
port = await freePort();
server = await startToybox();
client = await connectClient(join(dir, "admin.sock"), "client-1", { verbose: !!process.env.OCAPN_LOG });
});
after(async () => {
client?.close();
await server?.stop();
await rm(dir, { recursive: true, force: true });
});
test("createVat passes a remote object in; the vat's root comes back", async () => {
const carol = Far("carol", { greet: (name) => `hi ${name}, from carol` });
const { id, root } = await E(client.vatAdmin).createVat(await vatSrc("greeter.js"), { carol });
assert.match(id, /^[0-9a-f]{64}$/);
assert.equal(await E(root).hello(), "hello from the vat");
assert.equal(await E(root).greetVia("vat"), "hi vat, from carol");
});
test("a counter vat counts; two counter vats count apart", async () => {
const src = await vatSrc("counter.js");
const { root: a } = await E(client.vatAdmin).createVat(src);
const { root: b } = await E(client.vatAdmin).createVat(src);
assert.equal(await E(a).increment(), 1);
assert.equal(await E(a).increment(), 2);
assert.equal(await E(b).increment(), 1);
assert.equal(await E(a).read(), 2);
assert.equal(await E(b).read(), 1);
});
test("a vat serves the web through the ingress it is given", async () => {
const ingress = await E(client.vatAdmin).makeIngress({ vat: "hello" });
await E(client.vatAdmin).createVat(await vatSrc("hello.js"), { ingress });
const resp = await fetch(`http://127.0.0.1:${port}/greet?vat=hello`);
assert.equal(resp.status, 200);
assert.equal(resp.headers.get("content-type"), "text/html; charset=utf-8");
assert.match(await resp.text(), /<h1>Hello, world<\/h1><p>from a toybox vat, at \/greet<\/p>/);
});
test("an ingress nobody serves is a 503", async () => {
await E(client.vatAdmin).makeIngress({ vat: "idle" });
assert.equal((await fetch(`http://127.0.0.1:${port}/?vat=idle`)).status, 503);
});
test("after a crash, a request restarts the vat that serves it", async () => {
// its own toybox: a crash breaks every session to it, the shared client's too
const home = await mkdtemp(join(tmpdir(), "toybox-phoenix-"));
await mkdir(join(home, "state"));
const port2 = await freePort();
const start = () =>
startWorkerd(
"config.capnp",
[
"-s", `http=127.0.0.1:${port2}`, "-s", `admin=unix:${join(home, "admin.sock")}`,
"-d", `state=${join(home, "state")}`, "-e", `switchboard=unix:${join(home, "no-switchboard.sock")}`,
],
{ port: port2, host: "127.0.0.1" },
[join(home, "admin.sock")],
);
let toybox = await start();
try {
const creator = await connectClient(join(home, "admin.sock"), "creator");
const ingress = await E(creator.vatAdmin).makeIngress({ vat: "phoenix" });
const { root } = await E(creator.vatAdmin).createVat(await vatSrc("hello.js"), { ingress });
assert.equal((await fetch(`http://127.0.0.1:${port2}/?vat=phoenix`)).status, 200);
await root; // its handoff, settled before the client goes
creator.close();
await toybox.stop();
toybox = await start();
const resp = await fetch(`http://127.0.0.1:${port2}/again?vat=phoenix`);
assert.equal(resp.status, 200);
assert.match(await resp.text(), /Hello, world.*at \/again/);
} finally {
await toybox.stop();
await rm(home, { recursive: true, force: true });
}
});
-266
View File
@@ -1,266 +0,0 @@
/** @file toybox end to end: a real workerd, an admin socket, a public port, a switchboard with two handles. */
import { mkdir, mkdtemp, readFile, rm } from "node:fs/promises";
import { createServer as createHttpServer, request } from "node:http";
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";
import { freePort, here, startWorkerd } from "./workerd.js";
const app = (name) => readFile(join(here, "apps", name), "utf8");
/**
* The admin socket: holding it is the authority to create and upgrade vats.
*
* @param {string} socketPath
*/
const makeAdmin = (socketPath) => {
const send = (method, path, src, { egress, mount } = {}) =>
new Promise((resolve, reject) => {
const q = new URLSearchParams();
if (egress) q.set("egress", typeof egress === "string" ? egress : JSON.stringify(egress));
if (mount) q.set("mount", JSON.stringify(mount));
const req = request({ socketPath, path: `${path}?${q}`, method, agent: false }, (res) => {
let body = "";
res.on("data", (c) => (body += c));
res.on("end", () => resolve(res.statusCode === 200 ? JSON.parse(body) : { status: res.statusCode, body }));
});
req.on("error", reject);
req.end(src);
});
return {
/** `createVat`: a new vat, reachable only by its id here, and by its mount */
create: (src, grants) => send("POST", "/", src, grants),
upgrade: (vat, src, grants) => send("PUT", `/${vat}`, src, grants),
/** a vat from before ids */
byName: (name, src, grants) => send("PUT", `/by-name/${name}`, src, grants),
};
};
/** @param {string} base the public origin */
const makePublic = (base) => ({
get: async (pathAndQuery, headers = {}) => {
const resp = await fetch(`${base}${pathAndQuery}`, { headers });
return { status: resp.status, text: await resp.text(), referrerPolicy: resp.headers.get("Referrer-Policy") };
},
text: async (pathAndQuery) => (await fetch(`${base}${pathAndQuery}`)).text(),
put: async (pathAndQuery, src) => (await fetch(`${base}${pathAndQuery}`, { method: "PUT", body: src })).status,
});
/** An HTTP server that answers GET with "plain ok", echoes WebSocket messages, and counts requests. */
const startEcho = async () => {
let hits = 0;
const server = createHttpServer((_req, res) => (hits++, 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}`,
hits: () => hits,
stop: () => new Promise((resolve) => (wss.close(), server.closeAllConnections(), server.close(resolve))),
};
};
let dir, port, server, switchboard, echo, echoLabels, admin, pub;
/**
* A toybox in `dir/<name>` that holds one switchboard handle.
*
* @param {string} name
* @param {number} port
* @param {"public" | "local"} handle which switchboard socket it gets
*/
const startToybox = async (name, port, handle) => {
const home = join(dir, name);
await mkdir(join(home, "state"), { recursive: true });
return startWorkerd(
"config.capnp",
[
"-s", `http=127.0.0.1:${port}`, "-s", `admin=unix:${join(home, "admin.sock")}`,
"-d", `state=${join(home, "state")}`, "-e", `switchboard=unix:${join(dir, `sb-${handle}.sock`)}`,
],
{ port, host: "127.0.0.1" },
[join(home, "admin.sock")],
);
};
before(async () => {
dir = await mkdtemp(join(tmpdir(), "toybox-"));
port = await freePort();
echo = await startEcho();
echoLabels = { host: "127.0.0.1", port: echo.host.split(":")[1] };
switchboard = await startWorkerd(
"switchboard.capnp",
["-s", `public=unix:${join(dir, "sb-public.sock")}`, "-s", `local=unix:${join(dir, "sb-local.sock")}`],
{ path: join(dir, "sb-local.sock") },
[join(dir, "sb-public.sock"), join(dir, "sb-local.sock")],
);
// the tests' echo server is on localhost, so this toybox holds the local handle
server = await startToybox("main", port, "local");
admin = makeAdmin(join(dir, "main", "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 });
});
const at = (name) => ({ mount: { vat: name } });
test("nothing mounted: 404", async () => {
assert.equal((await pub.get("/?vat=nobody")).status, 404);
});
test("createVat returns an id and a code hash; unmounted, the vat is unreachable", async () => {
const { vat, hash } = await admin.create(await app("counter-v1.js"));
assert.match(vat, /^[0-9a-f]{64}$/);
assert.match(hash, /^[0-9a-f]{64}$/);
assert.equal((await pub.get("/")).status, 404);
});
test("install, run, upgrade: the app's state carries over; every start states its grants", async () => {
const { vat } = await admin.create(await app("counter-v1.js"), at("alice"));
assert.equal(await pub.text("/?vat=alice"), "v1 count 1\n");
assert.equal(await pub.text("/?vat=alice"), "v1 count 2\n");
await admin.upgrade(vat, await app("counter-v2.js"), at("alice"));
assert.equal(await pub.text("/?vat=alice"), "v2 count 3\n");
await admin.upgrade(vat, await app("counter-v2.js"));
assert.equal((await pub.get("/?vat=alice")).status, 404);
await admin.upgrade(vat, await app("counter-v2.js"), at("alice"));
assert.equal(await pub.text("/?vat=alice"), "v2 count 4\n");
});
test("vats don't share state", async () => {
await admin.create(await app("counter-v1.js"), at("bob"));
await admin.create(await app("counter-v1.js"), at("carol"));
assert.equal(await pub.text("/?vat=bob"), "v1 count 1\n");
assert.equal(await pub.text("/?vat=carol"), "v1 count 1\n");
});
test("an app gets no fetch", async () => {
await admin.create(await app("try-fetch.js"), at("bob-fetch"));
assert.match(await pub.text("/?vat=bob-fetch"), /^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("/", "export class App {}"), 404);
});
test("the path is the app's; its labels are the mount's and the request's together", async () => {
await admin.create(await app("show-labels.js"), at("lab"));
const got = await pub.get("/a/b?vat=lab&page=2");
assert.equal(got.text, `/a/b ${JSON.stringify({ vat: "lab", page: "2" })}\n`);
assert.equal(got.referrerPolicy, "no-referrer");
});
test("a label given twice is a 400", async () => {
assert.equal((await pub.get("/?vat=lab&vat=lab")).status, 400);
});
test("mounts that could both match a request are refused", async () => {
const src = await app("show-labels.js");
await admin.create(src, at("x"));
const narrower = await admin.create(src, { mount: { vat: "x", n: "2" } });
assert.equal(narrower.status, 409);
assert.match(narrower.body, /overlaps another/);
// no key in common with the {vat} mounts: ?vat=x&app=x would carry both
assert.equal((await admin.create(src, { mount: { app: "x" } })).status, 409);
assert.match((await admin.create(src, { mount: { vat: "y", n: "2" } })).vat, /^[0-9a-f]{64}$/);
});
test("a request reaches a vat only by naming every label of its mount", async () => {
await admin.create(await app("show-labels.js"), { mount: { vat: "both", flavor: "plain" } });
assert.equal((await pub.get("/?vat=both")).status, 404);
assert.equal((await pub.get("/?vat=both&flavor=fancy")).status, 404);
assert.equal((await pub.get("/?vat=both&flavor=plain")).status, 200);
});
test("routing reads no labels from headers", async () => {
const got = await pub.get("/?vat=both", { "Toybox-Labels": JSON.stringify({ flavor: "plain" }) });
assert.equal(got.status, 404);
});
test("code, state, and mount survive a restart", async () => {
await admin.create(await app("counter-v1.js"), at("dave"));
assert.equal(await pub.text("/?vat=dave"), "v1 count 1\n");
await server.stop();
server = await startToybox("main", port, "local");
assert.equal(await pub.text("/?vat=dave"), "v1 count 2\n");
});
test("a vat from before ids is unreachable until the admin mounts it by its old name", async () => {
await admin.byName("old", await app("counter-v1.js"));
assert.equal((await pub.get("/?vat=old")).status, 404);
await admin.byName("old", await app("counter-v1.js"), at("old"));
assert.equal(await pub.text("/?vat=old"), "v1 count 1\n");
});
const probe = (vat, target, { ws, constrain } = {}) =>
pub.text(
`/?vat=${vat}&url=${encodeURIComponent(`http://${target}/`)}` +
(ws ? "&ws=1" : "") +
(constrain ? `&constrain=${encodeURIComponent(JSON.stringify(constrain))}` : ""),
);
test("a vat granted a host reaches it through the switchboard: fetch and WebSocket", async () => {
await admin.create(await app("egress-probe.js"), { ...at("erin"), egress: echoLabels });
assert.equal(await probe("erin", echo.host), "got: plain ok\n");
assert.equal(await probe("erin", echo.host, { ws: true }), "echo: hello\n");
});
test("same code, no grant: no egress", async () => {
await admin.create(await app("egress-probe.js"), at("frank"));
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.create(await app("try-fetch.js"), { ...at("heidi"), egress: echoLabels });
assert.match(await pub.text("/?vat=heidi"), /^fetch refused: This worker is not permitted to access the internet/);
});
test("the switchboard refuses what a grant doesn't cover", async () => {
await admin.create(await app("egress-probe.js"), { ...at("grace"), egress: { host: "127.0.0.1", port: "1" } });
assert.match(await probe("grace", echo.host), /^got: egress refused: .*egress to 127\.0\.0\.1 not granted/);
assert.match(await probe("grace", echo.host, { ws: true }), /^no websocket: 403 egress refused: .*not granted/);
});
test("a vat can narrow its grant, but not contradict it", async () => {
await admin.create(await app("egress-probe.js"), { ...at("ivan"), egress: { scheme: "http" } });
assert.equal(await probe("ivan", echo.host, { constrain: echoLabels }), "got: plain ok\n");
assert.match(
await probe("ivan", echo.host, { constrain: { scheme: "https" } }),
/^refused: label scheme is already set to a different value/,
);
});
test("a grant stored before labels, a bare host:port, still works", async () => {
await admin.create(await app("egress-probe.js"), { ...at("kim"), egress: echo.host });
assert.equal(await probe("kim", echo.host), "got: plain ok\n");
});
test("a grant survives a restart", async () => {
await server.stop();
server = await startToybox("main", port, "local");
assert.equal(await probe("erin", echo.host, { ws: true }), "echo: hello\n");
});
test("a toybox holding only the public handle can't reach localhost, whatever the grant", async () => {
const port2 = await freePort();
const other = await startToybox("public-only", port2, "public");
try {
const admin2 = makeAdmin(join(dir, "public-only", "admin.sock"));
await admin2.create(await app("egress-probe.js"), { ...at("judy"), egress: echoLabels });
const before = echo.hits();
const got = await (
await fetch(`http://127.0.0.1:${port2}/?vat=judy&url=${encodeURIComponent(`http://${echo.host}/`)}`)
).text();
// workerd reports the public network's refusal (restrictPeers) to JS only as "internal error"
assert.match(got, /^got: egress refused: /);
assert.equal(echo.hits(), before);
} finally {
await other.stop();
}
});
-51
View File
@@ -1,51 +0,0 @@
/** @file Run real workerd processes for tests. */
import { spawn } from "node:child_process";
import { rm } from "node:fs/promises";
import { connect, createServer } from "node:net";
import { join } from "node:path";
export 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");
export 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). */
export const accepting = (opts) =>
new Promise((resolve) => {
const s = connect(opts, () => (s.end(), resolve(true)));
s.on("error", () => resolve(false));
});
/**
* 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
*/
export 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", process.env.WORKERD_LOG ? "inherit" : "ignore", "pipe"],
});
let log = "";
// WORKERD_LOG=1 echoes workerd's stderr, for debugging
child.stderr.on("data", (c) => (process.env.WORKERD_LOG ? process.stderr.write(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}`);
};
-59
View File
@@ -1,59 +0,0 @@
/**
* @file The runtime of an OCapN vat: SES, an OCapN peer, and the vat's own
* module (`vat.js`), which exports `buildRootObject(vatPowers, params)`.
*
* Bundled (see build.js) and loaded as the facet's main module beside the
* vat's code. The vat's designator is its id; it reaches other peers, and
* they reach it, through the vatAdmin's router (`env.router`).
*/
import "@endo/init";
import { DurableObject } from "cloudflare:workers";
import { E, Far } from "@endo/far";
import { makeClient } from "@endo/ocapn";
import { makeRpcNetLayer } from "./netlayer.js";
import * as vat from "./vat.js";
/**
* The swissnum of a vat's bootstrap: the vatAdmin starts the vat through it.
* Not exported: workerd takes every export of a main module for an entrypoint.
*/
const VAT_SWISSNUM = "vat";
export class App extends DurableObject {
#gateway;
constructor(ctx, env) {
super(ctx, env);
this.#gateway = this.#boot();
}
async #boot() {
const { vatId, router } = this.env;
let root;
const vatPowers = Object.freeze({ E, Far, storage: this.ctx.storage.kv });
const bootstrap = Far("vatBootstrap", {
/** (Re)build the root: on creation, and when the vatAdmin restarts the vat. */
start: (params) => {
root = vat.buildRootObject(vatPowers, params);
return root;
},
});
// workerd runs FinalizationRegistry callbacks outside any request, where
// sending the resulting GC message is forbidden I/O. So imports are
// never collected here; they go when their session does.
const ocapn = makeClient({
debugLabel: vatId.slice(0, 8),
swissnumTable: new Map([[VAT_SWISSNUM, bootstrap]]),
enableImportCollection: false,
});
const netlayer = await ocapn.registerNetlayer(
makeRpcNetLayer({ designator: vatId, openGateway: (designator) => router.gateway(designator) }),
);
return netlayer.gateway;
}
/** This vat's OCapN gateway, for the router. */
gateway() {
return this.#gateway;
}
}
-15
View File
@@ -1,15 +0,0 @@
/**
* A counter whose count lives in the vat's storage.
*
* TODO: a durable Exo once vatPowers offers a zone, e.g.
* `zone.exoClass("Counter", undefined, () => ({ count: 0 }), { incr() { this.state.count += 1; return this.state.count; } })`
*/
export const buildRootObject = ({ Far, storage }) =>
Far("counterRoot", {
increment: () => {
const n = (storage.get("count") ?? 0) + 1;
storage.put("count", n);
return n;
},
read: () => storage.get("count") ?? 0,
});
-6
View File
@@ -1,6 +0,0 @@
/** A vat that greets through the `carol` it was created with. */
export const buildRootObject = ({ E, Far }, { carol }) =>
Far("greeterRoot", {
greetVia: (name) => E(carol).greet(name),
hello: () => "hello from the vat",
});
-12
View File
@@ -1,12 +0,0 @@
/** Hello, world, served through the `ingress` it was created with. */
export const buildRootObject = ({ E, Far }, { ingress }) => {
const handler = Far("helloHandler", {
handle: ({ path }) => ({
status: 200,
headers: { "content-type": "text/html; charset=utf-8" },
body: `<!doctype html><title>hello</title><h1>Hello, world</h1><p>from a toybox vat, at ${path}</p>\n`,
}),
});
E(ingress).serve(handler);
return Far("helloRoot", {});
};
+37 -2
View File
@@ -12,19 +12,54 @@ grows past a form or two.
- `GET /` — the mint form. Prefill via query string: `?url=…&headers=<urlencoded JSON>&msPerCall=…&title=…`; secret header keys (cookie, authorization, proxy-authorization) render **blank** — paste them at mint time; never echoed back. A dedicated `cookie` field is sugar for the `cookie` header (if the JSON also has one, they must agree). - `GET /` — the mint form. Prefill via query string: `?url=…&headers=<urlencoded JSON>&msPerCall=…&title=…`; secret header keys (cookie, authorization, proxy-authorization) render **blank** — paste them at mint time; never echoed back. A dedicated `cookie` field is sugar for the `cookie` header (if the JSON also has one, they must agree).
- `GET /form?render=1&…` — the same form for a proxying worker (diigo-cache's `/_connect`): the action is left as an `ACTION` placeholder for the proxy to point at its own relay. - `GET /form?render=1&…` — the same form for a proxying worker (diigo-cache's `/_connect`): the action is left as an `ACTION` placeholder for the proxy to point at its own relay.
- `POST /` — mint. Form-encoded (that page) or JSON `{url, headers, msPerCall}` (service-style). Responds `{id}` (and `x-webapp-id`). The id is *not* logged: possession is authority. - `POST /` — mint. Form-encoded (that page) or JSON `{url, headers, msPerCall}` (service-style). Responds `{id, storeId}` (and `x-webapp-id`, `x-webapp-store-id`). Neither id is logged: possession is authority.
- `POST /at/<id>` — drive an instance by capability: JSON `{path?, query?, method?, body?}` → the fluent chain → `{status, text}` (or `{error}`; messages carry no header/param values). - `POST /at/<id>` — drive an instance by capability: JSON `{path?, query?, method?, body?}` → the fluent chain → `{status, text}` (or `{error}`; messages carry no header/param values).
- `PUT /store/<storeId>/<key>` — set a header-store entry (JSON value body), e.g. `PUT /store/<storeId>/cookie` with `"session=…"` rotates the cookie; `DELETE` removes it. → `{ok: true}` (or `{error}`; messages carry no keys or values).
## RPC surface (same-account workers; binding `entrypoint: "WebAppFactory"`) ## RPC surface (same-account workers; binding `entrypoint: "WebAppFactory"`)
- `fetch(request)` — the HTTP surface above, over the binding - `fetch(request)` — the HTTP surface above, over the binding
- `newWebApp({url, headers?, msPerCall?})` → `{id, app}` — the fluent stub; - `newWebApp({url, headers?, msPerCall?})` → `{id, storeId, app}` — the fluent stub;
promise pipelining makes a whole `pathjoin().query().get()` chain a promise pipelining makes a whole `pathjoin().query().get()` chain a
single round trip single round trip
- `at(id)` — the fluent stub for a previously-minted instance - `at(id)` — the fluent stub for a previously-minted instance
- 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)
+16
View File
@@ -0,0 +1,16 @@
{
"name": "webapp",
"private": true,
"type": "module",
"scripts": {
"types": "wrangler types",
"lint": "wrangler types --check && tsc -p tsconfig.json && tsc -p tests/tsconfig.json",
"test": "vitest run"
},
"devDependencies": {
"@cloudflare/vitest-plugin": "1.3.6",
"typescript": "^5.9.0",
"vitest": "4.1.11",
"wrangler": "^4.148.0"
}
}
+2240
View File
File diff suppressed because it is too large. Load diff
+253 -34
View File
@@ -7,21 +7,52 @@
// 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;
@@ -29,8 +60,10 @@ 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>;
} }
/** /**
@@ -57,17 +90,23 @@ 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.
@@ -85,13 +124,13 @@ export class WebApp extends RpcTarget implements FluentGet {
throw new Error(`pathjoin: ${ref} not within ${hereNoQuery.href}`); throw new Error(`pathjoin: ${ref} not within ${hereNoQuery.href}`);
} }
const baseParams: Query = Object.fromEntries(here.searchParams); const baseParams: Query = Object.fromEntries(here.searchParams);
return new WebApp(unionQuery(there.href, baseParams), this.#headers, this.#send); return new WebApp(unionQuery(there.href, baseParams), this.#headers, this.#maps, this.#send, this.#exchange);
} }
/** Union headers, flower-style (see {@link WebApp}): a shared key with a /** Union headers, flower-style (see {@link WebApp}): a shared key with a
* different value throws; the same value is fine. */ * different value throws; the same value is fine. */
withHeaders(h: Headers): WebApp { withHeaders(h: Headers): WebApp {
return new WebApp(this.#url, unionHeaders(this.#headers, h), this.#send); return new WebApp(this.#url, unionHeaders(this.#headers, h), this.#maps, this.#send, this.#exchange);
} }
/** one key, flower-style (see {@link WebApp}) */ /** one key, flower-style (see {@link WebApp}) */
@@ -105,15 +144,72 @@ export class WebApp extends RpcTarget implements FluentGet {
/** Union params into the existing query, flower-style (see {@link WebApp}). */ /** Union params into the existing query, flower-style (see {@link WebApp}). */
query(params: Query): WebApp { query(params: Query): WebApp {
return new WebApp(unionQuery(this.#url, params), this.#headers, this.#send); return new WebApp(unionQuery(this.#url, params), this.#headers, this.#maps, this.#send, this.#exchange);
}
/** Add headers read from `map` at each send, unioned flower-style (see
* {@link WebApp}) with the rest — so a contradiction surfaces at send. */
withHeaderMap(map: HeaderMap): WebApp {
return new WebApp(this.#url, this.#headers, [...this.#maps, map], this.#send, this.#exchange);
} }
async get(): Promise<string> { async get(): Promise<string> {
return this.#send(this.#url, this.#headers, "GET"); return this.#send(this.#url, await this.#headersNow(), "GET");
} }
async post(body: string): Promise<string> { async post(body: string): Promise<string> {
return this.#send(this.#url, this.#headers, "POST", body); return this.#send(this.#url, await this.#headersNow(), "POST", body);
}
/**
* POST `fields` as multipart/form-data. `echoCookies` maps a form field
* to a cookie name: at send, that cookie's value (from the headers,
* header store included) goes in the field, so a double-submit CSRF
* token can be sent by a holder who never sees it.
*
* Unlike `get`/`post`, a non-OK status is a {@link Reply}, not an
* Error, and redirects are not followed: a 3xx to a login page is how
* many sites say "session expired".
*/
async postForm(fields: Record<string, string> = {}, echoCookies: Record<string, string> = {}): Promise<Reply> {
const headers = await this.#headersNow();
if (Object.keys(headers).some((k) => k.toLowerCase() === "content-type")) {
throw new Error("postForm: content-type is set by the form, not the headers");
}
const jar = parseCookies(
Object.entries(headers).find(([k]) => k.toLowerCase() === "cookie")?.[1] ?? "",
);
const form = new FormData();
for (const [name, value] of Object.entries(fields)) form.append(name, value);
for (const [field, cookie] of Object.entries(echoCookies)) {
// names only: values must not appear in Error messages
if (field in fields) throw new Error(`postForm: contradicts on ${field}`);
const value = jar.get(cookie);
if (value === undefined) throw new Error(`postForm: no ${cookie} cookie to echo`);
form.append(field, value);
}
const res = await this.#exchange(this.#url, headers, "POST", form);
const location = res.headers.get("location");
return {
status: res.status,
contentType: res.headers.get("content-type") ?? "",
...withDefined("contentDisposition", res.headers.get("content-disposition")),
...withDefined("location", location === null ? null : stripQuery(location, this.#url)),
body: new Uint8Array(await res.arrayBuffer()),
};
}
async #headersNow(): Promise<Headers> {
let headers = this.#headers;
for (const map of this.#maps) {
const entries = await map.entries();
for (const [key, value] of entries) {
// key name only: header values must not appear in Error messages
if (typeof value !== "string") throw new Error(`headers: ${key} is not a string`);
}
headers = unionHeaders(headers, Object.fromEntries(entries) as Headers);
}
return headers;
} }
} }
@@ -149,11 +245,16 @@ class Gate {
* storage, surviving eviction (instance memory evaporates when the DO naps; * storage, surviving eviction (instance memory evaporates when the DO naps;
* storage is where "this instance means this URL" durably lives). * storage is where "this instance means this URL" durably lives).
* *
* Its cookie, when minted by {@link mintInstance}, is not in `init` but in
* the header store, whose id goes to the account owner and not to whoever
* holds the instance id: the owner can rotate it, and the holder cannot
* swap in another account's session.
*
* @see https://developers.cloudflare.com/durable-objects/ — newUniqueId is * @see https://developers.cloudflare.com/durable-objects/ — newUniqueId is
* unforgeable (128-bit, inderivable); idFromName by contrast is ambient: * unforgeable (128-bit, inderivable); idFromName by contrast is ambient:
* any worker with the namespace binding can recompute it * any worker with the namespace binding can recompute it
*/ */
export class WebAppDo extends DurableObject<{ WEBAPP?: never }> { export class WebAppDo extends DurableObject<WebAppEnv> {
#ready?: Promise<void>; #ready?: Promise<void>;
/** one-shot configuration; a different-url re-configure is refused */ /** one-shot configuration; a different-url re-configure is refused */
@@ -207,16 +308,46 @@ export class WebAppDo extends DurableObject<{ WEBAPP?: never }> {
} }
return res.text(); return res.text();
}; };
return new WebApp(init.url, init.headers ?? {}, send); const exchange: Exchange = async (url, headers, method, body) => {
await gate.waitMyTurn();
return fetch(url, { method, headers, body, redirect: "manual" });
};
const maps = init.headerStore === undefined ? [] : [this.#readHeaderStore(init.headerStore)];
return new WebApp(init.url, init.headers ?? {}, maps, send, exchange);
}
/** a header map reading the store's read-only view, afresh at each send */
#readHeaderStore(storeId: string): HeaderMap {
const ns = this.env.MAP_STORE_DO;
const store = ns.get(ns.idFromString(storeId));
return {
async entries() {
using rd = await store.readOnly();
return await rd.entries();
},
};
} }
} }
/** split the cookie (any case of the header name) from the other headers */
function splitCookie(headers: Headers): { cookie?: string; rest: Headers } {
const rest: Headers = {};
let cookie: string | undefined;
for (const [key, value] of Object.entries(headers)) {
if (key.toLowerCase() === "cookie") cookie = value;
else rest[key] = value;
}
return { cookie, rest };
}
type WebAppEnv = {
WEBAPP_DO: DurableObjectNamespace<WebAppDo>;
MAP_STORE_DO: DurableObjectNamespace<MapStoreDo>;
};
/** the worker's whole HTTP surface (the default export and the /** the worker's whole HTTP surface (the default export and the
* WebAppFactory entrypoint share it) */ * WebAppFactory entrypoint share it) */
const defaultFetch = async ( const defaultFetch = async (request: Request, env: WebAppEnv): Promise<Response> => {
request: Request,
env: { WEBAPP_DO: DurableObjectNamespace<WebAppDo> },
): Promise<Response> => {
const url = new URL(request.url); const url = new URL(request.url);
// mint form: site-agnostic; secret headers (cookie, authorization) are // mint form: site-agnostic; secret headers (cookie, authorization) are
@@ -227,7 +358,7 @@ const defaultFetch = async (
// mint: browser form POST (form-encoded) or JSON (service-style) // mint: browser form POST (form-encoded) or JSON (service-style)
if (url.pathname === "/" && request.method === "POST") { if (url.pathname === "/" && request.method === "POST") {
return mint(request, env.WEBAPP_DO); return mint(request, env);
} }
// the mint form, renderable for a proxying worker: ?render=1 keeps the // the mint form, renderable for a proxying worker: ?render=1 keeps the
@@ -246,6 +377,14 @@ const defaultFetch = async (
return drive(await app, request); return drive(await app, request);
} }
// a header store, by its id (the owner's, not the instance's): PUT
// JSON value sets a key (e.g. rotates the cookie), DELETE removes it
const entry = url.pathname.match(/^\/store\/([A-Za-z0-9-]+)\/([^/]+)$/);
if (entry && (request.method === "PUT" || request.method === "DELETE")) {
const store = env.MAP_STORE_DO.get(env.MAP_STORE_DO.idFromString(entry[1]));
return storeEntry(store, decodeURIComponent(entry[2]), request);
}
return new Response("not found", { status: 404 }); return new Response("not found", { status: 404 });
}; };
@@ -305,10 +444,7 @@ function withCookie(init: WebAppInit, cookie: string): WebAppInit {
} }
/** mint a fresh instance from form-encoded or JSON input */ /** mint a fresh instance from form-encoded or JSON input */
async function mint( async function mint(request: Request, env: WebAppEnv): Promise<Response> {
request: Request,
ns: DurableObjectNamespace<WebAppDo>,
): Promise<Response> {
const ct = request.headers.get("content-type") ?? ""; const ct = request.headers.get("content-type") ?? "";
let init: WebAppInit; let init: WebAppInit;
try { try {
@@ -335,13 +471,35 @@ async function mint(
return Response.json({ error: "bad url" }, { status: 400 }); return Response.json({ error: "bad url" }, { status: 400 });
} }
const id = ns.newUniqueId(); const { id, storeId } = await mintInstance(env, init);
const instance = ns.get(id); // note: ids intentionally NOT logged — they are bearer capabilities
await instance.configure(init);
const app = await instance.handle();
// note: id intentionally NOT logged — it is a bearer capability
console.log({ event: "mint", msPerCall: init.msPerCall }); console.log({ event: "mint", msPerCall: init.msPerCall });
return Response.json({ id: id.toString() }, { headers: { "x-webapp-id": id.toString() } }); return Response.json({ id, storeId }, { headers: { "x-webapp-id": id, "x-webapp-store-id": storeId } });
}
/**
* a configured instance plus its header store, shaped to hold just the
* cookie: the instance id is authority to use the cookie; the store id
* (for the account owner) is authority to rotate or remove it. Any
* `cookie` header in `init` goes to the store, not to the instance.
*/
async function mintInstance(env: WebAppEnv, init: WebAppInit): Promise<{ id: string; storeId: string }> {
const { cookie, rest } = splitCookie(init.headers ?? {});
const storeId = env.MAP_STORE_DO.newUniqueId();
const store = env.MAP_STORE_DO.get(storeId);
await store.configure("cookie", encodeShapes({ keyShape: "cookie", valueShape: M.string() }));
if (cookie !== undefined) {
using full = await store.store();
await full.init("cookie", cookie);
}
const id = env.WEBAPP_DO.newUniqueId();
await env.WEBAPP_DO.get(id).configure({
url: init.url,
headers: rest,
...(init.msPerCall === undefined ? {} : { msPerCall: init.msPerCall }),
headerStore: storeId.toString(),
});
return { id: id.toString(), storeId: storeId.toString() };
} }
/** drive a handle from the /at/<id> JSON protocol (also used by the /** drive a handle from the /at/<id> JSON protocol (also used by the
@@ -368,6 +526,60 @@ async function drive(app: FluentGet, request: Request): Promise<Response> {
} }
} }
/** the /store/<id>/<key> protocol: PUT a JSON value (set, or init if
* absent), or DELETE → {ok} (or {error}; messages carry no values) */
async function storeEntry(store: DurableObjectStub<MapStoreDo>, key: string, request: Request): Promise<Response> {
let value: unknown;
if (request.method === "PUT") {
try {
value = await request.json();
} catch (err) {
return Response.json({ error: `bad input: ${String(err)}` }, { status: 400 });
}
}
try {
using full = await store.store();
if (request.method === "DELETE") {
if (await full.has(key)) await full.delete(key);
} else if (await full.has(key)) {
await full.set(key, value);
} else {
await full.init(key, value);
}
} catch (err) {
// store errors name shapes, never keys or values (see lib/src/store.ts)
return Response.json({ error: String(err) }, { status: 400 });
}
// note: neither id nor value logged
console.log({ event: "store", method: request.method });
return Response.json({ ok: true });
}
/** a cookie header's name → value (the first of a repeated name wins) */
function parseCookies(header: string): Map<string, string> {
const jar = new Map<string, string>();
for (const pair of header.split(";")) {
const eq = pair.indexOf("=");
if (eq < 0) continue;
const name = pair.slice(0, eq).trim();
if (name && !jar.has(name)) jar.set(name, pair.slice(eq + 1).trim());
}
return jar;
}
/** `ref` resolved against `base`, query and fragment dropped: they can carry credentials */
function stripQuery(ref: string, base: string): string {
const url = new URL(ref, base);
url.search = "";
url.hash = "";
return url.href;
}
/** `{[key]: value}`, or `{}` when value is null: optional fields stay absent */
function withDefined<K extends string>(key: K, value: string | null): { [P in K]?: string } {
return (value === null ? {} : { [key]: value }) as { [P in K]?: string };
}
function unionHeaders(base: Headers, more: Headers): Headers { function unionHeaders(base: Headers, more: Headers): Headers {
const merged = { ...base }; const merged = { ...base };
for (const [key, value] of Object.entries(more)) { for (const [key, value] of Object.entries(more)) {
@@ -396,37 +608,37 @@ function unionQuery(base: string, params: Query): string {
/** /**
* the RPC service of the webapp worker. Flow: * the RPC service of the webapp worker. Flow:
* *
* const { id, app } = await env.WEBAPP.newWebApp({ * const { id, storeId, app } = await env.WEBAPP.newWebApp({
* url: "https://api.github.com", headers: { authorization: `Bearer ${t}` }, * url: "https://diigo.example/", headers: { cookie: c },
* msPerCall: 250, * msPerCall: 250,
* }); * });
* // hold id (persist it, hand it to workers you trust), then e.g.: * // hold id (persist it, hand it to workers you trust), then e.g.:
* const text = await app.query({ per_page: 5 }).get(); * const text = await app.query({ per_page: 5 }).get();
* // later, from another worker (same account) holding the id: * // later, from another worker (same account) holding the id:
* const app2 = env.WEBAPP.at(id); * const app2 = env.WEBAPP.at(id);
* // the account owner, holding storeId, after logging in again:
* await (await env.WEBAPP.store(storeId)).set("cookie", c2);
*/ */
export class WebAppFactory extends WorkerEntrypoint { export class WebAppFactory extends WorkerEntrypoint {
/** the same HTTP surface as the default export, over the entrypoint /** the same HTTP surface as the default export, over the entrypoint
* (for service-binding callers that proxy pages, e.g. diigo-cache's * (for service-binding callers that proxy pages, e.g. diigo-cache's
* /_connect → this form) */ * /_connect → this form) */
async fetch(request: Request): Promise<Response> { async fetch(request: Request): Promise<Response> {
const env = this.env as { WEBAPP_DO: DurableObjectNamespace<WebAppDo> }; return defaultFetch(request, this.env);
return defaultFetch(request, env);
} }
/** mint a fresh instance: unforgeable uniqueId + configured fluent stub. /** mint a fresh instance: unforgeable uniqueId + configured fluent stub,
* The caller decides who else ever learns the id. (If the caller only * plus the id of its header store (see {@link mintInstance}). The
* caller decides who else ever learns either id. (If the caller only
* wanted the id, it must dispose() the app stub — workerd warns about * wanted the id, it must dispose() the app stub — workerd warns about
* undisposed stubs; see the `at()` alternative + `/at/<id>` route for * undisposed stubs; see the `at()` alternative + `/at/<id>` route for
* id-only flows.) */ * id-only flows.) */
async newWebApp(init: WebAppInit): Promise<{ id: string; app: FluentGet }> { async newWebApp(init: WebAppInit): Promise<{ id: string; storeId: string; app: FluentGet }> {
new URL(init.url); // fail before minting on garbage new URL(init.url); // fail before minting on garbage
const { id, storeId } = await mintInstance(this.env, init);
const ns = this.env.WEBAPP_DO; const ns = this.env.WEBAPP_DO;
const id = ns.newUniqueId(); const app = await ns.get(ns.idFromString(id)).handle();
const instance = ns.get(id); return { id, storeId, app };
await instance.configure(init);
const app = await instance.handle();
return { id: id.toString(), app };
} }
/** reconnect by a previously-obtained id */ /** reconnect by a previously-obtained id */
@@ -434,6 +646,13 @@ export class WebAppFactory extends WorkerEntrypoint {
const ns = this.env.WEBAPP_DO; const ns = this.env.WEBAPP_DO;
return ns.get(ns.idFromString(id)).handle(); return ns.get(ns.idFromString(id)).handle();
} }
/** a header store by its id, with full authority (the instance id
* reaches none: see {@link mintInstance}) */
async store(storeId: string) {
const ns = this.env.MAP_STORE_DO;
return ns.get(ns.idFromString(storeId)).store();
}
} }
function sleep(ms: number): Promise<void> { function sleep(ms: number): Promise<void> {
return new Promise((r) => setTimeout(r, ms)); return new Promise((r) => setTimeout(r, ms));
+131
View File
@@ -0,0 +1,131 @@
import { env } from "cloudflare:workers";
import { runInDurableObject } from "cloudflare:test";
import { afterEach, describe, expect, it, vi } from "vitest";
import worker, { type WebAppInit } from "../src/index.js";
type TestEnv = {
WEBAPP_DO: DurableObjectNamespace<import("../src/index.js").WebAppDo>;
MAP_STORE_DO: DurableObjectNamespace<import("../src/index.js").MapStoreDo>;
};
const testEnv = env as unknown as TestEnv;
const URL_ = "https://diigo.example/api/";
/** the cookie header of each outbound fetch */
function spyCookies(): (string | null)[] {
const seen: (string | null)[] = [];
vi.spyOn(globalThis, "fetch").mockImplementation(async (_input, init) => {
seen.push(new Headers(init?.headers).get("cookie"));
return new Response("ok");
});
return seen;
}
afterEach(() => {
vi.restoreAllMocks();
});
/** the worker's HTTP surface, as a JSON client */
const call = (path: string, method: string, body?: unknown) =>
worker.fetch(
new Request(`https://webapp.example${path}`, {
method,
body: body === undefined ? undefined : JSON.stringify(body),
headers: { "content-type": "application/json" },
}),
testEnv,
);
const mint = async (init: WebAppInit) => {
const { id, storeId } = (await (await call("/", "POST", init)).json()) as { id: string; storeId: string };
const instance = testEnv.WEBAPP_DO.get(testEnv.WEBAPP_DO.idFromString(id));
return { id, storeId, instance };
};
/** the failure of a call: its error status, or what it threw; "" if it succeeds */
async function errorOf(response: Promise<Response>): Promise<string> {
try {
const res = await response;
return res.ok ? "" : `${res.status} ${await res.text()}`;
} catch (err) {
return String(err);
}
}
/** `init` as stored */
const storedInit = (stub: DurableObjectStub) =>
runInDurableObject(stub, (_obj, state) => state.storage.kv.get<WebAppInit>("init"));
describe("cookie rotation", () => {
it("mint puts the cookie in the header store, not init; sends carry it", async () => {
const seen = spyCookies();
const { storeId, instance } = await mint({
url: URL_,
headers: { Cookie: "session=a", accept: "application/json" },
});
expect(await storedInit(instance)).toEqual({
url: URL_,
headers: { accept: "application/json" },
headerStore: storeId,
});
using app = await instance.handle();
await app.get();
expect(seen).toEqual(["session=a"]);
});
it("the store id rotates under handles already out, and revokes", async () => {
const seen = spyCookies();
const { storeId, instance } = await mint({ url: URL_, headers: { cookie: "session=a" } });
using app = await instance.handle();
await app.pathjoin("x").get();
expect(await (await call(`/store/${storeId}/cookie`, "PUT", "session=b")).json()).toEqual({ ok: true });
await app.pathjoin("x").get();
expect(await (await call(`/store/${storeId}/cookie`, "DELETE")).json()).toEqual({ ok: true });
await app.get();
expect(seen).toEqual(["session=a", "session=b", null]);
});
it("the instance id reaches no store: its holder cannot swap in another session", async () => {
const seen = spyCookies();
const { id, instance } = await mint({ url: URL_, headers: { cookie: "session=alice" } });
expect(await errorOf(call(`/store/${id}/cookie`, "PUT", "session=mallory"))).not.toBe("");
expect((await call(`/at/${id}/cookie`, "POST", { cookie: "session=mallory" })).status).toBe(404);
using app = await instance.handle();
await app.get();
expect(seen).toEqual(["session=alice"]);
});
it("the store's shapes admit only the cookie; errors carry no values", async () => {
const { storeId } = await mint({ url: URL_ });
const err = await errorOf(call(`/store/${storeId}/authorization`, "PUT", "Bearer s3kr1t"));
expect(err).toContain('cookie: key: must match \\"cookie\\"');
expect(err).not.toContain("s3kr1t");
expect(await errorOf(call(`/store/${storeId}/cookie`, "PUT", 42))).toContain("must match M.string()");
});
it("an instance minted before header stores keeps its cookie in init, untouched", async () => {
const seen = spyCookies();
const instance = testEnv.WEBAPP_DO.get(testEnv.WEBAPP_DO.newUniqueId());
const old = { url: URL_, headers: { cookie: "session=old" }, msPerCall: 0 };
await runInDurableObject(instance, (_obj, state) => state.storage.put("init", old));
using app = await instance.handle();
await app.get();
expect(seen).toEqual(["session=old"]);
expect(await storedInit(instance)).toEqual(old);
});
it("a fixed header contradicting the stored cookie fails at send, naming no values", async () => {
spyCookies();
const { instance } = await mint({ url: URL_, headers: { cookie: "session=a" } });
using app = await instance.handle();
const err = await app
.withHeader("cookie", "session=mine")
.get()
.then(
() => "",
(e: unknown) => String(e),
);
expect(err).toContain("headers: contradicts on cookie");
expect(err).not.toContain("session=");
});
});
+117
View File
@@ -0,0 +1,117 @@
import { env } from "cloudflare:workers";
import { afterEach, describe, expect, it, vi } from "vitest";
import worker, { type WebAppInit } from "../src/index.js";
type TestEnv = {
WEBAPP_DO: DurableObjectNamespace<import("../src/index.js").WebAppDo>;
MAP_STORE_DO: DurableObjectNamespace<import("../src/index.js").MapStoreDo>;
};
const testEnv = env as unknown as TestEnv;
const URL_ = "https://site.example/app/export/7/data";
type Seen = { url: string; redirect?: string; contentType: string | null; form: Record<string, string> };
/** each outbound fetch, its form fields read back; answers with `res()` */
function spyForms(res: () => Response): Seen[] {
const seen: Seen[] = [];
vi.spyOn(globalThis, "fetch").mockImplementation(async (input, init) => {
const req = new Request(input as string, init as RequestInit);
const form = Object.fromEntries([...(await req.formData()).entries()].map(([k, v]) => [k, String(v)]));
seen.push({ url: req.url, redirect: init?.redirect, contentType: req.headers.get("content-type"), form });
return res();
});
return seen;
}
afterEach(() => {
vi.restoreAllMocks();
});
const mint = async (init: WebAppInit) => {
const res = await worker.fetch(
new Request("https://webapp.example/", {
method: "POST",
body: JSON.stringify(init),
headers: { "content-type": "application/json" },
}),
testEnv,
);
const { id, storeId } = (await res.json()) as { id: string; storeId: string };
return { id, storeId, instance: testEnv.WEBAPP_DO.get(testEnv.WEBAPP_DO.idFromString(id)) };
};
/** the failure of a call: what it threw; "" if it succeeds */
async function errorOf(p: Promise<unknown>): Promise<string> {
try {
await p;
return "";
} catch (err) {
return String(err);
}
}
describe("postForm", () => {
it("echoes a cookie from the header store into a form field; the holder never sees it", async () => {
const seen = spyForms(
() =>
new Response("col1,col2", {
headers: {
"content-type": "text/csv",
"content-disposition": 'attachment;filename="x.csv"',
"set-cookie": "sess=next; HttpOnly",
},
}),
);
const { instance } = await mint({ url: URL_, headers: { cookie: "a=1; tok=s3cret; b=2" } });
using app = await instance.handle();
const reply = await app.query({ since: "2026-07-13\x1f" }).postForm({ kind: "all" }, { csrf: "tok" });
expect(seen).toEqual([
{
url: `${URL_}?since=2026-07-13%1F`,
redirect: "manual",
contentType: expect.stringMatching(/^multipart\/form-data; boundary=/),
form: { kind: "all", csrf: "s3cret" },
},
]);
expect(reply).toEqual({
status: 200,
contentType: "text/csv",
contentDisposition: 'attachment;filename="x.csv"',
body: new TextEncoder().encode("col1,col2"),
});
});
it("does not follow a redirect: the 3xx comes back, location query-stripped", async () => {
spyForms(() => new Response(null, { status: 302, headers: { location: "/login?next=s3cret#x" } }));
const { instance } = await mint({ url: URL_, headers: { cookie: "tok=t" } });
using app = await instance.handle();
const reply = await app.postForm({}, { csrf: "tok" });
expect(reply).toEqual({ status: 302, contentType: "", location: "https://site.example/login", body: new Uint8Array() });
});
it("refuses, naming no values: a missing cookie, a field set twice, a content-type header", async () => {
spyForms(() => new Response("ok"));
const { instance } = await mint({ url: URL_, headers: { cookie: "tok=s3cret" } });
using app = await instance.handle();
expect(await errorOf(app.postForm({}, { csrf: "nope" }))).toMatch(/no nope cookie to echo/);
const twice = await errorOf(app.postForm({ csrf: "mine" }, { csrf: "tok" }));
expect(twice).toMatch(/contradicts on csrf/);
expect(twice).not.toMatch(/s3cret|mine/);
const typed = app.withHeader("content-type", "text/plain");
expect(await errorOf(typed.postForm())).toMatch(/content-type is set by the form/);
});
it("rotating the store's cookie changes what is echoed", async () => {
const seen = spyForms(() => new Response("ok"));
const { storeId, instance } = await mint({ url: URL_, headers: { cookie: "tok=one" } });
using app = await instance.handle();
await app.postForm({}, { csrf: "tok" });
await worker.fetch(
new Request(`https://webapp.example/store/${storeId}/cookie`, { method: "PUT", body: JSON.stringify("tok=two") }),
testEnv,
);
await app.postForm({}, { csrf: "tok" });
expect(seen.map((s) => s.form.csrf)).toEqual(["one", "two"]);
});
});
+7
View File
@@ -0,0 +1,7 @@
{
"extends": "../tsconfig.json",
"compilerOptions": {
"types": ["@cloudflare/vitest-plugin/types"]
},
"include": ["./**/*", "../worker-configuration.d.ts"]
}
+1
View File
@@ -7,6 +7,7 @@
"esModuleInterop": true, "esModuleInterop": true,
"verbatimModuleSyntax": true, "verbatimModuleSyntax": true,
"skipLibCheck": true, "skipLibCheck": true,
"lib": ["ES2022", "ESNext.Disposable"],
"noEmit": true, "noEmit": true,
"types": [] "types": []
}, },
+9
View File
@@ -0,0 +1,9 @@
import { defineProject } from "vitest/config";
import { cloudflareTest } from "@cloudflare/vitest-plugin";
export default defineProject({
plugins: [cloudflareTest({ wrangler: { configPath: "./wrangler.jsonc" } })],
test: {
include: ["tests/**/*.test.ts"],
},
});
+8 -2
View File
@@ -5,7 +5,13 @@
"compatibility_date": "2026-09-29", "compatibility_date": "2026-09-29",
"observability": { "enabled": true }, "observability": { "enabled": true },
"durable_objects": { "durable_objects": {
"bindings": [{ "name": "WEBAPP_DO", "class_name": "WebAppDo" }] "bindings": [
{ "name": "WEBAPP_DO", "class_name": "WebAppDo" },
{ "name": "MAP_STORE_DO", "class_name": "MapStoreDo" }
]
}, },
"migrations": [{ "tag": "v1", "new_sqlite_classes": ["WebAppDo"] }] "migrations": [
{ "tag": "v1", "new_sqlite_classes": ["WebAppDo"] },
{ "tag": "v2", "new_sqlite_classes": ["MapStoreDo"] }
]
} }