Files
captoys/toybox/labels.js
T
dckcandClaude Opus 5.5 8d8900d84d capsite/toybox: vats have ids, not names; ingress by mount
createVat (POST / on the admin socket) makes a vat with newUniqueId and
returns its id, which addresses it only there. A vat is reachable only
where it is mounted: ?mount=<labels> on each start, as ?egress= is
granted; absent means none.

The ingress reads a request's labels from its query string, plus a
Toybox-Labels header for secret ones (a URL ends up in logs and
Referers). It routes to the vat whose mount labels the request carries,
every one: stricter than Flower's no-conflict match, since an ingress
client holds no constrained handle and a label it omits must not match.
Registration keeps Flower's rule, so a mount must conflict with every
other and no request can carry two. The app keeps the whole path and
sees its connection labels in Toybox-Connection-Labels.

A vat from before ids lives at idFromName(name). PUT /by-name/<name>
reaches it on the admin socket; it stays unreachable from outside until
mounted.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-13 21:44:29 -05:00

74 lines
2.4 KiB
JavaScript

/**
* @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`);
}
};
/**
* Do `a` and `b` give some key different values? Flower refuses to register
* a target that doesn't conflict with every other: lookups stay unambiguous.
*
* @param {Record<string, string>} a
* @param {Record<string, string>} b
*/
export const conflict = (a, b) => Object.entries(a).some(([k, v]) => k in b && b[k] !== v);
/**
* Does `request` carry every label of `mount`, with the same values?
*
* Stricter than Flower's match (no conflict): an ingress client holds no
* constrained handle, so a label it omits must not match, or a secret
* label would protect nothing.
*
* @param {Record<string, string>} mount
* @param {Record<string, string>} request
*/
export const carries = (mount, request) => Object.entries(mount).every(([k, v]) => request[k] === v);