Compare commits
4
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
200b30b721 | ||
|
|
ff74bde424 | ||
|
|
85e9fcd894 | ||
|
|
fec416d1df |
No files matched your search
@@ -0,0 +1,212 @@
|
||||
# System-type toolsets at launch
|
||||
|
||||
Status: design only. Nothing here is built and no `roles/` or contract file is
|
||||
changed. Prepared 2026-11-10 against `refactor` `8630b8dfc` for phase P2 of
|
||||
#1873 (unified workspace). PRD requirement: REQ-SYS-2, "An agent working in a
|
||||
project gets the toolsets of the system types that project uses."
|
||||
|
||||
## What exists today
|
||||
|
||||
- **Modules.** `installedSystemTypes()` (`packages/plugins/src/system-types.mjs`)
|
||||
loads every installed `system-type` module and returns
|
||||
`{id, label, description, toolsets}`. It is fail-closed: a refused module, a
|
||||
missing or extra key, or a toolset name that fails `NAME` throws
|
||||
`system-type-invalid`. All five shipped modules (business-management, coding,
|
||||
creative-writing, marketing, montage) declare `toolsets: []`.
|
||||
- **Organization enable.** The organization file's
|
||||
`plugins['system-type']` holds the enabled ids. `plugins.set` writes it
|
||||
through the organization writer behind the owner fence
|
||||
(`organization-system-types-human-only`) and returns a transaction receipt.
|
||||
The bus reads the current set with `system-types.current` on every read and
|
||||
write, so it never trusts a stale snapshot.
|
||||
- **Project selection.** `project.system-types.set` appends a row to
|
||||
`project_system_types` (bus store, `seq`-ordered, latest wins) and accepts
|
||||
only enabled ids (`system-type-not-enabled`). A read returns
|
||||
`{selected, active, inactive}`, where `active = selected ∩ enabled` and
|
||||
`inactive = selected − enabled`.
|
||||
- **Launch.** `resolveInstance` (`packages/organization/src/resolve.mjs`)
|
||||
starts from the role definition's `tools` and `network`, narrows them by
|
||||
each layer's `limits.tools` and `limits.network` (system, business,
|
||||
project, agent), and freezes the result with a digest. The role's `tools`
|
||||
is therefore a ceiling: a launch gets those tools minus whatever a layer
|
||||
limit removes, never more. The launcher (`packages/cli/src/launcher.mjs`) passes that
|
||||
result to `buildBundle` (`packages/harness/src/bundle.mjs`), which writes
|
||||
`policy.json` (`tools: builtinTools(resolved.limits.tools)`), `tools.json`
|
||||
(typed host tools) and `manifest.json` once each (`flag: "wx"`). Typed
|
||||
host tools (`typedTools`, `packages/harness/src/tools.mjs`) are not granted
|
||||
by tool name: each is built only when the resolved `authority` holds the
|
||||
action it needs.
|
||||
- **Run tasks.** `scripts/mosaic-task.mjs` validates a mission's
|
||||
`capabilities.tools` as a ceiling and a task's `capabilities.tools` as the
|
||||
allowlist, both drawn from `SUPPORTED_TOOLS`. A task narrows the mission.
|
||||
It never widens it.
|
||||
|
||||
Nothing on the launch path reads system types today, so every launch gets
|
||||
its role's tools as narrowed by the layer limits. That is the coding
|
||||
module's behavior, and the design keeps it as the default.
|
||||
|
||||
## Resolution at launch
|
||||
|
||||
A launch resolves its toolsets in the host-side launcher, before
|
||||
`resolveInstance` freezes the digest. In order:
|
||||
|
||||
1. **Pick the project.** The launch's project is the one the session is
|
||||
started for: the thread's project for a project thread, or none. A launch
|
||||
with no single project (an organization-wide session, or a role started
|
||||
with several code projects in scope) gets no system-type toolsets. Taking
|
||||
the union across projects would widen one project's agent with another
|
||||
project's tools, so the union is ruled out.
|
||||
2. **Read the active set.** The launcher asks the bus, as the host, for the
|
||||
project's selection and computes `active = selected ∩ enabled`. `enabled`
|
||||
comes from `system-types.current`, the same trusted read the routes use.
|
||||
Inactive selections are dropped and recorded in the manifest. They do not
|
||||
refuse the launch, because the owner may disable a type at any time and
|
||||
that must not strand running work.
|
||||
3. **Load the modules.** `installedSystemTypes()` runs. If it throws
|
||||
`system-type-invalid`, the launch refuses with that code. An active id
|
||||
with no installed module refuses `system-type-missing`.
|
||||
4. **Map toolset names to tools.** Each active module's `toolsets` are looked
|
||||
up in the reviewed mapping file (below). The result is a set of tool names,
|
||||
each a member of the role vocabulary `TOOLS`
|
||||
(`packages/organization/src/vocabulary.mjs`). Typed host tools cannot be
|
||||
named: they stay gated by the role's `authority` actions exactly as today,
|
||||
and a system type never changes `authority`.
|
||||
5. **Bound by the ceilings.** The role's `tools` stays the hard ceiling for
|
||||
every effective tool, including any a system type switches on. A role may
|
||||
hold some of its tools back for typed projects by listing them in
|
||||
`systemTypeTools` (new, see the proposal), which must be a subset of the
|
||||
role's `tools`. Then:
|
||||
- `base` = (role `tools` − `systemTypeTools`), narrowed by every layer's
|
||||
`limits.tools`, as `resolveInstance` does today;
|
||||
- `added` = mapped tools ∩ `systemTypeTools`, narrowed by every layer's
|
||||
`limits.tools` the same way;
|
||||
- `effective` = `base ∪ added`;
|
||||
- for a run task, `effective` is then intersected with the mission's
|
||||
`capabilities.tools` and then the task's. That intersection covers
|
||||
base tools too, as it does today.
|
||||
|
||||
Every term is a subset of role `tools` ∩ every layer limit, so a system
|
||||
type can only switch on a tool the role already holds and the layers
|
||||
already allow. Invariant 7 holds because each later step can only remove
|
||||
entries. A role without `systemTypeTools` behaves exactly as today.
|
||||
6. **Record it.** `resolveInstance` takes the added list as an input and
|
||||
includes `systemTypes: {project, active, dropped, toolsets, added}` in the
|
||||
resolved object, so the digest covers it. `buildBundle` writes the same
|
||||
block into `manifest.json`. The run record then shows which types and
|
||||
mapping produced each tool.
|
||||
|
||||
No step adds credentials, network reach or authority. `limits.network` and
|
||||
`authority` are computed exactly as today. A toolset that needs a credential
|
||||
gets it the way any role does, through the role file's `credentials` and a
|
||||
reviewed commit. A system type never grants one.
|
||||
|
||||
## The mapping file
|
||||
|
||||
The mapping is repository policy, so it lives under `roles/` and changes only
|
||||
through a reviewed commit, the same rule that governs `roles/*.json`
|
||||
(invariant 7, AGENTS.md "Role model"). Proposed path:
|
||||
`roles/toolsets/system-types.json`. It sits in a subdirectory because two
|
||||
loaders read every `roles/*.json` as a role definition
|
||||
(`instance-settings.mjs` `definitions()`, `harness/tests/tools.test.mjs`).
|
||||
A mapping file at `roles/` top level would be read as a broken role.
|
||||
|
||||
Proposed format:
|
||||
|
||||
```json
|
||||
{
|
||||
"toolsetsVersion": 1,
|
||||
"toolsets": {
|
||||
"repository": { "tools": ["read", "write", "edit", "bash", "grep", "find", "ls"] },
|
||||
"web-research": { "tools": ["web"] }
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
The two entries are illustrations, not part of the proposal. No shipped
|
||||
module names a toolset today, so the file would ship as
|
||||
`{"toolsetsVersion": 1, "toolsets": {}}`.
|
||||
|
||||
Rules a loader enforces, fail-closed:
|
||||
|
||||
- Exactly the keys `toolsetsVersion` (must be `1`) and `toolsets`. Each entry
|
||||
has exactly the key `tools`.
|
||||
- Each toolset name matches `NAME` from `packages/plugins/src/kinds.mjs`, the
|
||||
same pattern modules must use.
|
||||
- `tools` is a non-empty list of distinct names, each in `TOOLS`. A typed
|
||||
host tool name, or anything else, refuses `toolset-mapping-invalid` at
|
||||
load.
|
||||
- The file is read from the release copy, like the role files, and its
|
||||
sha256 goes into `manifest.sources`.
|
||||
|
||||
Who may change it: a reviewed commit on the repository, landed by the lead
|
||||
with Jason's authority over role policy. An organization owner cannot change
|
||||
it at runtime, and neither the Console nor the bus has a write path to it.
|
||||
Organization owners choose which types are enabled and projects choose among
|
||||
them. The repository decides what a type's toolsets may contain.
|
||||
|
||||
## Fail-closed behavior
|
||||
|
||||
| Condition | Result |
|
||||
| --- | --- |
|
||||
| An installed module is refused or malformed | launch refuses `system-type-invalid` |
|
||||
| An active id has no installed module | launch refuses `system-type-missing` |
|
||||
| A module names a toolset the mapping lacks | launch refuses `toolset-unmapped` |
|
||||
| The mapping file is missing, unreadable or invalid | launch refuses `toolset-mapping-invalid`, but only when at least one active type declares a toolset; a launch with nothing to map never reads it |
|
||||
| A mapped tool is outside the role's `systemTypeTools` (and so not switched on) | the tool is dropped and recorded under `dropped`, launch continues |
|
||||
| The bus cannot answer the project's selection | launch refuses `system-types-unavailable`; it does not fall back to "no types", which could hide a missing toolset the operator expects |
|
||||
| A selection is inactive (disabled in the organization) | dropped and recorded, launch continues |
|
||||
|
||||
The two "continue" rows are narrowing, never widening. Every row that could
|
||||
change what an agent may do refuses instead.
|
||||
|
||||
## The coding module
|
||||
|
||||
Coding declares `toolsets: []`. It adds nothing, and a coding project's agent
|
||||
keeps its role's tools as the layer limits narrow them: for
|
||||
`roles/coder.json` the ceiling is `read, write, edit, bash, grep, find, ls`
|
||||
with network `api-only`, the same as today. Turning the design on changes nothing for any shipped module until a
|
||||
module declares a toolset and a reviewed commit maps it.
|
||||
|
||||
## Proposed `roles/` change (not applied)
|
||||
|
||||
1. Add an optional V2 key `systemTypeTools` to the role schema
|
||||
(`packages/organization/src/role.mjs`): a distinct list from `TOOLS` (same
|
||||
check as `checkTools`, but empty is allowed) that must be a subset of the
|
||||
role's own `tools`, or the role file refuses at load. It names the tools
|
||||
the role holds back unless an active system type maps them. Absent means
|
||||
`[]`: nothing is held back and no system type changes that role's tools.
|
||||
Every existing role file stays valid and behaves as today, and
|
||||
`checkCreative` keeps inspecting `tools`, which still bounds everything.
|
||||
|
||||
```diff
|
||||
const V2_REQUIRED = ["roleVersion", "name", "title", "contract", "tools", "network", "authority", "credentials"];
|
||||
-const V2_KEYS = [...V2_REQUIRED, "messaging", "skills", "hireable"];
|
||||
+const V2_KEYS = [...V2_REQUIRED, "messaging", "skills", "hireable", "systemTypeTools"];
|
||||
```
|
||||
|
||||
2. Add `roles/toolsets/system-types.json` with an empty `toolsets` object, and
|
||||
a loader beside the role loader with the checks listed above.
|
||||
3. Have `resolveInstance` accept `systemTypes` (the launcher's step 1-4
|
||||
result) and apply step 5. Have `buildBundle` copy the block into the
|
||||
manifest. `scripts/mosaic-task.mjs` already bounds tools by mission and
|
||||
task. It would apply that filter to the final effective set.
|
||||
|
||||
No contract under `contracts/` changes: the constitution's least-privilege
|
||||
rule already covers this. The design adds no grant path: the effective set
|
||||
stays within role `tools` and every layer limit, and `authority`, network and
|
||||
credentials are untouched. What it does add is a runtime input (the
|
||||
project's active system types) that can switch a held-back tool on, so a
|
||||
role author who lists a tool in `systemTypeTools` is deciding that an
|
||||
organization owner's enable plus a project's selection may turn it on. Each of the
|
||||
three steps is a reviewed change to role policy and needs Jason's sign-off
|
||||
before it is built.
|
||||
|
||||
## Open points for the build
|
||||
|
||||
- Business-file projects (`b.projects`, used for checkouts) and bus projects
|
||||
(UUIDs in `project_system_types`) are separate records today. Step 1 needs
|
||||
the launch to carry the bus project id of the thread it serves. The build
|
||||
should confirm how a project thread names its project at launch before
|
||||
wiring step 2.
|
||||
- A running session keeps the toolsets it launched with. Disabling a type
|
||||
takes effect at the next launch, which matches how role changes behave.
|
||||
@@ -1,2 +0,0 @@
|
||||
// Old path of packages/organization/src/accounts.mjs (rename design r2, stage B).
|
||||
export * from "../../organization/src/accounts.mjs";
|
||||
@@ -1298,32 +1298,3 @@ shows selected, active and inactive ids. The owner replaces the selection with
|
||||
`mosaic project system-types-set PROJECT --types coding,montage --org ID`; an
|
||||
empty `--types ''` clears it. Disabled selections stay recorded but become
|
||||
inactive. Project settings changes are append-only and require human proof.
|
||||
|
||||
## Accounts
|
||||
|
||||
`mosaic accounts` administers the instance-level accounts store locally, as the
|
||||
effective uid owning the config directory. It requires a human shell, rejects `--tester`,
|
||||
and takes no business selector or bus connection. It has no organization rights
|
||||
verbs. Console sign-in, sessions and membership evaluation are later Phase M
|
||||
slices; these commands alone do not turn accounts mode on.
|
||||
|
||||
```sh
|
||||
mosaic accounts init --name Operator
|
||||
mosaic accounts list --json
|
||||
mosaic accounts user add --name Pat --subject pocket:verified-subject --revision 1
|
||||
mosaic accounts user disable u-0123456789ab --revision 2
|
||||
mosaic accounts user enable u-0123456789ab --revision 3
|
||||
mosaic accounts user signout u-0123456789ab --revision 4
|
||||
mosaic accounts user remove u-0123456789ab --revision 5
|
||||
```
|
||||
|
||||
Init creates one operator account containing every configured `console.sso`
|
||||
subject and refuses if accounts.json already exists. Add mints its id and defaults
|
||||
to instance role `user`; `--role operator` creates another operator. Writes require
|
||||
the file revision from list. Disable, enable and signout advance `authGen`, and
|
||||
the last active operator cannot be disabled or removed. Removal scans ownership
|
||||
while holding the registry then accounts locks, refuses if the account owns an
|
||||
organization (including archived organizations) or any registry entry lacks its
|
||||
owner metadata during creation, and removes its memberships in
|
||||
the same write. List reports rollback rows needing repair; its JSON omits invite
|
||||
bindings and hashes. Receipts record effective process uid and verb and omit subjects and secrets.
|
||||
@@ -1,109 +0,0 @@
|
||||
// Phase M2: the local, instance-uid account administration exception. No org
|
||||
// rights commands and no bus transport: account init must work before orgs exist.
|
||||
import { basename, join } from 'node:path';
|
||||
import { configDir } from '../../organization/src/organization-config.mjs';
|
||||
import { accountsRepairs, assertAccountsOwner, loadAccounts, mintAccountId, withAccounts } from '../../organization/src/accounts.mjs';
|
||||
import { withRegistry, readRegistryLocked } from '../../organization/src/org-registry.mjs';
|
||||
import { readJsonFile, rejectUnknownKeys, requireString } from '../../organization/src/util.mjs';
|
||||
import { CliError } from './errors.mjs';
|
||||
import { refuseInsideAgent } from './transport.mjs';
|
||||
|
||||
export const ACCOUNTS_USAGE = [
|
||||
'mosaic accounts init [--name NAME]',
|
||||
'mosaic accounts list [--json]',
|
||||
'mosaic accounts user add --name NAME --subject PROVIDER:SUB [--role operator|user] --revision N',
|
||||
'mosaic accounts user disable|enable|signout|remove ID --revision N',
|
||||
].join('\n');
|
||||
function parse(args) {
|
||||
const words = [], values = {}, flags = new Set();
|
||||
for (let i = 0; i < args.length; i++) {
|
||||
const arg = args[i];
|
||||
if (!arg.startsWith('--')) { words.push(arg); continue; }
|
||||
if (flags.has(arg) || Object.hasOwn(values, arg)) throw new CliError(`duplicate ${arg}`, 4);
|
||||
if (arg === '--tester') throw new CliError('refused: accounts takes the human shell, not --tester', 3);
|
||||
if (arg === '--json') flags.add(arg);
|
||||
else if (['--name', '--subject', '--role', '--revision'].includes(arg) && args[i + 1] && !args[i + 1].startsWith('--')) values[arg] = args[++i];
|
||||
else throw new CliError(ACCOUNTS_USAGE, 4);
|
||||
}
|
||||
const verb = words[0] === 'user' ? `user.${words[1]}` : words[0];
|
||||
const valid = ['init', 'list'].includes(verb) ? words.length === 1 : verb === 'user.add' ? words.length === 2 : ['user.disable', 'user.enable', 'user.signout', 'user.remove'].includes(verb) && words.length === 3;
|
||||
if (!valid) throw new CliError(ACCOUNTS_USAGE, 4);
|
||||
const allowed = verb === 'init' ? ['--name'] : verb === 'list' ? [] : verb === 'user.add' ? ['--name', '--subject', '--role', '--revision'] : ['--revision'];
|
||||
if (Object.keys(values).some(k => !allowed.includes(k)) || (flags.size && verb !== 'list')) throw new CliError(ACCOUNTS_USAGE, 4);
|
||||
const revision = verb === 'init' ? 0 : Number(values['--revision']);
|
||||
if (verb !== 'list' && verb !== 'init' && (!/^[1-9][0-9]*$/.test(values['--revision'] ?? '') || !Number.isSafeInteger(revision))) throw new CliError('--revision requires the revision from accounts list', 4);
|
||||
return { verb, values, flags, revision, id: words[2] };
|
||||
}
|
||||
function owns(user, owner, providers) {
|
||||
return user.subjects.some(s => s.provider === owner.provider && s.sub === owner.sub && providers.find(p => p.id === s.provider)?.issuer === owner.iss);
|
||||
}
|
||||
function readOwner(root) {
|
||||
const owner = readJsonFile(join(root, 'owner.json'), 'organization owner', st => {
|
||||
if (st.uid !== process.geteuid() || (st.mode & 0o777) !== 0o600) throw new CliError('refused: unsafe organization owner', 3);
|
||||
});
|
||||
rejectUnknownKeys(owner, ['provider', 'iss', 'sub'], 'organization owner');
|
||||
for (const key of ['provider', 'iss', 'sub']) requireString(owner[key], `owner.${key}`, { max: 2048 });
|
||||
return owner;
|
||||
}
|
||||
export async function accountsCommand(args, io, deps) {
|
||||
refuseInsideAgent(io.env);
|
||||
try {
|
||||
const parsed = parse(args);
|
||||
const dir = deps.configDir ?? configDir(io.env);
|
||||
// This is deliberately before withRegistry as well as withAccounts.
|
||||
assertAccountsOwner(dir, { uid: deps.uid ?? process.geteuid() });
|
||||
const providers = deps.system.console?.sso?.providers ?? [];
|
||||
const ctx = { configDir: dir, instance: deps.instance ?? basename(dir).replace(/^mosaic-/, ''), providers };
|
||||
if (parsed.verb === 'list') {
|
||||
const doc = loadAccounts(join(dir, 'accounts.json'), ctx);
|
||||
const repairs = accountsRepairs(doc);
|
||||
// Invites are not administered here. Never expose bindings or token hashes.
|
||||
const listed = { ...doc, invites: doc.invites.map(({ tokenHash, bind, ...invite }) => invite), repairs };
|
||||
io.stdout.write(parsed.flags.has('--json') ? JSON.stringify(listed, null, 2) + '\n' : [`accounts revision ${doc.revision}`, ...doc.users.map(u => `${u.id} ${u.name} ${u.instanceRole} ${u.state} authGen ${u.authGen}`), ...repairs.map(r => `${r.kind} ${r.org} needs repair: ${[r.unknownRole, ...r.unknownRights].filter(Boolean).join(', ')}`)].join('\n') + '\n');
|
||||
return;
|
||||
}
|
||||
let added;
|
||||
const mutate = doc => {
|
||||
if (parsed.verb === 'init') {
|
||||
const subjects = providers.flatMap(p => (p.subjects ?? []).map(s => ({ provider: p.id, sub: s.sub })));
|
||||
if (!subjects.length) throw new CliError('refused: init requires console.sso subjects', 3);
|
||||
added = { id: mintAccountId(), name: parsed.values['--name'] ?? 'Operator', subjects, instanceRole: 'operator', state: 'active', authGen: 1, created: new Date().toISOString() };
|
||||
doc.users.push(added); return;
|
||||
}
|
||||
if (parsed.verb === 'user.add') {
|
||||
const value = parsed.values['--subject'] ?? '', colon = value.indexOf(':');
|
||||
if (!parsed.values['--name'] || colon < 1 || colon === value.length - 1) throw new CliError('user add needs --name and --subject PROVIDER:SUB', 4);
|
||||
added = { id: mintAccountId(), name: parsed.values['--name'], subjects: [{ provider: value.slice(0, colon), sub: value.slice(colon + 1) }], instanceRole: parsed.values['--role'] ?? 'user', state: 'active', authGen: 1, created: new Date().toISOString() };
|
||||
doc.users.push(added); return;
|
||||
}
|
||||
const u = doc.users.find(u => u.id === parsed.id);
|
||||
if (!u) throw new CliError('refused: account not found', 2);
|
||||
if (parsed.verb === 'user.remove') { doc.users = doc.users.filter(x => x !== u); doc.memberships = doc.memberships.filter(m => m.user !== u.id); }
|
||||
else if (parsed.verb === 'user.signout') u.authGen++;
|
||||
else { const state = parsed.verb === 'user.disable' ? 'disabled' : 'active'; if (u.state === state) throw new CliError('refused: account already has that state', 2); u.state = state; }
|
||||
};
|
||||
const opts = { revision: parsed.revision, verb: parsed.verb, init: parsed.verb === 'init' };
|
||||
let result;
|
||||
if (parsed.verb === 'user.remove') {
|
||||
result = await withRegistry(ctx, registry => withAccounts(ctx, { ...opts, registry }, async doc => {
|
||||
const user = doc.users.find(u => u.id === parsed.id);
|
||||
if (!user) throw new CliError('refused: account not found', 2);
|
||||
// Even disabled accounts keep ownership. A real create reserves before
|
||||
// writing owner.json, so any missing owner must block removal too.
|
||||
for (const entry of Object.values((await readRegistryLocked(registry)).orgs)) {
|
||||
let owner;
|
||||
try { owner = readOwner(entry.root); }
|
||||
catch (e) { if (/not found/.test(e.message)) throw new CliError(`refused: org-create-in-progress: ${entry.id} has no owner metadata`, 3); throw e; }
|
||||
if (owns(user, owner, providers)) throw new CliError(`refused: user-owns-org: ${entry.id}`, 3);
|
||||
}
|
||||
mutate(doc);
|
||||
}), { write: false });
|
||||
} else result = await withAccounts(ctx, opts, mutate);
|
||||
io.stdout.write(`accounts ${parsed.verb.replace('.', ' ')}: ${added?.id ?? parsed.id ?? ''}, revision ${result.revision}\n`);
|
||||
} catch (e) {
|
||||
if (e instanceof CliError) throw e;
|
||||
const error = new CliError(`refused: ${e.code ? e.code + ': ' : ''}${e.message}${e.committed ? '; accounts write committed; durability or receipt step failed; inspect before retrying' : ''}`, 3);
|
||||
if (e.committed) error.committed = true;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
@@ -24,7 +24,6 @@ import { hostsCommand, HOSTS_USAGE } from "./hosts.mjs";
|
||||
import { grantsCommand, GRANTS_USAGE } from "./grants.mjs";
|
||||
import { instancesCommand, orgInstancesCommand, ORG_INSTANCES_USAGE } from "./instances.mjs";
|
||||
import { accessCommand, accessContextFor } from "./access.mjs";
|
||||
import { accountsCommand, ACCOUNTS_USAGE } from "./accounts.mjs";
|
||||
import { createChannelCaller } from "../../plugins/src/channel.mjs";
|
||||
import { PluginError } from "../../plugins/src/kinds.mjs";
|
||||
import { CliError, CliExit } from "./errors.mjs";
|
||||
@@ -53,7 +52,6 @@ import { devCommand, DEV_USAGE, fixtureLaunchEnv, fixtureLauncherOptions } from
|
||||
import { runCommand, RUN_USAGE } from "./run-evidence.mjs";
|
||||
|
||||
export const USAGE = `usage:
|
||||
${ACCOUNTS_USAGE}
|
||||
${ORG_USAGE}
|
||||
${USAGE_USAGE}
|
||||
${LIMITS_USAGE}
|
||||
@@ -747,7 +745,6 @@ export async function main(argv, io = { env: process.env, stdin: process.stdin,
|
||||
now: deps.now,
|
||||
});
|
||||
}
|
||||
if (verb === 'accounts') return accountsCommand(rest, io, { system: deps.system ?? loadSystem({ env: io.env }), configDir: deps.accountsConfigDir });
|
||||
if (verb === "limits") return limitsCommand(rest,io,p=>callFor(p,io,deps));
|
||||
if (verb === "usage") return usageCommand(rest, io, p => callFor(p, io, deps));
|
||||
if (verb === "plugins") return pluginsCommand(rest, io, (p) => callFor(p, io, deps));
|
||||
|
||||
@@ -1,71 +0,0 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { mkdtempSync, chmodSync, writeFileSync, readFileSync, mkdirSync, existsSync, rmSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { accountsCommand } from '../src/accounts.mjs';
|
||||
import { main } from '../src/cli.mjs';
|
||||
import { withRegistry, reserveOrg } from '../../organization/src/org-registry.mjs';
|
||||
const providers = [{ id: 'pocket', issuer: 'https://id.example', subjects: [{ sub: 'owner' }, { sub: 'other-owner' }] }, { id: 'second', issuer: 'https://second.example', subjects: [{ sub: 'owner' }] }];
|
||||
function setup(t) {
|
||||
const configDir = mkdtempSync(join(tmpdir(), 'a-')); chmodSync(configDir, 0o700); t.after(() => rmSync(configDir, { recursive: true, force: true }));
|
||||
const deps = { configDir, instance: 'test', system: { console: { sso: { providers } } } }; let output = '';
|
||||
const io = { env: {}, stdout: { write: s => output += s }, stderr: { write: () => {} } };
|
||||
return { configDir, deps, io, run: args => accountsCommand(args, io, deps), read: () => JSON.parse(readFileSync(join(configDir, 'accounts.json'))), output: () => output };
|
||||
}
|
||||
test('init makes exactly one operator from all configured subjects, then refuses rerun', async t => {
|
||||
const s = setup(t); await s.run(['init']); const d = s.read(); assert.equal(d.users.length, 1); assert.equal(d.users[0].instanceRole, 'operator'); assert.equal(d.users[0].subjects.length, 3); assert.match(d.users[0].id, /^u-[0-9a-f]{12}$/); assert.equal(d.revision, 1);
|
||||
await assert.rejects(s.run(['init']), /already exists/); await s.run(['list', '--json']); assert.match(s.output(), /authGen/);
|
||||
const r = JSON.parse(readFileSync(join(s.configDir, 'accounts-receipts.jsonl'), 'utf8')); assert.equal(r.uid, process.getuid()); assert.equal(r.verb, 'init'); assert.ok(!JSON.stringify(r).includes('other-owner'));
|
||||
});
|
||||
test('CLI add, disable, enable, signout and remove; last operator guard and stale revisions', async t => {
|
||||
const s = setup(t); await s.run(['init']); const op = s.read().users[0].id;
|
||||
await s.run(['user', 'add', '--name', 'Pat', '--subject', 'pocket:pat:sub', '--revision', '1']); const id = s.read().users[1].id;
|
||||
for (const [verb, revision, generation] of [['disable', 2, 2], ['enable', 3, 3], ['signout', 4, 4]]) { await s.run(['user', verb, id, '--revision', String(revision)]); assert.equal(s.read().users[1].authGen, generation); }
|
||||
await assert.rejects(s.run(['user', 'disable', op, '--revision', '5']), /active operator/);
|
||||
await assert.rejects(s.run(['user', 'signout', id, '--revision', '1']), /revision-conflict/);
|
||||
await s.run(['user', 'remove', id, '--revision', '5']); assert.equal(s.read().users.length, 1); assert.equal(s.read().revision, 6);
|
||||
assert.equal(readFileSync(join(s.configDir, 'accounts-receipts.jsonl'), 'utf8').trim().split('\n').length, 6);
|
||||
});
|
||||
test('host uid refusal happens before any lock, even on list or remove', async t => {
|
||||
const s = setup(t); for (const args of [['list'], ['init'], ['user', 'remove', 'u-000000000001', '--revision', '1']]) {
|
||||
await assert.rejects(accountsCommand(args, s.io, { ...s.deps, uid: process.getuid() + 1 }), /uid/);
|
||||
assert.equal(existsSync(join(s.configDir, 'accounts.lock')), false); assert.equal(existsSync(join(s.configDir, 'org-runtime.lock')), false);
|
||||
}
|
||||
});
|
||||
test('remove holds registry before accounts, refuses ownership of active or archived orgs, including a preceding create', async t => {
|
||||
const s = setup(t); await s.run(['init']); await s.run(['user', 'add', '--name', 'Pat', '--subject', 'pocket:pat', '--revision', '1']); const id = s.read().users[1].id;
|
||||
const root = join(s.configDir, 'acme'); mkdirSync(root, { mode: 0o700 });
|
||||
let release, entered; const ready = new Promise(r => entered = r), hold = new Promise(r => release = r);
|
||||
const create = withRegistry(s.deps, async registry => { entered(); await hold; registry.orgs.acme = { id: 'acme', root, state: 'archived', revision: 1, created: '2026-11-10T00:00:00Z' }; writeFileSync(join(root, 'owner.json'), JSON.stringify({ provider: 'pocket', iss: 'https://id.example', sub: 'pat' }), { mode: 0o600 }); });
|
||||
await ready; const removing = s.run(['user', 'remove', id, '--revision', '2']); release(); await create; await assert.rejects(removing, /user-owns-org/); assert.equal(s.read().revision, 2);
|
||||
});
|
||||
test('remove drops memberships atomically, and malformed ownership metadata refuses', async t => {
|
||||
const s = setup(t); await s.run(['init']); await s.run(['user', 'add', '--name', 'Pat', '--subject', 'pocket:pat', '--revision', '1']); const d = s.read(), id = d.users[1].id;
|
||||
d.memberships.push({ org: 'acme', incarnation: 'a'.repeat(32), user: id, role: 'member', rights: ['org.view'], scope: { projects: ['*'], instances: ['*'] }, rev: 1, by: d.users[0].id, added: '2026-11-10T00:00:00Z', via: 'invite:abc' }); writeFileSync(join(s.configDir, 'accounts.json'), JSON.stringify(d));
|
||||
await s.run(['user', 'remove', id, '--revision', '2']); assert.deepEqual(s.read().memberships, []);
|
||||
const root = join(s.configDir, 'broken'); mkdirSync(root, { mode: 0o700 });
|
||||
await withRegistry(s.deps, async registry => { registry.orgs.broken = { id: 'broken', root, state: 'active', revision: 1, created: 'now' }; writeFileSync(join(root, 'owner.json'), '{}', { mode: 0o600 }); });
|
||||
await assert.rejects(s.run(['user', 'remove', s.read().users[0].id, '--revision', '3']), /owner/);
|
||||
});
|
||||
test('mosaic main wires accounts without a bus; tester, agent and unexpected args refuse', async t => {
|
||||
const s = setup(t); await main(['accounts', 'init'], s.io, { system: s.deps.system, accountsConfigDir: s.configDir }); assert.equal(s.read().users.length, 1);
|
||||
for (const args of [['list', '--tester'], ['list', '--subject', 'pocket:x'], ['user', 'add', 'chosen-id', '--name', 'Pat', '--subject', 'pocket:x', '--revision', '1'], ['list', '--json', '--json']]) await assert.rejects(s.run(args));
|
||||
await assert.rejects(accountsCommand(['list'], { ...s.io, env: { MOSAIC_RUN_ID: 'run' } }, s.deps), /human shell/);
|
||||
});
|
||||
|
||||
|
||||
test('review P2-2: real reservation wins before owner.json exists, and removal refuses', async t => {
|
||||
const s = setup(t); await s.run(['init']); await s.run(['user', 'add', '--name', 'Pat', '--subject', 'pocket:pat', '--revision', '1']); const id = s.read().users[1].id;
|
||||
const dataRoot = join(s.configDir, 'data'); mkdirSync(dataRoot, { mode: 0o700 }); const ctx = { configDir: s.configDir, instance: 'test', dataRoot };
|
||||
const org = await reserveOrg(ctx, 'acme'); assert.equal(existsSync(join(org.root, 'owner.json')), false);
|
||||
await assert.rejects(s.run(['user', 'remove', id, '--revision', '2']), /org-create-in-progress/); assert.equal(s.read().users.length, 2); assert.equal(s.read().revision, 2);
|
||||
});
|
||||
|
||||
|
||||
test('host boundary and receipt use effective uid, not real uid (current design clarification)', async t => {
|
||||
const s = setup(t), realUid = process.getuid, effectiveUid = process.geteuid();
|
||||
process.getuid = () => effectiveUid + 1;
|
||||
try { await s.run(['init']); await s.run(['list']); const receipt = JSON.parse(readFileSync(join(s.configDir, 'accounts-receipts.jsonl'), 'utf8')); assert.equal(receipt.uid, effectiveUid); }
|
||||
finally { process.getuid = realUid; }
|
||||
});
|
||||
@@ -7,9 +7,8 @@ stay as the old names: each module there re-exports the module of the same name
|
||||
here (`src/business.mjs` re-exports `src/organization-config.mjs`), and its
|
||||
`src/cli.mjs` runs this package's `src/cli.mjs`. Brief: `docs/plans/2026-10-04_slice-1.md`,
|
||||
row S1. Design: `agents/darkwing/work/slice1-data-model-2026-10-04.md` and its
|
||||
addenda A and B. Configuration readers refuse on any problem. Runtime
|
||||
organization and accounts stores provide explicit locked writes; this package
|
||||
never opens a token file.
|
||||
addenda A and B. The package reads files and refuses on any problem. It
|
||||
never writes, and it never opens a token file.
|
||||
|
||||
```sh
|
||||
scripts/mosaic org config validate mosaic-stack
|
||||
@@ -584,44 +583,3 @@ The optional `plugins.system-type` list enables installed system-type modules fo
|
||||
an organization. Missing means none. Unknown ids refuse validation. Changes
|
||||
are owner-only; project selections are separate bus settings and a selection
|
||||
whose type is disabled remains recorded as inactive.
|
||||
|
||||
## Instance accounts (Phase M2)
|
||||
|
||||
`src/accounts.mjs` validates `<configDir>/accounts.json` without a dependency or
|
||||
JSON schema engine, as `access.mjs` does. `checkAccounts(document, {providers})`
|
||||
and `loadAccounts(file, {providers})` return frozen documents. Providers are the
|
||||
configured `console.sso.providers` objects (including issuer). Reads refuse an
|
||||
unsafe config directory, unsafe file, unknown field or malformed row. Account
|
||||
ids may be organization humans. `accountsRepairs(document)` reports unknown
|
||||
roles and rights retained on read for rollback; these rows must grant nothing.
|
||||
M4 supplies the authorization evaluator; M2 alone grants no Console or bus access.
|
||||
|
||||
`withAccounts(ctx, {revision, verb, actor?, registry?, init?}, mutate)` takes
|
||||
`accounts.lock`, reloads, checks the expected file revision, awaits `mutate` on a
|
||||
copy, maintains `authGen` and membership `rev`, validates and atomically replaces
|
||||
0600 bytes. Init publishes with a no-clobber hard link; replacements use rename.
|
||||
Both fsync the config directory before the receipt is appended. `ctx` contains `configDir`, `instance`, and `providers`. Init requires
|
||||
revision 0 and an absent file. Other writes require an existing valid file. Writes
|
||||
refuse unknown rights/roles on new or changed rows. Unchanged rollback rows pass
|
||||
through exactly, so they cannot block account revocation or another row's repair. Role templates are never evaluated as authority by this store.
|
||||
|
||||
For a caller already holding accounts.lock,
|
||||
`withAccountsLock(ctx, handle => withAccountsLocked(handle, options, mutate))`
|
||||
reuses that live opaque handle. Never acquire an earlier lock from the callback.
|
||||
The config path must have no symlink ancestors, including the home directory.
|
||||
The order is lifecycle, registry, accounts. Registry-dependent writes pass the
|
||||
live document from `withRegistry`; `readRegistryLocked(registry, ctx?)` verifies
|
||||
the lock and rereads its persisted snapshot without relocking. A changed registry
|
||||
or an instance mismatch refuses before account publication. Callbacks cannot
|
||||
reuse a released handle, or run two writes at once through one handle.
|
||||
|
||||
Each committed write appends and fsyncs one receipt under the accounts lock in
|
||||
`accounts-receipts.jsonl`: effective OS uid, actor id, verb, old/new file revisions, changed
|
||||
account ids with changed field names, and before/after membership rights, scope
|
||||
and revisions. No account
|
||||
name, subject, invite binding, hash, raw token or cookie enters the receipt. The
|
||||
store preflights the receipt destination before replacing accounts. As with
|
||||
other two-file journals, a crash or IO failure between replacement and receipt
|
||||
append can leave committed state without its receipt; an observed append failure
|
||||
has `committed: true` and must be inspected before retrying. It never rolls back
|
||||
committed authority silently or fabricates a success receipt.
|
||||
@@ -1,266 +0,0 @@
|
||||
// Phase M2 (#1873): instance accounts, independent of Console sessions and bus authority.
|
||||
import { constants, lstatSync } from 'node:fs';
|
||||
import * as fs from 'node:fs/promises';
|
||||
import { dirname, join, parse, resolve } from 'node:path';
|
||||
import { randomBytes } from 'node:crypto';
|
||||
import { OrgError, readRegistryLocked } from './org-registry.mjs';
|
||||
import { readJsonFile, requireObject, rejectUnknownKeys, requireString, requirePositiveInt, requireId, requireDistinctList, deepFreeze, canonicalJson } from './util.mjs';
|
||||
import { RIGHTS, ROLE_RIGHTS, normalizeRights } from './org-rights.mjs';
|
||||
|
||||
const fail = (code, message = code) => { throw new OrgError(code, message); };
|
||||
const bad = message => fail('accounts-invalid', message);
|
||||
const USER_ID = /^u-[0-9a-f]{12}$/;
|
||||
const HEX32 = /^[0-9a-f]{32}$/;
|
||||
const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
|
||||
const same = (a, b) => canonicalJson(a) === canonicalJson(b);
|
||||
const array = (x, at) => { if (!Array.isArray(x)) bad(`${at} must be an array`); return x; };
|
||||
const text = (x, at) => {
|
||||
requireString(x, at, { max: 255 });
|
||||
if (/[\u0000-\u001f\u007f-\u009f\u061c\u200e\u200f\u202a-\u202e\u2066-\u2069]/.test(x)) bad(`${at} must not contain control or bidi formatting characters`);
|
||||
return x;
|
||||
};
|
||||
const keys = (x, allowed, at) => { requireObject(x, at); rejectUnknownKeys(x, allowed, at); };
|
||||
const accountId = (x, at) => { if (typeof x !== 'string' || !USER_ID.test(x)) bad(`${at} must be u- plus 12 lowercase hex characters`); };
|
||||
function time(x, at) {
|
||||
if (typeof x !== 'string' || !/^\d{4}-\d\d-\d\dT\d\d:\d\d:\d\d(?:\.\d{1,3})?Z$/.test(x) || !Number.isFinite(Date.parse(x)) || new Date(x).toISOString().slice(0, 19) !== x.slice(0, 19)) bad(`${at} must be a real ISO time`);
|
||||
}
|
||||
function providerMap(context) {
|
||||
const providers = new Map();
|
||||
for (const p of array(context?.providers, 'accounts context.providers')) {
|
||||
text(p.id, 'provider.id');
|
||||
if (p.id === 'local-dev' || typeof p.issuer !== 'string' || !p.issuer.startsWith('https://')) bad('provider must have an https issuer and must not be local-dev');
|
||||
try { if (new URL(p.issuer).protocol !== 'https:') bad('invalid issuer'); } catch { bad('invalid issuer'); }
|
||||
if (providers.has(p.id)) bad('duplicate configured provider');
|
||||
providers.set(p.id, p);
|
||||
}
|
||||
return providers;
|
||||
}
|
||||
function subject(s, at, providers) {
|
||||
keys(s, ['provider', 'sub'], at);
|
||||
if (s.provider === 'local-dev' || !providers.has(s.provider)) bad(`${at}.provider must be a configured provider, never local-dev`);
|
||||
text(s.sub, `${at}.sub`);
|
||||
}
|
||||
function scope(s, at) {
|
||||
keys(s, ['projects', 'instances', 'expires'], at);
|
||||
for (const key of ['projects', 'instances']) {
|
||||
const entries = requireDistinctList(s[key], `${at}.${key}`, x => {
|
||||
if (x === '*') return;
|
||||
if (key === 'projects') { if (typeof x !== 'string' || !UUID.test(x)) bad(`${at}.projects must contain UUIDs`); }
|
||||
else requireId(x, `${at}.instances`);
|
||||
}, { nonEmpty: true });
|
||||
if (entries.includes('*') && entries.length !== 1) bad(`${at}.${key} must use "*" alone`);
|
||||
}
|
||||
if (s.expires !== undefined) time(s.expires, `${at}.expires`);
|
||||
}
|
||||
function rowRights(row, at) {
|
||||
requireId(row.role, `${at}.role`);
|
||||
if (row.role === 'owner') bad(`${at}.role is not grantable`);
|
||||
const rights = requireDistinctList(row.rights, `${at}.rights`, r => {
|
||||
text(r, `${at}.rights`);
|
||||
if (r === 'owner' || (Object.hasOwn(RIGHTS, r) && !RIGHTS[r].grantable)) bad(`${at}.rights is not grantable`);
|
||||
if (!/^[a-z][a-z0-9]*(?:\.[a-z][a-z0-9]*)*$/.test(r)) bad(`${at}.rights contains an invalid name`);
|
||||
}, { nonEmpty: true });
|
||||
if (!rights.includes('org.view') || !same(rights, [...rights].sort())) bad(`${at}.rights must be sorted and contain org.view`);
|
||||
scope(row.scope, `${at}.scope`);
|
||||
requireId(row.org, `${at}.org`);
|
||||
if (typeof row.incarnation !== 'string' || !HEX32.test(row.incarnation)) bad(`${at}.incarnation must be 32 lowercase hex characters`);
|
||||
}
|
||||
|
||||
// Pure whole-file validation. Unknown, well-formed rights/roles survive a rollback;
|
||||
// accountsRepairs exposes them without changing the persisted schema.
|
||||
export function checkAccounts(document, context) {
|
||||
const providers = providerMap(context);
|
||||
keys(document, ['accountsVersion', 'revision', 'users', 'memberships', 'invites'], 'accounts file');
|
||||
if (document.accountsVersion !== 1) bad('accountsVersion must be 1');
|
||||
requirePositiveInt(document.revision, 'revision');
|
||||
const users = new Set(), subjects = new Set(); let operators = 0;
|
||||
array(document.users, 'users').forEach((u, i) => {
|
||||
const at = `users[${i}]`;
|
||||
keys(u, ['id', 'subjects', 'name', 'instanceRole', 'state', 'authGen', 'created'], at);
|
||||
accountId(u.id, `${at}.id`); if (users.has(u.id)) bad('duplicate account id'); users.add(u.id);
|
||||
text(u.name, `${at}.name`);
|
||||
if (!['operator', 'user'].includes(u.instanceRole)) bad(`${at}.instanceRole must be operator or user`);
|
||||
if (!['active', 'disabled'].includes(u.state)) bad(`${at}.state must be active or disabled`);
|
||||
requirePositiveInt(u.authGen, `${at}.authGen`); time(u.created, `${at}.created`);
|
||||
if (u.instanceRole === 'operator' && u.state === 'active') operators++;
|
||||
array(u.subjects, `${at}.subjects`).forEach((s, j) => {
|
||||
subject(s, `${at}.subjects[${j}]`, providers); const key = JSON.stringify([s.provider, s.sub]);
|
||||
if (subjects.has(key)) bad('subject appears more than once in the file'); subjects.add(key);
|
||||
});
|
||||
if (!u.subjects.length) bad(`${at}.subjects must not be empty`);
|
||||
});
|
||||
if (!operators) bad('at least one active operator must exist');
|
||||
const memberships = new Set();
|
||||
array(document.memberships, 'memberships').forEach((m, i) => {
|
||||
const at = `memberships[${i}]`;
|
||||
keys(m, ['org', 'incarnation', 'user', 'role', 'rights', 'scope', 'rev', 'added', 'by', 'via'], at);
|
||||
rowRights(m, at); accountId(m.user, `${at}.user`); if (!users.has(m.user)) bad(`${at}.user must name an account`);
|
||||
accountId(m.by, `${at}.by`); text(m.via, `${at}.via`); time(m.added, `${at}.added`); requirePositiveInt(m.rev, `${at}.rev`);
|
||||
const key = JSON.stringify([m.org, m.incarnation, m.user]); if (memberships.has(key)) bad('duplicate membership'); memberships.add(key);
|
||||
});
|
||||
const invites = new Set(), hashes = new Set();
|
||||
array(document.invites, 'invites').forEach((invite, i) => {
|
||||
const at = `invites[${i}]`;
|
||||
keys(invite, ['id', 'org', 'incarnation', 'role', 'rights', 'scope', 'bind', 'tokenHash', 'by', 'created', 'expires', 'state', 'user', 'used'], at);
|
||||
rowRights(invite, at); text(invite.id, `${at}.id`); if (!UUID.test(invite.id)) bad(`${at}.id must be a UUID`);
|
||||
if (invites.has(invite.id)) bad('duplicate invite id'); invites.add(invite.id);
|
||||
if (typeof invite.tokenHash !== 'string' || !/^[0-9a-f]{64}$/.test(invite.tokenHash) || hashes.has(invite.tokenHash)) bad(`${at}.tokenHash must be a unique sha256 hex hash`); hashes.add(invite.tokenHash);
|
||||
accountId(invite.by, `${at}.by`); time(invite.created, `${at}.created`); time(invite.expires, `${at}.expires`);
|
||||
if (Date.parse(invite.expires) <= Date.parse(invite.created)) bad(`${at}.expires must follow created`);
|
||||
const ttl = Date.parse(invite.expires) - Date.parse(invite.created);
|
||||
if (ttl < 3600000 || ttl > 168 * 3600000) bad(`${at} validity must be between 1 and 168 hours`);
|
||||
if (!['open', 'used', 'revoked', 'declined', 'expired'].includes(invite.state)) bad(`${at}.state is invalid`);
|
||||
requireObject(invite.bind, `${at}.bind`);
|
||||
if (Object.hasOwn(invite.bind, 'email')) { keys(invite.bind, ['email'], `${at}.bind`); text(invite.bind.email, `${at}.bind.email`); if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(invite.bind.email)) bad(`${at}.bind.email must be one address`); }
|
||||
else subject(invite.bind, `${at}.bind`, providers);
|
||||
if (invite.state === 'used') { accountId(invite.user, `${at}.user`); time(invite.used, `${at}.used`); }
|
||||
else if (invite.user !== undefined || invite.used !== undefined) bad(`${at} unused invite has usage fields`);
|
||||
});
|
||||
return deepFreeze(structuredClone(document));
|
||||
}
|
||||
export function accountsRepairs(doc) {
|
||||
const result = [];
|
||||
for (const [kind, rows] of [['membership', doc.memberships], ['invite', doc.invites]]) for (const row of rows) {
|
||||
const unknownRole = Object.hasOwn(ROLE_RIGHTS, row.role) ? undefined : row.role;
|
||||
const unknownRights = row.rights.filter(r => !Object.hasOwn(RIGHTS, r));
|
||||
if (unknownRole || unknownRights.length) result.push({ kind, org: row.org, ...(kind === 'membership' ? { user: row.user } : { id: row.id }), ...(unknownRole ? { unknownRole } : {}), unknownRights });
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
// Check the host boundary before any read or lock. Every ancestor must be a real
|
||||
// directory; configDir itself belongs to this uid and is not publicly writable.
|
||||
export function assertAccountsOwner(configDir, { uid = process.geteuid() } = {}) {
|
||||
if (typeof configDir !== 'string' || resolve(configDir) !== configDir) fail('accounts-unsafe', 'config directory must be an absolute normalized path');
|
||||
let current = parse(configDir).root;
|
||||
for (const part of configDir.slice(current.length).split('/').filter(Boolean)) {
|
||||
current = join(current, part); const st = lstatSync(current);
|
||||
if (!st.isDirectory() || st.isSymbolicLink()) fail('accounts-unsafe', 'config path contains a symlink or non-directory');
|
||||
}
|
||||
const st = lstatSync(configDir);
|
||||
if (st.uid !== uid) fail('accounts-unsafe', `config directory must belong to uid ${uid}: ${configDir}`);
|
||||
if (st.mode & 0o022) fail('accounts-unsafe', 'config directory must not be writable by group or other');
|
||||
}
|
||||
export function loadAccounts(file, context, { uid = process.geteuid() } = {}) {
|
||||
assertAccountsOwner(dirname(file), { uid });
|
||||
const doc = readJsonFile(file, 'accounts file', st => {
|
||||
if (st.uid !== uid) fail('accounts-unsafe', `accounts file must belong to uid ${uid}: ${file}`);
|
||||
if (st.mode & 0o022) fail('accounts-unsafe', 'accounts file must not be writable by group or other');
|
||||
});
|
||||
return checkAccounts(doc, context);
|
||||
}
|
||||
export const mintAccountId = () => `u-${randomBytes(6).toString('hex')}`;
|
||||
const heldAccounts = new WeakMap();
|
||||
|
||||
// Low-level helper for callers already holding the earlier lifecycle/registry
|
||||
// locks. A handle is valid only within this callback and serializes all writes.
|
||||
export async function withAccountsLock(ctx, fn) {
|
||||
assertAccountsOwner(ctx.configDir);
|
||||
const lock = join(ctx.configDir, 'accounts.lock'); let fd;
|
||||
for (let n = 0; n < 200; n++) {
|
||||
try { fd = await fs.open(lock, 'wx', 0o600); break; }
|
||||
catch (e) { if (e.code !== 'EEXIST') throw e; await new Promise(r => setTimeout(r, 10)); }
|
||||
}
|
||||
if (!fd) fail('accounts-locked', 'accounts lock is held; inspect it before retrying');
|
||||
const handle = Object.freeze({}); heldAccounts.set(handle, { ctx, writing: false });
|
||||
try { return await fn(handle); }
|
||||
finally { heldAccounts.delete(handle); await fd.close(); await fs.unlink(lock); }
|
||||
}
|
||||
export async function withAccounts(ctx, options, fn) {
|
||||
if (options.registry) await readRegistryLocked(options.registry, ctx);
|
||||
return withAccountsLock(ctx, handle => withAccountsLocked(handle, options, fn));
|
||||
}
|
||||
const VERBS = new Set(['init', 'user.add', 'user.disable', 'user.enable', 'user.signout', 'user.remove', 'user.update', 'membership.add', 'membership.rights', 'membership.remove', 'membership.leave', 'invite.create', 'invite.accept', 'invite.decline', 'invite.revoke', 'org.delete', 'orphans.sweep']);
|
||||
function generations(next, before, verb) {
|
||||
for (const u of next.users) {
|
||||
const old = before?.users.find(x => x.id === u.id);
|
||||
if (!old) { u.authGen = 1; continue; }
|
||||
const changed = u.state !== old.state || u.instanceRole !== old.instanceRole || !same(u.subjects, old.subjects);
|
||||
if (changed) u.authGen = old.authGen + 1;
|
||||
else if (u.authGen !== old.authGen && !(verb === 'user.signout' && u.authGen === old.authGen + 1)) bad('authGen may only advance one generation for signout or an authentication change');
|
||||
if (u.created !== old.created) bad('account created time is immutable');
|
||||
}
|
||||
for (const m of next.memberships) {
|
||||
const old = before?.memberships.find(x => x.org === m.org && x.incarnation === m.incarnation && x.user === m.user);
|
||||
// A rollback row grants nothing, but must not block unrelated account
|
||||
// revocation or the repair of another row. Preserve unchanged rows exactly.
|
||||
if (old && same(m, old)) continue;
|
||||
if (!Object.hasOwn(ROLE_RIGHTS, m.role) || m.role === 'owner') fail('unknown-role', 'unknown or ungrantable membership role');
|
||||
m.rights = [...normalizeRights(m.rights)];
|
||||
m.rev = old ? old.rev + (!same(old.rights, m.rights) || !same(old.scope, m.scope) ? 1 : 0) : 1;
|
||||
}
|
||||
for (const i of next.invites) {
|
||||
const old = before?.invites.find(x => x.id === i.id);
|
||||
if (old && same(i, old)) continue;
|
||||
if (!Object.hasOwn(ROLE_RIGHTS, i.role) || i.role === 'owner') fail('unknown-role', 'unknown or ungrantable invite role');
|
||||
i.rights = [...normalizeRights(i.rights)];
|
||||
}
|
||||
}
|
||||
// Receipt construction has no free-form text: no names, subjects, bindings,
|
||||
// tokens, hashes, caller-supplied objects, or cookies can enter the log.
|
||||
function receipt(before, after, { verb, actor }, at) {
|
||||
if (!VERBS.has(verb)) bad('unsupported receipt verb');
|
||||
if (actor !== undefined && actor !== 'human') accountId(actor, 'receipt actor');
|
||||
const membership = m => ({ org: m.org, incarnation: m.incarnation, user: m.user, role: Object.hasOwn(ROLE_RIGHTS, m.role) ? m.role : 'unknown', rights: m.rights, scope: m.scope, rev: m.rev });
|
||||
return { at, uid: process.geteuid(), actor: actor ?? 'human', verb, revisionBefore: before?.revision ?? 0, revisionAfter: after.revision,
|
||||
users: after.users.filter(u => !before || !same(u, before.users.find(x => x.id === u.id))).map(u => u.id),
|
||||
userChanges: after.users.flatMap(u => {
|
||||
const old = before?.users.find(x => x.id === u.id);
|
||||
const changed = ['subjects', 'name', 'instanceRole', 'state', 'authGen', 'created'].filter(key => !old || !same(u[key], old[key]));
|
||||
return changed.length ? [{ id: u.id, changed }] : [];
|
||||
}),
|
||||
removedUsers: (before?.users ?? []).filter(u => !after.users.some(x => x.id === u.id)).map(u => u.id),
|
||||
memberships: { before: (before?.memberships ?? []).map(membership), after: after.memberships.map(membership) } };
|
||||
}
|
||||
async function syncDirectory(dir) {
|
||||
const fd = await fs.open(dir, constants.O_RDONLY | constants.O_DIRECTORY | constants.O_NOFOLLOW);
|
||||
try { await fd.sync(); } finally { await fd.close(); }
|
||||
}
|
||||
|
||||
// Re-read under the lock, compare expected revision, validate and atomically
|
||||
// replace 0600 bytes, then append and fsync exactly one receipt while still held.
|
||||
// A post-rename receipt failure reports committed=true: retrying is not safe.
|
||||
export async function withAccountsLocked(handle, options, fn) {
|
||||
const held = heldAccounts.get(handle);
|
||||
if (!held || held.writing) fail('accounts-locked', 'a live, unused accounts lock handle is required');
|
||||
held.writing = true;
|
||||
const { ctx } = held; const file = join(ctx.configDir, 'accounts.json');
|
||||
let temp, receiptFd, committed = false;
|
||||
try {
|
||||
if (options.registry) await readRegistryLocked(options.registry, ctx);
|
||||
assertAccountsOwner(ctx.configDir);
|
||||
let before;
|
||||
if (options.init) {
|
||||
try { await fs.lstat(file); fail('accounts-exists', 'accounts.json already exists'); } catch (e) { if (e.code !== 'ENOENT') throw e; }
|
||||
if (options.revision !== 0) fail('revision-conflict');
|
||||
} else before = loadAccounts(file, ctx);
|
||||
if (!Number.isSafeInteger(options.revision) || options.revision !== (before?.revision ?? 0)) fail('revision-conflict', `revision-conflict: accounts revision is ${before?.revision ?? 0}`);
|
||||
const next = before ? structuredClone(before) : { accountsVersion: 1, revision: 0, users: [], memberships: [], invites: [] };
|
||||
await fn(next);
|
||||
generations(next, before, options.verb); next.revision = (before?.revision ?? 0) + 1;
|
||||
const checked = checkAccounts(next, ctx);
|
||||
const line = receipt(before, checked, options, new Date().toISOString());
|
||||
// Refuse unsafe receipt destinations before publishing the account change.
|
||||
receiptFd = await fs.open(join(ctx.configDir, 'accounts-receipts.jsonl'), constants.O_WRONLY | constants.O_APPEND | constants.O_CREAT | constants.O_NOFOLLOW | constants.O_NONBLOCK, 0o600);
|
||||
const st = await receiptFd.stat();
|
||||
if (!st.isFile() || st.uid !== process.geteuid() || (st.mode & 0o022) || st.nlink !== 1) fail('accounts-unsafe', 'unsafe accounts receipt file');
|
||||
temp = join(ctx.configDir, `.accounts-${randomBytes(12).toString('hex')}`);
|
||||
const fd = await fs.open(temp, 'wx', 0o600);
|
||||
try { await fd.writeFile(JSON.stringify(checked, null, 2) + '\n'); await fd.sync(); } finally { await fd.close(); }
|
||||
await options.beforeReplace?.();
|
||||
if (options.registry) await readRegistryLocked(options.registry, ctx);
|
||||
// Defend against a non-cooperating writer even though cooperative writers
|
||||
// already serialize through accounts.lock.
|
||||
if (before) { if (!same(loadAccounts(file, ctx), before)) fail('revision-conflict', 'accounts changed before replace'); }
|
||||
// Init must never replace a target, including one created by a writer that
|
||||
// ignores accounts.lock. link publishes the complete inode or fails EEXIST.
|
||||
if (before) await fs.rename(temp, file);
|
||||
else await fs.link(temp, file);
|
||||
committed = true;
|
||||
if (!before) await fs.unlink(temp);
|
||||
await (options.syncDirectory ?? syncDirectory)(ctx.configDir);
|
||||
await receiptFd.writeFile(JSON.stringify(line) + '\n'); await receiptFd.sync();
|
||||
return checked;
|
||||
} catch (e) { if (committed) e.committed = true; throw e; }
|
||||
finally { if (temp) await fs.rm(temp, { force: true }); if (receiptFd) await receiptFd.close(); held.writing = false; }
|
||||
}
|
||||
@@ -41,6 +41,34 @@ function noBoundKeys(entry, at) {
|
||||
}
|
||||
}
|
||||
|
||||
// A var value and every leaf it holds, as a var check's message quotes them (JSON.stringify).
|
||||
function quoted(value, out = []) {
|
||||
if (Array.isArray(value)) value.forEach((v) => quoted(v, out));
|
||||
else if (isPlainObject(value)) Object.values(value).forEach((v) => quoted(v, out));
|
||||
if (value !== undefined) out.push(JSON.stringify(value));
|
||||
return out;
|
||||
}
|
||||
|
||||
// checkVars, but a refusal never carries a value. Var checks quote the value they refuse, a value
|
||||
// can be a secret pasted in the wrong place, and these refusals reach the Console. Each key is
|
||||
// rechecked alone to name the one at fault; a message that quotes its value is replaced by one
|
||||
// that names the key only. A rule across keys (the fallback set) names keys only already.
|
||||
function checkVarsNamingKeys(vars, layer, where) {
|
||||
try { return checkVars(vars, layer, where); }
|
||||
catch (error) {
|
||||
if (error?.name !== "BusinessError" || !isPlainObject(vars)) throw error;
|
||||
for (const [key, value] of Object.entries(vars)) {
|
||||
try { checkVars({ [key]: value }, layer, where); }
|
||||
catch (one) {
|
||||
if (one?.name !== "BusinessError") throw one;
|
||||
if (quoted(value).some((text) => one.message.includes(text))) refuse(`${where}: variable ${key} has a value it can't take (the value is not shown)`);
|
||||
throw one;
|
||||
}
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// The root file's document. `where` names it in messages, never with a host path.
|
||||
export function checkRootAgents(document, { rolesDir, where = "root agents" } = {}) {
|
||||
requireObject(document, where);
|
||||
@@ -66,7 +94,7 @@ export function checkRootAgents(document, { rolesDir, where = "root agents" } =
|
||||
agents[name] = {
|
||||
definition: entry.definition,
|
||||
title: definition.title,
|
||||
vars: checkVars(entry.vars ?? {}, "agent", `${at}.vars`),
|
||||
vars: checkVarsNamingKeys(entry.vars ?? {}, "agent", `${at}.vars`),
|
||||
excludeOrgs: entry.excludeOrgs === undefined ? [] : requireDistinctList(entry.excludeOrgs, `${at}.excludeOrgs`, (id) => requireId(id, `${at}.excludeOrgs entry`)),
|
||||
};
|
||||
}
|
||||
@@ -121,7 +149,7 @@ export function checkProjectAgents(agents, { rolesDir, where }) {
|
||||
if (Object.hasOwn(entry, "definition")) {
|
||||
const definition = definitionOf(entry.definition, at, rolesDir, cache);
|
||||
if (entry.definition === CHIEF || entry.definition === ASSISTANT) refuse(`${at}: ${entry.definition} can't be a project agent`);
|
||||
out[name] = { form: "define", definition: entry.definition, title: definition.title, vars: checkVars(entry.vars ?? {}, "agent", `${at}.vars`) };
|
||||
out[name] = { form: "define", definition: entry.definition, title: definition.title, vars: checkVarsNamingKeys(entry.vars ?? {}, "agent", `${at}.vars`) };
|
||||
continue;
|
||||
}
|
||||
if (!isPlainObject(entry.vars) || Object.keys(entry.vars).length === 0) refuse(`${at}: an entry defines (definition), narrows (vars) or excludes (exclude)`);
|
||||
@@ -129,7 +157,7 @@ export function checkProjectAgents(agents, { rolesDir, where }) {
|
||||
for (const key of Object.keys(entry.vars)) {
|
||||
if (!INTERSECT.includes(key)) refuse(`${at}.vars: a project can only narrow ${INTERSECT.join(", ")}, not ${JSON.stringify(key)}`);
|
||||
}
|
||||
out[name] = { form: "narrow", vars: checkVars(entry.vars, "project", `${at}.vars`) };
|
||||
out[name] = { form: "narrow", vars: checkVarsNamingKeys(entry.vars, "project", `${at}.vars`) };
|
||||
}
|
||||
return deepFreeze(out);
|
||||
}
|
||||
|
||||
@@ -128,7 +128,7 @@ export function checkRoutes({ proposed, old = null, levels, catalogue = null })
|
||||
for (const [harness, model, thinking] of ROUTES) {
|
||||
// A missing model refuses no-route at launch, not here.
|
||||
if (proposed.launch && Object.hasOwn(vars, model) && !Object.hasOwn(proposed.launch.models, vars[model])) {
|
||||
throw new OrganizationError("unknown-model", `${instance} variable ${model} isn't a launch.models key`);
|
||||
throw new OrganizationError("unknown-model", `${instance} ${model} ${JSON.stringify(vars[model] ?? null)} isn't a launch.models key`);
|
||||
}
|
||||
const currentEntry = proposed.launch?.models?.[vars[model]];
|
||||
const previousEntry = old?.launch?.models?.[before[model]];
|
||||
@@ -149,12 +149,12 @@ export function checkRoutes({ proposed, old = null, levels, catalogue = null })
|
||||
}
|
||||
if (!Object.hasOwn(vars, harness) || (vars[harness] === before[harness] && vars[thinking] === before[thinking])) continue;
|
||||
const name = vars[harness];
|
||||
if (!on.includes(name)) throw new OrganizationError("harness-off", `${instance} variable ${harness} isn't switched on in plugins.harness`);
|
||||
if (!on.includes(name)) throw new OrganizationError("harness-off", `${instance} ${harness} ${name} isn't switched on in plugins.harness`);
|
||||
if (vars[thinking] === undefined) continue;
|
||||
const applies = levels(name);
|
||||
if (!Array.isArray(applies)) throw new OrganizationError("harness-refused", `${instance} variable ${harness}: its harness module didn't load, so its thinking levels are unknown`);
|
||||
if (!Array.isArray(applies)) throw new OrganizationError("harness-refused", `harness module ${name} didn't load, so its thinking levels are unknown`);
|
||||
if (!applies.includes(vars[thinking])) {
|
||||
throw new OrganizationError("thinking-unsupported", `${instance} variable ${thinking}: its harness doesn't apply this thinking level; it applies ${applies.join(", ")}`);
|
||||
throw new OrganizationError("thinking-unsupported", `harness ${name} doesn't apply thinking ${vars[thinking]} (${instance} ${thinking}); it applies ${applies.join(", ")}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -374,7 +374,7 @@ export function launchSettings({ dir, rolesDir, system, sessions = () => [], now
|
||||
const entry = offered ? { provider: offered.provider, model: offered.model, module: offered.module, upstream: offered.upstream } : null;
|
||||
if (!existing && !offered && catalogue?.reason === 'model-key-collision') throw new OrganizationError('model-key-collision');
|
||||
if (!existing && !offered && catalogue?.status === 'unavailable') throw new OrganizationError('model-catalogue-unavailable');
|
||||
if (!existing && !offered) throw new OrganizationError('unknown-model', `${instance} variable ${prefix}model isn't a launch.models key`);
|
||||
if (!existing && !offered) throw new OrganizationError('unknown-model', `${instance} model isn't a launch.models key`);
|
||||
if (existing && offered && canonicalJson(existing) !== canonicalJson(entry) && !unchanged) throw new OrganizationError('model-key-collision');
|
||||
if (!existing || existing.module) {
|
||||
if (unchanged) continue;
|
||||
|
||||
@@ -36,29 +36,14 @@ export async function readRegistry({ configDir }) {
|
||||
return validateRegistry(doc, file);
|
||||
}
|
||||
export async function listOrgs(ctx) { const doc = await readRegistry(ctx); return doc ? Object.values(doc.orgs) : []; }
|
||||
const heldRegistries = new WeakMap();
|
||||
// Only a live withRegistry document may be reused by a later accounts-lock
|
||||
// holder. Recheck the persisted snapshot without reacquiring this lock.
|
||||
export async function readRegistryLocked(registry, ctx) {
|
||||
const held = heldRegistries.get(registry);
|
||||
if (!held) fail('org-locked', 'a live registry lock document is required');
|
||||
if (ctx && (ctx.configDir !== held.ctx.configDir || ctx.instance !== held.ctx.instance)) fail('org-mismatch', 'registry lock belongs to another instance');
|
||||
const current = await readRegistry(held.ctx);
|
||||
if (JSON.stringify(current) !== held.persisted) fail('registry-changed', 'registry-changed before accounts replace');
|
||||
return validateRegistry(registry, join(held.ctx.configDir, 'org-runtime.json'));
|
||||
}
|
||||
export async function withRegistry(ctx, fn, { write = true } = {}) {
|
||||
await safeDir(ctx.configDir); await privateDir(ctx.configDir); const lock = join(ctx.configDir, 'org-runtime.lock'); let fd;
|
||||
for (let n = 0; n < 200; n++) { try { fd = await fs.open(lock, 'wx', 0o600); break; } catch (e) { if (e.code !== 'EEXIST') throw e; await new Promise(r => setTimeout(r, 10)); } }
|
||||
if (!fd) fail('org-locked', 'registry lock is held', lock);
|
||||
try {
|
||||
const persisted = await readRegistry(ctx);
|
||||
const registry = persisted ?? { version: 1, instance: ctx.instance, orgs: {} };
|
||||
const registry = await readRegistry(ctx) ?? { version: 1, instance: ctx.instance, orgs: {} };
|
||||
if (registry.instance !== ctx.instance) fail('org-mismatch', 'registry instance mismatch');
|
||||
heldRegistries.set(registry, { ctx, persisted: JSON.stringify(persisted) });
|
||||
let result;
|
||||
try { result = await fn(registry); }
|
||||
finally { heldRegistries.delete(registry); }
|
||||
const result = await fn(registry);
|
||||
if (write) {
|
||||
const file = join(ctx.configDir, 'org-runtime.json'); const temp = join(ctx.configDir, `.org-runtime-${randomBytes(12).toString('hex')}`);
|
||||
try { await fs.writeFile(temp, JSON.stringify(registry, null, 2) + '\n', { mode: 0o600, flag: 'wx' }); await fs.rename(temp, file); } finally { await fs.rm(temp, { force: true }); }
|
||||
|
||||
@@ -205,7 +205,7 @@ function checkLaunch(launch, roles, definitions, file) {
|
||||
for (const name of Object.keys(limits.tokens?.daily ?? {})) {
|
||||
if (name !== account?.name) refuse(`${where}.limits.tokens.daily names ${name}, which isn't ${where}.account.name`);
|
||||
}
|
||||
for(const [instance,role] of Object.entries(roles))if(role.vars.model!==undefined&&!Object.hasOwn(models,role.vars.model))refuse(`${where}: instance ${instance} variable model selects an unknown model`);
|
||||
for(const [instance,role] of Object.entries(roles))if(role.vars.model!==undefined&&!Object.hasOwn(models,role.vars.model))refuse(`${where}: instance ${instance} selects unknown model ${role.vars.model}`);
|
||||
// No upper bound: limits have no ceiling in code (Q150).
|
||||
const threadIdleSeconds = launch.threadIdleSeconds === undefined ? THREAD_IDLE_SECONDS : requirePositiveInt(launch.threadIdleSeconds, `${where}.threadIdleSeconds`);
|
||||
// Row 194 (#1692, D2.4, D2.5): the human's per-agent forbid. keep names declared instances the
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
|
||||
import { refuse } from "./errors.mjs";
|
||||
import { ACTIONS, NETWORKS, TOOLS } from "./vocabulary.mjs";
|
||||
import { isPlainObject, requireString } from "./util.mjs";
|
||||
import { isPlainObject, requireDistinctList, requirePositiveInt, requireString } from "./util.mjs";
|
||||
import { NAME as MODULE_NAME } from "../../plugins/src/kinds.mjs";
|
||||
|
||||
export const LAYERS = Object.freeze(["system", "business", "project", "agent"]);
|
||||
@@ -29,7 +29,7 @@ function string(max = 200) {
|
||||
|
||||
function oneOf(values) {
|
||||
return (v, key) => {
|
||||
if (!values.includes(v)) refuse(`variable ${key} must be one of: ${values.join(", ")}`);
|
||||
if (!values.includes(v)) refuse(`variable ${key} must be one of: ${values.join(", ")} (got ${JSON.stringify(v)})`);
|
||||
return v;
|
||||
};
|
||||
}
|
||||
@@ -37,13 +37,13 @@ function oneOf(values) {
|
||||
// A harness plug-in module's name, in the loader's pattern (row 65, REQ-PLUG-2). Whether the
|
||||
// module exists or is switched on is checked at launch and at a write, never at load.
|
||||
function moduleName(v, key) {
|
||||
if (typeof v !== "string" || !MODULE_NAME.test(v)) refuse(`variable ${key} must be a harness module name`);
|
||||
if (typeof v !== "string" || !MODULE_NAME.test(v)) refuse(`variable ${key} must be a harness module name (got ${JSON.stringify(v)})`);
|
||||
return v;
|
||||
}
|
||||
|
||||
function integer(min) {
|
||||
return (v, key) => {
|
||||
if (!Number.isSafeInteger(v) || v < min) refuse(`variable ${key} must be an integer of at least ${min}`);
|
||||
if (!Number.isSafeInteger(v) || v < min) refuse(`variable ${key} must be an integer of at least ${min} (got ${JSON.stringify(v)})`);
|
||||
return v;
|
||||
};
|
||||
}
|
||||
@@ -55,7 +55,7 @@ function baseUrl(v, key) {
|
||||
try {
|
||||
url = new URL(v);
|
||||
} catch {
|
||||
refuse(`variable ${key} must be an http or https URL`);
|
||||
refuse(`variable ${key} must be an http or https URL (got ${JSON.stringify(v)})`);
|
||||
}
|
||||
if (!["http:", "https:"].includes(url.protocol) || url.username || url.password || url.search || url.hash || v.endsWith("/")) {
|
||||
refuse(`variable ${key} must be an http or https base URL with no credentials, query, fragment or trailing slash`);
|
||||
@@ -70,31 +70,21 @@ function searxngUrl(v, key) {
|
||||
baseUrl(v, key);
|
||||
const url = new URL(v);
|
||||
const loopback = ["127.0.0.1", "localhost", "[::1]"].includes(url.hostname);
|
||||
if (url.protocol === "http:" && !loopback) refuse(`variable ${key} must be https, or http on loopback`);
|
||||
if (url.protocol === "http:" && !loopback) refuse(`variable ${key} must be https, or http on loopback (got ${JSON.stringify(v)})`);
|
||||
return v;
|
||||
}
|
||||
|
||||
function branch(v, key) {
|
||||
if (typeof v !== "string" || !BRANCH.test(v)) refuse(`variable ${key} must be a git branch name`);
|
||||
if (typeof v !== "string" || !BRANCH.test(v)) refuse(`variable ${key} must be a git branch name (got ${JSON.stringify(v)})`);
|
||||
return v;
|
||||
}
|
||||
|
||||
function list(check, { nonEmpty = true } = {}) {
|
||||
return (v, key) => {
|
||||
if (!Array.isArray(v)) refuse(`variable ${key} must be an array`);
|
||||
if (nonEmpty && v.length === 0) refuse(`variable ${key} must not be empty`);
|
||||
const seen = new Set();
|
||||
for (const item of v) {
|
||||
check(item, `${key} entry`);
|
||||
if (seen.has(item)) refuse(`duplicate entry in variable ${key}`);
|
||||
seen.add(item);
|
||||
}
|
||||
return [...seen];
|
||||
};
|
||||
return (v, key) => requireDistinctList(v, `variable ${key}`, (item) => check(item, `${key} entry`), { nonEmpty });
|
||||
}
|
||||
|
||||
function boolean(v, key) {
|
||||
if (typeof v !== "boolean") refuse(`variable ${key} must be true or false`);
|
||||
if (typeof v !== "boolean") refuse(`variable ${key} must be true or false (got ${JSON.stringify(v)})`);
|
||||
return v;
|
||||
}
|
||||
|
||||
@@ -135,10 +125,7 @@ export const REGISTRY = Object.freeze({
|
||||
"bus.tester": def(["business"], boolean, { default: false }),
|
||||
|
||||
// Project.
|
||||
"tracker.project": def(["project"], (v, key) => {
|
||||
if (!Number.isSafeInteger(v) || v < 1) refuse(`variable ${key} must be a positive integer`);
|
||||
return v;
|
||||
}),
|
||||
"tracker.project": def(["project"], (v, key) => requirePositiveInt(v, `variable ${key}`)),
|
||||
// On: the tracker project is the root of a tree, and sync reads its descendants too.
|
||||
"tracker.descendants": def(["project"], boolean, { default: false }),
|
||||
"git.workingBranch": def(["project"], branch),
|
||||
@@ -174,17 +161,16 @@ export const REGISTRY = Object.freeze({
|
||||
// Limits narrow the role definition's ceilings. limits.authority is an
|
||||
// allowlist: a role action it doesn't name becomes gated.
|
||||
"limits.tools": def(["business", "project", "agent"], list((v, key) => {
|
||||
if (!TOOLS.includes(v)) refuse(`variable ${key} names an unsupported tool`);
|
||||
if (!TOOLS.includes(v)) refuse(`variable ${key} names an unsupported tool: ${JSON.stringify(v)}`);
|
||||
}, { nonEmpty: false }), { merge: "intersect" }),
|
||||
"limits.network": def(["business", "project", "agent"], oneOf(NETWORKS), { merge: "intersect" }),
|
||||
"limits.authority": def(["business", "project", "agent"], list((v, key) => {
|
||||
if (!ACTIONS.includes(v)) refuse(`variable ${key} names an unknown action`);
|
||||
if (!ACTIONS.includes(v)) refuse(`variable ${key} names an unknown action: ${JSON.stringify(v)}`);
|
||||
}, { nonEmpty: false }), { merge: "intersect" }),
|
||||
});
|
||||
|
||||
// Check one layer's vars object. `where` names the file and path for the
|
||||
// message. Returns a frozen copy with checked values. Validators name the key
|
||||
// and the rule, never a value: refusals can reach the Console and the trail.
|
||||
// message. Returns a frozen copy with checked values.
|
||||
export function checkVars(vars, layer, where) {
|
||||
if (!LAYERS.includes(layer)) throw new Error(`unknown layer ${layer}`);
|
||||
if (!isPlainObject(vars)) refuse(`${where} must be a JSON object`);
|
||||
|
||||
@@ -1,180 +0,0 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { mkdtempSync, mkdirSync, writeFileSync, readFileSync, chmodSync, symlinkSync, unlinkSync, existsSync, readdirSync, rmSync, statSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { checkAccounts, loadAccounts, accountsRepairs, withAccounts, withAccountsLock, withAccountsLocked } from '../src/accounts.mjs';
|
||||
import { templateRender } from '../src/org-service.mjs';
|
||||
import { loadBusiness } from '../src/organization-config.mjs';
|
||||
import { REPO_ROLES } from './helpers.mjs';
|
||||
import { withRegistry, readRegistryLocked } from '../src/org-registry.mjs';
|
||||
|
||||
export const OP = 'u-000000000001', USER = 'u-000000000002';
|
||||
export const context = { providers: [{ id: 'pocket', issuer: 'https://id.example', subjects: [{ sub: 'owner' }] }] };
|
||||
export const user = (id = OP, sub = 'owner', instanceRole = 'operator') => ({ id, subjects: [{ provider: 'pocket', sub }], name: 'Person', instanceRole, state: 'active', authGen: 1, created: '2026-11-10T00:00:00Z' });
|
||||
export const member = () => ({ org: 'acme', incarnation: 'a'.repeat(32), user: USER, role: 'member', rights: ['board.read', 'org.view', 'thread.read'], scope: { projects: ['*'], instances: ['*'] }, rev: 1, added: '2026-11-10T00:00:00Z', by: OP, via: 'invite:abc' });
|
||||
export const document = () => ({ accountsVersion: 1, revision: 1, users: [user(), user(USER, 'member', 'user')], memberships: [], invites: [] });
|
||||
function setup(t, doc = document()) {
|
||||
const dir = mkdtempSync(join(tmpdir(), 'a-')); chmodSync(dir, 0o700); t.after(() => rmSync(dir, { recursive: true, force: true }));
|
||||
const file = join(dir, 'accounts.json'); writeFileSync(file, JSON.stringify(doc), { mode: 0o600 });
|
||||
return { configDir: dir, instance: 'test', ...context, file };
|
||||
}
|
||||
const write = (ctx, revision, fn, rest = {}) => withAccounts(ctx, { revision, verb: 'user.signout', ...rest }, fn);
|
||||
|
||||
test('loader refuses every malformed document as a whole, including nested unknown keys', t => {
|
||||
const cases = [d => d.accountsVersion = 2, d => d.revision = 0, d => d.cookie = 'secret', d => d.users[0].id = 'human', d => d.users[0].authGen = 0, d => d.users[0].authGen = 1.5, d => d.users[0].instanceRole = 'owner', d => d.users[0].state = 'disabled', d => d.users[0].subjects[0].provider = 'local-dev', d => d.users[0].subjects[0].provider = 'missing', d => d.users[1].subjects = d.users[0].subjects, d => d.users[0].subjects[0].iss = 'https://evil', d => d.users[0].created = '2026-02-30T00:00:00Z', d => d.memberships.push({}), d => { const m = member(); m.rights.push('org.setup'); d.memberships.push(m); }, d => { const m = member(); m.rights.reverse(); d.memberships.push(m); }, d => { const m = member(); m.scope.instances = ['*', 'coder']; d.memberships.push(m); }, d => { const m = member(); m.user = 'u-000000000003'; d.memberships.push(m); }];
|
||||
for (const mutate of cases) { const d = document(); mutate(d); assert.throws(() => checkAccounts(d, context), undefined, mutate.toString()); }
|
||||
assert.throws(() => checkAccounts(document(), { providers: [{ id: 'pocket', issuer: 'local-dev' }] }));
|
||||
const s = setup(t); assert.throws(() => loadAccounts(s.file, s, { uid: process.getuid() + 1 }), /uid/);
|
||||
for (const mode of [0o620, 0o602]) { chmodSync(s.file, mode); assert.throws(() => loadAccounts(s.file, s), /writable/); }
|
||||
chmodSync(s.file, 0o600); const target = join(s.configDir, 'real'); writeFileSync(target, JSON.stringify(document()), { mode: 0o600 }); unlinkSync(s.file); symlinkSync(target, s.file); assert.throws(() => loadAccounts(s.file, s), /symbolic/);
|
||||
});
|
||||
|
||||
test('unknown role and right load as repair rows; account may be a business human', () => {
|
||||
const d = document(); const m = member(); m.role = 'future'; m.rights.push('zz.future'); d.memberships.push(m);
|
||||
const loaded = checkAccounts(d, { ...context, businesses: { acme: { human: OP } } });
|
||||
assert.equal(loaded.users[0].id, OP); assert.ok(Object.isFrozen(loaded.users[0]));
|
||||
assert.deepEqual(accountsRepairs(loaded), [{ kind: 'membership', org: 'acme', user: USER, unknownRole: 'future', unknownRights: ['zz.future'] }]);
|
||||
});
|
||||
|
||||
test('writes refuse stale revision, unknown role/right and reserved rights without a receipt', async t => {
|
||||
const s = setup(t); const before = readFileSync(s.file, 'utf8');
|
||||
await assert.rejects(write(s, 9, () => {}), /revision-conflict/);
|
||||
for (const change of [m => m.role = 'future', m => m.rights.push('zz.future'), m => m.rights.push('org.setup')]) {
|
||||
await assert.rejects(write(s, 1, d => { const m = member(); change(m); d.memberships.push(m); }));
|
||||
}
|
||||
assert.equal(readFileSync(s.file, 'utf8'), before); assert.equal(existsSync(join(s.configDir, 'accounts-receipts.jsonl')), false);
|
||||
});
|
||||
|
||||
test('rights/scope revisions and security generations are maintained by the writer', async t => {
|
||||
const d = document(); d.memberships.push(member()); const s = setup(t, d);
|
||||
let rev = 1;
|
||||
for (const change of [u => u.state = 'disabled', u => u.state = 'active', u => u.instanceRole = 'operator', u => u.subjects[0].sub = 'new-sub']) {
|
||||
const result = await write(s, rev++, next => change(next.users[1])); assert.equal(result.users[1].authGen, rev);
|
||||
}
|
||||
let result = await write(s, rev++, next => next.users[1].authGen++); assert.equal(result.users[1].authGen, rev);
|
||||
result = await write(s, rev++, next => next.memberships[0].rights.push('task.write')); assert.equal(result.memberships[0].rev, 2);
|
||||
result = await write(s, rev++, next => next.memberships[0].scope.instances = ['coder']); assert.equal(result.memberships[0].rev, 3);
|
||||
assert.equal(result.users[1].authGen, 6);
|
||||
const lines = readFileSync(join(s.configDir, 'accounts-receipts.jsonl'), 'utf8').trim().split('\n').map(JSON.parse);
|
||||
assert.equal(lines.length, 7); for (const r of lines) { assert.equal(r.uid, process.getuid()); assert.equal(r.verb, 'user.signout'); }
|
||||
assert.equal(statSync(s.file).mode & 0o777, 0o600);
|
||||
});
|
||||
|
||||
test('concurrent writes serialize and the later stale write changes nothing', async t => {
|
||||
const s = setup(t); let release, entered; const ready = new Promise(r => entered = r); const wait = new Promise(r => release = r);
|
||||
const first = write(s, 1, async d => { entered(); await wait; d.users[1].authGen++; }); await ready;
|
||||
const second = write(s, 1, d => { d.users[1].authGen++; }); release();
|
||||
const results = await Promise.allSettled([first, second]); assert.equal(results[0].status, 'fulfilled'); assert.equal(results[1].status, 'rejected'); assert.match(results[1].reason.message, /revision-conflict/);
|
||||
assert.equal(loadAccounts(s.file, s).revision, 2);
|
||||
});
|
||||
|
||||
test('locked helpers require live handles; registry dependency is rechecked before replace', async t => {
|
||||
const s = setup(t); let held;
|
||||
await assert.rejects(readRegistryLocked({}), /lock/);
|
||||
await assert.rejects(withAccountsLocked({}, { revision: 1, verb: 'user.signout' }, () => {}), /lock/);
|
||||
await withRegistry(s, async registry => {
|
||||
assert.equal(await readRegistryLocked(registry), registry);
|
||||
await withAccountsLock(s, async handle => { held = handle; await withAccountsLocked(handle, { revision: 1, verb: 'user.signout', registry }, d => d.users[1].authGen++); });
|
||||
});
|
||||
await assert.rejects(withAccountsLocked(held, { revision: 2, verb: 'user.signout' }, () => {}), /lock/);
|
||||
const before = readFileSync(s.file, 'utf8');
|
||||
await withRegistry(s, async registry => {
|
||||
await assert.rejects(write(s, 2, d => { d.users[1].authGen++; writeFileSync(join(s.configDir, 'org-runtime.json'), JSON.stringify({ version: 1, instance: 'test', orgs: { changed: { id: 'changed', root: '/elsewhere', state: 'active', revision: 1, created: 'now' } } }), { mode: 0o600 }); }, { registry }), /registry-changed/);
|
||||
}, { write: false });
|
||||
assert.equal(readFileSync(s.file, 'utf8'), before);
|
||||
});
|
||||
|
||||
test('failure before replace leaves old bytes, no receipt line and no temporary file', async t => {
|
||||
const s = setup(t); const before = readFileSync(s.file, 'utf8');
|
||||
await assert.rejects(write(s, 1, d => d.users[1].authGen++, { beforeReplace: () => { throw new Error('injected failure'); } }), /injected failure/);
|
||||
assert.equal(readFileSync(s.file, 'utf8'), before); assert.deepEqual(readdirSync(s.configDir).filter(f => f.startsWith('.accounts-')), []); assert.equal(readFileSync(join(s.configDir, 'accounts-receipts.jsonl'), 'utf8'), '');
|
||||
});
|
||||
|
||||
test('receipts never serialize subject secrets, tokens, cookies, names or arbitrary actor text', async t => {
|
||||
const s = setup(t); const secret = 'subject-secret';
|
||||
await write(s, 1, d => { d.users[1].subjects[0].sub = secret; d.users[1].name = 'cookie-secret'; }, { actor: OP });
|
||||
const raw = readFileSync(join(s.configDir, 'accounts-receipts.jsonl'), 'utf8'); assert.ok(!raw.includes(secret)); assert.ok(!raw.includes('cookie-secret'));
|
||||
await assert.rejects(write(s, 2, () => {}, { verb: 'token-secret' }));
|
||||
await assert.rejects(write(s, 2, () => {}, { actor: 'cookie-secret' }));
|
||||
});
|
||||
|
||||
test('malformed membership rows refuse all of the file; reserved rights never load', () => {
|
||||
const mutations = [m => m.role = 'owner', m => m.rev = 0, m => m.incarnation = 'old', m => m.rights = ['thread.read'], m => m.rights = ['org.view', 'org.view'], m => m.scope = { projects: [], instances: ['*'] }, m => m.scope.projects = ['not-a-uuid'], m => m.scope.expires = 'not-a-time', m => m.extra = true, m => m.by = 'human', m => m.via = '', m => m.added = 'bad'];
|
||||
for (const mutate of mutations) { const d = document(), m = member(); mutate(m); d.memberships.push(m); assert.throws(() => checkAccounts(d, context), undefined, mutate.toString()); }
|
||||
for (const right of ['members.manage', 'org.setup', 'org.lifecycle', 'request.write', 'owner']) { const d = document(), m = member(); m.rights = ['org.view', right].sort(); d.memberships.push(m); assert.throws(() => checkAccounts(d, context)); }
|
||||
});
|
||||
function invitation() {
|
||||
const { user: ignored, rev, added, via, ...row } = member();
|
||||
return { ...row, id: '11111111-2222-3333-4444-555555555555', bind: { email: '[email protected]' }, tokenHash: 'b'.repeat(64), created: '2026-11-10T00:00:00Z', expires: '2026-11-13T00:00:00Z', state: 'open' };
|
||||
}
|
||||
test('invite schema refuses raw secrets, domain/bearer bindings, malformed hashes and unknown fields', () => {
|
||||
const d = document(); d.invites.push(invitation()); assert.equal(checkAccounts(d, context).invites.length, 1);
|
||||
for (const mutate of [i => i.token = 'raw-token', i => i.cookie = 'raw-cookie', i => i.bind = { domain: 'example.com' }, i => i.bind = {}, i => i.bind = { email: '@example.com' }, i => i.bind.sub = 'extra-binding', i => i.tokenHash = 'raw-token', i => i.state = 'other', i => i.rights = ['org.setup', 'org.view'], i => i.scope.level = 'collaborator']) {
|
||||
const next = structuredClone(d); mutate(next.invites[0]); assert.throws(() => checkAccounts(next, context));
|
||||
}
|
||||
d.invites[0].role = 'future'; d.invites[0].rights.push('zz.future'); assert.equal(accountsRepairs(checkAccounts(d, context))[0].kind, 'invite');
|
||||
});
|
||||
test('unsafe receipt destination refuses before publication and leaves its target intact', async t => {
|
||||
const s = setup(t), target = join(s.configDir, 'target'); writeFileSync(target, 'untouched', { mode: 0o600 }); symlinkSync(target, join(s.configDir, 'accounts-receipts.jsonl'));
|
||||
const before = readFileSync(s.file, 'utf8'); await assert.rejects(write(s, 1, d => d.users[1].authGen++)); assert.equal(readFileSync(s.file, 'utf8'), before); assert.equal(readFileSync(target, 'utf8'), 'untouched');
|
||||
});
|
||||
test('account lock allows sequential writes and registry handles cannot cross instance boundaries', async t => {
|
||||
const s = setup(t), other = setup(t);
|
||||
await withAccountsLock(s, async handle => {
|
||||
await withAccountsLocked(handle, { revision: 1, verb: 'user.signout' }, d => d.users[1].authGen++);
|
||||
await withAccountsLocked(handle, { revision: 2, verb: 'user.signout' }, d => d.users[1].authGen++);
|
||||
});
|
||||
await withRegistry(other, async registry => { await assert.rejects(write(s, 3, () => {}, { registry }), /another instance/); });
|
||||
assert.equal(loadAccounts(s.file, s).revision, 3);
|
||||
});
|
||||
test('invite token hashes and subject bindings never reach receipts', async t => {
|
||||
const s = setup(t); const invite = invitation(); invite.bind = { provider: 'pocket', sub: 'subject-secret' };
|
||||
await write(s, 1, d => d.invites.push(invite), { verb: 'invite.create' });
|
||||
const raw = readFileSync(join(s.configDir, 'accounts-receipts.jsonl'), 'utf8'); assert.ok(!raw.includes(invite.tokenHash)); assert.ok(!raw.includes('subject-secret')); assert.equal(JSON.parse(raw).verb, 'invite.create');
|
||||
});
|
||||
|
||||
test('a failed rename preserves published bytes and appends no receipt', async t => {
|
||||
const s = setup(t), before = readFileSync(s.file, 'utf8');
|
||||
await assert.rejects(write(s, 1, d => d.users[1].authGen++, { beforeReplace: () => { const temp = readdirSync(s.configDir).find(f => f.startsWith('.accounts-')); unlinkSync(join(s.configDir, temp)); } }), { code: 'ENOENT' });
|
||||
assert.equal(readFileSync(s.file, 'utf8'), before); assert.equal(readFileSync(join(s.configDir, 'accounts-receipts.jsonl'), 'utf8'), ''); assert.ok(!existsSync(join(s.configDir, 'accounts.lock')));
|
||||
});
|
||||
|
||||
|
||||
test('review P2-1: rollback rows survive unrelated disable/signout but cannot be added or edited', async t => {
|
||||
const d = document(), m = member(), invite = invitation(); m.role = 'future'; m.rights.push('zz.future'); invite.rights.push('zz.future'); d.memberships.push(m); d.invites.push(invite); const s = setup(t, d);
|
||||
const disabled = await write(s, 1, next => next.users[1].state = 'disabled', { verb: 'user.disable' });
|
||||
assert.deepEqual(disabled.memberships, d.memberships); assert.deepEqual(disabled.invites, d.invites);
|
||||
const signedOut = await write(s, 2, next => next.users[0].authGen++); assert.deepEqual(signedOut.memberships, d.memberships); assert.equal(signedOut.users[0].authGen, 2);
|
||||
await assert.rejects(write(s, 3, next => next.memberships[0].scope.instances = ['coder']), /unknown/);
|
||||
await assert.rejects(write(s, 3, next => next.invites[0].state = 'revoked'), /unknown/);
|
||||
const repaired = await write(s, 3, next => { next.memberships[0].role = 'member'; next.memberships[0].rights = ['org.view']; }); assert.equal(repaired.memberships[0].rev, 2); assert.deepEqual(repaired.invites, d.invites);
|
||||
});
|
||||
test('review P3-1: control and bidi characters refuse in names, subjects and provenance', () => {
|
||||
for (const control of ['\x1b', '\x07', '\r', '\n', '\x7f', '\u0085', '\u202e', '\u2066']) for (const field of ['name', 'sub', 'via']) {
|
||||
const d = document(); d.memberships.push(member()); if (field === 'via') d.memberships[0].via += control; else if (field === 'sub') d.users[1].subjects[0].sub += control; else d.users[1].name += control;
|
||||
assert.throws(() => checkAccounts(d, context));
|
||||
}
|
||||
});
|
||||
test('review P3-2: invite validity is between one and 168 hours inclusive', () => {
|
||||
for (const hours of [0.5, 169, 87600]) { const d = document(), i = invitation(); i.expires = new Date(Date.parse(i.created) + hours * 3600000).toISOString(); d.invites.push(i); assert.throws(() => checkAccounts(d, context)); }
|
||||
for (const hours of [1, 168]) { const d = document(), i = invitation(); i.expires = new Date(Date.parse(i.created) + hours * 3600000).toISOString(); d.invites.push(i); assert.equal(checkAccounts(d, context).invites.length, 1); }
|
||||
});
|
||||
test('review P3-3: receipts record actual changed field names even for a misleading verb', async t => {
|
||||
const s = setup(t); await write(s, 1, d => d.users[1].instanceRole = 'operator');
|
||||
const r = JSON.parse(readFileSync(join(s.configDir, 'accounts-receipts.jsonl'), 'utf8')); assert.deepEqual(r.userChanges, [{ id: USER, changed: ['instanceRole', 'authGen'] }]);
|
||||
});
|
||||
test('review P3-4: directory durability failure reports committed state and appends no success receipt', async t => {
|
||||
const s = setup(t); let error;
|
||||
try { await write(s, 1, d => d.users[1].authGen++, { syncDirectory: async () => { throw new Error('injected directory sync failure'); } }); } catch (e) { error = e; }
|
||||
assert.match(error?.message ?? '', /injected directory sync failure/); assert.equal(error.committed, true); assert.equal(loadAccounts(s.file, s).revision, 2); assert.equal(readFileSync(join(s.configDir, 'accounts-receipts.jsonl'), 'utf8'), '');
|
||||
});
|
||||
test('review P3-5: init cannot clobber a file created after its last absence check', async t => {
|
||||
const s = setup(t); unlinkSync(s.file); const intruder = 'concurrent file';
|
||||
await assert.rejects(withAccounts(s, { revision: 0, verb: 'init', init: true, beforeReplace: () => writeFileSync(s.file, intruder, { mode: 0o600 }) }, d => d.users.push(user())), { code: 'EEXIST' });
|
||||
assert.equal(readFileSync(s.file, 'utf8'), intruder); assert.equal(readFileSync(join(s.configDir, 'accounts-receipts.jsonl'), 'utf8'), '');
|
||||
});
|
||||
test('review P3-6: a real business file and accounts file load with the same human id', async t => {
|
||||
const s = setup(t), doc = await templateRender('acme'); doc.human = OP; mkdirSync(join(s.configDir, 'businesses')); writeFileSync(join(s.configDir, 'businesses', 'acme.json'), JSON.stringify(doc), { mode: 0o600 });
|
||||
assert.equal(loadBusiness('acme', { dir: s.configDir, rolesDir: REPO_ROLES }).human, OP); assert.equal(loadAccounts(s.file, s).users[0].id, OP);
|
||||
});
|
||||
@@ -123,7 +123,7 @@ test("malformed root definitions refuse", () => {
|
||||
|
||||
test("a refused var names its key, never its value", () => {
|
||||
const secret = "sk-secret-marker-7731";
|
||||
const hides = (fn, key) => assert.throws(fn, (error) => error.name === "BusinessError" && error.message.includes(`variable ${key}`) && !error.message.includes(secret));
|
||||
const hides = (fn, key) => assert.throws(fn, (error) => error.name === "BusinessError" && error.message.includes(`variable ${key} has a value it can't take`) && !error.message.includes(secret));
|
||||
hides(() => root({ x: { definition: "reviewer", vars: { thinking: secret } } }), "thinking");
|
||||
hides(() => root({ x: { definition: "reviewer", vars: { model: "opus-5.5", harness: `${secret}!` } } }), "harness");
|
||||
hides(() => root({ x: { definition: "reviewer", vars: { "limits.tools": ["read", secret] } } }), "limits.tools");
|
||||
|
||||
@@ -251,7 +251,7 @@ test("names that are Object.prototype properties don't count as declared", () =>
|
||||
refuses(() => check((d) => { d.arbiters.delivery = "constructor"; }), /arbiters\.delivery names constructor/);
|
||||
refuses(() => check((d) => { d.launch.by = "constructor"; }), /launch\.by names constructor/);
|
||||
refuses(() => check((d) => { d.launch.instances = ["constructor"]; }), /instances names constructor/);
|
||||
refuses(() => check((d) => { d.roles.pm.vars.model = "constructor"; }), /variable model selects an unknown model/);
|
||||
refuses(() => check((d) => { d.roles.pm.vars.model = "constructor"; }), /unknown model constructor/);
|
||||
refuses(() => check((d) => { d.roles.pm.definition = "constructor"; }), /not found/, 4);
|
||||
const labels = check((d) => { d.tracker.labels = JSON.parse('{"__proto__": 5}'); }).tracker.labels;
|
||||
assert.equal(Object.getPrototypeOf(labels), Object.prototype);
|
||||
|
||||
@@ -1,140 +0,0 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { join } from "node:path";
|
||||
import {
|
||||
BusinessError, checkVars, loadBusiness, loadProject, projectFilePath, systemVars,
|
||||
} from "../src/index.mjs";
|
||||
import { businessDoc, REPO_ROLES, tmp, writeJson } from "./helpers.mjs";
|
||||
import { checkRoutes, launchSettings } from "../src/launch-settings.mjs";
|
||||
import { OrganizationError } from "../src/organization.mjs";
|
||||
|
||||
const SECRET = "sk-test-NOT-A-SECRET-1234";
|
||||
|
||||
function hidesValue(fn, key, reason) {
|
||||
assert.throws(fn, (error) => {
|
||||
assert.ok(error instanceof BusinessError);
|
||||
assert.equal(error.name, "BusinessError");
|
||||
assert.equal(error.exitCode, 2);
|
||||
assert.ok(error.message.includes(`variable ${key}`), error.message);
|
||||
if (reason) assert.match(error.message, reason);
|
||||
assert.ok(!error.message.includes(SECRET), "refusal disclosed a var value");
|
||||
assert.ok(!String(error).includes(SECRET), "rendered error disclosed a var value");
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
for (const [scope, key, value] of [
|
||||
["business", "tracker.kind", SECRET],
|
||||
["agent", "thinking", SECRET],
|
||||
["agent", "model", SECRET],
|
||||
]) {
|
||||
test(`organization file ${scope} ${key} refusal names its key and hides its value`, () => {
|
||||
const root = tmp();
|
||||
const doc = businessDoc(root);
|
||||
if (scope === "agent") doc.roles.coder.vars[key] = value;
|
||||
else doc.vars[key] = value;
|
||||
writeJson(join(root, "businesses/acme.json"), doc);
|
||||
hidesValue(() => loadBusiness("acme", { dir: root, rolesDir: REPO_ROLES }), key, key === "model" ? /unknown model/ : /one of/);
|
||||
});
|
||||
}
|
||||
|
||||
for (const scope of ["project", "role"]) {
|
||||
test(`project file ${scope} var refusal names its key and hides its value`, () => {
|
||||
const root = tmp();
|
||||
const vars = { "tracker.project": SECRET };
|
||||
writeJson(projectFilePath(root), {
|
||||
projectVersion: 1, id: "stack",
|
||||
...(scope === "project" ? { vars } : { roles: { coder: { vars } } }),
|
||||
});
|
||||
hidesValue(() => loadProject(root), "tracker.project", /positive integer/);
|
||||
});
|
||||
}
|
||||
|
||||
test("systemVars accepts user values and refuses malformed ones without quoting them", () => {
|
||||
const config = {
|
||||
environment: "development", dataRoot: "/d",
|
||||
execution: { backend: "docker", provider: "zai", model: "m", adapter: "pi" },
|
||||
};
|
||||
for (const key of ["environment", "dataRoot", "execution.backend", "execution.provider", "execution.model", "execution.adapter"]) {
|
||||
const bad = structuredClone(config);
|
||||
const value = { pasted: SECRET };
|
||||
if (key.startsWith("execution.")) bad.execution[key.slice("execution.".length)] = value;
|
||||
else bad[key] = value;
|
||||
hidesValue(() => systemVars(bad), key);
|
||||
}
|
||||
});
|
||||
|
||||
// Exercise every validator that used to quote a scalar, a nested leaf or a duplicate.
|
||||
// Removing any of their redactions must fail here even if a caller sanitizes its error.
|
||||
for (const [key, layer, value, reason] of [
|
||||
["tracker.kind", "business", SECRET, /one of/],
|
||||
["harness", "agent", `${SECRET}!`, /harness module name/],
|
||||
["fallback.harness", "agent", `${SECRET}!`, /harness module name/],
|
||||
["thinking", "agent", { pasted: [SECRET] }, /one of/],
|
||||
["fallback.thinking", "agent", SECRET, /one of/],
|
||||
["tracker.reconcileMinutes", "business", SECRET, /integer of at least 1/],
|
||||
["tracker.pollSeconds", "project", SECRET, /integer of at least 10/],
|
||||
["tracker.baseUrl", "business", SECRET, /http or https URL/],
|
||||
["gitea.baseUrl", "business", SECRET, /http or https URL/],
|
||||
["web.searxng", "business", `http://${SECRET}.example`, /https, or http on loopback/],
|
||||
["git.workingBranch", "project", `${SECRET}!`, /git branch name/],
|
||||
["git.protectedBranches", "project", [SECRET, `${SECRET}!`], /git branch name/],
|
||||
["git.protectedBranches", "project", [SECRET, SECRET], /duplicate entry/],
|
||||
["suites", "project", [SECRET, SECRET], /duplicate entry/],
|
||||
["tracker.descendants", "project", SECRET, /true or false/],
|
||||
["bus.tester", "business", SECRET, /true or false/],
|
||||
["tracker.project", "project", SECRET, /positive integer/],
|
||||
["limits.tools", "agent", ["read", SECRET], /unsupported tool/],
|
||||
["limits.authority", "agent", ["message.send", SECRET], /unknown action/],
|
||||
["limits.network", "agent", SECRET, /one of/],
|
||||
]) {
|
||||
test(`checkVars hides refused ${key}: ${reason.source}`, () => {
|
||||
hidesValue(() => checkVars({ [key]: value }, layer, "fixture vars"), key, reason);
|
||||
});
|
||||
}
|
||||
|
||||
for (const prefix of ["", "fallback."]) {
|
||||
test(`route writer unknown ${prefix}model names its key and hides its value`, () => {
|
||||
const root = tmp();
|
||||
const doc = businessDoc(root);
|
||||
writeJson(join(root, "businesses/acme.json"), doc);
|
||||
const settings = launchSettings({ dir: root, rolesDir: REPO_ROLES });
|
||||
const route = { harness: "pi", model: Object.keys(doc.launch.models)[0], thinking: "high", fallback: null };
|
||||
if (prefix) route.fallback = { harness: "pi", model: SECRET, thinking: "high" };
|
||||
else route.model = SECRET;
|
||||
assert.throws(() => settings.setRoutes("acme", {
|
||||
revision: settings.writer.read("acme").revision, routes: { coder: route },
|
||||
}, "jason"), error => {
|
||||
assert.ok(error instanceof OrganizationError);
|
||||
assert.equal(error.code, "unknown-model");
|
||||
assert.ok(!String(error).includes(SECRET));
|
||||
assert.ok(error.message.includes(`variable ${prefix}model`), error.message);
|
||||
return true;
|
||||
});
|
||||
});
|
||||
|
||||
for (const [field, code] of [
|
||||
["model", "unknown-model"], ["harness", "harness-off"],
|
||||
["harness", "harness-refused"], ["thinking", "thinking-unsupported"],
|
||||
]) {
|
||||
test(`route check ${prefix}${field} ${code} names its key and hides its value`, () => {
|
||||
const vars = prefix ? {
|
||||
harness: "pi", model: "listed", thinking: "high",
|
||||
"fallback.harness": "pi", "fallback.model": "listed", "fallback.thinking": "high",
|
||||
} : { harness: "pi", model: "listed", thinking: "high" };
|
||||
const key = `${prefix}${field}`;
|
||||
vars[key] = SECRET;
|
||||
const proposed = {
|
||||
roles: { coder: { vars } }, launch: { models: { listed: {} } },
|
||||
plugins: { harness: code === "harness-refused" ? ["pi", SECRET] : ["pi"] },
|
||||
};
|
||||
assert.throws(() => checkRoutes({ proposed, levels: name => name === SECRET ? null : ["high"] }), error => {
|
||||
assert.ok(error instanceof OrganizationError);
|
||||
assert.equal(error.code, code);
|
||||
assert.ok(!String(error).includes(SECRET), "route refusal disclosed a var value");
|
||||
assert.ok(error.message.includes(`variable ${key}`), error.message);
|
||||
return true;
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import { startServer, previewOptions, proofOptions, DEFAULT_BOARD } from './serv
|
||||
import { busCaller, busBusiness, userCaller, consoleCall } from './bus.mjs';
|
||||
import { organizationConsole } from './organizations.mjs';
|
||||
import { consoleBus, userOptionsFor, agentCaller, TESTER_AGENT_VERBS } from './console-bus.mjs';
|
||||
import { SYSTEM_TYPE_VERBS } from './system-types-http.mjs';
|
||||
import { consoleAccess } from './access.mjs';
|
||||
import { accessContextFor } from '../../cli/src/access.mjs';
|
||||
import { accessFilePath, loadAccess } from '../../organization/src/access.mjs';
|
||||
@@ -169,6 +170,9 @@ try {
|
||||
// The owner reads request status on the selected organization's bus, as on the root Console. Request
|
||||
// writes stay off: the README "Console actions" inventory marks them unavailable for the organizations release.
|
||||
requestRead: session.user === undefined ? consoleCall({ dataRoot: root, socket: paths.socket, business: id }) : null,
|
||||
// Phase P2 (#1873): the owner's System types writes, the organization's enable switch (plugins.set
|
||||
// with its revision) and a project's selection, over the owner's human transport under --send only.
|
||||
systemTypesWrite: session.user === undefined && sendOn ? agentCaller(owner, SYSTEM_TYPE_VERBS) : null,
|
||||
reads: consoleReads({ root: REPO, queueRoot: options.checkout ?? REPO, system, configDir: configDir(), rolesDir: rolesDir(), business: id, orgRoot: root }),
|
||||
};
|
||||
},
|
||||
|
||||
@@ -620,7 +620,7 @@
|
||||
html += p && !c.refused && redesigned ? window.MosaicProjects.header(p, c.section, c.threads) : heading(p?.name ?? 'Project unavailable');
|
||||
if (p && !c.refused) {
|
||||
if (p.archived && redesigned && !c.canSend) html += '<p class="project-panel" role="status">This project is archived. Its saved conversations and records stay readable.</p>';
|
||||
if (c.settings) html += `<p><a href="#/projects/${esc(c.id)}">Project overview</a></p><h2>Project information</h2><p>Repository: ${txt(p.repo || 'Not linked')}</p><div data-chat-remote>Repository setting unavailable.</div>`;
|
||||
if (c.settings) html += `<p><a href="#/projects/${esc(c.id)}">Project overview</a></p><h2>Project information</h2><p>Repository: ${txt(p.repo || 'Not linked')}</p><div data-chat-remote>Repository setting unavailable.</div><div data-chat-system-types>System types unavailable.</div>`;
|
||||
else if (c.section === 'conversations' && redesigned) html += `<div class="project-section-title"><h2>Conversations</h2></div><div data-project-conversations>${c.threads.filter(t=>!t.shared && t.project===p.id).map(t=>`<a class="project-conversation-row" href="${threadPath(esc(t.id))}" data-conversation-id="${esc(t.id)}"><span class="project-conversation-icon" aria-hidden="true">◌</span><span><strong>${txt(t.name)}</strong><small>With ${txt(t.instance)}</small></span><span data-conversation-status></span><span aria-hidden="true">›</span></a>`).join('') || '<p>No conversations yet. Start one to begin work.</p>'}</div>${p.archived ? '' : `<details class="project-new-conversation"${location.hash.includes('new=1')?' open':''}><summary class="btn primary">New conversation</summary>${creation('thread', c.id)}</details>`}<h2>Shared with me</h2>${c.threads.some(t=>t.shared && t.project===p.id)?threads(c.threads.filter(t=>t.shared && t.project===p.id)):'<p>Nothing is shared with you yet.</p>'}`;
|
||||
else if (c.section === 'conversations') html += '<h2>Conversations</h2>' + threads(c.threads.filter(t => !t.shared)) + (p.archived ? '' : creation('thread', c.id));
|
||||
else if (redesigned) html += '<div data-project-content>Reading…</div>';
|
||||
@@ -684,6 +684,9 @@
|
||||
const remote = c.container.querySelector('[data-chat-remote]');
|
||||
const project = c.projects.find(p => p.id === c.id);
|
||||
if (remote && roomOwner() && typeof window.MosaicProjectRemote?.mount === 'function') { remote.replaceChildren(); window.MosaicProjectRemote.mount({ container: remote, projectId: c.id, repositoryKey: project?.repo ?? null, projectArchived: !!c.projectArchived, preserve: true }); }
|
||||
// Phase P2 (#1873): the project's System types selection, the owner's, mounted like the remote.
|
||||
const types = c.container.querySelector('[data-chat-system-types]');
|
||||
if (types && roomOwner() && typeof window.MosaicSystemTypes?.mount === 'function') window.MosaicSystemTypes.mount({ container: types, projectId: c.id, archived: !!c.projectArchived });
|
||||
if (!window.MosaicOrg && (!redesigned || c.kind !== 'project' || c.settings || c.projectArchived)) window.MosaicProjectArchive?.mount(c.container, c);
|
||||
if (redesigned && c.kind === 'project' && c.projectArchived && !c.settings) {
|
||||
const controls = c.container.querySelector('.project-archive-control');
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
<link rel="stylesheet" href="/processing.css"><script src="/processing.js" defer></script>
|
||||
<link rel="stylesheet" href="/org-create.css"><script src="/org-create.js" defer></script>
|
||||
<link rel="stylesheet" href="/onboarding.css"><script src="/onboarding.js" defer></script><link rel="stylesheet" href="/panes.css">
|
||||
<script src="/workspace-data.js" defer></script><script src="/workspace.js" defer></script><script src="/tasks-board.js" defer></script><script src="/console-restart.js" defer></script><script src="/display.js" defer></script><script src="/inbox.js" defer></script><script src="/files.js" defer></script><script src="/project-remote.js" defer></script><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="/projects.js" defer></script><script src="/chat.js" defer></script><script src="/project-archive.js" defer></script><script src="/agents.js" defer></script><script src="/panes.js" defer></script><script src="/bus.js" defer></script><script src="/requests.js" defer></script></head>
|
||||
<script src="/workspace-data.js" defer></script><script src="/workspace.js" defer></script><script src="/tasks-board.js" defer></script><script src="/console-restart.js" defer></script><script src="/display.js" defer></script><script src="/inbox.js" defer></script><script src="/files.js" defer></script><script src="/project-remote.js" defer></script><link rel="stylesheet" href="/system-types.css"><script src="/system-types.js" defer></script><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="/projects.js" defer></script><script src="/chat.js" defer></script><script src="/project-archive.js" defer></script><script src="/agents.js" defer></script><script src="/panes.js" defer></script><script src="/bus.js" defer></script><script src="/requests.js" defer></script></head>
|
||||
<body>
|
||||
<div hidden aria-hidden="true"><select id="palette"></select><select id="mode"><option value="system">system</option><option value="light">light</option><option value="dim">dim</option><option value="dark">dark</option></select></div>
|
||||
<a class="skip" href="#main">Skip to content</a>
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
/* Phase P2 (#1873): System types on the organization dashboard (.ws-types, the chips and the
|
||||
Settings row's Open) and on a project's information page (.project-types). */
|
||||
html[data-organizations] .ws-chip{display:inline-flex;align-items:center;font-size:12px;font-weight:500;padding:1px 7px;border:1px solid var(--line);border-radius:4px;color:var(--text);white-space:nowrap}
|
||||
html[data-organizations] .ws-cfg button.go{border:0;background:none;padding:0;cursor:pointer;font:inherit;font-size:12.5px;font-weight:600;color:var(--action)}
|
||||
html[data-organizations] .ws-cfg button.go:hover{text-decoration:underline}
|
||||
html[data-organizations] .ws-types-list{display:grid;list-style:none;margin:0;padding:0;border:1px solid var(--line);border-radius:8px;overflow:hidden;background:var(--surface)}
|
||||
html[data-organizations] .ws-types-list li{display:grid;grid-template-columns:minmax(0,1fr) auto auto;align-items:center;gap:4px 12px;padding:8px 12px;border-bottom:1px solid color-mix(in srgb,var(--line) 60%,transparent)}
|
||||
html[data-organizations] .ws-types-list li:last-child{border-bottom:0}
|
||||
html[data-organizations] .ws-types-list .t{display:flex;flex-direction:column;min-width:0}
|
||||
html[data-organizations] .ws-types-list .t b{font-size:13px;font-weight:600;color:var(--strong)}
|
||||
html[data-organizations] .ws-types-list .t .d{font-size:12px;color:var(--muted);overflow-wrap:anywhere}
|
||||
html[data-organizations] .ws-types-list .ws-dot{font-size:12.5px}
|
||||
html[data-organizations] .ws-types-list .ws-btn[aria-disabled="true"]{color:var(--muted);cursor:progress}
|
||||
html[data-organizations] .ws-types>.note{margin:8px 0 0}
|
||||
html[data-organizations] .ws-types-status:empty{display:none}
|
||||
html[data-organizations] .ws-types-status code{overflow-wrap:anywhere}
|
||||
html[data-organizations] .ws-types h2:focus{outline:none}
|
||||
@media (max-width:759px){
|
||||
html[data-organizations] .ws-types-list li{grid-template-columns:minmax(0,1fr) auto}
|
||||
html[data-organizations] .ws-types-list .t{grid-column:1/-1}
|
||||
}
|
||||
|
||||
.project-types{border:1px solid var(--border, var(--line, #aaa));border-radius:4px;padding:1rem;margin-top:1rem;overflow-wrap:anywhere}
|
||||
.project-types fieldset{border:0;margin:0;padding:0;display:grid;gap:.5rem}
|
||||
.project-types legend{font-weight:600;margin-bottom:.25rem}
|
||||
.project-types-option{display:flex;align-items:flex-start;gap:.5rem;min-height:44px}
|
||||
.project-types-option input{margin-top:.2rem;width:20px;height:20px;flex:none}
|
||||
.project-types-option span{display:flex;flex-direction:column;min-width:0}
|
||||
.project-types-option small{color:var(--muted, inherit)}
|
||||
.project-types button{min-height:44px;margin-top:.75rem}
|
||||
.project-types-tag{font-size:.8em;font-weight:600;padding:0 .4em;border:1px solid currentColor;border-radius:4px;color:var(--warning, inherit)}
|
||||
.project-types [role="status"]:empty{display:none}
|
||||
.project-types [role="status"]:focus{outline:none}
|
||||
@@ -0,0 +1,195 @@
|
||||
// Phase P2 (#1873): System types, the owner's control on the organization dashboard and on a project's
|
||||
// information page. The modules, their labels and descriptions come from the system-type plug-ins
|
||||
// through /api/system-types; nothing here lists a module. Writes go to the same route, which uses the
|
||||
// organization writer (revision, owner fence, receipt) or project.system-types.set; the server and the
|
||||
// bus recheck every write, so a control shown here grants nothing.
|
||||
(() => {
|
||||
'use strict';
|
||||
const esc = value => String(value ?? '').replace(/[&<>"']/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]));
|
||||
const enc = encodeURIComponent;
|
||||
const ID = /^[A-Za-z0-9][A-Za-z0-9._-]{0,159}$/;
|
||||
const orgPath = (org, rest) => (org ? `/api/orgs/${enc(org)}/${rest}` : `/api/${rest}`);
|
||||
const LINES = {
|
||||
'sign-in-required': 'Sign in again to see or change system types.',
|
||||
'owner-only': "System types are the organization owner's.",
|
||||
'read-only': 'This Console only reads. Changing system types needs signed-in send mode.',
|
||||
'human-required': "Only the organization's owner can change system types.",
|
||||
'organization-system-types-human-only': "Only the organization's owner can change system types.",
|
||||
'revision-conflict': 'The organization file changed after this page read it. The list was read again; try again.',
|
||||
'organization-busy': 'The organization file is being written. Try again in a moment.',
|
||||
'system-type-not-enabled': 'A chosen type is not enabled in the organization any more. The list was read again.',
|
||||
'system-type-unknown': 'A chosen type is not installed in this tree.',
|
||||
'project-not-found': 'This project was not found.',
|
||||
'not-configured': 'System types are not configured on this Console.',
|
||||
'system-type-invalid': 'An installed system type module is invalid, so none can be read.',
|
||||
'no-bus-host': 'The bus is not running.',
|
||||
'invalid-request': 'The request was refused as invalid.',
|
||||
};
|
||||
const line = code => LINES[code] || 'The bus did not confirm the outcome. The list was read again; check it before you retry.';
|
||||
|
||||
// One request; a refusal keeps its code so the page can say why.
|
||||
async function call(path, body) {
|
||||
const init = body === undefined ? { cache: 'no-store', redirect: 'manual' }
|
||||
: { method: 'POST', cache: 'no-store', redirect: 'manual', headers: { 'content-type': 'application/json' }, body: JSON.stringify(body) };
|
||||
let response;
|
||||
try { response = await fetch(path, init); } catch { return { ok: false, code: 'outcome-unknown' }; }
|
||||
if (response.type === 'opaqueredirect') return { ok: false, code: 'sign-in-required' };
|
||||
const value = await response.json().catch(() => null);
|
||||
if (!response.ok) return { ok: false, code: typeof value?.error === 'string' ? value.error : 'outcome-unknown' };
|
||||
return value && typeof value === 'object' ? { ok: true, value } : { ok: false, code: 'outcome-unknown' };
|
||||
}
|
||||
const read = (org, project = null) => call(orgPath(org, project === null ? 'system-types' : `system-types/projects/${enc(project)}`));
|
||||
const labelOf = (value, id) => value.installed.find(m => m.id === id)?.label || id;
|
||||
const valid = value => value && Array.isArray(value.installed) && Array.isArray(value.enabled);
|
||||
|
||||
// ---------- organization dashboard ----------
|
||||
// Per organization: the last read, the type a write is pending on, and the last outcome line.
|
||||
const orgs = new Map();
|
||||
const slot = org => { if (!orgs.has(org)) orgs.set(org, { value: null, code: null, pending: null, outcome: null }); return orgs.get(org); };
|
||||
function remember(org, result) {
|
||||
const s = slot(org);
|
||||
if (result.ok && valid(result.value)) { s.value = result.value; s.code = null; } else { s.value = null; s.code = result.ok ? 'outcome-unknown' : result.code; }
|
||||
return s;
|
||||
}
|
||||
// The chip row under the header: the enabled modules' labels.
|
||||
function chips(org) {
|
||||
const s = slot(org), v = s.value;
|
||||
const body = !v ? `<span class="note">${esc(s.code === 'owner-only' ? line('owner-only') : 'System types unavailable')}</span>`
|
||||
: !v.installed.length ? '<span class="note">No system type modules are installed.</span>'
|
||||
: v.enabled.length ? `${v.enabled.map(id => `<span class="ws-chip">${esc(labelOf(v, id))}</span>`).join('')}<span class="note">Each project picks from these; none is required.</span>`
|
||||
: '<span class="note">None enabled. Enable them under System types below; each project picks from the enabled ones.</span>';
|
||||
return `<div class="ws-typechips ws-chips" data-system-type-chips><span class="ws-label">System types</span>${body}</div>`;
|
||||
}
|
||||
// The Settings row's value.
|
||||
function summary(org) {
|
||||
const v = slot(org).value;
|
||||
return v ? `${v.enabled.filter(id => v.installed.some(m => m.id === id)).length} of ${v.installed.length} installed enabled` : 'Could not be read';
|
||||
}
|
||||
// A side-by-side pane (U5) passes { pane: true, idPrefix }: it shows the types without the switch,
|
||||
// which stays in the organization's full view, and its heading id carries the pane's prefix.
|
||||
function section(org, { pane = false, idPrefix = '' } = {}) {
|
||||
const s = slot(org), v = s.value;
|
||||
const head = `<div class="ws-sec-h"><h2 id="${esc(idPrefix)}ws-types-h" tabindex="-1" data-system-types-heading>System types</h2><span class="note">installed modules; each project picks from the enabled ones</span></div>`;
|
||||
const status = `<p class="note ws-types-status" role="status" data-system-types-status>${s.outcome ? s.outcome : ''}</p>`;
|
||||
if (!v) return `<div class="ws-types" data-system-types>${head}<p class="ws-panel tight note">${esc(s.code === 'owner-only' ? line('owner-only') : `System types unavailable. ${line(s.code)}`)}</p>${status}</div>`;
|
||||
if (!v.installed.length) return `<div class="ws-types" data-system-types>${head}<p class="ws-panel tight note">No system type modules are installed.</p>${status}</div>`;
|
||||
const rows = v.installed.map(m => {
|
||||
const on = v.enabled.includes(m.id), busy = s.pending !== null;
|
||||
const state = `<span class="ws-dot ${on ? 's-working' : 's-idle'}"><b aria-hidden="true">${on ? '●' : '–'}</b>${on ? 'enabled' : 'off'}</span>`;
|
||||
const control = v.writable && !pane
|
||||
? `<button type="button" class="ws-btn" data-system-type-toggle="${esc(m.id)}" data-on="${on}" data-focus-key="${esc(`st:${m.id}`)}" aria-label="${esc(`${on ? 'Disable' : 'Enable'} ${m.label}`)}"${busy ? ' aria-disabled="true"' : ''}>${s.pending === m.id ? 'Saving…' : on ? 'Disable' : 'Enable'}</button>`
|
||||
: '';
|
||||
return `<li data-system-type="${esc(m.id)}"><span class="t"><b>${esc(m.label)}</b><span class="d">${esc(m.description)}</span></span>${state}${control}</li>`;
|
||||
}).join('');
|
||||
const why = pane ? "<p class=\"note\">Change system types from this organization's full view.</p>"
|
||||
: v.writable ? '<p class="note">Disabling a type keeps it in the projects that pick it; they show it as inactive until it is enabled again.</p>'
|
||||
: `<p class="note">${esc(line('read-only'))}</p>`;
|
||||
return `<div class="ws-types" data-system-types>${head}<ul class="ws-types-list">${rows}</ul>${why}${status}</div>`;
|
||||
}
|
||||
// The dashboard asks for this before it paints; the result stays for chips(), summary() and section().
|
||||
const load = async org => remember(org, await read(org));
|
||||
// After a write, the three parts are repainted in place; the 10 s dashboard repaint reads again anyway.
|
||||
function repaint(org) {
|
||||
if (window.MosaicOrg?.id !== org) return;
|
||||
const view = [...document.querySelectorAll('[data-org-dashboard]')].find(v => !v.closest('[data-pane]')); if (!view) return;
|
||||
const a = document.activeElement, key = view.contains(a) ? a.dataset?.focusKey || null : null;
|
||||
const swap = (selector, html) => { const el = view.querySelector(selector); if (el) el.outerHTML = html; };
|
||||
swap('[data-system-type-chips]', chips(org));
|
||||
swap('[data-system-types]', section(org));
|
||||
const value = view.querySelector('[data-org-setting="System types"] .v'); if (value) { value.textContent = summary(org); value.title = summary(org); }
|
||||
if (key) view.querySelector(`[data-focus-key="${CSS.escape(key)}"]`)?.focus({ preventScroll: true });
|
||||
}
|
||||
async function toggle(button) {
|
||||
const org = window.MosaicOrg?.id, s = org ? slot(org) : null, type = button.dataset.systemTypeToggle;
|
||||
if (!s?.value?.writable || s.pending !== null || typeof s.value.revision !== 'string') return;
|
||||
const on = s.value.enabled.includes(type);
|
||||
const enabled = on ? s.value.enabled.filter(id => id !== type) : [...s.value.enabled, type];
|
||||
s.pending = type; s.outcome = null; repaint(org);
|
||||
const result = await call(orgPath(org, 'system-types'), { revision: s.value.revision, enabled });
|
||||
s.pending = null;
|
||||
const label = labelOf(s.value, type);
|
||||
if (result.ok && typeof result.value.transaction === 'string') {
|
||||
s.outcome = `${esc(label)} ${on ? 'disabled' : 'enabled'}. Receipt <code>${esc(result.value.transaction)}</code>.`;
|
||||
} else s.outcome = esc(result.ok ? line('outcome-unknown') : line(result.code));
|
||||
await load(org);
|
||||
repaint(org);
|
||||
}
|
||||
document.addEventListener('click', e => {
|
||||
const button = e.target.closest?.('[data-system-type-toggle]');
|
||||
// A pending write marks the buttons aria-disabled, not disabled, so focus stays on the one pressed.
|
||||
if (button) { e.preventDefault(); if (button.getAttribute('aria-disabled') !== 'true' && !button.closest('[data-pane]')) toggle(button); return; }
|
||||
const open = e.target.closest?.('[data-system-types-open]');
|
||||
if (open) { e.preventDefault(); const h = (open.closest('[data-org-dashboard]') || document).querySelector('[data-system-types-heading]'); h?.scrollIntoView({ block: 'start' }); h?.focus({ preventScroll: true }); }
|
||||
});
|
||||
|
||||
// ---------- project information page ----------
|
||||
let active = null;
|
||||
document.addEventListener('mosaic-signed-out', () => { active?.dispose(); active = null; });
|
||||
// The chat view mounts again on every repaint and keeps the section; leaving the page marks it stale,
|
||||
// so coming back (say, after the dashboard changed the enabled set) reads again.
|
||||
window.addEventListener('hashchange', () => { if (active) active.stale = true; });
|
||||
function mount({ container, projectId, archived = false }) {
|
||||
if (!(container instanceof Element)) return;
|
||||
const org = window.MosaicOrg?.id ?? null;
|
||||
if (active && active.org === org && active.projectId === projectId) {
|
||||
container.replaceChildren(active.section);
|
||||
if (active.archived !== archived) { active.archived = archived; active.paint(); }
|
||||
if (active.stale) { active.stale = false; active.refresh(); }
|
||||
return;
|
||||
}
|
||||
active?.dispose();
|
||||
const section = document.createElement('section');
|
||||
section.className = 'project-types';
|
||||
section.setAttribute('data-project-system-types', '');
|
||||
container.replaceChildren(section);
|
||||
let value = null, code = null, outcome = '', saving = false, disposed = false, generation = 0;
|
||||
const state = { org, projectId, section, archived, stale: false, paint, refresh: () => refresh(), dispose: () => { disposed = true; generation++; section.replaceChildren(); } };
|
||||
active = state;
|
||||
function paint() {
|
||||
if (disposed) return;
|
||||
const head = '<h2 tabindex="-1">System types</h2>';
|
||||
const status = `<p role="status" tabindex="-1" data-project-system-types-status>${outcome}</p>`;
|
||||
if (!ID.test(projectId ?? '')) { section.innerHTML = `${head}<p>System types unavailable.</p>`; return; }
|
||||
if (!value) { section.innerHTML = `${head}<p>${esc(code ? `System types unavailable. ${line(code)}` : 'Reading system types…')}</p>${status}`; return; }
|
||||
const sel = value.project, enabled = value.installed.filter(m => value.enabled.includes(m.id));
|
||||
const inactive = sel.inactive.length
|
||||
? `<div data-project-system-types-inactive><p>Inactive, because the organization has disabled them:</p><ul>${sel.inactive.map(id => `<li data-inactive-type="${esc(id)}">${esc(labelOf(value, id))} <span class="project-types-tag">inactive</span></li>`).join('')}</ul>${value.writable && !state.archived ? '<p class="muted">Saving keeps only enabled types, so it removes these from the project.</p>' : ''}</div>`
|
||||
: '';
|
||||
const intro = '<p>This project picks from the system types its organization has enabled. None is required.</p>';
|
||||
const canWrite = value.writable && !state.archived;
|
||||
if (!enabled.length) {
|
||||
// With nothing enabled the only change left is clearing what the project still selects.
|
||||
const clear = canWrite && sel.inactive.length
|
||||
? `<form data-project-system-types-form><button type="submit" class="btn"${saving ? ' disabled' : ''}>${saving ? 'Saving…' : 'Clear the inactive types'}</button></form>` : '';
|
||||
section.innerHTML = `${head}${intro}<p>The organization has no system types enabled. The owner enables them on the organization dashboard.</p>${inactive}${clear}${status}`;
|
||||
return;
|
||||
}
|
||||
const boxes = enabled.map(m => `<label class="project-types-option"><input type="checkbox" name="system-type" value="${esc(m.id)}"${sel.active.includes(m.id) ? ' checked' : ''}${canWrite && !saving ? '' : ' disabled'}><span><strong>${esc(m.label)}</strong><small>${esc(m.description)}</small></span></label>`).join('');
|
||||
const why = state.archived ? '<p class="muted">This project is archived; its selection is shown read only.</p>' : value.writable ? '' : `<p class="muted">${esc(line('read-only'))}</p>`;
|
||||
section.innerHTML = `${head}${intro}<form data-project-system-types-form><fieldset><legend>Selected system types</legend>${boxes}</fieldset>${inactive}${canWrite ? `<button type="submit" class="btn"${saving ? ' disabled' : ''}>${saving ? 'Saving…' : 'Save system types'}</button>` : ''}</form>${why}${status}`;
|
||||
}
|
||||
async function refresh() {
|
||||
const turn = ++generation;
|
||||
const result = await read(org, projectId);
|
||||
if (disposed || turn !== generation) return;
|
||||
if (result.ok && valid(result.value) && result.value.project) { value = result.value; code = null; } else { value = null; code = result.ok ? 'outcome-unknown' : result.code; }
|
||||
paint();
|
||||
}
|
||||
section.addEventListener('submit', async e => {
|
||||
e.preventDefault();
|
||||
if (saving || !value?.writable || state.archived) return;
|
||||
const chosen = [...section.querySelectorAll('input[name="system-type"]:checked')].map(i => i.value);
|
||||
saving = true; outcome = ''; paint();
|
||||
const result = await call(orgPath(org, `system-types/projects/${enc(projectId)}`), { systemTypes: chosen });
|
||||
if (disposed) return;
|
||||
saving = false;
|
||||
outcome = result.ok ? esc(result.value.selected.length ? `Saved. Selected: ${result.value.selected.map(id => labelOf(value, id)).join(', ')}.` : 'Saved. No system types selected.') : esc(line(result.code));
|
||||
await refresh();
|
||||
section.querySelector('[data-project-system-types-status]')?.focus({ preventScroll: true });
|
||||
});
|
||||
paint();
|
||||
refresh();
|
||||
}
|
||||
|
||||
window.MosaicSystemTypes = Object.freeze({ load, chips, summary, section, mount });
|
||||
})();
|
||||
@@ -1,7 +1,8 @@
|
||||
// The unified workspace screens in organization mode: the sidebar's Organizations and Projects
|
||||
// groups (sample 01), the organization dashboard (sample 03) and the Agents levels (sample 08).
|
||||
// Every read goes through MosaicWorkspaceData (workspace-data.js). Nothing here writes; the bus
|
||||
// restart button is console-restart.js's own control, with its gating and trail unchanged.
|
||||
// restart button is console-restart.js's own control, with its gating and trail unchanged, and the
|
||||
// System types chips, row and section are system-types.js's (Phase P2), with its own reads and writes.
|
||||
(() => {
|
||||
'use strict';
|
||||
if (!document.documentElement.hasAttribute('data-organizations')) return;
|
||||
@@ -154,7 +155,7 @@
|
||||
const secH = (title, note = '', link = '') => `<div class="ws-sec-h"><h2>${title}</h2>${note ? `<span class="note">${note}</span>` : ''}${link}</div>`;
|
||||
|
||||
function settingsList(org, ctx) {
|
||||
const { business, settings, instances, orgCount } = ctx;
|
||||
const { business, settings, instances, orgCount, types } = ctx;
|
||||
const b = business?.business, s = settings;
|
||||
const unread = 'Could not be read';
|
||||
const names = instances ? Object.keys(instances) : null;
|
||||
@@ -166,7 +167,7 @@
|
||||
const rows = [
|
||||
['business', 'Identity', b ? `${label(org)} · ${org.id} · kind not recorded` : unread, 'org file', 'business'],
|
||||
['flag', 'Appearance', `${titleCase(theme)} theme · ${initials(org)} avatar`, b?.appearance?.theme || org.theme ? 'org file' : 'system', 'appearance'],
|
||||
['cube', 'System types', 'Not built yet', 'not built', null],
|
||||
['cube', 'System types', types ? types.summary(org.id) : 'Could not be read', 'org file', types ? '@system-types' : null],
|
||||
['folder', 'Projects', b ? (Array.isArray(b.projects) && b.projects.length ? plural(b.projects.length, 'project') : 'No projects') : unread, 'org file', '#/projects'],
|
||||
['people', 'Agents and roles', names ? (names.length ? `${plural(names.length, 'agent')} · ${Object.values(instances).filter(running).length} running` : 'No agents') : unread, 'org file', 'agents'],
|
||||
['term', 'Models and routing', models ? (models.length ? models.join(' · ') : 'No models set') : unread, 'org file', 'routing'],
|
||||
@@ -185,8 +186,10 @@
|
||||
['close', 'Archive or delete', "Archive or delete this organization's file", 'danger', 'organizations'],
|
||||
];
|
||||
return `<ul class="ws-cfg one" data-org-settings>${rows.map(([ic, name, value, tag, page]) => {
|
||||
const href = page === null ? null : page.startsWith('#') ? page : `#/settings/${page}`;
|
||||
const go = href ? `<a class="go" href="${esc(href)}" data-focus-key="${esc(`set:${name}`)}" aria-label="Open ${esc(name)}">Open</a>` : '<span class="go" aria-hidden="true"></span>';
|
||||
const href = page === null || page.startsWith('@') ? null : page.startsWith('#') ? page : `#/settings/${page}`;
|
||||
// '@system-types' is the System types section further down this page, not a route.
|
||||
const go = page === '@system-types' ? `<button type="button" class="go" data-system-types-open data-focus-key="${esc(`set:${name}`)}" aria-label="Open ${esc(name)}">Open</button>`
|
||||
: href ? `<a class="go" href="${esc(href)}" data-focus-key="${esc(`set:${name}`)}" aria-label="Open ${esc(name)}">Open</a>` : '<span class="go" aria-hidden="true"></span>';
|
||||
return `<li${tag === 'danger' ? ' class="danger"' : ''} data-org-setting="${esc(name)}">${icon(ic)}<span class="k">${esc(name)}</span><span class="v" title="${esc(value)}">${esc(value)}</span><span class="src-tag">${esc(tag)}</span>${go}</li>`;
|
||||
}).join('')}</ul>`;
|
||||
}
|
||||
@@ -200,7 +203,8 @@
|
||||
const list = Array.isArray(listRead) ? listRead : null;
|
||||
const org = list?.find(o => o.id === id && !o.invalid);
|
||||
if (!org) return `<article class="ws ws-dash" data-org-dashboard data-org-unavailable><div class="ws-vh"><h1 tabindex="-1">Organization unavailable</h1><p class="sub">${list ? 'This organization is not on your list.' : 'The organization list could not be read.'}</p></div></article>`;
|
||||
const reads = await Promise.allSettled([data.business(id), data.settings(id), data.instances(id), data.threads(id), data.projects(id), data.queue(id), data.agents(id), data.claims(id)]);
|
||||
const types = window.MosaicSystemTypes || null;
|
||||
const reads = await Promise.allSettled([data.business(id), data.settings(id), data.instances(id), data.threads(id), data.projects(id), data.queue(id), data.agents(id), data.claims(id), types ? types.load(id) : null]);
|
||||
const value = i => (reads[i].status === 'fulfilled' ? reads[i].value : null);
|
||||
const business = value(0), settings = value(1), instances = instancesOf(value(2));
|
||||
const threads = rowsOf(value(3)), projectRows = rowsOf(value(4)), queue = rowsOf(value(5));
|
||||
@@ -220,7 +224,7 @@
|
||||
const meta = `<p class="ws-meta"><span>Kind not recorded</span><span>id <code>${esc(id)}</code></span><span>Tracker: ${tracker ? esc(titleCase(tracker)) : 'not set'}</span><span>${dot(bcls, bglyph, org.busHealthy ? `bus running, read ${clock()}` : `bus unavailable, read ${clock()}`, true)}</span><span id="${esc(why)}">Restart is a trailed human action; reload arrives with the cross-org update.</span></p>`;
|
||||
|
||||
const projectName = pid => projectRows?.find(p => p.id === pid)?.name || pid;
|
||||
const typechips = '<div class="ws-typechips ws-chips"><span class="ws-label">System types</span><span class="note">No system types yet. They arrive with the system types update.</span></div>';
|
||||
const typechips = types ? types.chips(id) : '<div class="ws-typechips ws-chips"><span class="ws-label">System types</span><span class="note">System types unavailable</span></div>';
|
||||
const docChips = !docRead ? '<span class="note">Docs unavailable</span>' : docRead.failed ? '<span class="note">Docs unavailable</span>' : docRead.rows.length ? docRead.rows.slice(0, 6).map(d => `<a class="ws-doc" href="#/docs/${enc(d.project)}/${enc(d.path)}" data-focus-key="${esc(`doc:${d.project}:${d.path}`)}" title="${esc(`${d.project}/${d.path}`)}">${icon('doc')}${esc(docName(d.path))}<small>${esc(projectName(d.project))}</small></a>`).join('') : '<span class="note">No docs yet</span>';
|
||||
const docrow = `<div class="ws-docrow ws-chips"><span class="ws-label">Docs</span>${docChips}<a class="ws-more" href="#/docs" data-focus-key="docs-all">All docs</a></div>`;
|
||||
|
||||
@@ -258,7 +262,8 @@
|
||||
</section>
|
||||
<section aria-label="Settings">
|
||||
${secH('Settings', 'every setting, with where its value comes from')}
|
||||
${settingsList(org, { business, settings, instances, orgCount: list ? list.length : null })}
|
||||
${settingsList(org, { business, settings, instances, orgCount: list ? list.length : null, types })}
|
||||
${types ? types.section(id, { pane, idPrefix: opts.idPrefix || '' }) : ''}
|
||||
</section>
|
||||
</div>
|
||||
</article>`;
|
||||
|
||||
@@ -18,6 +18,7 @@ import { themesHTTP } from './themes-http.mjs';
|
||||
import { DECISION_ROUTE, decisionHTTP, decisionRefuse, createConfirmations } from './decision-http.mjs';
|
||||
import { REQUEST_ROUTE, requestHTTP, requestRefuse } from './request-http.mjs';
|
||||
import { PROJECT_REMOTE_ROUTE, projectRemoteHTTP } from './project-remote-http.mjs';
|
||||
import { SYSTEM_TYPES_ROUTE, systemTypesHTTP } from './system-types-http.mjs';
|
||||
import { orgAdminHTTP, defaultOrgAdmin } from './org-admin.mjs';
|
||||
import { consoleRestartHTTP } from './console-restart-http.mjs';
|
||||
import { workspaceHTTP } from './workspace-read.mjs';
|
||||
@@ -76,6 +77,9 @@ const files = new Map([
|
||||
['/editor.css', ['editor.css', 'text/css; charset=utf-8']],
|
||||
['/icons.svg', ['icons.svg', 'image/svg+xml']],
|
||||
['/project-remote.css', ['project-remote.css', 'text/css; charset=utf-8']],
|
||||
// Phase P2 (#1873): the owner's System types control (dashboard section and project selection).
|
||||
['/system-types.js', ['system-types.js', 'text/javascript; charset=utf-8']],
|
||||
['/system-types.css', ['system-types.css', 'text/css; charset=utf-8']],
|
||||
...[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']]),
|
||||
]);
|
||||
@@ -507,7 +511,9 @@ const AGENT_ROUTES = {
|
||||
};
|
||||
// F3: the writes the owner makes on a selected organization's bus from Settings (dev org Console).
|
||||
const ORG_OWNER_WRITE = path => path === '/api/settings' || path === '/api/instances' || path === '/api/routes'
|
||||
|| Object.hasOwn(SEAT_ROUTES, path) || Object.hasOwn(AGENT_ROUTES, path) || path.startsWith('/api/proxy/');
|
||||
|| Object.hasOwn(SEAT_ROUTES, path) || Object.hasOwn(AGENT_ROUTES, path) || path.startsWith('/api/proxy/')
|
||||
// Phase P2 (#1873): the System types enable switch and a project's selection; the route keeps its own checks.
|
||||
|| SYSTEM_TYPES_ROUTE.test(path);
|
||||
async function agentSend(req, res, { route, agentWrite, preview, log, note = null }) {
|
||||
let target = null;
|
||||
// The instance or name is quoted and cut short so a crafted value can't forge a log line. A reason is never logged.
|
||||
@@ -773,7 +779,7 @@ async function libraryStatusRead(res, read, 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, libraryStatus = null, libraryRevert = null, chat = null, nameWrite = null, assistantStop = null, instancesWrite = null, routesWrite = null, agentWrite = null, proxyCall = null, processing = null, decisionWrite = null, requestRead = null, requestWrite = null, userOptions = null, federation = null, organizations = null, preselect = null, routes = [], orgAdmin = null, devAllowHosts = [], log = line => console.log(line), fieldsFile = undefined, restart = null, trail = null, boot = randomUUID(), workspaceTimeoutMs = undefined, agentLevels = null } = {}) {
|
||||
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, libraryStatus = null, libraryRevert = null, chat = null, nameWrite = null, assistantStop = null, instancesWrite = null, routesWrite = null, agentWrite = null, proxyCall = null, processing = null, decisionWrite = null, requestRead = null, requestWrite = null, systemTypesWrite = null, userOptions = null, federation = null, organizations = null, preselect = null, routes = [], orgAdmin = null, devAllowHosts = [], log = line => console.log(line), fieldsFile = undefined, restart = null, trail = null, boot = randomUUID(), workspaceTimeoutMs = undefined, agentLevels = null } = {}) {
|
||||
// org-lan-bind: the dev owner on the LAN is an explicit opt-in. `devAllowHosts` names the Host
|
||||
// names (without a port) a remote browser may use; only the dev owner takes them, and only with
|
||||
// them may it bind a non-loopback IPv4 address. SSO, preview and tester listeners stay loopback.
|
||||
@@ -796,6 +802,8 @@ export async function startServer({ host = '127.0.0.1', port = 7330, board = DEF
|
||||
// #797 / #1839 Task 3: the request intake reads need --sso, the writes also --send; never the tester.
|
||||
if ((requestRead !== null || requestWrite !== null) && (tester || sso === null || (requestRead !== null && typeof requestRead !== 'function') || (requestWrite !== null && (send === null || typeof requestWrite !== 'function'))))
|
||||
throw new Error('Console requests need --sso (and --send for the writes) on the main listener');
|
||||
// Phase P2 (#1873): the owner's System types writes need --sso and --send, never the tester.
|
||||
if (systemTypesWrite !== null && (tester || sso === null || send === null || typeof systemTypesWrite !== 'function')) throw new Error('System types writes need --sso and --send on the main listener');
|
||||
const confirmations = createConfirmations();
|
||||
const registered = routeTable();
|
||||
for (const registerRoutes of routes) registerRoutes(registered);
|
||||
@@ -818,7 +826,7 @@ export async function startServer({ host = '127.0.0.1', port = 7330, board = DEF
|
||||
// `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, libraryStatus, libraryRevert, chat, nameWrite, assistantStop, instancesWrite, routesWrite, agentWrite, proxyCall, processing, decisionWrite, requestRead, requestWrite, upstream, files };
|
||||
const owner = { bus, reads, send, settingsWrite, projectFiles, projectFile, projectSave, libraryStatus, libraryRevert, chat, nameWrite, assistantStop, instancesWrite, routesWrite, agentWrite, proxyCall, processing, decisionWrite, requestRead, requestWrite, systemTypesWrite, 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 }))) {
|
||||
@@ -832,7 +840,7 @@ export async function startServer({ host = '127.0.0.1', port = 7330, board = DEF
|
||||
let boundAddress = null, boundPort = null;
|
||||
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, libraryStatus, libraryRevert, chat, nameWrite, assistantStop, instancesWrite, routesWrite, agentWrite, proxyCall, processing, decisionWrite, requestRead, requestWrite, upstream, files } = owner, granted = false, chatGrants = [];
|
||||
let { bus, reads, send, settingsWrite, projectFiles, projectFile, projectSave, libraryStatus, libraryRevert, chat, nameWrite, assistantStop, instancesWrite, routesWrite, agentWrite, proxyCall, processing, decisionWrite, requestRead, requestWrite, systemTypesWrite, upstream, files } = owner, granted = false, chatGrants = [];
|
||||
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
|
||||
@@ -902,7 +910,7 @@ export async function startServer({ host = '127.0.0.1', port = 7330, board = DEF
|
||||
// alone stays open on a loopback Host for the status and release gates (Q-C).
|
||||
if (sso !== null && !named && !(path === '/healthz' && req.method === 'GET')) return json(res, 403, { error: 'public Host required' });
|
||||
// No CORS. JSON content type and same-origin checks keep browser forms out.
|
||||
const decisionPath = DECISION_ROUTE.test(path), requestPath = REQUEST_ROUTE.test(path), remotePath = PROJECT_REMOTE_ROUTE.test(path);
|
||||
const decisionPath = DECISION_ROUTE.test(path), requestPath = REQUEST_ROUTE.test(path), remotePath = PROJECT_REMOTE_ROUTE.test(path), systemTypesPath = SYSTEM_TYPES_ROUTE.test(path);
|
||||
if (req.headers.origin && req.headers.origin !== (named ? (sso ? (localOrigin ?? sso.publicOrigin) : `https://${preview.host}`) : `http://${req.headers.host}`))
|
||||
return decisionPath ? decisionRefuse(res, 'cross-origin') : requestPath ? requestRefuse(res, 'cross-origin') : json(res, 403, { error: 'cross-origin request refused' });
|
||||
let signer = preview, ssoSession = null;
|
||||
@@ -914,7 +922,7 @@ export async function startServer({ host = '127.0.0.1', port = 7330, board = DEF
|
||||
if (!session && path !== '/healthz') {
|
||||
if (decisionPath) return decisionRefuse(res, 'sign-in-required');
|
||||
if (requestPath) return requestRefuse(res, 'sign-in-required');
|
||||
if (remotePath) return json(res, 401, { error: 'sign-in-required' });
|
||||
if (remotePath || systemTypesPath) return json(res, 401, { error: 'sign-in-required' });
|
||||
if (path.startsWith('/api/') || req.method !== 'GET') return json(res, 401, { error: 'sign-in required' });
|
||||
res.writeHead(303, { location: `/auth/login?return=${encodeURIComponent(path + search)}`, 'cache-control': 'no-store' });
|
||||
return res.end();
|
||||
@@ -928,7 +936,7 @@ export async function startServer({ host = '127.0.0.1', port = 7330, board = DEF
|
||||
if (!organizations && !userRoute(path, req.method)) return json(res, 403, { error: 'owner-only', message: 'this part of the Console is the business owner\'s' });
|
||||
if (!organizations && userOptions === null) return json(res, 503, { error: 'not-configured', message: 'the Console has no user transport' });
|
||||
if (!organizations) ({ bus, send, settingsWrite, projectFiles, projectFile, projectSave, chat, nameWrite, assistantStop, instancesWrite, routesWrite, agentWrite, processing } = userOptions(session));
|
||||
reads = null; upstream = null; libraryStatus = null; libraryRevert = null; files = USER_FILES; granted = true; proxyCall = null;
|
||||
reads = null; upstream = null; libraryStatus = null; libraryRevert = null; files = USER_FILES; granted = true; proxyCall = null; systemTypesWrite = null;
|
||||
chatGrants = sso.chatGrants?.(session) ?? [];
|
||||
}
|
||||
}
|
||||
@@ -959,7 +967,7 @@ export async function startServer({ host = '127.0.0.1', port = 7330, board = DEF
|
||||
({ bus = null, reads = null, send = null, settingsWrite = null, projectFiles = null, projectFile = null,
|
||||
projectSave = null, libraryStatus = null, libraryRevert = null, chat = null, nameWrite = null,
|
||||
assistantStop = null, instancesWrite = null, routesWrite = null, agentWrite = null, proxyCall = null,
|
||||
processing = null, decisionWrite = null, requestRead = null, requestWrite = null, upstream = null } = selected);
|
||||
processing = null, decisionWrite = null, requestRead = null, requestWrite = null, systemTypesWrite = null, upstream = null } = selected);
|
||||
}
|
||||
// F3: the owner's settings writes reach the selected organization's bus too (limits, routing,
|
||||
// agents, assistant, instances, model proxy). The routes below keep their own checks.
|
||||
@@ -1016,7 +1024,13 @@ export async function startServer({ host = '127.0.0.1', port = 7330, board = DEF
|
||||
return projectRemoteHTTP(req, res, { path, read: requestRead, write: send ? requestWrite : null,
|
||||
session: ssoSession, live: () => sso.session(req), origin: localOrigin ?? sso.publicOrigin });
|
||||
}
|
||||
if (tester && ['/project-remote.js', '/project-remote.css', '/files.js', '/files.css'].includes(path)) return json(res, 403, { error: 'owner-only' });
|
||||
// Phase P2 (#1873): the owner's System types read and writes on the selected organization.
|
||||
if (systemTypesPath) {
|
||||
if (tester || granted || !named || sso === null) return json(res, 403, { error: 'owner-only' });
|
||||
return systemTypesHTTP(req, res, { path, read: requestRead, write: send ? systemTypesWrite : null,
|
||||
session: ssoSession, live: () => sso.session(req), origin: localOrigin ?? sso.publicOrigin });
|
||||
}
|
||||
if (tester && ['/project-remote.js', '/project-remote.css', '/files.js', '/files.css', '/system-types.js', '/system-types.css'].includes(path)) return json(res, 403, { error: 'owner-only' });
|
||||
// Row 194 (#1692, D4.1): the tester also launches and terminates.
|
||||
if (tester && !['GET', 'HEAD'].includes(req.method) && !['/api/instances', '/api/routes', '/api/name', '/api/agent/launch', '/api/agent/terminate'].includes(path) && !TESTER_CHAT_WRITE.test(path))
|
||||
return json(res, 403, { error: 'read-only', sent: false });
|
||||
|
||||
@@ -0,0 +1,140 @@
|
||||
// Phase P2 (#1873): the owner's System types routes on a selected organization. Owner Console
|
||||
// transports only; no caller-supplied business, actor or subject reaches the bus.
|
||||
//
|
||||
// GET /api/system-types installed modules (label, description) and the enabled set,
|
||||
// plus the plugin revision when writes are on
|
||||
// POST /api/system-types {revision, enabled}: plugins.set {'system-type': enabled},
|
||||
// the organization writer's owner fence, revision and receipt
|
||||
// GET /api/system-types/projects/<id> the same, with the project's selection, active and inactive
|
||||
// POST /api/system-types/projects/<id> {systemTypes}: project.system-types.set
|
||||
//
|
||||
// `read` is the owner's Console call (consoleCall): it reaches only `system-types.console`, which the
|
||||
// broker answers to the business human's console cap. `write` is the owner's human transport limited
|
||||
// to SYSTEM_TYPE_VERBS, present only for the owner's own session under --send.
|
||||
export const SYSTEM_TYPES_ROUTE = /^\/api\/system-types(?:\/projects\/([^/]+))?$/;
|
||||
export const SYSTEM_TYPE_VERBS = Object.freeze(['plugins', 'plugins.set', 'project.system-types.set']);
|
||||
const ID = /^[A-Za-z0-9][A-Za-z0-9._-]{0,159}$/;
|
||||
// A module name, as plugins/src/kinds.mjs NAME.
|
||||
const NAME = /^[a-z][a-z0-9-]{0,63}$/;
|
||||
const REVISION = /^[a-f0-9]{64}$/;
|
||||
const TRANSACTION = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
|
||||
const MAX_TYPES = 64;
|
||||
const one = (req, key) => req.headersDistinct[key]?.length === 1 ? req.headersDistinct[key][0] : null;
|
||||
const codes = {
|
||||
'invalid-request': 400, 'invalid-plugins': 400, 'unknown-plugin': 400, 'system-type-unknown': 400,
|
||||
'project-not-found': 404, 'revision-conflict': 409, 'organization-busy': 409, 'system-type-not-enabled': 409,
|
||||
'human-required': 403, 'organization-system-types-human-only': 403, 'read-only': 403,
|
||||
'not-configured': 503, 'system-type-invalid': 503, 'no-bus-host': 503,
|
||||
};
|
||||
function json(res, status, value) {
|
||||
res.writeHead(status, { 'content-type': 'application/json', 'cache-control': 'no-store' });
|
||||
res.end(JSON.stringify(value));
|
||||
}
|
||||
const names = list => Array.isArray(list) && list.length <= MAX_TYPES && list.every(x => typeof x === 'string' && NAME.test(x)) && new Set(list).size === list.length;
|
||||
const text = value => typeof value === 'string' && value.trim() !== '' && value.length <= 400 && !/[\r\n]/.test(value);
|
||||
function consoleShape(result, project) {
|
||||
if (!result || !Array.isArray(result.installed) || !names(result.enabled)) return null;
|
||||
const installed = [];
|
||||
for (const m of result.installed) {
|
||||
if (!m || !NAME.test(m.id ?? '') || !text(m.label) || !text(m.description)) return null;
|
||||
installed.push({ id: m.id, label: m.label, description: m.description });
|
||||
}
|
||||
if (new Set(installed.map(m => m.id)).size !== installed.length) return null;
|
||||
let selection = null;
|
||||
if (project !== null) {
|
||||
const p = result.project;
|
||||
if (!p || p.project !== project || !names(p.selected) || !names(p.active) || !names(p.inactive)) return null;
|
||||
selection = { project, selected: p.selected, active: p.active, inactive: p.inactive };
|
||||
} else if (result.project !== null) return null;
|
||||
return { installed, enabled: result.enabled, project: selection };
|
||||
}
|
||||
const selectionShape = (r, project) => r && r.project === project && names(r.selected) && names(r.active) && names(r.inactive)
|
||||
? { project, selected: r.selected, active: r.active, inactive: r.inactive } : null;
|
||||
|
||||
async function body(req, keys) {
|
||||
if (one(req, 'content-type')?.split(';')[0].trim().toLowerCase() !== 'application/json') return null;
|
||||
const chunks = []; let size = 0;
|
||||
for await (const chunk of req) { size += chunk.length; if (size > 4096) return 'too-large'; chunks.push(chunk); }
|
||||
let value;
|
||||
try { value = JSON.parse(Buffer.concat(chunks).toString('utf8')); } catch { return null; }
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value) || Object.keys(value).length !== keys.length || !keys.every(k => Object.hasOwn(value, k))) return null;
|
||||
return value;
|
||||
}
|
||||
|
||||
export async function systemTypesHTTP(req, res, { path, read, write, session, live, origin }) {
|
||||
const refuse = (status, error) => json(res, status, { error });
|
||||
const fail = error => { const code = codes[error?.code] ? error.code : 'outcome-unknown'; return refuse(codes[code] ?? 503, code); };
|
||||
if (!['GET', 'POST'].includes(req.method)) return refuse(405, 'method-not-allowed');
|
||||
if (!session) return refuse(401, 'sign-in-required');
|
||||
if (!read) return refuse(503, 'not-configured');
|
||||
if (req.method === 'POST' && !write) return refuse(403, 'read-only');
|
||||
const site = one(req, 'sec-fetch-site'), from = one(req, 'origin');
|
||||
if (req.method === 'POST' ? from !== origin || site !== 'same-origin'
|
||||
: (req.headersDistinct.origin && from !== origin) || (req.headersDistinct['sec-fetch-site'] && !['same-origin', 'none'].includes(site)))
|
||||
return refuse(403, 'cross-origin');
|
||||
let project = null;
|
||||
const match = SYSTEM_TYPES_ROUTE.exec(path);
|
||||
if (match?.[1] !== undefined) {
|
||||
try { project = decodeURIComponent(match[1]); } catch { return refuse(404, 'project-not-found'); }
|
||||
if (!ID.test(project)) return refuse(404, 'project-not-found');
|
||||
}
|
||||
let args = null;
|
||||
if (req.method === 'POST') {
|
||||
const value = await body(req, project === null ? ['revision', 'enabled'] : ['systemTypes']);
|
||||
if (value === 'too-large') return refuse(413, 'invalid-request');
|
||||
if (!value) return refuse(400, 'invalid-request');
|
||||
if (project === null ? typeof value.revision !== 'string' || !REVISION.test(value.revision) || !names(value.enabled) : !names(value.systemTypes))
|
||||
return refuse(400, 'invalid-request');
|
||||
args = value;
|
||||
}
|
||||
if (live() !== session) return refuse(401, 'sign-in-required');
|
||||
const subject = { provider: session.provider, iss: session.iss, sub: session.sub };
|
||||
// The read comes first on every request: a write names only modules this organization has installed.
|
||||
let current;
|
||||
try { current = consoleShape(await read('system-types.console', project === null ? {} : { project }, { subject }), project); }
|
||||
catch (error) { return fail(error); }
|
||||
if (!current) return refuse(503, 'outcome-unknown');
|
||||
const installed = new Set(current.installed.map(m => m.id));
|
||||
|
||||
if (req.method === 'GET') {
|
||||
let revision = null;
|
||||
if (write && project === null) {
|
||||
// The enabled set and its revision come from one plugins read. The page sends back a whole
|
||||
// replacement list, so a list older than its revision would undo a change made in between.
|
||||
let plugins;
|
||||
try { plugins = await write('plugins', {}); } catch (error) { return fail(error); }
|
||||
revision = plugins?.revision ?? null;
|
||||
const enabled = plugins?.plugins?.['system-type'] ?? [];
|
||||
if (!REVISION.test(revision ?? '') || !names(enabled)) return refuse(503, 'outcome-unknown');
|
||||
current.enabled = enabled;
|
||||
}
|
||||
if (live() !== session) return refuse(401, 'sign-in-required');
|
||||
return json(res, 200, { ...current, writable: Boolean(write), ...(project === null && write ? { revision } : {}) });
|
||||
}
|
||||
|
||||
// The session is checked again after the read: a sign-out while it ran dispatches no write.
|
||||
if (live() !== session) return refuse(401, 'sign-in-required');
|
||||
|
||||
if (project === null) {
|
||||
if (args.enabled.some(id => !installed.has(id))) return refuse(400, 'system-type-unknown');
|
||||
let result;
|
||||
try { result = await write('plugins.set', { revision: args.revision, plugins: { 'system-type': args.enabled } }); }
|
||||
catch (error) { return fail(error); }
|
||||
// A write admitted at spawn may already have committed: its receipt is returned even if the
|
||||
// session ended meanwhile, never reported as not done.
|
||||
const enabled = result?.plugins?.['system-type'] ?? [];
|
||||
if (!result || !REVISION.test(result.revision ?? '') || !TRANSACTION.test(result.transaction ?? '') || !names(enabled)
|
||||
|| JSON.stringify(enabled) !== JSON.stringify(args.enabled))
|
||||
return refuse(503, 'outcome-unknown');
|
||||
return json(res, 200, { enabled, revision: result.revision, transaction: result.transaction });
|
||||
}
|
||||
|
||||
// The broker refuses a type the organization has not enabled (system-type-not-enabled); a type that
|
||||
// is not installed is refused here first.
|
||||
if (args.systemTypes.some(id => !installed.has(id))) return refuse(400, 'system-type-unknown');
|
||||
let result;
|
||||
try { result = selectionShape(await write('project.system-types.set', { project, systemTypes: args.systemTypes }), project); }
|
||||
catch (error) { return fail(error); }
|
||||
if (!result || JSON.stringify(result.selected) !== JSON.stringify(args.systemTypes)) return refuse(503, 'outcome-unknown');
|
||||
return json(res, 200, result);
|
||||
}
|
||||
@@ -88,6 +88,6 @@ test('a refused root file hides the root level only, and says why without a host
|
||||
writeFileSync(join(dir, 'agents.json'), JSON.stringify({ agentsVersion: 1, agents: { x: { definition: 'reviewer', vars: { thinking: 'sk-secret-marker-7731' } } } }));
|
||||
const badVar = await call({ owner: true, session: {}, organizations: { members }, levels: { ...levels, loadRoot: () => loadRootAgents(dir, { rolesDir }) } });
|
||||
assert.equal(badVar.body.root.state, 'invalid');
|
||||
assert.match(badVar.body.root.reason, /variable thinking must be one of/);
|
||||
assert.match(badVar.body.root.reason, /variable thinking has a value it can't take/);
|
||||
assert.doesNotMatch(JSON.stringify(badVar.body), /secret-marker/);
|
||||
});
|
||||
@@ -18,8 +18,8 @@ test('served Console browser: real board fixtures, keyboard, drafts, receipts, t
|
||||
assert.equal(await b.evaluate('document.querySelector("#session-count").textContent'), '3 of 4');
|
||||
await b.evaluate('document.querySelector("#hide-offline").click()');
|
||||
assert.equal(await b.evaluate('document.querySelectorAll("table.sessions tbody tr").length'), 4);
|
||||
// Rows 129, 154, 191, 194 and 294: chat.js, editor.js, processing.js, agents.js and federation.js are the scripts added, and #1839 adds requests.js (the intake panel under an owner's chat message), all same origin; the list is the pin, so another script is a visible change. #797 adds project-archive.js immediately after chat.js. F2 adds onboarding.js after org-create.js, and F3 adds console-restart.js after it. U2 (#1873) adds workspace-data.js and workspace.js before console-restart.js, and U4b adds tasks-board.js after workspace.js. U5 (#1873) adds panes.js (Side by side) just before bus.js, which mounts it.
|
||||
assert.deepEqual(await b.evaluate('[...document.querySelectorAll("script")].map(s => s.getAttribute("src"))'), ['/editor.js', '/processing.js', '/org-create.js', '/onboarding.js', '/workspace-data.js', '/workspace.js', '/tasks-board.js', '/console-restart.js', '/display.js', '/inbox.js', '/files.js', '/project-remote.js', '/federation.js', '/brand.js', '/app.js', '/markdown-preview.js', '/projects.js', '/chat.js', '/project-archive.js', '/agents.js', '/panes.js', '/bus.js', '/requests.js']);
|
||||
// Rows 129, 154, 191, 194 and 294: chat.js, editor.js, processing.js, agents.js and federation.js are the scripts added, and #1839 adds requests.js (the intake panel under an owner's chat message), all same origin; the list is the pin, so another script is a visible change. #797 adds project-archive.js immediately after chat.js. F2 adds onboarding.js after org-create.js, and F3 adds console-restart.js after it. U2 (#1873) adds workspace-data.js and workspace.js before console-restart.js, and U4b adds tasks-board.js after workspace.js. U5 (#1873) adds panes.js (Side by side) just before bus.js, which mounts it. P2 (#1873) adds system-types.js after project-remote.js.
|
||||
assert.deepEqual(await b.evaluate('[...document.querySelectorAll("script")].map(s => s.getAttribute("src"))'), ['/editor.js', '/processing.js', '/org-create.js', '/onboarding.js', '/workspace-data.js', '/workspace.js', '/tasks-board.js', '/console-restart.js', '/display.js', '/inbox.js', '/files.js', '/project-remote.js', '/system-types.js', '/federation.js', '/brand.js', '/app.js', '/markdown-preview.js', '/projects.js', '/chat.js', '/project-archive.js', '/agents.js', '/panes.js', '/bus.js', '/requests.js']);
|
||||
assert.equal(await b.evaluate(`document.querySelectorAll('script[src="/processing.js"]').length`), 1);
|
||||
assert.equal(await b.evaluate(`document.querySelectorAll('script[src="/chat.js"]').length`), 1);
|
||||
assert.equal(await b.evaluate('!!window.injected'), false);
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
import { createHash, randomUUID } from 'node:crypto';
|
||||
import { installedSystemTypes } from '../../../plugins/src/system-types.mjs';
|
||||
|
||||
// Phase P2 (#1873): a per-organization stand-in for the two transports the System types routes use,
|
||||
// shaped as the broker answers them. `requestRead` answers system-types.console with the installed
|
||||
// modules (the plug-in data itself, read from packages/plugins), the enabled set and a project's
|
||||
// selection. `systemTypesWrite` answers plugins, plugins.set (revision checked, a transaction
|
||||
// returned) and project.system-types.set (enabled types only). `owner: false` makes plugins.set
|
||||
// refuse as the organization writer's owner fence does for anyone but the human.
|
||||
export async function systemTypesFake({ enabled = [], projects = {}, owner = true } = {}) {
|
||||
const installed = await installedSystemTypes();
|
||||
const state = { enabled: [...enabled], projects: structuredClone(projects), n: 0, writes: [] };
|
||||
const revision = () => createHash('sha256').update(`system-types-${state.n}`).digest('hex');
|
||||
const fail = code => { throw Object.assign(new Error(code), { code }); };
|
||||
const selection = project => {
|
||||
const selected = state.projects[project];
|
||||
return { project, selected: [...selected], active: selected.filter(x => state.enabled.includes(x)), inactive: selected.filter(x => !state.enabled.includes(x)) };
|
||||
};
|
||||
const requestRead = async (verb, args) => {
|
||||
if (verb !== 'system-types.console') fail('invalid-request');
|
||||
if (args.project !== undefined && !Object.hasOwn(state.projects, args.project)) fail('project-not-found');
|
||||
return { installed: structuredClone(installed), enabled: [...state.enabled], project: args.project === undefined ? null : selection(args.project) };
|
||||
};
|
||||
const systemTypesWrite = async (verb, args) => {
|
||||
state.writes.push(verb);
|
||||
if (verb === 'plugins') return { business: 'fake', revision: revision(), plugins: { 'system-type': [...state.enabled] } };
|
||||
if (verb === 'plugins.set') {
|
||||
if (args.revision !== revision()) fail('revision-conflict');
|
||||
if (!owner) fail('organization-system-types-human-only');
|
||||
state.enabled = [...args.plugins['system-type']]; state.n++;
|
||||
return { business: 'fake', revision: revision(), plugins: { 'system-type': [...state.enabled] }, transaction: randomUUID() };
|
||||
}
|
||||
if (verb === 'project.system-types.set') {
|
||||
if (!Object.hasOwn(state.projects, args.project)) fail('project-not-found');
|
||||
if (args.systemTypes.some(x => !state.enabled.includes(x))) fail('system-type-not-enabled');
|
||||
state.projects[args.project] = [...args.systemTypes];
|
||||
return selection(args.project);
|
||||
}
|
||||
fail('invalid-request');
|
||||
};
|
||||
return { requestRead, systemTypesWrite, state, installed };
|
||||
}
|
||||
@@ -0,0 +1,244 @@
|
||||
// Phase P2 (#1873): System types on the organization dashboard (sample 03) and on a project's information
|
||||
// page. The Console, its per-organization routes and the pages are real; only the two bus transports are
|
||||
// the fake in helpers/system-types-fake.mjs, which reads the installed modules from packages/plugins. The
|
||||
// server-side admission checks and the real organization writer are in system-types-http.test.mjs.
|
||||
// MOSAIC_UX_SHOTS=<dir> also writes light and dark shots at 1440x900 and 390x844.
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { mkdirSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { startServer } from '../src/serve.mjs';
|
||||
import { createDevOwner } from '../src/dev-owner.mjs';
|
||||
import { browser } from './browser.mjs';
|
||||
import { close } from './fixture.mjs';
|
||||
import { systemTypesFake } from './helpers/system-types-fake.mjs';
|
||||
|
||||
const P = '10000000-0000-4000-8000-0000000000b1';
|
||||
const RECEIPT = /Receipt ([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})\./;
|
||||
const waitFor = b => expression => b.evaluate(`(async()=>{for(let i=0;i<300;i++){if(${expression})return true;await new Promise(r=>setTimeout(r,30))}throw Error(${JSON.stringify(expression)}+' | '+(document.querySelector('main')?.innerText||'').slice(0,1500))})()`);
|
||||
|
||||
// One running organization with one project. `send` is the owner's signed-in send mode; without it the
|
||||
// Console only reads and the route gets no writer.
|
||||
function organizations(st, { send = true } = {}) {
|
||||
const business = { business: { id: 'acme', human: 'jason', arbiters: [], projects: [P], vars: {}, instances: [], appearance: { theme: null } } };
|
||||
return {
|
||||
list: async () => [{ id: 'acme', label: 'Acme', theme: null, busHealthy: true }],
|
||||
resolve: async id => id !== 'acme' ? null : ({
|
||||
busHealthy: true, requestRead: st.requestRead, systemTypesWrite: send ? st.systemTypesWrite : null, ...(send ? { send: async () => ({}) } : {}),
|
||||
reads: { business: async () => business, settings: async () => ({}), queue: async () => ({ rows: [] }), busStatus: async () => ({}) },
|
||||
bus: { instances: async () => ({ business: 'acme', revision: 'a'.repeat(64), launch: true, noHire: [], retired: [], definitions: {}, instances: {} }),
|
||||
messages: async () => ({ messages: [], unread: 0 }), agents: async () => [], 'thread.claims': async () => [], inbox: async () => [], tasks: async () => [] },
|
||||
chat: { projects: async () => [{ id: P, name: 'Work', repo: null }], threads: async () => [] },
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
async function page(t, st, { send = true, width = 1440, height = 900 } = {}) {
|
||||
const server = await startServer({ port: 0, sso: createDevOwner(), ...(send ? { send: async () => ({}) } : {}), organizations: organizations(st, { send }), log: () => {} });
|
||||
t.after(() => close(server));
|
||||
const b = await browser({ machine: false }); t.after(() => b.close());
|
||||
const errors = [];
|
||||
b.on('Runtime.exceptionThrown', p => errors.push(p.exceptionDetails?.exception?.description ?? p.exceptionDetails?.text ?? 'exception'));
|
||||
await b.viewport(width, height);
|
||||
const base = `http://127.0.0.1:${server.address().port}/`;
|
||||
return { b, base, wait: waitFor(b), errors };
|
||||
}
|
||||
const dashboard = async ({ b, base, wait }) => {
|
||||
await b.navigate(`${base}#/orgs/acme/organization`);
|
||||
await wait('document.querySelector("[data-org-dashboard] [data-system-types]") && !/Reading/.test(document.querySelector("[data-system-types]").textContent)');
|
||||
};
|
||||
const settings = async ({ b, base, wait }) => {
|
||||
await b.navigate(`${base}#/orgs/acme/projects/${P}/settings`);
|
||||
await wait('document.querySelector("[data-project-system-types]") && !/Reading system types/.test(document.querySelector("[data-project-system-types]").textContent)');
|
||||
};
|
||||
const text = (b, selector) => b.evaluate(`(document.querySelector(${JSON.stringify(selector)})?.innerText ?? '').replace(/\\s+/g, ' ').trim()`);
|
||||
const chips = b => b.evaluate(`[...document.querySelectorAll('[data-system-type-chips] .ws-chip')].map(c => c.textContent)`);
|
||||
const click = (b, selector) => b.evaluate(`(document.querySelector(${JSON.stringify(selector)}).click(), true)`);
|
||||
|
||||
test('the dashboard lists the installed modules from the plug-in data and the owner switches one with a receipt', { timeout: 60000 }, async t => {
|
||||
const st = await systemTypesFake({ enabled: ['coding'], projects: { [P]: [] } });
|
||||
const p = await page(t, st); const { b, wait } = p;
|
||||
await dashboard(p);
|
||||
// Every row is a module the plug-ins declare, in their order, with their label and description.
|
||||
const rows = await b.evaluate(`[...document.querySelectorAll('[data-system-types] li')].map(li => [li.dataset.systemType, li.querySelector('.t b').textContent, li.querySelector('.t .d').textContent, li.querySelector('.ws-dot').textContent.replace(/[●–]/, '')])`);
|
||||
assert.deepEqual(rows, st.installed.map(m => [m.id, m.label, m.description, m.id === 'coding' ? 'enabled' : 'off']));
|
||||
assert.ok(st.installed.length >= 2, 'the tree installs more than one system type module');
|
||||
assert.deepEqual(await chips(b), ['Coding']);
|
||||
assert.match(await text(b, '[data-system-type-chips]'), /Each project picks from these; none is required\.$/);
|
||||
assert.match(await text(b, '[data-org-setting="System types"]'), new RegExp(`1 of ${st.installed.length} installed enabled`));
|
||||
// The Settings row's Open moves focus to the section heading.
|
||||
await click(b, '[data-org-setting="System types"] [data-system-types-open]');
|
||||
assert.equal(await b.evaluate('document.activeElement?.dataset.systemTypesHeading'), '');
|
||||
|
||||
// Enable Marketing from the keyboard; the receipt is the organization writer's transaction.
|
||||
await b.evaluate(`document.querySelector('[data-system-type-toggle="marketing"]').focus(), true`);
|
||||
assert.equal(await b.evaluate(`document.activeElement.getAttribute('aria-label')`), 'Enable Marketing');
|
||||
await b.key('Enter');
|
||||
await wait(`/Receipt/.test(document.querySelector('[data-system-types-status]')?.textContent)`);
|
||||
assert.match(await text(b, '[data-system-types-status]'), new RegExp(`^Marketing enabled\\. ${RECEIPT.source}$`));
|
||||
assert.deepEqual(st.state.enabled, ['coding', 'marketing']);
|
||||
assert.deepEqual(await chips(b), ['Coding', 'Marketing']);
|
||||
assert.match(await text(b, '[data-org-setting="System types"]'), new RegExp(`2 of ${st.installed.length} installed enabled`));
|
||||
// Focus stays on the switch it pressed, which now says Disable.
|
||||
assert.deepEqual(await b.evaluate(`[document.activeElement.dataset.focusKey, document.activeElement.getAttribute('aria-label')]`), ['st:marketing', 'Disable Marketing']);
|
||||
|
||||
// Disable Coding; another write changed the organization file meanwhile, so the first try is refused.
|
||||
st.state.n++;
|
||||
await click(b, '[data-system-type-toggle="coding"]');
|
||||
await wait(`/changed after this page read it/.test(document.querySelector('[data-system-types-status]')?.textContent)`);
|
||||
assert.deepEqual(st.state.enabled, ['coding', 'marketing']);
|
||||
await click(b, '[data-system-type-toggle="coding"]');
|
||||
await wait(`/Coding disabled/.test(document.querySelector('[data-system-types-status]')?.textContent)`);
|
||||
assert.match(await text(b, '[data-system-types-status]'), RECEIPT);
|
||||
assert.deepEqual(st.state.enabled, ['marketing']);
|
||||
assert.equal(await b.evaluate(`document.querySelector('[data-system-type="coding"] .ws-dot').textContent.replace(/[●–]/, '')`), 'off');
|
||||
assert.deepEqual(p.errors, []);
|
||||
});
|
||||
|
||||
test('without the owner proof the switch is refused, and a read-only Console offers none', { timeout: 60000 }, async t => {
|
||||
// The writer's owner fence refuses: the page says so and nothing changes.
|
||||
const fenced = await systemTypesFake({ enabled: ['coding'], projects: { [P]: [] }, owner: false });
|
||||
const p = await page(t, fenced); const { b, wait } = p;
|
||||
await dashboard(p);
|
||||
await click(b, '[data-system-type-toggle="marketing"]');
|
||||
await wait(`/owner can change/.test(document.querySelector('[data-system-types-status]')?.textContent)`);
|
||||
assert.equal(await text(b, '[data-system-types-status]'), "Only the organization's owner can change system types.");
|
||||
assert.deepEqual(fenced.state.enabled, ['coding']);
|
||||
assert.doesNotMatch(await text(b, '[data-system-types]'), /Receipt/);
|
||||
|
||||
// A Console without send mode reads the list and shows no switch; no write reaches the transport.
|
||||
const readOnly = await systemTypesFake({ enabled: ['coding'], projects: { [P]: ['coding'] } });
|
||||
const q = await page(t, readOnly, { send: false });
|
||||
await dashboard(q);
|
||||
assert.equal(await q.b.evaluate(`document.querySelectorAll('[data-system-types] li').length`), readOnly.installed.length);
|
||||
assert.equal(await q.b.evaluate(`document.querySelectorAll('[data-system-type-toggle]').length`), 0);
|
||||
assert.match(await text(q.b, '[data-system-types]'), /This Console only reads\. Changing system types needs signed-in send mode\./);
|
||||
// The project page shows the selection read only too.
|
||||
await settings(q);
|
||||
assert.equal(await q.b.evaluate(`[...document.querySelectorAll('[data-project-system-types] input')].every(i => i.disabled)`), true);
|
||||
assert.equal(await q.b.evaluate(`document.querySelector('[data-project-system-types] button[type=submit]')`), null);
|
||||
assert.deepEqual(readOnly.state.writes, []);
|
||||
});
|
||||
|
||||
test('a project selects only enabled modules and a disabled selection shows as inactive', { timeout: 60000 }, async t => {
|
||||
// Montage was selected, then disabled in the organization.
|
||||
const st = await systemTypesFake({ enabled: ['coding', 'marketing'], projects: { [P]: ['montage'] } });
|
||||
const p = await page(t, st); const { b, wait } = p;
|
||||
await settings(p);
|
||||
const label = id => st.installed.find(m => m.id === id).label;
|
||||
assert.deepEqual(await b.evaluate(`[...document.querySelectorAll('[data-project-system-types] input[name=system-type]')].map(i => [i.value, i.checked, i.closest('label').querySelector('strong').textContent])`),
|
||||
[['coding', false, label('coding')], ['marketing', false, label('marketing')]]);
|
||||
assert.deepEqual(await b.evaluate(`[...document.querySelectorAll('[data-inactive-type]')].map(li => [li.dataset.inactiveType, li.innerText.replace(/\\s+/g, ' ').trim()])`), [['montage', `${label('montage')} inactive`]]);
|
||||
assert.match(await text(b, '[data-project-system-types-inactive]'), /Saving keeps only enabled types/);
|
||||
|
||||
// Pick Marketing and save: the inactive one goes, the saved selection is the bus's answer.
|
||||
await b.evaluate(`document.querySelector('[data-project-system-types] input[value=marketing]').click(), true`);
|
||||
await click(b, '[data-project-system-types] button[type=submit]');
|
||||
await wait(`/Saved/.test(document.querySelector('[data-project-system-types-status]')?.textContent)`);
|
||||
assert.equal(await text(b, '[data-project-system-types-status]'), `Saved. Selected: ${label('marketing')}.`);
|
||||
assert.equal(await b.evaluate(`document.activeElement?.hasAttribute('data-project-system-types-status')`), true);
|
||||
assert.deepEqual(st.state.projects[P], ['marketing']);
|
||||
assert.equal(await b.evaluate(`document.querySelector('[data-inactive-type]')`), null);
|
||||
|
||||
// The organization disables Coding after the page read it; saving it is refused and the list is read again.
|
||||
await b.evaluate(`document.querySelector('[data-project-system-types] input[value=coding]').click(), true`);
|
||||
st.state.enabled = ['marketing'];
|
||||
await click(b, '[data-project-system-types] button[type=submit]');
|
||||
await wait(`/not enabled in the organization/.test(document.querySelector('[data-project-system-types-status]')?.textContent)`);
|
||||
assert.deepEqual(st.state.projects[P], ['marketing']);
|
||||
assert.deepEqual(await b.evaluate(`[...document.querySelectorAll('[data-project-system-types] input[name=system-type]')].map(i => i.value)`), ['marketing']);
|
||||
|
||||
// The owner disables Marketing on the dashboard; back on the project it stays selected, shown inactive.
|
||||
await dashboard(p);
|
||||
await click(b, '[data-system-type-toggle="marketing"]');
|
||||
await wait(`/Marketing disabled/.test(document.querySelector('[data-system-types-status]')?.textContent)`);
|
||||
assert.deepEqual(st.state.enabled, []);
|
||||
await settings(p);
|
||||
assert.match(await text(b, '[data-project-system-types]'), /The organization has no system types enabled\./);
|
||||
assert.deepEqual(await b.evaluate(`[...document.querySelectorAll('[data-inactive-type]')].map(li => li.dataset.inactiveType)`), ['marketing']);
|
||||
assert.equal(await b.evaluate(`document.querySelectorAll('[data-project-system-types] input').length`), 0);
|
||||
// With nothing enabled the owner can still clear the inactive selection, without enabling a type first.
|
||||
assert.equal(await text(b, '[data-project-system-types] button[type=submit]'), 'Clear the inactive types');
|
||||
await click(b, '[data-project-system-types] button[type=submit]');
|
||||
await wait(`/Saved/.test(document.querySelector('[data-project-system-types-status]')?.textContent)`);
|
||||
assert.equal(await text(b, '[data-project-system-types-status]'), 'Saved. No system types selected.');
|
||||
assert.deepEqual(st.state.projects[P], []);
|
||||
assert.deepEqual(st.state.enabled, []);
|
||||
assert.equal(await b.evaluate(`document.querySelector('[data-inactive-type]')`), null);
|
||||
assert.equal(await b.evaluate(`document.querySelector('[data-project-system-types] button[type=submit]')`), null);
|
||||
assert.deepEqual(p.errors, []);
|
||||
|
||||
// A read-only Console shows the same inactive selection and offers no clear button.
|
||||
const readOnly = await systemTypesFake({ enabled: [], projects: { [P]: ['coding'] } });
|
||||
const q = await page(t, readOnly, { send: false });
|
||||
await settings(q);
|
||||
assert.deepEqual(await q.b.evaluate(`[...document.querySelectorAll('[data-inactive-type]')].map(li => li.dataset.inactiveType)`), ['coding']);
|
||||
assert.equal(await q.b.evaluate(`document.querySelector('[data-project-system-types] button[type=submit]')`), null);
|
||||
assert.deepEqual(readOnly.state.writes, []);
|
||||
});
|
||||
|
||||
test('the dashboard section and the project page fit a phone screen in light and dark', { timeout: 90000 }, async t => {
|
||||
const st = await systemTypesFake({ enabled: ['coding', 'marketing'], projects: { [P]: ['coding', 'montage'] } });
|
||||
const shots = process.env.MOSAIC_UX_SHOTS;
|
||||
if (shots) mkdirSync(shots, { recursive: true });
|
||||
for (const [width, height] of [[1440, 900], [390, 844]]) {
|
||||
const p = await page(t, st, { width, height }); const { b } = p;
|
||||
for (const mode of ['light', 'dark']) {
|
||||
await b.call('Emulation.setEmulatedMedia', { features: [{ name: 'prefers-color-scheme', value: mode }] });
|
||||
await dashboard(p);
|
||||
// The default appearance is Harbor in the dark whatever the OS says; the header switch picks the mode.
|
||||
if (await b.evaluate(`document.getElementById('theme-switch').getAttribute('aria-checked')`) !== String(mode === 'dark')) {
|
||||
await b.evaluate(`document.getElementById('theme-switch').click(), true`);
|
||||
await p.wait(`document.getElementById('theme-switch').getAttribute('aria-checked') === '${mode === 'dark'}'`);
|
||||
}
|
||||
await b.evaluate(`document.querySelector('[data-system-types-heading]').scrollIntoView({ block: 'start' }), true`);
|
||||
assert.equal(await b.evaluate('document.documentElement.scrollWidth <= document.documentElement.clientWidth'), true, `dashboard ${width} ${mode}`);
|
||||
assert.equal(await b.evaluate(`[...document.querySelectorAll('[data-system-types] li')].every(li => li.scrollWidth <= li.clientWidth)`), true, `dashboard rows ${width} ${mode}`);
|
||||
if (shots) await b.screenshot(join(shots, `p2-dashboard-types-${width}-${mode}.png`));
|
||||
await settings(p);
|
||||
await b.evaluate(`document.querySelector('[data-project-system-types]').scrollIntoView({ block: 'start' }), true`);
|
||||
// At 1440 a project page taller than the window already overflows by its header's primary action
|
||||
// (projects.css, before P2), so the wide check is the section's own box; the phone check is the page.
|
||||
if (width === 390) assert.equal(await b.evaluate('document.documentElement.scrollWidth <= document.documentElement.clientWidth'), true, `project ${width} ${mode}`);
|
||||
assert.equal(await b.evaluate(`(s => s.scrollWidth <= s.clientWidth && s.getBoundingClientRect().right <= document.documentElement.clientWidth)(document.querySelector('[data-project-system-types]'))`), true, `project section ${width} ${mode}`);
|
||||
assert.equal(await b.evaluate(`[...document.querySelectorAll('[data-project-system-types] .project-types-option')].every(o => o.getBoundingClientRect().height >= 44)`), true, `targets ${width} ${mode}`);
|
||||
if (shots) await b.screenshot(join(shots, `p2-project-types-${width}-${mode}.png`));
|
||||
}
|
||||
assert.deepEqual(p.errors, []);
|
||||
}
|
||||
});
|
||||
|
||||
// U5's Side by side draws an organization dashboard into a pane. Each pane reads its own organization's
|
||||
// system types; a pane only shows them (the switch stays in the organization's full view, as the bus
|
||||
// restart does), and its Settings row opens the section in the same pane.
|
||||
test('a side-by-side pane shows its own organization\'s system types and offers no switch', { timeout: 60000 }, async t => {
|
||||
const acme = await systemTypesFake({ enabled: ['coding'], projects: { [P]: [] } });
|
||||
const beta = await systemTypesFake({ enabled: ['marketing'], projects: {} });
|
||||
const one = organizations(acme);
|
||||
const server = await startServer({ port: 0, sso: createDevOwner(), send: async () => ({}), log: () => {}, organizations: {
|
||||
list: async () => [{ id: 'acme', label: 'Acme', theme: null, busHealthy: true }, { id: 'beta', label: 'Beta', theme: null, busHealthy: true }],
|
||||
resolve: async id => id === 'acme' ? one.resolve('acme') : id === 'beta' ? { ...(await one.resolve('acme')), requestRead: beta.requestRead, systemTypesWrite: beta.systemTypesWrite,
|
||||
chat: { projects: async () => [], threads: async () => [] } } : null,
|
||||
} });
|
||||
t.after(() => close(server));
|
||||
const b = await browser({ machine: false }); t.after(() => b.close());
|
||||
const errors = [];
|
||||
b.on('Runtime.exceptionThrown', p => errors.push(p.exceptionDetails?.exception?.description ?? p.exceptionDetails?.text ?? 'exception'));
|
||||
await b.viewport(1440, 900);
|
||||
const wait = waitFor(b);
|
||||
await b.navigate(`http://127.0.0.1:${server.address().port}/#/orgs/acme/side-by-side?a=acme/organization&b=beta/organization`);
|
||||
const ready = key => `document.querySelector('#pane-${key} [data-system-types]') && !/Reading/.test(document.querySelector('#pane-${key} [data-system-types]').textContent)`;
|
||||
await wait(`${ready('a')} && ${ready('b')}`);
|
||||
const paneChips = key => b.evaluate(`[...document.querySelectorAll('#pane-${key} [data-system-type-chips] .ws-chip')].map(c => c.textContent)`);
|
||||
assert.deepEqual(await paneChips('a'), ['Coding']);
|
||||
assert.deepEqual(await paneChips('b'), ['Marketing']);
|
||||
assert.equal(await b.evaluate(`document.querySelectorAll('.panes [data-system-type-toggle]').length`), 0);
|
||||
for (const key of ['a', 'b'])
|
||||
assert.match(await text(b, `#pane-${key} [data-system-types]`), /Change system types from this organization's full view\./);
|
||||
const ids = await b.evaluate(`[...document.querySelectorAll('[data-system-types-heading]')].map(h => h.id)`);
|
||||
assert.equal(ids.length, 2); assert.equal(new Set(ids).size, 2);
|
||||
await click(b, '#pane-b [data-org-setting="System types"] [data-system-types-open]');
|
||||
assert.equal(await b.evaluate(`document.activeElement === document.querySelector('#pane-b [data-system-types-heading]')`), true);
|
||||
assert.deepEqual([acme.state.writes.includes('plugins.set'), beta.state.writes.includes('plugins.set')], [false, false]);
|
||||
assert.deepEqual(errors, []);
|
||||
});
|
||||
@@ -0,0 +1,235 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { mkdtempSync, mkdirSync, readFileSync, rmSync } from 'node:fs';
|
||||
import { request } from 'node:http';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { businessDoc, rolesCopy, systemFor, writeJson } from '../../organization/tests/helpers.mjs';
|
||||
import { loadBusiness } from '../../organization/src/organization-config.mjs';
|
||||
import { resolveInstance } from '../../organization/src/resolve.mjs';
|
||||
import { busOrganization } from '../../bus/src/organization-context.mjs';
|
||||
import { startBroker } from '../../bus/src/runtime.mjs';
|
||||
import { serve } from '../../bus/src/server.mjs';
|
||||
import { Client } from '../../bus/src/client.mjs';
|
||||
import { installedSystemTypes } from '../../plugins/src/system-types.mjs';
|
||||
import { startServer } from '../src/serve.mjs';
|
||||
import { close } from './fixture.mjs';
|
||||
|
||||
// Phase P2 (#1873): the owner's System types routes on a selected organization, end to end. An
|
||||
// ephemeral organization with the real runtime writer (plugins.set, the system types owner fence,
|
||||
// changes.jsonl) and the broker's console socket for the read; the SSO session is a fixture
|
||||
// authority. `writer` picks the cap the write transport binds: the owner's human cap, or the tester.
|
||||
async function fixture(t, { writer = 'owner', send = true, afterRead = null, beforeWrite = null } = {}) {
|
||||
// Cleanups run last-made first, registered as soon as each resource exists.
|
||||
const undo = []; t.after(async () => { for (const step of undo.reverse()) await step(); });
|
||||
const root = mkdtempSync(join(tmpdir(), 'st-'));
|
||||
const rolesDir = rolesCopy(root), system = systemFor(root);
|
||||
const doc = businessDoc(root, 'demo');
|
||||
doc.plugins = { ...(doc.plugins ?? {}), 'system-type': ['coding'], harness: ['pi'] };
|
||||
doc.vars = { ...doc.vars, 'bus.tester': true };
|
||||
const file = writeJson(join(root, 'businesses/demo.json'), doc);
|
||||
mkdirSync(join(root, 'data'), { mode: 0o700 });
|
||||
const business = loadBusiness('demo', { dir: root, rolesDir });
|
||||
const resolved = Object.fromEntries(Object.keys(business.roles).map(instance => [instance, resolveInstance({ system, business, instance })]));
|
||||
undo.push(() => rmSync(root, { recursive: true, force: true }));
|
||||
const runtime = await startBroker({ dataRoot: join(root, 'data'), businesses: { demo: busOrganization(business, resolved) }, organization: { dir: root, rolesDir, system } });
|
||||
undo.push(() => runtime.close());
|
||||
const path = join(root, 'b.sock');
|
||||
const socketServer = await serve({ broker: runtime.broker, path, peerReader: { read: async () => ({}), close: () => {} },
|
||||
authenticateConsole: proof => runtime.broker.bindConsole({ business: 'demo', human: 'jason', subject: proof.subject }) });
|
||||
undo.push(() => socketServer.close());
|
||||
const owner = runtime.broker.bindHuman({ business: 'demo', human: 'jason', via: 'cli', outsideAgent: true });
|
||||
const role = Object.keys(business.roles)[0];
|
||||
const cap = writer === 'tester' ? runtime.broker.bindTester({ business: 'demo' })
|
||||
: writer === 'agent' ? runtime.broker.bindLaunch({ business: 'demo', role, run: 'agent-run', harness: 'pi' }) : owner;
|
||||
const calls = [];
|
||||
// afterRead and beforeWrite let a test act between the route's read and its write.
|
||||
const requestRead = async (verb, args, ctx) => { calls.push(['read', verb]); const r = await new Client({ path, console: { subject: ctx.subject } }).call(verb, args); await afterRead?.(); return r; };
|
||||
const systemTypesWrite = async (verb, args) => { await beforeWrite?.(verb); calls.push(['write', verb]); return runtime.broker.request(cap, { verb, args }); };
|
||||
const session = { provider: 'fixture', iss: 'https://issuer.example', sub: 'owner', name: 'jason' };
|
||||
let active = true;
|
||||
const sso = { publicOrigin: '', publicHost: '', formTargets: () => [], session: () => active ? session : null, handle: () => {}, health: () => ({}) };
|
||||
const organizations = {
|
||||
list: async () => [{ id: 'demo', label: 'Demo', busHealthy: true }],
|
||||
resolve: async id => id !== 'demo' ? null : { busHealthy: true, send: send ? async () => ({}) : null, requestRead, systemTypesWrite: send ? systemTypesWrite : null,
|
||||
reads: { business: async () => ({ business: { id: 'demo' } }) }, bus: {}, chat: { projects: async () => [], threads: async () => [] } },
|
||||
};
|
||||
const web = await startServer({ port: 0, board: 'http://127.0.0.1:9', sso, send: async () => ({}), organizations, log: () => {} });
|
||||
const base = `http://127.0.0.1:${web.address().port}`;
|
||||
sso.publicOrigin = base; sso.publicHost = new URL(base).host;
|
||||
undo.push(() => close(web));
|
||||
const project = runtime.broker.request(owner, { verb: 'project.create', args: { name: 'example' } }).id;
|
||||
const onDisk = () => JSON.parse(readFileSync(file)).plugins['system-type'];
|
||||
const ownerRequest = (verb, args) => runtime.broker.request(owner, { verb, args });
|
||||
return { base, file, project, calls, onDisk, ownerRequest, signOut: () => { active = false; } };
|
||||
}
|
||||
|
||||
function call(f, path, method = 'GET', body, headers = {}) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const data = body === undefined ? undefined : typeof body === 'string' ? body : JSON.stringify(body);
|
||||
const req = request(f.base + path, { method, headers: { ...(data !== undefined ? { origin: f.base, 'sec-fetch-site': 'same-origin', 'content-type': 'application/json', 'content-length': Buffer.byteLength(data) } : {}), ...headers } }, res => {
|
||||
let text = ''; res.on('data', chunk => { text += chunk; }); res.on('end', () => resolve([res.statusCode, JSON.parse(text)]));
|
||||
});
|
||||
req.on('error', reject); req.setTimeout(10000, () => req.destroy(new Error('fixture request timed out'))); req.end(data);
|
||||
});
|
||||
}
|
||||
const ORG = '/api/orgs/demo/system-types';
|
||||
|
||||
test('the owner reads the installed modules and enables one with the revision, getting a receipt', async t => {
|
||||
const f = await fixture(t);
|
||||
const [status, before] = await call(f, ORG);
|
||||
assert.equal(status, 200, JSON.stringify(before));
|
||||
// Labels and descriptions are the modules' own, never a list in the WebUI.
|
||||
const modules = (await installedSystemTypes()).map(({ id, label, description }) => ({ id, label, description }));
|
||||
assert.deepEqual(before.installed, modules);
|
||||
assert.deepEqual(before.enabled, ['coding']);
|
||||
assert.equal(before.project, null); assert.equal(before.writable, true); assert.match(before.revision, /^[a-f0-9]{64}$/);
|
||||
const [saved, receipt] = await call(f, ORG, 'POST', { revision: before.revision, enabled: ['coding', 'montage'] });
|
||||
assert.equal(saved, 200, JSON.stringify(receipt));
|
||||
assert.deepEqual(receipt.enabled, ['coding', 'montage']);
|
||||
assert.match(receipt.transaction, /^[0-9a-f-]{36}$/); assert.notEqual(receipt.revision, before.revision);
|
||||
assert.deepEqual(f.onDisk(), ['coding', 'montage']);
|
||||
const [, after] = await call(f, ORG);
|
||||
assert.deepEqual(after.enabled, ['coding', 'montage']); assert.equal(after.revision, receipt.revision);
|
||||
// The revision is the proof: the old one is refused and nothing is written.
|
||||
const [stale, conflict] = await call(f, ORG, 'POST', { revision: before.revision, enabled: [] });
|
||||
assert.deepEqual([stale, conflict.error], [409, 'revision-conflict']);
|
||||
assert.deepEqual(f.onDisk(), ['coding', 'montage']);
|
||||
});
|
||||
|
||||
test('the admission checks refuse before any write', async t => {
|
||||
const f = await fixture(t);
|
||||
const [, { revision }] = await call(f, ORG);
|
||||
const good = { revision, enabled: [] };
|
||||
for (const [label, body, headers, expected] of [
|
||||
['unknown module', { revision, enabled: ['nope'] }, {}, [400, 'system-type-unknown']],
|
||||
['duplicate', { revision, enabled: ['coding', 'coding'] }, {}, [400, 'invalid-request']],
|
||||
['extra field', { ...good, actor: 'jason' }, {}, [400, 'invalid-request']],
|
||||
['business field', { ...good, business: 'other' }, {}, [400, 'invalid-request']],
|
||||
['no revision', { enabled: [] }, {}, [400, 'invalid-request']],
|
||||
['bad revision', { revision: 'x', enabled: [] }, {}, [400, 'invalid-request']],
|
||||
['not JSON', '{', {}, [400, 'invalid-request']],
|
||||
['too large', JSON.stringify({ revision, enabled: [], pad: 'x'.repeat(5000) }), {}, [413, 'invalid-request']],
|
||||
['form post', good, { 'content-type': 'text/plain' }, [400, 'invalid-request']],
|
||||
// The Console's own origin check answers a foreign Origin first; the route's own check answers Sec-Fetch-Site.
|
||||
['cross origin', good, { origin: 'https://other.example' }, [403, 'cross-origin request refused']],
|
||||
['cross site', good, { 'sec-fetch-site': 'cross-site' }, [403, 'cross-origin']],
|
||||
]) {
|
||||
const writes = f.calls.filter(c => c[0] === 'write' && c[1] === 'plugins.set').length;
|
||||
const [status, result] = await call(f, ORG, 'POST', body, headers);
|
||||
assert.deepEqual([status, result.error], expected, label);
|
||||
assert.equal(f.calls.filter(c => c[0] === 'write' && c[1] === 'plugins.set').length, writes, label);
|
||||
assert.deepEqual(f.onDisk(), ['coding'], label);
|
||||
}
|
||||
const [method] = await call(f, ORG, 'PUT');
|
||||
assert.equal(method, 405);
|
||||
const [cross, crossBody] = await call(f, ORG, 'GET', undefined, { origin: 'https://other.example' });
|
||||
assert.equal(cross, 403); assert.match(crossBody.error, /^cross-origin/);
|
||||
const [badProject, badProjectBody] = await call(f, `${ORG}/projects/${encodeURIComponent('../x')}`);
|
||||
assert.deepEqual([badProject, badProjectBody.error], [404, 'project-not-found']);
|
||||
f.signOut();
|
||||
const [signedOut, signedOutBody] = await call(f, ORG, 'POST', good);
|
||||
assert.deepEqual([signedOut, signedOutBody.error], [401, 'sign-in-required']);
|
||||
assert.deepEqual(f.onDisk(), ['coding']);
|
||||
});
|
||||
|
||||
test('a sign-out while the read runs dispatches no write on either route', async t => {
|
||||
let signOutOnRead = false;
|
||||
const f = await fixture(t, { afterRead: () => { if (signOutOnRead) f.signOut(); } });
|
||||
const [, { revision }] = await call(f, ORG);
|
||||
signOutOnRead = true;
|
||||
const writes = () => f.calls.filter(c => c[0] === 'write' && c[1] !== 'plugins').length;
|
||||
for (const [path, body] of [[ORG, { revision, enabled: [] }], [`${ORG}/projects/${f.project}`, { systemTypes: ['coding'] }]]) {
|
||||
const [status, result] = await call(f, path, 'POST', body);
|
||||
assert.deepEqual([status, result.error], [401, 'sign-in-required'], path);
|
||||
}
|
||||
assert.equal(writes(), 0);
|
||||
assert.deepEqual(f.onDisk(), ['coding']);
|
||||
});
|
||||
|
||||
test('the enabled set and its revision come from one read, so a change in between is not undone', async t => {
|
||||
let raced = false;
|
||||
const f = await fixture(t, { beforeWrite: async verb => {
|
||||
// Another owner action enables Marketing after the route's Console read and before its plugins read.
|
||||
if (verb !== 'plugins' || raced) return;
|
||||
raced = true;
|
||||
const { revision } = await f.ownerRequest('plugins', {});
|
||||
await f.ownerRequest('plugins.set', { revision, plugins: { 'system-type': ['coding', 'marketing'] } });
|
||||
} });
|
||||
const [, view] = await call(f, ORG);
|
||||
assert.equal(raced, true);
|
||||
assert.deepEqual(view.enabled, ['coding', 'marketing']);
|
||||
const [saved, receipt] = await call(f, ORG, 'POST', { revision: view.revision, enabled: view.enabled.filter(x => x !== 'coding') });
|
||||
assert.equal(saved, 200, JSON.stringify(receipt));
|
||||
assert.deepEqual(f.onDisk(), ['marketing']);
|
||||
});
|
||||
|
||||
test('without the owner human proof the enable switch is refused', async t => {
|
||||
// A Console without --send has no write transport: the read works, the write is read-only.
|
||||
const readOnly = await fixture(t, { send: false });
|
||||
const [status, view] = await call(readOnly, ORG);
|
||||
assert.equal(status, 200); assert.equal(view.writable, false); assert.equal(view.revision, undefined);
|
||||
const [refused, body] = await call(readOnly, ORG, 'POST', { revision: 'a'.repeat(64), enabled: [] });
|
||||
assert.deepEqual([refused, body.error], [403, 'read-only']);
|
||||
assert.deepEqual(readOnly.onDisk(), ['coding']);
|
||||
// A write transport bound as the tester or as an agent, not the owner's human cap, is refused by the
|
||||
// broker, and the receipt-bearing path never runs.
|
||||
for (const writer of ['tester', 'agent']) {
|
||||
const f = await fixture(t, { writer });
|
||||
const [fenced, fence] = await call(f, ORG, 'POST', { revision: 'b'.repeat(64), enabled: [] });
|
||||
assert.deepEqual([fenced, fence.error], [403, 'human-required'], writer);
|
||||
assert.deepEqual(f.onDisk(), ['coding'], writer);
|
||||
}
|
||||
});
|
||||
|
||||
test('a project selects only enabled modules and a disabled selection reads as inactive', async t => {
|
||||
const f = await fixture(t);
|
||||
const path = `${ORG}/projects/${f.project}`;
|
||||
const [status, empty] = await call(f, path);
|
||||
assert.equal(status, 200, JSON.stringify(empty));
|
||||
assert.deepEqual(empty.project, { project: f.project, selected: [], active: [], inactive: [] });
|
||||
assert.equal(empty.revision, undefined);
|
||||
const [notEnabled, notEnabledBody] = await call(f, path, 'POST', { systemTypes: ['montage'] });
|
||||
assert.deepEqual([notEnabled, notEnabledBody.error], [409, 'system-type-not-enabled']);
|
||||
const [unknown, unknownBody] = await call(f, path, 'POST', { systemTypes: ['nope'] });
|
||||
assert.deepEqual([unknown, unknownBody.error], [400, 'system-type-unknown']);
|
||||
const [saved, selection] = await call(f, path, 'POST', { systemTypes: ['coding'] });
|
||||
assert.equal(saved, 200, JSON.stringify(selection));
|
||||
assert.deepEqual(selection, { project: f.project, selected: ['coding'], active: ['coding'], inactive: [] });
|
||||
// The owner disables coding for the organization; the project keeps its selection, now inactive.
|
||||
const [, { revision }] = await call(f, ORG);
|
||||
assert.equal((await call(f, ORG, 'POST', { revision, enabled: [] }))[0], 200);
|
||||
const [, after] = await call(f, path);
|
||||
assert.deepEqual(after.enabled, []);
|
||||
assert.deepEqual(after.project, { project: f.project, selected: ['coding'], active: [], inactive: ['coding'] });
|
||||
const [missing, missingBody] = await call(f, `${ORG}/projects/no-such-project`);
|
||||
assert.deepEqual([missing, missingBody.error], [404, 'project-not-found']);
|
||||
const [missingWrite, missingWriteBody] = await call(f, `${ORG}/projects/no-such-project`, 'POST', { systemTypes: [] });
|
||||
assert.deepEqual([missingWrite, missingWriteBody.error], [404, 'project-not-found']);
|
||||
});
|
||||
|
||||
test('the tester listener and a Console without SSO never reach the routes or the files', async t => {
|
||||
const { createDevOwner } = await import('../src/dev-owner.mjs');
|
||||
let reached = 0;
|
||||
const hit = async () => { reached++; return {}; };
|
||||
const organizations = { list: async () => [{ id: 'demo', label: 'Demo', busHealthy: true }],
|
||||
resolve: async () => ({ busHealthy: true, send: hit, requestRead: hit, systemTypesWrite: hit, reads: { business: async () => ({ business: { id: 'demo' } }) }, bus: {}, chat: { projects: async () => [], threads: async () => [] } }) };
|
||||
const root = mkdtempSync(join(tmpdir(), 'stt-')), socket = join(root, 't.sock');
|
||||
const tester = await startServer({ tester: true, testerSocket: socket, board: 'http://127.0.0.1:9', organizations, log: () => {} });
|
||||
t.after(async () => { await close(tester); rmSync(root, { recursive: true, force: true }); });
|
||||
const viaTester = (path, method = 'GET') => new Promise((resolve, reject) => {
|
||||
const req = request({ socketPath: socket, path, method, headers: { host: 'mosaic-tester.invalid' } }, res => { res.resume(); res.on('end', () => resolve(res.statusCode)); });
|
||||
req.on('error', reject); req.end();
|
||||
});
|
||||
for (const path of ['/system-types.js', '/system-types.css']) assert.equal(await viaTester(path), 403, path);
|
||||
// The tester has no organization session, so the per-organization routes answer org-unavailable.
|
||||
for (const [path, method] of [[ORG, 'GET'], [ORG, 'POST'], [`${ORG}/projects/p1`, 'GET']]) assert.ok([403, 404].includes(await viaTester(path, method)), path);
|
||||
const plain = await startServer({ port: 0, organizations, log: () => {} }); t.after(() => close(plain));
|
||||
const plainBase = `http://127.0.0.1:${plain.address().port}`;
|
||||
assert.ok((await fetch(`${plainBase}${ORG}`)).status >= 400);
|
||||
// The owner's dev session on a Console with the routes: the file is served.
|
||||
const owner = await startServer({ port: 0, sso: createDevOwner(), send: async () => ({}), organizations, log: () => {} }); t.after(() => close(owner));
|
||||
assert.equal((await fetch(`http://127.0.0.1:${owner.address().port}/system-types.js`)).status, 200);
|
||||
assert.equal(reached, 0);
|
||||
await assert.rejects(startServer({ port: 0, sso: createDevOwner(), systemTypesWrite: hit, log: () => {} }), /System types writes need/);
|
||||
});
|
||||
@@ -6,11 +6,13 @@ import { createOrgAdmin } from '../src/org-admin.mjs';
|
||||
import { browser } from './browser.mjs';
|
||||
import { close } from './fixture.mjs';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { systemTypesFake } from './helpers/system-types-fake.mjs';
|
||||
|
||||
const waitFor = b => expression => b.evaluate(`(async()=>{for(let i=0;i<150;i++){if(${expression})return;await new Promise(r=>setTimeout(r,30))}throw Error(${JSON.stringify(expression)})})()`);
|
||||
|
||||
// Three organizations as sample 01 and 03 show them: two running buses and one stopped.
|
||||
function workspaceOrgs({ failThreads = false, seen = null } = {}) {
|
||||
// Three organizations as sample 01 and 03 show them: two running buses and one stopped. P2: each running
|
||||
// organization's System types read comes from the installed modules; `send` adds the owner's writes.
|
||||
function workspaceOrgs({ failThreads = false, seen = null, send = false, types = {} } = {}) {
|
||||
const ORGS = [
|
||||
{ id: 'ddigital', label: 'Distinctly Digital', theme: 'rosewood', busHealthy: true },
|
||||
{ id: 'forge', label: 'Forge', theme: 'cobalt', busHealthy: true },
|
||||
@@ -24,6 +26,8 @@ function workspaceOrgs({ failThreads = false, seen = null } = {}) {
|
||||
const AGENTS = { ddigital: [{ role: 'chief', holder_run: 'r1', state: 'waiting' }, { role: 'copywriter', holder_run: 'r2', state: 'working' }], forge: [] };
|
||||
const claim = (thread, holder_run) => ({ thread, holder_run, harness: 'claude', at: '2026-11-09T12:00:00.000Z' });
|
||||
const CLAIMS = { ddigital: [claim('t3', 'r1'), claim('t1', 'r2'), claim('t2', 'r3')], forge: [] };
|
||||
const TYPES = { ddigital: { enabled: ['coding', 'marketing'], projects: { 'spring-launch': ['coding'], 'brand-refresh': ['marketing'] } }, forge: { enabled: [], projects: { 'api-gateway': [] } }, ...types };
|
||||
const fakes = {};
|
||||
const FILES = { 'spring-launch': ['docs/north-star.md', 'docs/launch-plan.md', 'src/index.js'], 'brand-refresh': ['docs/brand-voice-guide.md'], 'api-gateway': [] };
|
||||
const instances = id => ({ business: id, revision: 'a'.repeat(64), launch: true, noHire: [], retired: [], definitions: {},
|
||||
instances: Object.fromEntries(Object.entries(INSTANCES[id]).map(([n, [definition, running]]) => [n, { definition, vars: {}, launched: true, launcher: false, protected: false, kept: false, running, alive: [] }])) });
|
||||
@@ -35,8 +39,10 @@ function workspaceOrgs({ failThreads = false, seen = null } = {}) {
|
||||
seen?.push(id);
|
||||
const org = ORGS.find(o => o.id === id); if (!org) return null;
|
||||
if (!org.busHealthy) return { busHealthy: false, reads: null, bus: {}, chat: { projects: async () => [], threads: async () => [] } };
|
||||
fakes[id] ??= systemTypesFake(TYPES[id]);
|
||||
const st = await fakes[id];
|
||||
return {
|
||||
busHealthy: true,
|
||||
busHealthy: true, requestRead: st.requestRead, systemTypesWrite: send ? st.systemTypesWrite : null, ...(send ? { send: async () => ({}) } : {}),
|
||||
reads: { business: async () => business(id), settings: async () => ({ release: '0.0.42', notifier: { binding: 'discord' }, launch: { limits: { total: 4 } } }),
|
||||
queue: async () => ({ rows: id === 'ddigital' ? [{ state: 'done' }, { state: 'in-progress' }, { state: 'queued' }] : [] }), busStatus: async () => ({}) },
|
||||
bus: { instances: async () => instances(id), messages: async () => ({ messages: [], unread: 0 }), agents: async () => AGENTS[id], 'thread.claims': async () => CLAIMS[id], inbox: async () => [], tasks: async () => [] },
|
||||
@@ -125,7 +131,8 @@ test('organization dashboard renders every section from the per-organization rea
|
||||
assert.equal(head.avatar, 'DD'); assert.equal(head.title, 'Distinctly Digital');
|
||||
assert.match(head.meta, /Kind not recorded/); assert.match(head.meta, /id ddigital/); assert.match(head.meta, /Tracker: Vikunja/); assert.match(head.meta, /bus running, read \d/);
|
||||
assert.match(head.colour, /^#[0-9a-f]{6}$/i);
|
||||
assert.match(await b.evaluate('document.querySelector(".ws-typechips").innerText'), /System types\s+No system types yet/i);
|
||||
// System types: the enabled modules by their own labels (P2).
|
||||
assert.match(await b.evaluate('document.querySelector(".ws-typechips").innerText'), /System types\s+Coding\s*Marketing\s+Each project picks from these/i);
|
||||
// Docs come from the project files reads: only documents, named by project.
|
||||
assert.deepEqual(await b.evaluate('[...document.querySelectorAll(".ws-doc")].map(a => a.innerText.replace(/\\s+/g, " ").trim())'), ['North star Spring launch', 'Launch plan Spring launch', 'Brand voice guide Brand refresh']);
|
||||
// KPIs and metrics: one honest empty tile each, with no invented number.
|
||||
@@ -150,7 +157,8 @@ test('organization dashboard renders every section from the per-organization rea
|
||||
for (const s of settings) { assert.ok(s.icon, s.name); assert.ok(s.value, s.name); assert.ok(TAGS.includes(s.tag), `${s.name}: ${s.tag}`); }
|
||||
const by = Object.fromEntries(settings.map(s => [s.name, s]));
|
||||
assert.deepEqual([by['Members and invites'].tag, by['Members and invites'].href], ['not built', null]);
|
||||
assert.deepEqual([by['System types'].tag, by['System types'].href], ['not built', null]);
|
||||
assert.deepEqual([by['System types'].tag, by['System types'].value], ['org file', '2 of 5 installed enabled']);
|
||||
assert.equal(await b.evaluate('document.querySelector(\'[data-org-setting="System types"] button.go\').textContent'), 'Open');
|
||||
assert.deepEqual([by['Archive or delete'].tag, by['Archive or delete'].danger, by['Archive or delete'].href], ['danger', true, '#/settings/organizations']);
|
||||
assert.equal(by.Identity.value, 'Distinctly Digital · ddigital · kind not recorded');
|
||||
assert.equal(by['Task tracker'].value, 'Vikunja');
|
||||
@@ -170,7 +178,8 @@ test('organization dashboard empty states say so in one short line', async t =>
|
||||
await waitFor(b)('document.querySelector("[data-org-dashboard] [data-org-settings]")');
|
||||
const text = await b.evaluate('document.querySelector("[data-org-dashboard]").innerText');
|
||||
// Section labels are uppercased by CSS, so innerText carries the uppercase form.
|
||||
for (const line of [/System types\s+No system types yet\. They arrive with the system types update\./i, /Docs\s+No docs yet/i, /No KPIs defined in the organization file/, /No metrics recorded yet/, /Active agent sessions\s+none running/i, /No active agent sessions/, /Queue\s+no rows/i, /Tracker: not set/])
|
||||
// The empty organization has no System types read, so it says unavailable, never that none exist.
|
||||
for (const line of [/System types\s+System types unavailable/i, /Docs\s+No docs yet/i, /No KPIs defined in the organization file/, /No metrics recorded yet/, /Active agent sessions\s+none running/i, /No active agent sessions/, /Queue\s+no rows/i, /Tracker: not set/])
|
||||
assert.match(text, line);
|
||||
assert.equal(await b.evaluate('document.querySelectorAll("[data-org-settings] > li").length'), SETTINGS.length);
|
||||
});
|
||||
|
||||
@@ -8,6 +8,7 @@ test('integrated project settings and conversation mounts use authorized context
|
||||
[`GET /api/chat/rooms/${ids.project}/messages?limit=100`]: {status:503,body:{error:'fixture-unavailable'}},
|
||||
'GET /api/project-files?project=fixture-repository': {status:503,body:{error:'fixture-unavailable'}},
|
||||
'GET /api/project-file?project=fixture-repository&path=spec%2Fnorth-star.md': {status:503,body:{error:'fixture-unavailable'}},
|
||||
[`GET /api/system-types/projects/${ids.project}`]: {status:503,body:{error:'fixture-unavailable'}},
|
||||
'/api/project-remotes/fixture-repository': { project: ids.repository, remote: null, revision: 'a'.repeat(64), firstSaveAllowed: true },
|
||||
};
|
||||
const { f, b, wait } = await w15Fixture(t, { scenario });
|
||||
|
||||
Reference in new issue
Block a user