From 425faf32a6eb07aea3bbcbbb73d7fda8103d8aee Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 5 Oct 2026 14:11:14 +0000 Subject: [PATCH 1/9] deps: bump globals from 17.12.0 to 17.13.0 in the minor group Bumps the minor group with 1 update: [globals](https://github.com/sindresorhus/globals). Updates `globals` from 17.12.0 to 17.13.0 - [Release notes](https://github.com/sindresorhus/globals/releases) - [Commits](https://github.com/sindresorhus/globals/compare/v17.12.0...v17.13.0) --- updated-dependencies: - dependency-name: globals dependency-version: 17.13.0 dependency-type: direct:development update-type: version-update:semver-minor dependency-group: minor ... Signed-off-by: dependabot[bot] --- package-lock.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index 3d34b56..026db3f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1673,9 +1673,9 @@ } }, "node_modules/globals": { - "version": "17.12.0", - "resolved": "https://registry.npmjs.org/globals/-/globals-17.12.0.tgz", - "integrity": "sha512-cezEd/DTyyht9cvSSURyygXPfy04GtWO/5e6ZPvH7fCtjKz9PYOmuawphw1Ctd1f6C+5JypXfGD7ahNMXvevBA==", + "version": "17.13.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-17.13.0.tgz", + "integrity": "sha512-RwMTC61u7hrG3ZJMkZQOK4JLJrKS8QtoTii63EmWvFXHqycY9FObBJQCbsLxSO8kjKhWE5kV1GC+Vb1g3RI0Zg==", "dev": true, "license": "MIT", "engines": { From 60b53947efe366ecca899ab697deebdcc9046617 Mon Sep 17 00:00:00 2001 From: "cldmv-bot[bot]" <230771808+cldmv-bot[bot]@users.noreply.github.com> Date: Mon, 5 Oct 2026 14:13:36 +0000 Subject: [PATCH 2/9] chore: bump version to 1.2.3 --- package-lock.json | 4 ++-- package.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index 026db3f..f2082c4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@cldmv/slothlet-vine", - "version": "1.2.2", + "version": "1.2.3", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@cldmv/slothlet-vine", - "version": "1.2.2", + "version": "1.2.3", "license": "Apache-2.0", "devDependencies": { "@cldmv/configs": "^1.2.4", diff --git a/package.json b/package.json index 9d480c1..d36ffad 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@cldmv/slothlet-vine", - "version": "1.2.2", + "version": "1.2.3", "description": "Vines between slothlet api trees — location-transparent forwarding leaves over pluggable transports (Web Worker postMessage, worker_threads, process IPC, WebSocket), permission-gated by slothlet itself.", "type": "module", "license": "Apache-2.0", From f2beda4d0a20fd44106df7017555518f1199bc85 Mon Sep 17 00:00:00 2001 From: Shinrai Date: Wed, 7 Oct 2026 00:01:04 -0700 Subject: [PATCH 3/9] feat(serve): a per-call requested context on the call frame, accepted, narrowed or refused by a serve({ context }) host check and merged under the principal's keys, all protected, into the call's scope (#79) The call frame gains an optional, data-only context and the surface frame an optional context: true flag, advertised only by a serve with a check; no FRAME_VERSION change. The check runs after the principal is resolved and before the gate, fails closed (a throw or a non-plain-object answer is VINE_DENIED), and a serve without a check refuses a frame that carries one. around receives the accepted context. --- schemas/frame.schema.json | 12 +++- src/lib/errors.mjs | 6 +- src/lib/frame.mjs | 73 ++++++++++++++++--- src/lib/principal.mjs | 39 ++++++----- src/serve.mjs | 144 ++++++++++++++++++++++++++++++++++---- 5 files changed, 230 insertions(+), 44 deletions(-) diff --git a/schemas/frame.schema.json b/schemas/frame.schema.json index c37ec8f..4309d04 100644 --- a/schemas/frame.schema.json +++ b/schemas/frame.schema.json @@ -10,7 +10,11 @@ "properties": { "type": { "const": "surface" }, "v": { "const": 1 }, - "leaves": { "type": "array", "items": { "type": "string" } } + "leaves": { "type": "array", "items": { "type": "string" } }, + "context": { + "const": true, + "description": "present only when the serve accepts a per-call requested context (#79); an older grow ignores it" + } } }, { @@ -20,7 +24,11 @@ "type": { "const": "call" }, "callId": { "type": "string", "minLength": 1 }, "path": { "type": "string", "description": "dotted leaf path exactly as served, e.g. exts.pdfViewer.open" }, - "args": { "type": "array" } + "args": { "type": "array" }, + "context": { + "type": "object", + "description": "optional per-call REQUESTED context (#79): plain data, never identity; accepted, narrowed or refused by the serve's context check. Sent only to a serve whose surface advertised context: true" + } } }, { diff --git a/src/lib/errors.mjs b/src/lib/errors.mjs index 11f176c..31d08f3 100644 --- a/src/lib/errors.mjs +++ b/src/lib/errors.mjs @@ -33,8 +33,10 @@ export const CODES = Object.freeze({ NO_LEAF: "VINE_NO_LEAF", /** * The serving side's channel principal is not permitted to call this path — slothlet's rules said - * no (or could not be asked), and the leaf was never invoked. Produced ONLY by a `serve()` that - * bound a `principal`; a trusted-transport serve (no principal) never answers it. + * no (or could not be asked), and the leaf was never invoked. Produced by a `serve()` that bound a + * `principal`, and for a per-call requested context (#79) that was refused: by the serve's + * `context` check or because it has none — and, locally by a grow, before sending one to a far + * side that does not accept it. */ DENIED: "VINE_DENIED", /** diff --git a/src/lib/frame.mjs b/src/lib/frame.mjs index a0c62b1..3b6b00f 100644 --- a/src/lib/frame.mjs +++ b/src/lib/frame.mjs @@ -152,22 +152,68 @@ export function findFunctionArg(args) { /** * Build the `surface` frame — the served leaf manifest, sent once when a serve starts. + * + * `context: true` is added ONLY when the serve accepts a per-call requested context (#79) — a serve + * with a `context` check. Without one the frame is byte for byte the v1 frame. An older grow drops + * the unknown key; a newer grow refuses to send a requested context to a far side that did not + * advertise it, so a client never believes a call is scoped when the far side would ignore it. * @param {string[]} leaves - Dotted callable paths being served. - * @returns {{ type: "surface", v: number, leaves: string[] }} The frame. + * @param {{ context?: boolean }} [features] - What this serve accepts beyond the v1 call frame. + * @returns {{ type: "surface", v: number, leaves: string[], context?: true }} The frame. */ -export function surfaceFrame(leaves) { - return { type: "surface", v: FRAME_VERSION, leaves: [...leaves] }; +export function surfaceFrame(leaves, features = {}) { + const frame = { type: "surface", v: FRAME_VERSION, leaves: [...leaves] }; + if (features.context === true) frame.context = true; + return frame; } /** - * Build a `call` frame. + * Build a `call` frame. The `context` key — the caller's per-call REQUESTED context (#79) — is present + * only when one was given, so a call without one is byte for byte the v1 frame. * @param {string} callId - Correlation id, unique per grow-side link. * @param {string} path - The dotted leaf path exactly as served. * @param {unknown[]} args - Data-only arguments. - * @returns {{ type: "call", callId: string, path: string, args: unknown[] }} The frame. + * @param {object|null} [context] - A data-only plain object, already validated by {@link findContextFault}. + * @returns {{ type: "call", callId: string, path: string, args: unknown[], context?: object }} The frame. */ -export function callFrame(callId, path, args) { - return { type: "call", callId, path, args }; +export function callFrame(callId, path, args, context = null) { + const frame = { type: "call", callId, path, args }; + if (context !== null && context !== undefined) frame.context = context; + return frame; +} + +/** + * Is `value` a plain data object — `{}` / `Object.create(null)` — as opposed to an array, a class + * instance, or a primitive? + * @param {unknown} value - Candidate. + * @returns {boolean} True for a plain object. + */ +export function isPlainObject(value) { + if (value === null || typeof value !== "object" || Array.isArray(value)) return false; + const proto = Object.getPrototypeOf(value); + return proto === Object.prototype || proto === null; +} + +/** + * The data-only rules for a requested context (#79) — the same rules a call's arguments are held to, + * plus a shape: it must be a PLAIN object (it is merged key by key into a context scope, and only + * plain data gets full-depth `protect` there), it may hold no function anywhere, and it must survive + * a structured clone (a Symbol or a WeakMap is not data either). Never invokes user code beyond what + * `structuredClone` itself does, and never throws. + * @param {unknown} value - The candidate requested context. + * @returns {string|null} A location naming the first fault (`context`, `context.onDone`), or `null` + * when the value is acceptable. + */ +export function findContextFault(value) { + try { + if (!isPlainObject(value)) return "context"; + const functionAt = findFunctionArg([value]); + if (functionAt !== null) return `context${functionAt.slice("arg[0]".length)}`; + structuredClone(value); + return null; + } catch { + return "context"; + } } /** @@ -277,7 +323,10 @@ export function parseFrame(message) { if (isSafePath(leaf)) leaves.push(leaf); else unsafe.push(typeof leaf === "string" ? leaf : String(leaf)); } - return { type: "surface", v: FRAME_VERSION, leaves, unsafe }; + const frame = { type: "surface", v: FRAME_VERSION, leaves, unsafe }; + // #79: the serve accepts a requested context. Only a literal `true` counts. + if (message.context === true) frame.context = true; + return frame; } // Event-forwarding frames key on `subId`, not `callId`. `subscriberPath` is UNTRUSTED here, but @@ -324,7 +373,13 @@ export function parseFrame(message) { if (type === "call") { if (!isSafePath(message.path)) return null; if (!Array.isArray(message.args)) return null; - return { type: "call", callId, path: message.path, args: [...message.args] }; + const frame = { type: "call", callId, path: message.path, args: [...message.args] }; + // #79: a requested context is carried through RAW — `null` / `undefined` mean "none", anything + // else is validated by the serve, which answers `VINE_DATA_ONLY` for a bad one rather than + // dropping the frame (a dropped call would only settle on the caller's budget). + const context = message.context; + if (context !== undefined && context !== null) frame.context = context; + return frame; } if (type === "result") { return { type: "result", callId, value: message.value }; diff --git a/src/lib/principal.mjs b/src/lib/principal.mjs index 780a85d..54c9e25 100644 --- a/src/lib/principal.mjs +++ b/src/lib/principal.mjs @@ -13,7 +13,7 @@ * */ -import { isSafePath } from "./frame.mjs"; +import { isPlainObject, isSafePath } from "./frame.mjs"; /** * @typedef {object} Principal @@ -31,19 +31,6 @@ import { isSafePath } from "./frame.mjs"; * function form (its answer is per frame, so there is no single normalized value to show). */ -/** - * Is `value` a plain data object — `{}` / `Object.create(null)` — as opposed to an array, a class - * instance, or a primitive? Only plain data gets full-depth `protect` from slothlet's context scope, - * and a principal's context is deep-cloned per scope, so anything else is refused up front. - * @param {unknown} value - Candidate. - * @returns {boolean} True for a plain object. - */ -function isPlainObject(value) { - if (value === null || typeof value !== "object" || Array.isArray(value)) return false; - const proto = Object.getPrototypeOf(value); - return proto === Object.prototype || proto === null; -} - /** * Normalize one principal VALUE (a string or a `{ path, context? }` object) into a frozen * {@link Principal}. Used at setup for the static forms and per frame for a resolver's answer. @@ -151,8 +138,23 @@ export async function assertPrincipalSupport(api, needs, who) { `@cldmv/slothlet ≥ 3.22.0 is required to gate calls for a channel principal` ); } - if (needs.scope) await probe(api, who, "scope", () => api.slothlet.context.scope({ context: {}, fn: () => true })); - if (needs.run) await probe(api, who, "run", () => api.slothlet.context.run({}, () => true)); + if (needs.scope) await assertScopeSupport(api, who, "a principal"); + if (needs.run) await probe(api, who, "run", "a principal", () => api.slothlet.context.run({}, () => true)); +} + +/** + * Require a WORKING `api.slothlet.context.scope()` — probed with an empty scope, because a + * `scope: false` instance still exposes `scope` as a function that rejects `SCOPE_DISABLED`. Used for + * a principal (every call runs inside its scope) and for a `context` check on `serve()` (#79: every + * call carrying an accepted requested context runs inside a scope holding it). + * @param {object} api - The slothlet instance. + * @param {string} who - `serve` / `grow`, for the error message. + * @param {string} feature - What needs the scope (`a principal`, `a context check`), for the message. + * @returns {Promise} Resolves when the probe answered `true`. + * @throws {TypeError} When the probe failed. + */ +export async function assertScopeSupport(api, who, feature) { + await probe(api, who, "scope", feature, () => api.slothlet.context.scope({ context: {}, fn: () => true })); } /** @@ -161,11 +163,12 @@ export async function assertPrincipalSupport(api, needs, who) { * @param {object} api - The slothlet instance. * @param {string} who - `serve` / `grow`. * @param {"scope"|"run"} name - Which `api.slothlet.context.*` function is being probed. + * @param {string} feature - What needs it, for the message. * @param {() => unknown} attempt - The probe call. * @returns {Promise} Resolves when the probe answered `true`. * @throws {TypeError} When the probe threw, rejected, or answered anything but `true`. */ -async function probe(api, who, name, attempt) { +async function probe(api, who, name, feature, attempt) { let reason; try { if (typeof api.slothlet?.context?.[name] === "function" && (await attempt()) === true) return; @@ -174,7 +177,7 @@ async function probe(api, who, name, attempt) { reason = err?.message ?? String(err); } throw new TypeError( - `@cldmv/slothlet-vine: ${who}() with a principal needs a working api.slothlet.context.${name}() to run under the principal's context — ${reason}` + `@cldmv/slothlet-vine: ${who}() with ${feature} needs a working api.slothlet.context.${name}() to run each call under its context — ${reason}` ); } diff --git a/src/serve.mjs b/src/serve.mjs index 837c251..c5b8402 100644 --- a/src/serve.mjs +++ b/src/serve.mjs @@ -14,10 +14,19 @@ */ import { CODES, VineError } from "./lib/errors.mjs"; -import { errorFrame, findFunctionArg, isSafePath, parseFrame, resultFrame, surfaceFrame } from "./lib/frame.mjs"; +import { + errorFrame, + findContextFault, + findFunctionArg, + isPlainObject, + isSafePath, + parseFrame, + resultFrame, + surfaceFrame +} from "./lib/frame.mjs"; import { createEventForwarder } from "./lib/events.mjs"; import { assertApi, assertChannel } from "./lib/link.mjs"; -import { assertPrincipalSupport, bindPrincipal, gate } from "./lib/principal.mjs"; +import { assertPrincipalSupport, assertScopeSupport, bindPrincipal, gate } from "./lib/principal.mjs"; /** * Serve this instance's leaves to the far side of `channel`. @@ -54,7 +63,22 @@ import { assertPrincipalSupport, bindPrincipal, gate } from "./lib/principal.mjs * `api.slothlet.permissions.global.checkCall` (@cldmv/slothlet ≥ 3.22.0) — * each checked here, fail-closed, as a `TypeError`. Omit it for a trusted transport (a worker you * spawned, a process you forked): nothing changes, byte for byte. See `docs/PERMISSIONS.md`. - * @param {(call: { callId: string, path: string, args: unknown[], principal: Readonly<{ path: string, context?: object }>|null, invoke: () => Promise }) => unknown} [options.around] - + * @param {(requested: object, call: { principal: Readonly<{ path: string, context?: object }>|null, path: string, args: unknown[] }) => object|false|null|undefined|Promise} [options.context] - + * The host check for a per-call **requested context** (#79): a data-only plain object the far side + * attaches to one `call` frame (grow-side `link.with(context, fn)` or `grow({ context })`) — a request, + * never identity. Called only for frames that carry one, after the principal is resolved and before + * the permission gate, with a private copy of the request, the resolved principal (`null` without + * one), the path and the frame's arguments. It ACCEPTS by answering a plain object — the request + * itself, or a narrowed or changed one — and REFUSES by answering anything else (`false`, `null`, a + * non-plain or function-bearing object) or by throwing/rejecting; a refusal is `VINE_DENIED` and + * the leaf never runs. May be async. The accepted keys are merged into the call's context scope + * UNDER the principal's (a principal key always wins) and every key is write-protected, so + * `checkCall`, rule conditions, `requires`-principals, `around` and the leaf all see it and none + * can rewrite it. **Without this option a frame that carries a requested context is refused** + * (`VINE_DENIED`), so a client is never silently unscoped; with it, the `surface` frame advertises + * `context: true`, which a grow needs before it will send one. Requires a working + * `api.slothlet.context.scope` (checked at setup as a `TypeError`); a principal is NOT required. + * @param {(call: { callId: string, path: string, args: unknown[], principal: Readonly<{ path: string, context?: object }>|null, context: object|null, invoke: () => Promise }) => unknown} [options.around] - * A per-call wrapper (#51) for the host's own scope around each ACCEPTED call: a transaction, a * deadline, an audit record. It runs after every check the vine makes — the served-surface check, * the principal gate (inside the principal's context scope), and the args data-only check — so it @@ -63,7 +87,8 @@ import { assertPrincipalSupport, bindPrincipal, gate } from "./lib/principal.mjs * and may be called more than once (a transaction retried on a write conflict), each time with a * fresh copy of the original arguments. `args` is a private copy for inspection — changing it * changes nothing `invoke()` passes. `principal` is the principal resolved for THIS frame, or `null` - * on a serve without one. Whatever `around` returns is the call's result (the data-only return + * on a serve without one; `context` is the requested context the `context` check accepted for this + * call (a private copy), or `null` when the frame carried none. Whatever `around` returns is the call's result (the data-only return * check applies to it, exactly as to a leaf's own value); whatever it throws is the call's error. * Identity is not established here — that is `principal`'s job; `around` decorates a call already * authorized. With `around` set, the call's arguments must be structured-cloneable (they already @@ -76,7 +101,8 @@ import { assertPrincipalSupport, bindPrincipal, gate } from "./lib/principal.mjs * normalized `{ path, context? }` this serve bound (the resolver itself for the function form), or * `null` when none was given. * @throws {TypeError} When `api` is not a slothlet instance or `channel` is not a Channel, when - * `around` is given and is not a function — or, with a `principal`, when it is malformed or the + * `around` or `context` is given and is not a function, when `context` is given and the instance's + * `context.scope` does not work — or, with a `principal`, when it is malformed or the * instance cannot enforce it (see `options.principal`). * * @example @@ -103,6 +129,13 @@ export async function serve(api, channel, options = {}) { if (around !== null && typeof around !== "function") { throw new TypeError("@cldmv/slothlet-vine: serve() around must be a function when given"); } + // The requested-context check (#79). Checked before the surface is read, like the principal: a + // serve that cannot open a scope for an accepted context must not advertise that it accepts one. + const contextCheck = options.context ?? null; + if (contextCheck !== null && typeof contextCheck !== "function") { + throw new TypeError("@cldmv/slothlet-vine: serve() context must be a function when given"); + } + if (contextCheck !== null && !principal) await assertScopeSupport(api, "serve", "a context check"); const { leaves, excluded } = await collectLeaves(api, options); const served = new Set(leaves); @@ -151,7 +184,11 @@ export async function serve(api, channel, options = {}) { // With a principal: resolve it, open its context scope, ask the rules engine, and only then // run the ordinary pipeline — all inside the scope. Without one: the v1 trusted-transport // pipeline, unchanged (no gate, no scope, host standing). - const value = principal ? await answerAs(callId, path, args) : await execute(callId, path, args, null); + const value = principal + ? await answerAs(callId, path, args, frame.context) + : frame.context !== undefined + ? await answerScoped(callId, path, args, frame.context) + : await execute(callId, path, args, null, null); // Data-only cuts BOTH ways, and this is the half a grow side cannot enforce. Over a cloning // transport a returned function fails as an opaque DataCloneError; over a by-reference one // (loopback, same realm) it sails straight through and hands the caller a live closure over @@ -190,9 +227,11 @@ export async function serve(api, channel, options = {}) { * @param {unknown[]} args - The call's arguments (the parsed frame's fresh copy). * @param {Readonly<{ path: string, context?: object }>|null} who - The principal resolved for this * frame, or `null` on a serve without one (handed to `around`). + * @param {object|null} accepted - The requested context the check accepted (#79), or `null` (handed + * to `around` as a private copy). * @returns {Promise} The leaf's (or `around`'s) resolved value. */ - async function execute(callId, path, args, who) { + async function execute(callId, path, args, who, accepted) { // The grow-side stub already refuses a function-bearing argument before it ever sends a // frame (see grow.mjs), but that enforcement only covers frames built by a legitimate // vineStub call. Nothing stops a frame constructed directly against the channel — possible @@ -218,6 +257,7 @@ export async function serve(api, channel, options = {}) { path, args: copyArgs(path, snapshot), principal: who, + context: accepted === null ? null : structuredClone(accepted), invoke: () => invoke(api, path, copyArgs(path, snapshot)) }); } @@ -237,16 +277,57 @@ export async function serve(api, channel, options = {}) { * `Reflect.apply(leaf, …)` still enters the leaf without a slothlet gate of its own — which is * exactly why this gate sits in front of it — and once inside, the leaf's own `self.*` calls are * gated as the leaf's module, as for any host-initiated call. + * + * A requested context (#79) is judged between the two: {@link accept} runs once the principal is + * resolved and before the scope opens, and the keys it accepts are merged into the scope UNDER the + * principal's — a principal key always wins — and write-protected with them. * @param {string} callId - The call's correlation id. * @param {string} path - The served leaf path. * @param {unknown[]} args - The call's arguments. + * @param {unknown} requested - The frame's requested context, or `undefined` when it carried none. * @returns {Promise} The leaf's resolved value. - * @throws {VineError} `VINE_DENIED` when the principal may not call `path` (or could not be judged). + * @throws {VineError} `VINE_DENIED` when the principal may not call `path` (or could not be judged), + * or its requested context was refused; `VINE_DATA_ONLY` for a requested context that is not data. */ - async function answerAs(callId, path, args) { + async function answerAs(callId, path, args, requested) { const who = principal.resolve(); if (!who) throw denied(path, null); - const context = who.context ?? {}; + const accepted = requested === undefined ? null : await accept(requested, who, path, args); + const context = accepted === null ? (who.context ?? {}) : { ...accepted, ...(who.context ?? {}) }; + return await scoped(context, path, who, async () => { + if (!(await gate(api, who, path, args))) throw denied(path, who); + return await execute(callId, path, args, who, accepted); + }); + } + + /** + * Answer one call that carries a requested context on a serve WITHOUT a principal (#79): judge the + * request through {@link accept}, then run the ordinary pipeline inside a scope holding the + * accepted keys, write-protected. There is no gate to run — the transport is trusted — but the + * request is still the host's to accept. + * @param {string} callId - The call's correlation id. + * @param {string} path - The served leaf path. + * @param {unknown[]} args - The call's arguments. + * @param {unknown} requested - The frame's requested context. + * @returns {Promise} The leaf's resolved value. + * @throws {VineError} `VINE_DENIED` / `VINE_DATA_ONLY` as for {@link accept}. + */ + async function answerScoped(callId, path, args, requested) { + const accepted = await accept(requested, null, path, args); + return await scoped(accepted, path, null, () => execute(callId, path, args, null, accepted)); + } + + /** + * Run `body` inside a context scope carrying `context`, every top-level key write-protected. Fail + * closed: a scope that throws or never runs `body` is `VINE_DENIED` — nothing runs outside the + * scope the call was accepted into. `body`'s own outcome (value or error) is passed through. + * @param {object} context - The scope's context (the principal's, the accepted request's, or both). + * @param {string} path - The served leaf path, for the denial. + * @param {{ path: string }|null} who - The principal, for the denial's serve-side detail. + * @param {() => Promise} body - The gated pipeline. + * @returns {Promise} `body`'s value. + */ + async function scoped(context, path, who, body) { /** @type {{ value: unknown }|{ err: unknown }|null} Set ONLY by `fn` — null means the scope never ran it. */ let outcome = null; try { @@ -255,8 +336,7 @@ export async function serve(api, channel, options = {}) { protect: Object.keys(context), fn: async () => { try { - if (!(await gate(api, who, path, args))) throw denied(path, who); - outcome = { value: await execute(callId, path, args, who) }; + outcome = { value: await body() }; } catch (err) { outcome = { err }; } @@ -271,6 +351,44 @@ export async function serve(api, channel, options = {}) { return outcome.value; } + /** + * Judge one requested context (#79). In order: it must be data (`VINE_DATA_ONLY` — the same rules + * as a call's arguments, plus a plain-object shape); a serve without a `context` check refuses it + * (`VINE_DENIED` — a client must never believe a call is scoped when it is not); the host's check + * gets a private copy and must answer a plain, data-only object, anything else — `false`, `null`, a + * throw, a rejection, junk — being a refusal (`VINE_DENIED`). The accepted answer is copied, so the + * host's own object is never shared with the scope. + * @param {unknown} requested - The frame's requested context (untrusted). + * @param {Readonly<{ path: string, context?: object }>|null} who - The resolved principal, or `null`. + * @param {string} path - The served leaf path. + * @param {unknown[]} args - The call's arguments (as a rule condition would receive them). + * @returns {Promise} The accepted context. + * @throws {VineError} `VINE_DATA_ONLY` or `VINE_DENIED`. + */ + async function accept(requested, who, path, args) { + const fault = findContextFault(requested); + if (fault !== null) { + throw new VineError( + CODES.DATA_ONLY, + `slothlet-vine: '${path}' was called with a requested context that is not data (at ${fault}) — the vine is data-only`, + { path, location: fault } + ); + } + const refused = new VineError(CODES.DENIED, `slothlet-vine: the requested context for '${path}' is not accepted on this channel`, { + path, + principal: who ? who.path : null + }); + if (contextCheck === null) throw refused; + let answer; + try { + answer = await contextCheck(structuredClone(requested), { principal: who, path, args: [...args] }); + } catch { + throw refused; + } + if (!isPlainObject(answer) || findContextFault(answer) !== null) throw refused; + return structuredClone(answer); + } + /** * Build the `VINE_DENIED` error for one refused call. The message names the PATH only; the * principal is attached as serve-side detail and never crosses (`toWire` sends `name` / `message` / @@ -316,7 +434,7 @@ export async function serve(api, channel, options = {}) { // Publish the surface immediately. v1 sends it exactly once: a grow mounts from one manifest and // re-publication is not a re-mount. Sent AFTER the receive handler is registered so a far side // that answers instantly cannot race an unregistered channel. - send(surfaceFrame(leaves)); + send(surfaceFrame(leaves, { context: contextCheck !== null })); return { leaves, From e618068951f97c9bdcc2171998d90bdd0905035d Mon Sep 17 00:00:00 2001 From: Shinrai Date: Wed, 7 Oct 2026 00:01:04 -0700 Subject: [PATCH 4/9] feat(grow): link.with(context, fn) and grow({ context }) send a per-call requested context, refused locally when the far side does not accept one (#79) --- src/grow.mjs | 141 +++++++++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 137 insertions(+), 4 deletions(-) diff --git a/src/grow.mjs b/src/grow.mjs index a116a68..db253ae 100644 --- a/src/grow.mjs +++ b/src/grow.mjs @@ -14,7 +14,7 @@ */ import { CODES, VineError, fromWire } from "./lib/errors.mjs"; -import { callFrame, findFunctionArg, isSafePath, parseFrame } from "./lib/frame.mjs"; +import { callFrame, findContextFault, findFunctionArg, isSafePath, parseFrame } from "./lib/frame.mjs"; import { createEventForwarder } from "./lib/events.mjs"; import { PendingTable, assertApi, assertChannel, makeNonce, onCloseSafe } from "./lib/link.mjs"; import { assertPrincipalSupport, bindPrincipal } from "./lib/principal.mjs"; @@ -56,14 +56,27 @@ export const DEFAULT_BUDGET_MS = 30_000; * must exist (plus a working `context.run` when a static principal carries a `context`); * `permissions.global.checkCall` is NOT required, because a grow answers no calls. Omit it for a * trusted transport: nothing changes. - * @returns {Promise<{ id: string, leaves: string[], skipped: string[], collisions: string[], close: () => Promise, closed: Promise<{reason: string, info?: object}> }>} + * @param {object|(() => object|null|undefined)} [options.context] - The link's DEFAULT per-call + * **requested context** (#79): attached to every call made over this link outside a + * {@link link.with} extent. A plain, data-only object (validated here, copied once), or a function + * called synchronously per call that answers one (or `null` / `undefined` for none) — the way a + * client sends whatever it currently has pinned without the far side holding any state. A request, + * never identity: the far side's `serve({ context })` check accepts, narrows or refuses it. A call + * carrying one is refused locally (`VINE_DENIED`, nothing sent) when the far side did not advertise + * that it accepts requested contexts; a resolver that throws rejects that call with its error, and + * an answer that is not data rejects it `VINE_DATA_ONLY`. + * @returns {Promise<{ id: string, leaves: string[], skipped: string[], collisions: string[], context: boolean, with: Function, close: () => Promise, closed: Promise<{reason: string, info?: object}> }>} * The live link. The three path lists are DISJOINT and together account for every leaf the far * side published: `leaves` are the paths actually mounted and forwarding; `skipped` are far leaves * refused locally (unsafe path, outside `paths`, rejected by `add()`, or published after the link * had already ended); `collisions` are paths the local instance already occupied, which are NOT * mounted — the incumbent keeps answering there and the far leaf is unreachable through this link. - * @throws {TypeError} When `api` is not a slothlet instance or `channel` is not a Channel — or, with a + * `context` is whether the far side accepts a requested context (its `surface` advertised it), and + * `with(context, fn, ...args)` runs `fn` with a requested context on every call it makes over THIS link. + * @throws {TypeError} When `api` is not a slothlet instance or `channel` is not a Channel, when + * `context` is not a function or a plain object — or, with a * `principal`, when it is malformed or the instance cannot enforce it (see `options.principal`). + * @throws {VineError} `VINE_DATA_ONLY` when a static `context` is not data. * @throws {VineError} `VINE_GONE` when the channel closes before the surface arrives, `VINE_BUDGET` * when the handshake budget elapses first. * @@ -90,6 +103,7 @@ export async function grow(api, channel, options = {}) { const budgetMs = Number.isFinite(options.budgetMs) && options.budgetMs > 0 ? Number(options.budgetMs) : DEFAULT_BUDGET_MS; const handshakeMs = handshakeBudget(options.handshakeMs, budgetMs); const prefixes = Array.isArray(options.paths) ? options.paths.filter((p) => typeof p === "string" && p.length > 0) : null; + const defaultContext = bindDefaultContext(options.context); const nonce = makeNonce(); // The separator is a HYPHEN, and that is load-bearing. Probed on @cldmv/slothlet 3.14.0: a @@ -100,6 +114,15 @@ export async function grow(api, channel, options = {}) { // zero-cost, works on both patched and unpatched slothlet, and nothing benefits from a colon. const moduleID = `vine-${nonce}`; const pending = new PendingTable(nonce); + // `link.with()` (#79) carries its requested context on the grow instance's own context scope, under + // a key private to this link — so a `with` on one link never scopes calls over another. The value is + // a HOLDER function, not the data: slothlet shares callables by reference across scopes (#408) and + // deep-clones everything else, and a protected key's plain value comes back as a read-only view. The + // holder answers a fresh copy of the snapshot per call, and only holders this link minted (the + // WeakSet) are honoured, so a grow-side module that writes its own function under the key is ignored. + const contextKey = `${moduleID}:context`; + /** @type {WeakSet} */ + const holders = new WeakSet(); /** @type {{ closed: boolean, gone: boolean }} The link's terminal state; both are one-way. */ const state = { closed: false, gone: false }; @@ -249,6 +272,9 @@ export async function grow(api, channel, options = {}) { throw err; } + /** Whether the far side advertised that it accepts a requested context (#79). @type {boolean} */ + const acceptsContext = surface.context === true; + /** @type {string[]} */ const mounted = []; /** @type {string[]} */ @@ -288,6 +314,26 @@ export async function grow(api, channel, options = {}) { } } + /** + * The requested context for one call: the innermost {@link link.with} extent's (read from the + * grow instance's live context — a stub runs inside its caller's extent, slothlet's wrapper sees + * to that), else the link default. `null` means none. + * @param {string} path - The leaf being called, for errors. + * @returns {object|null} A fresh, validated copy, or `null`. + * @throws {VineError|unknown} `VINE_DATA_ONLY` for a default resolver's non-data answer; the + * resolver's own error when it throws. + */ + function requestedContext(path) { + let holder; + try { + holder = api.slothlet?.context?.get?.()?.[contextKey]; + } catch { + holder = undefined; + } + if (typeof holder === "function" && holders.has(holder)) return holder(); + return defaultContext(path); + } + /** * Build the forwarding stub for one leaf. Reached ONLY when slothlet has already allowed the call * — permission denial happens in the wrapper, before this body runs. @@ -309,10 +355,21 @@ export async function grow(api, channel, options = {}) { location: functionAt }); } + // The requested context (#79): the innermost `link.with()` extent's, else the link default. + // Refused LOCALLY when the far side never said it accepts one — an older serve would drop the + // key and run the call unscoped, and the caller would never know. + const requested = requestedContext(path); + if (requested !== null && !acceptsContext) { + throw new VineError( + CODES.DENIED, + `slothlet-vine: '${path}' was called with a requested context, but the far side does not accept one`, + { path } + ); + } const callId = pending.nextCallId(); const settled = pending.open(callId, { path, budgetMs }); try { - channel.send(callFrame(callId, path, args)); + channel.send(callFrame(callId, path, args, requested)); } catch (err) { // The frame could not be handed to the transport — an un-cloneable argument the data-only // scan cannot see (a getter that returns a function, a Proxy hiding its keys), or a dead @@ -344,6 +401,39 @@ export async function grow(api, channel, options = {}) { leaves: mounted, skipped, collisions, + /** + * Whether the far side accepts a per-call requested context (#79) — its `surface` frame said so + * (a `serve()` with a `context` check). When `false`, a call that carries one is refused locally. + * @type {boolean} + */ + context: acceptsContext, + /** + * Run `fn` with a per-call **requested context** (#79): every call `fn` makes over THIS link — + * directly or through any module, across every `await` — carries `context` on its `call` frame, + * for the far side's `serve({ context })` check to accept, narrow or refuse. It is a request, + * not identity, and it lives on the call, not the channel: the far side holds no state, two + * extents over one link can carry different contexts at once, and a later `with()` never races + * an in-flight call. The innermost extent wins (no merge); `null` clears it for a nested extent + * (the link default does not apply there either). Calls over other links are untouched. + * + * `context` is checked by the same data-only rules as arguments — a plain object with no + * function anywhere that survives a structured clone — and copied once, so mutating it later + * changes nothing. Runs `fn` inside `api.slothlet.context.scope()` on the grow instance, so that + * instance must not be `scope: false`. + * @param {object|null} context - The requested context, or `null` for none. + * @param {(...args: unknown[]) => unknown} fn - The extent. + * @param {...unknown} args - Forwarded to `fn`. + * @returns {Promise} `fn`'s value. + * @throws {TypeError} When `fn` is not a function. + * @throws {VineError} `VINE_DATA_ONLY` when `context` is not data. + */ + async with(context, fn, ...args) { + if (typeof fn !== "function") throw new TypeError("@cldmv/slothlet-vine: link.with() needs a function to run"); + const snapshot = context === null ? null : validContext(context, "link.with()"); + const holder = () => (snapshot === null ? null : structuredClone(snapshot)); + holders.add(holder); + return await api.slothlet.context.scope({ context: { [contextKey]: holder }, protect: [contextKey], fn, args }); + }, closed: closedPromise, /** * Tear the link down locally: unmount every stub and settle every in-flight call with @@ -425,6 +515,49 @@ async function ownedPaths(api, moduleID, paths) { } } +/** + * Validate a requested context (#79) against the data-only rules and copy it. + * @param {unknown} value - The candidate. + * @param {string} where - Who supplied it, for the error (`link.with()`, `grow() context`, a path). + * @returns {object} A structured clone of `value`. + * @throws {VineError} `VINE_DATA_ONLY` naming the first fault. + */ +function validContext(value, where) { + const fault = findContextFault(value); + if (fault !== null) { + throw new VineError( + CODES.DATA_ONLY, + `slothlet-vine: ${where} was given a requested context that is not data (at ${fault}) — the vine is data-only`, + { location: fault } + ); + } + return structuredClone(value); +} + +/** + * Bind `grow()`'s `context` option (#79) — the link's default requested context — into a per-call + * reader. A static object is validated and copied once (each call gets its own copy); a function is + * called synchronously per call and its answer validated then. + * @param {unknown} option - The raw option (`undefined` / `null` → no default). + * @returns {(path: string) => object|null} Answers the default for one call, or `null`. + * @throws {TypeError} When the option is neither a function nor an object. + * @throws {VineError} `VINE_DATA_ONLY` when a static object is not data. + */ +function bindDefaultContext(option) { + if (option === undefined || option === null) return () => null; + if (typeof option === "function") { + return (path) => { + const answer = option(); + return answer === undefined || answer === null ? null : validContext(answer, `the context resolver for '${path}'`); + }; + } + if (typeof option !== "object") { + throw new TypeError("@cldmv/slothlet-vine: grow() context must be a plain object or a function when given"); + } + const snapshot = validContext(option, "grow()"); + return () => structuredClone(snapshot); +} + /** * Normalize the handshake deadline. Mirrors `budgetMs`'s reading — anything that is not a usable * positive number falls back to the default rather than silently meaning "no deadline", because a From 93a52b3cad7115510df02865cfbaccbe735fcbfd Mon Sep 17 00:00:00 2001 From: Shinrai Date: Wed, 7 Oct 2026 00:01:04 -0700 Subject: [PATCH 5/9] test: requested context accept, narrow, refuse, key protection, no check configured, data-only violations, old/new interop, loopback and websocket (#79) --- tests/e2e-loopback-context.test.vitest.mjs | 617 ++++++++++++++++++++ tests/fixtures/context-api/work.mjs | 63 ++ tests/fixtures/context-grow-api/relay.mjs | 26 + tests/frame.test.vitest.mjs | 63 ++ tests/grow-serve-units.test.vitest.mjs | 19 +- tests/package-surface.test.vitest.mjs | 8 + tests/serve-principal-units.test.vitest.mjs | 60 ++ 7 files changed, 855 insertions(+), 1 deletion(-) create mode 100644 tests/e2e-loopback-context.test.vitest.mjs create mode 100644 tests/fixtures/context-api/work.mjs create mode 100644 tests/fixtures/context-grow-api/relay.mjs diff --git a/tests/e2e-loopback-context.test.vitest.mjs b/tests/e2e-loopback-context.test.vitest.mjs new file mode 100644 index 0000000..e284262 --- /dev/null +++ b/tests/e2e-loopback-context.test.vitest.mjs @@ -0,0 +1,617 @@ +/** + * + * @Project: @cldmv/slothlet-vine + * @Filename: /tests/e2e-loopback-context.test.vitest.mjs + * @Date: 2026-10-06T12:00:00-07:00 (1791313200) + * @Author: Nate Corcoran + * @Email: + * ----- + * @Last modified by: Nate Corcoran (Shinrai@users.noreply.github.com) + * @Last modified time: 2026-10-06T12:00:00-07:00 (1791313200) + * ----- + * @Copyright: Copyright (c) 2013-2026 Catalyzed Motivation Inc. All rights reserved. + * + */ + +// Per-call requested context (#79): `link.with()` / `grow({ context })` on the grow side, the +// `serve({ context })` host check on the serve side, the protected merge into the call's context +// scope, and old/new interop — against REAL slothlet instances, over loopback and websocket. + +import { describe, it, expect, afterEach, afterAll } from "vitest"; +import { once } from "node:events"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { WebSocketServer, WebSocket } from "ws"; +import slothlet from "@cldmv/slothlet"; + +import { grow, serve } from "../src/index.mjs"; +import { CODES, VineError, VineRemoteError } from "../src/lib/errors.mjs"; +import { createPair } from "../src/transport/loopback.mjs"; +import { createChannel as createWsChannel } from "../src/transport/websocket.mjs"; + +const here = path.dirname(fileURLToPath(import.meta.url)); +const CONTEXT_DIR = path.join(here, "fixtures", "context-api"); +const CONTEXT_GROW_DIR = path.join(here, "fixtures", "context-grow-api"); + +/** Teardown callbacks, run in reverse after each test. @type {Array<() => Promise|void>} */ +let teardown = []; +/** Every ws server stood up, closed in afterAll as a backstop. @type {Set} */ +const servers = new Set(); + +afterEach(async () => { + for (const fn of teardown.reverse()) { + try { + await fn(); + } catch { + // Teardown must never mask the assertion that already failed. + } + } + teardown = []; +}); + +afterAll(async () => { + for (const wss of servers) await new Promise((resolve) => wss.close(() => resolve())); + servers.clear(); +}); + +/** + * Create a real instance and register its shutdown. + * @param {object} options - `slothlet()` options. + * @returns {Promise} The instance. + */ +async function instance(options) { + const api = await slothlet({ silent: true, ...options }); + teardown.push(async () => api.slothlet?.shutdown?.()); + return api; +} + +/** + * A served context-api instance and a grown one over loopback. + * @param {object} [serveOptions] - `serve()` options. + * @param {object} [growOptions] - `grow()` options. + * @param {object} [serveInstance] - Extra `slothlet()` options for the serving instance. + * @returns {Promise<{ serveApi: object, growApi: object, serving: object, link: object }>} The pair. + */ +async function linked(serveOptions = {}, growOptions = {}, serveInstance = {}) { + const serveApi = await instance({ base: CONTEXT_DIR, ...serveInstance }); + const growApi = await instance({ base: CONTEXT_GROW_DIR }); + const [near, far] = createPair(); + const serving = await serve(serveApi, far, serveOptions); + const link = await grow(growApi, near, { budgetMs: 2000, ...growOptions }); + teardown.push(async () => { + await link.close(); + serving.close(); + }); + return { serveApi, growApi, serving, link }; +} + +/** + * A RAW peer on one channel end: records every frame and sends hand-built ones — an old (pre-#79) + * implementation, or a page script with a socket. + * @param {object} channel - The channel end to drive. + * @returns {object} The peer controls. + */ +function rawPeer(channel) { + const frames = []; + channel.onMessage((frame) => frames.push(frame)); + /** + * @param {(frame: object) => boolean} predicate - Frame to wait for. + * @returns {Promise} The first matching frame. + */ + async function waitFor(predicate) { + const deadline = Date.now() + 3000; + for (;;) { + const hit = frames.find(predicate); + if (hit) return hit; + if (Date.now() > deadline) throw new Error("timed out waiting for a frame"); + await new Promise((resolve) => setTimeout(resolve, 5)); + } + } + let seq = 0; + return { + frames, + waitFor, + surface: () => waitFor((frame) => frame.type === "surface"), + /** + * @param {string} leaf - Leaf path. + * @param {unknown[]} [args] - Arguments. + * @param {object} [extra] - Extra frame keys (e.g. `{ context }`). + * @returns {Promise} The terminal frame. + */ + call(leaf, args = [], extra = {}) { + const callId = `raw#${++seq}`; + channel.send({ type: "call", callId, path: leaf, args, ...extra }); + return waitFor((frame) => (frame.type === "result" || frame.type === "error") && frame.callId === callId); + }, + send: (frame) => channel.send(frame) + }; +} + +/** + * The remote code a far-side refusal carries, asserting it crossed as a `VineRemoteError`. + * @param {Promise} promise - The call. + * @returns {Promise} `.remoteCode`. + */ +async function remoteCode(promise) { + const err = await promise.then( + () => null, + (e) => e + ); + expect(err).toBeInstanceOf(VineRemoteError); + expect(err.code).toBe(CODES.REMOTE); + return err.remoteCode; +} + +const acceptAll = (requested) => requested; + +describe("requested context — accept, narrow, refuse (loopback, no principal)", () => { + it("accept: the leaf sees the requested keys; the surface advertised it; link.context is true", async () => { + const { growApi, link } = await linked({ context: acceptAll }); + expect(link.context).toBe(true); + expect(await link.with({ project: "A" }, () => growApi.work.read())).toEqual({ project: "A", actor: null, extra: null }); + // Outside the extent, nothing is attached. + expect(await growApi.work.read()).toEqual({ project: null, actor: null, extra: null }); + }); + + it("the extent crosses a grow-side module boundary and an async hop; `with` forwards args and returns fn's value", async () => { + const { growApi, link } = await linked({ context: acceptAll }); + expect(await link.with({ project: "B" }, () => growApi.relay.read())).toMatchObject({ project: "B" }); + expect(await link.with({ project: "C" }, (a, b) => a + b, 2, 3)).toBe(5); + }); + + it("narrow: the check answers a subset — the dropped key never reaches the scope", async () => { + const seen = []; + const { growApi, link } = await linked({ + context: (requested, call) => { + seen.push({ requested, call }); + return { project: requested.project }; + } + }); + const got = await link.with({ project: "A", extra: { depth: 1 } }, () => growApi.work.read()); + expect(got).toEqual({ project: "A", actor: null, extra: null }); + expect(seen).toEqual([{ requested: { project: "A", extra: { depth: 1 } }, call: { principal: null, path: "work.read", args: [] } }]); + }); + + it("narrow: the check may also CHANGE a value (e.g. canonicalize it)", async () => { + const { growApi, link } = await linked({ context: (requested) => ({ project: String(requested.project).toUpperCase() }) }); + expect(await link.with({ project: "a" }, () => growApi.work.read())).toMatchObject({ project: "A" }); + }); + + it("refuse: false, null, a throw, a rejection, or junk are all VINE_DENIED — and the leaf never runs", async () => { + const answers = [ + () => false, + () => null, + () => undefined, + () => true, + () => { + throw new Error("no such project"); + }, + async () => { + throw new Error("async no"); + }, + () => ["project"], + () => ({ project: "A", cb: () => {} }), + () => new (class Ctx {})() + ]; + for (const answer of answers) { + const { serveApi, growApi, link } = await linked({ context: answer }); + expect(await remoteCode(link.with({ project: "A" }, () => growApi.work.read()))).toBe(CODES.DENIED); + expect(await serveApi.work.count()).toBe(0); + } + }); + + it("the refusal names the path only; the host's own error never crosses", async () => { + const { growApi, link } = await linked({ + context: () => { + throw new Error("secret detail"); + } + }); + const err = await link.with({ project: "A" }, () => growApi.work.read()).catch((e) => e); + expect(err.message).toMatch(/requested context for 'work\.read' is not accepted/); + expect(err.message).not.toMatch(/secret detail/); + }); + + it("an async check is awaited", async () => { + const { growApi, link } = await linked({ + context: async (requested) => { + await new Promise((resolve) => setTimeout(resolve, 5)); + return requested; + } + }); + expect(await link.with({ project: "A" }, () => growApi.work.read())).toMatchObject({ project: "A" }); + }); + + it("the check sees a private copy — mutating it changes nothing that was asked", async () => { + const { growApi, link } = await linked({ + context: (requested) => { + const answer = { project: requested.project }; + requested.project = "mutated"; + return answer; + } + }); + expect(await link.with({ project: "A" }, () => growApi.work.read())).toMatchObject({ project: "A" }); + }); +}); + +describe("requested context — protection of the accepted keys", () => { + it("a leaf cannot reassign an accepted key (CONTEXT_KEY_PROTECTED)", async () => { + const { growApi, link } = await linked({ context: acceptAll }); + const err = await link.with({ project: "A" }, () => growApi.work.tamper()).catch((e) => e); + expect(err).toBeInstanceOf(VineRemoteError); + expect(err.remoteCode ?? err.code).toMatch(/CONTEXT_KEY_PROTECTED/); + }); + + it("nor a nested field of one", async () => { + const { growApi, link } = await linked({ context: acceptAll }); + const err = await link.with({ extra: { depth: 1 } }, () => growApi.work.deep()).catch((e) => e); + expect(err.remoteCode ?? err.code).toMatch(/CONTEXT_KEY_PROTECTED/); + }); + + it("the accepted context lives on the call: concurrent extents over ONE link carry their own", async () => { + const { growApi, link } = await linked({ context: acceptAll }); + const [a, b] = await Promise.all([ + link.with({ project: "A" }, () => growApi.work.slow(30)), + link.with({ project: "B" }, () => growApi.work.slow(5)) + ]); + expect([a, b]).toEqual(["A", "B"]); + }); +}); + +describe("requested context — merged with the channel principal", () => { + const PERMISSIONS = { + defaultPolicy: "deny", + rules: [ + { caller: "remote.renderer", target: "work.count", effect: "allow" }, + { caller: "remote.renderer", target: "work.tamper", effect: "allow" }, + // The rule condition sees the accepted requested context. + { caller: "remote.renderer", target: "work.read", effect: "allow", condition: (ctx) => ctx?.project === "A" } + ] + }; + const RENDERER = { path: "remote.renderer", context: { actor: { id: "u42" } } }; + + it("the check runs after the principal is resolved and sees it; checkCall's condition sees the accepted key", async () => { + const seen = []; + const { serveApi, growApi, link } = await linked( + { + principal: RENDERER, + context: (requested, { principal }) => { + seen.push(principal.path); + return requested; + } + }, + {}, + { permissions: PERMISSIONS } + ); + expect(await link.with({ project: "A" }, () => growApi.work.read())).toEqual({ project: "A", actor: { id: "u42" }, extra: null }); + expect(await remoteCode(link.with({ project: "B" }, () => growApi.work.read()))).toBe(CODES.DENIED); + expect(seen).toEqual(["remote.renderer", "remote.renderer"]); + expect(await serveApi.work.count()).toBe(1); + }); + + it("a principal key always wins over a requested one of the same name", async () => { + const { growApi, link } = await linked({ principal: RENDERER, context: acceptAll }, {}, { permissions: PERMISSIONS }); + const got = await link.with({ project: "A", actor: { id: "mallory" } }, () => growApi.work.read()); + expect(got.actor).toEqual({ id: "u42" }); + }); + + it("a refused context is VINE_DENIED before the gate — the rules are never asked", async () => { + // An explicit deny audits `permission:denied` whenever the gate is asked — so its absence proves + // the gate never was. The control serve (accepting check) shows the audit does fire. + const permissions = { defaultPolicy: "deny", rules: [{ caller: "remote.renderer", target: "work.read", effect: "deny" }] }; + const audited = async (check) => { + const { serveApi, growApi, link } = await linked({ principal: RENDERER, context: check }, {}, { permissions }); + const audits = []; + serveApi.slothlet.lifecycle.on("permission:denied", (payload) => audits.push(payload)); + expect(await remoteCode(link.with({ project: "A" }, () => growApi.work.read()))).toBe(CODES.DENIED); + await new Promise((resolve) => setTimeout(resolve, 20)); + return audits.filter((d) => d.caller === "remote.renderer" && d.target === "work.read"); + }; + expect(await audited(acceptAll)).not.toEqual([]); + expect(await audited(() => false)).toEqual([]); + }); + + it("accepted keys are protected alongside the principal's", async () => { + const { growApi, link } = await linked({ principal: RENDERER, context: acceptAll }, {}, { permissions: PERMISSIONS }); + const err = await link.with({ project: "A" }, () => growApi.work.tamper()).catch((e) => e); + expect(err.remoteCode ?? err.code).toMatch(/CONTEXT_KEY_PROTECTED/); + }); + + it("a call without a requested context is unchanged — the check is not consulted", async () => { + let asked = 0; + const { growApi } = await linked( + { + principal: RENDERER, + context: (requested) => { + asked++; + return requested; + } + }, + {}, + { permissions: PERMISSIONS } + ); + expect(await growApi.work.count()).toBe(0); + expect(asked).toBe(0); + }); + + it("around receives the accepted context (a copy), runs inside the scope, and sees null without one", async () => { + const seen = []; + const { growApi, link } = await linked( + { + principal: RENDERER, + context: (requested) => ({ project: requested.project }), + around: ({ context, invoke }) => { + seen.push(context); + return invoke(); + } + }, + {}, + { permissions: PERMISSIONS } + ); + await link.with({ project: "A", extra: 1 }, () => growApi.work.read()); + await growApi.work.count(); + expect(seen).toEqual([{ project: "A" }, null]); + }); +}); + +describe("requested context — no check configured", () => { + it("the surface does not advertise it; the grow refuses LOCALLY and sends nothing", async () => { + const serveApi = await instance({ base: CONTEXT_DIR }); + const growApi = await instance({ base: CONTEXT_GROW_DIR }); + const [near, far] = createPair(); + const serving = await serve(serveApi, far); + const sent = []; + const send = near.send.bind(near); + near.send = (frame) => { + sent.push(frame); + send(frame); + }; + const link = await grow(growApi, near, { budgetMs: 2000 }); + teardown.push(async () => { + await link.close(); + serving.close(); + }); + expect(link.context).toBe(false); + const err = await link.with({ project: "A" }, () => growApi.work.read()).catch((e) => e); + expect(err).toBeInstanceOf(VineError); + expect(err).not.toBeInstanceOf(VineRemoteError); + expect(err.code).toBe(CODES.DENIED); + expect(sent.filter((frame) => frame.type === "call")).toEqual([]); + // A call with no requested context works as ever. + expect(await growApi.work.read()).toMatchObject({ project: null }); + }); + + it("a hand-built frame carrying one is refused serve-side (VINE_DENIED) — never silently ignored", async () => { + const serveApi = await instance({ base: CONTEXT_DIR }); + const [near, far] = createPair(); + const peer = rawPeer(near); + const serving = await serve(serveApi, far); + teardown.push(() => serving.close()); + const surface = await peer.surface(); + expect(surface).not.toHaveProperty("context"); + expect((await peer.call("work.read", [], { context: { project: "A" } })).error.code).toBe(CODES.DENIED); + expect(await serveApi.work.count()).toBe(0); + expect((await peer.call("work.read")).value).toEqual({ project: null, actor: null, extra: null }); + }); +}); + +describe("requested context — data-only", () => { + it("grow side: link.with() refuses a non-plain or function-bearing context before anything runs", async () => { + const { growApi, link } = await linked({ context: acceptAll }); + let ran = 0; + const fn = () => { + ran++; + return growApi.work.read(); + }; + for (const bad of [{ cb() {} }, { nested: [1, () => 2] }, [1], "A", 7, new Date(), new (class Ctx {})(), { s: Symbol("x") }]) { + const err = await link.with(bad, fn).catch((e) => e); + expect(err).toBeInstanceOf(VineError); + expect(err.code).toBe(CODES.DATA_ONLY); + } + expect(ran).toBe(0); + await expect(link.with({ project: "A" }, "not a function")).rejects.toThrow(TypeError); + }); + + it("grow side: the error names where the function was", async () => { + const { link } = await linked({ context: acceptAll }); + const err = await link.with({ a: { onDone() {} } }, () => 1).catch((e) => e); + expect(err.location).toBe("context.a.onDone"); + }); + + it("serve side: a hand-built frame's context is held to the same rules — VINE_DATA_ONLY, the check never asked", async () => { + let asked = 0; + const serveApi = await instance({ base: CONTEXT_DIR }); + const [near, far] = createPair(); + const peer = rawPeer(near); + const serving = await serve(serveApi, far, { + context: (requested) => { + asked++; + return requested; + } + }); + teardown.push(() => serving.close()); + expect((await peer.surface()).context).toBe(true); + for (const bad of [{ cb: () => {} }, "A", [1], 7, true]) { + expect((await peer.call("work.read", [], { context: bad })).error.code).toBe(CODES.DATA_ONLY); + } + expect(asked).toBe(0); + // `null` is "no context", not a fault. + expect((await peer.call("work.read", [], { context: null })).value).toMatchObject({ project: null }); + expect(asked).toBe(0); + }); +}); + +describe("requested context — grow({ context }), the link default", () => { + it("a resolver is read per call — the server holds no pin state", async () => { + let pinned = "A"; + const { growApi } = await linked({ context: acceptAll }, { context: () => (pinned ? { project: pinned } : null) }); + expect(await growApi.work.read()).toMatchObject({ project: "A" }); + pinned = "B"; + expect(await growApi.work.read()).toMatchObject({ project: "B" }); + pinned = null; + expect(await growApi.work.read()).toMatchObject({ project: null }); + }); + + it("a static default is copied once; with() overrides it; with(null) clears it", async () => { + const pin = { project: "A" }; + const { growApi, link } = await linked({ context: acceptAll }, { context: pin }); + pin.project = "mutated"; + expect(await growApi.work.read()).toMatchObject({ project: "A" }); + expect(await link.with({ project: "B" }, () => growApi.work.read())).toMatchObject({ project: "B" }); + expect(await link.with(null, () => growApi.work.read())).toMatchObject({ project: null }); + // Nested: the innermost extent wins, no merge. + expect(await link.with({ project: "C", extra: 1 }, () => link.with({ project: "D" }, () => growApi.work.read()))).toEqual({ + project: "D", + actor: null, + extra: null + }); + }); + + it("a resolver that throws rejects that call with its own error; a non-data answer is VINE_DATA_ONLY", async () => { + let mode = "throw"; + const { growApi } = await linked( + { context: acceptAll }, + { + context: () => { + if (mode === "throw") throw new Error("no pin"); + return { cb: () => {} }; + } + } + ); + await expect(growApi.work.read()).rejects.toThrow("no pin"); + mode = "junk"; + await expect(growApi.work.read()).rejects.toMatchObject({ code: CODES.DATA_ONLY }); + }); + + it("a bad static default fails grow() itself", async () => { + const growApi = await instance({ base: CONTEXT_GROW_DIR }); + const [near] = createPair(); + await expect(grow(growApi, near, { context: "A" })).rejects.toThrow(TypeError); + await expect(grow(growApi, near, { context: { cb() {} } })).rejects.toMatchObject({ code: CODES.DATA_ONLY }); + }); + + it("only holders the link minted are honoured — a function planted under the link's key is ignored", async () => { + const { growApi, link } = await linked({ context: acceptAll }); + const forged = await growApi.slothlet.context.run({ [`${link.id}:context`]: () => ({ project: "X" }) }, () => growApi.work.read()); + expect(forged).toMatchObject({ project: null }); + }); + + it("with() on one link never scopes calls over another", async () => { + const serveApi = await instance({ base: CONTEXT_DIR }); + const otherApi = await instance({ base: path.join(here, "fixtures", "principal-api") }); + const growApi = await instance({ base: CONTEXT_GROW_DIR }); + const [a1, a2] = createPair(); + const [b1, b2] = createPair(); + const s1 = await serve(serveApi, a2, { context: acceptAll }); + const s2 = await serve(otherApi, b2, { paths: ["host"] }); // accepts no context + const l1 = await grow(growApi, a1, { budgetMs: 2000 }); + const l2 = await grow(growApi, b1, { budgetMs: 2000 }); + teardown.push(async () => { + await l1.close(); + await l2.close(); + s1.close(); + s2.close(); + }); + const got = await l1.with({ project: "A" }, async () => [await growApi.work.read(), await growApi.host.deps.list()]); + expect(got).toEqual([{ project: "A", actor: null, extra: null }, ["slothlet", "slothlet-vine"]]); + }); +}); + +describe("requested context — old/new interop (no FRAME_VERSION change)", () => { + it("an old grow (no context on its frames) talks to a new serve with a check — unchanged, the check not asked", async () => { + let asked = 0; + const serveApi = await instance({ base: CONTEXT_DIR }); + const [near, far] = createPair(); + const peer = rawPeer(near); + const serving = await serve(serveApi, far, { + context: (requested) => { + asked++; + return requested; + } + }); + teardown.push(() => serving.close()); + const surface = await peer.surface(); + expect(surface.v).toBe(1); // no version bump — an old grow still mounts from this frame + expect((await peer.call("work.read")).value).toEqual({ project: null, actor: null, extra: null }); + expect(asked).toBe(0); + }); + + it("a new grow facing an old serve (a surface without `context`) refuses a requested context locally; plain calls work", async () => { + const growApi = await instance({ base: CONTEXT_GROW_DIR }); + const [near, far] = createPair(); + const oldServe = rawPeer(far); + oldServe.send({ type: "surface", v: 1, leaves: ["work.read"] }); + const link = await grow(growApi, near, { budgetMs: 2000 }); + teardown.push(() => link.close()); + expect(link.context).toBe(false); + await expect(link.with({ project: "A" }, () => growApi.work.read())).rejects.toMatchObject({ code: CODES.DENIED }); + expect(oldServe.frames.filter((frame) => frame.type === "call")).toEqual([]); + // A plain call's frame carries no `context` key at all — byte for byte the v1 frame. + const pending = growApi.work.read(); + const call = await oldServe.waitFor((frame) => frame.type === "call"); + expect(Object.keys(call).sort()).toEqual(["args", "callId", "path", "type"]); + oldServe.send({ type: "result", callId: call.callId, value: "old" }); + expect(await pending).toBe("old"); + }); + + it("a new grow facing a new serve sends `context` on the call frame only inside an extent", async () => { + const growApi = await instance({ base: CONTEXT_GROW_DIR }); + const [near, far] = createPair(); + const newServe = rawPeer(far); + newServe.send({ type: "surface", v: 1, leaves: ["work.read"], context: true }); + const link = await grow(growApi, near, { budgetMs: 2000 }); + teardown.push(() => link.close()); + expect(link.context).toBe(true); + const pending = link.with({ project: "A" }, () => growApi.work.read()); + const call = await newServe.waitFor((frame) => frame.type === "call"); + expect(call.context).toEqual({ project: "A" }); + newServe.send({ type: "result", callId: call.callId, value: "ok" }); + expect(await pending).toBe("ok"); + }); +}); + +describe("serve({ context }) setup", () => { + it("throws a TypeError for a non-function check", async () => { + const serveApi = await instance({ base: CONTEXT_DIR }); + const [, far] = createPair(); + await expect(serve(serveApi, far, { context: { project: "A" } })).rejects.toThrow(/context must be a function/); + }); + + it("fails closed on a `scope: false` instance — a TypeError, and no surface is published", async () => { + const serveApi = await instance({ base: CONTEXT_DIR, scope: false }); + const [near, far] = createPair(); + const peer = rawPeer(near); + await expect(serve(serveApi, far, { context: acceptAll })).rejects.toThrow( + /context check needs a working api\.slothlet\.context\.scope\(\)/ + ); + await new Promise((resolve) => setTimeout(resolve, 20)); + expect(peer.frames).toEqual([]); + }); +}); + +describe("requested context over websocket", () => { + it("accepted, narrowed and refused per call over one live socket", async () => { + const serveApi = await instance({ base: CONTEXT_DIR }); + const growApi = await instance({ base: CONTEXT_GROW_DIR }); + const wss = new WebSocketServer({ host: "127.0.0.1", port: 0 }); + servers.add(wss); + teardown.push(() => new Promise((resolve) => wss.close(() => resolve()))); + await once(wss, "listening"); + const { port } = wss.address(); + wss.on("connection", async (socket) => { + const serving = await serve(serveApi, createWsChannel(socket), { + // Only projects this (fake) user can access are accepted; `extra` is always dropped. + context: (requested) => (requested.project === "A" ? { project: "A" } : false) + }); + teardown.push(() => serving.close()); + }); + const socket = new WebSocket(`ws://127.0.0.1:${port}/`); + const channel = createWsChannel(socket); + teardown.push(() => socket.terminate()); + const link = await grow(growApi, channel, { budgetMs: 2000 }); + teardown.push(() => link.close()); + expect(link.context).toBe(true); + expect(await link.with({ project: "A", extra: 1 }, () => growApi.work.read())).toEqual({ project: "A", actor: null, extra: null }); + expect(await remoteCode(link.with({ project: "B" }, () => growApi.work.read()))).toBe(CODES.DENIED); + expect(await growApi.work.read()).toMatchObject({ project: null }); + }); +}); diff --git a/tests/fixtures/context-api/work.mjs b/tests/fixtures/context-api/work.mjs new file mode 100644 index 0000000..3213cc1 --- /dev/null +++ b/tests/fixtures/context-api/work.mjs @@ -0,0 +1,63 @@ +/** + * + * @Project: @cldmv/slothlet-vine + * @Filename: /tests/fixtures/context-api/work.mjs + * @Date: 2026-10-06T12:00:00-07:00 (1791313200) + * @Author: Nate Corcoran + * @Email: + * ----- + * @Last modified by: Nate Corcoran (Shinrai@users.noreply.github.com) + * @Last modified time: 2026-10-06T12:00:00-07:00 (1791313200) + * ----- + * @Copyright: Copyright (c) 2013-2026 Catalyzed Motivation Inc. All rights reserved. + * + */ + +import { context } from "@cldmv/slothlet/runtime"; + +let runs = 0; + +/** + * What the per-call context scope holds for the keys the requested-context tests use (#79). + * @returns {{ project: unknown, actor: unknown, extra: unknown }} The scope's view. + */ +export function read() { + runs++; + return { project: context.project ?? null, actor: context.actor ?? null, extra: context.extra ?? null }; +} + +/** + * Wait, then read — so two concurrent calls overlap on the serving side. + * @param {number} ms - Delay. + * @returns {Promise} The scope's `project` after the delay. + */ +export async function slow(ms) { + await new Promise((resolve) => setTimeout(resolve, ms)); + return context.project ?? null; +} + +/** + * Try to rewrite an accepted requested-context key, then a nested field of it. + * @returns {string} Never returns normally when the key is protected. + */ +export function tamper() { + context.project = "hijacked"; + return "tampered"; +} + +/** + * Try to rewrite a NESTED field of an accepted requested-context key. + * @returns {string} Never returns normally when the key is protected. + */ +export function deep() { + context.extra.depth = "hijacked"; + return "tampered"; +} + +/** + * How many times `read` ran — a refused call must never reach it. + * @returns {number} The count. + */ +export function count() { + return runs; +} diff --git a/tests/fixtures/context-grow-api/relay.mjs b/tests/fixtures/context-grow-api/relay.mjs new file mode 100644 index 0000000..fd603bb --- /dev/null +++ b/tests/fixtures/context-grow-api/relay.mjs @@ -0,0 +1,26 @@ +/** + * + * @Project: @cldmv/slothlet-vine + * @Filename: /tests/fixtures/context-grow-api/relay.mjs + * @Date: 2026-10-06T12:00:00-07:00 (1791313200) + * @Author: Nate Corcoran + * @Email: + * ----- + * @Last modified by: Nate Corcoran (Shinrai@users.noreply.github.com) + * @Last modified time: 2026-10-06T12:00:00-07:00 (1791313200) + * ----- + * @Copyright: Copyright (c) 2013-2026 Catalyzed Motivation Inc. All rights reserved. + * + */ + +import { self } from "@cldmv/slothlet/runtime"; + +/** + * A grow-side MODULE that calls a vine stub after an `await` — so a `link.with()` extent has to + * survive both a module boundary and an async hop to reach the stub (#79). + * @returns {Promise} Whatever the far `work.read` answered. + */ +export async function read() { + await new Promise((resolve) => setTimeout(resolve, 5)); + return self.work.read(); +} diff --git a/tests/frame.test.vitest.mjs b/tests/frame.test.vitest.mjs index 6e19120..d2ae902 100644 --- a/tests/frame.test.vitest.mjs +++ b/tests/frame.test.vitest.mjs @@ -19,7 +19,9 @@ import { UNSAFE_SEGMENTS, callFrame, errorFrame, + findContextFault, findFunctionArg, + isPlainObject, isSafePath, isSafeSegment, parseFrame, @@ -239,3 +241,64 @@ describe("parseFrame", () => { expect({}.pwned).toBeUndefined(); }); }); + +describe("requested context (#79) — frames and the data-only rules", () => { + it("a surface frame advertises `context: true` only when asked, and is the v1 frame otherwise", () => { + expect(surfaceFrame(["a"])).not.toHaveProperty("context"); + expect(surfaceFrame(["a"], { context: false })).not.toHaveProperty("context"); + expect(surfaceFrame(["a"], { context: true })).toEqual({ type: "surface", v: FRAME_VERSION, leaves: ["a"], context: true }); + }); + + it("a call frame carries `context` only when one is given", () => { + expect(callFrame("n#1", "a.b", [])).not.toHaveProperty("context"); + expect(callFrame("n#1", "a.b", [], null)).not.toHaveProperty("context"); + expect(callFrame("n#1", "a.b", [], { project: "A" })).toEqual({ + type: "call", + callId: "n#1", + path: "a.b", + args: [], + context: { project: "A" } + }); + }); + + it("parseFrame keeps a surface's `context` only when it is literally true", () => { + expect(parseFrame({ type: "surface", v: 1, leaves: [], context: true }).context).toBe(true); + expect(parseFrame({ type: "surface", v: 1, leaves: [], context: "yes" })).not.toHaveProperty("context"); + expect(parseFrame({ type: "surface", v: 1, leaves: [] })).not.toHaveProperty("context"); + }); + + it("parseFrame carries a call's `context` through raw (validated by the serve); null / undefined mean none", () => { + expect(parseFrame({ type: "call", callId: "c", path: "a.b", args: [], context: { p: 1 } }).context).toEqual({ p: 1 }); + expect(parseFrame({ type: "call", callId: "c", path: "a.b", args: [], context: "junk" }).context).toBe("junk"); + expect(parseFrame({ type: "call", callId: "c", path: "a.b", args: [], context: null })).not.toHaveProperty("context"); + expect(parseFrame({ type: "call", callId: "c", path: "a.b", args: [] })).not.toHaveProperty("context"); + }); + + it("isPlainObject accepts {} and null-prototype objects only", () => { + expect(isPlainObject({})).toBe(true); + expect(isPlainObject(Object.create(null))).toBe(true); + for (const value of [null, undefined, [], "s", 1, new Date(), new Map(), new (class X {})()]) expect(isPlainObject(value)).toBe(false); + }); + + it("findContextFault: plain data passes; a non-plain value, a function anywhere, or an un-cloneable value is named", () => { + expect(findContextFault({ project: "A", when: new Date(0), tags: new Set(["x"]), nested: { list: [1, 2] } })).toBeNull(); + expect(findContextFault([1])).toBe("context"); + expect(findContextFault("A")).toBe("context"); + expect(findContextFault(null)).toBe("context"); + expect(findContextFault({ a: { b: [0, () => 1] } })).toBe("context.a.b[1]"); + expect(findContextFault({ s: Symbol("x") })).toBe("context"); + expect(findContextFault({ w: new WeakMap() })).toBe("context"); + }); + + it("findContextFault never throws on a hostile value", () => { + const hostile = new Proxy( + {}, + { + getPrototypeOf() { + throw new Error("boom"); + } + } + ); + expect(findContextFault(hostile)).toBe("context"); + }); +}); diff --git a/tests/grow-serve-units.test.vitest.mjs b/tests/grow-serve-units.test.vitest.mjs index b474d3a..287a366 100644 --- a/tests/grow-serve-units.test.vitest.mjs +++ b/tests/grow-serve-units.test.vitest.mjs @@ -47,7 +47,7 @@ function fakeChannel(behaviour = {}) { throw new Error("this fake transport refuses a re-registration"); } handler = fn; - if (behaviour.syncSurface) fn({ type: "surface", v: 1, leaves: behaviour.leaves ?? ["far.leaf"] }); + if (behaviour.syncSurface) fn({ type: "surface", v: 1, leaves: behaviour.leaves ?? ["far.leaf"], ...behaviour.surface }); }, /** * Push a frame at the registered handler, as the transport would. @@ -511,6 +511,23 @@ describe("grow — stub dispatch edge cases", () => { await link.close(); }); + it("a context.get() that throws is no extent — the link default applies (#79)", async () => { + const api = fakeApi(); + api.slothlet.context = { + get() { + throw new Error("no context here"); + } + }; + const channel = fakeChannel({ syncSurface: true, surface: { context: true } }); + const link = await grow(api, channel, { budgetMs: 500, context: { project: "A" } }); + const pending = api.far.leaf(1); + const call = channel.sent.find((frame) => frame.type === "call"); + expect(call.context).toEqual({ project: "A" }); + channel.deliver({ type: "result", callId: call.callId, value: "ok" }); + expect(await pending).toBe("ok"); + await link.close(); + }); + it("refuses a call made after close() with VINE_CLOSED", async () => { const { api, link } = await grown(); const stub = api.far.leaf; diff --git a/tests/package-surface.test.vitest.mjs b/tests/package-surface.test.vitest.mjs index 51e0431..c180d71 100644 --- a/tests/package-surface.test.vitest.mjs +++ b/tests/package-surface.test.vitest.mjs @@ -139,6 +139,14 @@ describe("frame schema", () => { expect(Object.keys(errorFrame("n#1", new Error("x")).error)).toEqual(expect.arrayContaining(error.properties.error.required)); }); + it("declares the requested-context keys (#79) as optional — the v1 shapes stay valid", () => { + const [surface, call] = schema.oneOf; + expect(surface.properties.context.const).toBe(true); + expect(surface.required).not.toContain("context"); + expect(call.properties.context.type).toBe("object"); + expect(call.required).not.toContain("context"); + }); + it("requires a non-empty callId, matching parseFrame's own rejection", () => { const [, call, result, error] = schema.oneOf; expect(call.properties.callId.minLength).toBe(1); diff --git a/tests/serve-principal-units.test.vitest.mjs b/tests/serve-principal-units.test.vitest.mjs index c3186ed..9aeb5d2 100644 --- a/tests/serve-principal-units.test.vitest.mjs +++ b/tests/serve-principal-units.test.vitest.mjs @@ -838,3 +838,63 @@ describe("no principal — the v1 trusted-transport behaviour is pinned, byte fo expect((await call(channel, "math.add")).type).toBe("result"); }); }); + +describe("requested context (#79) — the scope seam, on a fake instance", () => { + /** + * Deliver one call frame carrying a requested context and return its terminal frame. + * @param {object} channel - The fake channel. + * @param {object} context - The requested context. + * @returns {Promise} The `result` / `error` frame. + */ + async function callWith(channel, context) { + const before = channel.sent.length; + channel.deliver({ type: "call", callId: "c1", path: "math.add", args: [1, 2], context }); + await tick(); + return channel.sent.slice(before).find((frame) => frame.type === "result" || frame.type === "error"); + } + + it("without a principal: the accepted keys open a protected scope around the leaf", async () => { + const { api, channel } = await served({}, { context: (requested) => requested }); + expect((await callWith(channel, { project: "A" })).value).toBe(3); + expect(api.scopes).toEqual([{ context: { project: "A" }, protect: ["project"] }]); + expect(api.log).toEqual(["scope:enter", "leaf", "scope:exit"]); + }); + + it("with a principal: merged under the principal's keys, all protected, the gate inside", async () => { + const { api, channel } = await served( + {}, + { principal: { path: "remote.renderer", context: { actor: "u1" } }, context: () => ({ project: "A", actor: "forged" }) } + ); + expect((await callWith(channel, { project: "A" })).value).toBe(3); + expect(api.scopes).toEqual([{ context: { project: "A", actor: "u1" }, protect: ["project", "actor"] }]); + expect(api.log).toEqual(["scope:enter", "gate:remote.renderer->math.add", "leaf", "scope:exit"]); + }); + + it("a scope that never runs fn is VINE_DENIED — nothing runs outside the accepted scope", async () => { + let probed = false; + const { api, channel } = await served( + { + scope: async (options) => { + if (!probed) { + probed = true; + return options.fn(); // the setup probe + } + return undefined; + } + }, + { context: (requested) => requested } + ); + const frame = await callWith(channel, { project: "A" }); + expect(frame.error.code).toBe(CODES.DENIED); + expect(api.log).not.toContain("leaf"); + }); + + it("a context check alone does not need the permission system — only a working scope", async () => { + await expect( + serve(fakeApi({ noPermissions: true, noCheckCall: true, noResolveLevel: true }), fakeChannel(), { context: (r) => r }) + ).resolves.toBeTruthy(); + await expect(serve(fakeApi({ noScope: true }), fakeChannel(), { context: (r) => r })).rejects.toThrow( + /context check needs a working api\.slothlet\.context\.scope\(\)/ + ); + }); +}); From 87c8ee21d0b8c6cca68bee58821902de9d81e539 Mon Sep 17 00:00:00 2001 From: Shinrai Date: Wed, 7 Oct 2026 00:01:04 -0700 Subject: [PATCH 6/9] =?UTF-8?q?docs:=20the=20per-call=20requested=20contex?= =?UTF-8?q?t=20=E2=80=94=20wire=20keys,=20serve({=20context=20})=20and=20l?= =?UTF-8?q?ink.with()/grow({=20context=20}),=20the=20refuse-without-a-chec?= =?UTF-8?q?k=20default,=20interop,=20and=20no=20context=20on=20subscriptio?= =?UTF-8?q?ns=20in=20v1=20(#79)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/CONFIGURATION.md | 89 +++++++++++++++++++++++++++++++++------ docs/CUSTOM-TRANSPORTS.md | 2 +- docs/DESIGN.md | 32 ++++++++++---- docs/ERRORS.md | 20 ++++----- docs/PERMISSIONS.md | 43 +++++++++++++++++++ docs/TRANSPORTS.md | 2 +- 6 files changed, 156 insertions(+), 32 deletions(-) diff --git a/docs/CONFIGURATION.md b/docs/CONFIGURATION.md index b5aede3..4413ac5 100644 --- a/docs/CONFIGURATION.md +++ b/docs/CONFIGURATION.md @@ -15,7 +15,8 @@ const serving = await serve(api, channel, { modules: ["ext-1"], // extra moduleIDs to union in (runtime add() mounts leaves() alone can't see) budgetMs: 30_000, // accepted, IGNORED in v1 — documented for symmetry with grow() principal: { path: "remote.renderer", context: { actor } }, // OPTIONAL — the channel principal for an untrusted peer - around: ({ callId, path, args, principal, invoke }) => invoke() // OPTIONAL — the host's own scope around each accepted call + context: (requested, { principal, path, args }) => requested, // OPTIONAL — accept / narrow / refuse a per-call requested context + around: ({ callId, path, args, principal, context, invoke }) => invoke() // OPTIONAL — the host's own scope around each accepted call }); // serving: { leaves: string[], excluded: string[], principal: object|Function|null, close(): void } @@ -24,9 +25,11 @@ const link = await grow(api, channel, { budgetMs: 30_000, // per-call settle budget; exceeded → VINE_BUDGET handshakeMs: 30_000, // deadline for the surface frame itself; defaults to budgetMs paths: ["exts"], // dotted prefixes to mount (grow-side mirror of serve's own paths filter) - principal: "remote.plugin" // OPTIONAL — the channel principal for this end's EVENT server-half only + principal: "remote.plugin", // OPTIONAL — the channel principal for this end's EVENT server-half only + context: () => ({ project: pinned }) // OPTIONAL — the default per-call requested context (object, or a per-call function) }); -// link: { id, leaves: string[], skipped: string[], collisions: string[], close(): Promise, closed: Promise<{reason}> } +// link: { id, leaves: string[], skipped: string[], collisions: string[], context: boolean, with(context, fn, ...args), close(): Promise, closed: Promise<{reason}> } +await link.with({ project: "p1" }, () => api.project.files.list()); // every call in the extent carries { project: "p1" } ``` --- @@ -88,9 +91,42 @@ wss.on("connection", async (socket, request) => { The full model — what a principal is and is not, the rule shapes, and the worked call and subscription tables — is in [PERMISSIONS.md → Serving to an untrusted peer](PERMISSIONS.md#serving-to-an-untrusted-peer-the-channel-principal). +### `context` + +**Type:** `(requested: object, call: { principal, path, args }) => object | false | null | undefined` (or a Promise of one) **Default:** none — a requested context is **refused** + +The host check for a per-call **requested context**: a data-only plain object the far side attaches to one `call` frame (grow-side [`link.with()`](#linkwithcontext-fn-args) or [`grow({ context })`](#context-1)), such as the project a browser window is working in. It is a request, never identity — identity is the [`principal`](#principal)'s, bound to the channel — and it lives on the call, so the server holds no per-connection state and nothing races when a client switches projects. + +The check runs only for frames that carry one, **after** the principal is resolved and **before** the permission gate. It receives a private copy of the request and `{ principal, path, args }` (`principal` is `null` on a serve without one; `args` are the frame's arguments as a rule condition would see them), and may be async. + +| The check answers | Outcome | +| ------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------- | +| a plain, data-only object — the request itself | **accept**: those keys go into the call's context scope | +| a different plain object (a subset, a canonicalized value) | **narrow**: only what it answered goes into the scope | +| `false`, `null`, `undefined`, `true`, an array, a class instance, a function-bearing object | **refuse**: [`VINE_DENIED`](ERRORS.md#vine_-codes); the gate is never asked and the leaf never runs | +| a throw or a rejection | **refuse**: `VINE_DENIED` — the host's own error stays serve-side; the message names the path only | + +The accepted keys are merged into the call's `context.scope` **under** the principal's — a principal key always wins, so a request can never override `actor` — and every key is write-protected (`CONTEXT_KEY_PROTECTED` on any write, nested fields included). `checkCall`, rule conditions, `requires`-principals, [`around`](#around) and the leaf all see it as plain `context.project`. On a serve without a principal, an accepted context still runs in such a scope; there is just no gate. + +**No check configured, the default, refuses**: a frame that carries a requested context is answered `VINE_DENIED`, never run unscoped, so a client can never believe a call is scoped when it isn't. A serve with a check adds `context: true` to its `surface` frame; a grow refuses locally to send a requested context to a far side that did not advertise it (which also covers an older serve that would drop the key). A request that is not data — not a plain object, a function anywhere, a value structured clone refuses — is `VINE_DATA_ONLY`, checked before the host's check is called. + +`serve()` throws a `TypeError` when `context` is not a function, or when the instance's `context.scope` does not work (a `scope: false` instance). The permission system is **not** required unless a `principal` is also set. Like a principal, a context-accepting serve must run on the async (Node) runtime: under `runtime: "live"` concurrent scopes on one instance interleave. + +```javascript +await serve(api, createChannel(socket), { + paths: ["project"], + principal: { path: "remote.renderer", context: { actor: { id: user.id } } }, + // Accept the pinned project only when this user can access it; drop anything else the client sent. + context: async (requested, { principal }) => + (await projects.canAccess(principal.context.actor.id, requested.project)) ? { project: requested.project } : false +}); +``` + +Subscriptions carry no requested context in v1: a `sub` frame is resolved from the principal alone (see [PERMISSIONS.md](PERMISSIONS.md#per-call-requested-context)). + ### `around` -**Type:** `({ callId, path, args, principal, invoke }) => unknown` **Default:** none +**Type:** `({ callId, path, args, principal, context, invoke }) => unknown` **Default:** none A per-call wrapper for the host's own scope around each call the vine has accepted: a transaction, a deadline, an audit record. It runs after every check the vine makes — the served-surface check (`VINE_NO_LEAF`), the principal gate (`VINE_DENIED`, inside the principal's context scope), and the args data-only check (`VINE_DATA_ONLY`) — so it never sees a frame the vine would have refused. Identity is not established here: that is [`principal`](#principal)'s job, and `around` decorates a call that is already authorized. @@ -100,6 +136,7 @@ A per-call wrapper for the host's own scope around each call the vine has accept | `path` | The served leaf path. | | `args` | A private copy of the call's arguments, for inspection. Changing it changes nothing `invoke()` passes. | | `principal` | The frozen `{ path, context? }` resolved for **this** frame (a function-form principal is resolved per frame), or `null` on a serve without a principal. | +| `context` | A private copy of the requested context the [`context`](#context) check accepted for this call, or `null` when the frame carried none. `around` runs inside the scope that holds it. | | `invoke` | `() => Promise` — runs the gated leaf with the call's original arguments. It takes no arguments (the call that was authorized is the call that runs) and may be called more than once; each call gets a fresh copy of the arguments as they arrived, so a retry never sees what a failed attempt did to them. | Whatever `around` returns is the call's result — the data-only return check applies to it exactly as to a leaf's own value, so a wrapper that returns a function is refused `VINE_DATA_ONLY`. Whatever it throws is the call's error, and reaches the grow side as a `VineRemoteError` carrying that error's own name, message and code. `around` may also answer without calling `invoke()` at all (a cached answer). With `around` set, a call's arguments must be structured-cloneable — they already must be to cross any real transport — and ones that are not answer `VINE_DATA_ONLY`. @@ -175,6 +212,32 @@ The channel principal for **this end's event server-half only**. A grow answers const link = await grow(hostApi, workerChannel, { principal: "remote.plugin" }); ``` +### `context` + +**Type**: `object | () => (object | null | undefined)` +**Default**: none + +The link's **default** per-call requested context: attached to every call made over this link outside a [`link.with()`](#linkwithcontext-fn-args) extent. A static plain object is validated and copied once (mutating it later changes nothing). A function is called synchronously **per call** and answers the context for that call, or `null` / `undefined` for none — the way a window sends whatever project it currently has pinned while the server holds nothing: + +```javascript +let pinned = null; // set by the UI +const link = await grow(viewApi, channel, { context: () => (pinned ? { project: pinned } : null) }); +``` + +The same data-only rules as arguments apply: a static value that is not a plain data object throws at `grow()` (`TypeError` for a non-object, `VINE_DATA_ONLY` for one that is not data); a resolver whose answer is not data rejects that call `VINE_DATA_ONLY`, and one that throws rejects it with its own error — nothing is sent either way. A call carrying a requested context to a far side that does not accept one (`link.context === false`) is refused locally with `VINE_DENIED` and nothing is sent. What the far side does with it is its [`context`](#context) check's decision. + +### `link.with(context, fn, ...args)` + +Not an option — a method on the returned link. Runs `fn(...args)` so that **every call it makes over this link** — directly, through any module, across every `await` — carries `context` on its `call` frame, and resolves to `fn`'s value. The extent is carried on the grow instance's own context scope (so that instance must not be `scope: false`), under a key private to the link: calls over other links are untouched. The innermost extent wins outright (no merge with an outer one or with the default), and `with(null, fn)` runs `fn` with no requested context at all. `context` is validated (`VINE_DATA_ONLY`) and copied up front; a non-function `fn` is a `TypeError`. + +```javascript +// Two windows, one link: each call carries its own project, concurrently. +await Promise.all([ + link.with({ project: "A" }, () => viewApi.project.files.list()), + link.with({ project: "B" }, () => viewApi.project.files.list()) +]); +``` + --- ## Return values @@ -190,14 +253,16 @@ const link = await grow(hostApi, workerChannel, { principal: "remote.plugin" }); ### `link` (from `grow()`) -| Field | Type | Meaning | -| ------------ | ---------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------- | -| `id` | `string` | The link's internal moduleID (`vine-`), for diagnostics. | -| `leaves` | `string[]` | Paths actually mounted and forwarding. | -| `skipped` | `string[]` | Far leaves refused locally: an unsafe path, outside `paths`, rejected by `add()`, or published after the link had already ended. | -| `collisions` | `string[]` | Paths the local instance already occupied. **Not mounted** — the incumbent keeps answering there, and this leaf is unreachable through this link. | -| `close()` | `() => Promise` | Unmount every stub this link owns and settle every in-flight call `VINE_CLOSED`. Idempotent. | -| `closed` | `Promise<{ reason, info? }>` | Resolves once the link ends, whichever way — `reason` is `"closed"` (you called `close()`) or `"gone"` (the far side died). | +| Field | Type | Meaning | +| ------------ | ----------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------- | +| `id` | `string` | The link's internal moduleID (`vine-`), for diagnostics. | +| `leaves` | `string[]` | Paths actually mounted and forwarding. | +| `skipped` | `string[]` | Far leaves refused locally: an unsafe path, outside `paths`, rejected by `add()`, or published after the link had already ended. | +| `collisions` | `string[]` | Paths the local instance already occupied. **Not mounted** — the incumbent keeps answering there, and this leaf is unreachable through this link. | +| `context` | `boolean` | Whether the far side accepts a per-call requested context (its `surface` advertised it). When `false`, a call carrying one is refused locally. | +| `with()` | `(context, fn, ...args) => Promise` | Run `fn` with a per-call requested context on every call it makes over this link — see [`link.with()`](#linkwithcontext-fn-args). | +| `close()` | `() => Promise` | Unmount every stub this link owns and settle every in-flight call `VINE_CLOSED`. Idempotent. | +| `closed` | `Promise<{ reason, info? }>` | Resolves once the link ends, whichever way — `reason` is `"closed"` (you called `close()`) or `"gone"` (the far side died). | `leaves`, `skipped`, and `collisions` are **disjoint** and together account for every leaf the far side published. diff --git a/docs/CUSTOM-TRANSPORTS.md b/docs/CUSTOM-TRANSPORTS.md index 217140b..8b34590 100644 --- a/docs/CUSTOM-TRANSPORTS.md +++ b/docs/CUSTOM-TRANSPORTS.md @@ -21,7 +21,7 @@ Only `send` and `onMessage` are required. `close`, `onClose`, and `capabilities` ### The rules, restated as things to actually do -- **`send`/`onMessage` carry plain frame objects.** If your medium structured-clones (like `postMessage`), pass frames through untouched and declare `capabilities.structuredClone: true, codec: "none"`. If your medium only carries bytes/strings, own your encode/decode internally and declare `codec: "json"` (or whatever you use) — see `transport/websocket` for the reference byte transport. +- **`send`/`onMessage` carry plain frame objects.** If your medium structured-clones (like `postMessage`), pass frames through untouched and declare `capabilities.structuredClone: true, codec: "none"`. If your medium only carries bytes/strings, own your encode/decode internally and declare `codec: "json"` (or whatever you use) — see `transport/websocket` for the reference byte transport. Carry every key of a frame, including ones you don't recognize: frames grow optional keys without a version bump (a `call`'s requested `context` and a `surface`'s `context: true` flag, for example), and a transport that copies only the keys it knows would silently strip them. - **`onMessage`/`onClose` are single-handler registrations — last write wins.** A second `onMessage(fn)` call replaces the first, it doesn't add a second listener. - **A handler must never be allowed to throw into your transport.** Wrap every call to a registered handler: diff --git a/docs/DESIGN.md b/docs/DESIGN.md index 40e4c4f..49a2dc8 100644 --- a/docs/DESIGN.md +++ b/docs/DESIGN.md @@ -46,8 +46,8 @@ All frames are objects with a `type`. Unknown `type`s are ignored (forward compa | Frame | Shape | Direction | | ------- | ------------------------------------------------------------------------------------------------------------ | ---------------------------------------------------------------------------------------------- | -| surface | `{ type: "surface", v: 1, leaves: string[] }` | serve → grow, once on link start (and again if the served surface changes — v1 sends once) | -| call | `{ type: "call", callId: string, path: string, args: unknown[] }` | grow → serve | +| surface | `{ type: "surface", v: 1, leaves: string[], context?: true }` | serve → grow, once on link start (and again if the served surface changes — v1 sends once) | +| call | `{ type: "call", callId: string, path: string, args: unknown[], context?: object }` | grow → serve | | result | `{ type: "result", callId: string, value?: unknown }` | serve → grow | | error | `{ type: "error", callId: string, error: { name: string, message: string, code?: string, stack?: string } }` | serve → grow | | sub | `{ type: "sub", subId: string, event: string, subscriberPath: string \| null }` | either → either — subscribe to a far event AS an identity (event forwarding is bidirectional) | @@ -58,6 +58,7 @@ All frames are objects with a `type`. Unknown `type`s are ignored (forward compa - `callId`: unique per grow-side link (monotonic counter + link nonce; never `Math.random` collisions). - `subId`: the event-forwarding correlation id. Event forwarding is bidirectional, so EACH end draws its own subscriptions' `subId`s from its own nonce (separate from `callId`'s, so a `sub-ack` and a call `result` can never be confused, and separate per end, so the two directions' ids never collide); frames route by TYPE regardless. `subscriberPath` is the subscriber's own identity (from `api.slothlet.caller()`), matched — never mounted — against the EMITTING instance's event rules, so an untrusted string cannot pollute a prototype the way an unguarded `call` path could. See [Event forwarding](#event-forwarding-schema-v1). - `path`: the dotted leaf path exactly as served (e.g. `exts.pdfViewer.open`). +- `context` (call, optional): the caller's per-call **requested context** — plain data, never identity — for the serve's `context` check to accept, narrow or refuse; `context: true` (surface, optional) says the serve has such a check. Both keys are additive: a frame without them is the v1 frame, and `FRAME_VERSION` is unchanged. See [Requested context (#79)](#requested-context-79). - Function-valued `args` are **rejected grow-side** before dispatch (`VINE_DATA_ONLY`) — the vine is data-only in v1. So are function-valued **return values**, rejected serve-side with the same code; see [Data-only, both directions](#data-only-both-directions). - Errors cross as data and are re-thrown grow-side as `VineRemoteError` (name/message/code preserved, remote stack attached as `.remoteStack`). @@ -71,7 +72,8 @@ const serving = await vine.serve(api, channel, { paths: ["exts"], // dotted prefixes to serve; DEFAULT: all leaves EXCEPT "slothlet.**" (the control plane is NEVER served) modules: ["ext-1"], // extra moduleIDs to union in (runtime add() mounts) budgetMs: 30_000, // unused serve-side v1 (documented for symmetry) - principal: { path: "remote.renderer", context: { actor } } // OPTIONAL: the channel principal (#33) — string | { path, context? } | () => either + principal: { path: "remote.renderer", context: { actor } }, // OPTIONAL: the channel principal (#33) — string | { path, context? } | () => either + context: (requested, { principal, path, args }) => requested // OPTIONAL: the requested-context check (#79) — accept | narrow | refuse }); // serving: { leaves: string[], excluded: string[], principal: object|Function|null, close(): void } @@ -80,23 +82,26 @@ const link = await vine.grow(api, channel, { budgetMs: 30_000, // per-call settle budget; exceeded → VINE_BUDGET error handshakeMs: 30_000, // deadline for the surface frame; Infinity to wait forever paths: ["exts"], // dotted prefixes to mount (grow-side mirror of serve's) - principal: "remote.plugin" // OPTIONAL: the channel principal for this end's EVENT server-half only (a grow answers no calls) + principal: "remote.plugin", // OPTIONAL: the channel principal for this end's EVENT server-half only (a grow answers no calls) + context: () => ({ project: pinned }) // OPTIONAL: the link's default per-call requested context (#79) — object | () => object|null }); -// link: { id, leaves: string[], skipped: string[], collisions: string[], close(): Promise, closed: Promise<{reason}> } +// link: { id, leaves: string[], skipped: string[], collisions: string[], context: boolean, with(context, fn, ...args), close(): Promise, closed: Promise<{reason}> } +await link.with({ project: "p1" }, () => api.project.files.list()); // every call in the extent carries the requested context ``` Semantics: - **serve** answers `call` frames by resolving the dotted path against the live api and invoking it. It re-validates that the path is within the served surface (never trust the wire). Thrown/rejected errors become `error` frames. It sends `surface` immediately on start, derived from the instance's leaf records filtered by `paths` and the hard `slothlet.**` exclusion. - **serve with a `principal`** judges every `call` frame as that identity before anything runs. The pipeline, in order: `VINE_NO_LEAF` (the surface filter is the outer boundary and is already public in the `surface` frame) → resolve the principal (a function form is called per frame; a throw or a non-principal answer denies the frame) → open `api.slothlet.context.scope({ context: principal.context ?? {}, protect: Object.keys(context), fn })` → `api.slothlet.permissions.global.checkCall(principal.path, path, args)` inside that scope, `VINE_DENIED` unless it answers exactly `true` → the args data-only check (`VINE_DATA_ONLY`) → invoke (through `around`, when one is given), still inside the scope → the return-value data-only check → `result`. The gate precedes the args scan because rule conditions are user code that receives `args`: an unauthorized principal's payload never reaches one. Without a `principal` the pipeline is the v1 one above, byte for byte — no gate, no scope, host standing. See [Channel principal (#33)](#channel-principal-33) under the implementation notes. -- **serve with `around`** hands each accepted call to the host's wrapper in place of the bare invocation: `around({ callId, path, args, principal, invoke })` runs after the surface check, the principal gate (inside its scope) and the args data-only check, and its return value — not the leaf's — goes through the return-value data-only check and becomes the `result`; a throw becomes the `error`. `invoke()` is fixed to the gated path and to a private snapshot of the gated arguments: it takes no arguments, may be called more than once (a retried transaction), and each call receives a fresh structured clone of the snapshot, while `around`'s own `args` is a separate copy. The parsed frame's `args` is a fresh array whose elements are still shared, so without the snapshot a wrapper — or a bug in one — could mutate a nested argument after the rules engine judged it. See [CONFIGURATION.md → `around`](CONFIGURATION.md#around). +- **serve with a `context` check** (#79) judges a `call` frame's optional requested context: after the principal is resolved (when there is one) and before the gate, a request that is not data is `VINE_DATA_ONLY`, and the check must answer a plain, data-only object or the call is `VINE_DENIED`; the accepted keys are merged into the call's scope under the principal's keys (principal wins), every key write-protected, so the gate, rule conditions, `around` and the leaf all see them. Without a check, a frame carrying a requested context is `VINE_DENIED`. A frame without one is unaffected. See [Requested context (#79)](#requested-context-79). +- **serve with `around`** hands each accepted call to the host's wrapper in place of the bare invocation: `around({ callId, path, args, principal, context, invoke })` runs after the surface check, the principal gate (inside its scope) and the args data-only check, and its return value — not the leaf's — goes through the return-value data-only check and becomes the `result`; a throw becomes the `error`. `invoke()` is fixed to the gated path and to a private snapshot of the gated arguments: it takes no arguments, may be called more than once (a retried transaction), and each call receives a fresh structured clone of the snapshot, while `around`'s own `args` is a separate copy. The parsed frame's `args` is a fresh array whose elements are still shared, so without the snapshot a wrapper — or a bug in one — could mutate a nested argument after the rules engine judged it. See [CONFIGURATION.md → `around`](CONFIGURATION.md#around). - **grow** awaits the `surface` frame (subject to `handshakeMs`), then mounts one async stub per leaf at the identical dotted path via slothlet's synthetic in-memory add (`api.slothlet.api.add(path, stubFn, { moduleID })`, one shared link moduleID) so **slothlet's own permission system gates stub calls exactly like real leaves**. `link.close()` removes the mounts (`api.slothlet.api.remove(moduleID)`) and settles all pending calls with `VINE_CLOSED`. - A **channel is directional**: one serve end, one grow end. Bidirectional forwarding = two channels (transports that have paired endpoints expose pairs). - **Death**: `channel.onClose` (or transport-detected far-side death) force-settles every pending call with `VINE_GONE` and resolves `link.closed`. Pending calls NEVER hang. - **Budget**: each grow-side call arms a timer (`budgetMs`); expiry settles that call with `VINE_BUDGET` (the late result frame, if it arrives, is ignored — settle-once). - **Settle-once** everywhere: a callId settles exactly once (result | error | budget | gone | closed); later frames for it are dropped. -Error codes (all `VineError` subclasses carrying `.code`): `VINE_GONE`, `VINE_BUDGET`, `VINE_CLOSED`, `VINE_DATA_ONLY`, `VINE_BAD_FRAME`, `VINE_NO_LEAF` (call for a path not in the served surface), `VINE_DENIED` (the channel principal may not call this path — produced only by a serve that bound a `principal`), `VINE_REMOTE`. Remote application errors re-throw as `VineRemoteError` (their own name/message/code) — except a `VINE_*` code, which is never adopted from the wire; see [Errors](#errors) below. +Error codes (all `VineError` subclasses carrying `.code`): `VINE_GONE`, `VINE_BUDGET`, `VINE_CLOSED`, `VINE_DATA_ONLY`, `VINE_BAD_FRAME`, `VINE_NO_LEAF` (call for a path not in the served surface), `VINE_DENIED` (the channel principal may not call this path, or a per-call requested context was refused), `VINE_REMOTE`. Remote application errors re-throw as `VineRemoteError` (their own name/message/code) — except a `VINE_*` code, which is never adopted from the wire; see [Errors](#errors) below. ## Event forwarding (schema v1) @@ -243,10 +248,21 @@ The serve side answers `call` frames with host standing: `invoke()` walks the bo - **Events.** With a principal, `null` from the far side is the channel; a far `subscriberPath` at or under the principal's path narrows to the lower of the two levels; any other claim is ignored. Prefix _substitution_ (use the hint's level whenever it is under the prefix) was considered and rejected: a forged sibling under the prefix could claim a sibling's higher grant. Resolution runs inside `context.run(principal.context)` when a context is bound, at subscribe and again per emit. On the installed slothlet `context.run()` returns a Promise even for a synchronous callback, so a principal-bound `sub` is acked after that resolution settles and each emit is forwarded after its re-resolution; a `sub` repeated while the first is still resolving registers nothing, and an `unsub` or a teardown that lands in that window cancels it — nothing is acked and no listener is registered. Without a principal the event half is synchronous and unchanged. - **`grow({ principal })`.** The forwarder is symmetric and the trusted end of a channel is not always the serving end — a host that grows leaves out of an untrusted worker plugin still answers that plugin's `sub` frames for host events. So `grow()` takes the same option for its event server-half only; a grow answers no `call` frames, so `checkCall` is not required there. - **`VINE_DENIED`.** A new vine code, not slothlet's `PERMISSION_DENIED`: it is the vine refusing on its own account, and the contract is that those are `VineError`s. Not `VINE_NO_LEAF` either — the surface frame already disclosed what exists, and a distinguishable "you may not" hides nothing extra. The message names the path only; the principal is serve-side detail and never crosses (`toWire` sends `name` / `message` / `code` / `stack`). Grow-side it arrives, like every remote `VINE_*` code, as a `VineRemoteError` with `.code === "VINE_REMOTE"` and `.remoteCode === "VINE_DENIED"`. -- **Wire format unchanged.** No frame, schema or `FRAME_VERSION` change: the `call` frame still carries no identity (identity is the channel's, not the frame's), and the `sub` frame still carries the far side's own `caller()`. A 1.1.x grow talks to a serve with a principal, and vice versa, unchanged. +- **Wire format unchanged.** No frame, schema or `FRAME_VERSION` change: the `call` frame still carries no identity (identity is the channel's, not the frame's), and the `sub` frame still carries the far side's own `caller()`. A 1.1.x grow talks to a serve with a principal, and vice versa, unchanged. (The per-call requested context, #79, later added two optional keys — see [Requested context (#79)](#requested-context-79).) - **Runtime caveat.** A principal-bearing serve must run on the async (Node) runtime: under `runtime: "live"` (and in browser mode) concurrent `scope()` calls on one instance interleave, and one channel's actor could be attributed to another channel's call. The instance does not expose its runtime mode publicly, so this is documented rather than refused. - **`paths` / `modules` are orthogonal.** The surface filter decides what is reachable at all (published in `surface`, reported in `excluded`); the principal gate decides who may call it. With a principal, `paths` is the coarse allow-list and the rules are the fine one; both are consulted on every frame. +### Requested context (#79) + +Identity is the channel's; some context is the call's. A client that works in project A in one window and project B in another needs every call to run server-side in a scope holding its project, without the server holding pin state per connection (which races in-flight calls when it changes) and without a `project` parameter threaded through every leaf. So a `call` frame may carry an optional `context` — a **requested** context, data only — and the host decides what of it to accept. + +- **Grow side.** `link.with(context, fn, ...args)` runs `fn` so that every call it makes over that link carries `context`; the extent rides the grow instance's own `context.scope()` under a link-private key holding a function (slothlet shares callables by reference across scopes and deep-clones the rest), and only holders the link minted are honoured. The innermost extent wins; `null` clears. `grow({ context })` is the link default outside any extent — an object, or a function read synchronously per call (what a window has pinned right now). Every requested context is checked with the argument rules plus a shape (`findContextFault`: a plain object, no function anywhere, structured-cloneable) and copied; a violation is `VINE_DATA_ONLY` before anything is sent. +- **Serve side.** `serve({ context: (requested, { principal, path, args }) => accepted })`. It runs only for frames that carry one, after the principal is resolved and before the gate, on a private copy, and may be async. A plain, data-only answer is accepted (the request itself, or a narrowed or changed object); anything else — `false`, `null`, `true`, junk, a throw, a rejection — is `VINE_DENIED`, whose message names the path only. The accepted object is copied, merged as `{ ...accepted, ...principal.context }` (a principal key always wins) and every key goes into `protect`. Without a principal the call still runs in a scope holding the accepted keys; `serve({ context })` therefore probes `context.scope` at setup (a `TypeError` on a `scope: false` instance) but does not need the permission system. +- **No check configured: refuse.** A frame carrying a requested context to a serve without a check is `VINE_DENIED`, not run unscoped. Ignoring it was the alternative; refusing means a client can never believe it is scoped when it is not, and a host that wants requests ignored can say so explicitly with `context: () => ({})`. +- **Interop, no `FRAME_VERSION` change.** A serve with a check adds `context: true` to its `surface`; a grow sends a requested context only to a far side that advertised it and otherwise refuses the call locally with `VINE_DENIED` (`link.context` reports which). Old grow → new serve: an old grow never sends the key, so every call is answered as before and the check is never consulted. New grow → old serve: the old surface lacks the flag, so the new grow refuses locally rather than letting the old serve's `parseFrame` drop the unknown key and run the call unscoped; plain calls are byte for byte the v1 frame. Hand-built frames with a `context` reaching an old serve are ignored by it — the host must not rely on a requested context against a serve that predates this. +- **Events (v1 decision): no requested context on subscriptions.** A `sub` frame has no `context` key and a subscription's level is resolved from the principal alone, at subscribe and per emit. A subscription outlives any one call, so a per-call request has no meaning there; per-project events are expressed as project-named events plus conditional event rules over the principal's context. +- **Runtime caveat.** As for a principal: a context-accepting serve belongs on the async (Node) runtime, where concurrent scopes on one instance do not interleave. + ### Security notes (v1 limits, deliberate) - **No serve-side concurrency cap.** A peer may have any number of calls in flight; each one invokes a real leaf (with a `principal`, each one is first judged by the rules engine — but a denial is still work). The boundary is assumed to be one you established (a worker you spawned, a process you forked, a socket you authenticated at the transport layer), not an open port. A hostile peer on such a channel can exhaust the serving side by volume alone. diff --git a/docs/ERRORS.md b/docs/ERRORS.md index e16e479..e5cee28 100644 --- a/docs/ERRORS.md +++ b/docs/ERRORS.md @@ -10,16 +10,16 @@ import { CODES, VineError, VineRemoteError } from "@cldmv/slothlet-vine"; ## `VINE_*` codes -| Code | Fires when | -| ---------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `VINE_GONE` | The far side died — its channel closed (or a transport-detected death fired) with the call still in flight. | -| `VINE_BUDGET` | The per-call settle budget (`budgetMs`) elapsed before a terminal frame arrived. Also fires for a `grow()` handshake that never sees a `surface` frame within `handshakeMs`. | -| `VINE_CLOSED` | `link.close()` was called with the call still in flight, or a call was made after `close()`. | -| `VINE_DATA_ONLY` | A function-valued argument or return value was found in the data graph — the vine is data-only in v1. | -| `VINE_BAD_FRAME` | A frame couldn't be parsed, or an outbound frame couldn't be handed to the transport (the medium refused it — see [TRANSPORTS.md](TRANSPORTS.md)). | -| `VINE_NO_LEAF` | A call named a path that isn't in the served surface. Re-validated serve-side on **every** call — a path learned from an earlier, wider surface (or invented) can't reach a leaf this serve doesn't publish. | -| `VINE_DENIED` | The serving side's **channel principal** may not call this path — slothlet's rules said no (or could not be asked), and the leaf never ran. Produced only by a `serve()` that bound a `principal` (see [PERMISSIONS.md](PERMISSIONS.md#serving-to-an-untrusted-peer-the-channel-principal)); a trusted-transport serve never answers it. The message names the path only, never the principal. | -| `VINE_REMOTE` | `.code` on a `VineRemoteError` for a remote application error that carried no `code` of its own — or one whose code was a reserved `VINE_*` value (see below). | +| Code | Fires when | +| ---------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `VINE_GONE` | The far side died — its channel closed (or a transport-detected death fired) with the call still in flight. | +| `VINE_BUDGET` | The per-call settle budget (`budgetMs`) elapsed before a terminal frame arrived. Also fires for a `grow()` handshake that never sees a `surface` frame within `handshakeMs`. | +| `VINE_CLOSED` | `link.close()` was called with the call still in flight, or a call was made after `close()`. | +| `VINE_DATA_ONLY` | A function-valued argument or return value was found in the data graph — the vine is data-only in v1. Also a per-call **requested context** that is not data (not a plain object, a function anywhere, or a value structured clone refuses) — grow-side from `link.with()` / `grow({ context })` before anything is sent, serve-side for a hand-built frame. | +| `VINE_BAD_FRAME` | A frame couldn't be parsed, or an outbound frame couldn't be handed to the transport (the medium refused it — see [TRANSPORTS.md](TRANSPORTS.md)). | +| `VINE_NO_LEAF` | A call named a path that isn't in the served surface. Re-validated serve-side on **every** call — a path learned from an earlier, wider surface (or invented) can't reach a leaf this serve doesn't publish. | +| `VINE_DENIED` | The serving side's **channel principal** may not call this path — slothlet's rules said no (or could not be asked), and the leaf never ran. Produced by a `serve()` that bound a `principal` (see [PERMISSIONS.md](PERMISSIONS.md#serving-to-an-untrusted-peer-the-channel-principal)). Also a per-call **requested context** that was refused — by the serve's `context` check, or because the serve has none (see [PERMISSIONS.md](PERMISSIONS.md#per-call-requested-context)); a trusted-transport serve answers it only for that. A grow produces it **locally** (a plain `VineError`, nothing sent) for a call carrying a requested context to a far side that does not accept one (`link.context === false`). The message names the path only, never the principal. | +| `VINE_REMOTE` | `.code` on a `VineRemoteError` for a remote application error that carried no `code` of its own — or one whose code was a reserved `VINE_*` value (see below). | Every one of these is a `VineError` instance, so `err instanceof VineError` is the general "this came from the vine itself" check, and `err.code === CODES.GONE` (etc.) is how you branch on which one. diff --git a/docs/PERMISSIONS.md b/docs/PERMISSIONS.md index 69054dc..f7d4e50 100644 --- a/docs/PERMISSIONS.md +++ b/docs/PERMISSIONS.md @@ -133,6 +133,49 @@ With a principal, every far `sub` is resolved as `principal.path` — `null` fro The forwarder is symmetric, and the trusted end of a channel is not always the serving end: a Node host that grows leaves out of an untrusted worker plugin still answers that plugin's `sub` frames for host events. `grow(api, channel, { principal })` binds the same identity for that end's event server-half — every far `sub` is resolved as the principal with the same narrowing rules — and nothing else: a grow answers no `call` frames, so only the event-side preconditions apply and `checkCall` is not required. +### Per-call requested context + +A principal is per channel. Some context is per **call** and chosen by the caller: the project a user is working in, with one browser window on project A and another on project B over the same or separate sockets. Putting it in the principal makes the server hold per-connection state that races with in-flight calls when it changes; putting it in arguments threads a parameter through every leaf. So a `call` frame may carry an optional, data-only **requested context**, and the host decides what of it to believe: + +```javascript +// serve side +await serve(api, createChannel(socket), { + paths: ["project"], + principal: { path: "remote.renderer", context: { actor: { id: user.id, roles: user.roles } } }, + context: async (requested, { principal }) => + (await projects.canAccess(principal.context.actor.id, requested.project)) ? { project: requested.project } : false +}); + +// a rule can now be scoped by the accepted project, and a leaf reads it as context.project +const rules = [ + { caller: "remote.renderer", target: "project.files.*", effect: "allow", condition: (ctx) => typeof ctx.project === "string" } +]; +``` + +```javascript +// grow side (the page) +await link.with({ project: "p1" }, () => viewApi.project.files.list()); // every call in the extent carries it +const link2 = await grow(viewApi, channel, { context: () => ({ project: pinned }) }); // or per call, from whatever is pinned +``` + +The pipeline for a frame that carries one, with a principal: `VINE_NO_LEAF` → resolve the principal → `VINE_DATA_ONLY` if the request is not data (a plain object with no function anywhere that survives a structured clone — the argument rules plus a shape) → the host's `context` check, `VINE_DENIED` unless it answers a plain, data-only object → open the scope with the accepted keys merged **under** the principal's, every key write-protected → the gate (`checkCall`) → the args scan → invoke → `result`. Without a principal the same, minus the gate. A frame without a requested context is untouched by all of this. + +| requested context on the frame | `context` check answers | scope the gate and leaf see | net | +| ------------------------------ | ------------------------------ | --------------------------------- | ------------------------------------------------------ | +| none | not called | the principal's context only | as before | +| `{ project: "p1" }` | the request | `{ project: "p1", actor }` | the rules decide, with `ctx.project === "p1"` | +| `{ project: "p1", debug: 1 }` | `{ project: "p1" }` (narrowed) | `{ project: "p1", actor }` | `debug` never reaches anything | +| `{ project: "p2" }` | `false` (no access) | none — no scope is opened | `VINE_DENIED`; the rules are never asked | +| `{ actor: { id: "root" } }` | the request | `{ actor }` — the principal's own | the principal key wins; the request cannot override it | +| `{ project: "p1" }` | — (no check configured) | none | `VINE_DENIED` — a request is never silently ignored | +| `{ cb() {} }` or `"p1"` | not called | none | `VINE_DATA_ONLY` | + +Three decisions worth knowing: + +- **No check, no context.** A serve without a `context` check refuses a frame that carries one (`VINE_DENIED`) rather than ignoring it, so a client never believes a call is scoped when it is not. A serve with a check advertises `context: true` on its `surface` frame, and a grow refuses locally (`VINE_DENIED`, nothing sent) to send one where it was not advertised — which also covers an older serve that would drop the unknown key and run the call unscoped. No `FRAME_VERSION` change: an older grow never sends the key and is answered exactly as before. +- **It is a request, not identity.** The check is where the host ties it to identity — "may this principal work in this project?" — and what it answers is all that reaches the scope. Rules that matter should still be written against the principal (`caller:`) and read the accepted keys in conditions. +- **Events carry no requested context in v1.** A `sub` frame has no `context` key; the level is resolved from the principal (and its context) alone, at subscribe and per emit. A subscription outlives any one call, so a per-call request has no natural meaning there; a client that needs per-project events subscribes to project-named events and lets conditional event rules check `ctx.actor`. + ### What the instance must provide — fail-closed at setup `serve()` / `grow()` throw a `TypeError` before publishing anything when a principal is given and the instance cannot enforce it: diff --git a/docs/TRANSPORTS.md b/docs/TRANSPORTS.md index 05fcb9f..d601291 100644 --- a/docs/TRANSPORTS.md +++ b/docs/TRANSPORTS.md @@ -182,7 +182,7 @@ const channel = await connect("ws://127.0.0.1:8710"); const link = await grow(hostApi, channel, { budgetMs: 5000 }); ``` -A websocket is the one built-in transport that routinely faces a peer the host did not spawn, and the vine's default serve answers every `call` frame with host standing. When the socket was authenticated at the upgrade (a cookie, a token, an `Origin` check — anything the page cannot forge), hand the resulting identity to `serve()` as its `principal`: `await serve(api, createChannel(socket), { paths: ["host", "project"], principal: { path: user.admin ? "remote.admin" : "remote.renderer", context: { actor: { id: user.id, roles: user.roles } } } })`. Every frame on that socket is then judged by slothlet's rules as that identity — one `serve()` per connection, one principal per socket — and a page script that builds `call` frames by hand gets exactly what the rules grant `remote.renderer`, no more. The transport authenticates; `serve()` binds. See [PERMISSIONS.md](PERMISSIONS.md#serving-to-an-untrusted-peer-the-channel-principal) for the rule shapes and the slothlet release the call half requires. +A websocket is the one built-in transport that routinely faces a peer the host did not spawn, and the vine's default serve answers every `call` frame with host standing. When the socket was authenticated at the upgrade (a cookie, a token, an `Origin` check — anything the page cannot forge), hand the resulting identity to `serve()` as its `principal`: `await serve(api, createChannel(socket), { paths: ["host", "project"], principal: { path: user.admin ? "remote.admin" : "remote.renderer", context: { actor: { id: user.id, roles: user.roles } } } })`. Every frame on that socket is then judged by slothlet's rules as that identity — one `serve()` per connection, one principal per socket — and a page script that builds `call` frames by hand gets exactly what the rules grant `remote.renderer`, no more. The transport authenticates; `serve()` binds. Per-call context the page chooses — the project a window has pinned — is not identity and does not belong in the principal: send it as a requested context (`link.with()` / `grow({ context })`) and accept or narrow it with `serve({ context })` against the bound principal; see [PERMISSIONS.md](PERMISSIONS.md#per-call-requested-context). See [PERMISSIONS.md](PERMISSIONS.md#serving-to-an-untrusted-peer-the-channel-principal) for the rule shapes and the slothlet release the call half requires. `ws` is an **optional peer dependency** — imported by nothing in the core, and only lazily by this module's own `connect()`. `createChannel(socket)` wraps a socket you already have (a live socket is itself proof `ws` is installed), so it needs no import; `connect(url)` is the one entry point that constructs a client socket, so it's the one that imports `ws`, and the one that throws a clear "install the optional peer dependency `ws`" error when it's missing. From c92143fc11be1905985a4330eb23baee32df42d8 Mon Sep 17 00:00:00 2001 From: Shinrai Date: Wed, 7 Oct 2026 00:22:20 -0700 Subject: [PATCH 7/9] feat: a dedicated VINE_CONTEXT code for a refused requested context, distinct from VINE_DENIED for a rules refusal (#79) The serve's context check refusing (a non-object answer, a throw or a rejection), a serve with no check, and the grow's local refusal when the far side does not accept requested contexts now answer VINE_CONTEXT. VINE_DENIED stays the principal/permission refusal, including for an accepted context the rules then refuse. --- docs/CONFIGURATION.md | 16 +++++----- docs/DESIGN.md | 11 +++---- docs/ERRORS.md | 23 +++++++------- docs/PERMISSIONS.md | 10 ++++--- src/grow.mjs | 4 +-- src/lib/errors.mjs | 14 ++++++--- src/serve.mjs | 19 ++++++------ tests/e2e-loopback-context.test.vitest.mjs | 35 ++++++++++++++-------- tests/errors.test.vitest.mjs | 17 ++++++++++- 9 files changed, 93 insertions(+), 56 deletions(-) diff --git a/docs/CONFIGURATION.md b/docs/CONFIGURATION.md index 4413ac5..2ff3a29 100644 --- a/docs/CONFIGURATION.md +++ b/docs/CONFIGURATION.md @@ -99,16 +99,16 @@ The host check for a per-call **requested context**: a data-only plain object th The check runs only for frames that carry one, **after** the principal is resolved and **before** the permission gate. It receives a private copy of the request and `{ principal, path, args }` (`principal` is `null` on a serve without one; `args` are the frame's arguments as a rule condition would see them), and may be async. -| The check answers | Outcome | -| ------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------- | -| a plain, data-only object — the request itself | **accept**: those keys go into the call's context scope | -| a different plain object (a subset, a canonicalized value) | **narrow**: only what it answered goes into the scope | -| `false`, `null`, `undefined`, `true`, an array, a class instance, a function-bearing object | **refuse**: [`VINE_DENIED`](ERRORS.md#vine_-codes); the gate is never asked and the leaf never runs | -| a throw or a rejection | **refuse**: `VINE_DENIED` — the host's own error stays serve-side; the message names the path only | +| The check answers | Outcome | +| ------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------- | +| a plain, data-only object — the request itself | **accept**: those keys go into the call's context scope | +| a different plain object (a subset, a canonicalized value) | **narrow**: only what it answered goes into the scope | +| `false`, `null`, `undefined`, `true`, an array, a class instance, a function-bearing object | **refuse**: [`VINE_CONTEXT`](ERRORS.md#vine_-codes); the gate is never asked and the leaf never runs | +| a throw or a rejection | **refuse**: `VINE_CONTEXT` — the host's own error stays serve-side; the message names the path only | The accepted keys are merged into the call's `context.scope` **under** the principal's — a principal key always wins, so a request can never override `actor` — and every key is write-protected (`CONTEXT_KEY_PROTECTED` on any write, nested fields included). `checkCall`, rule conditions, `requires`-principals, [`around`](#around) and the leaf all see it as plain `context.project`. On a serve without a principal, an accepted context still runs in such a scope; there is just no gate. -**No check configured, the default, refuses**: a frame that carries a requested context is answered `VINE_DENIED`, never run unscoped, so a client can never believe a call is scoped when it isn't. A serve with a check adds `context: true` to its `surface` frame; a grow refuses locally to send a requested context to a far side that did not advertise it (which also covers an older serve that would drop the key). A request that is not data — not a plain object, a function anywhere, a value structured clone refuses — is `VINE_DATA_ONLY`, checked before the host's check is called. +**No check configured, the default, refuses**: a frame that carries a requested context is answered `VINE_CONTEXT`, never run unscoped, so a client can never believe a call is scoped when it isn't. A serve with a check adds `context: true` to its `surface` frame; a grow refuses locally to send a requested context to a far side that did not advertise it (which also covers an older serve that would drop the key). A request that is not data — not a plain object, a function anywhere, a value structured clone refuses — is `VINE_DATA_ONLY`, checked before the host's check is called. `serve()` throws a `TypeError` when `context` is not a function, or when the instance's `context.scope` does not work (a `scope: false` instance). The permission system is **not** required unless a `principal` is also set. Like a principal, a context-accepting serve must run on the async (Node) runtime: under `runtime: "live"` concurrent scopes on one instance interleave. @@ -224,7 +224,7 @@ let pinned = null; // set by the UI const link = await grow(viewApi, channel, { context: () => (pinned ? { project: pinned } : null) }); ``` -The same data-only rules as arguments apply: a static value that is not a plain data object throws at `grow()` (`TypeError` for a non-object, `VINE_DATA_ONLY` for one that is not data); a resolver whose answer is not data rejects that call `VINE_DATA_ONLY`, and one that throws rejects it with its own error — nothing is sent either way. A call carrying a requested context to a far side that does not accept one (`link.context === false`) is refused locally with `VINE_DENIED` and nothing is sent. What the far side does with it is its [`context`](#context) check's decision. +The same data-only rules as arguments apply: a static value that is not a plain data object throws at `grow()` (`TypeError` for a non-object, `VINE_DATA_ONLY` for one that is not data); a resolver whose answer is not data rejects that call `VINE_DATA_ONLY`, and one that throws rejects it with its own error — nothing is sent either way. A call carrying a requested context to a far side that does not accept one (`link.context === false`) is refused locally with `VINE_CONTEXT` and nothing is sent. What the far side does with it is its [`context`](#context) check's decision. ### `link.with(context, fn, ...args)` diff --git a/docs/DESIGN.md b/docs/DESIGN.md index 49a2dc8..42fcc39 100644 --- a/docs/DESIGN.md +++ b/docs/DESIGN.md @@ -93,7 +93,7 @@ Semantics: - **serve** answers `call` frames by resolving the dotted path against the live api and invoking it. It re-validates that the path is within the served surface (never trust the wire). Thrown/rejected errors become `error` frames. It sends `surface` immediately on start, derived from the instance's leaf records filtered by `paths` and the hard `slothlet.**` exclusion. - **serve with a `principal`** judges every `call` frame as that identity before anything runs. The pipeline, in order: `VINE_NO_LEAF` (the surface filter is the outer boundary and is already public in the `surface` frame) → resolve the principal (a function form is called per frame; a throw or a non-principal answer denies the frame) → open `api.slothlet.context.scope({ context: principal.context ?? {}, protect: Object.keys(context), fn })` → `api.slothlet.permissions.global.checkCall(principal.path, path, args)` inside that scope, `VINE_DENIED` unless it answers exactly `true` → the args data-only check (`VINE_DATA_ONLY`) → invoke (through `around`, when one is given), still inside the scope → the return-value data-only check → `result`. The gate precedes the args scan because rule conditions are user code that receives `args`: an unauthorized principal's payload never reaches one. Without a `principal` the pipeline is the v1 one above, byte for byte — no gate, no scope, host standing. See [Channel principal (#33)](#channel-principal-33) under the implementation notes. -- **serve with a `context` check** (#79) judges a `call` frame's optional requested context: after the principal is resolved (when there is one) and before the gate, a request that is not data is `VINE_DATA_ONLY`, and the check must answer a plain, data-only object or the call is `VINE_DENIED`; the accepted keys are merged into the call's scope under the principal's keys (principal wins), every key write-protected, so the gate, rule conditions, `around` and the leaf all see them. Without a check, a frame carrying a requested context is `VINE_DENIED`. A frame without one is unaffected. See [Requested context (#79)](#requested-context-79). +- **serve with a `context` check** (#79) judges a `call` frame's optional requested context: after the principal is resolved (when there is one) and before the gate, a request that is not data is `VINE_DATA_ONLY`, and the check must answer a plain, data-only object or the call is `VINE_CONTEXT`; the accepted keys are merged into the call's scope under the principal's keys (principal wins), every key write-protected, so the gate, rule conditions, `around` and the leaf all see them. Without a check, a frame carrying a requested context is `VINE_CONTEXT`. A frame without one is unaffected. See [Requested context (#79)](#requested-context-79). - **serve with `around`** hands each accepted call to the host's wrapper in place of the bare invocation: `around({ callId, path, args, principal, context, invoke })` runs after the surface check, the principal gate (inside its scope) and the args data-only check, and its return value — not the leaf's — goes through the return-value data-only check and becomes the `result`; a throw becomes the `error`. `invoke()` is fixed to the gated path and to a private snapshot of the gated arguments: it takes no arguments, may be called more than once (a retried transaction), and each call receives a fresh structured clone of the snapshot, while `around`'s own `args` is a separate copy. The parsed frame's `args` is a fresh array whose elements are still shared, so without the snapshot a wrapper — or a bug in one — could mutate a nested argument after the rules engine judged it. See [CONFIGURATION.md → `around`](CONFIGURATION.md#around). - **grow** awaits the `surface` frame (subject to `handshakeMs`), then mounts one async stub per leaf at the identical dotted path via slothlet's synthetic in-memory add (`api.slothlet.api.add(path, stubFn, { moduleID })`, one shared link moduleID) so **slothlet's own permission system gates stub calls exactly like real leaves**. `link.close()` removes the mounts (`api.slothlet.api.remove(moduleID)`) and settles all pending calls with `VINE_CLOSED`. - A **channel is directional**: one serve end, one grow end. Bidirectional forwarding = two channels (transports that have paired endpoints expose pairs). @@ -101,7 +101,7 @@ Semantics: - **Budget**: each grow-side call arms a timer (`budgetMs`); expiry settles that call with `VINE_BUDGET` (the late result frame, if it arrives, is ignored — settle-once). - **Settle-once** everywhere: a callId settles exactly once (result | error | budget | gone | closed); later frames for it are dropped. -Error codes (all `VineError` subclasses carrying `.code`): `VINE_GONE`, `VINE_BUDGET`, `VINE_CLOSED`, `VINE_DATA_ONLY`, `VINE_BAD_FRAME`, `VINE_NO_LEAF` (call for a path not in the served surface), `VINE_DENIED` (the channel principal may not call this path, or a per-call requested context was refused), `VINE_REMOTE`. Remote application errors re-throw as `VineRemoteError` (their own name/message/code) — except a `VINE_*` code, which is never adopted from the wire; see [Errors](#errors) below. +Error codes (all `VineError` subclasses carrying `.code`): `VINE_GONE`, `VINE_BUDGET`, `VINE_CLOSED`, `VINE_DATA_ONLY`, `VINE_BAD_FRAME`, `VINE_NO_LEAF` (call for a path not in the served surface), `VINE_DENIED` (the channel principal may not call this path — produced only by a serve that bound a `principal`), `VINE_CONTEXT` (a per-call requested context was refused), `VINE_REMOTE`. Remote application errors re-throw as `VineRemoteError` (their own name/message/code) — except a `VINE_*` code, which is never adopted from the wire; see [Errors](#errors) below. ## Event forwarding (schema v1) @@ -257,10 +257,11 @@ The serve side answers `call` frames with host standing: `invoke()` walks the bo Identity is the channel's; some context is the call's. A client that works in project A in one window and project B in another needs every call to run server-side in a scope holding its project, without the server holding pin state per connection (which races in-flight calls when it changes) and without a `project` parameter threaded through every leaf. So a `call` frame may carry an optional `context` — a **requested** context, data only — and the host decides what of it to accept. - **Grow side.** `link.with(context, fn, ...args)` runs `fn` so that every call it makes over that link carries `context`; the extent rides the grow instance's own `context.scope()` under a link-private key holding a function (slothlet shares callables by reference across scopes and deep-clones the rest), and only holders the link minted are honoured. The innermost extent wins; `null` clears. `grow({ context })` is the link default outside any extent — an object, or a function read synchronously per call (what a window has pinned right now). Every requested context is checked with the argument rules plus a shape (`findContextFault`: a plain object, no function anywhere, structured-cloneable) and copied; a violation is `VINE_DATA_ONLY` before anything is sent. -- **Serve side.** `serve({ context: (requested, { principal, path, args }) => accepted })`. It runs only for frames that carry one, after the principal is resolved and before the gate, on a private copy, and may be async. A plain, data-only answer is accepted (the request itself, or a narrowed or changed object); anything else — `false`, `null`, `true`, junk, a throw, a rejection — is `VINE_DENIED`, whose message names the path only. The accepted object is copied, merged as `{ ...accepted, ...principal.context }` (a principal key always wins) and every key goes into `protect`. Without a principal the call still runs in a scope holding the accepted keys; `serve({ context })` therefore probes `context.scope` at setup (a `TypeError` on a `scope: false` instance) but does not need the permission system. -- **No check configured: refuse.** A frame carrying a requested context to a serve without a check is `VINE_DENIED`, not run unscoped. Ignoring it was the alternative; refusing means a client can never believe it is scoped when it is not, and a host that wants requests ignored can say so explicitly with `context: () => ({})`. -- **Interop, no `FRAME_VERSION` change.** A serve with a check adds `context: true` to its `surface`; a grow sends a requested context only to a far side that advertised it and otherwise refuses the call locally with `VINE_DENIED` (`link.context` reports which). Old grow → new serve: an old grow never sends the key, so every call is answered as before and the check is never consulted. New grow → old serve: the old surface lacks the flag, so the new grow refuses locally rather than letting the old serve's `parseFrame` drop the unknown key and run the call unscoped; plain calls are byte for byte the v1 frame. Hand-built frames with a `context` reaching an old serve are ignored by it — the host must not rely on a requested context against a serve that predates this. +- **Serve side.** `serve({ context: (requested, { principal, path, args }) => accepted })`. It runs only for frames that carry one, after the principal is resolved and before the gate, on a private copy, and may be async. A plain, data-only answer is accepted (the request itself, or a narrowed or changed object); anything else — `false`, `null`, `true`, junk, a throw, a rejection — is `VINE_CONTEXT`, whose message names the path only. The accepted object is copied, merged as `{ ...accepted, ...principal.context }` (a principal key always wins) and every key goes into `protect`. Without a principal the call still runs in a scope holding the accepted keys; `serve({ context })` therefore probes `context.scope` at setup (a `TypeError` on a `scope: false` instance) but does not need the permission system. +- **No check configured: refuse.** A frame carrying a requested context to a serve without a check is `VINE_CONTEXT`, not run unscoped. Ignoring it was the alternative; refusing means a client can never believe it is scoped when it is not, and a host that wants requests ignored can say so explicitly with `context: () => ({})`. +- **Interop, no `FRAME_VERSION` change.** A serve with a check adds `context: true` to its `surface`; a grow sends a requested context only to a far side that advertised it and otherwise refuses the call locally with `VINE_CONTEXT` (`link.context` reports which). Old grow → new serve: an old grow never sends the key, so every call is answered as before and the check is never consulted. New grow → old serve: the old surface lacks the flag, so the new grow refuses locally rather than letting the old serve's `parseFrame` drop the unknown key and run the call unscoped; plain calls are byte for byte the v1 frame. Hand-built frames with a `context` reaching an old serve are ignored by it — the host must not rely on a requested context against a serve that predates this. - **Events (v1 decision): no requested context on subscriptions.** A `sub` frame has no `context` key and a subscription's level is resolved from the principal alone, at subscribe and per emit. A subscription outlives any one call, so a per-call request has no meaning there; per-project events are expressed as project-named events plus conditional event rules over the principal's context. +- **`VINE_CONTEXT`, not `VINE_DENIED`.** A refused request has its own code so a client can tell "this context is not yours" (unpin it) from "the rules refused this call"; an accepted context that the gate then refuses is `VINE_DENIED` as before. Like every remote `VINE_*` code it arrives as a `VineRemoteError` with `.code === "VINE_REMOTE"` and `.remoteCode === "VINE_CONTEXT"`; the grow's local refusal is a plain `VineError` with `.code === "VINE_CONTEXT"`. - **Runtime caveat.** As for a principal: a context-accepting serve belongs on the async (Node) runtime, where concurrent scopes on one instance do not interleave. ### Security notes (v1 limits, deliberate) diff --git a/docs/ERRORS.md b/docs/ERRORS.md index e5cee28..d508725 100644 --- a/docs/ERRORS.md +++ b/docs/ERRORS.md @@ -10,16 +10,17 @@ import { CODES, VineError, VineRemoteError } from "@cldmv/slothlet-vine"; ## `VINE_*` codes -| Code | Fires when | -| ---------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `VINE_GONE` | The far side died — its channel closed (or a transport-detected death fired) with the call still in flight. | -| `VINE_BUDGET` | The per-call settle budget (`budgetMs`) elapsed before a terminal frame arrived. Also fires for a `grow()` handshake that never sees a `surface` frame within `handshakeMs`. | -| `VINE_CLOSED` | `link.close()` was called with the call still in flight, or a call was made after `close()`. | -| `VINE_DATA_ONLY` | A function-valued argument or return value was found in the data graph — the vine is data-only in v1. Also a per-call **requested context** that is not data (not a plain object, a function anywhere, or a value structured clone refuses) — grow-side from `link.with()` / `grow({ context })` before anything is sent, serve-side for a hand-built frame. | -| `VINE_BAD_FRAME` | A frame couldn't be parsed, or an outbound frame couldn't be handed to the transport (the medium refused it — see [TRANSPORTS.md](TRANSPORTS.md)). | -| `VINE_NO_LEAF` | A call named a path that isn't in the served surface. Re-validated serve-side on **every** call — a path learned from an earlier, wider surface (or invented) can't reach a leaf this serve doesn't publish. | -| `VINE_DENIED` | The serving side's **channel principal** may not call this path — slothlet's rules said no (or could not be asked), and the leaf never ran. Produced by a `serve()` that bound a `principal` (see [PERMISSIONS.md](PERMISSIONS.md#serving-to-an-untrusted-peer-the-channel-principal)). Also a per-call **requested context** that was refused — by the serve's `context` check, or because the serve has none (see [PERMISSIONS.md](PERMISSIONS.md#per-call-requested-context)); a trusted-transport serve answers it only for that. A grow produces it **locally** (a plain `VineError`, nothing sent) for a call carrying a requested context to a far side that does not accept one (`link.context === false`). The message names the path only, never the principal. | -| `VINE_REMOTE` | `.code` on a `VineRemoteError` for a remote application error that carried no `code` of its own — or one whose code was a reserved `VINE_*` value (see below). | +| Code | Fires when | +| ---------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `VINE_GONE` | The far side died — its channel closed (or a transport-detected death fired) with the call still in flight. | +| `VINE_BUDGET` | The per-call settle budget (`budgetMs`) elapsed before a terminal frame arrived. Also fires for a `grow()` handshake that never sees a `surface` frame within `handshakeMs`. | +| `VINE_CLOSED` | `link.close()` was called with the call still in flight, or a call was made after `close()`. | +| `VINE_DATA_ONLY` | A function-valued argument or return value was found in the data graph — the vine is data-only in v1. Also a per-call **requested context** that is not data (not a plain object, a function anywhere, or a value structured clone refuses) — grow-side from `link.with()` / `grow({ context })` before anything is sent, serve-side for a hand-built frame. | +| `VINE_BAD_FRAME` | A frame couldn't be parsed, or an outbound frame couldn't be handed to the transport (the medium refused it — see [TRANSPORTS.md](TRANSPORTS.md)). | +| `VINE_NO_LEAF` | A call named a path that isn't in the served surface. Re-validated serve-side on **every** call — a path learned from an earlier, wider surface (or invented) can't reach a leaf this serve doesn't publish. | +| `VINE_DENIED` | The serving side's **channel principal** may not call this path — slothlet's rules said no (or could not be asked), and the leaf never ran. Produced only by a `serve()` that bound a `principal` (see [PERMISSIONS.md](PERMISSIONS.md#serving-to-an-untrusted-peer-the-channel-principal)); a trusted-transport serve never answers it. The message names the path only, never the principal. | +| `VINE_CONTEXT` | A per-call **requested context** was refused and the leaf never ran: the serve's `context` check answered something other than a plain data object, threw or rejected, or the serve has no check (see [PERMISSIONS.md](PERMISSIONS.md#per-call-requested-context)). A grow also produces it **locally** (a plain `VineError`, nothing sent) for a call carrying a requested context to a far side that does not accept one (`link.context === false`). Distinct from `VINE_DENIED` so a client can tell "this context is not yours" from "the rules refused this call"; an accepted context that the rules then refuse is `VINE_DENIED`. The message names the path only. | +| `VINE_REMOTE` | `.code` on a `VineRemoteError` for a remote application error that carried no `code` of its own — or one whose code was a reserved `VINE_*` value (see below). | Every one of these is a `VineError` instance, so `err instanceof VineError` is the general "this came from the vine itself" check, and `err.code === CODES.GONE` (etc.) is how you branch on which one. @@ -83,7 +84,7 @@ try { Without that remap, a far side — hostile, or merely running its own vine and forwarding a failure verbatim — could send `{ name: "VineError", code: "VINE_CLOSED" }` and the resulting local error would satisfy `err instanceof VineError && err.code === CODES.CLOSED`, the documented way to detect _this_ link closing, and drive a consumer's teardown path from **across the boundary**. A link-state code describes this link's own state; it can only ever be produced locally. -The remap is blind to which `VINE_*` code arrived — including ones the far side's own `serve()` legitimately produced, like `VINE_NO_LEAF`, `VINE_DATA_ONLY` or `VINE_DENIED`. So a call the serving side's channel principal was not permitted to make surfaces grow-side as `err instanceof VineRemoteError && err.code === "VINE_REMOTE" && err.remoteCode === "VINE_DENIED"` — deliberately, because "this link is denied by the far side" is a statement about the far side, not about this link's state. Read `.remoteCode` to see what the far side actually said; read `.code` to know it came from over there: +The remap is blind to which `VINE_*` code arrived — including ones the far side's own `serve()` legitimately produced, like `VINE_NO_LEAF`, `VINE_DATA_ONLY`, `VINE_DENIED` or `VINE_CONTEXT`. So a call the serving side's channel principal was not permitted to make surfaces grow-side as `err instanceof VineRemoteError && err.code === "VINE_REMOTE" && err.remoteCode === "VINE_DENIED"` — deliberately, because "this link is denied by the far side" is a statement about the far side, not about this link's state. Read `.remoteCode` to see what the far side actually said; read `.code` to know it came from over there: ```javascript try { diff --git a/docs/PERMISSIONS.md b/docs/PERMISSIONS.md index f7d4e50..0c72880 100644 --- a/docs/PERMISSIONS.md +++ b/docs/PERMISSIONS.md @@ -158,21 +158,23 @@ await link.with({ project: "p1" }, () => viewApi.project.files.list()); // every const link2 = await grow(viewApi, channel, { context: () => ({ project: pinned }) }); // or per call, from whatever is pinned ``` -The pipeline for a frame that carries one, with a principal: `VINE_NO_LEAF` → resolve the principal → `VINE_DATA_ONLY` if the request is not data (a plain object with no function anywhere that survives a structured clone — the argument rules plus a shape) → the host's `context` check, `VINE_DENIED` unless it answers a plain, data-only object → open the scope with the accepted keys merged **under** the principal's, every key write-protected → the gate (`checkCall`) → the args scan → invoke → `result`. Without a principal the same, minus the gate. A frame without a requested context is untouched by all of this. +The pipeline for a frame that carries one, with a principal: `VINE_NO_LEAF` → resolve the principal → `VINE_DATA_ONLY` if the request is not data (a plain object with no function anywhere that survives a structured clone — the argument rules plus a shape) → the host's `context` check, `VINE_CONTEXT` unless it answers a plain, data-only object → open the scope with the accepted keys merged **under** the principal's, every key write-protected → the gate (`checkCall`) → the args scan → invoke → `result`. Without a principal the same, minus the gate. A frame without a requested context is untouched by all of this. | requested context on the frame | `context` check answers | scope the gate and leaf see | net | | ------------------------------ | ------------------------------ | --------------------------------- | ------------------------------------------------------ | | none | not called | the principal's context only | as before | | `{ project: "p1" }` | the request | `{ project: "p1", actor }` | the rules decide, with `ctx.project === "p1"` | | `{ project: "p1", debug: 1 }` | `{ project: "p1" }` (narrowed) | `{ project: "p1", actor }` | `debug` never reaches anything | -| `{ project: "p2" }` | `false` (no access) | none — no scope is opened | `VINE_DENIED`; the rules are never asked | +| `{ project: "p2" }` | `false` (no access) | none — no scope is opened | `VINE_CONTEXT`; the rules are never asked | | `{ actor: { id: "root" } }` | the request | `{ actor }` — the principal's own | the principal key wins; the request cannot override it | -| `{ project: "p1" }` | — (no check configured) | none | `VINE_DENIED` — a request is never silently ignored | +| `{ project: "p1" }` | — (no check configured) | none | `VINE_CONTEXT` — a request is never silently ignored | | `{ cb() {} }` or `"p1"` | not called | none | `VINE_DATA_ONLY` | +A refused request is `VINE_CONTEXT`, deliberately distinct from `VINE_DENIED`: a client can tell "this context is not yours" (unpin the project) from "the rules say you may not call this". An accepted context that the rules then refuse is still `VINE_DENIED`. + Three decisions worth knowing: -- **No check, no context.** A serve without a `context` check refuses a frame that carries one (`VINE_DENIED`) rather than ignoring it, so a client never believes a call is scoped when it is not. A serve with a check advertises `context: true` on its `surface` frame, and a grow refuses locally (`VINE_DENIED`, nothing sent) to send one where it was not advertised — which also covers an older serve that would drop the unknown key and run the call unscoped. No `FRAME_VERSION` change: an older grow never sends the key and is answered exactly as before. +- **No check, no context.** A serve without a `context` check refuses a frame that carries one (`VINE_CONTEXT`) rather than ignoring it, so a client never believes a call is scoped when it is not. A serve with a check advertises `context: true` on its `surface` frame, and a grow refuses locally (`VINE_CONTEXT`, nothing sent) to send one where it was not advertised — which also covers an older serve that would drop the unknown key and run the call unscoped. No `FRAME_VERSION` change: an older grow never sends the key and is answered exactly as before. - **It is a request, not identity.** The check is where the host ties it to identity — "may this principal work in this project?" — and what it answers is all that reaches the scope. Rules that matter should still be written against the principal (`caller:`) and read the accepted keys in conditions. - **Events carry no requested context in v1.** A `sub` frame has no `context` key; the level is resolved from the principal (and its context) alone, at subscribe and per emit. A subscription outlives any one call, so a per-call request has no natural meaning there; a client that needs per-project events subscribes to project-named events and lets conditional event rules check `ctx.actor`. diff --git a/src/grow.mjs b/src/grow.mjs index db253ae..3238946 100644 --- a/src/grow.mjs +++ b/src/grow.mjs @@ -62,7 +62,7 @@ export const DEFAULT_BUDGET_MS = 30_000; * called synchronously per call that answers one (or `null` / `undefined` for none) — the way a * client sends whatever it currently has pinned without the far side holding any state. A request, * never identity: the far side's `serve({ context })` check accepts, narrows or refuses it. A call - * carrying one is refused locally (`VINE_DENIED`, nothing sent) when the far side did not advertise + * carrying one is refused locally (`VINE_CONTEXT`, nothing sent) when the far side did not advertise * that it accepts requested contexts; a resolver that throws rejects that call with its error, and * an answer that is not data rejects it `VINE_DATA_ONLY`. * @returns {Promise<{ id: string, leaves: string[], skipped: string[], collisions: string[], context: boolean, with: Function, close: () => Promise, closed: Promise<{reason: string, info?: object}> }>} @@ -361,7 +361,7 @@ export async function grow(api, channel, options = {}) { const requested = requestedContext(path); if (requested !== null && !acceptsContext) { throw new VineError( - CODES.DENIED, + CODES.CONTEXT, `slothlet-vine: '${path}' was called with a requested context, but the far side does not accept one`, { path } ); diff --git a/src/lib/errors.mjs b/src/lib/errors.mjs index 31d08f3..c88e897 100644 --- a/src/lib/errors.mjs +++ b/src/lib/errors.mjs @@ -33,12 +33,18 @@ export const CODES = Object.freeze({ NO_LEAF: "VINE_NO_LEAF", /** * The serving side's channel principal is not permitted to call this path — slothlet's rules said - * no (or could not be asked), and the leaf was never invoked. Produced by a `serve()` that bound a - * `principal`, and for a per-call requested context (#79) that was refused: by the serve's - * `context` check or because it has none — and, locally by a grow, before sending one to a far - * side that does not accept it. + * no (or could not be asked), and the leaf was never invoked. Produced ONLY by a `serve()` that + * bound a `principal`, for a rules refusal; a refused requested context is {@link CODES.CONTEXT}. */ DENIED: "VINE_DENIED", + /** + * A per-call requested context (#79) was refused, and the leaf was never invoked: the serve's + * `context` check answered something other than a plain data object, threw or rejected, or the + * serve has no check at all — or, produced LOCALLY by a grow with nothing sent, the far side never + * advertised that it accepts a requested context. Distinct from `VINE_DENIED` (the rules refused) + * so a client can tell "this context is not yours" (unpin it) from "you may not call this". + */ + CONTEXT: "VINE_CONTEXT", /** * `.code` for a remote application error that carried none of its own — and for one whose code * was in the reserved `VINE_*` namespace, which is never adopted from the wire (the far side's diff --git a/src/serve.mjs b/src/serve.mjs index c5b8402..298e75d 100644 --- a/src/serve.mjs +++ b/src/serve.mjs @@ -70,12 +70,12 @@ import { assertPrincipalSupport, assertScopeSupport, bindPrincipal, gate } from * the permission gate, with a private copy of the request, the resolved principal (`null` without * one), the path and the frame's arguments. It ACCEPTS by answering a plain object — the request * itself, or a narrowed or changed one — and REFUSES by answering anything else (`false`, `null`, a - * non-plain or function-bearing object) or by throwing/rejecting; a refusal is `VINE_DENIED` and + * non-plain or function-bearing object) or by throwing/rejecting; a refusal is `VINE_CONTEXT` and * the leaf never runs. May be async. The accepted keys are merged into the call's context scope * UNDER the principal's (a principal key always wins) and every key is write-protected, so * `checkCall`, rule conditions, `requires`-principals, `around` and the leaf all see it and none * can rewrite it. **Without this option a frame that carries a requested context is refused** - * (`VINE_DENIED`), so a client is never silently unscoped; with it, the `surface` frame advertises + * (`VINE_CONTEXT`), so a client is never silently unscoped; with it, the `surface` frame advertises * `context: true`, which a grow needs before it will send one. Requires a working * `api.slothlet.context.scope` (checked at setup as a `TypeError`); a principal is NOT required. * @param {(call: { callId: string, path: string, args: unknown[], principal: Readonly<{ path: string, context?: object }>|null, context: object|null, invoke: () => Promise }) => unknown} [options.around] - @@ -286,8 +286,8 @@ export async function serve(api, channel, options = {}) { * @param {unknown[]} args - The call's arguments. * @param {unknown} requested - The frame's requested context, or `undefined` when it carried none. * @returns {Promise} The leaf's resolved value. - * @throws {VineError} `VINE_DENIED` when the principal may not call `path` (or could not be judged), - * or its requested context was refused; `VINE_DATA_ONLY` for a requested context that is not data. + * @throws {VineError} `VINE_DENIED` when the principal may not call `path` (or could not be judged); + * `VINE_CONTEXT` when its requested context was refused; `VINE_DATA_ONLY` for one that is not data. */ async function answerAs(callId, path, args, requested) { const who = principal.resolve(); @@ -310,7 +310,8 @@ export async function serve(api, channel, options = {}) { * @param {unknown[]} args - The call's arguments. * @param {unknown} requested - The frame's requested context. * @returns {Promise} The leaf's resolved value. - * @throws {VineError} `VINE_DENIED` / `VINE_DATA_ONLY` as for {@link accept}. + * @throws {VineError} `VINE_CONTEXT` / `VINE_DATA_ONLY` as for {@link accept}; `VINE_DENIED` when + * the scope cannot be opened. */ async function answerScoped(callId, path, args, requested) { const accepted = await accept(requested, null, path, args); @@ -354,16 +355,16 @@ export async function serve(api, channel, options = {}) { /** * Judge one requested context (#79). In order: it must be data (`VINE_DATA_ONLY` — the same rules * as a call's arguments, plus a plain-object shape); a serve without a `context` check refuses it - * (`VINE_DENIED` — a client must never believe a call is scoped when it is not); the host's check + * (`VINE_CONTEXT` — a client must never believe a call is scoped when it is not); the host's check * gets a private copy and must answer a plain, data-only object, anything else — `false`, `null`, a - * throw, a rejection, junk — being a refusal (`VINE_DENIED`). The accepted answer is copied, so the + * throw, a rejection, junk — being a refusal (`VINE_CONTEXT`). The accepted answer is copied, so the * host's own object is never shared with the scope. * @param {unknown} requested - The frame's requested context (untrusted). * @param {Readonly<{ path: string, context?: object }>|null} who - The resolved principal, or `null`. * @param {string} path - The served leaf path. * @param {unknown[]} args - The call's arguments (as a rule condition would receive them). * @returns {Promise} The accepted context. - * @throws {VineError} `VINE_DATA_ONLY` or `VINE_DENIED`. + * @throws {VineError} `VINE_DATA_ONLY` or `VINE_CONTEXT`. */ async function accept(requested, who, path, args) { const fault = findContextFault(requested); @@ -374,7 +375,7 @@ export async function serve(api, channel, options = {}) { { path, location: fault } ); } - const refused = new VineError(CODES.DENIED, `slothlet-vine: the requested context for '${path}' is not accepted on this channel`, { + const refused = new VineError(CODES.CONTEXT, `slothlet-vine: the requested context for '${path}' is not accepted on this channel`, { path, principal: who ? who.path : null }); diff --git a/tests/e2e-loopback-context.test.vitest.mjs b/tests/e2e-loopback-context.test.vitest.mjs index e284262..d05e79a 100644 --- a/tests/e2e-loopback-context.test.vitest.mjs +++ b/tests/e2e-loopback-context.test.vitest.mjs @@ -177,7 +177,7 @@ describe("requested context — accept, narrow, refuse (loopback, no principal)" expect(await link.with({ project: "a" }, () => growApi.work.read())).toMatchObject({ project: "A" }); }); - it("refuse: false, null, a throw, a rejection, or junk are all VINE_DENIED — and the leaf never runs", async () => { + it("refuse: false, null, a throw, a rejection, or junk are all VINE_CONTEXT — and the leaf never runs", async () => { const answers = [ () => false, () => null, @@ -195,7 +195,7 @@ describe("requested context — accept, narrow, refuse (loopback, no principal)" ]; for (const answer of answers) { const { serveApi, growApi, link } = await linked({ context: answer }); - expect(await remoteCode(link.with({ project: "A" }, () => growApi.work.read()))).toBe(CODES.DENIED); + expect(await remoteCode(link.with({ project: "A" }, () => growApi.work.read()))).toBe(CODES.CONTEXT); expect(await serveApi.work.count()).toBe(0); } }); @@ -288,26 +288,37 @@ describe("requested context — merged with the channel principal", () => { expect(await serveApi.work.count()).toBe(1); }); + it("on one link: a refused context is VINE_CONTEXT, an accepted one the rules refuse is VINE_DENIED", async () => { + const { growApi, link } = await linked( + { principal: RENDERER, context: (requested) => (requested.project === "X" ? false : requested) }, + {}, + { permissions: PERMISSIONS } + ); + expect(await remoteCode(link.with({ project: "X" }, () => growApi.work.read()))).toBe(CODES.CONTEXT); + expect(await remoteCode(link.with({ project: "B" }, () => growApi.work.read()))).toBe(CODES.DENIED); + expect(await link.with({ project: "A" }, () => growApi.work.read())).toMatchObject({ project: "A" }); + }); + it("a principal key always wins over a requested one of the same name", async () => { const { growApi, link } = await linked({ principal: RENDERER, context: acceptAll }, {}, { permissions: PERMISSIONS }); const got = await link.with({ project: "A", actor: { id: "mallory" } }, () => growApi.work.read()); expect(got.actor).toEqual({ id: "u42" }); }); - it("a refused context is VINE_DENIED before the gate — the rules are never asked", async () => { + it("a refused context is VINE_CONTEXT before the gate — the rules are never asked", async () => { // An explicit deny audits `permission:denied` whenever the gate is asked — so its absence proves // the gate never was. The control serve (accepting check) shows the audit does fire. const permissions = { defaultPolicy: "deny", rules: [{ caller: "remote.renderer", target: "work.read", effect: "deny" }] }; - const audited = async (check) => { + const audited = async (check, code) => { const { serveApi, growApi, link } = await linked({ principal: RENDERER, context: check }, {}, { permissions }); const audits = []; serveApi.slothlet.lifecycle.on("permission:denied", (payload) => audits.push(payload)); - expect(await remoteCode(link.with({ project: "A" }, () => growApi.work.read()))).toBe(CODES.DENIED); + expect(await remoteCode(link.with({ project: "A" }, () => growApi.work.read()))).toBe(code); await new Promise((resolve) => setTimeout(resolve, 20)); return audits.filter((d) => d.caller === "remote.renderer" && d.target === "work.read"); }; - expect(await audited(acceptAll)).not.toEqual([]); - expect(await audited(() => false)).toEqual([]); + expect(await audited(acceptAll, CODES.DENIED)).not.toEqual([]); + expect(await audited(() => false, CODES.CONTEXT)).toEqual([]); }); it("accepted keys are protected alongside the principal's", async () => { @@ -374,13 +385,13 @@ describe("requested context — no check configured", () => { const err = await link.with({ project: "A" }, () => growApi.work.read()).catch((e) => e); expect(err).toBeInstanceOf(VineError); expect(err).not.toBeInstanceOf(VineRemoteError); - expect(err.code).toBe(CODES.DENIED); + expect(err.code).toBe(CODES.CONTEXT); expect(sent.filter((frame) => frame.type === "call")).toEqual([]); // A call with no requested context works as ever. expect(await growApi.work.read()).toMatchObject({ project: null }); }); - it("a hand-built frame carrying one is refused serve-side (VINE_DENIED) — never silently ignored", async () => { + it("a hand-built frame carrying one is refused serve-side (VINE_CONTEXT) — never silently ignored", async () => { const serveApi = await instance({ base: CONTEXT_DIR }); const [near, far] = createPair(); const peer = rawPeer(near); @@ -388,7 +399,7 @@ describe("requested context — no check configured", () => { teardown.push(() => serving.close()); const surface = await peer.surface(); expect(surface).not.toHaveProperty("context"); - expect((await peer.call("work.read", [], { context: { project: "A" } })).error.code).toBe(CODES.DENIED); + expect((await peer.call("work.read", [], { context: { project: "A" } })).error.code).toBe(CODES.CONTEXT); expect(await serveApi.work.count()).toBe(0); expect((await peer.call("work.read")).value).toEqual({ project: null, actor: null, extra: null }); }); @@ -543,7 +554,7 @@ describe("requested context — old/new interop (no FRAME_VERSION change)", () = const link = await grow(growApi, near, { budgetMs: 2000 }); teardown.push(() => link.close()); expect(link.context).toBe(false); - await expect(link.with({ project: "A" }, () => growApi.work.read())).rejects.toMatchObject({ code: CODES.DENIED }); + await expect(link.with({ project: "A" }, () => growApi.work.read())).rejects.toMatchObject({ code: CODES.CONTEXT }); expect(oldServe.frames.filter((frame) => frame.type === "call")).toEqual([]); // A plain call's frame carries no `context` key at all — byte for byte the v1 frame. const pending = growApi.work.read(); @@ -611,7 +622,7 @@ describe("requested context over websocket", () => { teardown.push(() => link.close()); expect(link.context).toBe(true); expect(await link.with({ project: "A", extra: 1 }, () => growApi.work.read())).toEqual({ project: "A", actor: null, extra: null }); - expect(await remoteCode(link.with({ project: "B" }, () => growApi.work.read()))).toBe(CODES.DENIED); + expect(await remoteCode(link.with({ project: "B" }, () => growApi.work.read()))).toBe(CODES.CONTEXT); expect(await growApi.work.read()).toMatchObject({ project: null }); }); }); diff --git a/tests/errors.test.vitest.mjs b/tests/errors.test.vitest.mjs index aa9e8fb..7e3bdea 100644 --- a/tests/errors.test.vitest.mjs +++ b/tests/errors.test.vitest.mjs @@ -20,9 +20,24 @@ describe("CODES", () => { it("carries every code the design names, frozen", () => { expect(Object.isFrozen(CODES)).toBe(true); expect(Object.values(CODES)).toEqual( - expect.arrayContaining(["VINE_GONE", "VINE_BUDGET", "VINE_CLOSED", "VINE_DATA_ONLY", "VINE_BAD_FRAME", "VINE_NO_LEAF"]) + expect.arrayContaining([ + "VINE_GONE", + "VINE_BUDGET", + "VINE_CLOSED", + "VINE_DATA_ONLY", + "VINE_BAD_FRAME", + "VINE_NO_LEAF", + "VINE_DENIED", + "VINE_CONTEXT", + "VINE_REMOTE" + ]) ); }); + + it("keeps a refused requested context (VINE_CONTEXT, #79) distinct from a rules refusal (VINE_DENIED)", () => { + expect(CODES.CONTEXT).toBe("VINE_CONTEXT"); + expect(CODES.CONTEXT).not.toBe(CODES.DENIED); + }); }); describe("VineError", () => { From 860d0188c31cafb7779d4ad4f8fa21da36248d71 Mon Sep 17 00:00:00 2001 From: "cldmv-bot[bot]" <230771808+cldmv-bot[bot]@users.noreply.github.com> Date: Thu, 8 Oct 2026 23:22:05 +0000 Subject: [PATCH 8/9] chore: bump version to 1.3.0 --- package-lock.json | 4 ++-- package.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index f2082c4..75067d6 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@cldmv/slothlet-vine", - "version": "1.2.3", + "version": "1.3.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@cldmv/slothlet-vine", - "version": "1.2.3", + "version": "1.3.0", "license": "Apache-2.0", "devDependencies": { "@cldmv/configs": "^1.2.4", diff --git a/package.json b/package.json index d36ffad..7f1661f 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@cldmv/slothlet-vine", - "version": "1.2.3", + "version": "1.3.0", "description": "Vines between slothlet api trees — location-transparent forwarding leaves over pluggable transports (Web Worker postMessage, worker_threads, process IPC, WebSocket), permission-gated by slothlet itself.", "type": "module", "license": "Apache-2.0", From b49f53f62d357a1965b28e3e33984bec2fa04c4b Mon Sep 17 00:00:00 2001 From: Shinrai Date: Thu, 8 Oct 2026 16:38:00 -0700 Subject: [PATCH 9/9] docs: add the v1.3.0 release notes and README What's New --- README.md | 9 +-- docs/changelog/v1/v1.3.0.md | 114 ++++++++++++++++++++++++++++++++++++ 2 files changed, 119 insertions(+), 4 deletions(-) create mode 100644 docs/changelog/v1/v1.3.0.md diff --git a/README.md b/README.md index f6ee9e0..3f00df7 100644 --- a/README.md +++ b/README.md @@ -16,17 +16,18 @@ That gating follows slothlet's own rule about **who is calling**: a call made by ## ✨ What's New -### Latest: v1.2.2 (October 2026) +### Latest: v1.3.0 (October 2026) -- **Dev-tooling dependency bump ([#72](https://github.com/CLDMV/slothlet-vine/pull/72), [#75](https://github.com/CLDMV/slothlet-vine/pull/75))** — `@cldmv/fix-headers` moves from 2.1.1 to 2.2.0, so `@Last modified by` now follows content edits only and a header-only rewrite keeps the recorded editor, and `@cldmv/configs` moves from 1.2.0 to 1.2.4, whose shared config no longer forces the author fields. Neither changed any file header here. No runtime source changed and the peer ranges are the same as in v1.2.1 (`@cldmv/slothlet >=3.22.0`, optional `ws >=8.0.0`); it's a drop-in replacement for v1.2.1. -- [View full v1.2.2 Changelog](https://github.com/CLDMV/slothlet-vine/blob/master/docs/changelog/v1/v1.2.2.md) +- **Per-call requested context ([#80](https://github.com/CLDMV/slothlet-vine/pull/80))** — `link.with(context, fn)` and `grow({ context })` attach a data-only context to individual calls, such as the project a browser window has open. A new `serve({ context })` host check accepts, narrows or refuses it (a refusal is the new `VINE_CONTEXT` code), and whatever it accepts joins the call's scope under the principal's keys, write-protected, with no per-connection state on the server. +- **Opt-in and wire compatible** — a serve without a `context` check and a grow that sets none behave exactly as in v1.2.x; the frame version and the `@cldmv/slothlet >=3.22.0` peer range are unchanged. A serve with no check refuses a frame that carries a requested context instead of ignoring it. +- [View full v1.3.0 Changelog](https://github.com/CLDMV/slothlet-vine/blob/master/docs/changelog/v1/v1.3.0.md) ### Recent Releases +- **v1.2.2** (October 2026) — dev-tooling bump only: `@cldmv/fix-headers` 2.2.0 and `@cldmv/configs` 1.2.4, so a header-only rewrite keeps `@Last modified by`; no runtime change (#72, #75) ([Changelog](https://github.com/CLDMV/slothlet-vine/blob/master/docs/changelog/v1/v1.2.2.md)) - **v1.2.1** (October 2026) — dev-tooling dependency refresh in the lockfile only (`vitest` 5.0.3, `@cldmv/vitest-runner` 1.5.1, jsonv lint/format packages, `ws` for the transport tests); no runtime changes ([Changelog](https://github.com/CLDMV/slothlet-vine/blob/master/docs/changelog/v1/v1.2.1.md)) - **v1.2.0** (October 2026) — channel principal: `serve({ principal })` judges every call and subscription as a transport-authenticated identity, plus a per-call `serve({ around })` wrapper; requires `@cldmv/slothlet >=3.22.0` ([Changelog](https://github.com/CLDMV/slothlet-vine/blob/master/docs/changelog/v1/v1.2.0.md)) - **v1.1.3** (September 2026) — transports buffer frames that arrive before `grow()`/`serve()` registers a handler, so attaching after an `await` no longer loses the surface frame ([Changelog](https://github.com/CLDMV/slothlet-vine/blob/master/docs/changelog/v1/v1.1.3.md)) -- **v1.1.2** (September 2026) — `@cldmv/slothlet` peer floor raised to `>=3.20.0`; no runtime changes ([Changelog](https://github.com/CLDMV/slothlet-vine/blob/master/docs/changelog/v1/v1.1.2.md)) 📚 **For complete version history and detailed release notes, see the [docs/changelog/](https://github.com/CLDMV/slothlet-vine/tree/master/docs/changelog/) folder.** diff --git a/docs/changelog/v1/v1.3.0.md b/docs/changelog/v1/v1.3.0.md new file mode 100644 index 0000000..865308a --- /dev/null +++ b/docs/changelog/v1/v1.3.0.md @@ -0,0 +1,114 @@ +# Slothlet Vine v1.3.0 Changelog + +**Release Date**: October 2026 +**Release Type**: Minor +**Branch**: `next` → `master` + +--- + +## Overview + +A call can now carry a context of its own. Until now, everything a served leaf knew about its caller came from the channel: `serve({ principal })` binds one identity to the connection, and the `call` frame deliberately carries none. Applications also need context that the caller chooses per call and that is not identity, such as the project a user has open, where one browser window works in project A and another in project B, over the same socket or separate ones. Keeping that in the principal makes the server hold per-connection state that races with in-flight calls whenever it changes, and passing it as an argument means threading a parameter through every leaf. + +v1.3.0 adds a **requested context**: an optional, data-only object on the `call` frame. The grow side sets it per call with `link.with(context, fn)` or as a link-wide default with `grow({ context })`. The serve side decides what to believe through a new `serve({ context })` host check, which can accept the request, narrow or change it, or refuse it. Whatever the check accepts is merged into the call's context scope under the principal's keys, every key write-protected, so the permission gate, rule conditions, `around` and the leaf all see it and none of them can rewrite it. The server holds no state about it. + +The feature is opt-in on both ends. A serve without a `context` check and a grow that never sets a context send and answer exactly the frames they did in v1.2.x. The frame version is unchanged, and the `@cldmv/slothlet` peer range is the same (`>=3.22.0`, optional `ws >=8.0.0`). + +--- + +## ✨ Features + +### `serve({ context })`: the host check for a requested context ([#80](https://github.com/CLDMV/slothlet-vine/pull/80), [#79](https://github.com/CLDMV/slothlet-vine/issues/79)) + +`context: (requested, { principal, path, args }) => object | false | null | undefined`, and it may be async. It runs only for a `call` frame that carries a requested context, after the principal is resolved and before the permission gate. It receives a private copy of the request, the resolved principal (`null` on a serve without one), the leaf path, and the frame's arguments as a rule condition would see them. + +- **Accept** by answering a plain object: the request itself, or a narrowed or changed one. The answer is copied, so the host's own object is never shared with the scope. Dropping a key narrows the request, and changing a value canonicalizes it. +- **Refuse** by answering anything else (`false`, `null`, a non-plain object, an object holding a function) or by throwing or rejecting. A refusal is the new `VINE_CONTEXT` error and the leaf never runs. The host's own error never crosses the wire, and the message names the path only. +- **Without a check, a requested context is refused**, never silently ignored, so a client can never believe a call is scoped when it is not. +- **Setup fails closed.** A `context` that is not a function throws a `TypeError`. So does an instance whose `api.slothlet.context.scope` does not work (for example a `scope: false` instance), and nothing is published in that case. A principal is not required: a check alone needs a working scope, not the permission system. + +```javascript +await serve(api, createChannel(socket), { + paths: ["project"], + principal: { path: "remote.renderer", context: { actor: { id: user.id, roles: user.roles } } }, + context: async (requested, { principal }) => + (await projects.canAccess(principal.context.actor.id, requested.project)) ? { project: requested.project } : false +}); +``` + +The pipeline for a frame carrying a requested context, with a principal: `VINE_NO_LEAF` → resolve the principal → `VINE_DATA_ONLY` if the request is not data → the `context` check (`VINE_CONTEXT` unless it answers a plain, data-only object) → open the scope with the accepted keys merged under the principal's, every key write-protected → the gate (`checkCall`) → the argument scan → invoke → result. Without a principal the same, minus the gate. A frame with no requested context never reaches any of this. + +| Requested context on the frame | `context` check answers | Scope the gate and leaf see | Outcome | +| ------------------------------ | ------------------------------ | -------------------------------- | --------------------------------------------------- | +| none | not called | the principal's context only | as in v1.2.x | +| `{ project: "p1" }` | the request | `{ project: "p1", actor }` | the rules decide, with `ctx.project === "p1"` | +| `{ project: "p1", debug: 1 }` | `{ project: "p1" }` (narrowed) | `{ project: "p1", actor }` | `debug` never reaches anything | +| `{ project: "p2" }` | `false` (no access) | no scope is opened | `VINE_CONTEXT`; the rules are never asked | +| `{ actor: { id: "root" } }` | the request | `{ actor }`, the principal's own | the principal key wins; the request cannot override | +| `{ project: "p1" }` | no check configured | no scope is opened | `VINE_CONTEXT` | +| `{ cb() {} }` or `"p1"` | not called | no scope is opened | `VINE_DATA_ONLY` | + +A refused request (`VINE_CONTEXT`) is deliberately distinct from a rules refusal (`VINE_DENIED`): a client can tell "this context is not yours" and unpin the project, apart from "you may not call this". An accepted context that the rules then refuse is still `VINE_DENIED`. + +`serve({ around })` receives the accepted context as a new `context` property on its call argument: a private copy, or `null` when the frame carried none. It runs inside the scope that holds it. + +### `link.with(context, fn, ...args)` and `grow({ context })`: sending a requested context ([#80](https://github.com/CLDMV/slothlet-vine/pull/80), [#79](https://github.com/CLDMV/slothlet-vine/issues/79)) + +`link.with(context, fn, ...args)` runs `fn` with a requested context on every call it makes over that link, directly or through any module and across every `await`, and resolves to `fn`'s value. + +```javascript +await link.with({ project: "p1" }, () => viewApi.project.files.list()); +``` + +- **The context lives on the call, not the channel.** Two extents over one link carry different contexts at the same time, a later `with()` never races a call already in flight, and calls over a different link are untouched. +- **The innermost extent wins, with no merge.** `with(null, fn)` clears the context for a nested extent, and the link default does not apply there either. +- **It is copied once.** The context is checked by the same data-only rules as arguments, with a plain-object shape on top, and then copied, so mutating the original afterwards changes nothing. A non-plain or function-bearing context rejects with `VINE_DATA_ONLY` naming where the fault is (for example `context.onDone`) before anything runs. `with()` runs `fn` inside `api.slothlet.context.scope()` on the grow instance, so that instance must not be `scope: false`. +- **Only the link's own extents count.** The extent travels as a holder function minted by that link under a key private to it, and a function a grow-side module plants under the same key is ignored. + +`grow(api, channel, { context })` sets the link's default: a plain object (validated and copied once), or a function called synchronously on every call that answers an object, or `null` / `undefined` for none. A resolver is how a client sends whatever it currently has pinned without the far side holding any state. A resolver that throws rejects that call with its own error, and a non-data answer rejects it with `VINE_DATA_ONLY`. A bad static default fails `grow()` itself, and a `context` that is neither a function nor an object throws a `TypeError`. + +The resolved `link` also gains `link.context`, a boolean: whether the far side accepts a requested context. + +A call that would carry a requested context is **refused locally** with `VINE_CONTEXT`, and nothing is sent, when the far side did not advertise that it accepts one. An older serve would drop the unknown key and run the call unscoped, and the caller would never know. + +### `VINE_CONTEXT` error code ([#80](https://github.com/CLDMV/slothlet-vine/pull/80), [#79](https://github.com/CLDMV/slothlet-vine/issues/79)) + +`CODES.CONTEXT` (`"VINE_CONTEXT"`) is exported alongside the existing codes. A serve answers it for a refused requested context, and a grow produces it locally for a context sent to a far side that does not accept one. Like every `VINE_*` code that originates on the far side, it reaches the grow side as a `VineRemoteError` with `.code === "VINE_REMOTE"` and `.remoteCode === "VINE_CONTEXT"`. + +### Wire format ([#80](https://github.com/CLDMV/slothlet-vine/pull/80)) + +Two optional keys, no frame version change: + +- A `surface` frame carries `context: true` only when the serve has a `context` check. Without one it is byte for byte the v1 frame. Only a literal `true` counts. +- A `call` frame carries `context` (an object) only when the caller sent one. `parseFrame` carries it through raw, and the serve validates it, so a bad one is answered with `VINE_DATA_ONLY` instead of being dropped and left to run into the caller's budget. + +`schemas/frame.schema.json` lists both keys as optional, so the v1 shapes remain valid. + +--- + +## 📚 Documentation + +- **NEW:** [docs/changelog/v1/v1.3.0.md](./v1.3.0.md): this changelog. +- `docs/PERMISSIONS.md` gains a "Per-call requested context" section: the pipeline order, the outcome table above, and three recorded decisions. A serve without a check refuses rather than ignores. A requested context is a request, not identity, so the check is where the host ties it to the principal, and rules that matter should still be written against the principal. Events carry no requested context in v1: a `sub` frame has no `context` key, because a subscription outlives any one call. +- `docs/CONFIGURATION.md` documents `serve({ context })`, `grow({ context })`, `link.with()` and `link.context`, and the extended `around` argument. `docs/DESIGN.md` lists the two optional frame keys. `docs/ERRORS.md` adds `VINE_CONTEXT` and extends `VINE_DATA_ONLY` to cover a requested context that is not data. +- `docs/TRANSPORTS.md` points to a requested context for per-call state a page chooses, and `docs/CUSTOM-TRANSPORTS.md` now tells transport authors to carry every key of a frame, including ones they do not recognize: frames grow optional keys without a version bump, and a transport that copies only the keys it knows would silently strip `context`. + +## 🧪 Tests + +- New `tests/e2e-loopback-context.test.vitest.mjs`, with fixtures under `tests/fixtures/context-api/` and `tests/fixtures/context-grow-api/`. It covers accepting, narrowing, changing and refusing a context (`false`, `null`, a throw, a rejection and junk are all `VINE_CONTEXT`, and the leaf never runs); an async check; the check seeing a private copy; protection of accepted keys, including nested fields; concurrent extents over one link; merging with a principal, including a principal key winning, `VINE_CONTEXT` before the gate and `VINE_DENIED` after it; `around` receiving the context; a serve with no check; data-only violations on both sides; the `grow({ context })` default and resolver, `with(null)` and `with()` on one link not scoping another; old and new peer interop in both directions; setup failures, including a `scope: false` instance; and a live WebSocket accepting, narrowing and refusing per call. +- Unit coverage in `tests/frame.test.vitest.mjs` (frame building and parsing, `isPlainObject`, `findContextFault` on hostile values), `tests/serve-principal-units.test.vitest.mjs` (the scope seam on a fake instance, including a scope that never runs `fn` being `VINE_DENIED`), `tests/grow-serve-units.test.vitest.mjs`, `tests/errors.test.vitest.mjs` (`VINE_CONTEXT` is distinct from `VINE_DENIED`), and `tests/package-surface.test.vitest.mjs` (the schema keys are optional). + +## 🔧 Dependencies + +- `globals` `^17.12.0` → `^17.13.0` (locked 17.12.0 → 17.13.0), dev-only ([#77](https://github.com/CLDMV/slothlet-vine/pull/77)). This is the only lockfile change in the release apart from the package's own version. + +--- + +## Upgrade notes + +- **No breaking changes to the public API.** Every addition is optional: `serve({ context })`, `grow({ context })`, `link.with()`, `link.context`, `CODES.CONTEXT` and `around`'s new `context` property. A serve without `context` and a grow that sets none behave as in v1.2.x, and the wire format only gains optional keys. +- **One behavior to know about.** Before this release a serve ignored an unrecognized `context` key on a `call` frame. A serve now refuses such a frame with `VINE_CONTEXT` unless it has a `context` check. The grow shipped in earlier releases never sends one, so this only affects frames built by hand. +- **Mixed versions.** An older grow talking to a v1.3.0 serve works unchanged, and the check is not asked. A v1.3.0 grow facing an older serve sees no `context` on the surface (`link.context === false`) and refuses a requested context locally, while plain calls work. The host must not rely on a requested context where the serve is older than v1.3.0. +- **Custom transports** must pass every frame key through untouched. If a transport rebuilds frames from a fixed key list, it will drop `context` and the serve will then treat the call as having none. +- **Adopting it.** Add a `context` check to `serve()`, ideally one that ties the request to the verified principal, and send the context from the page with `link.with()` or `grow({ context })`. The serving instance needs a working `context.scope`, and the grow instance does too for `with()`. +- The setup error for a principal on an instance whose `context.scope` or `context.run` does not work now reads "needs a working `api.slothlet.context.()` to run each call under its context", since the same probe also guards the new check. It is the same `TypeError` raised at the same point.