Compare commits
13
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e5ed407e55 | ||
|
|
a984461cee | ||
|
|
8025ec4b20 | ||
|
|
f64dac4434 | ||
|
|
193a126d1c | ||
|
|
28b1c3c9d3 | ||
|
|
7fcd251261 | ||
|
|
9356f63c64 | ||
|
|
8d8900d84d | ||
|
|
f0557d29d6 | ||
|
|
2820c015ee | ||
|
|
7bf6eb44c7 | ||
|
|
88739d9495 |
No files matched your search
@@ -109,11 +109,10 @@ narrative.
|
||||
weekly page renders against prod.
|
||||
- [ ] **6. Cookie expiry**: when diigo's session ends, head refreshes
|
||||
fail; the public page keeps serving what's stored (say so on the
|
||||
page, don't fail silently). Rotation is in place, keeping the
|
||||
webapp id (so the cache's wiring and ids hold), by the store id
|
||||
minted alongside it and kept by the account owner: `PUT
|
||||
/store/<storeId>/cookie` (webapp README, "The header store").
|
||||
Still open: a rotate form in diigo-cache's UI.
|
||||
page, don't fail silently). Rotation is manual: re-mint via
|
||||
`/_connect`. Open question: can an existing cache be re-wired to
|
||||
a new webapp id (keeping its cache id and public username), or
|
||||
does re-mint mean a new cache? Prefer re-wire — durable ids.
|
||||
- [ ] **7. bib sync #195**: repeat the pattern for the next
|
||||
read-access need (webapp mints for any origin; the cache's
|
||||
item shape is diigo-specific): [the zotero goal tree](zotero-checklist.md).
|
||||
|
||||
+17
-36
@@ -174,13 +174,11 @@ async function proxyMint(request: Request, env: Env): Promise<Response> {
|
||||
const detail = (await res.text()).slice(0, 200);
|
||||
return home(env, { error: `mint said ${res.status}: ${detail}` }, null);
|
||||
}
|
||||
// webapp answers {id, storeId} (+ x-webapp-id, x-webapp-store-id);
|
||||
// the ids ride the headers, the wire page is ours
|
||||
// webapp answers {id} (+ x-webapp-id); the id rides the header, the
|
||||
// wire page is ours
|
||||
const id = res.headers.get("x-webapp-id");
|
||||
if (!id) return home(env, { error: "mint produced no id" }, null);
|
||||
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);
|
||||
return await wirePage(id);
|
||||
} catch (err) {
|
||||
console.error({ event: "mint", error: String(err) });
|
||||
return home(env, { error: `mint failed: ${String(err)}` }, null);
|
||||
@@ -190,37 +188,22 @@ async function proxyMint(request: Request, env: Env): Promise<Response> {
|
||||
/** the credential-save helper, once per page (see ./pm-save.ts) */
|
||||
const PM_SAVE_JS = pmSave.toString();
|
||||
|
||||
/** the suggested petname (in the input with id `petnameId`) gets the
|
||||
* holder's local hh:mm (the worker only knows UTC), so two saves the same
|
||||
* day are told apart at a glance */
|
||||
function petnameTimeJs(petnameId: string): string {
|
||||
return `{
|
||||
const p = document.getElementById(${JSON.stringify(petnameId)});
|
||||
/** the suggested petname gets the holder's local hh:mm (the worker only
|
||||
* knows UTC), so two saves the same day are told apart at a glance */
|
||||
const PETNAME_TIME_JS = `{
|
||||
const p = document.getElementById("petname");
|
||||
const d = new Date();
|
||||
p.value += " " + String(d.getHours()).padStart(2, "0") + ":" + String(d.getMinutes()).padStart(2, "0");
|
||||
}`;
|
||||
|
||||
/** the onclick that hands pmSave its browser powers */
|
||||
function pmSaveClick(value: string): string {
|
||||
return `pmSave(this, ${JSON.stringify(value)}, { petname: document.getElementById("petname"), credentials: navigator.credentials, PasswordCredential: window.PasswordCredential, log: console })`;
|
||||
}
|
||||
|
||||
/** the onclick that hands pmSave its browser powers, saving under the
|
||||
* petname in the input with id `petnameId` */
|
||||
function pmSaveClick(value: string, petnameId: string): string {
|
||||
return `pmSave(this, ${JSON.stringify(value)}, { petname: document.getElementById(${JSON.stringify(petnameId)}), credentials: navigator.credentials, PasswordCredential: window.PasswordCredential, log: console })`;
|
||||
}
|
||||
|
||||
/** the wire step, with the freshly-minted webapp id pre-filled, and the
|
||||
* store id (rotating the cookie) for the account owner to keep */
|
||||
async function wirePage(webappId: string, storeId: string | null): Promise<Response> {
|
||||
/** the wire step, with the freshly-minted webapp id pre-filled */
|
||||
async function wirePage(webappId: string): Promise<Response> {
|
||||
const fingerprint = await nick(webappId);
|
||||
const storeSection = storeId
|
||||
? `<h2>keep: the cookie's store id</h2>
|
||||
<p>This one is yours alone, not the cache's: with it you can rotate the
|
||||
diigo session (log in again, paste the new cookie) or revoke it. Fingerprint
|
||||
<code>${esc(await nick(storeId))}</code>
|
||||
<button type="button" onclick='navigator.clipboard.writeText(${JSON.stringify(storeId)}).then(() => this.textContent = "copied ✓")'>copy</button></p>
|
||||
<p><label>your petname for it <input id="store-petname" autocomplete="off" size="30" value="${esc(`diigo cookie store ${await nick(storeId)}`)}"></label></p>
|
||||
<script>${petnameTimeJs("store-petname")}</script>
|
||||
<p><button type="button" onclick='${pmSaveClick(storeId, "store-petname")}'>save to password manager</button></p>`
|
||||
: `<p><b>note:</b> webapp sent no store id, so this session's cookie cannot be rotated; when it expires, mint again.</p>`;
|
||||
const body = `<!doctype html>
|
||||
<html><head><meta charset="utf-8"><title>diigo cache: step 2 — wire the cache</title></head>
|
||||
<body>
|
||||
@@ -229,10 +212,8 @@ diigo session (log in again, paste the new cookie) or revoke it. Fingerprint
|
||||
<button type="button" onclick='navigator.clipboard.writeText(${JSON.stringify(webappId)}).then(() => this.textContent = "copied ✓")'>copy</button></p>
|
||||
<p><label>your petname for it <input id="petname" autocomplete="off" size="30" value="${esc(`diigo webapp ${fingerprint}`)}"></label>
|
||||
<small>(private to you: the password-manager username; never sent to the server)</small></p>
|
||||
<script>${petnameTimeJs("petname")}</script>
|
||||
<p><button type="button" onclick='${pmSaveClick(webappId, "petname")}'>save to password manager</button></p>
|
||||
${storeSection}
|
||||
<h2>wire</h2>
|
||||
<script>${PETNAME_TIME_JS}</script>
|
||||
<p><button type="button" onclick='${pmSaveClick(webappId)}'>save to password manager</button></p>
|
||||
<p>choose the cache mode, then <b>wire</b> — the next page shows the
|
||||
<b>cache id</b> (copy that too), after which you land back on home:</p>
|
||||
<form method="post" action="/_wire">
|
||||
@@ -339,8 +320,8 @@ async function wiredPage(cacheId: string, note: string, hasPublic: boolean): Pro
|
||||
<button type="button" onclick='navigator.clipboard.writeText(${JSON.stringify(cacheId)}).then(() => this.textContent = "copied ✓")'>copy</button></p>
|
||||
<p><label>your petname for it <input id="petname" autocomplete="off" size="30" value="${esc(`diigo cache ${fingerprint}`)}"></label>
|
||||
<small>(private to you: the password-manager username; never sent to the server)</small></p>
|
||||
<script>${petnameTimeJs("petname")}</script>
|
||||
<p><button type="button" onclick='${pmSaveClick(cacheId, "petname")}'>save to password manager</button></p>
|
||||
<script>${PETNAME_TIME_JS}</script>
|
||||
<p><button type="button" onclick='${pmSaveClick(cacheId)}'>save to password manager</button></p>
|
||||
<p>${note}</p>
|
||||
<p><a href="/cache/#${esc(cacheId)}">open this cache</a> — its <b>webkey</b>
|
||||
(bookmark it, or save the id above): fill it, watch progress${hasPublic ? ", fill its public companion" : ""}.</p>
|
||||
|
||||
@@ -1,146 +0,0 @@
|
||||
<!doctype html>
|
||||
<html><head><meta charset="utf-8">
|
||||
<title>webapp cookie rotation: who holds what</title>
|
||||
<!-- drawn after the style of MarkM's ERTP slides; cookie-rotation-wiring.png is a headless-Chromium screenshot of this page -->
|
||||
<style>
|
||||
html, body { margin: 0; background: #fff; }
|
||||
.sans { font-family: "Helvetica Neue", "DejaVu Sans", sans-serif; }
|
||||
.obj { font-family: "DejaVu Serif", serif; font-style: italic; }
|
||||
.mono { font-family: "DejaVu Sans Mono", monospace; }
|
||||
</style></head>
|
||||
<body>
|
||||
<svg id="c" xmlns="http://www.w3.org/2000/svg" width="1120" height="820" viewBox="0 0 1120 820"
|
||||
role="img" aria-labelledby="title desc">
|
||||
<title id="title">webapp cookie rotation: who holds what</title>
|
||||
<desc id="desc">Three actors and two makers. Alice holds a reference to a store made by
|
||||
makeMapStore, and to diigo-cache, which she wired up. She rotates the cookie
|
||||
by saying: await E(store).set("cookie", freshCookie). The store, and its
|
||||
read-only facets, both refer to the store's kv table, whose one row maps
|
||||
"cookie" to the session; its shapes are keyShape "cookie" and valueShape
|
||||
M.string(). makeWebApp makes access objects; each refers to its init
|
||||
table: url, headers, and headerStore. headerStore refers to the store's
|
||||
read-only facet, which access reads at every send. diigo-cache holds a
|
||||
reference to access. Mallory, who learned the instance id, also holds a
|
||||
reference to access, so she can use the cookie, but she has no reference
|
||||
to the store, so she cannot rotate it or swap in her own session.</desc>
|
||||
<defs>
|
||||
<radialGradient id="ball" cx="40%" cy="35%" r="70%">
|
||||
<stop offset="0%" stop-color="#7cc8f8"/>
|
||||
<stop offset="100%" stop-color="#3aa6ee"/>
|
||||
</radialGradient>
|
||||
<linearGradient id="objfill" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="0%" stop-color="#6cbcf4"/>
|
||||
<stop offset="100%" stop-color="#4aa8ec"/>
|
||||
</linearGradient>
|
||||
<marker id="head" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="9" markerHeight="9" orient="auto-start-reverse">
|
||||
<path d="M0,0 L10,5 L0,10 z" fill="#1d4f7a"/>
|
||||
</marker>
|
||||
</defs>
|
||||
</svg>
|
||||
<script>
|
||||
const svg = document.getElementById("c");
|
||||
const NS = "http://www.w3.org/2000/svg";
|
||||
const el = (name, attrs, parent = svg) => {
|
||||
const e = document.createElementNS(NS, name);
|
||||
for (const [k, v] of Object.entries(attrs)) e.setAttribute(k, v);
|
||||
parent.appendChild(e);
|
||||
return e;
|
||||
};
|
||||
const text = (x, y, s, cls, attrs = {}) => {
|
||||
const t = el("text", { x, y, class: cls, "text-anchor": "middle", fill: "#1d3550", ...attrs });
|
||||
t.textContent = s;
|
||||
return t;
|
||||
};
|
||||
|
||||
const INK = "#1d4f7a";
|
||||
const dot = (x, y) => el("circle", { cx: x, cy: y, r: 4.5, fill: INK });
|
||||
/** a reference: dot at the holder, arrowhead at the target */
|
||||
const ref = (x1, y1, x2, y2, bend = 0, extra = {}) => {
|
||||
dot(x1, y1);
|
||||
const mx = (x1 + x2) / 2 - (y2 - y1) * bend, my = (y1 + y2) / 2 + (x2 - x1) * bend;
|
||||
el("path", { d: `M${x1},${y1} Q${mx},${my} ${x2},${y2}`, fill: "none", stroke: INK, "stroke-width": 2, "marker-end": "url(#head)", ...extra });
|
||||
};
|
||||
|
||||
// maker box: a pale rounded region, with stacked instances inside
|
||||
function maker(x, y, w, h, label) {
|
||||
el("rect", { x, y, width: w, height: h, rx: 26, fill: "#d6ecfb", stroke: "#b5dbf5", "stroke-width": 1.5 });
|
||||
text(x + 18, y + 30, label, "obj", { "text-anchor": "start", "font-size": 20 });
|
||||
for (let i = 2; i >= 0; i -= 1) {
|
||||
el("rect", {
|
||||
x: x + 18 + i * 9, y: y + 48 + i * 9, width: w - 52, height: h - 82, rx: 22,
|
||||
fill: "#c8e5fa", stroke: "#8ec9f2", "stroke-width": 1.5, "fill-opacity": 0.9,
|
||||
});
|
||||
}
|
||||
}
|
||||
// an object: rounded square, optionally stacked (several made)
|
||||
function obj(x, y, w, h, label, stacked = false) {
|
||||
const n = stacked ? 3 : 1;
|
||||
for (let i = n - 1; i >= 0; i -= 1) {
|
||||
el("rect", { x: x + i * 7, y: y - i * 7, width: w, height: h, rx: 12, fill: "url(#objfill)", stroke: "#2f8fd6", "stroke-width": 1.5 });
|
||||
}
|
||||
text(x + w / 2, y + h / 2 + 7, label, "obj", { "font-size": 19 });
|
||||
}
|
||||
function actor(cx, cy, label) {
|
||||
el("circle", { cx, cy, r: 52, fill: "url(#ball)", stroke: "#2f8fd6", "stroke-width": 1.5 });
|
||||
text(cx, cy + 6, label, "sans", { "font-size": label.length > 8 ? 15 : 17 });
|
||||
}
|
||||
// a table like the ERTP ledger
|
||||
function table(x, y, title, rows, colW) {
|
||||
const rowH = 28, w = colW[0] + colW[1];
|
||||
el("rect", { x, y, width: w, height: 36 + rowH * rows.length, fill: "#5fb5f2", stroke: "#2f8fd6", "stroke-width": 1.5 });
|
||||
text(x + w / 2, y + 25, title, "obj", { "font-size": 18 });
|
||||
rows.forEach(([k, v], i) => {
|
||||
const ry = y + 36 + i * rowH;
|
||||
el("line", { x1: x, y1: ry, x2: x + w, y2: ry, stroke: "#2f8fd6", "stroke-width": 1.2 });
|
||||
text(x + 10, ry + 19, k, "mono", { "text-anchor": "start", "font-size": 14 });
|
||||
if (v) text(x + colW[0] + 10, ry + 19, v, "mono", { "text-anchor": "start", "font-size": 14 });
|
||||
});
|
||||
el("line", { x1: x + colW[0], y1: y + 36, x2: x + colW[0], y2: y + 36 + rowH * rows.length, stroke: "#1d4f7a", "stroke-width": 1.5 });
|
||||
return { rowY: (i) => y + 36 + i * rowH + rowH / 2, x, w, colX: x + colW[0] };
|
||||
}
|
||||
|
||||
// speech bubble (Alice rotates after logging in again)
|
||||
el("path", {
|
||||
d: "M30,22 h470 a16,16 0 0 1 16,16 v34 a16,16 0 0 1 -16,16 h-330 l-38,48 l6,-48 h-102 a16,16 0 0 1 -16,-16 v-34 a16,16 0 0 1 16,-16 z",
|
||||
fill: "#d9d9d9", stroke: "#555", "stroke-width": 2,
|
||||
});
|
||||
const code = el("text", { x: 46, y: 62, class: "mono", "font-size": 17, fill: "#222" });
|
||||
code.innerHTML = '<tspan fill="#b0177b">await</tspan> E(store).set(<tspan fill="#1a7f37">"cookie"</tspan>, freshCookie);';
|
||||
|
||||
// actors
|
||||
actor(150, 200, "Alice");
|
||||
actor(560, 200, "diigo-cache");
|
||||
actor(930, 200, "Mallory");
|
||||
|
||||
// makers
|
||||
maker(30, 320, 470, 470, "makeMapStore");
|
||||
maker(560, 320, 530, 470, "makeWebApp");
|
||||
|
||||
// MapStoreDo insides
|
||||
obj(80, 420, 120, 80, "store");
|
||||
obj(80, 600, 120, 80, "readOnly", true);
|
||||
const kv = table(260, 430, "kv", [["cookie", "session=…"], ["", ""], ["", ""], ["", ""], ["", ""]], [90, 110]);
|
||||
|
||||
// WebAppDo insides
|
||||
obj(620, 560, 140, 90, "access", true);
|
||||
const init = table(830, 420, "init", [["url", "diigo…"], ["headers", "{…}"], ["headerStore", ""], ["", ""]], [130, 85]);
|
||||
|
||||
// references
|
||||
ref(140, 228, 125, 418, 0.05); // Alice → store
|
||||
ref(172, 224, 508, 210, 0.04); // Alice → diigo-cache (she wired it)
|
||||
ref(560 - 6, 200 + 18, 660, 553, 0.12); // cache → access
|
||||
ref(930 - 14, 200 + 20, 720, 553, -0.08); // Mallory → access
|
||||
ref(160, 470, kv.x - 2, kv.rowY(0), 0.05); // store → kv
|
||||
ref(150, 666, kv.x - 2, kv.rowY(0) + 6, -0.25); // readOnly → kv (read)
|
||||
ref(760, 600, init.x - 2, init.rowY(2), -0.1); // access → init
|
||||
ref(init.colX + 40, init.rowY(2), 208, 640, -0.28); // headerStore → readOnly
|
||||
|
||||
// captions
|
||||
const cap = (x, y, s, attrs = {}) => text(x, y, s, "sans", { "font-size": 14, fill: "#33536f", ...attrs });
|
||||
cap(930, 118, "holds the instance id:", {});
|
||||
cap(930, 136, "uses the cookie, can't reach store", {});
|
||||
cap(330, 300, "storeId: rotate · revoke", {});
|
||||
cap(760, 806, "init.headerStore → the store's read-only facet, read at every send", {});
|
||||
cap(265, 806, "shapes: keyShape \"cookie\", valueShape M.string()", {});
|
||||
</script>
|
||||
</body></html>
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 113 KiB |
@@ -1,16 +0,0 @@
|
||||
{
|
||||
"name": "capsite-lib",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"types": "wrangler types -c wrangler.tests.jsonc",
|
||||
"lint": "wrangler types -c wrangler.tests.jsonc --check && tsc -p tsconfig.json && tsc -p tests/tsconfig.json",
|
||||
"test": "vitest run"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@cloudflare/vitest-plugin": "1.3.6",
|
||||
"typescript": "^5.9.0",
|
||||
"vitest": "4.1.11",
|
||||
"wrangler": "^4.148.0"
|
||||
}
|
||||
}
|
||||
Generated
-2240
File diff suppressed because it is too large.
Load diff
@@ -1,129 +0,0 @@
|
||||
/**
|
||||
* @file a work-alike for enough of @endo/patterns: `M.string()`, `M.any()`,
|
||||
* and literal patterns (a string, number, boolean, or null matches only
|
||||
* itself, so `"cookie"` as a key shape admits exactly one key). Matchers
|
||||
* are the same data endo makes: tagged records, as from @endo/pass-style's
|
||||
* `makeTagged` (frozen here, where endo would harden).
|
||||
*
|
||||
* Structured clone (DO storage, Workers RPC) drops a tagged record's
|
||||
* symbol-keyed and non-enumerable properties, so patterns cross those
|
||||
* boundaries in endo's smallcaps encoding: see {@link toSmallcaps}.
|
||||
* @see https://github.com/endojs/endo/tree/master/packages/patterns
|
||||
* @see https://github.com/endojs/endo/blob/master/packages/marshal/src/encodeToSmallcaps.js
|
||||
*/
|
||||
|
||||
const PASS_STYLE = Symbol.for("passStyle");
|
||||
|
||||
export type Tagged = Readonly<{ [Symbol.toStringTag]: string; payload: unknown }>;
|
||||
export type Pattern = Tagged | string | number | boolean | null;
|
||||
|
||||
/** @endo/pass-style's `makeTagged` */
|
||||
export function makeTagged(tag: string, payload: unknown): Tagged {
|
||||
return Object.freeze(
|
||||
Object.create(Object.prototype, {
|
||||
[PASS_STYLE]: { value: "tagged" },
|
||||
[Symbol.toStringTag]: { value: tag },
|
||||
payload: { value: payload, enumerable: true },
|
||||
}),
|
||||
) as Tagged;
|
||||
}
|
||||
|
||||
const isTagged = (x: unknown): x is Tagged =>
|
||||
typeof x === "object" && x !== null && (x as Record<symbol, unknown>)[PASS_STYLE] === "tagged";
|
||||
|
||||
const StringShape = makeTagged("match:string", Object.freeze([]));
|
||||
const AnyShape = makeTagged("match:any", undefined);
|
||||
|
||||
export const M = Object.freeze({
|
||||
string: (): Tagged => StringShape,
|
||||
any: (): Tagged => AnyShape,
|
||||
});
|
||||
|
||||
export function matches(specimen: unknown, pattern: Pattern): boolean {
|
||||
if (typeof pattern === "object" && pattern !== null) {
|
||||
const tag = isTagged(pattern) ? pattern[Symbol.toStringTag] : undefined;
|
||||
switch (tag) {
|
||||
case "match:string":
|
||||
return typeof specimen === "string";
|
||||
case "match:any":
|
||||
return true;
|
||||
default:
|
||||
throw new TypeError("not a pattern");
|
||||
}
|
||||
}
|
||||
return Object.is(specimen, pattern);
|
||||
}
|
||||
|
||||
/** the pattern as written in code, e.g. `M.string()` or `"cookie"` */
|
||||
export function describe(pattern: Pattern): string {
|
||||
if (typeof pattern === "object" && pattern !== null) {
|
||||
return `M.${pattern[Symbol.toStringTag].replace(/^match:/, "")}()`;
|
||||
}
|
||||
return JSON.stringify(pattern);
|
||||
}
|
||||
|
||||
/**
|
||||
* throw unless `specimen` matches `pattern`. Unlike endo's, the message
|
||||
* names the pattern and `label` but never the specimen: specimens here
|
||||
* can be credentials, and messages cross RPC and land in logs.
|
||||
*/
|
||||
export function mustMatch(specimen: unknown, pattern: Pattern, label: string): void {
|
||||
if (!matches(specimen, pattern)) {
|
||||
throw new TypeError(`${label}: must match ${describe(pattern)}`);
|
||||
}
|
||||
}
|
||||
|
||||
/** a smallcaps-encoded passable, as JSON-able data */
|
||||
export type Smallcaps = null | boolean | number | string | Smallcaps[] | { "#tag": string; payload: Smallcaps };
|
||||
|
||||
/** smallcaps quotes strings that start with one of these with `!` */
|
||||
const startsSpecial = (s: string) => /^[!-\-]/.test(s);
|
||||
|
||||
/**
|
||||
* the subset of @endo/marshal's smallcaps encoding that patterns here use:
|
||||
* primitives, arrays, and tagged records (not a whole CapData: no `#`
|
||||
* body prefix, no slots)
|
||||
*/
|
||||
export function toSmallcaps(passable: unknown): Smallcaps {
|
||||
if (passable === undefined) return "#undefined";
|
||||
if (passable === null || typeof passable === "boolean") return passable;
|
||||
if (typeof passable === "number") {
|
||||
if (Number.isNaN(passable)) return "#NaN";
|
||||
if (passable === Infinity) return "#Infinity";
|
||||
if (passable === -Infinity) return "#-Infinity";
|
||||
return Object.is(passable, -0) ? 0 : passable;
|
||||
}
|
||||
if (typeof passable === "string") return startsSpecial(passable) ? `!${passable}` : passable;
|
||||
if (Array.isArray(passable)) return passable.map(toSmallcaps);
|
||||
if (isTagged(passable)) {
|
||||
return { "#tag": toSmallcaps(passable[Symbol.toStringTag]) as string, payload: toSmallcaps(passable.payload) };
|
||||
}
|
||||
throw new TypeError("toSmallcaps: unsupported passable");
|
||||
}
|
||||
|
||||
/** the inverse of {@link toSmallcaps} */
|
||||
export function fromSmallcaps(encoding: Smallcaps): unknown {
|
||||
if (encoding === null || typeof encoding === "boolean" || typeof encoding === "number") return encoding;
|
||||
if (typeof encoding === "string") {
|
||||
if (encoding.startsWith("!")) return encoding.slice(1);
|
||||
switch (encoding) {
|
||||
case "#undefined":
|
||||
return undefined;
|
||||
case "#NaN":
|
||||
return NaN;
|
||||
case "#Infinity":
|
||||
return Infinity;
|
||||
case "#-Infinity":
|
||||
return -Infinity;
|
||||
}
|
||||
if (startsSpecial(encoding)) throw new TypeError("fromSmallcaps: unsupported special string");
|
||||
return encoding;
|
||||
}
|
||||
if (Array.isArray(encoding)) return Object.freeze(encoding.map(fromSmallcaps));
|
||||
if (typeof encoding === "object" && "#tag" in encoding) {
|
||||
const { "#tag": tag, payload, ...rest } = encoding;
|
||||
if (Object.keys(rest).length > 0) throw new TypeError("fromSmallcaps: extra properties on a tagged record");
|
||||
return makeTagged(fromSmallcaps(tag) as string, fromSmallcaps(payload));
|
||||
}
|
||||
throw new TypeError("fromSmallcaps: unsupported encoding");
|
||||
}
|
||||
@@ -1,60 +0,0 @@
|
||||
/**
|
||||
* @file a MapStore as a Durable Object of its own, so a store can be held
|
||||
* apart from whoever reads it: the DO id is the store's full authority
|
||||
* (e.g. the account owner's, to rotate a credential), while a reader is
|
||||
* handed only {@link MapStoreDo.readOnly}.
|
||||
*/
|
||||
|
||||
import { DurableObject } from "cloudflare:workers";
|
||||
import { fromSmallcaps, matches, toSmallcaps, type Pattern, type Smallcaps } from "./patterns.js";
|
||||
import { makeMapStore, type MapStore, type ReadonlyMapStore, type StoreShapes } from "./store.js";
|
||||
|
||||
/** store shapes as they cross RPC and sit in storage: smallcaps-encoded
|
||||
* (structured clone would strip a matcher down to its payload) */
|
||||
export type EncodedShapes = { keyShape?: Smallcaps; valueShape?: Smallcaps };
|
||||
|
||||
export const encodeShapes = ({ keyShape, valueShape }: StoreShapes): EncodedShapes => ({
|
||||
...(keyShape === undefined ? {} : { keyShape: toSmallcaps(keyShape) }),
|
||||
...(valueShape === undefined ? {} : { valueShape: toSmallcaps(valueShape) }),
|
||||
});
|
||||
|
||||
const decodeShapes = ({ keyShape, valueShape }: EncodedShapes): StoreShapes => ({
|
||||
...(keyShape === undefined ? {} : { keyShape: fromSmallcaps(keyShape) as Pattern }),
|
||||
...(valueShape === undefined ? {} : { valueShape: fromSmallcaps(valueShape) as Pattern }),
|
||||
});
|
||||
|
||||
type Config = { label: string; shapes: EncodedShapes };
|
||||
|
||||
/** a store whose label and shapes its creator fixes, once */
|
||||
export class MapStoreDo extends DurableObject {
|
||||
/** one-shot: fix the label and shapes (see {@link encodeShapes}); an
|
||||
* identical re-configure is a no-op */
|
||||
configure(label: string, shapes: EncodedShapes): void {
|
||||
const decoded = decodeShapes(shapes);
|
||||
for (const shape of [decoded.keyShape, decoded.valueShape]) {
|
||||
if (shape !== undefined) matches(undefined, shape); // throws on a non-pattern
|
||||
}
|
||||
makeMapStore(label, decoded, this.ctx.storage.kv); // throws on a bad label
|
||||
const config: Config = { label, shapes: encodeShapes(decoded) };
|
||||
const existing = this.ctx.storage.kv.get<Config>("config");
|
||||
if (existing) {
|
||||
if (JSON.stringify(existing) !== JSON.stringify(config)) {
|
||||
throw new Error("already configured with a different label or shapes");
|
||||
}
|
||||
return;
|
||||
}
|
||||
this.ctx.storage.kv.put("config", config);
|
||||
}
|
||||
|
||||
/** the store, with full authority */
|
||||
store(): MapStore<unknown> {
|
||||
const config = this.ctx.storage.kv.get<Config>("config");
|
||||
if (!config) throw new Error("not configured: this store was never set up");
|
||||
return makeMapStore(config.label, decodeShapes(config.shapes), this.ctx.storage.kv);
|
||||
}
|
||||
|
||||
/** the store's queries alone */
|
||||
readOnly(): ReadonlyMapStore<unknown> {
|
||||
return this.store().readOnly();
|
||||
}
|
||||
}
|
||||
@@ -1,133 +0,0 @@
|
||||
/**
|
||||
* @file a work-alike for enough of @agoric/store's MapStore: string keys,
|
||||
* shapes checked with ./patterns.js, entries kept in a Durable Object's
|
||||
* synchronous KV storage. The facets are RpcTargets (workerd's / capnweb's,
|
||||
* in place of endo's `Far`), so a store or its read-only view can be
|
||||
* handed out by reference.
|
||||
* @see https://github.com/Agoric/agoric-sdk/tree/master/packages/store
|
||||
*/
|
||||
|
||||
import { RpcTarget } from "cloudflare:workers";
|
||||
import { M, mustMatch, type Pattern } from "./patterns.js";
|
||||
|
||||
/** the storage a store needs: a DO's `ctx.storage.kv`, or a stand-in */
|
||||
export type Kv = Pick<SyncKvStorage, "get" | "put" | "delete" | "list">;
|
||||
|
||||
export type StoreShapes = { keyShape?: Pattern; valueShape?: Pattern };
|
||||
|
||||
/**
|
||||
* Entries live under `store:<label>:<key>`, so the label also names the
|
||||
* storage area (as a baggage key does for endo's durable stores): two
|
||||
* stores with one label on one Kv are the same store.
|
||||
*
|
||||
* Error messages name the label and shapes, never keys or values.
|
||||
*/
|
||||
export function makeMapStore<V>(label: string, { keyShape = M.string(), valueShape = M.any() }: StoreShapes, kv: Kv) {
|
||||
if (label.includes(":")) throw new TypeError("store label must not contain ':'");
|
||||
const prefix = `store:${label}:`;
|
||||
const core: Core<V> = {
|
||||
label,
|
||||
has: (key) => kv.get(prefix + key) !== undefined,
|
||||
get: (key) => kv.get<V>(prefix + key),
|
||||
put: (key, value) => kv.put(prefix + key, value),
|
||||
delete: (key) => {
|
||||
kv.delete(prefix + key);
|
||||
},
|
||||
entries: () => [...kv.list<V>({ prefix })].map(([k, v]): [string, V] => [k.slice(prefix.length), v]),
|
||||
checkKey: (key) => mustMatch(key, keyShape, `${label}: key`),
|
||||
checkValue: (value) => mustMatch(value, valueShape, `${label}: value`),
|
||||
};
|
||||
return new MapStore<V>(core);
|
||||
}
|
||||
|
||||
type Core<V> = {
|
||||
label: string;
|
||||
has(key: string): boolean;
|
||||
get(key: string): V | undefined;
|
||||
put(key: string, value: V): void;
|
||||
delete(key: string): void;
|
||||
entries(): [string, V][];
|
||||
checkKey(key: unknown): void;
|
||||
checkValue(value: unknown): void;
|
||||
};
|
||||
|
||||
/** the queries */
|
||||
function reads<V>(core: Core<V>) {
|
||||
return {
|
||||
has: (key: string): boolean => core.has(key),
|
||||
get: (key: string): V => {
|
||||
const value = core.get(key);
|
||||
if (value === undefined) throw new RangeError(`${core.label}: key not found`);
|
||||
return value;
|
||||
},
|
||||
keys: (): string[] => core.entries().map(([k]) => k),
|
||||
values: (): V[] => core.entries().map(([, v]) => v),
|
||||
entries: (): [string, V][] => core.entries(),
|
||||
getSize: (): number => core.entries().length,
|
||||
};
|
||||
}
|
||||
|
||||
/** a store's queries only: what a reader of a rotating credential holds */
|
||||
export class ReadonlyMapStore<V> extends RpcTarget {
|
||||
#reads: ReturnType<typeof reads<V>>;
|
||||
|
||||
constructor(core: Core<V>) {
|
||||
super();
|
||||
this.#reads = reads(core);
|
||||
}
|
||||
|
||||
has(key: string): boolean {
|
||||
return this.#reads.has(key);
|
||||
}
|
||||
get(key: string): V {
|
||||
return this.#reads.get(key);
|
||||
}
|
||||
keys(): string[] {
|
||||
return this.#reads.keys();
|
||||
}
|
||||
values(): V[] {
|
||||
return this.#reads.values();
|
||||
}
|
||||
entries(): [string, V][] {
|
||||
return this.#reads.entries();
|
||||
}
|
||||
getSize(): number {
|
||||
return this.#reads.getSize();
|
||||
}
|
||||
}
|
||||
|
||||
/** a store's full authority: the queries plus `init`/`set`/`delete`, and
|
||||
* {@link MapStore.readOnly} to hand out the queries alone */
|
||||
export class MapStore<V> extends ReadonlyMapStore<V> {
|
||||
#core: Core<V>;
|
||||
|
||||
constructor(core: Core<V>) {
|
||||
super(core);
|
||||
this.#core = core;
|
||||
}
|
||||
|
||||
/** add a key that is not yet present */
|
||||
init(key: string, value: V): void {
|
||||
this.#core.checkKey(key);
|
||||
this.#core.checkValue(value);
|
||||
if (this.#core.has(key)) throw new RangeError(`${this.#core.label}: key already registered`);
|
||||
this.#core.put(key, value);
|
||||
}
|
||||
|
||||
/** replace the value of a key that is present */
|
||||
set(key: string, value: V): void {
|
||||
this.#core.checkKey(key);
|
||||
this.#core.checkValue(value);
|
||||
if (!this.#core.has(key)) throw new RangeError(`${this.#core.label}: key not found`);
|
||||
this.#core.put(key, value);
|
||||
}
|
||||
|
||||
delete(key: string): void {
|
||||
if (!this.#core.has(key)) throw new RangeError(`${this.#core.label}: key not found`);
|
||||
this.#core.delete(key);
|
||||
}
|
||||
|
||||
readOnly(): ReadonlyMapStore<V> {
|
||||
return new ReadonlyMapStore(this.#core);
|
||||
}
|
||||
}
|
||||
@@ -1,142 +0,0 @@
|
||||
import { env } from "cloudflare:workers";
|
||||
import { evictDurableObject } from "cloudflare:test";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { M, fromSmallcaps, makeTagged, matches, mustMatch, toSmallcaps } from "../src/patterns.js";
|
||||
import { encodeShapes } from "../src/store-do.js";
|
||||
|
||||
type TestEnv = {
|
||||
STORE_DO: DurableObjectNamespace<import("./test-worker.js").StoreHost>;
|
||||
MAP_STORE_DO: DurableObjectNamespace<import("../src/store-do.js").MapStoreDo>;
|
||||
};
|
||||
const testEnv = env as unknown as TestEnv;
|
||||
|
||||
const SECRET = "session=s3kr1t";
|
||||
|
||||
/** the message an RPC call fails with, or "" if it succeeds */
|
||||
async function errorOf(call: Promise<unknown>): Promise<string> {
|
||||
try {
|
||||
await call;
|
||||
return "";
|
||||
} catch (err) {
|
||||
return String(err);
|
||||
}
|
||||
}
|
||||
|
||||
describe("patterns", () => {
|
||||
it("M.string() matches strings only; a literal matches only itself", () => {
|
||||
expect(matches("x", M.string())).toBe(true);
|
||||
expect(matches(1, M.string())).toBe(false);
|
||||
expect(matches(undefined, M.any())).toBe(true);
|
||||
expect(matches("cookie", "cookie")).toBe(true);
|
||||
expect(matches("authorization", "cookie")).toBe(false);
|
||||
});
|
||||
|
||||
it("matchers are endo's tagged records", () => {
|
||||
const s = M.string() as unknown as Record<string | symbol, unknown>;
|
||||
expect(s[Symbol.for("passStyle")]).toBe("tagged");
|
||||
expect(s[Symbol.toStringTag]).toBe("match:string");
|
||||
expect(Object.keys(s)).toEqual(["payload"]);
|
||||
expect(s.payload).toEqual([]);
|
||||
expect((M.any() as unknown as Record<string, unknown>).payload).toBeUndefined();
|
||||
expect(Object.isFrozen(s)).toBe(true);
|
||||
});
|
||||
|
||||
it("smallcaps: endo's encoding, round-tripped", () => {
|
||||
expect(toSmallcaps(M.string())).toEqual({ "#tag": "match:string", payload: [] });
|
||||
expect(toSmallcaps(M.any())).toEqual({ "#tag": "match:any", payload: "#undefined" });
|
||||
expect(toSmallcaps("#cookie")).toBe("!#cookie");
|
||||
expect(toSmallcaps([NaN, -0, Infinity])).toEqual(["#NaN", 0, "#Infinity"]);
|
||||
const back = fromSmallcaps(toSmallcaps(M.string())) as Parameters<typeof matches>[1];
|
||||
expect(matches("x", back)).toBe(true);
|
||||
expect(matches(1, back)).toBe(false);
|
||||
expect(fromSmallcaps("!#cookie")).toBe("#cookie");
|
||||
expect(() => matches("x", makeTagged("match:nope", []))).toThrow("not a pattern");
|
||||
});
|
||||
|
||||
it("mustMatch names the shape but not the specimen", () => {
|
||||
expect(() => mustMatch(SECRET, "cookie", "jar: key")).toThrow('jar: key: must match "cookie"');
|
||||
expect(() => mustMatch(SECRET, "cookie", "jar: key")).not.toThrow(SECRET);
|
||||
});
|
||||
});
|
||||
|
||||
describe("MapStore over RPC", () => {
|
||||
const fresh = () => testEnv.STORE_DO.get(testEnv.STORE_DO.newUniqueId());
|
||||
|
||||
it("rotates: the admin's set is what the read-only view sees next", async () => {
|
||||
const store = await fresh().cookies();
|
||||
const rd = await store.readOnly();
|
||||
await store.init("cookie", "session=old");
|
||||
expect(await rd.get("cookie")).toBe("session=old");
|
||||
await store.set("cookie", "session=new");
|
||||
expect(await rd.entries()).toEqual([["cookie", "session=new"]]);
|
||||
});
|
||||
|
||||
it("the read-only view has no write methods", async () => {
|
||||
const store = await fresh().cookies();
|
||||
const rd = (await store.readOnly()) as unknown as { set(k: string, v: string): Promise<void> };
|
||||
expect(await errorOf(rd.set("cookie", SECRET))).not.toBe("");
|
||||
expect(await store.has("cookie")).toBe(false);
|
||||
});
|
||||
|
||||
it("the key shape admits only `cookie`; errors carry no values", async () => {
|
||||
const store = await fresh().cookies();
|
||||
const err = await errorOf(store.init("authorization", SECRET));
|
||||
expect(err).toContain('cookie: key: must match "cookie"');
|
||||
expect(err).not.toContain(SECRET);
|
||||
expect(await errorOf(store.init("cookie", 42 as unknown as string))).toContain("cookie: value: must match M.string()");
|
||||
});
|
||||
|
||||
it("init refuses a present key; set and get refuse a missing one", async () => {
|
||||
const store = await fresh().cookies();
|
||||
expect(await errorOf(store.set("cookie", SECRET))).toContain("cookie: key not found");
|
||||
expect(await errorOf(store.get("cookie"))).toContain("cookie: key not found");
|
||||
await store.init("cookie", SECRET);
|
||||
const err = await errorOf(store.init("cookie", SECRET));
|
||||
expect(err).toContain("cookie: key already registered");
|
||||
expect(err).not.toContain(SECRET);
|
||||
});
|
||||
|
||||
it("survives eviction: entries live in DO storage", async () => {
|
||||
const stub = fresh();
|
||||
{
|
||||
// disposed before eviction: a live store stub holds the DO open
|
||||
using store = await stub.cookies();
|
||||
await store.init("cookie", "session=durable");
|
||||
}
|
||||
await evictDurableObject(stub);
|
||||
using again = await stub.cookies();
|
||||
expect(await again.get("cookie")).toBe("session=durable");
|
||||
expect(await again.getSize()).toBe(1);
|
||||
await again.delete("cookie");
|
||||
expect(await again.keys()).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("MapStoreDo", () => {
|
||||
const COOKIE_SHAPES = encodeShapes({ keyShape: "cookie", valueShape: M.string() });
|
||||
const fresh = () => testEnv.MAP_STORE_DO.get(testEnv.MAP_STORE_DO.newUniqueId());
|
||||
|
||||
it("keeps the shapes its creator chose, across eviction", async () => {
|
||||
const stub = fresh();
|
||||
await stub.configure("cookie", COOKIE_SHAPES);
|
||||
await evictDurableObject(stub);
|
||||
{
|
||||
using store = await stub.store();
|
||||
expect(await errorOf(store.init("authorization", SECRET))).toContain('cookie: key: must match "cookie"');
|
||||
expect(await errorOf(store.init("cookie", 42))).toContain("cookie: value: must match M.string()");
|
||||
await store.init("cookie", "session=a");
|
||||
}
|
||||
using rd = await stub.readOnly();
|
||||
expect(await rd.entries()).toEqual([["cookie", "session=a"]]);
|
||||
});
|
||||
|
||||
it("configures once: the same shapes again is a no-op, others are refused", async () => {
|
||||
const stub = fresh();
|
||||
await stub.configure("cookie", COOKIE_SHAPES);
|
||||
await stub.configure("cookie", encodeShapes({ keyShape: "cookie", valueShape: M.string() }));
|
||||
expect(await errorOf(stub.configure("cookie", encodeShapes({ keyShape: M.string() })))).toContain(
|
||||
"already configured",
|
||||
);
|
||||
expect(await errorOf(fresh().store())).toContain("not configured");
|
||||
});
|
||||
});
|
||||
@@ -1,17 +0,0 @@
|
||||
/** @file the tests' main module: a DO that hosts stores on its own storage. */
|
||||
import { DurableObject } from "cloudflare:workers";
|
||||
import { M } from "../src/patterns.js";
|
||||
import { makeMapStore, type MapStore } from "../src/store.js";
|
||||
|
||||
export { MapStoreDo } from "../src/store-do.js";
|
||||
|
||||
/** hosts the store from the credential-rotation sketch: one key, `cookie` */
|
||||
export class StoreHost extends DurableObject {
|
||||
cookies(): MapStore<string> {
|
||||
return makeMapStore<string>("cookie", { keyShape: "cookie", valueShape: M.string() }, this.ctx.storage.kv);
|
||||
}
|
||||
}
|
||||
|
||||
export default {
|
||||
fetch: () => new Response("store tests", { status: 404 }),
|
||||
};
|
||||
@@ -1,7 +0,0 @@
|
||||
{
|
||||
"extends": "../tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"types": ["@cloudflare/vitest-plugin/types"]
|
||||
},
|
||||
"include": ["./**/*", "../src/**/*", "../worker-configuration.d.ts"]
|
||||
}
|
||||
@@ -1,15 +0,0 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "preserve",
|
||||
"moduleResolution": "bundler",
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"verbatimModuleSyntax": true,
|
||||
"skipLibCheck": true,
|
||||
"lib": ["ES2022", "ESNext.Disposable"],
|
||||
"noEmit": true,
|
||||
"types": []
|
||||
},
|
||||
"include": ["src/**/*", "worker-configuration.d.ts"]
|
||||
}
|
||||
@@ -1,9 +0,0 @@
|
||||
import { defineProject } from "vitest/config";
|
||||
import { cloudflareTest } from "@cloudflare/vitest-plugin";
|
||||
|
||||
export default defineProject({
|
||||
plugins: [cloudflareTest({ wrangler: { configPath: "./wrangler.tests.jsonc" } })],
|
||||
test: {
|
||||
include: ["tests/**/*.test.ts"],
|
||||
},
|
||||
});
|
||||
@@ -1,17 +0,0 @@
|
||||
{
|
||||
"$schema": "node_modules/wrangler/config-schema.json",
|
||||
// a host DO for the stores under test (lib ships no worker of its own)
|
||||
"name": "capsite-lib-tests",
|
||||
"main": "./tests/test-worker.ts",
|
||||
"compatibility_date": "2026-09-29",
|
||||
"durable_objects": {
|
||||
"bindings": [
|
||||
{ "name": "STORE_DO", "class_name": "StoreHost" },
|
||||
{ "name": "MAP_STORE_DO", "class_name": "MapStoreDo" }
|
||||
]
|
||||
},
|
||||
"exports": {
|
||||
"StoreHost": { "type": "durable-object", "storage": "sqlite" },
|
||||
"MapStoreDo": { "type": "durable-object", "storage": "sqlite" }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
state/
|
||||
admin.sock
|
||||
switchboard-*.sock
|
||||
dist/
|
||||
@@ -0,0 +1,68 @@
|
||||
# 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".
|
||||
@@ -0,0 +1,8 @@
|
||||
import { DurableObject } from "cloudflare:workers";
|
||||
export class App extends DurableObject {
|
||||
fetch() {
|
||||
const n = (this.ctx.storage.kv.get("n") ?? 0) + 1;
|
||||
this.ctx.storage.kv.put("n", n);
|
||||
return new Response(`v1 count ${n}\n`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
import { DurableObject } from "cloudflare:workers";
|
||||
export class App extends DurableObject {
|
||||
fetch() {
|
||||
const n = (this.ctx.storage.kv.get("n") ?? 0) + 1;
|
||||
this.ctx.storage.kv.put("n", n);
|
||||
return new Response(`v2 count ${n}\n`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
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`);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
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`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
import { DurableObject } from "cloudflare:workers";
|
||||
export class App extends DurableObject {
|
||||
async fetch() {
|
||||
try { await fetch("https://example.com/"); return new Response("fetch ALLOWED\n"); }
|
||||
catch (e) { return new Response(`fetch refused: ${e.message}\n`); }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
/** @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"] });
|
||||
@@ -0,0 +1,33 @@
|
||||
/**
|
||||
* @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()) };
|
||||
};
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
# 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",
|
||||
);
|
||||
@@ -0,0 +1,53 @@
|
||||
/**
|
||||
* @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`);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
/**
|
||||
* @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`;
|
||||
@@ -0,0 +1,133 @@
|
||||
/**
|
||||
* @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() });
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,24 @@
|
||||
{
|
||||
"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"
|
||||
}
|
||||
}
|
||||
Generated
+578
@@ -0,0 +1,578 @@
|
||||
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: {}
|
||||
@@ -0,0 +1,20 @@
|
||||
/**
|
||||
* @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();
|
||||
}
|
||||
@@ -0,0 +1,575 @@
|
||||
/** @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;
|
||||
@@ -0,0 +1,34 @@
|
||||
# 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",
|
||||
);
|
||||
@@ -0,0 +1,81 @@
|
||||
/** @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));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
[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
|
||||
@@ -0,0 +1,11 @@
|
||||
[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
|
||||
@@ -0,0 +1,19 @@
|
||||
[Unit]
|
||||
Description=toybox vats (workerd)
|
||||
Requires=toybox.socket toybox-switchboard.socket
|
||||
After=toybox-switchboard.socket
|
||||
|
||||
[Service]
|
||||
ExecStart=/opt/toybox/node_modules/workerd/bin/workerd serve --experimental /opt/toybox/config.capnp -S http=3 -S admin=4 -d state=/var/lib/toybox -e switchboard=unix:/run/toybox/switchboard-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
|
||||
@@ -0,0 +1,13 @@
|
||||
[Unit]
|
||||
Description=toybox vats: public port and admin socket
|
||||
|
||||
[Socket]
|
||||
# order sets the fds: http=3, admin=4 (see toybox.service)
|
||||
ListenStream=127.0.0.1:8787
|
||||
ListenStream=/run/toybox/admin.sock
|
||||
# whoever can open admin.sock can install code
|
||||
SocketUser=root
|
||||
SocketMode=0600
|
||||
|
||||
[Install]
|
||||
WantedBy=sockets.target
|
||||
@@ -0,0 +1,10 @@
|
||||
// @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',
|
||||
});
|
||||
@@ -0,0 +1,53 @@
|
||||
/** @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);
|
||||
});
|
||||
@@ -0,0 +1,105 @@
|
||||
/** @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 });
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,266 @@
|
||||
/** @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();
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,51 @@
|
||||
/** @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}`);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
/**
|
||||
* @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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
/**
|
||||
* 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,
|
||||
});
|
||||
@@ -0,0 +1,6 @@
|
||||
/** 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",
|
||||
});
|
||||
@@ -0,0 +1,12 @@
|
||||
/** 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", {});
|
||||
};
|
||||
+2
-37
@@ -12,54 +12,19 @@ grows past a form or two.
|
||||
|
||||
- `GET /` — the mint form. Prefill via query string: `?url=…&headers=<urlencoded JSON>&msPerCall=…&title=…`; secret header keys (cookie, authorization, proxy-authorization) render **blank** — paste them at mint time; never echoed back. A dedicated `cookie` field is sugar for the `cookie` header (if the JSON also has one, they must agree).
|
||||
- `GET /form?render=1&…` — the same form for a proxying worker (diigo-cache's `/_connect`): the action is left as an `ACTION` placeholder for the proxy to point at its own relay.
|
||||
- `POST /` — mint. Form-encoded (that page) or JSON `{url, headers, msPerCall}` (service-style). Responds `{id, storeId}` (and `x-webapp-id`, `x-webapp-store-id`). Neither id is logged: possession is authority.
|
||||
- `POST /` — mint. Form-encoded (that page) or JSON `{url, headers, msPerCall}` (service-style). Responds `{id}` (and `x-webapp-id`). The id is *not* logged: possession is authority.
|
||||
- `POST /at/<id>` — drive an instance by capability: JSON `{path?, query?, method?, body?}` → the fluent chain → `{status, text}` (or `{error}`; messages carry no header/param values).
|
||||
- `PUT /store/<storeId>/<key>` — set a header-store entry (JSON value body), e.g. `PUT /store/<storeId>/cookie` with `"session=…"` rotates the cookie; `DELETE` removes it. → `{ok: true}` (or `{error}`; messages carry no keys or values).
|
||||
|
||||
## RPC surface (same-account workers; binding `entrypoint: "WebAppFactory"`)
|
||||
|
||||
- `fetch(request)` — the HTTP surface above, over the binding
|
||||
- `newWebApp({url, headers?, msPerCall?})` → `{id, storeId, app}` — the fluent stub;
|
||||
- `newWebApp({url, headers?, msPerCall?})` → `{id, app}` — the fluent stub;
|
||||
promise pipelining makes a whole `pathjoin().query().get()` chain a
|
||||
single round trip
|
||||
- `at(id)` — the fluent stub for a previously-minted instance
|
||||
- on a stub, `postForm(fields?, echoCookies?)` → `{status, contentType,
|
||||
contentDisposition?, location?, body}`: a multipart/form-data POST.
|
||||
`echoCookies` maps a form field to a cookie name; the cookie's value
|
||||
(header store included) fills the field at send, so a double-submit
|
||||
CSRF token goes out without the holder seeing it. Redirects are not
|
||||
followed and a non-OK status is a reply, not an Error (a 3xx to a login
|
||||
page is how many sites say "session expired"). `location` comes back
|
||||
query-stripped; `set-cookie` never comes back. `body` is bytes: the
|
||||
caller knows its charset.
|
||||
- `store(storeId)` — a header store, with full authority:
|
||||
`set("cookie", …)` rotates the cookie under every handle already out
|
||||
|
||||
## The header store: two ids, two authorities
|
||||
|
||||
Mint makes two things: the instance, and a header store (a `MapStoreDo`
|
||||
from [the capsite lib](../lib/src/store-do.ts), shaped
|
||||
`("cookie", { keyShape: "cookie", valueShape: M.string() })`). A `cookie`
|
||||
header goes into the store, not the instance's `init`; the instance reads
|
||||
the store's read-only view at every send.
|
||||
|
||||
- the **instance id** is authority to *use* the cookie: it goes to the
|
||||
cache's wiring.
|
||||
- the **store id** is authority to *choose* it: it stays with the account
|
||||
owner, who can rotate it (log in again, `PUT`) or revoke it (`DELETE`,
|
||||
or log out).
|
||||
|
||||
Kept apart because whoever can choose the cookie can swap in another
|
||||
account's session: if the cache's id could rotate, a leaked instance id
|
||||
would let Mallory fill the cache (and its public page) with her own
|
||||
bookmarks, and the owner's logout would not stop it.
|
||||
|
||||
Instances minted before header stores keep their cookie in
|
||||
`init.headers`, unchanged; they work as before and cannot rotate.
|
||||
|
||||
## Run
|
||||
|
||||
pnpm test # header store + rotation (vitest, workerd pool)
|
||||
npx wrangler dev # → its own ready line (usually :8787 with the
|
||||
# two-config diigo-cache run; standalone, :8788)
|
||||
|
||||
|
||||
@@ -1,16 +0,0 @@
|
||||
{
|
||||
"name": "webapp",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"types": "wrangler types",
|
||||
"lint": "wrangler types --check && tsc -p tsconfig.json && tsc -p tests/tsconfig.json",
|
||||
"test": "vitest run"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@cloudflare/vitest-plugin": "1.3.6",
|
||||
"typescript": "^5.9.0",
|
||||
"vitest": "4.1.11",
|
||||
"wrangler": "^4.148.0"
|
||||
}
|
||||
}
|
||||
Generated
-2240
File diff suppressed because it is too large.
Load diff
+34
-253
@@ -7,52 +7,21 @@
|
||||
// DurableObjectNamespace/DurableObjectId/DurableObjectStub are ambient types
|
||||
// in worker-configuration.d.ts (not exports of "cloudflare:workers")
|
||||
import { RpcTarget, DurableObject, WorkerEntrypoint } from "cloudflare:workers";
|
||||
import { M } from "../../lib/src/patterns.js";
|
||||
import { encodeShapes, MapStoreDo } from "../../lib/src/store-do.js";
|
||||
|
||||
export { MapStoreDo };
|
||||
|
||||
export type Query = Record<string, string | number>;
|
||||
type Headers = Record<string, string>;
|
||||
|
||||
/** headers looked up at each send, e.g. a store's read-only view: whoever
|
||||
* holds the store can rotate a credential under handles already out */
|
||||
export type HeaderMap = { entries(): [string, unknown][] | Promise<[string, unknown][]> };
|
||||
|
||||
/**
|
||||
* init for a new instance — fixed at creation. An instance is immutable
|
||||
* authority: no re-config methods, only attenuation via the fluent chain.
|
||||
* The one thing that changes under it is its header store's contents.
|
||||
*/
|
||||
export interface WebAppInit {
|
||||
url: string;
|
||||
headers?: Headers;
|
||||
/** min spacing between outbound calls, ms (0 = unthrottled) */
|
||||
msPerCall?: number;
|
||||
/** id of a {@link MapStoreDo} whose entries are added as headers at
|
||||
* each send (see {@link mintInstance}); the instance only reads it */
|
||||
headerStore?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* what {@link WebApp.postForm} hands back: the status and the response
|
||||
* headers a caller needs to tell a result from a refusal, never
|
||||
* `set-cookie` (a credential stays where it is) and with any query
|
||||
* stripped from `location`
|
||||
*/
|
||||
export type Reply = {
|
||||
status: number;
|
||||
contentType: string;
|
||||
contentDisposition?: string;
|
||||
location?: string;
|
||||
/** the body as sent: the caller knows its charset (some formats name
|
||||
* it in their own header, not in content-type) */
|
||||
body: Uint8Array;
|
||||
};
|
||||
|
||||
/** one outbound request, redirects not followed; the DO's gate and fetch */
|
||||
export type Exchange = (url: string, headers: Headers, method: "POST", body: FormData) => Promise<Response>;
|
||||
|
||||
/** structural type of a stub handle: chain to narrow, then fire */
|
||||
export interface FluentGet {
|
||||
pathjoin(ref: string): FluentGet;
|
||||
@@ -60,10 +29,8 @@ export interface FluentGet {
|
||||
withHeaders(h: Headers): FluentGet;
|
||||
withHeader(key: string, value: string): FluentGet;
|
||||
cookies(cookie: string): FluentGet;
|
||||
withHeaderMap(map: HeaderMap): FluentGet;
|
||||
get(): Promise<string>;
|
||||
post(body: string): Promise<string>;
|
||||
postForm(fields?: Record<string, string>, echoCookies?: Record<string, string>): Promise<Reply>;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -90,23 +57,17 @@ export interface FluentGet {
|
||||
export class WebApp extends RpcTarget implements FluentGet {
|
||||
#url: string;
|
||||
#headers: Headers;
|
||||
#maps: readonly HeaderMap[];
|
||||
#send: (url: string, headers: Headers, method: "GET" | "POST", body?: string) => Promise<string>;
|
||||
#exchange: Exchange;
|
||||
|
||||
constructor(
|
||||
url: string,
|
||||
headers: Headers,
|
||||
maps: readonly HeaderMap[],
|
||||
send: (url: string, headers: Headers, method: "GET" | "POST", body?: string) => Promise<string>,
|
||||
exchange: Exchange,
|
||||
) {
|
||||
super();
|
||||
this.#url = url;
|
||||
this.#headers = headers;
|
||||
this.#maps = maps;
|
||||
this.#send = send;
|
||||
this.#exchange = exchange;
|
||||
}
|
||||
|
||||
/** Join a relative path, merging the base's query.
|
||||
@@ -124,13 +85,13 @@ export class WebApp extends RpcTarget implements FluentGet {
|
||||
throw new Error(`pathjoin: ${ref} not within ${hereNoQuery.href}`);
|
||||
}
|
||||
const baseParams: Query = Object.fromEntries(here.searchParams);
|
||||
return new WebApp(unionQuery(there.href, baseParams), this.#headers, this.#maps, this.#send, this.#exchange);
|
||||
return new WebApp(unionQuery(there.href, baseParams), this.#headers, this.#send);
|
||||
}
|
||||
|
||||
/** Union headers, flower-style (see {@link WebApp}): a shared key with a
|
||||
* different value throws; the same value is fine. */
|
||||
withHeaders(h: Headers): WebApp {
|
||||
return new WebApp(this.#url, unionHeaders(this.#headers, h), this.#maps, this.#send, this.#exchange);
|
||||
return new WebApp(this.#url, unionHeaders(this.#headers, h), this.#send);
|
||||
}
|
||||
|
||||
/** one key, flower-style (see {@link WebApp}) */
|
||||
@@ -144,72 +105,15 @@ export class WebApp extends RpcTarget implements FluentGet {
|
||||
|
||||
/** Union params into the existing query, flower-style (see {@link WebApp}). */
|
||||
query(params: Query): WebApp {
|
||||
return new WebApp(unionQuery(this.#url, params), this.#headers, this.#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);
|
||||
return new WebApp(unionQuery(this.#url, params), this.#headers, this.#send);
|
||||
}
|
||||
|
||||
async get(): Promise<string> {
|
||||
return this.#send(this.#url, await this.#headersNow(), "GET");
|
||||
return this.#send(this.#url, this.#headers, "GET");
|
||||
}
|
||||
|
||||
async post(body: string): Promise<string> {
|
||||
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;
|
||||
return this.#send(this.#url, this.#headers, "POST", body);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -245,16 +149,11 @@ class Gate {
|
||||
* storage, surviving eviction (instance memory evaporates when the DO naps;
|
||||
* storage is where "this instance means this URL" durably lives).
|
||||
*
|
||||
* Its cookie, when minted by {@link mintInstance}, is not in `init` but in
|
||||
* the header store, whose id goes to the account owner and not to whoever
|
||||
* holds the instance id: the owner can rotate it, and the holder cannot
|
||||
* swap in another account's session.
|
||||
*
|
||||
* @see https://developers.cloudflare.com/durable-objects/ — newUniqueId is
|
||||
* unforgeable (128-bit, inderivable); idFromName by contrast is ambient:
|
||||
* any worker with the namespace binding can recompute it
|
||||
*/
|
||||
export class WebAppDo extends DurableObject<WebAppEnv> {
|
||||
export class WebAppDo extends DurableObject<{ WEBAPP?: never }> {
|
||||
#ready?: Promise<void>;
|
||||
|
||||
/** one-shot configuration; a different-url re-configure is refused */
|
||||
@@ -308,46 +207,16 @@ export class WebAppDo extends DurableObject<WebAppEnv> {
|
||||
}
|
||||
return res.text();
|
||||
};
|
||||
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();
|
||||
},
|
||||
};
|
||||
return new WebApp(init.url, init.headers ?? {}, send);
|
||||
}
|
||||
}
|
||||
|
||||
/** split the cookie (any case of the header name) from the other headers */
|
||||
function splitCookie(headers: Headers): { cookie?: string; rest: Headers } {
|
||||
const rest: Headers = {};
|
||||
let cookie: string | undefined;
|
||||
for (const [key, value] of Object.entries(headers)) {
|
||||
if (key.toLowerCase() === "cookie") cookie = value;
|
||||
else rest[key] = value;
|
||||
}
|
||||
return { cookie, rest };
|
||||
}
|
||||
|
||||
type WebAppEnv = {
|
||||
WEBAPP_DO: DurableObjectNamespace<WebAppDo>;
|
||||
MAP_STORE_DO: DurableObjectNamespace<MapStoreDo>;
|
||||
};
|
||||
|
||||
/** the worker's whole HTTP surface (the default export and the
|
||||
* WebAppFactory entrypoint share it) */
|
||||
const defaultFetch = async (request: Request, env: WebAppEnv): Promise<Response> => {
|
||||
const defaultFetch = async (
|
||||
request: Request,
|
||||
env: { WEBAPP_DO: DurableObjectNamespace<WebAppDo> },
|
||||
): Promise<Response> => {
|
||||
const url = new URL(request.url);
|
||||
|
||||
// mint form: site-agnostic; secret headers (cookie, authorization) are
|
||||
@@ -358,7 +227,7 @@ const defaultFetch = async (request: Request, env: WebAppEnv): Promise<Response>
|
||||
|
||||
// mint: browser form POST (form-encoded) or JSON (service-style)
|
||||
if (url.pathname === "/" && request.method === "POST") {
|
||||
return mint(request, env);
|
||||
return mint(request, env.WEBAPP_DO);
|
||||
}
|
||||
|
||||
// the mint form, renderable for a proxying worker: ?render=1 keeps the
|
||||
@@ -377,14 +246,6 @@ const defaultFetch = async (request: Request, env: WebAppEnv): Promise<Response>
|
||||
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 });
|
||||
};
|
||||
|
||||
@@ -444,7 +305,10 @@ function withCookie(init: WebAppInit, cookie: string): WebAppInit {
|
||||
}
|
||||
|
||||
/** mint a fresh instance from form-encoded or JSON input */
|
||||
async function mint(request: Request, env: WebAppEnv): Promise<Response> {
|
||||
async function mint(
|
||||
request: Request,
|
||||
ns: DurableObjectNamespace<WebAppDo>,
|
||||
): Promise<Response> {
|
||||
const ct = request.headers.get("content-type") ?? "";
|
||||
let init: WebAppInit;
|
||||
try {
|
||||
@@ -471,35 +335,13 @@ async function mint(request: Request, env: WebAppEnv): Promise<Response> {
|
||||
return Response.json({ error: "bad url" }, { status: 400 });
|
||||
}
|
||||
|
||||
const { id, storeId } = await mintInstance(env, init);
|
||||
// note: ids intentionally NOT logged — they are bearer capabilities
|
||||
const id = ns.newUniqueId();
|
||||
const instance = ns.get(id);
|
||||
await instance.configure(init);
|
||||
const app = await instance.handle();
|
||||
// note: id intentionally NOT logged — it is a bearer capability
|
||||
console.log({ event: "mint", msPerCall: init.msPerCall });
|
||||
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() };
|
||||
return Response.json({ id: id.toString() }, { headers: { "x-webapp-id": id.toString() } });
|
||||
}
|
||||
|
||||
/** drive a handle from the /at/<id> JSON protocol (also used by the
|
||||
@@ -526,60 +368,6 @@ async function drive(app: FluentGet, request: Request): Promise<Response> {
|
||||
}
|
||||
}
|
||||
|
||||
/** the /store/<id>/<key> protocol: PUT a JSON value (set, or init if
|
||||
* absent), or DELETE → {ok} (or {error}; messages carry no values) */
|
||||
async function storeEntry(store: DurableObjectStub<MapStoreDo>, key: string, request: Request): Promise<Response> {
|
||||
let value: unknown;
|
||||
if (request.method === "PUT") {
|
||||
try {
|
||||
value = await request.json();
|
||||
} catch (err) {
|
||||
return Response.json({ error: `bad input: ${String(err)}` }, { status: 400 });
|
||||
}
|
||||
}
|
||||
try {
|
||||
using full = await store.store();
|
||||
if (request.method === "DELETE") {
|
||||
if (await full.has(key)) await full.delete(key);
|
||||
} else if (await full.has(key)) {
|
||||
await full.set(key, value);
|
||||
} else {
|
||||
await full.init(key, value);
|
||||
}
|
||||
} catch (err) {
|
||||
// store errors name shapes, never keys or values (see lib/src/store.ts)
|
||||
return Response.json({ error: String(err) }, { status: 400 });
|
||||
}
|
||||
// note: neither id nor value logged
|
||||
console.log({ event: "store", method: request.method });
|
||||
return Response.json({ ok: true });
|
||||
}
|
||||
|
||||
/** a cookie header's name → value (the first of a repeated name wins) */
|
||||
function parseCookies(header: string): Map<string, string> {
|
||||
const jar = new Map<string, string>();
|
||||
for (const pair of header.split(";")) {
|
||||
const eq = pair.indexOf("=");
|
||||
if (eq < 0) continue;
|
||||
const name = pair.slice(0, eq).trim();
|
||||
if (name && !jar.has(name)) jar.set(name, pair.slice(eq + 1).trim());
|
||||
}
|
||||
return jar;
|
||||
}
|
||||
|
||||
/** `ref` resolved against `base`, query and fragment dropped: they can carry credentials */
|
||||
function stripQuery(ref: string, base: string): string {
|
||||
const url = new URL(ref, base);
|
||||
url.search = "";
|
||||
url.hash = "";
|
||||
return url.href;
|
||||
}
|
||||
|
||||
/** `{[key]: value}`, or `{}` when value is null: optional fields stay absent */
|
||||
function withDefined<K extends string>(key: K, value: string | null): { [P in K]?: string } {
|
||||
return (value === null ? {} : { [key]: value }) as { [P in K]?: string };
|
||||
}
|
||||
|
||||
function unionHeaders(base: Headers, more: Headers): Headers {
|
||||
const merged = { ...base };
|
||||
for (const [key, value] of Object.entries(more)) {
|
||||
@@ -608,37 +396,37 @@ function unionQuery(base: string, params: Query): string {
|
||||
/**
|
||||
* the RPC service of the webapp worker. Flow:
|
||||
*
|
||||
* const { id, storeId, app } = await env.WEBAPP.newWebApp({
|
||||
* url: "https://diigo.example/", headers: { cookie: c },
|
||||
* const { id, app } = await env.WEBAPP.newWebApp({
|
||||
* url: "https://api.github.com", headers: { authorization: `Bearer ${t}` },
|
||||
* msPerCall: 250,
|
||||
* });
|
||||
* // hold id (persist it, hand it to workers you trust), then e.g.:
|
||||
* const text = await app.query({ per_page: 5 }).get();
|
||||
* // later, from another worker (same account) holding the id:
|
||||
* const app2 = env.WEBAPP.at(id);
|
||||
* // the account owner, holding storeId, after logging in again:
|
||||
* await (await env.WEBAPP.store(storeId)).set("cookie", c2);
|
||||
*/
|
||||
export class WebAppFactory extends WorkerEntrypoint {
|
||||
/** the same HTTP surface as the default export, over the entrypoint
|
||||
* (for service-binding callers that proxy pages, e.g. diigo-cache's
|
||||
* /_connect → this form) */
|
||||
async fetch(request: Request): Promise<Response> {
|
||||
return defaultFetch(request, this.env);
|
||||
const env = this.env as { WEBAPP_DO: DurableObjectNamespace<WebAppDo> };
|
||||
return defaultFetch(request, env);
|
||||
}
|
||||
|
||||
/** mint a fresh instance: unforgeable uniqueId + configured fluent stub,
|
||||
* plus the id of its header store (see {@link mintInstance}). The
|
||||
* caller decides who else ever learns either id. (If the caller only
|
||||
/** mint a fresh instance: unforgeable uniqueId + configured fluent stub.
|
||||
* The caller decides who else ever learns the id. (If the caller only
|
||||
* wanted the id, it must dispose() the app stub — workerd warns about
|
||||
* undisposed stubs; see the `at()` alternative + `/at/<id>` route for
|
||||
* id-only flows.) */
|
||||
async newWebApp(init: WebAppInit): Promise<{ id: string; storeId: string; app: FluentGet }> {
|
||||
async newWebApp(init: WebAppInit): Promise<{ id: string; app: FluentGet }> {
|
||||
new URL(init.url); // fail before minting on garbage
|
||||
const { id, storeId } = await mintInstance(this.env, init);
|
||||
const ns = this.env.WEBAPP_DO;
|
||||
const app = await ns.get(ns.idFromString(id)).handle();
|
||||
return { id, storeId, app };
|
||||
const id = ns.newUniqueId();
|
||||
const instance = ns.get(id);
|
||||
await instance.configure(init);
|
||||
const app = await instance.handle();
|
||||
return { id: id.toString(), app };
|
||||
}
|
||||
|
||||
/** reconnect by a previously-obtained id */
|
||||
@@ -646,13 +434,6 @@ export class WebAppFactory extends WorkerEntrypoint {
|
||||
const ns = this.env.WEBAPP_DO;
|
||||
return ns.get(ns.idFromString(id)).handle();
|
||||
}
|
||||
|
||||
/** a header store by its id, with full authority (the instance id
|
||||
* reaches none: see {@link mintInstance}) */
|
||||
async store(storeId: string) {
|
||||
const ns = this.env.MAP_STORE_DO;
|
||||
return ns.get(ns.idFromString(storeId)).store();
|
||||
}
|
||||
}
|
||||
function sleep(ms: number): Promise<void> {
|
||||
return new Promise((r) => setTimeout(r, ms));
|
||||
|
||||
@@ -1,131 +0,0 @@
|
||||
import { env } from "cloudflare:workers";
|
||||
import { runInDurableObject } from "cloudflare:test";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import worker, { type WebAppInit } from "../src/index.js";
|
||||
|
||||
type TestEnv = {
|
||||
WEBAPP_DO: DurableObjectNamespace<import("../src/index.js").WebAppDo>;
|
||||
MAP_STORE_DO: DurableObjectNamespace<import("../src/index.js").MapStoreDo>;
|
||||
};
|
||||
const testEnv = env as unknown as TestEnv;
|
||||
|
||||
const URL_ = "https://diigo.example/api/";
|
||||
|
||||
/** the cookie header of each outbound fetch */
|
||||
function spyCookies(): (string | null)[] {
|
||||
const seen: (string | null)[] = [];
|
||||
vi.spyOn(globalThis, "fetch").mockImplementation(async (_input, init) => {
|
||||
seen.push(new Headers(init?.headers).get("cookie"));
|
||||
return new Response("ok");
|
||||
});
|
||||
return seen;
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
/** the worker's HTTP surface, as a JSON client */
|
||||
const call = (path: string, method: string, body?: unknown) =>
|
||||
worker.fetch(
|
||||
new Request(`https://webapp.example${path}`, {
|
||||
method,
|
||||
body: body === undefined ? undefined : JSON.stringify(body),
|
||||
headers: { "content-type": "application/json" },
|
||||
}),
|
||||
testEnv,
|
||||
);
|
||||
|
||||
const mint = async (init: WebAppInit) => {
|
||||
const { id, storeId } = (await (await call("/", "POST", init)).json()) as { id: string; storeId: string };
|
||||
const instance = testEnv.WEBAPP_DO.get(testEnv.WEBAPP_DO.idFromString(id));
|
||||
return { id, storeId, instance };
|
||||
};
|
||||
|
||||
/** the failure of a call: its error status, or what it threw; "" if it succeeds */
|
||||
async function errorOf(response: Promise<Response>): Promise<string> {
|
||||
try {
|
||||
const res = await response;
|
||||
return res.ok ? "" : `${res.status} ${await res.text()}`;
|
||||
} catch (err) {
|
||||
return String(err);
|
||||
}
|
||||
}
|
||||
|
||||
/** `init` as stored */
|
||||
const storedInit = (stub: DurableObjectStub) =>
|
||||
runInDurableObject(stub, (_obj, state) => state.storage.kv.get<WebAppInit>("init"));
|
||||
|
||||
describe("cookie rotation", () => {
|
||||
it("mint puts the cookie in the header store, not init; sends carry it", async () => {
|
||||
const seen = spyCookies();
|
||||
const { storeId, instance } = await mint({
|
||||
url: URL_,
|
||||
headers: { Cookie: "session=a", accept: "application/json" },
|
||||
});
|
||||
expect(await storedInit(instance)).toEqual({
|
||||
url: URL_,
|
||||
headers: { accept: "application/json" },
|
||||
headerStore: storeId,
|
||||
});
|
||||
using app = await instance.handle();
|
||||
await app.get();
|
||||
expect(seen).toEqual(["session=a"]);
|
||||
});
|
||||
|
||||
it("the store id rotates under handles already out, and revokes", async () => {
|
||||
const seen = spyCookies();
|
||||
const { storeId, instance } = await mint({ url: URL_, headers: { cookie: "session=a" } });
|
||||
using app = await instance.handle();
|
||||
await app.pathjoin("x").get();
|
||||
expect(await (await call(`/store/${storeId}/cookie`, "PUT", "session=b")).json()).toEqual({ ok: true });
|
||||
await app.pathjoin("x").get();
|
||||
expect(await (await call(`/store/${storeId}/cookie`, "DELETE")).json()).toEqual({ ok: true });
|
||||
await app.get();
|
||||
expect(seen).toEqual(["session=a", "session=b", null]);
|
||||
});
|
||||
|
||||
it("the instance id reaches no store: its holder cannot swap in another session", async () => {
|
||||
const seen = spyCookies();
|
||||
const { id, instance } = await mint({ url: URL_, headers: { cookie: "session=alice" } });
|
||||
expect(await errorOf(call(`/store/${id}/cookie`, "PUT", "session=mallory"))).not.toBe("");
|
||||
expect((await call(`/at/${id}/cookie`, "POST", { cookie: "session=mallory" })).status).toBe(404);
|
||||
using app = await instance.handle();
|
||||
await app.get();
|
||||
expect(seen).toEqual(["session=alice"]);
|
||||
});
|
||||
|
||||
it("the store's shapes admit only the cookie; errors carry no values", async () => {
|
||||
const { storeId } = await mint({ url: URL_ });
|
||||
const err = await errorOf(call(`/store/${storeId}/authorization`, "PUT", "Bearer s3kr1t"));
|
||||
expect(err).toContain('cookie: key: must match \\"cookie\\"');
|
||||
expect(err).not.toContain("s3kr1t");
|
||||
expect(await errorOf(call(`/store/${storeId}/cookie`, "PUT", 42))).toContain("must match M.string()");
|
||||
});
|
||||
|
||||
it("an instance minted before header stores keeps its cookie in init, untouched", async () => {
|
||||
const seen = spyCookies();
|
||||
const instance = testEnv.WEBAPP_DO.get(testEnv.WEBAPP_DO.newUniqueId());
|
||||
const old = { url: URL_, headers: { cookie: "session=old" }, msPerCall: 0 };
|
||||
await runInDurableObject(instance, (_obj, state) => state.storage.put("init", old));
|
||||
using app = await instance.handle();
|
||||
await app.get();
|
||||
expect(seen).toEqual(["session=old"]);
|
||||
expect(await storedInit(instance)).toEqual(old);
|
||||
});
|
||||
|
||||
it("a fixed header contradicting the stored cookie fails at send, naming no values", async () => {
|
||||
spyCookies();
|
||||
const { instance } = await mint({ url: URL_, headers: { cookie: "session=a" } });
|
||||
using app = await instance.handle();
|
||||
const err = await app
|
||||
.withHeader("cookie", "session=mine")
|
||||
.get()
|
||||
.then(
|
||||
() => "",
|
||||
(e: unknown) => String(e),
|
||||
);
|
||||
expect(err).toContain("headers: contradicts on cookie");
|
||||
expect(err).not.toContain("session=");
|
||||
});
|
||||
});
|
||||
@@ -1,117 +0,0 @@
|
||||
import { env } from "cloudflare:workers";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import worker, { type WebAppInit } from "../src/index.js";
|
||||
|
||||
type TestEnv = {
|
||||
WEBAPP_DO: DurableObjectNamespace<import("../src/index.js").WebAppDo>;
|
||||
MAP_STORE_DO: DurableObjectNamespace<import("../src/index.js").MapStoreDo>;
|
||||
};
|
||||
const testEnv = env as unknown as TestEnv;
|
||||
|
||||
const URL_ = "https://site.example/app/export/7/data";
|
||||
|
||||
type Seen = { url: string; redirect?: string; contentType: string | null; form: Record<string, string> };
|
||||
|
||||
/** each outbound fetch, its form fields read back; answers with `res()` */
|
||||
function spyForms(res: () => Response): Seen[] {
|
||||
const seen: Seen[] = [];
|
||||
vi.spyOn(globalThis, "fetch").mockImplementation(async (input, init) => {
|
||||
const req = new Request(input as string, init as RequestInit);
|
||||
const form = Object.fromEntries([...(await req.formData()).entries()].map(([k, v]) => [k, String(v)]));
|
||||
seen.push({ url: req.url, redirect: init?.redirect, contentType: req.headers.get("content-type"), form });
|
||||
return res();
|
||||
});
|
||||
return seen;
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
const mint = async (init: WebAppInit) => {
|
||||
const res = await worker.fetch(
|
||||
new Request("https://webapp.example/", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(init),
|
||||
headers: { "content-type": "application/json" },
|
||||
}),
|
||||
testEnv,
|
||||
);
|
||||
const { id, storeId } = (await res.json()) as { id: string; storeId: string };
|
||||
return { id, storeId, instance: testEnv.WEBAPP_DO.get(testEnv.WEBAPP_DO.idFromString(id)) };
|
||||
};
|
||||
|
||||
/** the failure of a call: what it threw; "" if it succeeds */
|
||||
async function errorOf(p: Promise<unknown>): Promise<string> {
|
||||
try {
|
||||
await p;
|
||||
return "";
|
||||
} catch (err) {
|
||||
return String(err);
|
||||
}
|
||||
}
|
||||
|
||||
describe("postForm", () => {
|
||||
it("echoes a cookie from the header store into a form field; the holder never sees it", async () => {
|
||||
const seen = spyForms(
|
||||
() =>
|
||||
new Response("col1,col2", {
|
||||
headers: {
|
||||
"content-type": "text/csv",
|
||||
"content-disposition": 'attachment;filename="x.csv"',
|
||||
"set-cookie": "sess=next; HttpOnly",
|
||||
},
|
||||
}),
|
||||
);
|
||||
const { instance } = await mint({ url: URL_, headers: { cookie: "a=1; tok=s3cret; b=2" } });
|
||||
using app = await instance.handle();
|
||||
const reply = await app.query({ since: "2026-07-13\x1f" }).postForm({ kind: "all" }, { csrf: "tok" });
|
||||
expect(seen).toEqual([
|
||||
{
|
||||
url: `${URL_}?since=2026-07-13%1F`,
|
||||
redirect: "manual",
|
||||
contentType: expect.stringMatching(/^multipart\/form-data; boundary=/),
|
||||
form: { kind: "all", csrf: "s3cret" },
|
||||
},
|
||||
]);
|
||||
expect(reply).toEqual({
|
||||
status: 200,
|
||||
contentType: "text/csv",
|
||||
contentDisposition: 'attachment;filename="x.csv"',
|
||||
body: new TextEncoder().encode("col1,col2"),
|
||||
});
|
||||
});
|
||||
|
||||
it("does not follow a redirect: the 3xx comes back, location query-stripped", async () => {
|
||||
spyForms(() => new Response(null, { status: 302, headers: { location: "/login?next=s3cret#x" } }));
|
||||
const { instance } = await mint({ url: URL_, headers: { cookie: "tok=t" } });
|
||||
using app = await instance.handle();
|
||||
const reply = await app.postForm({}, { csrf: "tok" });
|
||||
expect(reply).toEqual({ status: 302, contentType: "", location: "https://site.example/login", body: new Uint8Array() });
|
||||
});
|
||||
|
||||
it("refuses, naming no values: a missing cookie, a field set twice, a content-type header", async () => {
|
||||
spyForms(() => new Response("ok"));
|
||||
const { instance } = await mint({ url: URL_, headers: { cookie: "tok=s3cret" } });
|
||||
using app = await instance.handle();
|
||||
expect(await errorOf(app.postForm({}, { csrf: "nope" }))).toMatch(/no nope cookie to echo/);
|
||||
const twice = await errorOf(app.postForm({ csrf: "mine" }, { csrf: "tok" }));
|
||||
expect(twice).toMatch(/contradicts on csrf/);
|
||||
expect(twice).not.toMatch(/s3cret|mine/);
|
||||
const typed = app.withHeader("content-type", "text/plain");
|
||||
expect(await errorOf(typed.postForm())).toMatch(/content-type is set by the form/);
|
||||
});
|
||||
|
||||
it("rotating the store's cookie changes what is echoed", async () => {
|
||||
const seen = spyForms(() => new Response("ok"));
|
||||
const { storeId, instance } = await mint({ url: URL_, headers: { cookie: "tok=one" } });
|
||||
using app = await instance.handle();
|
||||
await app.postForm({}, { csrf: "tok" });
|
||||
await worker.fetch(
|
||||
new Request(`https://webapp.example/store/${storeId}/cookie`, { method: "PUT", body: JSON.stringify("tok=two") }),
|
||||
testEnv,
|
||||
);
|
||||
await app.postForm({}, { csrf: "tok" });
|
||||
expect(seen.map((s) => s.form.csrf)).toEqual(["one", "two"]);
|
||||
});
|
||||
});
|
||||
@@ -1,7 +0,0 @@
|
||||
{
|
||||
"extends": "../tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"types": ["@cloudflare/vitest-plugin/types"]
|
||||
},
|
||||
"include": ["./**/*", "../worker-configuration.d.ts"]
|
||||
}
|
||||
@@ -7,7 +7,6 @@
|
||||
"esModuleInterop": true,
|
||||
"verbatimModuleSyntax": true,
|
||||
"skipLibCheck": true,
|
||||
"lib": ["ES2022", "ESNext.Disposable"],
|
||||
"noEmit": true,
|
||||
"types": []
|
||||
},
|
||||
|
||||
@@ -1,9 +0,0 @@
|
||||
import { defineProject } from "vitest/config";
|
||||
import { cloudflareTest } from "@cloudflare/vitest-plugin";
|
||||
|
||||
export default defineProject({
|
||||
plugins: [cloudflareTest({ wrangler: { configPath: "./wrangler.jsonc" } })],
|
||||
test: {
|
||||
include: ["tests/**/*.test.ts"],
|
||||
},
|
||||
});
|
||||
@@ -5,13 +5,7 @@
|
||||
"compatibility_date": "2026-09-29",
|
||||
"observability": { "enabled": true },
|
||||
"durable_objects": {
|
||||
"bindings": [
|
||||
{ "name": "WEBAPP_DO", "class_name": "WebAppDo" },
|
||||
{ "name": "MAP_STORE_DO", "class_name": "MapStoreDo" }
|
||||
]
|
||||
"bindings": [{ "name": "WEBAPP_DO", "class_name": "WebAppDo" }]
|
||||
},
|
||||
"migrations": [
|
||||
{ "tag": "v1", "new_sqlite_classes": ["WebAppDo"] },
|
||||
{ "tag": "v2", "new_sqlite_classes": ["MapStoreDo"] }
|
||||
]
|
||||
"migrations": [{ "tag": "v1", "new_sqlite_classes": ["WebAppDo"] }]
|
||||
}
|
||||
Reference in new issue
Block a user