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.
This commit is contained in:
jason.woltje committed 2026-10-22 21:48:48 -05:00
1 parent 253346e3c3
commit c866a8a695
19 files changed
+974 -47

No files matched your search

+217
View File
@@ -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: <id>` and `name: <id>`. 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=<id> user="<id>"`. 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: <self>" and one plain `<a href="<origin>">label</a>` 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:<id>`.
- 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 `<a>` 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/<pid>/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
+9 -8
View File
@@ -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.
+24 -2
View File
@@ -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;
});
}
+71
View File
@@ -0,0 +1,71 @@
// FED R6 (#1800, sage-3142): the bus host's boot config carries the runtime's `access`
// ({ file, context }) when <configDir>/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);
}
});
+18
View File
@@ -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-");
+26
View File
@@ -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,
});
}
+2
View File
@@ -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"].';
+6 -2
View File
@@ -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) {
+22 -26
View File
@@ -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) {
+40
View File
@@ -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;
}
+1 -1
View File
@@ -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);
})();
+26
View File
@@ -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 });
})();
+2 -1
View File
@@ -4,11 +4,12 @@
<link rel="stylesheet" href="/shared/app.css"><link rel="stylesheet" href="/tokens.css"><link rel="stylesheet" href="/console.css"><link rel="stylesheet" href="/live.css"><link rel="stylesheet" href="/bus.css"><link rel="stylesheet" href="/chat.css"><link rel="stylesheet" href="/shell.css">
<link rel="stylesheet" href="/editor.css"><script src="/editor.js" defer></script>
<link rel="stylesheet" href="/processing.css"><script src="/processing.js" defer></script>
<script src="/brand.js" defer></script><script src="/app.js" defer></script><script src="/markdown-preview.js" defer></script><script src="/chat.js" defer></script><script src="/agents.js" defer></script><script src="/bus.js" defer></script></head>
<script src="/federation.js" defer></script><script src="/brand.js" defer></script><script src="/app.js" defer></script><script src="/markdown-preview.js" defer></script><script src="/chat.js" defer></script><script src="/agents.js" defer></script><script src="/bus.js" defer></script></head>
<body>
<a class="skip" href="#main">Skip to content</a>
<header class="cmdbar"><a href="/" class="wordmark"><svg class="mark" viewBox="0 0 16 17" aria-hidden="true"><use href="/icons.svg#mark"/></svg>Mosaic<small>Console</small></a>
<button type="button" class="cmdk" id="cmdk" aria-haspopup="dialog" aria-controls="cmdk-dialog"><svg class="i" aria-hidden="true"><use href="/icons.svg#i-search"/></svg><span>Go to a view, decision or task</span><kbd>Ctrl K</kbd></button>
<nav id="federation" class="federation small" aria-label="Consoles" hidden></nav>
<p class="fresh" id="fresh"><span id="fresh-board"></span><span id="fresh-bus"></span></p>
<div class="cmd-right"><label>Palette<select id="palette"></select></label><label>Appearance<select id="mode"><option value="system">system</option><option value="light">light</option><option value="dim">dim</option><option value="dark">dark</option></select></label><button class="btn" id="refresh" type="button">Refresh</button><button class="btn" id="pause" type="button" aria-pressed="false">Pause</button><form id="sign-out" method="post" action="/auth/logout" hidden><button class="btn" type="submit">Sign out</button></form></div></header>
<div id="processing-banner" class="processing-banner" role="alert" hidden></div>
+17
View File
@@ -0,0 +1,17 @@
<!doctype html>
<html lang="en" data-design="console" data-palette="harbor">
<head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>Mosaic Console</title>
<link rel="stylesheet" href="/tokens.css"><link rel="stylesheet" href="/console.css"><link rel="stylesheet" href="/chat.css">
<script src="/federation.js" defer></script><script src="/chat.js" defer></script><script src="/user.js" defer></script></head>
<body>
<a class="skip" href="#main">Skip to content</a>
<header class="cmdbar"><a href="/" class="wordmark"><svg class="mark" viewBox="0 0 16 17" aria-hidden="true"><use href="/icons.svg#mark"/></svg>Mosaic<small>Console</small></a>
<nav id="federation" class="federation small" aria-label="Consoles" hidden></nav>
<div class="cmd-right"><form id="sign-out" method="post" action="/auth/logout"><button class="btn" type="submit">Sign out</button></form></div></header>
<main id="main" class="content" tabindex="-1">
<div id="signed-out" class="banner ref" role="alert" tabindex="-1" hidden>You are signed out. <a href="/">Sign in again</a>.</div>
<p id="user-notice" class="banner ref">The business owner sees your thread's agent's decisions, inbox entries, claims, tasks and project files, but not your messages.</p>
<div id="chat"></div>
</main>
<footer class="foot"><span id="footer">Mosaic Stack</span></footer>
</body></html>
+23
View File
@@ -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.'; });
})();
+40 -3
View File
@@ -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);
+17 -4
View File
@@ -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;
}
+63
View File
@@ -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);
}
+350
View File
@@ -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, /<script src="\/user\.js" defer><\/script>/);
assert.doesNotMatch(page.body, /app\.js|bus\.js|agents\.js|processing\.js|editor\.js/);
// Only the granted project shows: the broker answered as alice, not as the owner.
const projects = await call(env.port, { path: '/api/chat/projects', headers: { cookie: session } });
assert.equal(projects.status, 200, JSON.stringify(projects.body));
assert.deepEqual(projects.body.rows.map(p => p.id), [env.p1]);
const made = await call(env.port, { method: 'POST', path: '/api/chat/threads', headers: writeHeaders(session), body: JSON.stringify({ project: env.p1, name: 'alice work' }) });
assert.equal(made.status, 200, JSON.stringify(made.body));
const threads = await call(env.port, { path: '/api/chat/threads', headers: { cookie: session } });
assert.deepEqual(threads.body.rows.map(x => x.id), [made.body.id]);
assert.equal(env.counts.owner, 0, 'an owner object ran for a user session');
assert.ok(env.opened.length > 0 && env.opened.every(u => u === 'alice'));
assert.ok(env.lines.some(l => / chat action=createThread user="alice" id=/.test(l)));
// The owner signs in as before and gets the owner page and config.
const ownerSession = (await signIn(env, 'owner-sub')).session;
const ownerConfig = await call(env.port, { path: '/api/config', headers: { cookie: ownerSession } });
assert.equal(Object.hasOwn(ownerConfig.body, 'user'), false);
assert.deepEqual(ownerConfig.body.federation, FEDERATION);
assert.match((await call(env.port, { path: '/', headers: { cookie: ownerSession } })).body, /<script src="\/app\.js" defer><\/script>/);
});
test('R6 G2 a subject with no live grant in this business is refused subject-unknown', async t => {
const past = new Date(Date.now() - 60000).toISOString();
for (const [why, users, grants, sub] of [
['a listed user with no grant', [ALICE], [], 'alice-sub'],
['a grant in another business', [ALICE], [grant({ business: 'other' })], 'alice-sub'],
['an expired grant', [ALICE], [grant({ expires: past })], 'alice-sub'],
["another user's grant", [ALICE, { id: 'bob', home: 'local', subjects: [{ provider: 'a', sub: 'bob-sub' }] }], [grant({ user: 'bob' })], 'alice-sub'],
['a sub in no file', [ALICE], [grant()], 'stranger-sub'],
]) {
await t.test(why, async t => {
const env = await setup(t, { users, grants });
const { r, session } = await signIn(env, sub);
assert.equal(r.status, 403, why);
assert.equal(session, null, `${why}: a session cookie was set`);
assert.match(env.lines.at(-1), / sso refused code=subject-unknown provider=a /);
assert.deepEqual(env.opened, []);
assert.equal(env.counts.user + env.counts.owner, 0);
});
}
await t.test('a file the loader refuses', async t => {
const env = await setup(t);
env.access.fail = true;
const { r, session } = await signIn(env, 'alice-sub');
assert.equal(r.status, 403);
assert.equal(session, null);
});
});
test('R6 G3 a revoke counts on the next request: 401 and the session is gone', async t => {
for (const [why, revoke] of [
['grant removed', env => { env.access.doc.grants = []; }],
['subject removed', env => { env.access.doc.users = [{ ...ALICE, subjects: [] }]; }],
['grant expired', env => { env.access.doc.grants = [grant({ expires: new Date(Date.now() - 1000).toISOString() })]; }],
['file now refused', env => { env.access.fail = true; }],
]) {
await t.test(why, async t => {
const env = await setup(t);
const { session } = await signIn(env, 'alice-sub');
assert.equal((await call(env.port, { path: '/api/chat/projects', headers: { cookie: session } })).status, 200);
revoke(env);
const before = env.counts.user;
const r = await call(env.port, { path: '/api/chat/projects', headers: { cookie: session } });
assert.equal(r.status, 401, `${why}: ${JSON.stringify(r.body)}`);
assert.equal(r.body.error, 'sign-in required');
assert.match(env.lines.at(-1), / sso refused code=user-revoked provider=a user="alice"$/);
assert.equal(env.counts.user, before, 'the transport ran after the revoke');
// Restoring the grant does not bring the session back: it was deleted.
env.access.fail = false; env.access.doc.users = [ALICE]; env.access.doc.grants = [grant()];
assert.equal((await call(env.port, { path: '/api/chat/projects', headers: { cookie: session } })).status, 401);
const page = await call(env.port, { path: '/', headers: { cookie: session } });
assert.equal(page.status, 303);
assert.match(page.headers.location, /^\/auth\/login\?return=/);
});
}
});
// Every owner route a user session might try; none is a chat route.
const OWNER_ROUTES = [
['GET', '/api/bus/inbox'], ['GET', '/api/bus/tasks'], ['GET', '/api/bus/names'], ['GET', '/api/bus/thread.claims'],
['POST', '/api/bus/message', { to: 'coder', body: 'hi' }],
['GET', '/api/settings'], ['POST', '/api/settings', { revision: 'x' }],
['POST', '/api/name', { role: 'assistant', name: 'x' }], ['POST', '/api/assistant/stop', {}],
['POST', '/api/instances', { instance: 'x' }], ['POST', '/api/routes', { routes: [] }],
['POST', '/api/agent/launch', { instance: 'x' }], ['GET', '/api/agent/request?id=x'],
['GET', '/api/processing'], ['POST', '/api/processing/halt', {}],
['GET', '/api/project-files?project=p'], ['GET', '/api/project-file?project=p&path=a'], ['POST', '/api/project-file', { project: 'p' }],
['GET', '/api/board'], ['GET', '/api/conversations'], ['POST', '/api/seen', {}], ['POST', '/api/reply', {}],
['GET', '/api/queue'], ['GET', '/api/queue/1'], ['GET', '/api/business'], ['GET', '/api/bus-status'], ['GET', '/api/task-fields'],
['GET', '/app.js'], ['GET', '/bus.js'], ['GET', '/agents.js'], ['GET', '/editor.js'], ['GET', '/shared/app.css'], ['GET', '/index.html'],
];
test('R6 G4 every owner route answers 403 owner-only before any bus call', async t => {
const env = await setup(t);
const { session } = await signIn(env, 'alice-sub');
const before = events(env);
for (const [method, path, value] of OWNER_ROUTES) {
const r = await call(env.port, { method, path, headers: value === undefined ? { cookie: session } : writeHeaders(session), body: value === undefined ? undefined : JSON.stringify(value) });
assert.equal(r.status, 403, `${method} ${path}: ${r.status} ${JSON.stringify(r.body)}`);
assert.equal(r.body.error, 'owner-only', `${method} ${path}`);
assert.equal(userRoute(new URL(path, 'http://x').pathname, method), false, `${method} ${path}`);
}
assert.deepEqual(env.counts, { owner: 0, user: 0 });
assert.deepEqual(env.opened, [], 'a user transport was built for an owner route');
assert.equal(events(env), before, 'something was written');
});
test('R6 G5 the bus refuses every owner verb over the user transport on its own', async t => {
const env = await setup(t);
const o = consoleBus(env.userTransport('alice'), { send: true, plugins: env.plugins });
const before = events(env);
for (const [why, run] of [
['inbox', () => o.bus.inbox()], ['tasks', () => o.bus.tasks()], ['agents', () => o.bus.agents()], ['trail', () => o.bus.trail()],
['decisions', () => o.bus.decisions()], ['instances', () => o.bus.instances()], ['routes', () => o.bus.routes()], ['thread.claims', () => o.bus['thread.claims']()],
['message.send', () => o.send({ to: 'chief', body: 'as the owner' })], ['limits.set', () => o.settingsWrite({ revision: 'x' })],
['project.files', () => o.projectFiles({ project: env.p1 })], ['project.file', () => o.projectFile({ project: env.p1, path: 'a' })],
['project.file.save', () => o.projectSave({ project: env.p1 })], ['name.set', () => o.nameWrite({ role: 'assistant', name: 'x' })],
['assistant.stop', () => o.assistantStop({})], ['instances.set', () => o.instancesWrite({})], ['routes.set', () => o.routesWrite({})],
['instance.launch', () => o.agentWrite('instance.launch', {})], ['processing.status', () => o.processing.read()],
['processing.halt', () => o.processing.halt({})], ['library.status', () => o.libraryStatus({})],
['project.create', () => o.chat.createProject({ name: 'mine' })],
]) await assert.rejects(Promise.resolve().then(run), e => e.code === 'access-refused', why);
assert.equal(events(env), before, 'a refused verb wrote');
});
test('R6 G6 the user page holds the notice word for word', async t => {
const html = readFileSync(new URL('user.html', PUBLIC), 'utf8');
const notice = /<p id="user-notice"[^>]*>([^<]*)<\/p>/.exec(html);
assert.ok(notice, 'no #user-notice paragraph');
assert.equal(notice[1], NOTICE);
const env = await setup(t);
const { session } = await signIn(env, 'alice-sub');
assert.ok((await call(env.port, { path: '/', headers: { cookie: session } })).body.includes(NOTICE));
});
// The user page outside a session: a small static server over the public files, /api/config stubbed.
async function userPage(t, config) {
const types = { html: 'text/html', js: 'text/javascript', css: 'text/css', svg: 'image/svg+xml' };
const server = createHttpServer((req, res) => {
const path = new URL(req.url, 'http://x').pathname;
if (path === '/api/config') { res.writeHead(200, { 'content-type': 'application/json' }); return res.end(JSON.stringify(config)); }
if (path.startsWith('/api/chat/')) { res.writeHead(200, { 'content-type': 'application/json' }); return res.end(JSON.stringify({ rows: [], at: new Date().toISOString() })); }
const name = path === '/' ? 'user.html' : path.slice(1);
if (!/^[a-z.]+$/.test(name)) { res.writeHead(404); return res.end(); }
try { const body = readFileSync(new URL(name, PUBLIC)); res.writeHead(200, { 'content-type': types[name.split('.').pop()] ?? 'text/plain' }); res.end(body); }
catch { res.writeHead(404); res.end(); }
});
await new Promise(r => server.listen(0, '127.0.0.1', r));
t.after(() => close(server));
return server.address().port;
}
test('R6 G7 the hub label and plain peer links render from config on both pages', async t => {
const links = 'JSON.stringify({label:document.querySelector("#hub-label")?.textContent,hidden:document.querySelector("#federation").hidden,peers:[...document.querySelectorAll("#federation a")].map(a=>[a.getAttribute("href"),a.textContent,a.attributes.length])})';
const expected = { label: 'This Console: hub', hidden: false, peers: [['https://factory.example.test', 'Software factory', 2]] };
const wait = (b, expression) => b.evaluate(`(async()=>{for(let i=0;i<100;i++){if(${expression})return;await new Promise(r=>setTimeout(r,30));}throw new Error(${JSON.stringify(expression)});})()`);
const b = await browser(); t.after(() => b.close());
// The owner page, over the real server.
const f = await fixture(); t.after(() => f.close());
const owner = await startServer({ port: 0, board: f.boardURL, federation: FEDERATION }); t.after(() => close(owner));
await b.navigate(`http://127.0.0.1:${owner.address().port}/`);
await wait(b, '!document.querySelector("#federation").hidden');
assert.deepEqual(JSON.parse(await b.evaluate(links)), expected);
// The user page, with a hostile label kept as text.
const hostile = { self: 'hub', peers: [...FEDERATION.peers, { id: 'x', origin: 'https://x.example.test', label: '<img src=x onerror="window.injected=1">' }, { id: 'y', origin: 'javascript:alert(1)', label: 'bad' }] };
await b.navigate(`http://127.0.0.1:${await userPage(t, { user: true, send: true, federation: hostile })}/`);
await wait(b, '!document.querySelector("#federation").hidden');
const user = JSON.parse(await b.evaluate(links));
assert.equal(user.label, 'This Console: hub');
assert.deepEqual(user.peers, [...expected.peers, ['https://x.example.test', '<img src=x onerror="window.injected=1">', 2]]);
assert.equal(await b.evaluate('window.injected'), undefined);
assert.equal(await b.evaluate('document.querySelector("#user-notice").textContent'), NOTICE);
// No federation in config: nothing is shown.
await b.navigate(`http://127.0.0.1:${await userPage(t, { user: true, send: true })}/`);
await wait(b, 'document.querySelector(".chat-area")');
assert.equal(await b.evaluate('document.querySelector("#federation").hidden'), true);
});
test('R6 G8 userCall spawns user-cli directly with the user nonce, no human nonce, and the user request', async t => {
const dir = mkdtempSync(join(tmpdir(), 'webui-user-call-'));
t.after(() => rmSync(dir, { recursive: true, force: true }));
const socket = join(dir, 'bus.sock');
const seen = [];
const server = createNetServer(conn => {
let input = '';
conn.on('data', chunk => {
input += chunk;
if (!input.includes('\n')) return;
const message = JSON.parse(input);
const pid = message.user?.pid;
// Read the child while it waits on this answer.
const proc = name => readFileSync(`/proc/${pid}/${name}`, 'utf8');
seen.push({ message, argv: proc('cmdline').split('\0'), env: proc('environ').split('\0').map(e => e.split('=')[0]), ppid: Number(proc('stat').replace(/^.*\) /s, '').split(' ')[1]) });
conn.end(JSON.stringify({ ok: true, result: { answered: true } }) + '\n');
});
});
await new Promise(r => server.listen(socket, r));
t.after(() => server.close());
const result = await userCall({ socket, business: 'demo', user: 'alice', env: { ...process.env, MOSAIC_BUS_CLI_NONCE: 'f'.repeat(64) } })('thread.list', { project: 'p' });
assert.deepEqual(result, { answered: true });
assert.equal(seen.length, 1);
const [{ message, argv, env, ppid }] = seen;
assert.equal(argv[1], USER_CLI);
assert.notEqual(argv[1], HUMAN_CLI);
assert.equal(argv[2], socket);
assert.equal(ppid, process.pid, 'user-cli re-executed: the Console is not its parent');
assert.ok(env.includes('MOSAIC_BUS_USER_NONCE'));
assert.equal(env.includes('MOSAIC_BUS_CLI_NONCE'), false);
assert.deepEqual(Object.keys(message).sort(), ['args', 'user', 'verb']);
assert.equal(message.verb, 'thread.list');
assert.deepEqual(message.args, { project: 'p' });
assert.equal(message.user.business, 'demo');
assert.equal(message.user.user, 'alice');
assert.match(message.user.nonce, /^[0-9a-f]{64}$/);
assert.equal(USER_CLI, fileURLToPath(new URL('../../bus/src/user-cli.mjs', import.meta.url)));
});