From c866a8a695160f03db3663171bcf11cbb669ec69 Mon Sep 17 00:00:00 2001 From: Jason Woltje Date: Thu, 22 Oct 2026 21:48:48 -0500 Subject: [PATCH] launchpad: row 294 FED R6 build and plan, granted-user sign-in (unrun, #1800) Console granted-user sign-in, a user-cli transport per session, the per-request grant re-read, the owner-only gate and user page, the notice, the hub label and peer links, accessContextFor peers, and the bus boot access wiring (sage-3142). Tests G1-G8 and B1-B3 are written and not yet run; runs wait for Sage's CLEAR. Choices C1-C9 in agents/launchpad/work/queue-294/BUILD.md. --- agents/launchpad/work/queue-294/BUILD.md | 217 +++++++++++++ packages/cli/src/access.mjs | 17 +- packages/cli/src/config.mjs | 26 +- packages/cli/tests/access-boot.test.mjs | 71 +++++ packages/cli/tests/access.test.mjs | 18 ++ packages/webui/src/access.mjs | 26 ++ packages/webui/src/chat-http.mjs | 2 + packages/webui/src/chat.mjs | 8 +- packages/webui/src/cli.mjs | 48 ++- packages/webui/src/console-bus.mjs | 40 +++ packages/webui/src/public/app.js | 2 +- packages/webui/src/public/federation.js | 26 ++ packages/webui/src/public/index.html | 3 +- packages/webui/src/public/user.html | 17 + packages/webui/src/public/user.js | 23 ++ packages/webui/src/serve.mjs | 43 ++- packages/webui/src/sso.mjs | 21 +- packages/webui/src/user-bus.mjs | 63 ++++ packages/webui/tests/granted-user.test.mjs | 350 +++++++++++++++++++++ 19 files changed, 974 insertions(+), 47 deletions(-) create mode 100644 agents/launchpad/work/queue-294/BUILD.md create mode 100644 packages/cli/tests/access-boot.test.mjs create mode 100644 packages/webui/src/access.mjs create mode 100644 packages/webui/src/console-bus.mjs create mode 100644 packages/webui/src/public/federation.js create mode 100644 packages/webui/src/public/user.html create mode 100644 packages/webui/src/public/user.js create mode 100644 packages/webui/src/user-bus.mjs create mode 100644 packages/webui/tests/granted-user.test.mjs diff --git a/agents/launchpad/work/queue-294/BUILD.md b/agents/launchpad/work/queue-294/BUILD.md new file mode 100644 index 000000000..123df3475 --- /dev/null +++ b/agents/launchpad/work/queue-294/BUILD.md @@ -0,0 +1,217 @@ +# Row 294 build notes (Launchpad): FED R6, Console granted-user sign-in (#1800) + +Brief: docs/plans/2026-10-23_mvp-readiness-rows.md, "Brief: FED R6, Console granted-user sign-in". +Design: agents/gizmoduck/work/federation/DESIGN.md at b93f098f0 (r4.11, sha256 1bcee8df…c8e6), +sections 3.1, 3.4, 3.7 and the R6 row of section 7. Assigned in sage-3136. Reviewer Duckworth. + +## Base and chain + +Branch launchpad/row294 on origin/refactor 253346e3c (270+271, schema 3n in packages/bus, and 571). +Parents done: 192 (#412 phase 1), R1 (201), R2, R3 (274). Row 208 and R7 (296) wait on this row. + +## Plan + +### What the Console does today + +- `sso.mjs` callback: a `sub` that is not in `console.sso.providers[].subjects` refuses + `subject-unknown`. A session record is `{provider, iss, sub, name, ...}` and every signed-in + request runs as the business human. +- `serve.mjs`: every bus route, chat route and write uses objects built once in `cli.mjs` over the + human transport (`busCaller` over `HUMAN_CLI`). +- `accessContextFor` (packages/cli/src/access.mjs:62) returns `peers: []`. + +### Build items + +1. **Granted-user sign-in (sso.mjs).** `createSso` takes an optional `users` hook: + `users.find({provider, sub})` returns a user id with a live grant, or null. The callback asks it + only after the owner subjects miss, so the owner path is unchanged. A user session record carries + `user: ` and `name: `. A miss on both refuses `subject-unknown`, as today (the log line + names the provider, iss and sub; no new code is needed for the refusal). +2. **The access hook (new `packages/webui/src/access.mjs`).** `consoleAccess({ load, business })`: + - `load` is `() => loadAccess(accessFilePath(configDir()), accessContextFor(system, env))`, R1's + loader, called on every use, never cached. + - `find({provider, sub})`: the user whose `subjects` hold that pair, with at least one grant in + the Console's business that `covers` at level `viewer` (the bus's own liveness test, + `#userGrants`). A loader throw finds nobody (fail closed). + - `check(session)`: the same test for the session's user and its own `(provider, sub)`. A user + whose subject was removed, whose grants were all removed or expired, or whose file now refuses + fails it. + - `business` resolves as `busCaller` does: `--business`, else the live host state. +3. **Per-request re-read (sso.mjs `session()`).** For a user session, `users.check(session)` runs + after the expiry check, before any route. A failure deletes the session record and logs + `sso refused code=user-revoked provider= user=""`. The request then goes the + signed-out way: 401 (`sign-in required`) for /api/ and writes, or the sign-in redirect for a + page GET. The cookie is not cleared on that response; it names a session that no longer exists, + so it signs nothing in, and the next sign-in replaces it. +4. **A user-cli transport per session (new user-bus.mjs, cli.mjs).** `userCall({ socket, + business, user })` spawns `USER_CLI` (packages/bus/src/user-cli.mjs) as a + direct child of the Console with `MOSAIC_BUS_USER_NONCE` set, so user-cli does not re-exec and + the broker's parent check (principal.mjs: parent argv[1] is webui/src/cli.mjs, argv[2] `serve`) + sees the Console. The request on stdin is `{business, user, verb, args}`. It never sets + `MOSAIC_BUS_CLI_NONCE` (it strips one from the inherited env) and never runs `HUMAN_CLI`. + `startServer` takes `userOptions(session)`, a factory that builds the whole bus-backed option + set (`consoleBus`, new console-bus.mjs) over that session's user transport, per request. +5. **Owner-only controls hidden (serve.mjs and a user page).** A user session reaches only: + - the chat routes (`/api/chat/*`), over its own user transport, and + - `GET /api/config`, `/healthz`, `/auth/*`, and the user page's static files. + Every other path answers 403 `owner-only` before any bus call. Behind that gate, a user request + never holds a human-transport object: the handler swaps every bus-backed option (`bus`, `chat`, + `send` and each write, `processing`, project files) for the same object built over that + session's user transport, and nulls the ones with no bus behind them (`reads`, the board proxy), + so with the gate removed an owner route still reaches the broker only as the user, or answers + `not-configured`. `GET /` serves a separate page, + `user.html` with `user.js`, that renders only projects, threads and messages (no board, inbox, + agents, settings, editor, processing or Sign-in-as-owner controls). The owner page's assets are + not served to a user session. The gate is one function, `userRoute(path, method)`, so the M1 + mutant removes one call. +6. **The notice.** `user.html` holds row 193's sentence word for word in the thread view: + "The business owner sees your thread's agent's decisions, inbox entries, claims, tasks and + project files, but not your messages." +7. **Hub label and peer links.** `/api/config` returns `federation: {self, peers: [{label, + origin}]}` from `system.federation` when set, for owner and user sessions alike. Both pages show + "This Console: " and one plain `label` per peer, built with + textContent and `setAttribute('href')`; no script, no fetch to a peer, no proxy (the hub proxy + route is R8's). +8. **F8 peers wiring.** `accessContextFor` returns + `peers: (system.federation?.peers ?? []).map((p) => p.id)`, and the ACCESS_USAGE text and the + function's comment drop "peer homes are not read yet". +9. **Bus boot wiring (packages/cli/src/config.mjs).** `bootConfig` adds + `access: { file: accessFilePath(configDir(env)), context: accessContextFor(system, env) }` when + the file exists (lstat). No file: no `access` key, so no user binds, as before R6. A context + that throws: a warning and no `access`. config.mjs now imports access.mjs, which imports + config.mjs (`loadSystem`); the cycle is safe because neither uses the other's exports at module + top level, only inside functions. + +### Choices and departures (for Sage and Duckworth) + +- **C1. A separate user page, not the owner SPA with sections hidden.** "Hidden" here means never + sent: the owner page's markup and scripts are not served to a user session. Hiding sections in + app.js would ship the owner controls and rely on CSS. Tradeoff: a second small page to keep. +- **C2. The Console's gate is an allowlist of user routes**, so a new owner route added later is + owner-only by default. +- **C2b. No human transport behind a user session.** Without the swap, M1 would send + `/api/bus/message` through the owner's `send` over human-cli, and the bus would accept it as the + owner. The swap is what makes "the bus refusal stands on its own" true. +- **C3. Liveness is the bus's test** (`covers` at `viewer`, in the Console's business), so the + Console and the broker agree on who is live. The broker still decides every verb, project, + instance and thread (design 3.1 D4). +- **C4. The bus's `access` boot option is wired here (sage-3142, Q2).** Build item 9 above. + Sage's ruling: with the file, users bind; without it, none; with a bad file, it "refuses as R1's + loader does"; a boot never invents a grant. My reading of "refuses": the broker's `access` is + R1's loader, read on every user request, so a bad file refuses every user bind + (`access-revoked`) and the owner's bus still boots. Refusing the whole boot would take the + owner's bus down over a user file. Tests B3 pin this reading. No live unit or config changes. +- **C5. Tests use an in-process Broker behind the user transport seam.** The broker's user + principal needs the Console as the user-cli's parent process, which a node:test process is not. + So the webui tests inject a `userOptions` factory over a real `Broker` with `bindUser` and an + `access` function, and a separate transport test checks the spawn: the `USER_CLI` path, the user + nonce variable, no human nonce, and the `{business, user, verb, args}` request. R7 runs the real + Console process end to end. + +- **C6. The chat's `plugins` read goes over the owner transport.** `createChat` asks the bus + `plugins` first (createChannelCaller), and `plugins` is not a user verb, so a user chat would + refuse before its first read. `createChat` takes an optional `plugins` function that answers + only that read; cli.mjs passes `() => ownerCall('plugins', {})`. It is a fixed, argument-free + read of which channel plugins are on, and it carries no user input. Every other chat verb goes + over the user transport. Tradeoff: one owner-transport read inside a user session, fixed in + code; the alternative was a new user verb in the broker, a policy change outside this row. +- **C7. The user transport is a new file, user-bus.mjs, not a mode in bus.mjs.** + bus/tests/channel-proof.test.mjs pins the sha256 of webui/src/bus.mjs (with human-cli.mjs, + human.mjs and cli/src/transport.mjs). Changing bus.mjs would mean an old-test change through + Mos. user-bus.mjs repeats the spawn and framing code (about 40 lines). bus.mjs is byte-identical + to the base. Tradeoff: two copies of the spawn plumbing. +- **C8. console-bus.mjs.** The bus-backed option set moved out of cli.mjs into `consoleBus(call, + {send, plugins})`, so the owner and each user session build the same set the same way. The + agent verb lists and `agentCaller` moved with it; the tester socket still builds its own. +- **C9. 401 and 403 from the broker.** chat-http's STATUS maps `access-refused` to 403 and + `access-revoked` to 401, so a broker refusal reaches the browser as the right status. + +## Owned files + +- cli: src/config.mjs, src/access.mjs, tests/access.test.mjs, tests/access-boot.test.mjs (new) +- webui src: sso.mjs, access.mjs (new), console-bus.mjs (new), user-bus.mjs (new), chat.mjs, + chat-http.mjs, cli.mjs, serve.mjs +- webui public: user.html (new), user.js (new), federation.js (new), index.html, app.js +- webui tests: granted-user.test.mjs (new) +- this BUILD.md + +No old test changes. Hash pins checked: `git grep` over 'packages/*/tests/*' for each touched +path with a 64-hex pin found only channel-proof's bus.mjs pin, which C7 avoids. + +## Tests + +New file packages/webui/tests/granted-user.test.mjs, on Sage's fake OP (tests/helpers/fake-oidc.mjs) +and an in-process Broker with an access function, like bus/tests/user-principal.test.mjs: + +- G1 a granted user signs in: session cookie set, `/api/config` says `user`, `GET /` is the user + page, the chat lists reach the broker as `user:`. +- G2 an ungranted `sub` is refused: a user in the file with no grant, a grant for another business, + an expired grant, and a sub in no file; each is `subject-unknown`, no cookie, nothing bound. +- G3 revoked mid-session: sign in, then remove the grant, remove the subject, expire the grant, + or make the loader refuse. The next chat read is 401 with a `user-revoked` log line and no + transport call. Restoring the grant does not bring the session back, and `GET /` redirects to + sign-in. +- G4 owner verbs: for each owner route (board reads under /api/bus/*, settings, names, instances, + routes, agents, processing, project files, /api/bus/message, /api/board), a user session gets + 403 `owner-only` and the transport ran zero times. +- G5 the bus refusal stands alone: the same session's user transport, called directly with each + owner verb, gets `access-refused` from the broker and nothing is written. +- G6 the notice: the user page's notice element text equals the sentence exactly. +- G7 hub label and peer links render from config: `/api/config` carries self and the peers' + labels and origins; the user and owner pages show "This Console: hub" and an `` per peer with + the configured href and label (browser test, sso-browser.test.mjs style). +- G8 the transport: `userCall` over a recording unix socket in a scratch dir. While the child + waits for the answer, the test reads /proc//cmdline (argv[1] is USER_CLI), environ + (MOSAIC_BUS_USER_NONCE set; MOSAIC_BUS_CLI_NONCE absent though the parent env passed one) and + stat (ppid is the test process, so user-cli did not re-exec). The request line is + `{verb, args, user: {business, user, nonce, pid}}`, which user-cli builds from + `{business, user, verb, args}`. + +packages/cli/tests/access-boot.test.mjs (sage-3142), over bootConfig and startBroker in a scratch +root, the boot config through a JSON round trip as over the host IPC: + +- B1 with access.json: `access.file` and `context.peers` set; alice (home a configured peer) + binds; bob gets `access-revoked`. +- B2 without the file: no `access` key; alice gets `access-revoked`. +- B3 with `{` and with a home that is not a configured peer: the bus boots; alice gets + `access-revoked`. + +packages/cli/tests/access.test.mjs: a grant whose user's home is a configured peer id +validates; an unconfigured peer id refuses `home must be a configured peer`. + +Old tests: the sso.test.mjs and serve tests stay as they are. If one needs a change, it goes to +Mos through Sage first (mos-1272). + +## Mutants + +| # | Change | Killed by | Expected | +|---|---|---|---| +| M1 | remove the `userRoute` gate call in serve.mjs | G4 (transport ran, code is `access-refused` or `not-configured`, not `owner-only`) | G4 red; **G5 and G4's "refused, nothing written" stay green: the bus refuses on its own** | +| M2 | skip `access.check(session)` per request | G3 | red | +| M3 | change one word of the notice in user.html | G6 | red | +| M4 | `peers: []` back in accessContextFor | cli peer test | red | +| M5 | sign-in skips the grant test (any listed user signs in) | G2 | red | +| M6 | userCall sets MOSAIC_BUS_CLI_NONCE or runs HUMAN_CLI | G8 | red | +| M7 | bootConfig drops `access` | B1 | red | + +M1 is the brief's "owner verb still refused by the bus": with the Console gate gone, the owner +verb reaches the broker over the user transport and the broker refuses it (`access-refused`, 403, +nothing written). G4 kills M1 because it pins the Console's own code and a zero transport count. + +## Run limits + +Each `timeout -k 10 LIMIT`, TMPDIR from `mktemp -d /mnt/storage/scratch/tmp/gXXXXXX`. A timeout with +no failed test goes to Sage first; a timeout in the shell phase goes to Mos unplaced (mos-1275). + +| Run | LIMIT | +|---|---| +| each touched or new file alone | 300 s | +| webui suite | 1800 s | +| bus suite | 900 s | +| cli suite | 1800 s | +| each scripts/test-*.sh | 900 s | +| each mutant probe | 300 s | + +## Review + +## Runs diff --git a/packages/cli/src/access.mjs b/packages/cli/src/access.mjs index a1d51eed3..9f1134347 100644 --- a/packages/cli/src/access.mjs +++ b/packages/cli/src/access.mjs @@ -26,9 +26,9 @@ export const ACCESS_USAGE = [ // The help text. A grant's `instances` is the real limit: the granted instance // runs with the owner's role, so a collaborator drives it with that authority. // Removing a user acts at once. Removing a provider from console.sso waits for a -// Console restart, so take the users out of access.json first. Peer homes are -// empty until the federation key lands (row 203), so a grant naming a peer -// home refuses until then. A grant naming a subject needs console.sso. +// Console restart, so take the users out of access.json first. A user's home is +// local or a peer named in federation.peers (FED R6, #1800). A grant naming a +// subject needs console.sso. export const ACCESS_HELP = `${ACCESS_USAGE} A grant gives a user exactly what the granted instances can do. The instances @@ -37,16 +37,16 @@ the owner's role. To cut a user off, revoke them here first. It acts at once. Removing a provider from console.sso takes effect only after a Console restart, so do that second. user remove refuses while the user has a grant at any business: revoke those first. -Peer homes are not read yet (row 203 adds the federation key), so a grant that -names a peer home refuses. A grant that names a sign-in subject needs console.sso +A user's home is local or a peer id from federation.peers in the system config; +any other home refuses. A grant that names a sign-in subject needs console.sso in the system config; without it every such grant refuses. The command runs as the instance's uid: access.json must belong to it and must not be writable by group or other, or the command refuses (exit 3) and writes nothing.`; // The instance's context for checkAccess, read from the system config and the business // files. providers and ssoSubjects come from console.sso (row 192's shape: each provider -// has an id and a subjects list). peers is empty until row 203. A business file that -// fails to load fails the whole command. +// has an id and a subjects list). peers are the ids in federation.peers (FED R6, F8), +// empty without the key. A business file that fails to load fails the whole command. export function accessContextFor(system, env) { const providers = system.console?.sso?.providers ?? []; const ssoSubjects = providers.flatMap((p) => p.subjects.map((s) => ({ provider: p.id, sub: s.sub }))); @@ -59,7 +59,8 @@ export function accessContextFor(system, env) { businesses[id] = { human: doc.human, instances: Object.keys(doc.roles) }; } } - return { providers: providers.map((p) => p.id), peers: [], businesses, ssoSubjects }; + const peers = (system.federation?.peers ?? []).map((p) => p.id); + return { providers: providers.map((p) => p.id), peers, businesses, ssoSubjects }; } // Every verb needs --business: the human proof runs through that business's bus. diff --git a/packages/cli/src/config.mjs b/packages/cli/src/config.mjs index d05faed44..79f5ad406 100644 --- a/packages/cli/src/config.mjs +++ b/packages/cli/src/config.mjs @@ -4,13 +4,14 @@ // Everything here reads and refuses; nothing writes. import { spawnSync } from "node:child_process"; -import { existsSync } from "node:fs"; +import { existsSync, lstatSync } from "node:fs"; import { dirname, join, resolve } from "node:path"; import { fileURLToPath } from "node:url"; -import { BusinessError, configDir, loadBusiness, loadProject, projectFilePath, resolveInstance, systemVars } from "../../business/src/index.mjs"; +import { BusinessError, accessFilePath, configDir, loadBusiness, loadProject, projectFilePath, resolveInstance, systemVars } from "../../business/src/index.mjs"; import { busBusiness } from "../../bus/src/business.mjs"; import { BusError } from "../../bus/src/broker.mjs"; import { CliError } from "./errors.mjs"; +import { accessContextFor } from "./access.mjs"; export const REPO = resolve(dirname(fileURLToPath(import.meta.url)), "..", "..", ".."); @@ -83,6 +84,25 @@ function trackerFor(system, b, warn) { }; } +// FED R6 (#1800, sage-3142): the runtime's `access`, { file, context }. The broker reads the file +// with R1's loadAccess on every user request, so a bad file leaves every user with no grant and +// the owner's bus still boots. The context is the cli's accessContextFor, peers included. No file, +// or a context that can't be built, means no `access`: no user binds, as before R6. +function accessFor(system, env, warn) { + const file = accessFilePath(configDir(env)); + try { + lstatSync(file); + } catch { + return null; + } + try { + return { file, context: accessContextFor(system, env) }; + } catch (e) { + warn(`access.json is present but its context can't be read, so no granted user binds: ${e.message}`); + return null; + } +} + // The `{op: 'boot'}` config for packages/bus/src/process.mjs, for one // business. The host holds a reader capability for the notifier and binds // launches later through bindLaunch, so `launches` starts empty. @@ -101,6 +121,8 @@ export function bootConfig({ system, businessId, env = process.env, warn = () => }; const tracker = trackerFor(vars, b, warn); if (tracker) config.trackers = { [b.id]: tracker }; + const access = accessFor(system, env, warn); + if (access) config.access = access; return config; }); } diff --git a/packages/cli/tests/access-boot.test.mjs b/packages/cli/tests/access-boot.test.mjs new file mode 100644 index 000000000..d6d77c1a0 --- /dev/null +++ b/packages/cli/tests/access-boot.test.mjs @@ -0,0 +1,71 @@ +// FED R6 (#1800, sage-3142): the bus host's boot config carries the runtime's `access` +// ({ file, context }) when /access.json exists, with the cli's accessContextFor as the +// context. The broker then binds a granted user; with no file, or a file R1's loader refuses, no +// user binds and the owner's bus still boots. Scratch roots only. The boot config goes through a +// JSON round trip first, as it does over the host's IPC. +import test from "node:test"; +import assert from "node:assert/strict"; +import { chmodSync, writeFileSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { startBroker } from "../../bus/src/runtime.mjs"; +import { bootConfig, loadSystem } from "../src/config.mjs"; +import { fixture, tmp } from "./helpers.mjs"; + +const SSO = { sso: { providers: [{ id: "authentik", subjects: [{ sub: "jason-sub" }] }] } }; +const DOC = { + accessVersion: 1, + revision: 1, + users: [{ id: "alice", home: "factory", subjects: [{ provider: "authentik", sub: "alice-sub" }] }], + grants: [{ id: "g-1", user: "alice", business: "acme", projects: ["*"], instances: ["*"], level: "collaborator" }], +}; + +// A scratch instance whose system config names the sign-in provider and one peer, and the boot +// config bootConfig makes for it. `file` is the access file's text, or null for none. +async function boot(t, file) { + const f = fixture(tmp(t, "mosaic-access-boot-"), "acme"); + const system = { + ...loadSystem({ env: f.env }), + console: SSO, + federation: { self: "hub", peers: [{ id: "factory", origin: "https://factory.example.test", label: "Software factory" }] }, + }; + const path = join(dirname(f.config), "access.json"); + if (file !== null) { + writeFileSync(path, file); + chmodSync(path, 0o600); + } + const warnings = []; + const { trackers, ...config } = JSON.parse(JSON.stringify(bootConfig({ system, businessId: "acme", env: f.env, warn: (w) => warnings.push(w) }))); + const runtime = await startBroker(config); + t.after(() => runtime.close()); + const bind = (user) => { + try { + return runtime.broker.bindUser({ business: "acme", user }) ? "bound" : "none"; + } catch (e) { + return e.code; + } + }; + return { config, path, bind, warnings }; +} + +test("R6 B1 a boot with access.json binds a granted user, whose home is a configured peer", async (t) => { + const s = await boot(t, JSON.stringify(DOC)); + assert.equal(s.config.access.file, s.path); + assert.deepEqual(s.config.access.context.peers, ["factory"]); + assert.deepEqual(s.config.access.context.ssoSubjects, [{ provider: "authentik", sub: "jason-sub" }]); + assert.equal(s.bind("alice"), "bound"); + assert.equal(s.bind("bob"), "access-revoked"); +}); + +test("R6 B2 a boot without access.json has no access and binds no user", async (t) => { + const s = await boot(t, null); + assert.equal(Object.hasOwn(s.config, "access"), false); + assert.equal(s.bind("alice"), "access-revoked"); +}); + +test("R6 B3 a boot with a file R1's loader refuses still boots and binds no user", async (t) => { + for (const text of ["{", JSON.stringify({ ...DOC, users: [{ ...DOC.users[0], home: "elsewhere" }] })]) { + const s = await boot(t, text); + assert.equal(s.config.access.file, s.path); + assert.equal(s.bind("alice"), "access-revoked", text); + } +}); diff --git a/packages/cli/tests/access.test.mjs b/packages/cli/tests/access.test.mjs index d801f44bc..fd5c056d1 100644 --- a/packages/cli/tests/access.test.mjs +++ b/packages/cli/tests/access.test.mjs @@ -15,6 +15,7 @@ import { socketPath } from "../src/config.mjs"; import { HUMAN_CLI, humanTransport } from "../src/transport.mjs"; import { fakeCli, humanEnv, io, tmp, fixture } from "./helpers.mjs"; import { systemFor, writeJson } from "../../business/tests/helpers.mjs"; +import { checkAccess } from "../../business/src/index.mjs"; const PROJECT = "3f1c2a64-0d8e-4b7a-9c55-1a2b3c4d5e6f"; const ROWS = [{ id: PROJECT, name: "hub", repo: null }]; @@ -282,6 +283,23 @@ test("the context reads console.sso subjects and the business files, and peers s assert.deepEqual(bare.providers, []); }); +// FED R6 (#1800, F8): the context's peers are federation.peers' ids, so a user whose home is a +// configured peer validates and one whose home is any other id refuses. +test("R6 a configured peer id validates as a user's home and an unconfigured one refuses", async (t) => { + const root = tmp(t, "mosaic-access-peers-"); + fixture(root, "acme"); + const system = { + console: { sso: { providers: [{ id: "authentik", subjects: [{ sub: "jason-sub" }] }] } }, + federation: { self: "hub", peers: [{ id: "factory", origin: "https://factory.example.test", label: "Software factory" }] }, + }; + const ctx = accessContextFor(system, { MOSAIC_CONFIG: join(root, "config", "config.json") }); + assert.deepEqual(ctx.peers, ["factory"]); + const doc = (home) => ({ ...DOC, users: [{ id: "bob", home, subjects: [{ provider: "authentik", sub: "bob-sub" }] }] }); + assert.equal(checkAccess(doc("factory"), ctx).users[0].home, "factory"); + assert.throws(() => checkAccess(doc("elsewhere"), ctx), /home must be a configured peer/); + assert.throws(() => checkAccess(doc("hub"), ctx), /home must be a configured peer/); +}); + // Real human-cli.mjs on a scratch bus, run from agent ancestry. The proof must fail. test("the real human-cli, run from agent ancestry, refuses and writes nothing", async (t) => { const root = tmp(t, "mosaic-access-real-"); diff --git a/packages/webui/src/access.mjs b/packages/webui/src/access.mjs new file mode 100644 index 000000000..7446814da --- /dev/null +++ b/packages/webui/src/access.mjs @@ -0,0 +1,26 @@ +// FED R6 (#1800, federation design 3.1 D3 and D5): who besides the owner may sign in to this +// Console. `load` is R1's loadAccess over this instance's access.json and context, called on every +// use and never cached, so a revoke, a removed subject or an expired grant counts on the next +// request. A user is live while one grant in the Console's business covers at `viewer`, the same +// test as the broker's #userGrants. The broker still decides every verb, project, instance and +// thread (D4). Any failure to load, or to name the business, finds nobody (fail closed). +import { covers } from '../../business/src/vocabulary.mjs'; + +export function consoleAccess({ load, business }) { + if (typeof load !== 'function' || typeof business !== 'function') throw new TypeError('consoleAccess needs load and business functions'); + const live = (doc, id, b) => doc.grants.some(g => g.user === id && g.business === b && + covers(g, { business: b, project: g.projects[0], instance: g.instances[0] }, new Date(), { level: 'viewer' })); + const lookup = (provider, sub) => { + try { + const doc = load(), b = business(); + const user = doc.users.find(u => u.subjects.some(s => s.provider === provider && s.sub === sub)); + return user && live(doc, user.id, b) ? user.id : null; + } catch { return null; } + }; + return Object.freeze({ + // The user id this (provider, sub) signs in as, or null. + find: ({ provider, sub }) => lookup(provider, sub), + // Is this session's user still the holder of its own subject, with a live grant? + check: session => typeof session?.user === 'string' && lookup(session.provider, session.sub) === session.user, + }); +} diff --git a/packages/webui/src/chat-http.mjs b/packages/webui/src/chat-http.mjs index c7770726d..7980c1442 100644 --- a/packages/webui/src/chat-http.mjs +++ b/packages/webui/src/chat-http.mjs @@ -17,6 +17,8 @@ const STATUS = { 'unknown-business': 403, 'read-only': 403, 'not-configured': 503, 'no-bus-host': 503, 'thread-instance-missing': 503, 'thread-instance-ambiguous': 503, 'outcome-unknown': 503, 'tester-off': 403, 'tester-required': 403, 'tester-refused': 403, + // FED R6 (#1800): the broker's refusals of a granted user. + 'access-refused': 403, 'access-revoked': 401, }; function safeMessage(code) { if (code === 'channel-disabled') return 'Run mosaic plugins to read the revision, then mosaic plugins set --file PATCH with plugins.channel set to ["cli","webui"].'; diff --git a/packages/webui/src/chat.mjs b/packages/webui/src/chat.mjs index 018574ad4..9694e96c1 100644 --- a/packages/webui/src/chat.mjs +++ b/packages/webui/src/chat.mjs @@ -6,9 +6,13 @@ import { createChannelCaller } from '../../plugins/src/channel.mjs'; import { PluginError } from '../../plugins/src/kinds.mjs'; import { BusReadError } from './bus.mjs'; -export function createChat({ call }) { +// FED R6 (#1800): `plugins`, when given, answers the channel settings read. A granted user's chat +// passes the owner's fixed `plugins` read, since `plugins` is not a user verb; every chat verb still +// goes over the user's own `call`. +export function createChat({ call, plugins = null }) { if (typeof call !== 'function') throw new TypeError('chat needs a bus transport function'); - const dispatch = createChannelCaller({ name: 'webui', call }); + if (plugins !== null && typeof plugins !== 'function') throw new TypeError('chat plugins must be a function'); + const dispatch = createChannelCaller({ name: 'webui', call: plugins === null ? call : (verb, args) => verb === 'plugins' ? plugins() : call(verb, args) }); const channel = async (verb, args) => { try { return await dispatch(verb, args); } catch (err) { diff --git a/packages/webui/src/cli.mjs b/packages/webui/src/cli.mjs index 300e239a0..47baaa9de 100644 --- a/packages/webui/src/cli.mjs +++ b/packages/webui/src/cli.mjs @@ -3,6 +3,11 @@ import { createChat } from './chat.mjs'; import { startServer, previewOptions, proofOptions, DEFAULT_BOARD } from './serve.mjs'; import { busCaller } from './bus.mjs'; +import { busBusiness, userCaller } from './user-bus.mjs'; +import { consoleBus, agentCaller, TESTER_AGENT_VERBS } from './console-bus.mjs'; +import { consoleAccess } from './access.mjs'; +import { accessContextFor } from '../../cli/src/access.mjs'; +import { accessFilePath, loadAccess } from '../../business/src/access.mjs'; import { startTesterSocket } from './tester-socket.mjs'; import { TESTER_CLI } from '../../cli/src/transport.mjs'; import { views } from '../../bus/src/views.mjs'; @@ -13,14 +18,6 @@ import { createSso, readSlot } from './sso.mjs'; import { statSync } from 'node:fs'; import { isAbsolute } from 'node:path'; import { ID_PATTERN } from '../../business/src/vocabulary.mjs'; -// Row 194 (#1692, D7): the Agents section's verbs. The caller refuses any other, so the server's -// agentWrite reaches only these. -const AGENT_VERBS = ['instance.launch', 'instance.regenerate', 'instance.end', 'veto.set', 'instance.request']; -const TESTER_AGENT_VERBS = ['instance.launch', 'instance.end', 'instance.request']; -const agentCaller = (call, verbs) => async (verb, args) => { - if (!verbs.includes(verb)) throw new Error(`the Console does not call ${verb}`); - return call(verb, args); -}; const args = process.argv.slice(2); const usage = 'node packages/webui/src/cli.mjs serve [--port N] [--tester-socket] [--checkout PATH] [--board http://127.0.0.1:7331] [--business ID] [--sso [--send]] [--public-host NAME --auth-header HEADER --allow-user ID [--send] [--proof-header NAME --proof-file PATH]]'; try { @@ -73,24 +70,13 @@ try { // no notifier. A refusal printed here names no path (redactor). // --send without a bus refuses to start rather than serve a composer that can't send. let bus = null, send = null, settingsWrite = null, projectFiles = null, projectFile = null, libraryFile = null, libraryStatus = null, librarySync = null, projectSave = null, chat = null, nameWrite = null, assistantStop = null, instancesWrite = null, routesWrite = null, agentWrite = null, busLine, system = null; - let processing = null; + let processing = null, ownerCall = null; try { system = loadSystem(); const { dataRoot } = system; - const call = busCaller({ dataRoot, socket: socketPath(dataRoot), business: options.business ?? null }); - processing = { read: () => call('processing.status'), halt: args => call('processing.halt', args), resume: args => call('processing.resume', args) }; - chat = createChat({ call }); - // Row 155 (#1649): `names` is a webui read only, so it sits beside the shared views. Row 165 - // (#1662): so is `instances`, and its write `instances.set` is only under --send. Row 65 - // (mos-0840): so is `routes`, and its write `routes.set`. - // Row 278: this owner/reader read belongs to the Console, not the shared - // role views or the tester adapter. The bus decides caller eligibility. - bus = { ...views({ call }), names: () => call('names'), instances: () => call('instances'), routes: () => call('routes'), 'thread.claims': () => call('thread.claims') }; - // Row 148: the spec and docs editor reads like the other reads and saves only under --send. - projectFiles = args => call('project.files', args); - projectFile = args => call('project.file', args); - libraryFile = args => call('library.file', args); libraryStatus = args => call('library.status', args); - if (sendOn) {send = args => call('message.send', args);librarySync=args=>call('library.sync',args);settingsWrite=args=>call('limits.set',args);projectSave=args=>call('project.file.save',args);nameWrite=args=>call('name.set',args);assistantStop=args=>call('assistant.stop',args);instancesWrite=args=>call('instances.set',args);routesWrite=args=>call('routes.set',args);agentWrite=agentCaller(call,AGENT_VERBS);} + const call = ownerCall = busCaller({ dataRoot, socket: socketPath(dataRoot), business: options.business ?? null }); + // FED R6 (#1800): the bus-backed options, shared with each granted user's session (console-bus.mjs). + ({ processing, chat, bus, projectFiles, projectFile, libraryFile, libraryStatus, send, librarySync, settingsWrite, projectSave, nameWrite, assistantStop, instancesWrite, routesWrite, agentWrite } = consoleBus(call, { send: sendOn })); busLine = `Bus: ${options.business ?? 'the running bus host\'s business'}`; } catch (err) { const why = redactor({ configDir: configDir() })(err.message); @@ -102,15 +88,25 @@ try { const reads = consoleReads({ root: REPO, queueRoot: options.checkout ?? REPO, system, configDir: configDir(), rolesDir: rolesDir(), business: options.business ?? null }); // The sign-in config is checked again here, then each slot file, before anything listens. A // provider that can't be reached starts degraded; one whose discovery is refused stops the start. - let sso = null; + let sso = null, userOptions = null; if (ssoOn) { if (!system.console?.sso) throw new Error('--sso needs console.sso in the system config'); const secrets = new Map(system.console.sso.providers.map(p => [p.id, readSlot(configDir(), p, { repo: REPO, checkout: options.checkout ?? null })])); - sso = await createSso(system.console.sso, { secrets }); + // FED R6 (#1800): access.json users sign in too. R1's loader reads the file on every check, + // with the same context as `mosaic access`; a missing or refused file signs nobody in. + const users = consoleAccess({ + load: () => loadAccess(accessFilePath(configDir()), accessContextFor(system, process.env)), + business: busBusiness({ dataRoot: system.dataRoot, business: options.business ?? null }), + }); + sso = await createSso(system.console.sso, { secrets, users }); + // Each request of a granted user's session gets the bus options over that user's own + // user-cli transport. The human transport above serves only the chat's fixed `plugins` read. + const plugins = () => ownerCall('plugins', {}); + userOptions = session => consoleBus(userCaller({ dataRoot: system.dataRoot, socket: socketPath(system.dataRoot), business: options.business ?? null, user: session.user }), { send: sendOn, plugins }); } let server; try { - server = await startServer({ board: options.board, port: options.port, bus, reads, preview: options.preview, sso, proof, send, settingsWrite, projectFiles, projectFile, libraryFile, libraryStatus, librarySync, projectSave, chat, nameWrite, assistantStop, instancesWrite, routesWrite, agentWrite, processing }); + server = await startServer({ board: options.board, port: options.port, bus, reads, preview: options.preview, sso, proof, send, settingsWrite, projectFiles, projectFile, libraryFile, libraryStatus, librarySync, projectSave, chat, nameWrite, assistantStop, instancesWrite, routesWrite, agentWrite, processing, userOptions, federation: system?.federation ?? null }); } catch (err) { sso?.close(); throw err; } let testerServer = null; if (testerSocket) { diff --git a/packages/webui/src/console-bus.mjs b/packages/webui/src/console-bus.mjs new file mode 100644 index 000000000..42ff9711a --- /dev/null +++ b/packages/webui/src/console-bus.mjs @@ -0,0 +1,40 @@ +// FED R6 (#1800): the Console's bus-backed options for startServer, built over one transport +// `call`. cli.mjs builds them once over the human transport. A granted user's session gets the same +// set over its own user transport (user-bus.mjs userCaller), so no route a user reaches holds a +// human-transport object, and the broker decides every verb the user sends (design 3.1 D4). +import { createChat } from './chat.mjs'; +import { views } from '../../bus/src/views.mjs'; + +// Row 194 (#1692, D7): the Agents section's verbs. The caller refuses any other, so the server's +// agentWrite reaches only these. +export const AGENT_VERBS = ['instance.launch', 'instance.regenerate', 'instance.end', 'veto.set', 'instance.request']; +export const TESTER_AGENT_VERBS = ['instance.launch', 'instance.end', 'instance.request']; +export const agentCaller = (call, verbs) => async (verb, args) => { + if (!verbs.includes(verb)) throw new Error(`the Console does not call ${verb}`); + return call(verb, args); +}; + +// `send` is the --send flag: without it every write is null. `plugins` goes to createChat. +export function consoleBus(call, { send: sendOn = false, plugins = null } = {}) { + const o = { + processing: { read: () => call('processing.status'), halt: args => call('processing.halt', args), resume: args => call('processing.resume', args) }, + chat: createChat({ call, plugins }), + // Row 155 (#1649): `names` is a webui read only, so it sits beside the shared views. Row 165 + // (#1662): so is `instances`, and its write `instances.set` is only under --send. Row 65 + // (mos-0840): so is `routes`, and its write `routes.set`. + // Row 278: this owner/reader read belongs to the Console, not the shared + // role views or the tester adapter. The bus decides caller eligibility. + bus: { ...views({ call }), names: () => call('names'), instances: () => call('instances'), routes: () => call('routes'), 'thread.claims': () => call('thread.claims') }, + // Row 148: the spec and docs editor reads like the other reads and saves only under --send. + projectFiles: args => call('project.files', args), + projectFile: args => call('project.file', args), + libraryFile: args => call('library.file', args), libraryStatus: args => call('library.status', args), + send: null, librarySync: null, settingsWrite: null, projectSave: null, nameWrite: null, assistantStop: null, instancesWrite: null, routesWrite: null, agentWrite: null, + }; + if (sendOn) { + o.send = args => call('message.send', args); o.librarySync = args => call('library.sync', args); o.settingsWrite = args => call('limits.set', args); + o.projectSave = args => call('project.file.save', args); o.nameWrite = args => call('name.set', args); o.assistantStop = args => call('assistant.stop', args); + o.instancesWrite = args => call('instances.set', args); o.routesWrite = args => call('routes.set', args); o.agentWrite = agentCaller(call, AGENT_VERBS); + } + return o; +} diff --git a/packages/webui/src/public/app.js b/packages/webui/src/public/app.js index 9739c9a8f..2a0366b79 100644 --- a/packages/webui/src/public/app.js +++ b/packages/webui/src/public/app.js @@ -476,5 +476,5 @@ setTimeout(() => { const a = document.activeElement; if (!a || a === document.body || a.disabled || !a.getClientRects().length) line.focus(); }); }); // Sign out (sage-1347, F7): a plain same-origin form POST to /auth/logout, shown only under --sso. - api('/api/config').then(config => { board = config.board; $('board-url').textContent = `Board: ${board}`; if (config.sso === true) { sso = true; $('sign-out').hidden = out(); } }).catch(err => { if (machine) error(err); }).finally(refresh); + api('/api/config').then(config => { board = config.board; $('board-url').textContent = `Board: ${board}`; window.MosaicFederation?.render(config.federation, $('federation')); if (config.sso === true) { sso = true; $('sign-out').hidden = out(); } }).catch(err => { if (machine) error(err); }).finally(refresh); })(); diff --git a/packages/webui/src/public/federation.js b/packages/webui/src/public/federation.js new file mode 100644 index 000000000..073cc07d8 --- /dev/null +++ b/packages/webui/src/public/federation.js @@ -0,0 +1,26 @@ +/* FED R6 (#1800): the hub label and plain peer links from /api/config's `federation`. Text and + href only: no script, no fetch to a peer and no proxy (the hub proxy route is R8's). */ +(() => { + 'use strict'; + function render(federation, container) { + if (!container) return; + container.replaceChildren(); + if (!federation || typeof federation !== 'object') { container.hidden = true; return; } + if (typeof federation.self === 'string') { + const label = document.createElement('span'); + label.id = 'hub-label'; + label.textContent = `This Console: ${federation.self}`; + container.append(label); + } + for (const peer of Array.isArray(federation.peers) ? federation.peers : []) { + if (typeof peer?.origin !== 'string' || !/^https?:\/\//.test(peer.origin)) continue; + const a = document.createElement('a'); + a.className = 'peer-link'; + a.setAttribute('href', peer.origin); + a.textContent = typeof peer.label === 'string' && peer.label ? peer.label : peer.origin; + container.append(' ', a); + } + container.hidden = !container.childNodes.length; + } + window.MosaicFederation = Object.freeze({ render }); +})(); diff --git a/packages/webui/src/public/index.html b/packages/webui/src/public/index.html index c69fadaf2..a2185147c 100644 --- a/packages/webui/src/public/index.html +++ b/packages/webui/src/public/index.html @@ -4,11 +4,12 @@ - +
MosaicConsole +

diff --git a/packages/webui/src/public/user.html b/packages/webui/src/public/user.html new file mode 100644 index 000000000..0397597af --- /dev/null +++ b/packages/webui/src/public/user.html @@ -0,0 +1,17 @@ + + +Mosaic Console + + + + +
MosaicConsole + +
+
+ + +
+
+
Mosaic Stack
+ diff --git a/packages/webui/src/public/user.js b/packages/webui/src/public/user.js new file mode 100644 index 000000000..47d7c7a5e --- /dev/null +++ b/packages/webui/src/public/user.js @@ -0,0 +1,23 @@ +/* FED R6 (#1800): a granted user's page. Projects, threads and messages through chat.js; the + broker decides what this user reads and sends. No owner control is on this page. */ +(() => { + 'use strict'; + const $ = id => document.getElementById(id); + let canSend = false, ready = false; + const route = () => { + const path = location.hash.slice(1).split('?')[0]; + return path.startsWith('/chat') ? path : '/chat'; + }; + function show(navigated) { + if (ready && window.MosaicChat) void window.MosaicChat.render({ path: route(), container: $('chat'), canSend, navigated }); + } + document.addEventListener('mosaic-signed-out', () => { $('signed-out').hidden = false; $('sign-out').hidden = true; }); + window.addEventListener('hashchange', () => show(true)); + fetch('/api/config', { cache: 'no-store', redirect: 'manual' }).then(async res => { + if (res.type === 'opaqueredirect' || res.status === 401) { document.dispatchEvent(new Event('mosaic-signed-out')); return; } + const config = await res.json(); + canSend = config.send === true; + window.MosaicFederation?.render(config.federation, $('federation')); + ready = true; show(true); + }).catch(() => { $('chat').textContent = 'The Console could not be read. Refresh to try again.'; }); +})(); diff --git a/packages/webui/src/serve.mjs b/packages/webui/src/serve.mjs index 288871d0c..cd51a0b6e 100644 --- a/packages/webui/src/serve.mjs +++ b/packages/webui/src/serve.mjs @@ -27,7 +27,7 @@ export function boardURL(value) { const root = resolve(import.meta.dirname, 'public'); const files = new Map([ ['/', ['index.html', 'text/html; charset=utf-8']], - ...['app.js', 'brand.js', 'bus.js', 'markdown-preview.js', 'chat.js', 'processing.js', 'agents.js'].map(f => ['/' + f, [f, 'text/javascript; charset=utf-8']]), + ...['app.js', 'brand.js', 'bus.js', 'markdown-preview.js', 'chat.js', 'processing.js', 'agents.js', 'federation.js'].map(f => ['/' + f, [f, 'text/javascript; charset=utf-8']]), ...['shared/app.css', 'tokens.css', 'console.css', 'live.css', 'bus.css', 'shell.css', 'chat.css', 'processing.css'].map(f => ['/' + f, [f, 'text/css; charset=utf-8']]), ['/editor.js', ['editor.js', 'text/javascript; charset=utf-8']], ['/editor.css', ['editor.css', 'text/css; charset=utf-8']], @@ -35,6 +35,26 @@ const files = new Map([ ...[400, 500, 600, 700].map(w => [`/assets/fonts/manrope-${w}.woff2`, [`assets/fonts/manrope-${w}.woff2`, 'font/woff2']]), ...[400, 500, 700].map(w => [`/assets/fonts/jetbrains-mono-${w}.woff2`, [`assets/fonts/jetbrains-mono-${w}.woff2`, 'font/woff2']]), ]); +// FED R6 (#1800): a granted user's page. The owner page's markup and scripts are never served to a +// user session, so its controls are not sent at all (design 3.1 D5). +const USER_FILES = new Map([ + ['/', ['user.html', 'text/html; charset=utf-8']], + ...['user.js', 'chat.js', 'federation.js'].map(f => ['/' + f, [f, 'text/javascript; charset=utf-8']]), + ...['tokens.css', 'console.css', 'chat.css'].map(f => ['/' + f, [f, 'text/css; charset=utf-8']]), + ...[...files].filter(([p]) => p.startsWith('/assets/fonts/') || p === '/icons.svg'), +]); +// The only routes a granted user's session reaches: the chat, the page config and the user page. +// Anything else is the owner's, so a route added later is owner-only until it is added here. +export function userRoute(path, method) { + if (path === '/api/chat' || path.startsWith('/api/chat/')) return true; + if (method !== 'GET' && method !== 'HEAD') return false; + return path === '/api/config' || path === '/favicon.ico' || USER_FILES.has(path); +} +// The hub label and plain peer links, from system.federation: ids, labels and origins only. +function federationView(federation) { + if (federation === null || typeof federation !== 'object') return null; + return { self: federation.self ?? null, peers: (federation.peers ?? []).map(({ id, label, origin }) => ({ id, label, origin })) }; +} function json(res, status, body) { res.writeHead(status, { 'content-type': 'application/json', 'cache-control': 'no-store' }); res.end(JSON.stringify(body)); @@ -579,7 +599,7 @@ async function projectFileRead(res, read, path, search) { // `log` takes one line per send attempt that got past sign-in; never the body. `fieldsFile` is for // tests; the field table is otherwise the committed packages/tasks/fields.json. // `agentWrite` (row 194, D7) is `(verb, args) => Promise` for the Agents section's verbs and instance.request. -export async function startServer({ host = '127.0.0.1', port = 7330, board = DEFAULT_BOARD, timeout = 20000, bus = null, reads = null, preview = null, sso = null, tester = false, testerSocket = null, testerAuthHeader = null, proof = null, testerProofHeader = null, send = null, settingsWrite = null, projectFiles = null, projectFile = null, projectSave = null, chat = null, nameWrite = null, assistantStop = null, instancesWrite = null, routesWrite = null, agentWrite = null, processing = null, log = line => console.log(line), fieldsFile = undefined } = {}) { +export async function startServer({ host = '127.0.0.1', port = 7330, board = DEFAULT_BOARD, timeout = 20000, bus = null, reads = null, preview = null, sso = null, tester = false, testerSocket = null, testerAuthHeader = null, proof = null, testerProofHeader = null, send = null, settingsWrite = null, projectFiles = null, projectFile = null, projectSave = null, chat = null, nameWrite = null, assistantStop = null, instancesWrite = null, routesWrite = null, agentWrite = null, processing = null, userOptions = null, federation = null, log = line => console.log(line), fieldsFile = undefined } = {}) { if (!isLoopback(host)) throw new Error('refusing to bind to non-loopback host'); if (host === 'localhost') host = '127.0.0.1'; if (tester && preview !== null) throw new Error('tester listener cannot use preview mode'); @@ -591,6 +611,11 @@ export async function startServer({ host = '127.0.0.1', port = 7330, board = DEF if (tester && !testerSocket) throw new Error('tester listener requires private socket'); if (!tester && testerSocket) throw new Error('tester socket requires tester listener'); const upstream = boardURL(board); + // `userOptions` (FED R6, #1800) is `session => options`, the bus-backed options over that session + // user's own transport (console-bus.mjs). Only a signed-in listener has user sessions. + if (userOptions !== null && (sso === null || typeof userOptions !== 'function')) throw new Error('user sessions need --sso'); + const owner = { bus, reads, send, settingsWrite, projectFiles, projectFile, projectSave, chat, nameWrite, assistantStop, instancesWrite, routesWrite, agentWrite, processing, upstream, files }; + const hub = federationView(federation); // An object with none of the three is refused too: asking for the preview never serves without it. if (preview !== null && !(preview = previewOptions({ publicHost: preview.host, authHeader: preview.header, allowUser: preview.user }))) { throw new Error('preview mode needs --public-host, --auth-header and --allow-user together'); @@ -598,6 +623,8 @@ export async function startServer({ host = '127.0.0.1', port = 7330, board = DEF if (proof !== null && (preview === null || tester)) throw new Error('proxy proof needs preview mode'); if (send !== null && ((preview === null && sso === null) || typeof send !== 'function')) throw new Error('--send needs the preview mode (--public-host, --auth-header and --allow-user) or --sso'); const server = createServer(async (req, res) => { + // A granted user's request rebinds these below; every other request keeps the owner's. + let { bus, reads, send, settingsWrite, projectFiles, projectFile, projectSave, chat, nameWrite, assistantStop, instancesWrite, routesWrite, agentWrite, processing, upstream, files } = owner, granted = false; res.setHeader('x-content-type-options', 'nosniff'); res.setHeader('referrer-policy', 'no-referrer'); // Under --sso the Sign out form's POST is redirected to the provider's end-session endpoint, and @@ -653,6 +680,14 @@ export async function startServer({ host = '127.0.0.1', port = 7330, board = DEF return res.end(); } if (session) signer = { sso: true, user: session.name, host: sso.publicHost, origin: sso.publicOrigin }; + // FED R6 (#1800): a granted user. The Console's gate first; behind it no human-transport + // object is left, so a route past the gate reaches the broker only as this user. + if (session?.user !== undefined) { + if (!userRoute(path, req.method)) return json(res, 403, { error: 'owner-only', message: 'this part of the Console is the business owner\'s' }); + if (userOptions === null) return json(res, 503, { error: 'not-configured', message: 'the Console has no user transport' }); + ({ bus, send, settingsWrite, projectFiles, projectFile, projectSave, chat, nameWrite, assistantStop, instancesWrite, routesWrite, agentWrite, processing } = userOptions(session)); + reads = null; upstream = null; files = USER_FILES; granted = true; + } } if (named && preview !== null) { // One value, equal to the user id; the proxy strips client copies and sets it after sign-in. @@ -715,6 +750,7 @@ export async function startServer({ host = '127.0.0.1', port = 7330, board = DEF const allowed = path === '/api/board' || conversation ? 'GET' : ['/api/seen', '/api/reply'].includes(path) ? 'POST' : null; if (allowed) { if (req.method !== allowed) return json(res, 405, { error: 'method not allowed' }); + if (upstream === null) return json(res, 503, { error: 'not-configured', message: 'the Console has no board for this session' }); let bytes; if (allowed === 'POST') { try { bytes = await body(req); } catch (err) { return json(res, 400, { error: err.message }); } @@ -763,7 +799,8 @@ export async function startServer({ host = '127.0.0.1', port = 7330, board = DEF if (req.method !== 'GET' && req.method !== 'HEAD') return json(res, 405, { error: 'method not allowed' }); // `send`: the page shows the composer only where a send can pass (the public name, --send on). // `edit`: the editor shows Save only where a save can pass, the same test as the route's. - if (path === '/api/config') return json(res, 200, { board: upstream, ...(tester ? { tester: true } : {}), ...(sso ? { sso: true } : {}), send: !tester && Boolean(send) && named, edit: !tester && Boolean(send) && Boolean(projectSave) && named }); + // `user` and `federation` (FED R6) only when there is one, so the owner's config is unchanged. + if (path === '/api/config') return json(res, 200, { board: upstream, ...(tester ? { tester: true } : {}), ...(sso ? { sso: true } : {}), ...(granted ? { user: true } : {}), ...(hub ? { federation: hub } : {}), send: !tester && Boolean(send) && named, edit: !tester && !granted && Boolean(send) && Boolean(projectSave) && named }); if (path === '/healthz') return json(res, 200, { ok: true, ...(sso ? { sso: sso.health() } : {}) }); if (path === '/favicon.ico') { res.writeHead(204); return res.end(); } const file = files.get(path); diff --git a/packages/webui/src/sso.mjs b/packages/webui/src/sso.mjs index 2d01e4412..dd5ad8f61 100644 --- a/packages/webui/src/sso.mjs +++ b/packages/webui/src/sso.mjs @@ -22,7 +22,7 @@ const SESSION_ABSOLUTE = 8 * 3600 * 1000, SESSION_IDLE = 3600 * 1000; const MAX_SESSIONS = 64, MAX_LOGINS = 32; const FETCH_TIMEOUT = 10000; const RETRY_CAP = 300000; -const CODES = new Set(['login-record-missing', 'state-mismatch', 'auth-error-response', 'grant-failed', 'id-token-invalid', 'signature-invalid', 'alg-refused', 'issuer-mismatch', 'subject-unknown', 'discovery-failed', 'jwks-failed']); +const CODES = new Set(['login-record-missing', 'state-mismatch', 'auth-error-response', 'grant-failed', 'id-token-invalid', 'signature-invalid', 'alg-refused', 'issuer-mismatch', 'subject-unknown', 'user-revoked', 'discovery-failed', 'jwks-failed']); const sha256 = value => createHash('sha256').update(value).digest('hex'); const escape = text => String(text).replace(/[&<>"']/g, c => `&#${c.charCodeAt(0)};`); @@ -123,7 +123,9 @@ async function discover(p, fetchOptions) { // `config` is console.sso, checked again here; `secrets` maps provider id to its client secret // (readSlot). `fetch` is for tests: a fetch that trusts only the fake OP's certificate. // `retryFirst` is for tests too; production waits 5 s, doubling, capped at 5 minutes. -export async function createSso(config, { secrets, fetch: customFetch = null, log = line => console.log(line), retryFirst = 5000 } = {}) { +// `users` (FED R6, #1800) is consoleAccess() from access.mjs, or null: a subject that is not the +// owner's signs in as the access-file user with a live grant, and each request checks it again. +export async function createSso(config, { secrets, fetch: customFetch = null, log = line => console.log(line), retryFirst = 5000, users = null } = {}) { checkConsole({ sso: config }); oauth ??= await import('oauth4webapi'); const publicOrigin = config.publicOrigin; @@ -278,14 +280,18 @@ export async function createSso(config, { secrets, fetch: customFetch = null, lo try { header = JSON.parse(Buffer.from(result.id_token.split('.')[0], 'base64url').toString('utf8')); } catch { header = null; } if (!header || !entry.config.algs.includes(header.alg)) { refuse('alg-refused', pid); return failed(res, clear); } if (claims.iss !== entry.config.issuer) { refuse('issuer-mismatch', pid); return failed(res, clear); } - const name = typeof claims.sub === 'string' ? entry.subjects.get(claims.sub) : undefined; + let name = typeof claims.sub === 'string' ? entry.subjects.get(claims.sub) : undefined; + // FED R6: console.sso subjects and access-file subjects are disjoint (R1), so the owner's + // list is asked first and a user is asked only on a miss. + const user = name === undefined && typeof claims.sub === 'string' ? users?.find({ provider: pid, sub: claims.sub }) ?? null : null; + if (user !== null) name = user; if (name === undefined) { refuse('subject-unknown', pid, ` iss=${JSON.stringify(String(claims.iss).slice(0, 255))} sub=${JSON.stringify(String(claims.sub).slice(0, 255))}`); return failed(res, clear); } // Step 7: the session, then the return path. const id = randomBytes(32).toString('base64url'), now = Date.now(); - remember(sessions, sha256(id), { provider: pid, iss: claims.iss, sub: claims.sub, name, idToken: result.id_token, created: now, seen: now }, MAX_SESSIONS); + remember(sessions, sha256(id), { provider: pid, iss: claims.iss, sub: claims.sub, name, ...(user !== null ? { user } : {}), idToken: result.id_token, created: now, seen: now }, MAX_SESSIONS); return redirect(res, returnURL(record.ret), [...clear, `${SESSION_COOKIE}=${id}; ${ATTRS}`]); } @@ -298,6 +304,13 @@ export async function createSso(config, { secrets, fetch: customFetch = null, lo if (!s) return null; const now = Date.now(); if (now - s.created >= SESSION_ABSOLUTE || now - s.seen >= SESSION_IDLE) { sessions.delete(key); return null; } + // FED R6 (design 3.1 D5): a user's grant is read again on every request, so a user revoked, + // removed or expired since sign-in has no session from the next request on. + if (s.user !== undefined && !users?.check(s)) { + sessions.delete(key); + refuse('user-revoked', s.provider, ` user=${JSON.stringify(s.user)}`); + return null; + } if (touch) s.seen = now; return s; } diff --git a/packages/webui/src/user-bus.mjs b/packages/webui/src/user-bus.mjs new file mode 100644 index 000000000..29c6302bf --- /dev/null +++ b/packages/webui/src/user-bus.mjs @@ -0,0 +1,63 @@ +// FED R6 (#1800): a granted user's bus transport, R3's user-cli.mjs (#414, design 3.6). Kept apart +// from bus.mjs so the human transport's reviewed bytes stay as row 164 froze them +// (bus/tests/channel-proof.test.mjs). Same protocol as humanCall: the request on stdin, JSON on +// stdout, or a bus error code as the last `^[a-z-]{1,64}$` line of stderr. +import { spawn } from 'node:child_process'; +import { randomBytes } from 'node:crypto'; +import { fileURLToPath } from 'node:url'; +import { readHostState } from '../../cli/src/host.mjs'; +import { BusReadError } from './bus.mjs'; + +export const USER_CLI = fileURLToPath(new URL('../../bus/src/user-cli.mjs', import.meta.url)); + +// The Console sets the user nonce itself, so user-cli.mjs doesn't re-exec and the broker sees the +// Console (`cli.mjs serve`) as its parent (principal.mjs). The human nonce is dropped, so this child +// can never pass as human-cli, and it only ever runs USER_CLI. +export function userCall({ socket, business, user, env = process.env, timeoutMs = 30000, maxBytes = 8 * 1024 * 1024 }) { + const { MOSAIC_BUS_CLI_NONCE, ...rest } = env; + return (verb, args = {}) => new Promise((resolve, reject) => { + let child; + try { + child = spawn(process.execPath, [USER_CLI, socket], { env: { ...rest, MOSAIC_BUS_USER_NONCE: randomBytes(32).toString('hex') }, stdio: ['pipe', 'pipe', 'pipe'] }); + } catch { return reject(new BusReadError('outcome-unknown', 'the bus transport did not start')); } + const out = [], err = []; + let size = 0, settled = false; + const done = (fn, value) => { if (!settled) { settled = true; clearTimeout(timer); fn(value); } }; + const timer = setTimeout(() => { child.kill('SIGKILL'); done(reject, new BusReadError('outcome-unknown', 'the bus transport did not finish in time')); }, timeoutMs); + child.stdout.on('data', chunk => { + size += chunk.length; + if (size > maxBytes) { child.kill('SIGKILL'); return done(reject, new BusReadError('response-too-large')); } + out.push(chunk); + }); + child.stderr.on('data', chunk => { if (err.length < 64) err.push(chunk); }); + child.on('error', () => done(reject, new BusReadError('outcome-unknown', 'the bus transport did not start'))); + child.on('close', status => { + if (status === null) return done(reject, new BusReadError('outcome-unknown', 'the bus transport did not finish')); + if (status !== 0) { + const code = Buffer.concat(err).toString('utf8').trim().split('\n').reverse().find(l => /^[a-z-]{1,64}$/.test(l)) ?? 'invalid-response'; + return done(reject, new BusReadError(code)); + } + try { done(resolve, JSON.parse(Buffer.concat(out).toString('utf8'))); } + catch { done(reject, new BusReadError('invalid-response')); } + }); + child.stdin.on('error', () => {}); // A child that exits early reports through 'close'. + child.stdin.end(`${JSON.stringify({ business, user, verb, args })}\n`); + }); +} + +// The business is the --business flag, or else the live bus host's, read on every call, as busCaller does. +export function busBusiness({ dataRoot, business = null }) { + return () => { + if (business) return business; + let state; + try { state = readHostState(dataRoot); } catch { throw new BusReadError('no-bus-host', 'the bus host state file is unreadable'); } + if (!state?.live) throw new BusReadError('no-bus-host', 'no bus host is running and no --business was given'); + return state.business; + }; +} + +// One granted user's caller, for that user's session only. +export function userCaller({ dataRoot, socket, business = null, user, env = process.env, timeoutMs = 30000 }) { + const which = busBusiness({ dataRoot, business }); + return async (verb, args) => userCall({ socket, business: which(), user, env, timeoutMs })(verb, args); +} diff --git a/packages/webui/tests/granted-user.test.mjs b/packages/webui/tests/granted-user.test.mjs new file mode 100644 index 000000000..edbe3b902 --- /dev/null +++ b/packages/webui/tests/granted-user.test.mjs @@ -0,0 +1,350 @@ +// FED R6 (#1800, federation design 3.1 D3-D5, 3.4 and 3.7): a granted user signs in to the +// Console. Every provider is the fake OP in helpers/fake-oidc.mjs on loopback; no test reaches a +// real provider, the live Console or a real config, slot or access file. The broker is an +// in-process Broker with an access function (bus/tests/user-principal.test.mjs), reached through a +// per-session user transport that binds the user on every call, as user-cli.mjs does. The real +// user-cli spawn is checked on its own in G8 against a recording socket. +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { request, createServer as createHttpServer } from 'node:http'; +import { createServer as createNetServer } from 'node:net'; +import { mkdtempSync, readFileSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { Store } from '../../bus/src/store.mjs'; +import { Broker } from '../../bus/src/broker.mjs'; +import { HUMAN_CLI } from '../../cli/src/transport.mjs'; +import { startServer, userRoute } from '../src/serve.mjs'; +import { createSso, SESSION_COOKIE } from '../src/sso.mjs'; +import { consoleAccess } from '../src/access.mjs'; +import { consoleBus } from '../src/console-bus.mjs'; +import { BusReadError } from '../src/bus.mjs'; +import { USER_CLI, userCall } from '../src/user-bus.mjs'; +import { close, fixture } from './fixture.mjs'; +import { browser } from './browser.mjs'; +import { fakeOidc } from './helpers/fake-oidc.mjs'; + +const HOST = 'console.example.test', ORIGIN = `https://${HOST}`; +const PUBLIC = new URL('../src/public/', import.meta.url); +const NOTICE = "The business owner sees your thread's agent's decisions, inbox entries, claims, tasks and project files, but not your messages."; +const FEDERATION = { self: 'hub', peers: [{ id: 'factory', origin: 'https://factory.example.test', label: 'Software factory' }] }; + +function call(port, { method = 'GET', path = '/', headers = {}, body, host = HOST } = {}) { + return new Promise((resolve, reject) => { + const length = body === undefined ? {} : { 'content-length': Buffer.byteLength(body) }; + const req = request({ host: '127.0.0.1', port, method, path, headers: { host, ...length, ...headers } }, res => { + let text = ''; + res.on('data', c => (text += c)); + res.on('end', () => resolve({ status: res.statusCode, headers: res.headers, body: text && (res.headers['content-type'] ?? '').startsWith('application/json') ? JSON.parse(text) : text })); + }); + req.on('error', reject); + req.end(body); + }); +} +const setCookies = r => [r.headers['set-cookie'] ?? []].flat(); +const sessionOf = r => setCookies(r).find(c => c.startsWith(`${SESSION_COOKIE}=`) && !c.includes('Max-Age=0'))?.split(';')[0] ?? null; +const writeHeaders = cookie => ({ cookie, origin: ORIGIN, 'sec-fetch-site': 'same-origin', 'content-type': 'application/json' }); +const grant = (over = {}) => ({ id: 'g-1', user: 'alice', business: 'demo', projects: ['*'], instances: ['assistant'], level: 'collaborator', ...over }); +const ALICE = { id: 'alice', home: 'local', subjects: [{ provider: 'a', sub: 'alice-sub' }] }; + +// The fake OP, an in-process broker for business `demo`, createSso with the access hook, and the +// human listener. The owner's options are counting stubs; each user session's options are +// consoleBus over that user's transport. `counts` records every owner stub and user transport call. +async function setup(t, { users = [ALICE], grants = [grant()] } = {}) { + const op = await fakeOidc(); + t.after(() => op.close()); + const iss = op.issuer('a'); + const root = mkdtempSync(join(tmpdir(), 'webui-granted-user-')); + const store = new Store(root); + t.after(() => { store.close(); rmSync(root, { recursive: true, force: true }); }); + const access = { doc: { accessVersion: 1, revision: 1, users, grants }, fail: false }; + const load = () => { if (access.fail) throw new Error('access file refused'); return access.doc; }; + const broker = new Broker({ + store, + businesses: { + demo: { + id: 'demo', human: 'jason', arbiters: { technical: 'chief', delivery: 'chief' }, vars: {}, + roles: { + chief: { definition: 'chief-of-staff', authority: { withinRole: ['message.send'], crossRole: [] } }, + assistant: { definition: 'assistant', authority: { withinRole: ['message.send'], crossRole: [] } }, + }, + definitions: { 'chief-of-staff': {}, assistant: { messaging: JSON.parse(readFileSync(new URL('../../../roles/assistant.json', import.meta.url), 'utf8')).messaging } }, + }, + }, + access: load, + }); + const human = broker.bindHuman({ business: 'demo', human: 'jason', via: 'cli', outsideAgent: true }); + const counts = { owner: 0, user: 0 }; + // As user-cli.mjs: a fresh bind per call, and the broker's refusal code as the transport's. + const userTransport = user => async (verb, args = {}) => { + counts.user++; + try { return broker.request(broker.bindUser({ business: 'demo', user }), { verb, args }); } + catch (e) { throw new BusReadError(e.code ?? 'invalid-response'); } + }; + const plugins = async () => ({ plugins: { channel: ['webui'] } }); + const opened = []; + const userOptions = session => { opened.push(session.user); return consoleBus(userTransport(session.user), { send: true, plugins }); }; + const lines = []; + const log = line => lines.push(line); + const config = { publicOrigin: ORIGIN, providers: [{ id: 'a', issuer: iss.issuer, clientId: iss.clientId, clientSecret: { slot: 'a' }, algs: ['RS256'], subjects: [{ sub: 'owner-sub', name: 'jason' }] }] }; + const sso = await createSso(config, { secrets: new Map([['a', iss.clientSecret]]), fetch: op.fetch, log, users: consoleAccess({ load, business: () => 'demo' }) }); + t.after(() => sso.close()); + const stub = name => async () => { counts.owner++; return name === 'send' ? { id: 'msg-0001' } : [{ verb: name }]; }; + const ownerBus = Object.fromEntries(['inbox', 'tasks', 'agents', 'trail', 'decisions', 'names', 'instances', 'routes', 'thread.claims'].map(v => [v, stub(v)])); + const owner = { + bus: ownerBus, send: stub('send'), settingsWrite: stub('settings'), projectFiles: stub('files'), projectFile: stub('file'), projectSave: stub('save'), + nameWrite: stub('name'), assistantStop: stub('stop'), instancesWrite: stub('instances'), routesWrite: stub('routes'), agentWrite: stub('agent'), + processing: { read: stub('processing'), halt: stub('halt'), resume: stub('resume') }, + reads: Object.fromEntries(['settings', 'business', 'busStatus', 'queue', 'row'].map(v => [v, stub(v)])), + chat: Object.fromEntries(['projects', 'threads', 'messages', 'send', 'open', 'createProject', 'createThread'].map(v => [v, stub(v)])), + }; + const web = await startServer({ port: 0, board: 'http://127.0.0.1:9', sso, log, ...owner, userOptions, federation: FEDERATION }); + t.after(() => close(web)); + const p1 = broker.request(human, { verb: 'project.create', args: { name: 'shared' } }).id; + const p2 = broker.request(human, { verb: 'project.create', args: { name: 'private' } }).id; + return { op, iss, broker, human, store, access, counts, opened, lines, port: web.address().port, p1, p2, userTransport, plugins }; +} + +// A whole sign-in with `sub`: start, a code at the OP, the callback. +async function signIn(env, sub) { + const start = await call(env.port, { path: '/auth/login' }); + assert.equal(start.status, 303); + const cookie = setCookies(start)[0].split(';')[0]; + const location = start.headers.location; + const g = env.op.grant(env.iss, location, { sub }); + const q = new URLSearchParams({ code: g.code, state: new URL(location).searchParams.get('state') }); + const r = await call(env.port, { path: `/auth/callback?${q}`, headers: { cookie } }); + return { r, session: sessionOf(r) }; +} +const events = env => env.store.get('SELECT count(*) n FROM events').n; + +test('R6 G1 a granted user signs in, gets the user page and reaches the broker as itself', async t => { + const env = await setup(t); + env.access.doc.grants = [grant({ projects: [env.p1] })]; + const { r, session } = await signIn(env, 'alice-sub'); + assert.equal(r.status, 303, JSON.stringify(r.body)); + assert.ok(session, 'a session cookie was set'); + const config = await call(env.port, { path: '/api/config', headers: { cookie: session } }); + assert.equal(config.status, 200); + assert.equal(config.body.user, true); + assert.equal(config.body.edit, false); + assert.deepEqual(config.body.federation, FEDERATION); + const page = await call(env.port, { path: '/', headers: { cookie: session } }); + assert.equal(page.status, 200); + assert.match(page.body, /