Compare commits

..
42 changed files with 968 additions and 2491 deletions
+2
View File
@@ -23,6 +23,8 @@ not fleet seats. Keep changes in `/mnt/storage/src/mosaic-stack`; do not modify
| Herb | Builder, a second gate lane (#1695) | T3 session (Codex, gpt-6.1-sol), started by Sage under mos-0977 | none, T3 only |
| Bushroot | Builder, retire the control board writer on 7331 (#1696) | T3 session (Claude, claude-haiku-5-5), started by Sage under mos-1008 | none, T3 only |
| Megavolt | Builder, MVP T1 item 6 (agent credentials and git identity, #1697) | T3 session (Claude, Opus 5.5), started by Sage under mos-0974/0977 | none, T3 only |
| Quackerjack | Builder, federation R5 and R2 (#414) | T3 session (Claude, claude-haiku-5-5), started by Sage under mos-1023 | none, T3 only |
| Gizmoduck | Federation design (#414) and builder of FED R1 | T3 session (Claude, Opus 5.5, thread 9312a896) | none, T3 only |
| Webby | Reviewer: Case 2 designs, rows 192 and 194 | T3 session (Claude, Opus 5.5, thread 03086006), started by Mos (mos-0981) | none, T3 only |
| Duckworth | Reviewer: federation designs, row 193 | T3 session (Claude, Opus 5.5, thread ffceafec), started by Mos (mos-0977) | none, T3 only |
| Sage | Project lead: coordination, review, integration; earlier DYOR strategy records retained | T3 session (Claude Code); Pi launcher `zai/glm-5.3:high` retained | `agents/sage/launch.sh` |
+9
View File
@@ -0,0 +1,9 @@
# Gizmoduck
Design and builder seat, Claude Opus 5.5, a T3 thread (9312a896). Author of the federation design
(`agents/gizmoduck/work/federation/DESIGN.md` on `gizmoduck/federation-design`, #414). Builds FED R1, the
access.json loader and validator, under Mos's order mos-1023 item 1.
No launcher in this directory. The seat works in its own worktree and has no credentials of its own; until
the credentials row settles per-agent identity, it pushes only its own `gizmoduck/*` branches through the
repository credential helper (mos-0981), never `refactor`, `main` or another seat's branch.
+100
View File
@@ -0,0 +1,100 @@
# Row 201 round 1 (#1699): FED R1, the access.json loader and validator
Gizmoduck, 2026-10-20. Reviewer Duckworth, gate owner Sage. Federation design #414.
- Brief: `docs/plans/2026-10-20_federation-r1-r2-r5.md`, section "Brief: FED R1", at a2af7ab49.
- Design: `agents/gizmoduck/work/federation/DESIGN.md` section 3.5, r4.9 a1daa3c07 on
`gizmoduck/federation-design`.
- Base: ae3c36f24 (`origin/refactor` when I branched, contains the brief). Branch `gizmoduck/fed-r1`.
- Candidate: the commit that adds this file. `candidate-manifest.sha256` lists the three code files.
## Interface
This is what R5 and row 193a build against. Any change to it in review goes to Sage before it lands.
Morgana confirmed it (morgana-0014) and Sage accepted it with `ssoSubjects` required (sage-1399). Sage
relays it to Quackerjack.
| Export | Shape |
|--------|-------|
| `LEVELS` | frozen `["collaborator"]`, the one allowed level set. 193a widens it with the single line `["collaborator", "viewer"]` |
| `accessFilePath(dir = configDir())` | `<dir>/access.json` |
| `checkAccess(doc, { providers, peers, businesses, ssoSubjects })` | pure, returns a frozen deep copy, throws `BusinessError` exit 2 naming the key path |
| `loadAccess(file, context, { uid = process.getuid() } = {})` | one no-follow read through `readJsonFile`, then the stat checks, then `checkAccess`. `uid` is a test seam |
| `covers(grant, { business, project, instance }, now, { level } = {})` | boolean. `now` must be a valid `Date`, otherwise TypeError |
The context: `providers` is an array of provider ids, `peers` an array of peer ids (never `local`),
`businesses` maps a business id to `{ human, instances }`, and `ssoSubjects` is an array of
`{ provider, sub }`, the owner's `console.sso.subjects`. A missing or malformed context refuses.
All four are exported from `src/index.mjs` too.
## Rules, code and tests
DESIGN 3.5 rule, where `access.mjs` enforces it, and the test that shows the refusal names the key path.
| 3.5 rule | access.mjs | access.test.mjs test | Mutant |
|----------|-----------|----------------------|--------|
| regular file, not a symlink | 167 (`readJsonFile`, util.mjs:66-89) | "loadAccess refuses a missing file, a symlink and a directory" | M03 |
| owned by the instance's uid | 168 | "loadAccess refuses another owner and group or other write" | M01 |
| no group or other write | 169 | same | M02 |
| unknown keys refuse | 147, 71, 83, 121 | "top level", "users: unknown keys", "users: subjects", "grants: unknown keys" | M05 M10 M16 M22 |
| `accessVersion` is 1 | 148 | "top level" | M06 |
| `revision` positive integer | 149 | "top level" | M07 |
| user id matches the id rule | 72 | "users: unknown keys and the id rule" | M11 |
| never `human` or `tester` | 73 | "users: human, tester and any business's human" | M12 |
| never any business's `human` | 74 | same, both businesses | M13 |
| provider is a given provider | 84-86 | "users: subjects, provider, sub" | M17 |
| a pair appears once in the file | 90 | same, within one user and across two | M20 |
| a pair is never in `console.sso.subjects` | 89 | same | M19 |
| `home` is a given peer or `local` | 75-77 | "users: home is a given peer or local" | M14 |
| grant names a declared business | 126-128 | "grants: unknown keys, id, user and business" | M26 |
| `projects` is `["*"]` or UUIDs | 98-107, 130-132 | "grants: projects" | M27 M28 M29 M30 |
| `instances` is `["*"]` or declared instances | 98-107, 133-135 | "grants: instances" | M31 |
| `level` is `collaborator` | 136 | "grants: any level other than collaborator" (missing, null, wrong case) | M32 |
| `expires` optional ISO time | 111-117, 137 | "grants: expires is optional ISO time" | M33 M34 |
| past `expires` is absent | covers 179 | "covers: an expired grant doesn't match, at and after the instant" | M38 M39 |
`covers`: `*` and listed ids match (180-182), an unlisted project, instance or business doesn't, an
expired grant doesn't (179), and a level filter rejects another level (183). Mutants M38 to M44.
## Deviations from the brief
1. Expiry. The brief's test list says expiry refuses. DESIGN 3.5 line 257 says a grant past `expires` is
treated as absent. Sage ruled the design wins (sage-1399), so a past `expires` passes `checkAccess`
and `covers` returns false at and after the instant. A malformed `expires` still refuses.
2. The brief lists "a grant disjoint from the given subjects". 3.5 has no rule that ties a grant to a
subject, and a grant names a user, not a subject. I read it as the 3.5 lines 248-249 subject rules:
a `(provider, sub)` pair that is also in `ssoSubjects` refuses, and so does a pair that appears twice.
Duckworth, say if you meant something else.
## Rules I added beyond 3.5
Each one is fail closed and has a test and a mutant.
- User ids and grant ids are unique (154, 123; M21, M24). A grant's `user` names a user in the file
(125; M25). Without these, a grant could point at nobody or two users could share an id.
- `projects` and `instances` are non-empty, distinct, and `*` never mixes with ids (98-106).
- `expires` must be UTC with a `Z` and must round-trip (`2026-02-30T00:00:00Z` refuses). `Date.parse`
accepts that date and rolls it to March 2, so the regex alone wasn't enough.
- The context is checked too (43-67; M35 to M37). `peers` can't name `local`, because that would make a
peer user look local.
- `covers` throws a TypeError for a `now` that isn't a valid Date (M44), since a bad clock would
otherwise make every grant look unexpired.
## Checks
- `node --test packages/business/tests/access.test.mjs`: 24 pass, 0 fail (single file, no slot).
- Morgana's `access-levels.test.mjs` at 08bb4d827, as a cross-check, not part of the candidate. On this
candidate it passes 3 of 4, and the one failure is the viewer case 193a adds. In a scratch copy with
`LEVELS` widened to `["collaborator", "viewer"]` it passes 4 of 4.
- `mutants.py`: 44 mutants, one per refusal and covers branch. All 44 anchors match once and every
mutant passes `node --check`. **Not run yet**: mutants need Sage's slot. Run as
`python3 -I agents/gizmoduck/work/queue-201/mutants.py <checkout> <empty scratch dir>`. It runs a control
first, puts each run in its own process group with a 60 s timeout, and exits 1 if any mutant survives.
- `node tools/unslop-hook/unslop-check.js` is clean on all three code files and this file.
- No test reads or pins `src/index.mjs` by hash (grep of packages/*/tests and scripts/test-*.sh).
- Not run: the business suite and `scripts/test-*.sh`. Both need Sage's slot.
## Boundaries kept
Nothing touched `~/.mosaic-dev`, `~/.config/mosaic-dev`, the live units or any access.json. Tests write
only under `mkdtemp` scratch dirs.
+1
View File
@@ -0,0 +1 @@
ae3c36f24883e870541158d3ade1f5d3ef2ab856
@@ -0,0 +1,3 @@
74ac7e9ed38c230e60e7f7ece373104446c362b6fce0a4f92ca2210a981dc4f4 packages/business/src/access.mjs
7ee59b6e792fb47048fd06d227b65467ef71dabfcbdabdc0527b780656323d20 packages/business/src/index.mjs
3bec097f55909b31faf2bbc62c81e1d4d0290f50e696d3d3d367e77d82c5b238 packages/business/tests/access.test.mjs
+127
View File
@@ -0,0 +1,127 @@
#!/usr/bin/env python3
"""Row 201 (FED R1) mutants: one per refusal in packages/business/src/access.mjs.
Usage: mutants.py <checkout> <scratch-dir>
Copies packages/business and packages/plugins from <checkout> into
<scratch-dir>, applies one mutant at a time (each anchor must match exactly
once), runs `node --test tests/access.test.mjs` in its own process group with
a timeout, and expects it to fail. Prints one line per mutant and exits 1 if
any survives, an anchor misses, or the unmutated control fails. Needs Sage's
slot. Writes nothing outside <scratch-dir>.
"""
import os, shutil, signal, subprocess, sys
MUTANTS = [
# loadAccess file checks
("M01 owner check", 'if (stat.uid !== uid) refuse(', 'if (false) refuse('),
("M02 group/other write check", 'if ((stat.mode & 0o022) !== 0) refuse(`access file', 'if (false) refuse(`access file'),
("M03 one-fd nofollow read", 'const document = readJsonFile(file, "access file", (stat) => {',
'const document = JSON.parse(readFileSync(file, "utf8")); ((stat) => {'),
# top level
("M04 document object", 'if (!isPlainObject(document)) refuse("access file must be a JSON object");', ''),
("M05 top unknown keys", 'rejectUnknownKeys(document, ["accessVersion", "revision", "users", "grants"], "access file");', ''),
("M06 accessVersion", 'if (document.accessVersion !== 1) refuse(', 'if (false) refuse('),
("M07 revision", 'requirePositiveInt(document.revision, "revision");', ''),
("M08 users array", 'requireArray(document.users, "users").forEach(', '(document.users ?? []).forEach('),
("M09 grants array", 'requireArray(document.grants, "grants").forEach(', '(document.grants ?? []).forEach('),
# users
("M10 user unknown keys", 'rejectUnknownKeys(user, ["id", "home", "subjects"], at);', ''),
("M11 user id rule", 'requireId(user.id, `${at}.id`);', ''),
("M12 human/tester", 'if (RESERVED_USERS.includes(user.id)) refuse(', 'if (false) refuse('),
("M13 business human", 'if (humans.has(user.id)) refuse(', 'if (false) refuse('),
("M14 home", 'if (user.home !== LOCAL_HOME && !ctx.peers.has(user.home)) {', 'if (false) {'),
("M15 subjects non-empty", 'if (subjects.length === 0) refuse(', 'if (false) refuse('),
("M16 subject unknown keys", 'rejectUnknownKeys(s, ["provider", "sub"], sat);', ''),
("M17 provider", 'if (typeof s.provider !== "string" || !ctx.providers.has(s.provider)) {', 'if (false) {'),
("M18 sub", 'requireText(s.sub, `${sat}.sub`);', ''),
("M19 owner subject", 'if (ctx.owners.has(key)) refuse(', 'if (false) refuse('),
("M20 duplicate subject", 'if (seenSubjects.has(key)) refuse(', 'if (false) refuse('),
("M21 duplicate user id", 'if (users.has(user.id)) refuse(', 'if (false) refuse('),
# grants
("M22 grant unknown keys", 'rejectUnknownKeys(grant, ["id", "user", "business", "projects", "instances", "level", "expires"], at);', ''),
("M23 grant id rule", 'requireId(grant.id, `${at}.id`);', ''),
("M24 duplicate grant id", 'if (seenGrants.has(grant.id)) refuse(', 'if (false) refuse('),
("M25 grant user", 'if (typeof grant.user !== "string" || !users.has(grant.user)) refuse(', 'if (false) refuse('),
("M26 grant business", 'if (typeof grant.business !== "string" || !ctx.businesses.has(grant.business)) {', 'if (false) {'),
("M27 scope non-empty", 'if (list.length === 0) refuse(`${where} must not be empty`);', ''),
("M28 scope * alone", 'if (list.length !== 1) refuse(', 'if (false) refuse('),
("M29 scope duplicate", 'if (seen.has(entry)) refuse(', 'if (false) refuse('),
("M30 project UUID", 'if (typeof p !== "string" || !UUID.test(p)) refuse(', 'if (false) refuse('),
("M31 declared instance", 'if (typeof x !== "string" || !business.instances.has(x)) refuse(', 'if (false) refuse('),
("M32 level", 'if (!LEVELS.includes(grant.level)) refuse(', 'if (false) refuse('),
("M33 expires format", 'if (typeof value !== "string" || !ISO_TIME.test(value) || Number.isNaN(Date.parse(value))) {', 'if (typeof value !== "string") {'),
("M34 expires real time", 'if (iso.slice(0, 19) !== value.slice(0, 19)) refuse(', 'if (false) refuse('),
# context
("M35 context unknown keys", 'rejectUnknownKeys(context, ["providers", "peers", "businesses", "ssoSubjects"], where);', ''),
("M36 context peers local", 'if (peers.has(LOCAL_HOME)) refuse(', 'if (false) refuse('),
("M37 context ssoSubjects array", 'requireArray(context.ssoSubjects, `${where}.ssoSubjects`).forEach(', '(context.ssoSubjects ?? []).forEach('),
# covers
("M38 covers expiry", 'if (grant.expires !== undefined && now.getTime() >= Date.parse(grant.expires)) return false;', ''),
("M39 covers expiry boundary", 'now.getTime() >= Date.parse(grant.expires)', 'now.getTime() > Date.parse(grant.expires)'),
("M40 covers business", 'if (grant.business !== business) return false;', ''),
("M41 covers project", 'if (!(grant.projects[0] === ALL || grant.projects.includes(project))) return false;', ''),
("M42 covers instance", 'if (!(grant.instances[0] === ALL || grant.instances.includes(instance))) return false;', ''),
("M43 covers level", 'if (level !== undefined && grant.level !== level) return false;', ''),
("M44 covers now check", 'if (!(now instanceof Date) || Number.isNaN(now.getTime())) throw', 'if (false) throw'),
]
TIMEOUT = 60
def run(cwd):
proc = subprocess.Popen(["node", "--test", "tests/access.test.mjs"], cwd=cwd,
stdout=subprocess.PIPE, stderr=subprocess.STDOUT, start_new_session=True)
try:
out, _ = proc.communicate(timeout=TIMEOUT)
except subprocess.TimeoutExpired:
os.killpg(proc.pid, signal.SIGKILL)
proc.communicate()
return None, "timeout"
return proc.returncode, out.decode(errors="replace")
def main():
if len(sys.argv) != 3:
sys.exit("usage: mutants.py <checkout> <scratch-dir>")
checkout, scratch = (os.path.abspath(a) for a in sys.argv[1:])
if not os.path.isfile(os.path.join(checkout, "packages/business/src/access.mjs")):
sys.exit(f"no packages/business/src/access.mjs under {checkout}")
if os.path.exists(scratch) and os.listdir(scratch):
sys.exit(f"scratch dir not empty: {scratch}")
for pkg in ("business", "plugins"):
shutil.copytree(os.path.join(checkout, "packages", pkg), os.path.join(scratch, "packages", pkg))
pkg = os.path.join(scratch, "packages/business")
target = os.path.join(pkg, "src/access.mjs")
original = open(target).read()
code, out = run(pkg)
if code != 0:
print(out)
sys.exit(f"control failed (exit {code}): the unmutated tests must pass")
print("control: pass")
bad = 0
for name, old, new in MUTANTS:
n = original.count(old)
if n != 1:
print(f"{name}: ANCHOR {n}")
bad += 1
continue
mutated = original.replace(old, new)
if name.startswith("M03"):
mutated = 'import { readFileSync } from "node:fs";\n' + mutated
open(target, "w").write(mutated)
code, _ = run(pkg)
open(target, "w").write(original)
if code == 0:
print(f"{name}: SURVIVED")
bad += 1
else:
print(f"{name}: killed ({'timeout' if code is None else f'exit {code}'})")
print(f"mutants {len(MUTANTS)}, not killed {bad}")
sys.exit(1 if bad else 0)
if __name__ == "__main__":
main()
-177
View File
@@ -1,177 +0,0 @@
# Row 192: Q175 phase 1, the Console verifies sign-in itself (`--sso`)
Launchpad, 2026-10-20. Author Launchpad, reviewer Webby, gate owner Sage. Issue #1690. Brief
`docs/plans/2026-10-20_sso-phase1.md`. Design `agents/sage/work/q175-sso/DESIGN.md` (blob 9e3eadb15c80).
Rulings sage-1340 (F1 to F6) and sage-1347 (F7, F8). Plan launchpad-0001.
Base 53bb59462 (`base.txt`). Candidate: branch `launchpad/row192`, r1 83b32d8d8 and the round 2 commit on it.
`candidate-manifest.sha256` lists the 18 changed code, test and doc paths; the packet files are not in it. Drift
check at the r2 commit: `git log 53bb59462..origin/refactor` (origin/refactor ae3c36f24) touches none of the 18 paths.
No request in any test goes to auth.diversecanvas.com, mosaic.woltje.com or 127.0.0.1:7332. Every provider
is the fake OP in `tests/helpers/fake-oidc.mjs`: https on 127.0.0.1 with a throwaway self-signed certificate,
and a fetch that refuses any other origin. The public Host in the tests is `console.example.test`, sent as a
header to the test's own loopback listener. No test reads or writes `~/.mosaic-dev`, `~/.config/mosaic-dev`,
`~/.mosaic`, a slot file outside its temp dir, or a live unit.
## Files
| Path | Change |
| --- | --- |
| `package.json`, `package-lock.json` | `oauth4webapi` 3.8.8, exact, at the root (design: the copy runs `npm ci` there). No other dependency. |
| `scripts/mosaic-config.mjs` | Accepts an optional `console.sso` key and checks it with `checkConsole`. Without the key, output is byte-identical (F2). |
| `scripts/test-config.sh` | Test 25: a good `console.sso` passes; each refusal class refuses. 31/31. |
| `packages/webui/src/sso-config.mjs` (new) | `checkConsole`: shape, `publicOrigin`, 1 to 8 providers, issuer https with a trailing slash, slot names, `ALGS` (asymmetric only), 1 to 64 subjects. |
| `packages/webui/src/sso.mjs` (new) | The relying party: `readSlot`, discovery and JWKS with retry, `/auth/login`, `/auth/callback`, `/auth/logout`, sessions, `health()`, `summary()`, `formTargets()`. |
| `packages/webui/src/serve.mjs` | Under `sso`: public Host only on the human listener (F4), `/auth/*` routes, session check before every page and API route, CSP `form-action` adds the end-session origins, tester listener refusals (F8). |
| `packages/webui/src/cli.mjs` | `--sso`, its refusals, slot reads, the start lines, `close()` on stop. |
| `packages/webui/src/chat-http.mjs` | Under `--sso` the session check already ran, so the header-user check is skipped; the Origin check uses `publicOrigin`. |
| `packages/webui/src/public/app.js`, `bus.js`, `index.html` | 401 `sign-in required` signs the page out with a link to `/auth/login?return=...` (design test 24, F1). The Sign out form shows only when `/api/config` says `sso` (F7). |
| `scripts/webui-service.sh` | Passes `--sso` through; `--send` refusal text names `--sso`. |
| `packages/webui/tests/service.test.mjs` | The `--send` refusal regex follows the new text. |
| `packages/webui/README.md` | New section "Sign-in in the Console (Q175 phase 1, #1690)". |
| `packages/webui/tests/helpers/fake-oidc.mjs` (new) | The fake OP. |
| `packages/webui/tests/sso.test.mjs` (new) | 28 tests: three positives and design tests 1 to 23 (some split in parts). |
| `packages/webui/tests/sso-browser.test.mjs` (new) | 2 tests: design test 24 with F1, and F7. |
## Tests run (single files, this worktree, final tree)
| File | Pass | Fail |
| --- | --- | --- |
| sso | 28 | 0 |
| sso-browser | 2 | 0 |
| pinned-copy | 10 | 0 |
| serve | 4 | 0 |
| preview | 11 | 0 |
| proof | 4 | 0 |
| proof-cli | 3 | 0 |
| send | 13 | 0 |
| service | 31 | 0 |
| tester-listener | 10 | 0 |
| tester-cli | 2 | 0 |
| board-held | 3 | 0 |
| send-browser | 2 | 0 |
| docs-browser | 1 | 0 |
| chat | 23 | 0 |
| chat-browser | 11 | 0 |
| bus-browser | 2 | 0 |
| settings-write | 4 | 0 |
| project-file | 10 | 0 |
| instances | 5 | 0 |
| routes | 5 | 0 |
| `scripts/test-config.sh` | 31 | 0 |
The first singles pass failed `pinned-copy` 1 of 10: `ERR_MODULE_NOT_FOUND: Cannot find package
'oauth4webapi'` from a copy's `sso.mjs`. The test's fixture repo writes an empty package.json and lock, so the
copy's `npm ci` installs nothing, and `sso.mjs` imported oauth4webapi at module load. `cli.mjs` imports
`sso.mjs` on every start, so plain `serve` broke. Fix: `sso.mjs` loads oauth4webapi inside `createSso`
(`oauth ??= await import('oauth4webapi')`). A Console without `--sso` never resolves the package. A real copy
still has it from the root lock. Mutant 21 restores the static import and `pinned-copy` kills it. After the
fix: pinned-copy, sso, sso-browser and serve rerun green (counts above).
Not run by me (slot work, busy at the time): the full webui package suite, the cli suite, and
`scripts/test-*.sh` other than test-config. `packages/cli/tests/stack-copy.test.mjs` and
`stack-release.test.mjs` also build copies with an empty lock and are the ones to watch for the same
failure. They need Sage's gate.
## Mutants (`mutants.py`, run as `python3 mutants.py <tree> [N ...]`)
Each mutant edits one source file, runs one test file filtered to one test name, and restores the file
checked by sha256. Final run on the final tree: `/mnt/storage/scratch/launchpad-r192/mutants-final.log`.
| # | Mutant | Test | Result |
| --- | --- | --- | --- |
| 1 | drop validateApplicationLevelSignature | sso 9 | KILLED |
| 2 | wrap the signature call in a continuing try/catch | sso 9 | KILLED |
| 3 | drop the subjects check | sso 10 | KILLED |
| 4 | accept HS256 and none in ALGS | sso 11 start | KILLED |
| 5 | take the provider from the query | sso 15 | KILLED |
| 6 | tester listener reads sign-in cookies (F8) | sso 12 | KILLED |
| 7 | tester listener takes Authorization (F8) | sso 12 | KILLED |
| 8 | tester listener takes x-authentik-* (F8) | sso 12 | KILLED |
| 9 | skip the login record delete | sso 7 | KILLED |
| 10 | drop HttpOnly from the session cookie | sso 20 and 21 | KILLED |
| 11 | refresh last-seen on every request | sso 13 idle | KILLED |
| 12 | build redirect_uri from the request Host | sso 22 | KILLED |
| 13 | serve the loopback read path under --sso (F4) | sso 22 | KILLED |
| 14 | bus.js fetchRead ignores 401 sign-in required (F1) | sso-browser 1 | KILLED |
| 15 | bus.js fetchApi ignores 401 sign-in required (F1) | sso-browser 1 | KILLED |
| 16 | app.js getJSON ignores 401 sign-in required | sso-browser 1 | KILLED |
| 17 | app.js api() ignores 401 sign-in required | sso-browser 1 | KILLED |
| 18 | signInRequired takes any 401 | sso-browser 1 | KILLED |
| 19 | Sign out shown without the sso condition (F7) | sso-browser 2 | KILLED |
| 20 | CSP form-action without the end-session origins | sso 13 CSP | KILLED |
| 21 | import oauth4webapi at module load | pinned-copy | KILLED |
| 22 | restore the r1 relative returnPath (round 2, B1) | sso 14 | KILLED |
| 23 | drop the second origin check (round 2, sage-1403) | sso 14 | SURVIVED, equivalent (see Round 2) |
Mutant 16 survived its first run: test 1 had no `getJSON` path. The History click on
`/api/conversations` was added and it is now killed.
## Deviations from the design
- **F1 (sage-1340).** `bus.js` `fetchRead` and `fetchApi` apply the same 401 `sign-in required` rule as
`app.js`, even when `/api/config` has no `sso`. Tested in sso-browser 1, mutants 14 and 15.
- **F7 (sage-1347).** A Sign out form (`POST /auth/logout`) in the command bar, hidden unless `/api/config`
says `sso`. Tested in sso-browser 2, mutant 19.
- **Proof header on 7333 under `--sso` (webby-0019 N2).** `cli.mjs` passes `testerProofHeader: null` under
`--sso`, since `--sso` refuses the proof pair and there is no header name to refuse. A request to 7333 that
still carries the old proof header is not refused for it. It gains nothing: under `--sso` no listener
reads a proof header (`proof` is null in `startServer`). Left as is.
## Assumptions and choices
- **F8 (sage-1347).** Under `--sso` the tester listener refuses a request carrying `Authorization`, either
`__Host-mosaic` cookie or any `x-authentik-*` header, case-insensitive (Node lower-cases header names).
sso 12 sends one of each; mutants 6 to 8.
- **oauth4webapi loads lazily** inside `createSso` (see the pinned-copy failure above).
- **Client authentication** is `ClientSecretBasic`; the fake OP's token endpoint checks Basic auth.
- **Caps**: 1 to 8 providers, 1 to 64 subjects per provider, 64 sessions, 32 pending logins. The 33rd login
drops the oldest record (sso 17).
- **CSP**: under `--sso`, `form-action` adds each provider's end-session origin. Chrome applies
`form-action` to the redirect after the Sign out POST, so without it the logout stops at the Console.
Tested in sso 13 CSP, mutant 20. Without `--sso` the CSP string is unchanged.
- **`--send` refusal text** in cli.mjs and webui-service.sh now ends "or --sso".
- **Start lines**: one `Sign-in provider:` line per provider with id, issuer, subject count and state
(`ready`, `refused` or `unreachable`). No client id, secret or sub.
- **Test 1** uses `console.example.test` as the public Host, not mosaic.woltje.com (no request goes there).
- **Test 11 slot files**: the owner check is not tested; the test can't create a file owned by another uid
without privilege. Symlink, 0644, 0640 and a file under the repository are tested.
- **Test 23** covers a provider down at start: the Console starts, `/auth/login` is 503, loopback `/healthz`
is 200 with `sso: { a: 'unreachable' }`, one `discovery-failed` line, and the retry makes sign-in work. An
HS-only discovery at start refuses the start (in test 23 and in test 11 start).
- **Test 22**: a Host of bare `localhost` without a port is not in the table. It hits the older
`non-local Host refused` check before the sso check; a comment in the test says so.
## Round 2 (webby-0019)
- **B1, open redirect after sign-in.** `returnPath` took any URL on `publicOrigin`, and dot segments can
normalize to a pathname of `//evil.example`, which a browser reads as scheme-relative in a relative
Location. Fix per sage-1403: `returnURL` (renamed) builds an absolute URL, `publicOrigin + pathname +
search`, checks its origin again, and returns its href. Anything else is `publicOrigin/`. The callback's
Location is now always absolute on the Console. Test 14 asserts the exact Location and its origin for each
case. It adds Webby's three rows (`/.//evil.example`, `/x/..//evil.example`,
`https://console.example.test//evil.example`), each now `https://console.example.test//evil.example`, a path
on the Console. It also adds `/board/./x`, which lands on `/board/x`. The positive test expects
`https://console.example.test/board?x=1`.
- **Mutant 22** restores the r1 relative `returnPath`; test 14 kills it.
- **Mutant 23** drops the second origin check. It SURVIVES, and I read it as equivalent: `checkConsole`
holds `publicOrigin` to a bare https origin, and a parsed https pathname always starts with `/`, so
`publicOrigin + pathname` can't parse to another origin. The check stays because sage-1403 asks for it.
Either the check goes, or it stays as a guard no test can reach. That is Sage's call.
- **N1.** The design cite is now blob 9e3eadb15c80 (`git rev-parse 83b32d8d8:agents/sage/work/q175-sso/DESIGN.md`).
- **N2.** Listed under Deviations.
- **N3.** `SIGN_IN_COOKIE` has its own comment, above the row 177 comment and `TESTER_CHAT_WRITE`.
- **N4.** Row 200 (#1698) covers it: `stack-release.sh` refuses `config-console-key` before any stop when the
target's loader refuses the key, on activate and rollback.
- **N5.** No change.
Round 2 runs, this worktree, final tree: `sso.test.mjs` 28/28 (`/mnt/storage/scratch/launchpad-r192/sso-r2.log`);
mutants 22 and 23 (`/mnt/storage/scratch/launchpad-r192/mutants-r2.log`).
## Not done here
- Row 200 (#1698, `stack-release.sh activate --sso`) is chained on this candidate and starts only after the
manifest is with Webby (sage-1369).
- Full suites are for Sage's gate.
-1
View File
@@ -1 +0,0 @@
53bb59462120a03a9f54b02881895e3f575e501c
@@ -1,18 +0,0 @@
95d9a48812eebc415e3605239788e5b0746f1457a3bf0f9b9651a8c745e8b0f6 package.json
390cbb0ef88de6cbc6d8f43b9e212abfdf0ef0e1c88fa02f5ab790caabc53eed package-lock.json
726b71bcfdb4d9409cefc86bed2fbe8e678c6efb50bcf5bb22344aeda1eee201 packages/webui/README.md
f7b6b68906f13a644854b8083287dfa2936e8defc6207d05739e89763cc91440 packages/webui/src/chat-http.mjs
4a2d226bdbfee98b180103f71b3cb8122303cfac4aeece42bd30ce167ba6ff55 packages/webui/src/cli.mjs
41ee8798c34ee491b80ae1b42c3e96cd5d12fb1603b1ab88ac2dd10b5fb27964 packages/webui/src/public/app.js
e6c8f838eb7cdc2ae556c04451ca4c8f2d433117a17f285a144cfc7dff03e2a4 packages/webui/src/public/bus.js
f276fa440ec938638cb3e0f1b812fafa929460a6b96f1dab4cf1076a7c0c18ea packages/webui/src/public/index.html
333d474b1c7e753938aedc639c08f9db0e787faf56aa8068d013fd304a0b3b7e packages/webui/src/serve.mjs
37b01ab0479d5f2a7ab9aac790931c6d88c790465392d9c0b65a105513f3e36b packages/webui/src/sso-config.mjs
b624f044d361c648cefa161abe5f2b96e0cd8a83bc7816c9d501a2cd840988ca packages/webui/src/sso.mjs
84e31d4beba0de5ed6d192921bd7b016296c8b2a42b3fcf1176663e182320d9e packages/webui/tests/helpers/fake-oidc.mjs
ac01037743fcbf1efa641794d3878f113b6206033d2f66cde072a52ccfae1a51 packages/webui/tests/service.test.mjs
9358e3755e174b61532e2c528e733ffcdb48941cbadb825f1b0254bc55d1920e packages/webui/tests/sso-browser.test.mjs
3f8527fd7ae1ffb2bffce3450971f125bd616954b534577ef54249c87f36e248 packages/webui/tests/sso.test.mjs
f8307d4bd998634467fbed8748157754132b76affac051f320196161ce385e4a scripts/mosaic-config.mjs
3301c1fabf616bb961d62f6decd0a46dadb0f399b8ba7f3c2ca60023d2b24c78 scripts/test-config.sh
7678bb6b45861c3dc6d775fc99269b1db7350ced60b89517d6ebeb4cc161781c scripts/webui-service.sh
@@ -1,59 +0,0 @@
# Row 192 (#1690) mutants: `python3 mutants.py <tree> [N ...]`. Each applies to the tree's source, runs one
# test file (optionally one name pattern) and restores the file from a saved copy, checked by sha256.
# Killed = test exit != 0.
import hashlib, os, subprocess, sys, signal
W = os.path.join(os.path.abspath(sys.argv[1]), 'packages', 'webui')
if not os.path.isfile(os.path.join(W, 'src', 'sso.mjs')): sys.exit(f'ABORT no packages/webui/src/sso.mjs under {sys.argv[1]}')
S, B, C, F = 'src/sso.mjs', 'src/serve.mjs', 'src/sso-config.mjs', 'src/public/'
T, P = 'tests/sso.test.mjs', 'tests/sso-browser.test.mjs'
M = [
('drop validateApplicationLevelSignature (9b)', S, [("try { await oauth.validateApplicationLevelSignature(entry.as, response, options()); } catch (err) {", "try { } catch (err) {")], T, '^9:'),
('wrap the signature call in a continuing try/catch (9b)', S, [("try { await oauth.validateApplicationLevelSignature(entry.as, response, options()); } catch (err) {", "try { try { await oauth.validateApplicationLevelSignature(entry.as, response, options()); } catch {} } catch (err) {")], T, '^9:'),
('drop the subjects check (10)', S, [("if (name === undefined) {", "if (false) {")], T, '^10:'),
('accept HS* in algs (11)', C, [("export const ALGS = Object.freeze(['PS256',", "export const ALGS = Object.freeze(['HS256', 'none', 'PS256',")], T, '^11: start'),
('take the provider from the query (15)', S, [("const entry = record && providers.get(record.provider);", "const entry = record && providers.get(query.get('provider') ?? record.provider);")], T, '^15:'),
('let the tester listener read cookies (12, F8)', B, [("SIGN_IN_COOKIE.test(req.headers.cookie ?? '') ||", "")], T, '^12:'),
('tester listener takes Authorization (12, F8)', B, [("Object.hasOwn(req.headers, 'authorization') ||", "")], T, '^12:'),
('tester listener takes x-authentik-* (12, F8)', B, [(" || Object.keys(req.headers).some(h => h.startsWith('x-authentik-'))", "")], T, '^12:'),
('skip the login record delete (7)', S, [("if (key !== null) logins.delete(key);\n // The provider", "// The provider")], T, '^7:'),
('drop HttpOnly from the session cookie (21)', S, [("`${SESSION_COOKIE}=${id}; ${ATTRS}`", "`${SESSION_COOKIE}=${id}; Path=/; Secure; SameSite=Lax`")], T, '^20 and 21'),
('refresh seen on every request (13)', B, [("sso.session(req, { touch: req.method === 'POST' || (req.method === 'GET' && !path.startsWith('/api/')) })", "sso.session(req, { touch: true })")], T, '^13: idle'),
('build redirect_uri from the request Host (22)', S, [("const redirectUri = `${publicOrigin}/auth/callback`;", "let redirectUri = `${publicOrigin}/auth/callback`;"), (" const query = new URLSearchParams(search);\n", " const query = new URLSearchParams(search);\n redirectUri = `https://${req.headers.host}/auth/callback`;\n")], T, '^22:'),
('serve the loopback read path under --sso (22)', B, [("if (sso !== null && !named && !(path === '/healthz' && req.method === 'GET')) return json(res, 403, { error: 'public Host required' });", "")], T, '^22:'),
('bus.js fetchRead ignores 401 sign-in required (F1)', F+'bus.js', [(" if (signInRequired(res, body)) { signOutSso(); throw signedOut(); }\n if (!res.ok || !body || !Array.isArray(body.rows))", " if (!res.ok || !body || !Array.isArray(body.rows))")], P, '^a 401'),
('bus.js fetchApi ignores 401 sign-in required (F1)', F+'bus.js', [(" if (signInRequired(res, body)) { signOutSso(); throw signedOut(); }\n if (!res.ok || !body || typeof body !== 'object'", " if (!res.ok || !body || typeof body !== 'object'")], P, '^a 401'),
('app.js getJSON ignores 401 sign-in required (24)', F+'app.js', [(" if (signInRequired(res, body)) { sso = true; signOut(); throw signedOut(); }\n return { ok: res.ok, body };", " return { ok: res.ok, body };")], P, '^a 401'),
('app.js api() ignores 401 sign-in required (24)', F+'app.js', [(" if (signInRequired(res, result)) { sso = true; signOut(); throw signedOut(); }\n", "")], P, '^a 401'),
('signInRequired takes any 401 (24)', F+'app.js', [("res.status === 401 && body?.error === 'sign-in required'", "res.status === 401")], P, '^a 401'),
('Sign out shown without the sso condition (F7)', F+'app.js', [("if (config.sso === true) { sso = true; $('sign-out').hidden = out(); }", "{ $('sign-out').hidden = out(); }")], P, '^the Sign out'),
('form-action without the end-session origins (CSP)', B, [("form-action 'self'${sso ? sso.formTargets().map(o => ` ${o}`).join('') : ''}`", "form-action 'self'`")], T, '^13: the CSP'),
('import oauth4webapi at module load (copies)', S, [("let oauth;\n", "import * as oauth from 'oauth4webapi';\n"), (" oauth ??= await import('oauth4webapi');\n", "")], 'tests/pinned-copy.test.mjs', '^serve runs from a copy'),
('restore the r1 relative returnPath (14, webby-0019 B1)', S, [(" if (u.origin !== publicOrigin) return home;\n const out = new URL(`${publicOrigin}${u.pathname}${u.search}`);\n return out.origin === publicOrigin ? out.href : home;", " return u.origin === publicOrigin ? u.pathname + u.search : '/';")], T, '^14:'),
('drop the second origin check (14, sage-1403)', S, [(" return out.origin === publicOrigin ? out.href : home;", " return `${publicOrigin}${u.pathname}${u.search}`;")], T, '^14:'),
]
sha = lambda b: hashlib.sha256(b).hexdigest()
only = sys.argv[2:]
os.chdir(W)
survived = []
for i, (name, path, pairs, test, pat) in enumerate(M, 1):
if only and str(i) not in only: continue
orig = open(path, 'rb').read(); h = sha(orig)
src = orig.decode()
for a, b in pairs:
if src.count(a) != 1: sys.exit(f'ABORT {i} {name}: pattern found {src.count(a)} times')
src = src.replace(a, b)
try:
open(path, 'w').write(src)
p = subprocess.Popen(['node', '--test', f'--test-name-pattern={pat}', test], stdout=subprocess.PIPE, stderr=subprocess.STDOUT, start_new_session=True)
try: out, _ = p.communicate(timeout=240)
except subprocess.TimeoutExpired:
os.killpg(p.pid, signal.SIGKILL); out, _ = p.communicate(); out += b'\nTIMEOUT'
text = out.decode(errors='replace')
ran = [l for l in text.splitlines() if l.startswith('ℹ tests ')]
verdict = 'KILLED' if p.returncode != 0 else 'SURVIVED'
if verdict == 'SURVIVED': survived.append(name)
print(f'{i:2} {verdict} {name} [{ran[0] if ran else "no count"}]', flush=True)
finally:
open(path, 'wb').write(orig)
if sha(open(path, 'rb').read()) != h: sys.exit(f'ABORT restore failed for {path}')
print('survived:', survived or 'none')
-158
View File
@@ -1,158 +0,0 @@
# Row 200: `stack-release.sh activate --sso` (Q175 phase 2 prerequisite)
Launchpad, 2026-10-20. Author Launchpad, reviewer Webby, gate owner Sage. Issue #1698. Brief
`docs/plans/2026-10-20_stack-release-sso.md` (blob 426336b177f6). Rulings sage-1389 (R1 uses the activation onto
the current copy, plus a same-copy test and a latest-set mutant), sage-1403 and sage-1407 (N4), mos-1056 item 5
(N4 is a window acceptance item), sage-1432 (round 2 for webby-0028 N1 and N2).
Base 4d487dfdc (`base.txt`): aa1bd209e plus row 192's two commits picked. 48359dbfa is 83b32d8d8 (patch-id
8d11f48a9e03) and 4d487dfdc is r2 37582e820 (patch-id 0e32af8aa2a4 on both). The webui tree at the base matches
`launchpad/row192` at 37582e820 path for path. Candidate: r1 5dc0b5534 and the round 2 commit on `launchpad/row200`.
`candidate-manifest.sha256` lists its 3 paths; the packet files are not in it. Drift check at commit time:
`git log aa1bd209e..origin/refactor` (origin/refactor 9ba31b5b3 at round 2) touches none of the 3 paths.
No test touches `~/.mosaic-dev`, `~/.config/mosaic-dev`, `~/.mosaic`, a live unit or a live slot. T56 runs on
the scratch roots and seeded pointers rows 187 and 189 use. Its config and slot sit in the test's temp dir,
and the slot carries the `proofFile` read watch, so a run that opened it would fail the test.
## Files
| Path | Change |
| --- | --- |
| `scripts/stack-release.sh` | `--sso` on activate (usage, parse, `sso_for_target`), `sso_checks` and `slot_problem` (item 2), `sso_base` (rollback flags, item 3), `config_checks` (item 3 and N4), the `sso:` plan line and step lines. |
| `packages/cli/tests/stack-release.test.mjs` | Fixture copies gain row 192's `--sso` marker, an old loader without `console`, and a C copy whose `ALGS` lack RS256. T56, 10 subtests (2 in round 2). |
| `docs/TOOLS.md` | Usage line and two paragraphs after row 189's `--no-send`. |
## What it does
1. **Item 1.** `activate ID BUSINESS --sso` drops `--public-host`, `--auth-header`, `--allow-user` and any proof
pair, and puts `--sso` after the other flags, before `--send` and `--tester-port`, as `webui-service.sh`
renders it (`sso_for_target`, `:775`). It may name the live copy once. After that, both a plain activate and
an `--sso` activate refuse `already-active`. Later activates carry `--sso` (`sso: carried`; T56 covers
activate, not `pm-on`, which carries the pointer's flags as it does for every flag).
`--plan` prints `sso: on; drops ...; carries ...`, plus the proof line's `dropped; --sso drops header ...`.
`--sso` with a pair, with `--no-proof` or on another verb is a usage error.
2. **Item 2** (`sso_checks`, `:817`). These checks run whenever the run's Console flags hold `--sso`, before
anything stops:
- `sso-target`: the target's `cli.mjs` has no `--sso`.
- `sso-config`: the target copy's own `checkConsole` refuses `console`, or there is no `console.sso`. The
config is parsed in node, and only the message's first line is kept. A JSON parse error never quotes
the text.
- `sso-slot`: one per provider, from `lstat`, `stat` and `realpath` in `readSlot`'s order (`slot_problem`,
`:847`). The slot is never opened.
3. **Item 3.**
- **Rollback** (`sso_base`, `:728`): from an `--sso` pointer to a copy without `--sso`, the Console flags
come from the rollback set of the activation that moved the stack onto the pointer's copy from the
target (`from_log arrival`). That set's `show-mosaic-webui.service.txt` is checked against its
`SHA256SUMS`. `--send` and `--tester-port` follow the pointer.
- **`sso-rollback-flags`** refuses when that set is missing, fails its sums, holds no readable argv, or
already ran `--sso`.
- **`config-console-key`** (`config_checks`, `:866`): any activate or rollback refuses before anything
stops when the config has a `console` key that the target's `scripts/mosaic-config.mjs validate` refuses.
The message says to remove the key first (restore step 1's backup), then run the verb again.
4. **N4, the first half** (sage-1403, sage-1407). With no `console.sso` and the stack on another copy, the
`sso-config` refusal names the first step: "activate ID without --sso first, then write console.sso, then
run activate ID --sso". A copy before row 192 refuses the key, so the re-pin comes first. The `--sso`
activate is then the allowed same-copy re-pin.
5. **TOOLS.md.** The flag, its refusals, the rollback rule and the N4 order.
## Window plan acceptance item (N4, mos-1056 item 5, sage-1407)
For the row 200 window plan, word for word:
- **Before the `console` key is written, the window re-pins to row 192 or later.** Check line, run before the
key is written: `git -C /mnt/storage/src/mosaic-stack merge-base --is-ancestor <row 192 landing sha>
<pointer copy sha from scripts/stack-release.sh status acme> && echo REPIN_OK`. It must print `REPIN_OK`.
If the window instead runs `activate <ID> acme --sso --plan` before the key is written, the
`refuses sso-config:` line must end with "activate <ID> without --sso first, then write console.sso, then run
activate <ID> --sso". It names that step only when the pointer is on another copy.
- **A rollback past row 192 removes the key first.** Check line, run before the rollback:
`scripts/stack-release.sh rollback acme --plan` must print no `refuses config-console-key:` line. If it
prints one, restore `config.json` from the window's step 1 backup and run the plan again. The rollback itself
refuses on the same check, so a skipped step stops it before anything stops.
- **Back-out line (webby-0028 N4, sage-1432).** Once a copy with `--sso` support is live, no verb turns `--sso`
off. Only a rollback to a copy before row 192 gets the preview set back, after the `console` key is removed
(the check line above).
## Tests run (this worktree, final tree)
- `node --test --test-name-pattern='^T56' tests/stack-release.test.mjs`: 10/10 on the round 2 tree
(`/mnt/storage/scratch/launchpad-r200/t56-r2c.log`). Round 1 was 8/8 (`t56-6.log`).
- The whole file, the cli suite and every `scripts/test-*.sh` go to Sage's gate. T1 to T55 have not run on
this tree.
## Mutants (`mutants.py`, run as `python3 mutants.py <tree> [N ...]`, each against T56)
| # | Mutant | Result |
| --- | --- | --- |
| 1 | no `sso-target` refusal (item 2) | KILLED |
| 2 | no `sso-config` refusal (item 2) | KILLED |
| 3 | no `sso-slot` refusal (item 2) | KILLED |
| 4 | no `sso-rollback-flags` refusal (item 3) | KILLED |
| 5 | no `config-console-key` refusal (item 3) | KILLED |
| 6 | keep `--auth-header` under `--sso` (item 1) | KILLED |
| 7 | keep `--public-host` under `--sso` (item 1) | KILLED |
| 8 | rollback takes the latest activation onto the copy, not the arrival (sage-1389) | KILLED |
| 9 | `sso-config` names no first step (N4) | KILLED |
| 10 | `slot_problem` compares the unresolved `REPO` (round 2, N1) | not run, Sage's gate |
| 11 | `proof_file_problem` compares the unresolved `REPO` (round 2, N1) | not run, Sage's gate |
| 12 | `config_checks` passes a config that doesn't parse (round 2, N2) | not run; equivalent if it survives, guard kept (sage-1446) |
9/9 killed (`/mnt/storage/scratch/launchpad-r200/mutants.log`). The script restored `stack-release.sh`, and it
matches its pre-run sha256. No mutant hit the 300 s timeout. This batch ran without Sage's slot (sage-1414),
while Darkwing's gate was running, so Sage reruns all 9 on the row 200 gate and doesn't take these results
(sage-1423). Mutants 10 to 12 are new in round 2 and have not run. Sage runs 1 to 12 on the gate (sage-1446). Mutant 5's
pattern now names the `holds` message, because the round 2 message also starts with `config-console-key "$cfg`.
Every pattern matches once on the round 2 tree.
## Choices
- **R1, `--send` and `--tester-port` follow the pointer.** These two flags come from the pointer, not from the
rollback set, so a `--no-send` or a tester port set after the arrival stays. This is row 189's rule that
rollback carries `--send` off. Tradeoff: the rolled-back Console differs from the set's unit in those two
flags, and the plan line says so.
- **`config_checks` runs for activate too**, not only rollback. An activate onto a pre-192 copy with the key
in place breaks that copy's loader the same way.
- **The inside-repository slot test** symlinks the secrets directory to a temp dir under the fixture repo. The
slot file is then not a link, but its real path is inside the repository.
## Not tested
- The slot owner-uid refusal: it needs a file owned by another uid, which needs privilege.
## Review r1 notes (webby-0028, non-blocking)
- **N1.** `slot_problem` (`:859`) and row 187's `proof_file_problem` (`:988`) compare a resolved path to
`REPO="$(pwd)"` (`:26`), which is not resolved. Run through the `~/src/mosaic-stack-dev-test` symlink, a slot
inside the repository passes here, and the Console refuses it after the stop. Confirmed by reading; Sage
rules on a round 2 or a follow-up.
- **N2.** `config_checks` (`:868`) returns 0 when config.json doesn't parse. Same ruling.
- **N3.** A check this script doesn't make: `slot_problem` refuses only a size of 0. A slot holding only a
newline, or two lines, passes here, and the Console's one-non-empty-line rule (`readSlot`) refuses it after
the stop. Reading the content would break the rule that this script never opens the slot.
- **N4, for the window's back-out step.** Once the live copy supports `--sso`, no verb turns `--sso` off on it.
The only way back to the preview set is a rollback to a copy before row 192, which removes the `console` key
first (the second check line above).
## Round 2 (sage-1432: N1 and N2; N3 and N4 stay notes)
- **N1.** `slot_problem` (`:847`) and `proof_file_problem` (`:989`) now compare the slot's real path with
`realpath -m` of both `REPO` and `COPIES`, and refuse "cannot be resolved" if either fails. T56's
"round 2 (webby-0028 N1)" subtest runs the script through a symlink to the fixture repo
(`repo-link.200/scripts/stack-release.sh`). A proof file inside the repository is refused `proof-file-invalid`,
and a slot whose secrets directory links into the repository is refused `sso-slot` "is inside the repository
or the copies". Mutants 10 and 11 undo each check.
- **N2, with a deviation.** `config_checks` (`:867`) now tells "no key" (node exits 1) from "doesn't parse"
(exits 2) and refuses `config-console-key` "cannot be read as JSON" on the second. But webby-0028's case
doesn't reach it: `load_config` (common.sh, through `scripts/mosaic-config.mjs env`) exits 1 on a config that
doesn't parse, before anything is read or stopped. So the refusal is a guard for a config that changes after
`load_config` ran, and no test reaches it without a race. The T56 subtest "round 2 (webby-0028 N2)" checks
what does happen: activate, rollback and `activate --plan` each exit 1 with "common: configuration load
failed", the pointer, the start/stop log and the activation log are unchanged, and activate works after the
config is restored. Mutant 12 drops the guard and is expected to survive as equivalent for that reason. sage-1446:
the guard stays, and mutant 12 is equivalent if it survives, the same call as row 192's mutant 23.
- **N4.** The back-out line is in the window plan section above.
## Not done here
- No change to the live pointer, units or config. The phase 2 window is Sage's and Mos's.
-1
View File
@@ -1 +0,0 @@
4d487dfdc468c58e3790976c3d4276fe60816fbe
@@ -1,3 +0,0 @@
6ee1adb3951aeb1e3862cb119c12b600b88641f325912c09840b841761aa12dd docs/TOOLS.md
9b231532d14b21d0d39ca89035d9067e827e18cc1cf3dfe298bef840ae87ff0c packages/cli/tests/stack-release.test.mjs
d24e75e9927623ad106b55bce82e40ddf7677d483626fb46923c3b3c7c1148ad scripts/stack-release.sh
@@ -1,48 +0,0 @@
# Row 200 (#1698) mutants: `python3 mutants.py <tree> [N ...]`. Each applies to the tree's scripts/stack-release.sh,
# runs T56 in packages/cli/tests/stack-release.test.mjs and restores the file from a saved copy, checked by sha256.
# Killed = test exit != 0.
import hashlib, os, subprocess, sys, signal
R = os.path.abspath(sys.argv[1])
S = os.path.join(R, 'scripts', 'stack-release.sh')
if not os.path.isfile(S): sys.exit(f'ABORT no scripts/stack-release.sh under {sys.argv[1]}')
T, PAT = 'tests/stack-release.test.mjs', '^T56'
M = [
('no sso-target refusal (item 2)', [(" sso_supported || { check_refuse sso-target", " true || { check_refuse sso-target")]),
('no sso-config refusal (item 2)', [(' check_refuse sso-config "$TO', ' : "$TO')]),
('no sso-slot refusal (item 2)', [('[ -z "$why" ] || check_refuse sso-slot', '[ -z "$why" ] || : sso-slot')]),
('no sso-rollback-flags refusal (item 3)', [(' check_refuse sso-rollback-flags "$TO', ' : "$TO')]),
('no config-console-key refusal (item 3)', [(' check_refuse config-console-key "$cfg holds', ' : "$cfg holds')]),
('keep --auth-header under --sso (item 1)', [(" --public-host | --auth-header | --allow-user)", " --public-host | --allow-user)")]),
('keep --public-host under --sso (item 1)', [(" --public-host | --auth-header | --allow-user)", " --auth-header | --allow-user)")]),
('rollback takes the latest activation onto the copy, not the arrival (sage-1389)', [('o.to===current&&o.from!==current){process.stdout.write(`${o.activation', 'o.to===current){process.stdout.write(`${o.activation')]),
('sso-config names no first step (N4)', [(' first="; activate $TO without --sso first, then write console.sso, then run activate $TO --sso"', ' first=""')]),
('slot_problem compares the unresolved REPO (round 2, webby-0028 N1)', [(' case "$real" in "$repo" | "$repo"/* | "$copies" | "$copies"/*) echo "is inside the repository or the copies"; return ;; esac\n [ "$size" != 0 ]', ' case "$real" in "$REPO" | "$REPO"/* | "$copies" | "$copies"/*) echo "is inside the repository or the copies"; return ;; esac\n [ "$size" != 0 ]')]),
('proof_file_problem compares the unresolved REPO (round 2, webby-0028 N1)', [(' case "$real" in "$repo" | "$repo"/* | "$copies" | "$copies"/*) echo "is inside the repository or the copies"; return ;; esac\n [ "$mode" = 600 ]', ' case "$real" in "$REPO" | "$REPO"/* | "$copies" | "$copies"/*) echo "is inside the repository or the copies"; return ;; esac\n [ "$mode" = 600 ]')]),
('config_checks passes a config that does not parse (round 2, webby-0028 N2; expected equivalent)', [(' [ "$key" != 1 ] || return 0', ' [ "$key" = 0 ] || return 0')]),
]
sha = lambda b: hashlib.sha256(b).hexdigest()
only = sys.argv[2:]
os.chdir(os.path.join(R, 'packages', 'cli'))
survived = []
for i, (name, pairs) in enumerate(M, 1):
if only and str(i) not in only: continue
orig = open(S, 'rb').read(); h = sha(orig)
src = orig.decode()
for a, b in pairs:
if src.count(a) != 1: sys.exit(f'ABORT {i} {name}: pattern found {src.count(a)} times')
src = src.replace(a, b)
try:
open(S, 'w').write(src); os.chmod(S, 0o755)
p = subprocess.Popen(['node', '--test', f'--test-name-pattern={PAT}', T], stdout=subprocess.PIPE, stderr=subprocess.STDOUT, start_new_session=True)
try: out, _ = p.communicate(timeout=300)
except subprocess.TimeoutExpired:
os.killpg(p.pid, signal.SIGKILL); out, _ = p.communicate(); out += b'\nTIMEOUT'
text = out.decode(errors='replace')
ran = [l for l in text.splitlines() if l.startswith('ℹ fail ')]
verdict = 'KILLED' if p.returncode != 0 else 'SURVIVED'
if verdict == 'SURVIVED': survived.append(name)
print(f'{i:2} {verdict} {name} [{ran[0] if ran else "no count"}]', flush=True)
finally:
open(S, 'wb').write(orig); os.chmod(S, 0o755)
if sha(open(S, 'rb').read()) != h: sys.exit(f'ABORT restore failed for {S}')
print('survived:', survived or 'none')
+10
View File
@@ -0,0 +1,10 @@
# Quackerjack
Builder seat, Claude Haiku 5.5 at high effort, started by Sage under mos-1023 item 1 and Q177 for federation
rows R5 (`mosaic access list, grant, revoke`) and R2 (the `federation` config key), per Gizmoduck's design
section 7 (#414).
No launcher in this directory. The seat runs as a T3 thread and works in its own worktree. It has no
credentials of its own; until the credentials row settles per-agent identity, it pushes only its own
`quackerjack/*` branches through the repository credential helper (mos-0981), never `refactor`, `main` or
another seat's branch.
+1
View File
@@ -1363,3 +1363,4 @@ are never rewritten or removed; corrections are new entries.
2026-10-20T18:41:20Z | Filbert (Opus 5.5, 048f3c63) | Q175 SSO design D1/D2 check (DESIGN.md blob eba861f5d2bb at d55d86bb0), static git diff 817e8f51b..d55d86bb0 | ack: D1 and D2 match; design approved; Mos C1 sequence confirm still open; no Gitea/queue record
2026-10-20T18:57:10Z | Filbert (Opus 5.5, 048f3c63) | row 190 design review r2 (#1688, DESIGN.md sha256 5493c9df3ec0) | CHANGES to Darkwing, cc Sage: B1 claim check on MOSAIC_CONFIG-only selection, B2 shared prefix drop-in dirs; N1-N7 notes; static read, no slot, no Gitea or queue record
2026-10-20T19:00:04Z | Filbert (Opus 5.5, 048f3c63) | row 190 design review r3 (#1688, DESIGN.md sha256 98236a1aff98) | APPROVE to Darkwing, cc Sage: B1 and B2 closed with M13 and M14, N7 settled by Sage's 48ebee0ff; static read, no slot; manifest round gets a queue review record (sage-1337)
2026-10-20T19:57:00Z | Gizmoduck (Opus 5.5, 9312a896) | row 201 FED R1 (#1699, #414) round 1: packages/business/src/access.mjs, index export, tests/access.test.mjs, packet agents/gizmoduck/work/queue-201 | 24/24 single file; 44 mutants written, not run (needs Sage's slot); expiry per sage-1399; to Duckworth for manifest review
+1 -31
View File
@@ -368,7 +368,7 @@ reports checkout or copy identity and refactor drift, and writes nothing.
```
scripts/stack-release.sh adopt BUSINESS
scripts/stack-release.sh activate ID BUSINESS [--pm | --pm-after-harness] [--migrate] [--tester-port N]
[--proof-header NAME --proof-file PATH | --no-proof | --sso] [--no-send] [--timeout S] [--plan]
[--proof-header NAME --proof-file PATH | --no-proof] [--no-send] [--timeout S] [--plan]
scripts/stack-release.sh pm-on BUSINESS [--timeout S]
scripts/stack-release.sh rollback BUSINESS [--restore ACTIVATION [--events N]] [--timeout S] [--plan]
scripts/stack-release.sh status BUSINESS
@@ -488,36 +488,6 @@ rollbacks carry the pointer, so send stays off. No flag puts `--send` back.
`--plan` prints one `send:` line: `dropped`, `off` with or without the
`--no-send` note, or `carried`.
`activate ... --sso` (row 200, #1698) starts the Console with Q175 sign-in
(row 192). It drops `--public-host`, `--auth-header`, `--allow-user` and any
proof pair from the pointer's Console flags and the unit, and puts `--sso` after
the other flags, before `--send` and `--tester-port`. It may name the live copy
once, so a copy already at row 192 or later turns sign-in on without a copy
change. Later activates, `pm-on` and rollbacks to a copy with `--sso` carry it.
`--sso` with a proof pair or `--no-proof`, or on any other verb, is a usage
error. Before anything stops, a run whose Console flags hold `--sso` refuses
`sso-target` for a target Console without it, `sso-config` when the target's own
`checkConsole` refuses the config's `console` key (or there is none, in which
case, from another copy, it names the first step: activate the target without
`--sso`, then write `console.sso`), and `sso-slot` for each provider's slot file
that is missing, a symlink, not a regular file, not owned by this uid, has group
or other bits, resolves inside the repository or the copies, or is empty. It
uses `lstat`, `stat` and `realpath` on the slot and never reads it. `--plan`
prints one `sso:` line: `on` with what it drops and carries, `carried`, or
`dropped` on a rollback.
A rollback from an `--sso` pointer to a copy whose Console lacks `--sso` takes
the Console flags from the rollback set of the activation that moved the stack
onto the pointer's copy from the target, past any same-copy re-pin, checked
against the set's `SHA256SUMS`. `--send` and `--tester-port` follow the pointer.
When that set is missing, fails its sums, holds no readable argv or already ran
`--sso`, the run refuses `sso-rollback-flags`. Any activate or rollback also
refuses `config-console-key` before anything stops when the config holds a
`console` key the target's `scripts/mosaic-config.mjs validate` refuses (a copy
before row 192 refuses the key). The message says to remove the key first. So a
window re-pins to row 192 or later before it writes the key, and a rollback past
row 192 removes the key first.
#### A target past c216f47db: the PM after the harness kind
From c216f47db on, the bus launches the PM only with a `harness` kind in the
@@ -0,0 +1,100 @@
# Federation build rows R1, R2 and R5 (#414)
Sage, 2026-10-20, for Mos's orders mos-1023 item 1, mos-1028 item 2 and mos-1034 item 1. The design is
Gizmoduck's `agents/gizmoduck/work/federation/DESIGN.md` at a1daa3c07 on `gizmoduck/federation-design` (r4.9,
sha256 a31cbebd070f9e3a5ca4ccb158b1747c6f5a83c3469855705b0026a581fb5c6a). Duckworth approved r4.5
(duckworth-0012), r4.6, r4.7 (duckworth-0020) and r4.8 (duckworth-0021, 8a22d0897 on `duckworth/reviews`),
and acked r4.9 (duckworth-0024). r4.7 applies Jason's Q181 hosts, r4.8 adds N22's R8 checks and r4.9 names who
runs them. None of them changes R1, R2 or R5. Section 7's table
sets each row's scope and test gate. Vikunja P19 #414. Seats push only their own `<seat>/*` branches
(mos-0981).
The other R rows are filed when their parents have candidates: R3 after R1 and row 196, R4 after R3, R5 and
row 193's viewer level (193a, mos-1019), R6 after #412 phase 1, R1, R2 and R3, R7 after R1-R6 and row 190.
R8 is a live window, not a code row. Its brief is the last section here.
## Brief: FED R1, the access.json loader and validator
Owner Gizmoduck, reviewer Duckworth, gate owner Sage. Base: refactor at this brief's commit. No parent.
`packages/business/src/access.mjs`: the access.json loader and validator from DESIGN.md section 3.5, and the
pure `covers`. Providers, peers and businesses come in as arguments. One read plus the file checks. The
exported interface is what R5 and row 193a build against, so the manifest names it, and any change to it in
review goes to Sage before it lands. Section 7 names `covers` but not the loader and validator exports or
the context shape. Morgana's 193a (508ee4d61 on `morgana/row193a`) assumes `checkAccess(doc, {providers,
peers, businesses})`, with `businesses` mapping an id to `{human, instances}`, throwing a `BusinessError` that
names the key path. Use those names, or tell Morgana and Quackerjack yours before the manifest goes to
Duckworth. Row 193a later widens the level check to `viewer` on R1's candidate (mos-1019).
Tests, in `packages/business/tests/`: every rule in 3.5 refuses with the key path named. That covers a
symlink, a non-regular file, another owner, group or other write, expiry, `*`, a grant disjoint from the given
subjects, `human` and `tester` ids, any level other than `collaborator`, and a project entry that isn't a
UUID. For `covers`: `*` and listed ids match, while an unlisted project, instance or business doesn't, an
expired grant doesn't, and a level filter rejects another level. One mutant per refusal, each killed.
Boundaries: no change to `~/.mosaic-dev`, `~/.config/mosaic-dev`, the live units or any live access.json. Tests
use scratch roots. Single test files run freely, and suites need Sage's slot.
Gate: Duckworth approves the manifest. The business suite and every `scripts/test-*.sh` are green on Sage's
gate, and the mutants are killed.
## Brief: FED R5, mosaic access list, grant and revoke
Owner Quackerjack, reviewer Duckworth, gate owner Sage. Chained on R1 (Q177). The build starts now against
R1's exported interface as the R1 manifest names it, and R5 lands after R1 and rebases onto R1's landing.
`mosaic access list`, `grant` and `revoke`: human proof, atomic write, revision bump, a receipt line. The help
text states the `instances` limit and the provider-removal order (DESIGN.md section 7, row R5). R4 later makes
these verbs call `access.reload`, so R5 leaves no other reload path.
Tests, in `packages/cli/tests/`: tester and agent callers are refused, a concurrent write refuses on a
revision conflict, the receipt line is appended, and a bad grant is refused before anything is written. One
mutant per refusal, each killed.
Boundaries: no change to `~/.mosaic-dev`, `~/.config/mosaic-dev`, the live units or any live access.json. The
human proof in tests uses the scratch stand-in pattern (a scratch bus verifies its own human-cli path).
Single test files run freely, and suites need Sage's slot.
Gate: Duckworth approves the manifest. The cli suite and every `scripts/test-*.sh` are green on Sage's gate,
and the mutants are killed.
## Brief: FED R2, the federation key in the system config
Owner Quackerjack, reviewer Webby, gate owner Sage. Chained on row 192 (#412 phase 1). The build starts when
row 192 has a candidate, on that candidate.
A `federation` key in `scripts/mosaic-config.mjs` with the shape in DESIGN.md section 3. A config with no
`federation` key still loads as it does today. This changes the config schema, so the manifest lists every
reader of the key.
Tests, in `scripts/test-config.sh`: a valid key loads, an unknown sub-key and a bad origin refuse, and a
config without the key loads. One mutant per refusal, each killed.
Boundaries: `~/.config/mosaic-dev/config.json` is user-authored and is not touched. Tests use scratch config
dirs only.
Gate: Webby approves the manifest. The cli suite and every `scripts/test-*.sh` are green on Sage's gate, and
the mutants are killed.
## Brief: FED R8, the hub window on 10.1.1.54
A live window, not a code row (DESIGN.md section 7, row R8, r4.9). Run by Jason and Mos. Nothing here
authorizes a step on any host; each step waits for the window's go.
Order (mos-1042 item 2). R8 runs after Bean and Chet are live in Case 2 and both Hermes gateways on 10.1.1.54
are stopped, because R8 requires that the hub host runs no other instance or agent and Q181 keeps Melanie's
Hermes on .54 until then. Case 2 go-live doesn't wait on R8. The risk, stated for 10-27: federation between
the two instances now depends on Melanie's switch-over date.
Entry checks, all before anything starts:
- R7, #412 phase 2, the hub host live (Vikunja #418) and Jason's #419 answer, as section 7 lists.
- The four hub user checks on 10.1.1.54 (N22, mos-1035 item 1): `sudo -l -U mosaic` grants nothing, `id
mosaic` shows only its own group, `~mosaic/.ssh/authorized_keys` is absent or empty, and no file under
`/etc/sudoers.d` names `mosaic`. Jason runs them as localadmin with his new key (#421 step 1), and the output
goes into the R8 record. No factory uid runs them, because R8 requires every factory root path to .54 to
fail (r4.9, mos-1037).
- The factory privilege checks, the key fingerprint comparison and the section 8 privilege path table, each
as section 7's R8 row states them.
Done when every entry check has its output recorded, Jason signs in to the hub and sends one message, Melanie
revokes, and his next request is refused.
@@ -0,0 +1,66 @@
# Row 193 build rows: 193a, 193b1, 193b2, 193c and 193d (#1691)
Sage, 2026-10-20, for Mos's go mos-1034 item 4. Row 193's design is Morgana's
`agents/morgana/work/queue-193/DESIGN.md` at e2d161e2e on `morgana/row193` (r5, sha256
dd7fcb419ed8510e9d8d74b569183386d0942af8b68eecc0bbb5e182e677b523), approved by Duckworth (duckworth-0018).
Section 8 lists the tests and mutants, and section 9 the split. The brief behind them is
`docs/plans/2026-10-20_human-users.md`. Vikunja P19 #414/#415, Gitea #1691.
Owner Morgana for every row, reviewer Duckworth, gate owner Sage. Each row chains on its parent's candidate
(Q177) and rebases onto the parent's landing. Seats push only their own `<seat>/*` branches (mos-0981).
Single test files run freely, and suites and mutant batches need Sage's slot.
Boundaries for every row: no change to `~/.mosaic-dev`, `~/.config/mosaic-dev`, the live units, slot files, any
live access.json or `~/.mosaic`. No request to the live Console, mosaic.woltje.com or auth.diversecanvas.com.
No user thread goes live before 193b1's message filter has landed (mos-1021 B2, FED R8).
The owner-visible records are fixed by Mos (mos-1023 item 2) and named in one user sentence, which FED R6
reuses word for word: "The business owner sees your thread's agent's decisions, inbox entries, claims, tasks
and project files, but not your messages."
## Brief: 193a, the access level check widened to viewer
Chained on FED R1. Started early on `morgana/row193a` (508ee4d61) against R1's interface as the FED brief
names it, with Sage's go (sage-1373). R1's level check accepts `viewer` and still refuses any other new
level. Tests in `packages/business/tests/` (section 8, Business), with the four mutants in Morgana's
BUILD.md. FED R4 chains on this row.
Gate: Duckworth approves the manifest. The business suite and every `scripts/test-*.sh` are green on Sage's
gate, and the mutants are killed.
## Brief: 193b1, viewer verbs, board refusals and the message filter
Chained on FED R3, FED R4 and row 196's verifier. The bus gets the viewer verb list, per-grant level
checks, board reads refused to users, and the section 5.7 `trail` and `replies` filter with `#readable`
owner-only. No schema change. The governance pin test names each item in the user sentence above. Bus tests
and mutants 1, 3, 6, 10, 11 and 12 from section 8.
Gate: Duckworth approves the manifest. The bus suite and every `scripts/test-*.sh` are green on Sage's gate,
and the mutants are killed.
## Brief: 193b2, thread shares (schema 3j)
Chained on 193b1. `thread_shares`, the share verbs, the share rules of `#readable` and `#threadRead`, and the
section 5.5 share filters. Schema 3i to 3j. The table is append-only, and row 181's restore guard and
`COUNTED` include it. Bus tests for shares and the migration, and mutants 2, 4, 7, 8 and 9 from section 8.
Going live needs a `--migrate` window with Jason. Sage takes it to Mos when this row is near.
Gate: Duckworth approves the manifest. The bus suite, the cli suite and every `scripts/test-*.sh` are green on
Sage's gate, and the mutants are killed.
## Brief: 193c, mosaic access user verbs and --level viewer
Chained on FED R5. `mosaic access user add/remove` and `grant --level viewer`, only where R5 lacks them. They
refuse tester and agent callers, write a receipt line and call `access.reload` (section 8, CLI). If R5
already has all of them, this row records that and closes with no code.
Gate: Duckworth approves the manifest. The cli suite and every `scripts/test-*.sh` are green on Sage's gate,
and the mutants are killed.
## Brief: 193d, the Console's shared list, share controls and viewer read-only
Chained on FED R6, row 192 and 193b2 (for share controls). A viewer has no composer or share control, and
with each UI check removed the bus still refuses (section 8, Webui).
Gate: Duckworth approves the manifest. The webui suite and every `scripts/test-*.sh` are green on Sage's
gate, and the mutants are killed.
+1 -11
View File
@@ -9,8 +9,7 @@
"version": "0.0.3",
"license": "UNLICENSED",
"dependencies": {
"@earendil-works/pi-coding-agent": "0.85.1",
"oauth4webapi": "3.8.8"
"@earendil-works/pi-coding-agent": "0.85.1"
}
},
"node_modules/@anthropic-ai/sdk": {
@@ -1934,15 +1933,6 @@
"url": "https://opencollective.com/node-fetch"
}
},
"node_modules/oauth4webapi": {
"version": "3.8.8",
"resolved": "https://registry.npmjs.org/oauth4webapi/-/oauth4webapi-3.8.8.tgz",
"integrity": "sha512-8N28E+a/oxfXWBgOMt+ZP/JUf/XR+IFbvkAEPP3gznXOMv9BpAAwiIj0TFNz3tGTPc0ZQ8zmWBNgN1nAys0gng==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/panva"
}
},
"node_modules/openai": {
"version": "6.40.0",
"resolved": "https://registry.npmjs.org/openai/-/openai-6.40.0.tgz",
+1 -2
View File
@@ -5,7 +5,6 @@
"description": "Minimal Mosaic Stack container proof of concept: one Pi agent, four local contract files, one real model request returning MOSAIC_HELLO_OK.",
"license": "UNLICENSED",
"dependencies": {
"@earendil-works/pi-coding-agent": "0.85.1",
"oauth4webapi": "3.8.8"
"@earendil-works/pi-coding-agent": "0.85.1"
}
}
+185
View File
@@ -0,0 +1,185 @@
// The access file, <configDir>/access.json (federation design section 3.5,
// FED R1, #414). It names the extra users who may sign in to this instance
// and the grants that let them reach a business's threads. The validator is
// pure: providers, peers, businesses and the owner's sign-in subjects come in
// as arguments. Any failed rule refuses the whole file, which leaves every
// user with no grant (fail closed).
import { join } from "node:path";
import { refuse } from "./errors.mjs";
import { configDir } from "./business.mjs";
import { isPlainObject, requireObject, rejectUnknownKeys, requireId, requirePositiveInt, readJsonFile, deepFreeze } from "./util.mjs";
// The grant levels. Row 193a widens this to collaborator and viewer.
export const LEVELS = Object.freeze(["collaborator"]);
// Ids a user may never take: the bus's own principals.
const RESERVED_USERS = Object.freeze(["human", "tester"]);
const LOCAL_HOME = "local";
const ALL = "*";
const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
const ISO_TIME = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d{1,3})?Z$/;
const SUB_MAX = 255;
export function accessFilePath(dir = configDir()) {
return join(dir, "access.json");
}
function requireArray(value, where) {
if (!Array.isArray(value)) refuse(`${where} must be an array`);
return value;
}
function requireText(value, where) {
if (typeof value !== "string" || value.length === 0 || value.length > SUB_MAX || value.includes("\0")) {
refuse(`${where} must be a non-empty string of at most ${SUB_MAX} characters`);
}
return value;
}
function subjectKey(provider, sub) {
return JSON.stringify([provider, sub]);
}
function checkContext(context) {
const where = "access context";
requireObject(context, where);
rejectUnknownKeys(context, ["providers", "peers", "businesses", "ssoSubjects"], where);
const providers = new Set(requireArray(context.providers, `${where}.providers`).map((p, i) => requireText(p, `${where}.providers[${i}]`)));
const peers = new Set(requireArray(context.peers, `${where}.peers`).map((p, i) => requireId(p, `${where}.peers[${i}]`)));
if (peers.has(LOCAL_HOME)) refuse(`${where}.peers must not name ${JSON.stringify(LOCAL_HOME)}`);
requireObject(context.businesses, `${where}.businesses`);
const businesses = new Map();
for (const [id, business] of Object.entries(context.businesses)) {
const at = `${where}.businesses.${id}`;
requireId(id, `${where}.businesses key`);
requireObject(business, at);
requireId(business.human, `${at}.human`);
const instances = requireArray(business.instances, `${at}.instances`).map((x, i) => requireId(x, `${at}.instances[${i}]`));
businesses.set(id, { human: business.human, instances: new Set(instances) });
}
const owners = new Set();
requireArray(context.ssoSubjects, `${where}.ssoSubjects`).forEach((s, i) => {
const at = `${where}.ssoSubjects[${i}]`;
requireObject(s, at);
owners.add(subjectKey(requireText(s.provider, `${at}.provider`), requireText(s.sub, `${at}.sub`)));
});
return { providers, peers, businesses, owners };
}
function checkUser(user, at, ctx, humans, seenSubjects) {
requireObject(user, at);
rejectUnknownKeys(user, ["id", "home", "subjects"], at);
requireId(user.id, `${at}.id`);
if (RESERVED_USERS.includes(user.id)) refuse(`${at}.id must not be ${JSON.stringify(user.id)}, a bus principal`);
if (humans.has(user.id)) refuse(`${at}.id must not be a business's human (${JSON.stringify(user.id)})`);
if (user.home !== LOCAL_HOME && !ctx.peers.has(user.home)) {
refuse(`${at}.home must be a configured peer or ${JSON.stringify(LOCAL_HOME)} (got ${JSON.stringify(user.home)})`);
}
const subjects = requireArray(user.subjects, `${at}.subjects`);
if (subjects.length === 0) refuse(`${at}.subjects must not be empty`);
subjects.forEach((s, i) => {
const sat = `${at}.subjects[${i}]`;
requireObject(s, sat);
rejectUnknownKeys(s, ["provider", "sub"], sat);
if (typeof s.provider !== "string" || !ctx.providers.has(s.provider)) {
refuse(`${sat}.provider must be a configured provider (got ${JSON.stringify(s.provider)})`);
}
requireText(s.sub, `${sat}.sub`);
const key = subjectKey(s.provider, s.sub);
if (ctx.owners.has(key)) refuse(`${sat} is an owner sign-in subject (console.sso.subjects)`);
if (seenSubjects.has(key)) refuse(`${sat} appears more than once in the file`);
seenSubjects.add(key);
});
}
// Either ["*"] alone or a non-empty list of distinct entries, each checked.
function checkScope(value, where, check) {
const list = requireArray(value, where);
if (list.length === 0) refuse(`${where} must not be empty`);
if (list.includes(ALL)) {
if (list.length !== 1) refuse(`${where} must be ["*"] alone or list entries without "*"`);
return;
}
const seen = new Set();
list.forEach((entry, i) => {
check(entry, `${where}[${i}]`);
if (seen.has(entry)) refuse(`duplicate entry in ${where}: ${JSON.stringify(entry)}`);
seen.add(entry);
});
}
function checkExpires(value, where) {
if (typeof value !== "string" || !ISO_TIME.test(value) || Number.isNaN(Date.parse(value))) {
refuse(`${where} must be an ISO time such as 2026-12-31T00:00:00Z (got ${JSON.stringify(value)})`);
}
const iso = new Date(value).toISOString();
if (iso.slice(0, 19) !== value.slice(0, 19)) refuse(`${where} is not a real time: ${value}`);
}
function checkGrant(grant, at, ctx, users, seenGrants) {
requireObject(grant, at);
rejectUnknownKeys(grant, ["id", "user", "business", "projects", "instances", "level", "expires"], at);
requireId(grant.id, `${at}.id`);
if (seenGrants.has(grant.id)) refuse(`${at}.id is a duplicate grant id: ${grant.id}`);
seenGrants.add(grant.id);
if (typeof grant.user !== "string" || !users.has(grant.user)) refuse(`${at}.user must name a user in this file (got ${JSON.stringify(grant.user)})`);
if (typeof grant.business !== "string" || !ctx.businesses.has(grant.business)) {
refuse(`${at}.business must name a declared business (got ${JSON.stringify(grant.business)})`);
}
const business = ctx.businesses.get(grant.business);
checkScope(grant.projects, `${at}.projects`, (p, where) => {
if (typeof p !== "string" || !UUID.test(p)) refuse(`${where} must be "*" or a bus project id, a lowercase UUID (got ${JSON.stringify(p)})`);
});
checkScope(grant.instances, `${at}.instances`, (x, where) => {
if (typeof x !== "string" || !business.instances.has(x)) refuse(`${where} must be "*" or a declared instance of ${grant.business} (got ${JSON.stringify(x)})`);
});
if (!LEVELS.includes(grant.level)) refuse(`${at}.level must be one of ${LEVELS.join(", ")} (got ${JSON.stringify(grant.level)})`);
if (grant.expires !== undefined) checkExpires(grant.expires, `${at}.expires`);
}
// Check a parsed access document against the instance's context. Returns the
// document frozen. Every refusal is a BusinessError naming the key path. An
// expired grant is not refused: covers() treats it as absent.
export function checkAccess(document, context) {
const ctx = checkContext(context);
const humans = new Set([...ctx.businesses.values()].map((b) => b.human));
if (!isPlainObject(document)) refuse("access file must be a JSON object");
rejectUnknownKeys(document, ["accessVersion", "revision", "users", "grants"], "access file");
if (document.accessVersion !== 1) refuse(`accessVersion must be 1 (got ${JSON.stringify(document.accessVersion)})`);
requirePositiveInt(document.revision, "revision");
const users = new Set();
const seenSubjects = new Set();
requireArray(document.users, "users").forEach((user, i) => {
checkUser(user, `users[${i}]`, ctx, humans, seenSubjects);
if (users.has(user.id)) refuse(`users[${i}].id is a duplicate user id: ${user.id}`);
users.add(user.id);
});
const seenGrants = new Set();
requireArray(document.grants, "grants").forEach((grant, i) => checkGrant(grant, `grants[${i}]`, ctx, users, seenGrants));
return deepFreeze(structuredClone(document));
}
// Read and check the access file. One descriptor, opened without following a
// link, serves the stat checks and the read (readJsonFile). The file holds
// authority, so it must belong to this uid and not be writable by group or
// other, as the business file is.
export function loadAccess(file, context, { uid = process.getuid() } = {}) {
const document = readJsonFile(file, "access file", (stat) => {
if (stat.uid !== uid) refuse(`access file must belong to uid ${uid}: ${file}`);
if ((stat.mode & 0o022) !== 0) refuse(`access file must not be writable by group or other (mode ${(stat.mode & 0o777).toString(8)}): ${file}`);
});
return checkAccess(document, context);
}
// Does a checked grant cover a thread? `target` is the thread's business, bus
// project id and instance. With `level`, the grant's level must be that one;
// without it, any level covers. An expired grant covers nothing.
export function covers(grant, { business, project, instance }, now, { level } = {}) {
if (!(now instanceof Date) || Number.isNaN(now.getTime())) throw new TypeError("covers needs a valid Date for now");
if (grant.expires !== undefined && now.getTime() >= Date.parse(grant.expires)) return false;
if (grant.business !== business) return false;
if (!(grant.projects[0] === ALL || grant.projects.includes(project))) return false;
if (!(grant.instances[0] === ALL || grant.instances.includes(instance))) return false;
if (level !== undefined && grant.level !== level) return false;
return true;
}
+1
View File
@@ -10,5 +10,6 @@ export { validateRoleDocument, loadRoleFile, loadRole } from "./role.mjs";
export { LAYERS, REGISTRY, THINKING, FALLBACK_VARS, checkVars, mergeVars } from "./vars.mjs";
export { parseCredentialRef, checkCredentialRef } from "./credentials.mjs";
export { configDir, businessFilePath, validateBusinessDocument, loadBusiness } from "./business.mjs";
export { LEVELS, accessFilePath, checkAccess, loadAccess, covers } from "./access.mjs";
export { projectFilePath, validateProjectDocument, loadProject } from "./project.mjs";
export { systemVars, resolveInstance, classify } from "./resolve.mjs";
+280
View File
@@ -0,0 +1,280 @@
// FED R1 (#414, row 201): the access file loader and validator, DESIGN.md
// section 3.5, and covers(). Every test works in a scratch directory.
import { test } from "node:test";
import assert from "node:assert/strict";
import { chmodSync, mkdirSync, symlinkSync } from "node:fs";
import { join } from "node:path";
import { BusinessError } from "../src/errors.mjs";
import { LEVELS, accessFilePath, checkAccess, loadAccess, covers } from "../src/access.mjs";
import { tmp, writeJson } from "./helpers.mjs";
const P1 = "0f8b6c1e-2a3d-4e5f-8a9b-0c1d2e3f4a5b";
const P2 = "11111111-2222-4333-8444-555555555555";
function context(over = {}) {
return {
providers: ["authentik"],
peers: ["factory"],
businesses: {
"creative-hub": { human: "melanie", instances: ["researcher", "writer"] },
acme: { human: "jason", instances: ["coder"] },
},
ssoSubjects: [{ provider: "authentik", sub: "owner-sub" }],
...over,
};
}
function doc() {
return {
accessVersion: 1,
revision: 3,
users: [
{ id: "bean", home: "factory", subjects: [{ provider: "authentik", sub: "s-1" }] },
{ id: "chet", home: "local", subjects: [{ provider: "authentik", sub: "s-2" }] },
],
grants: [
{ id: "g-1", user: "bean", business: "creative-hub", projects: ["*"], instances: ["researcher"], level: "collaborator", expires: "2026-12-31T00:00:00Z" },
{ id: "g-2", user: "chet", business: "creative-hub", projects: [P1], instances: ["*"], level: "collaborator" },
],
};
}
// Refuses with a BusinessError, exit 2 unless given, and a message matching `pattern`.
function refuses(fn, pattern, exitCode = 2) {
assert.throws(fn, (error) => {
assert.ok(error instanceof BusinessError, `not a BusinessError: ${error}`);
assert.equal(error.exitCode, exitCode);
assert.match(error.message, pattern);
return true;
});
}
function mutated(change) {
const d = doc();
change(d);
return d;
}
test("a valid document passes and comes back frozen", () => {
const checked = checkAccess(doc(), context());
assert.deepEqual(checked, doc());
assert.ok(Object.isFrozen(checked));
assert.ok(Object.isFrozen(checked.grants[0].projects));
});
test("checkAccess does not freeze or alias the caller's document", () => {
const d = doc();
const checked = checkAccess(d, context());
assert.ok(!Object.isFrozen(d));
assert.notEqual(checked.grants, d.grants);
});
test("LEVELS is the single allowed level set, collaborator only", () => {
assert.deepEqual([...LEVELS], ["collaborator"]);
assert.ok(Object.isFrozen(LEVELS));
});
test("accessFilePath is access.json in the config directory", () => {
assert.equal(accessFilePath("/x/y"), join("/x/y", "access.json"));
const dir = tmp();
const saved = process.env.MOSAIC_CONFIG;
process.env.MOSAIC_CONFIG = join(dir, "config.json");
try {
assert.equal(accessFilePath(), join(dir, "access.json"));
} finally {
if (saved === undefined) delete process.env.MOSAIC_CONFIG;
else process.env.MOSAIC_CONFIG = saved;
}
});
test("top level: object, unknown keys, accessVersion, revision, arrays", () => {
refuses(() => checkAccess([], context()), /access file must be a JSON object/);
refuses(() => checkAccess(mutated((d) => { d.extra = 1; }), context()), /unsupported access file key: "extra"/);
refuses(() => checkAccess(mutated((d) => { d.accessVersion = 2; }), context()), /accessVersion must be 1/);
refuses(() => checkAccess(mutated((d) => { delete d.accessVersion; }), context()), /accessVersion must be 1/);
for (const bad of [0, -1, 1.5, "3", undefined]) {
refuses(() => checkAccess(mutated((d) => { d.revision = bad; }), context()), /revision must be a positive integer/);
}
refuses(() => checkAccess(mutated((d) => { d.users = {}; }), context()), /^users must be an array/);
refuses(() => checkAccess(mutated((d) => { delete d.grants; }), context()), /^grants must be an array/);
});
test("users: unknown keys and the id rule", () => {
refuses(() => checkAccess(mutated((d) => { d.users[1].role = "x"; }), context()), /unsupported users\[1\] key: "role"/);
refuses(() => checkAccess(mutated((d) => { d.users[0] = "bean"; }), context()), /users\[0\] must be a JSON object/);
refuses(() => checkAccess(mutated((d) => { d.users[0].id = "Bean"; }), context()), /users\[0\]\.id must match/);
refuses(() => checkAccess(mutated((d) => { d.users[1].id = "bean"; }), context()), /users\[1\]\.id is a duplicate user id: bean/);
});
test("users: human, tester and any business's human are refused", () => {
refuses(() => checkAccess(mutated((d) => { d.users[0].id = "human"; d.grants = []; }), context()), /users\[0\]\.id must not be "human"/);
refuses(() => checkAccess(mutated((d) => { d.users[1].id = "tester"; d.grants = []; }), context()), /users\[1\]\.id must not be "tester"/);
refuses(() => checkAccess(mutated((d) => { d.users[0].id = "melanie"; d.grants = []; }), context()), /users\[0\]\.id must not be a business's human \("melanie"\)/);
// The human of a business the grant doesn't name is refused too.
refuses(() => checkAccess(mutated((d) => { d.users[1].id = "jason"; d.grants = []; }), context()), /users\[1\]\.id must not be a business's human \("jason"\)/);
});
test("users: home is a given peer or local", () => {
refuses(() => checkAccess(mutated((d) => { d.users[0].home = "elsewhere"; }), context()), /users\[0\]\.home must be a configured peer or "local" \(got "elsewhere"\)/);
refuses(() => checkAccess(mutated((d) => { delete d.users[1].home; }), context()), /users\[1\]\.home must be a configured peer/);
refuses(() => checkAccess(doc(), context({ peers: [] })), /users\[0\]\.home must be a configured peer/);
});
test("users: subjects, provider, sub, uniqueness and owner subjects", () => {
refuses(() => checkAccess(mutated((d) => { d.users[0].subjects = []; }), context()), /users\[0\]\.subjects must not be empty/);
refuses(() => checkAccess(mutated((d) => { delete d.users[0].subjects; }), context()), /users\[0\]\.subjects must be an array/);
refuses(() => checkAccess(mutated((d) => { d.users[0].subjects[0].extra = 1; }), context()), /unsupported users\[0\]\.subjects\[0\] key: "extra"/);
refuses(() => checkAccess(mutated((d) => { d.users[1].subjects[0].provider = "google"; }), context()), /users\[1\]\.subjects\[0\]\.provider must be a configured provider \(got "google"\)/);
refuses(() => checkAccess(mutated((d) => { d.users[0].subjects[0].sub = ""; }), context()), /users\[0\]\.subjects\[0\]\.sub must be a non-empty string/);
refuses(() => checkAccess(mutated((d) => { d.users[0].subjects[0].sub = 7; }), context()), /users\[0\]\.subjects\[0\]\.sub must be a non-empty string/);
// A pair twice in one user, and across two users.
refuses(() => checkAccess(mutated((d) => { d.users[0].subjects.push({ provider: "authentik", sub: "s-1" }); }), context()), /users\[0\]\.subjects\[1\] appears more than once/);
refuses(() => checkAccess(mutated((d) => { d.users[1].subjects[0].sub = "s-1"; }), context()), /users\[1\]\.subjects\[0\] appears more than once/);
// The owner's own sign-in subject can't be an extra user.
refuses(() => checkAccess(mutated((d) => { d.users[1].subjects[0].sub = "owner-sub"; }), context()), /users\[1\]\.subjects\[0\] is an owner sign-in subject/);
// The same sub under another provider is a different pair.
const twoProviders = context({ providers: ["authentik", "google"] });
assert.ok(checkAccess(mutated((d) => { d.users[1].subjects[0] = { provider: "google", sub: "owner-sub" }; }), twoProviders));
});
test("grants: unknown keys, id, user and business", () => {
refuses(() => checkAccess(mutated((d) => { d.grants[0].role = "x"; }), context()), /unsupported grants\[0\] key: "role"/);
refuses(() => checkAccess(mutated((d) => { d.grants[1].id = "g-1"; }), context()), /grants\[1\]\.id is a duplicate grant id: g-1/);
refuses(() => checkAccess(mutated((d) => { d.grants[0].id = "G 1"; }), context()), /grants\[0\]\.id must match/);
refuses(() => checkAccess(mutated((d) => { d.grants[0].user = "nobody"; }), context()), /grants\[0\]\.user must name a user in this file \(got "nobody"\)/);
refuses(() => checkAccess(mutated((d) => { d.grants[1].business = "other"; }), context()), /grants\[1\]\.business must name a declared business \(got "other"\)/);
refuses(() => checkAccess(mutated((d) => { d.grants[1].business = "constructor"; }), context()), /grants\[1\]\.business must name a declared business/);
});
test("grants: projects is [\"*\"] or distinct lowercase UUIDs", () => {
refuses(() => checkAccess(mutated((d) => { d.grants[1].projects = ["stack"]; }), context()), /grants\[1\]\.projects\[0\] must be "\*" or a bus project id, a lowercase UUID \(got "stack"\)/);
refuses(() => checkAccess(mutated((d) => { d.grants[1].projects = [P1.toUpperCase()]; }), context()), /grants\[1\]\.projects\[0\] must be "\*" or a bus project id/);
refuses(() => checkAccess(mutated((d) => { d.grants[1].projects = [P1, 7]; }), context()), /grants\[1\]\.projects\[1\] must be "\*" or a bus project id/);
refuses(() => checkAccess(mutated((d) => { d.grants[1].projects = ["*", P1]; }), context()), /grants\[1\]\.projects must be \["\*"\] alone/);
refuses(() => checkAccess(mutated((d) => { d.grants[1].projects = [P1, "*"]; }), context()), /grants\[1\]\.projects must be \["\*"\] alone/);
refuses(() => checkAccess(mutated((d) => { d.grants[1].projects = ["*", "*"]; }), context()), /grants\[1\]\.projects must be \["\*"\] alone/);
refuses(() => checkAccess(mutated((d) => { d.grants[1].projects = [P1, P1]; }), context()), /duplicate entry in grants\[1\]\.projects/);
refuses(() => checkAccess(mutated((d) => { d.grants[1].projects = []; }), context()), /grants\[1\]\.projects must not be empty/);
refuses(() => checkAccess(mutated((d) => { d.grants[1].projects = "*"; }), context()), /grants\[1\]\.projects must be an array/);
assert.ok(checkAccess(mutated((d) => { d.grants[1].projects = [P1, P2]; }), context()));
});
test("grants: instances is [\"*\"] or distinct declared instances of that business", () => {
refuses(() => checkAccess(mutated((d) => { d.grants[0].instances = ["coder"]; }), context()), /grants\[0\]\.instances\[0\] must be "\*" or a declared instance of creative-hub \(got "coder"\)/);
refuses(() => checkAccess(mutated((d) => { d.grants[0].instances = ["*", "writer"]; }), context()), /grants\[0\]\.instances must be \["\*"\] alone/);
refuses(() => checkAccess(mutated((d) => { d.grants[0].instances = ["writer", "writer"]; }), context()), /duplicate entry in grants\[0\]\.instances/);
refuses(() => checkAccess(mutated((d) => { d.grants[0].instances = []; }), context()), /grants\[0\]\.instances must not be empty/);
assert.ok(checkAccess(mutated((d) => { d.grants[0].instances = ["researcher", "writer"]; }), context()));
});
test("grants: any level other than collaborator refuses, naming grants[i].level", () => {
for (const bad of ["viewer", "owner", "Collaborator", "COLLABORATOR", "", null, 1]) {
refuses(() => checkAccess(mutated((d) => { d.grants[1].level = bad; }), context()), /grants\[1\]\.level must be one of collaborator/);
}
refuses(() => checkAccess(mutated((d) => { delete d.grants[0].level; }), context()), /grants\[0\]\.level must be one of collaborator \(got undefined\)/);
});
test("grants: expires is optional ISO time, and a past expiry is not refused", () => {
for (const bad of ["2026-12-31", "2026-12-31 00:00:00Z", "2026-12-31T00:00:00+01:00", "tomorrow", 1798675200000, null]) {
refuses(() => checkAccess(mutated((d) => { d.grants[0].expires = bad; }), context()), /grants\[0\]\.expires must be an ISO time/);
}
refuses(() => checkAccess(mutated((d) => { d.grants[0].expires = "2026-02-30T00:00:00Z"; }), context()), /grants\[0\]\.expires is not a real time/);
refuses(() => checkAccess(mutated((d) => { d.grants[0].expires = "2026-13-01T00:00:00Z"; }), context()), /grants\[0\]\.expires must be an ISO time/);
assert.ok(checkAccess(mutated((d) => { d.grants[0].expires = "2026-12-31T00:00:00.500Z"; }), context()));
// DESIGN 3.5: a grant past expires is treated as absent, not refused (sage-1399).
assert.ok(checkAccess(mutated((d) => { d.grants[0].expires = "2001-01-01T00:00:00Z"; }), context()));
});
test("the context is required and checked", () => {
refuses(() => checkAccess(doc()), /access context must be a JSON object/);
for (const key of ["providers", "peers", "ssoSubjects"]) {
const c = context();
delete c[key];
refuses(() => checkAccess(doc(), c), new RegExp(`access context\\.${key} must be an array`));
}
refuses(() => checkAccess(doc(), context({ businesses: [] })), /access context\.businesses must be a JSON object/);
refuses(() => checkAccess(doc(), context({ extra: 1 })), /unsupported access context key: "extra"/);
refuses(() => checkAccess(doc(), context({ peers: ["local"] })), /access context\.peers must not name "local"/);
refuses(() => checkAccess(doc(), context({ ssoSubjects: [{ provider: "authentik" }] })), /access context\.ssoSubjects\[0\]\.sub must be a non-empty string/);
refuses(() => checkAccess(doc(), context({ businesses: { acme: { human: "jason" } } })), /access context\.businesses\.acme\.instances must be an array/);
});
// loadAccess: the file checks, then checkAccess.
test("loadAccess reads a valid file and checks it", () => {
const file = writeJson(join(tmp(), "access.json"), doc());
assert.deepEqual(loadAccess(file, context()), doc());
});
test("loadAccess refuses a missing file, a symlink and a directory with exit 4", () => {
const dir = tmp();
refuses(() => loadAccess(join(dir, "access.json"), context()), /access file not found/, 4);
const real = writeJson(join(dir, "real.json"), doc());
const link = join(dir, "access.json");
symlinkSync(real, link);
refuses(() => loadAccess(link, context()), /access file must be a regular, non-symbolic-link file/, 4);
const sub = join(dir, "d");
mkdirSync(sub);
refuses(() => loadAccess(sub, context()), /access file must be a regular, non-symbolic-link file/, 4);
});
test("loadAccess refuses another owner and group or other write", () => {
const file = writeJson(join(tmp(), "access.json"), doc());
refuses(() => loadAccess(file, context(), { uid: process.getuid() + 1 }), /access file must belong to uid/);
for (const mode of [0o620, 0o602, 0o666]) {
chmodSync(file, mode);
refuses(() => loadAccess(file, context()), /access file must not be writable by group or other/);
}
chmodSync(file, 0o644);
assert.ok(loadAccess(file, context()));
});
test("loadAccess refuses bad JSON and a bad document with exit 2", () => {
const dir = tmp();
refuses(() => loadAccess(writeJson(join(dir, "a.json"), "{"), context()), /access file is not valid JSON/);
refuses(() => loadAccess(writeJson(join(dir, "b.json"), mutated((d) => { d.grants[0].level = "owner"; })), context()), /grants\[0\]\.level/);
});
// covers.
const NOW = new Date("2026-10-20T12:00:00Z");
const target = (over = {}) => ({ business: "creative-hub", project: P1, instance: "researcher", ...over });
test("covers: * and listed ids match", () => {
const [g1, g2] = checkAccess(doc(), context()).grants;
assert.equal(covers(g1, target({ project: P2 }), NOW), true);
assert.equal(covers(g2, target({ instance: "writer" }), NOW), true);
assert.equal(covers(g2, target(), NOW), true);
});
test("covers: an unlisted project, instance or business doesn't match", () => {
const [g1, g2] = checkAccess(doc(), context()).grants;
assert.equal(covers(g2, target({ project: P2 }), NOW), false);
assert.equal(covers(g1, target({ instance: "writer" }), NOW), false);
assert.equal(covers(g2, target({ business: "acme" }), NOW), false);
assert.equal(covers(g1, target({ business: "acme" }), NOW), false);
});
test("covers: an expired grant doesn't match, at and after the instant", () => {
const [g1, g2] = checkAccess(doc(), context()).grants;
assert.equal(covers(g1, target(), new Date("2026-12-30T23:59:59.999Z")), true);
assert.equal(covers(g1, target(), new Date("2026-12-31T00:00:00Z")), false);
assert.equal(covers(g1, target(), new Date("2027-01-01T00:00:00Z")), false);
// No expires never expires.
assert.equal(covers(g2, target(), new Date("2099-01-01T00:00:00Z")), true);
});
test("covers: a level filter rejects another level, and no filter accepts any", () => {
const g = checkAccess(doc(), context()).grants[0];
assert.equal(covers(g, target(), NOW, { level: "collaborator" }), true);
assert.equal(covers(g, target(), NOW, {}), true);
assert.equal(covers(g, target(), NOW, { level: "viewer" }), false);
assert.equal(covers({ ...g, level: "viewer" }, target(), NOW, { level: "collaborator" }), false);
});
test("covers: now must be a valid Date", () => {
const g = checkAccess(doc(), context()).grants[0];
assert.throws(() => covers(g, target(), Date.now()), TypeError);
assert.throws(() => covers(g, target(), new Date("nope")), TypeError);
});
+1 -224
View File
@@ -12,7 +12,6 @@ import {tmpdir} from 'node:os';
import {join,dirname,basename} from 'node:path';
import {fileURLToPath,pathToFileURL} from 'node:url';
import {once} from 'node:events';
import {ALGS} from '../../webui/src/sso-config.mjs';
const ROOT=fileURLToPath(new URL('../../..',import.meta.url)).replace(/\/$/,'');
const NODE=process.execPath;
@@ -261,17 +260,9 @@ const storeText=(v,froms,to=true)=>`export const SCHEMA_VERSION = '${v}';\nexpor
const KINDS_NEW=readFileSync(join(repo,'packages','plugins','src','kinds.mjs'),'utf8');
const KINDS_OLD=KINDS_NEW.replace('\n harness: Object.freeze({','\n "harness-before-c216f47db": Object.freeze({');
assert.notEqual(KINDS_OLD,KINDS_NEW);
// Row 200 (#1698): C1 and C take --sso and have the row 192 loader and checkConsole; C's checker
// refuses RS256, a stand-in for a target whose rules differ from the repository's. B has the loader
// from before row 192, which refuses a `console` key. The other copies have no loader.
const LOADER=readFileSync(join(repo,'scripts','mosaic-config.mjs'),'utf8'),SSO_CONFIG=readFileSync(join(repo,'packages','webui','src','sso-config.mjs'),'utf8');
const LOADER_OLD=LOADER.replace('["configVersion", "environment", "dataRoot", "execution", "console"]','["configVersion", "environment", "dataRoot", "execution"]');
const SSO_CONFIG_C=SSO_CONFIG.replace("'ES256', 'RS256',","'ES256',");
assert.notEqual(LOADER_OLD,LOADER);assert.notEqual(SSO_CONFIG_C,SSO_CONFIG);
const ssoFiles=(id)=>id===B?[['scripts/mosaic-config.mjs',LOADER_OLD]]:[C1,C].includes(id)?[['scripts/mosaic-config.mjs',LOADER],['packages/webui/src/sso-config.mjs',id===C?SSO_CONFIG_C:SSO_CONFIG]]:[];
for(const [id,store] of [[F,'// a copy from before SCHEMA_VERSION\n'],[A,storeText('3g',['3f'],'3x')],[B,storeText('3g',['3f'])],[C1,storeText('3g',['3f'],false)],[C,storeText('3h',['3f','3g'])],[S,storeText('3g',['3f'])],[U,storeText('3g',['3f'])]]){
const d=join(COPIES,id);
for(const [f,text,mode] of [['scripts/mosaic',script(mosaicStub),0o755],['packages/webui/src/cli.mjs',[B,C1,C].includes(id)?`// fixture Console, never run\n// serve accepts '--tester-port'\n// serve accepts '--proof-header' (row 178)\n${[C1,C].includes(id)?"// serve accepts '--sso' (row 192)\n":''}`:'// fixture Console before tester listener, never run\n',0o644],['packages/bus/src/store.mjs',store,0o644],['packages/bus/src/migrate-schema.mjs',`(${migrateStub})();\n`,0o644],['packages/plugins/src/kinds.mjs',[C1,C].includes(id)?KINDS_NEW:KINDS_OLD,0o644],...ssoFiles(id).map(([f,text])=>[f,text,0o644])]){
for(const [f,text,mode] of [['scripts/mosaic',script(mosaicStub),0o755],['packages/webui/src/cli.mjs',[B,C1,C].includes(id)?"// fixture Console, never run\n// serve accepts '--tester-port'\n// serve accepts '--proof-header' (row 178)\n":'// fixture Console before tester listener, never run\n',0o644],['packages/bus/src/store.mjs',store,0o644],['packages/bus/src/migrate-schema.mjs',`(${migrateStub})();\n`,0o644],['packages/plugins/src/kinds.mjs',[C1,C].includes(id)?KINDS_NEW:KINDS_OLD,0o644]]){
mkdirSync(dirname(join(d,f)),{recursive:true});writeFileSync(join(d,f),text,{mode});
}
for(const p of ['packages/business/src','roles'])cpSync(join(repo,p),join(d,p),{recursive:true});
@@ -1822,220 +1813,6 @@ test('T55 row 189: activate --no-send drops --send, and rollback and pm-on carry
});
});
// Row 200 (#1698): activate --sso. The config gets console.sso with one provider `a`, and its slot
// file sits where cli.mjs's readSlot looks, beside the config, with a read watch: the run may stat
// it but never open it. ES256 passes every copy's checker; RS256 passes C1's and fails C's.
const SLOT='console-sso-a';
const slotPath=(e)=>join(dirname(e.cfg),'secrets','mosaic-stack',SLOT);
const SSO_KEY={sso:{publicOrigin:'https://console.example.test',providers:[{id:'a',issuer:'https://op.example.test/',clientId:'mosaic-console',clientSecret:{slot:SLOT},algs:['ES256'],subjects:[{sub:'subject-1',name:'jason'}]}]}};
function setKey(e,key){const c=JSON.parse(readFileSync(e.cfg,'utf8'));if(key===undefined)delete c.console;else c.console=key;writeFileSync(e.cfg,JSON.stringify(c))}
async function ssoConfig(e,{key=SSO_KEY,slot=true}={}){
setKey(e,key);mkdirSync(dirname(slotPath(e)),{recursive:true,mode:0o700});
return slot?proofFile(e,SLOT,0o600,dirname(slotPath(e))):slotPath(e);
}
const ssoLines=(out)=>out.filter((l)=>l.startsWith('sso:'));
const DROPS='--public-host console.example --auth-header x-user --allow-user jason';
const SHOW=`show-${CON}.txt`;
const setOf=(e,act)=>join(e.state,'stack-rollback',act);
const arrivalSet=(e,to)=>setOf(e,logLines(e).findLast((l)=>l.result==='ok'&&l.to===to&&l.from!==to).activation);
const ROLLBACK_PLAN=(set)=>`sso: dropped; ${B}'s Console does not support --sso, so the rollback takes the Console flags from ${set}/${SHOW}, with --send and --tester-port as the pointer has them`;
const KEY_MSG=(e,to,op,why)=>`${e.cfg} holds a console key, and ${to}'s loader refuses it (${why}); remove the key first (restore the config.json backup from the window's step 1), then run the ${op} again`;
const RB_MSG=(why)=>`${B}'s Console does not support --sso, and the Console flags it ran with cannot be read: ${why}`;
// Rewrites a file in a rollback set (0700, files kept by cp -p) and, unless sums is false, its SHA256SUMS line.
function setEdit(set,name,from,to,sums=true){
const f=join(set,name);chmodSync(set,0o700);chmodSync(f,0o600);edit(f,from,to);
if(sums){const s=join(set,'SHA256SUMS');chmodSync(s,0o600);writeFileSync(s,readFileSync(s,'utf8').replace(new RegExp(`^[0-9a-f]{64}( \\./${name.replace(/[.@]/g,'\\$&')})$`,'m'),`${sha(f)}$1`))}
}
test('T56 row 200: activate --sso, its refusals before any stop, and the rollback to a Console without --sso',async(t)=>{
await t.test('item 1: --sso drops the preview set and the pair, puts --sso before --send and the tester port, and the plan names what it drops and carries',async(t)=>{
const e=await mk(t),base=await seedPreview(e,undefined,['--send']),f=await proofFile(e),pre=noSend(base);
let r=await prun(e,'activate',B,'acme','--tester-port','7333',...pair(f));assert.equal(r.code,0,r.out+r.err);
assert.deepEqual(pointer(e).console,[...pre,...pair(f),'--send','--tester-port','7333']);
await ssoConfig(e);
const want=['--port',e.port,'--business','acme','--sso','--send','--tester-port','7333'];
const out=await planOnly(e,0,'activate',C1,'acme','--sso');
assert.deepEqual([...proofLines(out),...sendLines(out),...ssoLines(out)],['proof: dropped; --sso drops header x-console-proof','send: carried',
`sso: on; drops ${DROPS}; carries --port ${e.port} --business acme --send --tester-port 7333`]);
assert.equal(out[out.findIndex((l)=>l.startsWith('sso:'))+1],`Console: scripts/webui-service.sh install --copy ${C1} --copies ${COPIES} ${want.join(' ')}`);
r=await prun(e,'activate',C1,'acme','--sso');assert.equal(r.code,0,r.out+r.err);
assert.equal(pointer(e).copy,C1);assert.deepEqual(pointer(e).console,want);
assert.ok(conExec(e).endsWith(` --checkout ${repo} ${want.join(' ')}`),conExec(e));
r=await prun(e,'status','acme');assert.equal(r.code,0,r.err);assert.match(r.out,/unit flags: match the pointer/);
const steps=stepText(e);
assert.ok(steps.includes(`Z drop --proof-header x-console-proof --proof-file ${f}; --sso was given\n`),steps);
assert.ok(steps.includes(`Z turn on --sso; drop ${DROPS}\n`),steps);
await noLeak(e);
});
await t.test('item 1: --sso may re-pin the live copy once; later activates carry it; --no-send still drops --send',async(t)=>{
const e=await mk(t),base=await seedPreview(e,undefined,['--send']);await ssoConfig(e);
let r=await prun(e,'activate',C1,'acme');assert.equal(r.code,0,r.out+r.err);
let out=await planOnly(e,0,'activate',C1,'acme','--sso');
assert.deepEqual([...proofLines(out),...ssoLines(out)],['proof: off',`sso: on; drops ${DROPS}; carries --port ${e.port} --business acme --send`]);
r=await prun(e,'activate',C1,'acme','--sso');assert.equal(r.code,0,r.out+r.err);
const l=logLines(e).at(-1);assert.deepEqual([l.op,l.from,l.to,l.result],['activate',C1,C1,'ok']);
assert.deepEqual(pointer(e).console,['--port',e.port,'--business','acme','--sso','--send']);
assert.doesNotMatch(stepText(e),/proof/);
await refused(e,['activate',C1,'acme','--sso'],'already-active',`${C1} is the pointer's copy`);
await refused(e,['activate',C1,'acme'],'already-active',`${C1} is the pointer's copy`);
out=await planOnly(e,0,'activate',C,'acme');assert.deepEqual(ssoLines(out),['sso: carried']);
r=await prun(e,'activate',C,'acme','--migrate');assert.equal(r.code,0,r.out+r.err);
// Carried, not turned on: no stamped step names sso (the install output's serve line still shows --sso).
assert.deepEqual(pointer(e).console,['--port',e.port,'--business','acme','--sso','--send']);assert.doesNotMatch(stepText(e),/^\S+Z .*sso/m);
assert.match(stepText(e),/^serve: .* --sso --send$/m);
r=await prun(e,'activate',C,'acme','--no-send');assert.equal(r.code,0,r.out+r.err);
assert.deepEqual(pointer(e).console,['--port',e.port,'--business','acme','--sso']);
r=await prun(e,'status','acme');assert.equal(r.code,0,r.err);assert.match(r.out,/unit flags: match the pointer/);
const g=await mk(t);await seedPreview(g,undefined,['--send']);await ssoConfig(g);
r=await prun(g,'activate',C1,'acme','--sso','--no-send');assert.equal(r.code,0,r.out+r.err);
assert.deepEqual(pointer(g).console,['--port',g.port,'--business','acme','--sso']);
assert.ok(stepText(g).includes('Z drop --send; --no-send was given\n'));
for(const x of [e,g])await noLeak(x);
});
await t.test('item 1: --sso with a pair or --no-proof, or on any verb but activate, is a usage error',async(t)=>{
const e=await mk(t);await seedPreview(e);const f=await proofFile(e);
const usageText=(await prun(e,'--help')).err,n=calls(e).length,logs=logLines(e).length,before=fp(e);
for(const args of [['activate',C1,'acme','--sso',...pair(f)],['activate',C1,'acme','--sso','--no-proof'],['activate',C1,'acme','--no-proof','--sso','--plan'],
['rollback','acme','--sso'],['pm-on','acme','--sso'],['status','acme','--sso'],['adopt','acme','--sso']]){
const r=await prun(e,...args);assert.equal(r.code,4,args.join(' '));assert.equal(r.out,'');assert.equal(r.err,usageText);
}
assert.match(usageText,/\[--proof-header NAME --proof-file PATH \| --no-proof \| --sso\] \[--no-send\]/);
assert.deepEqual(fp(e),before);assert.deepEqual(startsStops(e,n),[]);assert.equal(logLines(e).length,logs);
await noLeak(e);
});
await t.test('item 2: a target without --sso, a missing or bad console.sso, and each slot problem refuse before any stop',async(t)=>{
const e=await mk(t);await seedPreview(e);await ssoConfig(e,{slot:false});
const slot=slotPath(e),put=(mode=0o600,text='x\n')=>{rmSync(slot,{force:true,recursive:true});writeFileSync(slot,text,{mode});chmodSync(slot,mode)};
put();
await refused(e,['activate',B,'acme','--sso'],'sso-target',`${B}'s Console does not support --sso`);
setKey(e,undefined);
// N4: from another copy, the refusal names the re-pin without --sso as the first step.
await refused(e,['activate',C1,'acme','--sso'],'sso-config',`${C1}'s Console would refuse its --sso start: there is no console.sso in the system config; activate ${C1} without --sso first, then write console.sso, then run activate ${C1} --sso`);
let r=await prun(e,'activate',C1,'acme');assert.equal(r.code,0,r.out+r.err);
await refused(e,['activate',C1,'acme','--sso'],'sso-config',`${C1}'s Console would refuse its --sso start: there is no console.sso in the system config`);
setKey(e,{sso:{...SSO_KEY.sso,providers:[{...SSO_KEY.sso.providers[0],algs:['RS256']}]}});
r=await prun(e,'activate',C1,'acme','--sso','--plan');assert.equal(r.code,0,r.out+r.err);
await refused(e,['activate',C,'acme','--sso'],'sso-config',`${C}'s Console would refuse its --sso start: console.sso.providers[0].algs[0] must be one of ${ALGS.filter((a)=>a!=='RS256').join(', ')}; HS* and none are refused`);
setKey(e,SSO_KEY);
const bad=(why)=>`console.sso provider a client secret slot ${slot} ${why}`;
rmSync(slot);
await refused(e,['activate',C,'acme','--sso'],'sso-slot',bad('is missing'));
const elsewhere=join(e.root,'elsewhere');writeFileSync(elsewhere,'x\n',{mode:0o600});symlinkSync(elsewhere,slot);
await refused(e,['activate',C,'acme','--sso'],'sso-slot',bad('is a symbolic link'));
rmSync(slot);mkdirSync(slot,{mode:0o700});
await refused(e,['activate',C,'acme','--sso'],'sso-slot',bad('is not a regular file'));
for(const mode of [0o644,0o640,0o604]){put(mode);await refused(e,['activate',C,'acme','--sso'],'sso-slot',bad(`has mode ${mode.toString(8)}, which gives group or other permission bits`))}
put(0o600,'');
await refused(e,['activate',C,'acme','--sso'],'sso-slot',bad('is empty'));
// The slot directory resolving into the repository: the file is no link, but its real path is.
const inRepo=mkdtempSync(join(repo,'t56-slot-'));t.after(()=>rmSync(inRepo,{recursive:true,force:true}));
writeFileSync(join(inRepo,SLOT),'x\n',{mode:0o600});chmodSync(join(inRepo,SLOT),0o600);
const secrets=dirname(slot);rmSync(secrets,{recursive:true});symlinkSync(inRepo,secrets);
await refused(e,['activate',C,'acme','--sso'],'sso-slot',bad('is inside the repository or the copies'));
// --plan lists the sso refusal with the rest and writes nothing.
const out=await planOnly(e,3,'activate',C,'acme','--sso','--tester-port','80');
assert.deepEqual(out.filter((l)=>l.startsWith('refuses ')),[`refuses tester-port-invalid: --tester-port 80 is not a whole port from 1024 to 65535 other than 7332 and the Console's --port ${e.port}`,`refuses sso-slot: ${bad('is inside the repository or the copies')}`]);
rmSync(secrets);mkdirSync(secrets,{mode:0o700});put();
r=await prun(e,'activate',C,'acme','--sso','--migrate');assert.equal(r.code,0,r.out+r.err);
await noLeak(e);
});
await t.test('item 3: a config whose console key the target loader refuses stops an activate or a rollback before any stop, naming the first step',async(t)=>{
const e=await mk(t);await seedPreview(e);await ssoConfig(e);
let r=await prun(e,'activate',C1,'acme');assert.equal(r.code,0,r.out+r.err);
const why='mosaic-config: unsupported configuration key: "console"';
await refused(e,['activate',B,'acme'],'config-console-key',KEY_MSG(e,B,'activate',why));
await refused(e,['rollback','acme'],'config-console-key',KEY_MSG(e,B,'rollback',why));
const out=await planOnly(e,3,'rollback','acme');
assert.deepEqual(out.filter((l)=>l.startsWith('refuses ')),[`refuses config-console-key: ${KEY_MSG(e,B,'rollback',why)}`]);
setKey(e,{sso:{...SSO_KEY.sso,providers:[{...SSO_KEY.sso.providers[0],algs:['RS256']}]}});
await refused(e,['activate',C,'acme'],'config-console-key',KEY_MSG(e,C,'activate',`mosaic-config: console.sso.providers[0].algs[0] must be one of ${ALGS.filter((a)=>a!=='RS256').join(', ')}; HS* and none are refused`));
setKey(e,undefined);
r=await prun(e,'rollback','acme');assert.equal(r.code,0,r.out+r.err);assert.equal(pointer(e).copy,B);
await noLeak(e);
});
await t.test('item 3: a rollback from --sso to a Console without it takes the flags from the set of the activation onto the copy, past a same-copy re-pin (sage-1389)',async(t)=>{
const e=await mk(t),base=await seedPreview(e,undefined,['--send','--tester-port','7333']);await ssoConfig(e);
let r=await prun(e,'activate',C1,'acme');assert.equal(r.code,0,r.out+r.err);
const set=arrivalSet(e,C1);
r=await prun(e,'activate',C1,'acme','--sso');assert.equal(r.code,0,r.out+r.err);
r=await prun(e,'activate',C1,'acme','--no-send');assert.equal(r.code,0,r.out+r.err);
// The latest set holds the --sso Console; the arrival set holds B's.
assert.match(readFileSync(join(setOf(e,pointer(e).activation),SHOW),'utf8'),/ --sso /);
assert.doesNotMatch(readFileSync(join(set,SHOW),'utf8'),/ --sso /);
await refused(e,['rollback','acme'],'config-console-key',KEY_MSG(e,B,'rollback','mosaic-config: unsupported configuration key: "console"'));
setKey(e,undefined);
const want=[...noSend(base.slice(0,-2)),'--tester-port','7333'];
const out=await planOnly(e,0,'rollback','acme');
assert.deepEqual(ssoLines(out),[ROLLBACK_PLAN(set)]);
assert.equal(out.find((l)=>l.startsWith('Console: ')),`Console: scripts/webui-service.sh install --copy ${B} --copies ${COPIES} ${want.join(' ')}`);
r=await prun(e,'rollback','acme');assert.equal(r.code,0,r.out+r.err);
assert.equal(pointer(e).copy,B);assert.deepEqual(pointer(e).console,want);
assert.ok(conExec(e).endsWith(` --checkout ${repo} ${want.join(' ')}`),conExec(e));
assert.ok(stepText(e).includes(`Z drop --sso; ${B}'s Console does not support it, so the Console flags come from ${set}/${SHOW}\n`));
r=await prun(e,'status','acme');assert.equal(r.code,0,r.err);assert.match(r.out,/unit flags: match the pointer/);
// The plain case: --send and the preview set and pair come back as they were.
const g=await mk(t),gbase=await seedPreview(g,undefined,['--send']),f=await proofFile(g);
r=await prun(g,'activate',B,'acme',...pair(f));assert.equal(r.code,0,r.out+r.err);
await ssoConfig(g);
r=await prun(g,'activate',C1,'acme','--sso');assert.equal(r.code,0,r.out+r.err);
setKey(g,undefined);
r=await prun(g,'rollback','acme');assert.equal(r.code,0,r.out+r.err);
assert.deepEqual(pointer(g).console,[...noSend(gbase),...pair(f),'--send']);
for(const x of [e,g])await noLeak(x);
});
await t.test('item 3: a rollback set that is missing, fails its sums, holds no argv or already ran --sso refuses sso-rollback-flags',async(t)=>{
const e=await mk(t);await seedPreview(e);await ssoConfig(e);
let r=await prun(e,'activate',C1,'acme','--sso');assert.equal(r.code,0,r.out+r.err);
setKey(e,undefined);
const set=arrivalSet(e,C1),show=join(set,SHOW),text=readFileSync(show,'utf8');
setEdit(set,SHOW,' --public-host ',' --sso --public-host ');
await refused(e,['rollback','acme'],'sso-rollback-flags',RB_MSG(`the Console in ${show} already ran with --sso`));
let out=await planOnly(e,3,'rollback','acme');
assert.deepEqual(out.filter((l)=>l.startsWith('refuses ')),[`refuses sso-rollback-flags: ${RB_MSG(`the Console in ${show} already ran with --sso`)}`]);
setEdit(set,SHOW,' --sso --public-host ',' --public-host ',false);
await refused(e,['rollback','acme'],'sso-rollback-flags',RB_MSG(`${show} does not match the set's SHA256SUMS`));
setEdit(set,SHOW,' serve ',' ',true);
await refused(e,['rollback','acme'],'sso-rollback-flags',RB_MSG(`${show} holds no Console argv this script can read`));
rmSync(show);
await refused(e,['rollback','acme'],'sso-rollback-flags',RB_MSG(`${set} has no ${SHOW} and SHA256SUMS`));
writeFileSync(show,text);setEdit(set,SHOW,'\n','\n',true);
// A log whose arrival line came from another copy names no set for B.
const log=readFileSync(e.log,'utf8');chmodSync(e.log,0o600);
writeFileSync(e.log,log.replaceAll(`"from":"${B}","to":"${C1}"`,`"from":"${A}","to":"${C1}"`));
await refused(e,['rollback','acme'],'sso-rollback-flags',RB_MSG(`the log names no activation that moved the stack from ${B} onto ${C1}`));
writeFileSync(e.log,readFileSync(e.log,'utf8').replaceAll(`"from":"${A}","to":"${C1}"`,`"from":"${B}","to":"${C1}"`));
r=await prun(e,'rollback','acme');assert.equal(r.code,0,r.out+r.err);assert.equal(pointer(e).copy,B);
await noLeak(e);
});
await t.test('round 2 (webby-0028 N1): run through a symlinked repository path, a slot or a proof file inside the repository still refuses',async(t)=>{
const link=join(base,'repo-link.200');if(!existsSync(link))symlinkSync(repo,link);
const e=await mk(t);e.sr=join(link,'scripts','stack-release.sh');await seedPreview(e);
const f=await proofFile(e,'proof.200',0o600,mkdtempSync(join(repo,'t56-proof-')));t.after(()=>rmSync(dirname(f),{recursive:true,force:true}));
await refused(e,['activate',C1,'acme',...pair(f)],'proof-file-invalid',`--proof-file ${f} is inside the repository or the copies`);
await ssoConfig(e,{slot:false});
let r=await prun(e,'activate',C1,'acme');assert.equal(r.code,0,r.out+r.err);
const inRepo=mkdtempSync(join(repo,'t56-slot-'));t.after(()=>rmSync(inRepo,{recursive:true,force:true}));
writeFileSync(join(inRepo,SLOT),'x\n',{mode:0o600});chmodSync(join(inRepo,SLOT),0o600);
const secrets=dirname(slotPath(e));rmSync(secrets,{recursive:true});symlinkSync(inRepo,secrets);
await refused(e,['activate',C1,'acme','--sso'],'sso-slot',`console.sso provider a client secret slot ${slotPath(e)} is inside the repository or the copies`);
await noLeak(e);
});
await t.test('round 2 (webby-0028 N2): a config.json that does not parse stops the run before anything is read or stopped',async(t)=>{
const e=await mk(t);await seedPreview(e);
// load_config exits first, before the pointer, the log or a unit is read; config_checks' own
// refusal is the guard for a file that changes after that (BUILD.md, round 2).
const good=readFileSync(e.cfg,'utf8');writeFileSync(e.cfg,good.slice(0,-1));
const before=fp(e),n=calls(e).length,logs=logLines(e).length;
for(const args of [['activate',C1,'acme'],['rollback','acme'],['activate',C1,'acme','--plan']]){
const r=await prun(e,...args);assert.equal(r.code,1,args.join(' '));assert.match(r.err,/common: configuration load failed\n$/);
}
assert.deepEqual(fp(e),before);assert.deepEqual(startsStops(e,n),[]);assert.equal(logLines(e).length,logs);
writeFileSync(e.cfg,good);
const r=await prun(e,'activate',C1,'acme');assert.equal(r.code,0,r.out+r.err);
await noLeak(e);
});
});
// --- rehearse (D2, row 163; the brief's "Stack rehearsal on a scratch root") ---
// Copies of their own in RCOPIES: the real scripts/mosaic, config loader, business loader, roles and
// mosaic-config.mjs, with stand-ins for the bus host (cli.mjs), the Console, the Store and
-39
View File
@@ -129,45 +129,6 @@ Switching the Console, bus or wrapper, changing any unit, and starting or
stopping either service requires Mos's go for that live step. Agents build
and test fixtures; they stop before the live sequence.
## Sign-in in the Console (Q175 phase 1, #1690)
`serve --sso [--send] [--tester-port N]` makes the Console an OpenID Connect
relying party. It reads `console.sso` from the system config (`publicOrigin`
and one or more providers, each with its issuer, client id, a client secret
slot, the signing algorithms it accepts and the subjects allowed in). The
design is `agents/sage/work/q175-sso/DESIGN.md`. `--sso` can't be combined
with `--public-host`, `--auth-header`, `--allow-user` or `--proof-*`; `--send`
takes `--sso` in place of the preview set.
- The client secret is read once at start from
`<config dir>/secrets/mosaic-stack/<slot>`. The start refuses a slot that is
a symbolic link, not a regular file, owned by another user, has any group or
other mode bit, or sits in the repository, the `--checkout` directory or a
stack copy. The value is never printed.
- At start the Console fetches each provider's discovery document and JWKS. A
provider that can't be reached (a network error, a timeout, a status other
than 200, or a body that isn't JSON) leaves the Console up with that
provider's `/auth/login` at 503 and a retry every 5 s, doubling, capped at 5
minutes. A document that fails a check (issuer, algorithms, S256, a usable
key) refuses the start. `/healthz` adds `sso: { <id>: "ready" | "unreachable"
| "refused" }`.
- The Console listener serves only the Host of `publicOrigin`, whatever port
it listens on. Any other Host, a loopback one included, gets 403 `public
Host required`, except `GET /healthz`, which stays open on loopback for the
status and release gates. The loopback operator path in the preview section
above does not exist under `--sso`.
- Without a session, `/api/*` gets 401 `sign-in required` and a page load gets
a 303 to `/auth/login?return=<path>`. The page then shows the signed-out line
with a link back to sign-in. A session lasts at most 8 hours and ends after 1
hour without a page load or a write; a restart ends every session.
- The page shows Sign out only under `--sso`. It posts to `/auth/logout`, which
ends the session and goes to the provider's end-session endpoint when it has
one, or to `/auth/signed-out`.
- With `--tester-port`, the tester listener refuses any request carrying an
`Authorization` header, a `__Host-mosaic_session` or `__Host-mosaic_login`
cookie, or an `x-authentik-*` header (403 `tester listener takes no
sign-in`). It never serves `/auth/*`.
## Message a role (row 76, #1570)
```sh
+2 -3
View File
@@ -58,9 +58,8 @@ export async function chatHTTP(req, res, { path, search, chat, named, send, prev
if (one(req, 'origin') !== preview.origin) return refuse(403, 'cross-origin');
} else {
if (!send || !named || !preview) return refuse(403, 'read-only');
// Q175 phase 1 (#1690): under --sso the session was checked before this route.
if (preview.sso !== true && one(req, preview.header) !== preview.user) return refuse(401, 'unauthenticated');
if (one(req, 'origin') !== (preview.origin ?? `https://${preview.host}`)) return refuse(403, 'cross-origin');
if (one(req, preview.header) !== preview.user) return refuse(401, 'unauthenticated');
if (one(req, 'origin') !== `https://${preview.host}`) return refuse(403, 'cross-origin');
}
if (one(req, 'sec-fetch-site') !== 'same-origin') return refuse(403, 'cross-site');
}
+8 -27
View File
@@ -7,23 +7,20 @@ import { views } from '../../bus/src/views.mjs';
import { loadSystem, socketPath, REPO, rolesDir } from '../../cli/src/config.mjs';
import { configDir } from '../../business/src/business.mjs';
import { consoleReads, redactor } from './reads.mjs';
import { createSso, readSlot } from './sso.mjs';
import { statSync } from 'node:fs';
import { isAbsolute } from 'node:path';
import { ID_PATTERN } from '../../business/src/vocabulary.mjs';
const args = process.argv.slice(2);
const usage = 'node packages/webui/src/cli.mjs serve [--port N] [--tester-port N] [--checkout PATH] [--board http://127.0.0.1:7331] [--business ID] [--sso [--send]] [--public-host NAME --auth-header HEADER --allow-user ID [--send] [--proof-header NAME --proof-file PATH]]';
const usage = 'node packages/webui/src/cli.mjs serve [--port N] [--tester-port N] [--checkout PATH] [--board http://127.0.0.1:7331] [--business ID] [--public-host NAME --auth-header HEADER --allow-user ID [--send] [--proof-header NAME --proof-file PATH]]';
try {
if (args.length === 1 && ['--help', '-h'].includes(args[0])) { console.log(usage); process.exit(0); }
if (args.shift() !== 'serve') throw new Error(usage);
const options = { board: DEFAULT_BOARD, port: 7330 }, preview = {}, proofFlags = {};
let sendOn = false, ssoOn = false, testerPort = null;
let sendOn = false, testerPort = null;
while (args.length) {
const flag = args.shift();
// Row 76 (#1570): the one flag without a value.
if (flag === '--send') { sendOn = true; continue; }
// Q175 phase 1 (#1690): the Console signs people in itself, from console.sso in config.json.
if (flag === '--sso') { ssoOn = true; continue; }
const value = args.shift();
if (!value) throw new Error(`missing value for ${flag}`);
if (flag === '--port') {
@@ -54,10 +51,9 @@ try {
else throw new Error(`unknown option: ${flag}`);
}
if (testerPort !== null && (testerPort === options.port || testerPort === 7332)) throw new Error('--tester-port must differ from --port and cannot be the forwarded port 7332');
if (ssoOn && (Object.keys(preview).length || Object.keys(proofFlags).length)) throw new Error('--sso cannot be combined with --public-host, --auth-header, --allow-user or --proof-*');
options.preview = previewOptions(preview);
const proof = proofOptions(proofFlags, options.preview);
if (sendOn && !options.preview && !ssoOn) throw new Error('--send needs the preview mode (--public-host, --auth-header and --allow-user) or --sso');
if (sendOn && !options.preview) throw new Error('--send needs the preview mode (--public-host, --auth-header and --allow-user)');
// The bus views (slice 1 S5) need the system config. Without it the
// board still serves and the bus routes answer not-configured.
// The Queue, Business and Settings reads (row 54) need no bus; without
@@ -81,46 +77,31 @@ try {
busLine = `Bus: ${options.business ?? 'the running bus host\'s business'}`;
} catch (err) {
const why = redactor({ configDir: configDir() })(err.message);
if (ssoOn) throw new Error(`--sso needs the system config (${why})`);
if (sendOn) throw new Error(`--send needs the bus configured (${why})`);
if (testerPort !== null) throw new Error(`--tester-port needs the bus configured (${why})`);
busLine = `Bus: not configured (${why})`;
}
const reads = consoleReads({ root: REPO, queueRoot: options.checkout ?? REPO, system, configDir: configDir(), rolesDir: rolesDir(), business: options.business ?? null });
// The sign-in config is checked again here, then each slot file, before anything listens. A
// provider that can't be reached starts degraded; one whose discovery is refused stops the start.
let sso = null;
if (ssoOn) {
if (!system.console?.sso) throw new Error('--sso needs console.sso in the system config');
const secrets = new Map(system.console.sso.providers.map(p => [p.id, readSlot(configDir(), p, { repo: REPO, checkout: options.checkout ?? null })]));
sso = await createSso(system.console.sso, { secrets });
}
let server;
try {
server = await startServer({ board: options.board, port: options.port, bus, reads, preview: options.preview, sso, proof, send, settingsWrite, projectFiles, projectFile, projectSave, chat, nameWrite, assistantStop, instancesWrite, routesWrite });
} catch (err) { sso?.close(); throw err; }
const server = await startServer({ board: options.board, port: options.port, bus, reads, preview: options.preview, proof, send, settingsWrite, projectFiles, projectFile, projectSave, chat, nameWrite, assistantStop, instancesWrite, routesWrite });
let testerServer = null;
if (testerPort !== null) {
const call = busCaller({ dataRoot: system.dataRoot, socket: socketPath(system.dataRoot), business: options.business ?? null, cli: TESTER_CLI });
const testerBus = { ...views({ call }), names: () => call('names'), instances: () => call('instances'), routes: () => call('routes') };
try {
testerServer = await startServer({ host: '127.0.0.1', port: testerPort, board: options.board,
tester: true, testerNoSignIn: ssoOn, testerProofHeader: proof?.header ?? null, testerAuthHeader: options.preview?.header ?? null, bus: testerBus, reads,
tester: true, testerProofHeader: proof?.header ?? null, testerAuthHeader: options.preview?.header ?? null, bus: testerBus, reads,
projectFiles: args => call('project.files', args), projectFile: args => call('project.file', args),
instancesWrite: args => call('instances.set', args), routesWrite: args => call('routes.set', args), nameWrite: args => call('name.set', args),
// Row 177 (#1675): the tester's own chat, through the tester CLI; 7332's stays the human's.
chat: createChat({ call }),
});
} catch (err) { sso?.close(); await new Promise(resolve => server.close(resolve)); throw err; }
} catch (err) { await new Promise(resolve => server.close(resolve)); throw err; }
}
const previewLine = options.preview ? `\nPreview: https://${options.preview.host}/ through the proxy, signed-in user from ${options.preview.header}, ${send ? 'reads and human messages to roles (--send); one line per send below' : 'read-only'}` : '';
const proofLine = `\nProxy: proxy proof ${proof ? 'on' : 'off'}`;
// One line per provider: id, issuer, subject count and state. No client id, secret or sub.
const health = sso?.health() ?? {};
const ssoLines = sso ? `\nSign-in: ${sso.publicOrigin}/ (--sso), ${send ? 'reads and human writes (--send)' : 'read-only'}` + sso.summary().map(p => `\nSign-in provider: ${p.id} issuer ${p.issuer} subjects ${p.subjects} (${health[p.id]})`).join('') : '';
const queueLine = options.checkout ? '\nQueue: read from the --checkout directory' : '';
console.log(`Mosaic Console: http://127.0.0.1:${server.address().port}/\nBoard: ${options.board}\n${busLine}${queueLine}${previewLine}${ssoLines}${proofLine}${testerServer ? `\nTester Console: http://127.0.0.1:${testerServer.address().port}/ (binds the tester)` : ''}\nCtrl-C stops this server.`);
for (const signal of ['SIGINT', 'SIGTERM']) process.once(signal, () => { sso?.close(); server.close(); testerServer?.close(); });
console.log(`Mosaic Console: http://127.0.0.1:${server.address().port}/\nBoard: ${options.board}\n${busLine}${queueLine}${previewLine}${proofLine}${testerServer ? `\nTester Console: http://127.0.0.1:${testerServer.address().port}/ (binds the tester)` : ''}\nCtrl-C stops this server.`);
for (const signal of ['SIGINT', 'SIGTERM']) process.once(signal, () => { server.close(); testerServer?.close(); });
} catch (err) {
console.error(`refused: ${err.message}`);
process.exitCode = 2;
+3 -14
View File
@@ -16,11 +16,6 @@
// The first one sets body.signed-out (the listener below); after that no Console fetch is made.
const out = () => document.body.classList.contains('signed-out');
const signOut = () => { if (!out()) document.dispatchEvent(new Event('mosaic-signed-out')); };
// Q175 phase 1 (#1690): under --sso the Console itself answers a read without a session with 401
// `sign-in required`; that signs the page out too, and the line links to the Console's sign-in.
// Any other 401 stays an error. `sso` comes from /api/config.
let sso = false;
const signInRequired = (res, body) => res.status === 401 && body?.error === 'sign-in required';
const signedOut = () => Object.assign(new Error('the sign-in proxy redirected it, so it never reached the Console'), { signedOut: true });
const labels = { registration: 'registered', 'first-user-message': 'first message', 'tmux-pane': 'tmux pane', 'session-cwd': 'session cwd', 'workspace-git-root': 'git root' };
const source = value => value ? `<span class="source">${esc(labels[value] || value)}</span>` : '';
@@ -115,7 +110,6 @@
const res = await fetch(path, { cache: 'no-store', redirect: 'manual' });
if (res.type === 'opaqueredirect') { signOut(); throw signedOut(); }
let body; try { body = await res.json(); } catch { body = { error: `HTTP ${res.status}` }; }
if (signInRequired(res, body)) { sso = true; signOut(); throw signedOut(); }
return { ok: res.ok, body };
}
function blockNode(b) {
@@ -310,7 +304,6 @@
const res = await fetch(path, value === undefined ? { cache: 'no-store', redirect: 'manual' } : { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(value), redirect: 'manual' });
if (res.type === 'opaqueredirect') { signOut(); throw signedOut(); }
const result = await res.json();
if (signInRequired(res, result)) { sso = true; signOut(); throw signedOut(); }
if (!res.ok) throw Object.assign(new Error(result.error || `HTTP ${res.status}`), { status: res.status });
return result;
}
@@ -459,7 +452,7 @@
});
// Signed out: everything drawn from a Console read goes, because none of it can be refreshed. The
// line says so once; the send form (bus.js) keeps its draft and receipt. Reload is the way back.
document.addEventListener('mosaic-signed-out', e => {
document.addEventListener('mosaic-signed-out', () => {
if (out()) return;
document.body.classList.add('signed-out');
clearTimeout(timer); convGen++;
@@ -468,13 +461,9 @@
for (const id of ['projects', 'waiting', 'seen', 'sessions']) $(id).replaceChildren();
$('refresh').disabled = $('pause').disabled = true;
const line = $('signed-out');
line.innerHTML = sso || e.detail?.signIn
? `<p><b>Signed out.</b> <a href="/auth/login?${esc(new URLSearchParams({ return: location.pathname + location.search }))}">Sign in again</a>.</p>`
: '<p><b>Signed out.</b> Reload the page to sign in.</p>';
line.hidden = false; $('sign-out').hidden = true;
line.innerHTML = '<p><b>Signed out.</b> Reload the page to sign in.</p>'; line.hidden = false;
// Focus moves to the line only if what had it is gone or disabled, never out of a form being typed in.
setTimeout(() => { const a = document.activeElement; if (!a || a === document.body || a.disabled || !a.getClientRects().length) line.focus(); });
});
// Sign out (sage-1347, F7): a plain same-origin form POST to /auth/logout, shown only under --sso.
api('/api/config').then(config => { board = config.board; $('board-url').textContent = `Board: ${board}`; if (config.sso === true) { sso = true; $('sign-out').hidden = out(); } }).catch(err => { if (machine) error(err); }).finally(refresh);
api('/api/config').then(config => { board = config.board; $('board-url').textContent = `Board: ${board}`; }).catch(err => { if (machine) error(err); }).finally(refresh);
})();
-6
View File
@@ -30,10 +30,6 @@
const out = () => document.body.classList.contains('signed-out');
const signOut = () => { if (!out()) document.dispatchEvent(new Event('mosaic-signed-out')); };
const signedOut = () => new ReadError('signed-out', 'The sign-in proxy redirected the read.', 0);
// Q175 phase 1 (#1690, sage-1340 F1): under --sso a read without a session gets 401 `sign-in
// required` from the Console itself. That signs out the same way; any other 401 stays an error.
const signInRequired = (res, body) => res.status === 401 && body?.error === 'sign-in required';
const signOutSso = () => { if (!out()) document.dispatchEvent(new CustomEvent('mosaic-signed-out', { detail: { signIn: true } })); };
// `replies` takes a message id as `id`; trail takes `subject`.
async function fetchRead(verb, subject) {
if (out()) throw signedOut();
@@ -42,7 +38,6 @@
catch { throw new ReadError('unreachable', 'The Console server did not answer.', 0); }
if (res.type === 'opaqueredirect') { signOut(); throw signedOut(); }
try { body = await res.json(); } catch { throw new ReadError('unreachable', 'The Console server did not answer.', 0); }
if (signInRequired(res, body)) { signOutSso(); throw signedOut(); }
if (!res.ok || !body || !Array.isArray(body.rows)) throw new ReadError(typeof body?.error === 'string' ? body.error : 'invalid-response', typeof body?.message === 'string' ? body.message : `HTTP ${res.status}`, res.status);
return { ...body, rows: body.rows, at: body.at };
}
@@ -55,7 +50,6 @@
try { res = await fetch(`/api/${path}`, { cache: 'no-store', redirect: 'manual' }); } catch { throw unreachable(); }
if (res.type === 'opaqueredirect') { signOut(); throw signedOut(); }
try { body = await res.json(); } catch { throw unreachable(); }
if (signInRequired(res, body)) { signOutSso(); throw signedOut(); }
if (!res.ok || !body || typeof body !== 'object' || Array.isArray(body)) throw Object.assign(new ReadError(typeof body?.error === 'string' ? body.error : 'invalid-response', typeof body?.message === 'string' ? body.message : `HTTP ${res.status}`, res.status), { module: true });
return body;
}
+1 -1
View File
@@ -9,7 +9,7 @@
<header class="cmdbar"><a href="/" class="wordmark"><svg class="mark" viewBox="0 0 16 17" aria-hidden="true"><use href="/icons.svg#mark"/></svg>Mosaic<small>Console</small></a>
<button type="button" class="cmdk" id="cmdk" aria-haspopup="dialog" aria-controls="cmdk-dialog"><svg class="i" aria-hidden="true"><use href="/icons.svg#i-search"/></svg><span>Go to a view, decision or task</span><kbd>Ctrl K</kbd></button>
<p class="fresh" id="fresh"><span id="fresh-board"></span><span id="fresh-bus"></span></p>
<div class="cmd-right"><label>Palette<select id="palette"></select></label><label>Appearance<select id="mode"><option value="system">system</option><option value="light">light</option><option value="dim">dim</option><option value="dark">dark</option></select></label><button class="btn" id="refresh" type="button">Refresh</button><button class="btn" id="pause" type="button" aria-pressed="false">Pause</button><form id="sign-out" method="post" action="/auth/logout" hidden><button class="btn" type="submit">Sign out</button></form></div></header>
<div class="cmd-right"><label>Palette<select id="palette"></select></label><label>Appearance<select id="mode"><option value="system">system</option><option value="light">light</option><option value="dim">dim</option><option value="dark">dark</option></select></label><button class="btn" id="refresh" type="button">Refresh</button><button class="btn" id="pause" type="button" aria-pressed="false">Pause</button></div></header>
<div class="frame">
<nav class="tree" aria-label="Console"><h2 class="tree-title">Console</h2><ul class="s1-sections" id="sections"></ul><div id="board-tree"><h2 class="tree-title">Projects</h2><div id="projects"><p class="muted">Loading projects…</p></div><p class="small muted">Select a project to filter sessions. Waiting on you always shows all projects.</p></div></nav>
<main id="main" class="content" tabindex="-1"><div id="signed-out" class="banner ref" role="alert" tabindex="-1" hidden></div><div id="bus-down" class="banner err" role="alert" hidden></div><div class="page-head" id="board-head"><div><h1>Control board</h1><p id="status" class="small muted" role="status">Loading board…</p></div><div class="filters"><label><input id="show-machine" type="checkbox">Show machine sessions</label></div></div>
+26 -56
View File
@@ -61,8 +61,6 @@ async function body(req, max = 4096) {
// Row 65 (mos-0840) adds `routes`: each instance's harness, model, thinking and fallback.
const BUS_VERBS = ['inbox', 'tasks', 'agents', 'trail', 'decisions', 'credentials', 'replies', 'messages', 'usage', 'limits', 'names', 'instances', 'routes'];
const DECISION_LISTS = ['roles', 'recent'];
// Q175 phase 1 (#1690, F8): either sign-in cookie, which the tester listener refuses under --sso.
const SIGN_IN_COOKIE = /(?:^|;)\s*__host-mosaic_(?:session|login)\s*=/i;
// Row 177 (#1675): the chat writes the tester listener lets through, exactly the four chatHTTP routes.
const TESTER_CHAT_WRITE = /^\/api\/chat\/(?:projects|threads|threads\/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\/(?:messages|open))$/i;
async function busRead(res, bus, verb, search) {
@@ -162,18 +160,14 @@ async function busRead(res, bus, verb, search) {
// broker's to refuse. Nothing retries; an outcome the bus can't confirm is reported as unknown.
const SEND_KEYS = ['to', 'body', 'in_reply_to'];
const one = (req, name) => (req.headersDistinct[name]?.length === 1 ? req.headersDistinct[name][0] : null);
// Q175 phase 1 (#1690): under --sso the writer comes from a session the handler already checked,
// so it carries `sso: true` and the public origin; the preview writer still checks its header.
const signedIn = (req, p) => p.sso === true || one(req, p.header) === p.user;
const ownOrigin = p => p.origin ?? `https://${p.host}`;
async function busSend(req, res, { send, preview, log }) {
if (!signedIn(req, preview)) return json(res, 401, { error: 'sign-in required' });
if (one(req, preview.header) !== preview.user) return json(res, 401, { error: 'sign-in required' });
let to = null;
// `to` is quoted and cut short so a crafted value can't forge a log line.
const line = outcome => log(`${new Date().toISOString()} send user=${JSON.stringify(preview.user)} to=${typeof to === 'string' ? JSON.stringify(to.slice(0, 64)) : 'none'} ${outcome}`);
// `sent` tells the page which it was: false, nothing was written; 'unknown', it may have been.
const refuse = (status, error, message, sent = false) => { line(`error=${error}`); return json(res, status, { error, message, sent }); };
if (one(req, 'origin') !== ownOrigin(preview)) return refuse(403, 'cross-origin request refused', 'a send needs the Console\'s own Origin');
if (one(req, 'origin') !== `https://${preview.host}`) return refuse(403, 'cross-origin request refused', 'a send needs the Console\'s own Origin');
if (one(req, 'sec-fetch-site') !== 'same-origin') return refuse(403, 'cross-site request refused', 'a send needs Sec-Fetch-Site: same-origin');
let value;
try { value = JSON.parse((await body(req, 16384)).toString('utf8')); } catch (err) { return refuse(400, 'invalid-request', err.message); }
@@ -247,10 +241,10 @@ export function proofOptions({ proofHeader, proofFile } = {}, preview = null) {
// REQ-WEB-1: same signed-in send boundary, a distinct strict settings payload.
async function settingsSend(req, res, { settingsWrite, preview, log }) {
const refuse = (status, error, message) => json(res, status, { error, message, sent: false });
if (!signedIn(req, preview)) {
if (one(req, preview.header) !== preview.user) {
return refuse(401, 'sign-in required', 'sign in again');
}
if (one(req, 'origin') !== ownOrigin(preview)) {
if (one(req, 'origin') !== `https://${preview.host}`) {
return refuse(403, 'cross-origin request refused', 'settings need the Console origin');
}
if (one(req, 'sec-fetch-site') !== 'same-origin') {
@@ -298,8 +292,8 @@ async function seatSend(req, res, { route, write, preview, log }) {
// The instance is quoted and cut short so a crafted value can't forge a log line.
const line = outcome => log(`${new Date().toISOString()} ${route.kind} user=${JSON.stringify(preview.user)} instance=${quoted(instance)} ${outcome}`);
const refuse = (status, error, message, sent = false) => { line(`error=${error}`); return json(res, status, { error, message, sent }); };
if (!preview.tester && !signedIn(req, preview)) return json(res, 401, { error: 'sign-in required', message: 'sign in again', sent: false });
if (one(req, 'origin') !== ownOrigin(preview)) return refuse(403, 'cross-origin request refused', 'this needs the Console\'s own Origin');
if (!preview.tester && one(req, preview.header) !== preview.user) return json(res, 401, { error: 'sign-in required', message: 'sign in again', sent: false });
if (one(req, 'origin') !== (preview.origin ?? `https://${preview.host}`)) return refuse(403, 'cross-origin request refused', 'this needs the Console\'s own Origin');
if (one(req, 'sec-fetch-site') !== 'same-origin') return refuse(403, 'cross-site request refused', 'this needs same-origin Fetch Metadata');
let value;
try { value = JSON.parse((await body(req)).toString('utf8')); } catch (err) { return refuse(400, 'invalid-request', err.message); }
@@ -341,8 +335,8 @@ async function instanceSend(req, res, { instancesWrite, preview, log }) {
// The instance is quoted and cut short so a crafted value can't forge a log line. Vars are never logged.
const line = outcome => log(`${new Date().toISOString()} instance user=${JSON.stringify(preview.user)} instance=${quoted(instance)} ${outcome}`);
const refuse = (status, error, message, sent = false) => { line(`error=${error}`); return json(res, status, { error, message, sent }); };
if (!preview.tester && !signedIn(req, preview)) return json(res, 401, { error: 'sign-in required', message: 'sign in again', sent: false });
if (one(req, 'origin') !== ownOrigin(preview)) return refuse(403, 'cross-origin request refused', 'this needs the Console\'s own Origin');
if (!preview.tester && one(req, preview.header) !== preview.user) return json(res, 401, { error: 'sign-in required', message: 'sign in again', sent: false });
if (one(req, 'origin') !== (preview.origin ?? `https://${preview.host}`)) return refuse(403, 'cross-origin request refused', 'this needs the Console\'s own Origin');
if (one(req, 'sec-fetch-site') !== 'same-origin') return refuse(403, 'cross-site request refused', 'this needs same-origin Fetch Metadata');
let value;
try { value = JSON.parse((await body(req, 16384)).toString('utf8')); } catch (err) { return refuse(400, 'invalid-request', err.message); }
@@ -388,8 +382,8 @@ async function routesSend(req, res, { routesWrite, preview, log }) {
let names = [];
const line = outcome => log(`${new Date().toISOString()} routes user=${JSON.stringify(preview.user)} instances=${quoted(names.join(','))} ${outcome}`);
const refuse = (status, error, message, sent = false) => { line(`error=${error}`); return json(res, status, { error, message, sent }); };
if (!preview.tester && !signedIn(req, preview)) return json(res, 401, { error: 'sign-in required', message: 'sign in again', sent: false });
if (one(req, 'origin') !== ownOrigin(preview)) return refuse(403, 'cross-origin request refused', 'this needs the Console\'s own Origin');
if (!preview.tester && one(req, preview.header) !== preview.user) return json(res, 401, { error: 'sign-in required', message: 'sign in again', sent: false });
if (one(req, 'origin') !== (preview.origin ?? `https://${preview.host}`)) return refuse(403, 'cross-origin request refused', 'this needs the Console\'s own Origin');
if (one(req, 'sec-fetch-site') !== 'same-origin') return refuse(403, 'cross-site request refused', 'this needs same-origin Fetch Metadata');
let value;
try { value = JSON.parse((await body(req, 16384)).toString('utf8')); } catch (err) { return refuse(400, 'invalid-request', err.message); }
@@ -426,8 +420,8 @@ async function projectFileSend(req, res, { projectSave, preview, log }) {
const line = outcome => log(`${new Date().toISOString()} project-file user=${JSON.stringify(preview.user)} project=${quoted(target?.project)} path=${quoted(target?.path)} ${outcome}`);
// `sent` tells the page which it was: false, nothing was written; 'unknown', it may have been.
const refuse = (status, error, message, sent = false) => { line(`error=${error}`); return json(res, status, { error, message, sent }); };
if (!signedIn(req, preview)) return json(res, 401, { error: 'sign-in required', message: 'sign in again', sent: false });
if (one(req, 'origin') !== ownOrigin(preview)) return refuse(403, 'cross-origin request refused', 'a save needs the Console\'s own Origin');
if (one(req, preview.header) !== preview.user) return json(res, 401, { error: 'sign-in required', message: 'sign in again', sent: false });
if (one(req, 'origin') !== `https://${preview.host}`) return refuse(403, 'cross-origin request refused', 'a save needs the Console\'s own Origin');
if (one(req, 'sec-fetch-site') !== 'same-origin') return refuse(403, 'cross-site request refused', 'a save needs same-origin Fetch Metadata');
let value;
try { value = JSON.parse((await body(req, FILE_SAVE_MAX)).toString('utf8')); } catch (err) { return refuse(400, 'invalid-request', err.message); }
@@ -466,17 +460,13 @@ async function projectFileRead(res, read, path, search) {
return json(res, busStatus(code), { error: code, message: err instanceof BusReadError ? err.message : code });
}
}
// `send` is the bus write (row 76): `args => Promise<{id}>`, only with the preview mode or `sso`.
// `sso` is createSso() from sso.mjs (Q175 phase 1): the human listener signs people in itself.
// `testerNoSignIn` makes the tester listener refuse anything that looks like a sign-in (--sso). `log`
// `send` is the bus write (row 76): `args => Promise<{id}>`, only with the preview mode. `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.
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, testerNoSignIn = false, testerAuthHeader = null, proof = null, testerProofHeader = null, send = null, settingsWrite = null, projectFiles = null, projectFile = null, projectSave = null, chat = null, nameWrite = null, assistantStop = null, instancesWrite = null, routesWrite = null, log = line => console.log(line), fieldsFile = undefined } = {}) {
export async function startServer({ host = '127.0.0.1', port = 7330, board = DEFAULT_BOARD, timeout = 20000, bus = null, reads = null, preview = null, tester = false, testerAuthHeader = null, proof = null, testerProofHeader = null, send = null, settingsWrite = null, projectFiles = null, projectFile = null, projectSave = null, chat = null, nameWrite = null, assistantStop = null, instancesWrite = null, routesWrite = null, log = line => console.log(line), fieldsFile = undefined } = {}) {
if (!isLoopback(host)) throw new Error('refusing to bind to non-loopback host');
if (host === 'localhost') host = '127.0.0.1';
if (tester && preview !== null) throw new Error('tester listener cannot use preview mode');
if (tester && sso !== null) throw new Error('tester listener cannot use sso');
if (sso !== null && (preview !== null || proof !== null)) throw new Error('--sso cannot be combined with --auth-header, --allow-user or --proof-*');
if (tester && host !== '127.0.0.1') throw new Error('tester listener must bind 127.0.0.1');
// Row 177 (#1675): chat is allowed. The tester listener's chat binds the tester, so the bus keeps it on tester threads.
if (tester && [send, settingsWrite, projectSave, assistantStop].some(value => value !== null)) throw new Error('tester listener permits only instance, route, name and chat writes');
@@ -486,26 +476,21 @@ export async function startServer({ host = '127.0.0.1', port = 7330, board = DEF
throw new Error('preview mode needs --public-host, --auth-header and --allow-user together');
}
if (proof !== null && (preview === null || tester)) throw new Error('proxy proof needs preview mode');
if (send !== null && ((preview === null && sso === null) || typeof send !== 'function')) throw new Error('--send needs the preview mode (--public-host, --auth-header and --allow-user) or --sso');
if (send !== null && (preview === null || typeof send !== 'function')) throw new Error('--send needs the preview mode (--public-host, --auth-header and --allow-user)');
const server = createServer(async (req, res) => {
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
// browsers apply form-action to that redirect, so those origins (from discovery) are allowed too.
res.setHeader('content-security-policy', `default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; connect-src 'self'; font-src 'self'; object-src 'none'; base-uri 'none'; frame-ancestors 'none'; form-action 'self'${sso ? sso.formTargets().map(o => ` ${o}`).join('') : ''}`);
res.setHeader('content-security-policy', "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; connect-src 'self'; font-src 'self'; object-src 'none'; base-uri 'none'; frame-ancestors 'none'; form-action 'self'");
// Presence, even with an empty value: never turn the public listener into a tester ingress.
if (!tester && Object.hasOwn(req.headers, 'x-mosaic-tester')) return json(res, 403, { error: 'tester listener required' });
if (tester && testerAuthHeader && Object.hasOwn(req.headers, testerAuthHeader.toLowerCase())) return json(res, 403, { error: 'proxy header refused' });
if (tester && testerProofHeader && Object.hasOwn(req.headers, testerProofHeader.toLowerCase())) return json(res, 403, { error: 'proxy proof header refused' });
// Under --sso the tester listener reads no cookie and takes no sign-in of any kind (design section 6).
if (tester && testerNoSignIn && (Object.hasOwn(req.headers, 'authorization') || SIGN_IN_COOKIE.test(req.headers.cookie ?? '') || Object.keys(req.headers).some(h => h.startsWith('x-authentik-'))))
return json(res, 403, { error: 'tester listener takes no sign-in' });
let path, search;
// Node keeps the first of repeated Host headers; a second one (a loopback Host, then NAME) is
// never legitimate, so it is refused before any check reads the Host.
if ((req.headersDistinct.host?.length ?? 0) > 1) return json(res, 400, { error: 'repeated Host refused' });
// Preview mode: a request for the public name, exactly, as the proxy forwards it.
const named = preview !== null ? (req.headers.host ?? '').toLowerCase() === preview.host : sso !== null && (req.headers.host ?? '').toLowerCase() === sso.publicHost;
const named = preview !== null && (req.headers.host ?? '').toLowerCase() === preview.host;
// The proof precedes every auth-header read, including the wrong-Host fence.
if (proof !== null) {
const values = req.headersDistinct[proof.header];
@@ -527,34 +512,19 @@ export async function startServer({ host = '127.0.0.1', port = 7330, board = DEF
}
({ pathname: path, search } = new URL(req.url, 'http://localhost'));
} catch { return json(res, 400, { error: 'invalid URL' }); }
// Under --sso the human listener serves only the public Host, whatever its port; GET /healthz
// 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.
if (req.headers.origin && req.headers.origin !== (named ? (sso ? sso.publicOrigin : `https://${preview.host}`) : `http://${req.headers.host}`)) return json(res, 403, { error: 'cross-origin request refused' });
let signer = preview;
if (sso !== null && named) {
if (path.startsWith('/auth/')) return sso.handle(req, res, path, search);
// Only a page load or a write keeps a session alive; the page's background reads don't (Q-D).
const session = path === '/healthz' ? null : sso.session(req, { touch: req.method === 'POST' || (req.method === 'GET' && !path.startsWith('/api/')) });
if (!session && path !== '/healthz') {
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();
}
if (session) signer = { sso: true, user: session.name, host: sso.publicHost, origin: sso.publicOrigin };
}
if (named && preview !== null) {
if (req.headers.origin && req.headers.origin !== (named ? `https://${preview.host}` : `http://${req.headers.host}`)) return json(res, 403, { error: 'cross-origin request refused' });
if (named) {
// One value, equal to the user id; the proxy strips client copies and sets it after sign-in.
const values = req.headersDistinct[preview.header];
if (!values || values.length !== 1 || values[0] !== preview.user) return json(res, 401, { error: 'sign-in required' });
}
const writer = tester ? { tester: true, user: 'tester', origin: `http://127.0.0.1:${server.address().port}` } : signer;
const writer = tester ? { tester: true, user: 'tester', origin: `http://127.0.0.1:${server.address().port}` } : preview;
if (tester && !['GET', 'HEAD'].includes(req.method) && !['/api/instances', '/api/routes', '/api/name'].includes(path) && !TESTER_CHAT_WRITE.test(path))
return json(res, 403, { error: 'read-only', sent: false });
if(path==='/api/settings'&&req.method==='POST'){
if(!send||!settingsWrite||!named)return json(res,403,{error:'read-only',sent:false});
return settingsSend(req,res,{settingsWrite,preview:signer,log});
return settingsSend(req,res,{settingsWrite,preview,log});
}
if (Object.hasOwn(SEAT_ROUTES, path)) {
if (req.method !== 'POST') return json(res, 405, { error: 'method not allowed' });
@@ -574,18 +544,18 @@ export async function startServer({ host = '127.0.0.1', port = 7330, board = DEF
}
if (path === '/api/project-file' && req.method === 'POST') {
if (!send || !projectSave || !named) return json(res, 403, { error: 'read-only', sent: false });
return projectFileSend(req, res, { projectSave, preview: signer, log });
return projectFileSend(req, res, { projectSave, preview, log });
}
// Message sending has its own bounded payload and repeats the identity check.
if (send && path === '/api/bus/message') {
if (req.method !== 'POST') return json(res, 405, { error: 'method not allowed' });
return busSend(req, res, { send, preview: signer, log });
return busSend(req, res, { send, preview, log });
}
if (path === '/api/chat' || path.startsWith('/api/chat/')) {
return chatHTTP(req, res, { path, search, chat, named, send, preview: writer, log });
}
// Nothing else but GET and HEAD gets past here in preview mode, whatever the Host.
if ((preview !== null || sso !== null) && req.method !== 'GET' && req.method !== 'HEAD') return json(res, 403, { error: 'read-only', message: 'the Console preview is read-only' });
if (preview !== null && req.method !== 'GET' && req.method !== 'HEAD') return json(res, 403, { error: 'read-only', message: 'the Console preview is read-only' });
// The conversation routes (#1507, CHAT-02) keep their query; the board validates it.
const conversation = path === '/api/conversations' || path === '/api/conversation';
const allowed = path === '/api/board' || conversation ? 'GET' : ['/api/seen', '/api/reply'].includes(path) ? 'POST' : null;
@@ -637,8 +607,8 @@ export async function startServer({ host = '127.0.0.1', port = 7330, board = DEF
if (req.method !== 'GET' && req.method !== 'HEAD') return json(res, 405, { error: 'method not allowed' });
// `send`: the page shows the composer only where a send can pass (the public name, --send on).
// `edit`: the editor shows Save only where a save can pass, the same test as the route's.
if (path === '/api/config') return json(res, 200, { board: upstream, ...(tester ? { tester: true } : {}), ...(sso ? { sso: true } : {}), send: !tester && Boolean(send) && named, edit: !tester && Boolean(send) && Boolean(projectSave) && named });
if (path === '/healthz') return json(res, 200, { ok: true, ...(sso ? { sso: sso.health() } : {}) });
if (path === '/api/config') return json(res, 200, { board: upstream, ...(tester ? { tester: true } : {}), send: !tester && Boolean(send) && named, edit: !tester && Boolean(send) && Boolean(projectSave) && named });
if (path === '/healthz') return json(res, 200, { ok: true });
if (path === '/favicon.ico') { res.writeHead(204); return res.end(); }
const file = files.get(path);
if (!file) return json(res, 404, { error: 'not found' });
-75
View File
@@ -1,75 +0,0 @@
// Q175 phase 1 (#1690): the shape of config.json's optional `console` key. The one loader,
// scripts/mosaic-config.mjs, runs this on every read, so a bad key refuses the config for every
// tool (design section 4, C1). The Console's --sso start runs it again before it uses the value.
// Shape only: nothing here touches the network or a slot file. A refusal names the key path and
// never quotes a value, so a refusal can't print a client id or a subject.
// The asymmetric algorithms oauth4webapi 3.8.8 verifies (its supported(), build/index.js). No HS*
// and no none, so a client secret can never be a signing key.
export const ALGS = Object.freeze(['PS256', 'ES256', 'RS256', 'PS384', 'ES384', 'RS384', 'PS512', 'ES512', 'RS512', 'Ed25519', 'EdDSA', 'ML-DSA-44', 'ML-DSA-65', 'ML-DSA-87']);
const PROVIDER_ID = /^[a-z0-9-]{1,32}$/;
const SLOT = /^[a-z0-9-]{1,64}$/;
const SUB = /^[\x21-\x7e]{1,255}$/;
const CLIENT_ID = /^[\x21-\x7e]{1,255}$/;
// The name goes where the preview's --allow-user went (serve.mjs USER): visible ASCII, no comma.
const NAME = /^[\x21-\x2b\x2d-\x7e]{1,64}$/;
// The bus keeps these for the tester and the human role (section 6), so no subject can sign in as one.
const RESERVED_NAMES = ['tester', 'human'];
const object = v => typeof v === 'object' && v !== null && !Array.isArray(v);
function refuse(where, why) {
throw new Error(`${where} ${why}`);
}
function keys(value, where, allowed, required = allowed) {
if (!object(value)) refuse(where, 'must be a JSON object');
for (const key of Object.keys(value)) if (!allowed.includes(key)) refuse(`${where}.${key}`, 'is not a supported key');
for (const key of required) if (!Object.hasOwn(value, key)) refuse(`${where}.${key}`, 'is required');
}
function httpsURL(value, where) {
let url;
if (typeof value !== 'string' || value.length > 512) refuse(where, 'must be an https URL of at most 512 characters');
try { url = new URL(value); } catch { refuse(where, 'must be an https URL'); }
if (url.protocol !== 'https:' || url.username || url.password || url.search || url.hash || value.includes('?') || value.includes('#')) {
refuse(where, 'must be https with no credentials, query or fragment');
}
return url;
}
// Returns the value unchanged when it passes; throws an Error naming the bad key path otherwise.
export function checkConsole(value, where = 'console') {
keys(value, where, ['sso']);
const sso = value.sso, at = `${where}.sso`;
keys(sso, at, ['publicOrigin', 'providers']);
const origin = httpsURL(sso.publicOrigin, `${at}.publicOrigin`);
if (origin.origin !== sso.publicOrigin) refuse(`${at}.publicOrigin`, 'must be an https origin with no path, query, fragment or trailing slash');
if (!Array.isArray(sso.providers) || sso.providers.length === 0 || sso.providers.length > 8) refuse(`${at}.providers`, 'must be a list of 1 to 8 providers');
const ids = new Set();
sso.providers.forEach((p, i) => {
const pat = `${at}.providers[${i}]`;
keys(p, pat, ['id', 'issuer', 'clientId', 'clientSecret', 'algs', 'subjects']);
if (typeof p.id !== 'string' || !PROVIDER_ID.test(p.id)) refuse(`${pat}.id`, 'must match [a-z0-9-]{1,32}');
if (ids.has(p.id)) refuse(`${pat}.id`, 'repeats an earlier provider id');
ids.add(p.id);
httpsURL(p.issuer, `${pat}.issuer`);
if (typeof p.clientId !== 'string' || !CLIENT_ID.test(p.clientId)) refuse(`${pat}.clientId`, 'must be 1 to 255 visible characters');
keys(p.clientSecret, `${pat}.clientSecret`, ['slot']);
if (typeof p.clientSecret.slot !== 'string' || !SLOT.test(p.clientSecret.slot)) refuse(`${pat}.clientSecret.slot`, 'must match [a-z0-9-]{1,64}');
if (!Array.isArray(p.algs) || p.algs.length === 0) refuse(`${pat}.algs`, 'must be a non-empty list');
p.algs.forEach((alg, j) => {
if (!ALGS.includes(alg)) refuse(`${pat}.algs[${j}]`, `must be one of ${ALGS.join(', ')}; HS* and none are refused`);
});
if (new Set(p.algs).size !== p.algs.length) refuse(`${pat}.algs`, 'repeats an algorithm');
if (!Array.isArray(p.subjects) || p.subjects.length === 0 || p.subjects.length > 64) refuse(`${pat}.subjects`, 'must be a list of 1 to 64 subjects');
const subs = new Set();
p.subjects.forEach((s, j) => {
const sat = `${pat}.subjects[${j}]`;
keys(s, sat, ['sub', 'name']);
if (typeof s.sub !== 'string' || !SUB.test(s.sub)) refuse(`${sat}.sub`, 'must be 1 to 255 visible characters');
if (subs.has(s.sub)) refuse(`${sat}.sub`, 'repeats an earlier subject');
subs.add(s.sub);
if (typeof s.name !== 'string' || !NAME.test(s.name)) refuse(`${sat}.name`, 'must be 1 to 64 visible characters');
if (RESERVED_NAMES.includes(s.name.toLowerCase())) refuse(`${sat}.name`, `must not be ${RESERVED_NAMES.join(' or ')}`);
});
});
return value;
}
-347
View File
@@ -1,347 +0,0 @@
// Q175 phase 1 (#1690, design agents/sage/work/q175-sso/DESIGN.md): the Console as an OIDC relying
// party. Used only by the human listener under --sso; startServer refuses it on the tester listener.
// Every URL handed to a provider is built from the configured publicOrigin, never from a request's
// Host. A refusal logs one fixed code and the provider id, never an exception, its message or its
// cause: oauth4webapi's errors carry token bodies and claims there.
import { createHash, randomBytes } from 'node:crypto';
import { closeSync, constants, fstatSync, lstatSync, openSync, readFileSync, realpathSync } from 'node:fs';
import { homedir } from 'node:os';
import { join, relative, isAbsolute, resolve } from 'node:path';
import { checkConsole } from './sso-config.mjs';
// oauth4webapi loads in createSso, not here: cli.mjs imports this module on every start, and a
// Console without --sso must not need the package (pinned-copy.test.mjs builds copies whose lock
// installs nothing). A real copy installs it from the root lock.
let oauth;
export const SESSION_COOKIE = '__Host-mosaic_session';
export const LOGIN_COOKIE = '__Host-mosaic_login';
const ATTRS = 'Path=/; Secure; HttpOnly; SameSite=Lax';
const LOGIN_MAX_AGE = 600;
const SESSION_ABSOLUTE = 8 * 3600 * 1000, SESSION_IDLE = 3600 * 1000;
const MAX_SESSIONS = 64, MAX_LOGINS = 32;
const FETCH_TIMEOUT = 10000;
const RETRY_CAP = 300000;
const CODES = new Set(['login-record-missing', 'state-mismatch', 'auth-error-response', 'grant-failed', 'id-token-invalid', 'signature-invalid', 'alg-refused', 'issuer-mismatch', 'subject-unknown', 'discovery-failed', 'jwks-failed']);
const sha256 = value => createHash('sha256').update(value).digest('hex');
const escape = text => String(text).replace(/[&<>"']/g, c => `&#${c.charCodeAt(0)};`);
const under = (parent, child) => { const rel = relative(parent, child); return rel === '' || (!rel.startsWith('..') && !isAbsolute(rel)); };
// The slot file holding a provider's client secret (design section 4, D1). Only the Console's --sso
// start calls this. A refusal names the provider and the check, never the path's contents.
export function readSlot(configDir, provider, { repo, checkout = null, copies = join(process.env.XDG_DATA_HOME || join(homedir(), '.local', 'share'), 'mosaic-stack', 'copies') } = {}) {
const where = `console.sso provider ${provider.id} client secret slot`;
const file = join(resolve(configDir), 'secrets', 'mosaic-stack', provider.clientSecret.slot);
let st;
try { st = lstatSync(file); } catch { throw new Error(`${where} is missing`); }
if (st.isSymbolicLink()) throw new Error(`${where} must not be a symbolic link`);
let fd, real, text;
try {
fd = openSync(file, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK);
st = fstatSync(fd);
if (!st.isFile()) throw new Error(`${where} must be a regular file`);
if (st.uid !== process.getuid()) throw new Error(`${where} must be owned by the Console's user`);
if (st.mode & 0o077) throw new Error(`${where} must have no group or other permission bits`);
real = realpathSync(file);
for (const root of [repo, checkout, copies].filter(Boolean)) {
let base;
try { base = realpathSync(root); } catch { base = resolve(root); }
if (under(base, real)) throw new Error(`${where} must not be inside the repository or a stack copy`);
}
text = readFileSync(fd, 'utf8');
} catch (err) {
throw err.message?.startsWith(where) ? err : new Error(`${where} is not readable`);
} finally {
if (fd !== undefined) closeSync(fd);
}
const secret = text.endsWith('\n') ? text.slice(0, -1) : text;
if (!secret || /[\r\n]/.test(secret)) throw new Error(`${where} must hold one non-empty line`);
return secret;
}
export function cookies(req, name) {
const found = [];
for (const part of (req.headers.cookie ?? '').split(';')) {
const at = part.indexOf('=');
if (at > 0 && part.slice(0, at).trim() === name) found.push(part.slice(at + 1).trim());
}
return found;
}
// The JWKS check at discovery, the same filter the library's key selection uses (kty, alg, use,
// key_ops, crv; RSA at least 2048 bits), without a kid since no token is at hand yet.
function usableKey(jwk, alg) {
const kty = { RS: 'RSA', PS: 'RSA', ES: 'EC', Ed: 'OKP', ML: 'AKP' }[alg.slice(0, 2)];
if (jwk === null || typeof jwk !== 'object' || jwk.kty !== kty) return false;
if (jwk.alg !== undefined && jwk.alg !== alg) return false;
if (jwk.use !== undefined && jwk.use !== 'sig') return false;
if (Array.isArray(jwk.key_ops) && !jwk.key_ops.includes('verify')) return false;
const crv = { ES256: 'P-256', ES384: 'P-384', ES512: 'P-521', Ed25519: 'Ed25519', EdDSA: 'Ed25519' }[alg];
if (crv && jwk.crv !== crv) return false;
if (kty === 'RSA' && (typeof jwk.n !== 'string' || Buffer.from(jwk.n, 'base64url').length * 8 < 2048)) return false;
return true;
}
class Unreachable extends Error {}
class Refused extends Error {}
const https = value => { try { return new URL(value).protocol === 'https:'; } catch { return false; } };
// One provider's discovery and JWKS. Throws Unreachable (network, timeout, a status other than 200,
// a body that isn't JSON) or Refused (a JSON document that fails a check). Q-B, design section 4.
async function discover(p, fetchOptions) {
let response, text;
const options = () => ({ ...fetchOptions, signal: AbortSignal.timeout(FETCH_TIMEOUT) });
try {
response = await oauth.discoveryRequest(new URL(p.issuer), options());
if (response.status !== 200 || !/^application\/json\b/i.test(response.headers.get('content-type') ?? '')) throw new Error();
text = await response.text();
JSON.parse(text);
} catch { throw new Unreachable(); }
let as;
try {
as = await oauth.processDiscoveryResponse(new URL(p.issuer), new Response(text, { status: 200, headers: { 'content-type': 'application/json' } }));
} catch { throw new Refused(); }
if (as.issuer !== p.issuer) throw new Refused();
if (!Array.isArray(as.id_token_signing_alg_values_supported) || !p.algs.some(a => as.id_token_signing_alg_values_supported.includes(a))) throw new Refused();
if (!Array.isArray(as.code_challenge_methods_supported) || !as.code_challenge_methods_supported.includes('S256')) throw new Refused();
if (![as.authorization_endpoint, as.token_endpoint, as.jwks_uri].every(https)) throw new Refused();
if (as.end_session_endpoint !== undefined && !https(as.end_session_endpoint)) throw new Refused();
let jwks;
try {
const fetcher = fetchOptions[oauth.customFetch] ?? fetch;
const res = await fetcher(as.jwks_uri, { method: 'GET', headers: { accept: 'application/json, application/jwk-set+json' }, redirect: 'manual', signal: AbortSignal.timeout(FETCH_TIMEOUT) });
if (res.status !== 200) throw new Error();
jwks = JSON.parse(await res.text());
} catch { throw new Unreachable(); }
if (jwks === null || typeof jwks !== 'object' || !Array.isArray(jwks.keys)) throw new Refused();
const usable = p.algs.filter(alg => jwks.keys.some(k => usableKey(k, alg)));
if (!usable.length) throw new Refused();
return as;
}
// `config` is console.sso, checked again here; `secrets` maps provider id to its client secret
// (readSlot). `fetch` is for tests: a fetch that trusts only the fake OP's certificate.
// `retryFirst` is for tests too; production waits 5 s, doubling, capped at 5 minutes.
export async function createSso(config, { secrets, fetch: customFetch = null, log = line => console.log(line), retryFirst = 5000 } = {}) {
checkConsole({ sso: config });
oauth ??= await import('oauth4webapi');
const publicOrigin = config.publicOrigin;
const publicHost = new URL(publicOrigin).host;
const redirectUri = `${publicOrigin}/auth/callback`;
const fetchOptions = customFetch ? { [oauth.customFetch]: customFetch } : {};
const sessions = new Map(), logins = new Map();
const timers = new Set();
let closed = false;
const refuse = (code, provider, extra = '') => {
if (!CODES.has(code)) throw new Error(`unknown sso log code ${code}`);
log(`${new Date().toISOString()} sso refused code=${code} provider=${provider}${extra}`);
};
const providers = new Map(config.providers.map(p => {
if (typeof secrets?.get(p.id) !== 'string') throw new Error(`console.sso provider ${p.id} has no client secret`);
return [p.id, {
config: p, state: 'unreachable', as: null, jwks: {},
client: { client_id: p.clientId, id_token_signed_response_alg: [...p.algs] },
auth: oauth.ClientSecretBasic(secrets.get(p.id)),
subjects: new Map(p.subjects.map(s => [s.sub, s.name])),
}];
}));
async function attempt(entry, atStart) {
try {
entry.as = await discover(entry.config, fetchOptions);
entry.jwks = {};
entry.state = 'ready';
} catch (err) {
if (err instanceof Refused) {
entry.state = 'refused';
refuse('discovery-failed', entry.config.id);
if (atStart) throw new Error(`console.sso provider ${entry.config.id} refused at discovery: the issuer, algorithms, S256 or JWKS check failed`);
return;
}
entry.state = 'unreachable';
refuse('discovery-failed', entry.config.id);
throw err;
}
}
function retry(entry, delay) {
if (closed) return;
const timer = setTimeout(async () => {
timers.delete(timer);
try { await attempt(entry, false); } catch { retry(entry, Math.min(delay * 2, RETRY_CAP)); }
}, delay);
timer.unref();
timers.add(timer);
}
for (const entry of providers.values()) {
try { await attempt(entry, true); } catch (err) {
if (!(err instanceof Unreachable)) throw err;
retry(entry, retryFirst);
}
}
const page = (res, status, title, html, headers = {}) => {
res.writeHead(status, { 'content-type': 'text/html; charset=utf-8', 'cache-control': 'no-store', ...headers });
res.end(`<!doctype html><meta charset="utf-8"><title>${escape(title)}</title><main style="font-family:sans-serif;max-width:32rem;margin:4rem auto"><h1>${escape(title)}</h1>${html}</main>\n`);
};
const redirect = (res, location, setCookie = []) => {
res.writeHead(303, { location, 'cache-control': 'no-store', ...(setCookie.length ? { 'set-cookie': setCookie } : {}) });
res.end();
};
const failed = (res, setCookie = []) => page(res, 403, 'Sign-in failed', '<p><a href="/auth/login">Sign in again</a></p>', setCookie.length ? { 'set-cookie': setCookie } : {});
const expire = name => `${name}=; ${ATTRS}; Max-Age=0`;
const remember = (map, key, value, cap) => {
while (map.size >= cap) map.delete(map.keys().next().value);
map.set(key, value);
};
// B2: only a URL on publicOrigin itself; anything else, or nothing, is publicOrigin's `/`. The
// Location is absolute and its origin checked again: dot segments can leave a pathname of `//host`,
// which as a relative Location a browser reads as another origin (webby-0019 B1, sage-1403).
function returnURL(ret) {
const home = `${publicOrigin}/`;
try {
if (typeof ret !== 'string' || !ret) return home;
const u = new URL(ret, publicOrigin);
if (u.origin !== publicOrigin) return home;
const out = new URL(`${publicOrigin}${u.pathname}${u.search}`);
return out.origin === publicOrigin ? out.href : home;
} catch { return home; }
}
async function start(res, entry, ret) {
const state = oauth.generateRandomState(), nonce = oauth.generateRandomNonce(), verifier = oauth.generateRandomCodeVerifier();
const challenge = await oauth.calculatePKCECodeChallenge(verifier);
const id = randomBytes(32).toString('base64url');
remember(logins, sha256(id), { provider: entry.config.id, state, nonce, verifier, ret: typeof ret === 'string' ? ret.slice(0, 2048) : '/', expires: Date.now() + LOGIN_MAX_AGE * 1000 }, MAX_LOGINS);
const url = new URL(entry.as.authorization_endpoint);
for (const [k, v] of Object.entries({ client_id: entry.config.clientId, response_type: 'code', scope: 'openid profile email', redirect_uri: redirectUri, state, nonce, code_challenge: challenge, code_challenge_method: 'S256' })) url.searchParams.set(k, v);
return redirect(res, url.href, [`${LOGIN_COOKIE}=${id}; ${ATTRS}; Max-Age=${LOGIN_MAX_AGE}`]);
}
function login(res, query) {
const asked = query.get('provider');
const ret = query.get('return');
if (asked === null && providers.size > 1) {
const items = [...providers.values()].map(e => `<li><a href="/auth/login?${escape(new URLSearchParams({ provider: e.config.id, ...(ret ? { return: ret } : {}) }))}">${escape(e.config.id)}</a></li>`).join('');
return page(res, 200, 'Sign in', `<ul>${items}</ul>`);
}
const entry = asked === null ? [...providers.values()][0] : providers.get(asked);
if (!entry) return page(res, 404, 'Unknown sign-in provider', '<p><a href="/auth/login">Sign in</a></p>');
if (entry.state !== 'ready') return page(res, 503, 'sign-in unavailable', '<p>The sign-in provider can\'t be reached. Try again shortly.</p>');
return start(res, entry, ret);
}
// Design section 1, steps 2-7. Each refusal ends here with one log line and the 403 page.
async function callback(res, query, req) {
const clear = [expire(LOGIN_COOKIE)];
const ids = cookies(req, LOGIN_COOKIE);
const key = ids.length === 1 ? sha256(ids[0]) : null;
const record = key === null ? undefined : logins.get(key);
if (key !== null) logins.delete(key);
// The provider comes from the record, never from the query.
const entry = record && providers.get(record.provider);
if (!record || record.expires <= Date.now() || !entry) {
refuse('login-record-missing', record?.provider ?? 'none');
return failed(res, clear);
}
const pid = entry.config.id;
if (entry.state !== 'ready') { refuse('discovery-failed', pid); return failed(res, clear); }
let params;
try { params = oauth.validateAuthResponse(entry.as, entry.client, query, record.state); } catch (err) {
refuse(err instanceof oauth.AuthorizationResponseError ? 'auth-error-response' : 'state-mismatch', pid);
return failed(res, clear);
}
const options = () => ({ ...fetchOptions, [oauth.jwksCache]: entry.jwks, signal: AbortSignal.timeout(FETCH_TIMEOUT) });
let response;
try { response = await oauth.authorizationCodeGrantRequest(entry.as, entry.client, entry.auth, params, redirectUri, record.verifier, options()); } catch {
refuse('grant-failed', pid); return failed(res, clear);
}
let result, claims;
try {
result = await oauth.processAuthorizationCodeResponse(entry.as, entry.client, response, { expectedNonce: record.nonce, requireIdToken: true });
claims = oauth.getValidatedIdTokenClaims(result);
} catch (err) {
refuse(err instanceof oauth.ResponseBodyError ? 'grant-failed' : 'id-token-invalid', pid);
return failed(res, clear);
}
// Step 5: the library skips this for a token-endpoint ID token; point 2 needs it, on the same
// Response object. Its throw ends the callback.
try { await oauth.validateApplicationLevelSignature(entry.as, response, options()); } catch (err) {
const fetching = !(err instanceof oauth.OperationProcessingError || err instanceof oauth.UnsupportedOperationError) || [oauth.RESPONSE_IS_NOT_CONFORM, oauth.RESPONSE_IS_NOT_JSON].includes(err.code);
refuse(fetching ? 'jwks-failed' : 'signature-invalid', pid);
return failed(res, clear);
}
// Step 6: our own checks on top of the library's.
let header;
try { header = JSON.parse(Buffer.from(result.id_token.split('.')[0], 'base64url').toString('utf8')); } catch { header = null; }
if (!header || !entry.config.algs.includes(header.alg)) { refuse('alg-refused', pid); return failed(res, clear); }
if (claims.iss !== entry.config.issuer) { refuse('issuer-mismatch', pid); return failed(res, clear); }
const name = typeof claims.sub === 'string' ? entry.subjects.get(claims.sub) : undefined;
if (name === undefined) {
refuse('subject-unknown', pid, ` iss=${JSON.stringify(String(claims.iss).slice(0, 255))} sub=${JSON.stringify(String(claims.sub).slice(0, 255))}`);
return failed(res, clear);
}
// Step 7: the session, then the return path.
const id = randomBytes(32).toString('base64url'), now = Date.now();
remember(sessions, sha256(id), { provider: pid, iss: claims.iss, sub: claims.sub, name, idToken: result.id_token, created: now, seen: now }, MAX_SESSIONS);
return redirect(res, returnURL(record.ret), [...clear, `${SESSION_COOKIE}=${id}; ${ATTRS}`]);
}
// The session for this request, or null. `touch` is true only for a page load or a write (Q-D).
function session(req, { touch = false } = {}) {
const ids = cookies(req, SESSION_COOKIE);
if (ids.length !== 1) return null;
const key = sha256(ids[0]);
const s = sessions.get(key);
if (!s) return null;
const now = Date.now();
if (now - s.created >= SESSION_ABSOLUTE || now - s.seen >= SESSION_IDLE) { sessions.delete(key); return null; }
if (touch) s.seen = now;
return s;
}
function logout(req, res) {
const one = name => (req.headersDistinct[name]?.length === 1 ? req.headersDistinct[name][0] : null);
if (one('origin') !== publicOrigin || one('sec-fetch-site') !== 'same-origin') {
return page(res, 403, 'Sign-out refused', '<p>Sign out from the Console page.</p>');
}
const ids = cookies(req, SESSION_COOKIE);
const key = ids.length === 1 ? sha256(ids[0]) : null;
const s = key === null ? undefined : sessions.get(key);
if (key !== null) sessions.delete(key);
const entry = s && providers.get(s.provider);
let location = `${publicOrigin}/auth/signed-out`;
if (entry?.as?.end_session_endpoint) {
const url = new URL(entry.as.end_session_endpoint);
url.searchParams.set('id_token_hint', s.idToken);
url.searchParams.set('post_logout_redirect_uri', `${publicOrigin}/auth/signed-out`);
url.searchParams.set('client_id', entry.config.clientId);
location = url.href;
}
return redirect(res, location, [expire(SESSION_COOKIE)]);
}
// `/auth/*` on the human listener; the caller has already checked the Host.
async function handle(req, res, path, search) {
const query = new URLSearchParams(search);
if (path === '/auth/logout') {
if (req.method !== 'POST') return page(res, 405, 'Method not allowed', '');
return logout(req, res);
}
if (req.method !== 'GET') return page(res, 405, 'Method not allowed', '');
if (path === '/auth/login') return login(res, query);
if (path === '/auth/callback') return callback(res, query, req);
if (path === '/auth/signed-out') return page(res, 200, 'Signed out', '<p><a href="/auth/login">Sign in</a></p>');
return page(res, 404, 'Not found', '');
}
return {
publicOrigin, publicHost, sessions, logins, handle, session, returnURL,
health: () => Object.fromEntries([...providers].map(([id, e]) => [id, e.state])),
formTargets: () => [...new Set([...providers.values()].filter(e => e.as?.end_session_endpoint).map(e => new URL(e.as.end_session_endpoint).origin))],
summary: () => config.providers.map(p => ({ id: p.id, issuer: p.issuer, subjects: p.subjects.length })),
close() { closed = true; for (const t of timers) clearTimeout(t); timers.clear(); },
};
}
-155
View File
@@ -1,155 +0,0 @@
// Q175 phase 1 (#1690, design section 9): a fake OpenID provider for the --sso tests. A loopback
// https server with a self-signed certificate made in a temp dir; `op.fetch` is a fetch that trusts
// only that certificate, so the code under test needs no insecure flag. It serves any number of
// issuers (`/<name>/`) at once, each with discovery, a JWKS, authorization, token and end-session
// endpoints. Keys come from node:crypto. Nothing here reaches any other host.
import { constants, createHash, createHmac, generateKeyPairSync, randomBytes, sign } from 'node:crypto';
import { spawnSync } from 'node:child_process';
import { mkdtempSync, readFileSync, rmSync } from 'node:fs';
import { createServer, request } from 'node:https';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
export const b64url = value => Buffer.from(typeof value === 'string' ? value : JSON.stringify(value)).toString('base64url');
export function rsaKey(kid = `rsa-${randomBytes(4).toString('hex')}`, bits = 2048) {
const { privateKey, publicKey } = generateKeyPairSync('rsa', { modulusLength: bits });
return { kid, alg: 'RS256', privateKey, jwk: { ...publicKey.export({ format: 'jwk' }), kid, use: 'sig', alg: 'RS256' } };
}
export function ecKey(kid = `ec-${randomBytes(4).toString('hex')}`) {
const { privateKey, publicKey } = generateKeyPairSync('ec', { namedCurve: 'P-256' });
return { kid, alg: 'ES256', privateKey, jwk: { ...publicKey.export({ format: 'jwk' }), kid, use: 'sig', alg: 'ES256' } };
}
export function psKey(kid = `ps-${randomBytes(4).toString('hex')}`) {
const k = rsaKey(kid);
return { ...k, alg: 'PS256', jwk: { ...k.jwk, alg: 'PS256' } };
}
// A compact JWS. `alg` HS256 uses `hmac` as the key, `none` has an empty signature.
export function jwt(header, payload, { key = null, hmac = null } = {}) {
const input = `${b64url(header)}.${b64url(payload)}`;
let sig = '';
if (header.alg === 'HS256') sig = createHmac('sha256', hmac).update(input).digest('base64url');
else if (header.alg === 'RS256') sig = sign('sha256', Buffer.from(input), key).toString('base64url');
else if (header.alg === 'PS256') sig = sign('sha256', Buffer.from(input), { key, padding: constants.RSA_PKCS1_PSS_PADDING, saltLength: 32 }).toString('base64url');
else if (header.alg === 'ES256') sig = sign('sha256', Buffer.from(input), { key, dsaEncoding: 'ieee-p1363' }).toString('base64url');
return `${input}.${sig}`;
}
function certificate() {
const dir = mkdtempSync(join(tmpdir(), 'fake-oidc-'));
try {
const r = spawnSync('openssl', ['req', '-x509', '-newkey', 'ec', '-pkeyopt', 'ec_paramgen_curve:P-256', '-nodes', '-days', '1',
'-subj', '/CN=127.0.0.1', '-addext', 'subjectAltName=IP:127.0.0.1', '-keyout', join(dir, 'key.pem'), '-out', join(dir, 'cert.pem')], { encoding: 'utf8' });
if (r.status !== 0) throw new Error(`openssl failed: ${r.stderr}`);
return { key: readFileSync(join(dir, 'key.pem')), cert: readFileSync(join(dir, 'cert.pem')) };
} finally { rmSync(dir, { recursive: true, force: true }); }
}
// One issuer's state. `discovery` is merged over the defaults; `mode` is 'ok', 'down' (the socket is
// destroyed) or 'html' (200 with an HTML page) for discovery.
function issuerState(name, base, opts) {
const keys = opts.keys ?? [rsaKey()];
return {
name, issuer: `${base}/${name}/`, keys, retired: [], clientId: opts.clientId ?? `client-${name}`, clientSecret: opts.clientSecret ?? `secret-${name}-${randomBytes(6).toString('hex')}`,
discovery: opts.discovery ?? {}, mode: 'ok', jwksOverride: null, codes: new Map(), acceptTwice: false, requirePkce: true,
tokenRequests: [], authorizeRequests: [], jwksFetches: 0, discoveryFetches: 0,
};
}
export async function fakeOidc() {
const { key, cert } = certificate();
const issuers = new Map();
const server = createServer({ key, cert }, (req, res) => {
const url = new URL(req.url, 'https://127.0.0.1');
const [, name, ...rest] = url.pathname.split('/');
const iss = issuers.get(name);
const route = rest.join('/');
const json = (status, body, type = 'application/json') => { res.writeHead(status, { 'content-type': type }); res.end(typeof body === 'string' ? body : JSON.stringify(body)); };
if (!iss) return json(404, { error: 'no issuer' });
if (route === '.well-known/openid-configuration') {
iss.discoveryFetches++;
if (iss.mode === 'down') return req.socket.destroy();
if (iss.mode === 'html') return json(200, '<!doctype html><title>Sign in</title>', 'text/html');
return json(200, discovery(iss));
}
if (route === 'jwks') {
iss.jwksFetches++;
return json(200, iss.jwksOverride ?? { keys: iss.keys.map(k => k.jwk) });
}
if (route === 'authorize') { iss.authorizeRequests.push(Object.fromEntries(url.searchParams)); return json(200, { ok: true }); }
if (route === 'end-session') return json(200, { ok: true });
if (route === 'token' && req.method === 'POST') {
let text = '';
req.on('data', c => (text += c));
req.on('end', () => {
const form = new URLSearchParams(text);
const basic = Buffer.from((req.headers.authorization ?? '').replace(/^Basic /, ''), 'base64').toString();
const [id, secret] = basic.split(':').map(decodeURIComponent);
iss.tokenRequests.push({ form: Object.fromEntries(form), clientId: id });
if (id !== iss.clientId || secret !== iss.clientSecret) return json(401, { error: 'invalid_client' });
const grant = iss.codes.get(form.get('code'));
if (!grant) return json(400, { error: 'invalid_grant' });
if (!iss.acceptTwice) iss.codes.delete(form.get('code'));
if (form.get('redirect_uri') !== grant.redirectUri) return json(400, { error: 'invalid_grant', error_description: 'redirect_uri' });
if (iss.requirePkce && createHash('sha256').update(form.get('code_verifier') ?? '').digest('base64url') !== grant.challenge) return json(400, { error: 'invalid_grant', error_description: 'pkce' });
return json(200, { access_token: randomBytes(16).toString('hex'), token_type: 'Bearer', expires_in: 300, id_token: grant.idToken });
});
return;
}
return json(404, { error: 'not found' });
});
await new Promise(r => server.listen(0, '127.0.0.1', r));
const base = `https://127.0.0.1:${server.address().port}`;
const discovery = iss => ({
issuer: iss.issuer, authorization_endpoint: `${iss.issuer}authorize`, token_endpoint: `${iss.issuer}token`, jwks_uri: `${iss.issuer}jwks`,
end_session_endpoint: `${iss.issuer}end-session`, response_types_supported: ['code'], subject_types_supported: ['public'],
id_token_signing_alg_values_supported: [...new Set(iss.keys.map(k => k.alg))], code_challenge_methods_supported: ['S256'],
...iss.discovery,
});
// A fetch over node:https that trusts only this certificate. oauth4webapi passes a Headers object
// and a string or URLSearchParams body.
const opFetch = (input, init = {}) => new Promise((resolve, reject) => {
const url = new URL(typeof input === 'string' ? input : input.href ?? input.url);
if (url.protocol !== 'https:' || url.hostname !== '127.0.0.1' || url.port !== String(server.address().port)) return reject(new TypeError(`fake OP fetch refuses ${url.origin}`));
const headers = Object.fromEntries(new Headers(init.headers ?? {}));
const body = init.body === undefined || init.body === null ? undefined : String(init.body);
if (body !== undefined) headers['content-length'] = Buffer.byteLength(body);
const req = request(url, { method: init.method ?? 'GET', headers, ca: cert, signal: init.signal }, res => {
const chunks = [];
res.on('data', c => chunks.push(c));
res.on('end', () => {
const h = new Headers();
for (const [k, v] of Object.entries(res.headers)) h.set(k, Array.isArray(v) ? v.join(', ') : v);
const response = new Response(res.statusCode === 204 ? null : Buffer.concat(chunks), { status: res.statusCode, headers: h });
Object.defineProperty(response, 'url', { value: url.href });
resolve(response);
});
res.on('error', reject);
});
req.on('error', reject);
req.end(body);
});
return {
base, fetch: opFetch, cert,
issuer(name, opts = {}) { const iss = issuerState(name, base, opts); issuers.set(name, iss); return iss; },
// A code for `iss` whose ID token is built from the start redirect's nonce, the claims and header
// overrides. `challenge` defaults to the start redirect's; `idToken` replaces the token outright.
grant(iss, authorizeUrl, { sub = `user-${iss.name}`, claims = {}, header = {}, key = iss.keys[0], idToken = null, challenge = null, redirectUri = null } = {}) {
const q = new URL(authorizeUrl).searchParams;
const now = Math.floor(Date.now() / 1000);
const payload = { iss: iss.issuer, sub, aud: iss.clientId, iat: now, exp: now + 300, nonce: q.get('nonce'), ...claims };
for (const [k, v] of Object.entries(payload)) if (v === undefined) delete payload[k];
const token = idToken ?? jwt({ alg: key.alg, kid: key.kid, typ: 'JWT', ...header }, payload, { key: key.privateKey, hmac: iss.clientSecret });
const code = randomBytes(12).toString('base64url');
iss.codes.set(code, { idToken: token, challenge: challenge ?? q.get('code_challenge'), redirectUri: redirectUri ?? q.get('redirect_uri') });
return { code, idToken: token, payload };
},
rotate(iss, next = rsaKey()) { iss.retired.push(...iss.keys); iss.keys = [next]; return next; },
close: () => new Promise(r => { server.close(r); server.closeAllConnections(); }),
};
}
+1 -1
View File
@@ -30,7 +30,7 @@ function fakeCopy(dir) {
writeFileSync(join(copyRoot(dir), 'packages', 'webui', 'src', 'cli.mjs'), '');
return copyRoot(dir);
}
const SEND_REFUSAL = /^webui-service: --send needs the preview mode \(--public-host, --auth-header and --allow-user\) or --sso$/m;
const SEND_REFUSAL = /^webui-service: --send needs the preview mode \(--public-host, --auth-header and --allow-user\)$/m;
const TEMPLATE = readFileSync(join(REPO, 'packages', 'webui', 'systemd', 'mosaic-webui.service.in'), 'utf8');
// The unit as the base script renders it (row 75): the template with its four
-132
View File
@@ -1,132 +0,0 @@
// Q175 phase 1 (#1690, design test 24, sage-1340 F1, sage-1347 F7): the page under --sso. A read
// the Console answers 401 `sign-in required` signs the page out the way a proxy redirect does, and
// the line links to /auth/login with the current path and query; any other 401 stays an error. Both
// fetch paths in app.js and both in bus.js (fetchRead and fetchApi) do it. The Sign out form shows
// only when /api/config says sso. The board-held.test.mjs stub, with two lists of paths: those in
// window.__signIn answer 401 `sign-in required`, those in window.__other 401 `unauthenticated`. A
// page URL holding `sso` gets `sso: true` added to /api/config. Poll timers fire after 150 ms.
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { mkdtempSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { Store } from '../../bus/src/store.mjs';
import { Broker } from '../../bus/src/broker.mjs';
import { views } from '../../bus/src/views.mjs';
import { startServer } from '../src/serve.mjs';
import { browser } from './browser.mjs';
import { fixture, close } from './fixture.mjs';
const businesses = { demo: { id: 'demo', human: 'jason', arbiters: { technical: 'cto', delivery: 'pm' }, roles: { pm: { authority: { withinRole: ['message.send'], crossRole: [] } }, cto: { authority: { withinRole: ['message.send'], crossRole: [] } } } } };
const stub = `(() => {
const real = window.fetch.bind(window);
window.__reqs = []; window.__signIn = []; window.__other = [];
const timeout = window.setTimeout.bind(window);
window.setTimeout = (f, ms, ...rest) => timeout(f, ms === 10000 ? 150 : ms, ...rest);
const answer = (status, body) => new Response(JSON.stringify(body), { status, headers: { 'content-type': 'application/json' } });
window.fetch = async (url, init = {}) => {
const path = String(url).split('?')[0];
window.__reqs.push(path);
if (window.__signIn.includes(path)) return answer(401, { error: 'sign-in required' });
if (window.__other.includes(path)) return answer(401, { error: 'unauthenticated' });
if (path === '/api/config' && /sso/.test(location.search)) return answer(200, { ...(await (await real(url, init)).json()), sso: true });
return real(url, init);
};
})();`;
async function setup() {
const root = mkdtempSync(join(tmpdir(), 'webui-sso-browser-'));
const store = new Store(root), b0 = new Broker({ store, businesses });
b0.request(b0.bindLaunch({ business: 'demo', role: 'pm', run: 'pm-run', harness: 'pi', address: 'pm-run' }), { verb: 'role.claim' });
const reader = b0.bindReader({ business: 'demo' });
const bus = views({ call: async (verb, args = {}) => structuredClone(b0.request(reader, { verb, args })) });
const f = await fixture(), b = await browser({ machine: true });
const web = await startServer({ port: 0, board: f.boardURL, bus }), base = `http://127.0.0.1:${web.address().port}/`;
await b.call('Page.addScriptToEvaluateOnNewDocument', { source: stub });
await b.viewport(1280);
const wait = expr => b.evaluate(`(async()=>{for(let i=0;i<100;i++){if(${expr})return true;await new Promise(r=>setTimeout(r,50))}throw new Error('Condition timed out: '+${JSON.stringify(expr)})})()`);
const text = sel => b.evaluate(`document.querySelector(${JSON.stringify(sel)})?.textContent ?? null`);
const shown = sel => b.evaluate(`(()=>{const e=document.querySelector(${JSON.stringify(sel)});return !!e && !e.hidden && getComputedStyle(e).display !== 'none' && !!e.getClientRects().length})()`);
const reqs = () => b.evaluate('window.__reqs.length');
const settle = () => new Promise(r => setTimeout(r, 600));
const done = async () => { await b.close(); await close(web); await f.close(); store.close(); rmSync(root, { recursive: true, force: true }); };
return { b, base, wait, text, shown, reqs, settle, done };
}
const LOADED = 'document.querySelector("table.sessions [data-open=\'proj/agent1\']")';
const LINE = 'Signed out. Sign in again.';
test('a 401 sign-in required on each fetch path signs the page out with a sign-in link and stops the refresh; another 401 stays an error', { timeout: 120000 }, async () => {
const { b, base, wait, text, shown, reqs, settle, done } = await setup();
// The line, its link back to this page, the Sign out form gone, and no further fetch.
const out = async ret => {
await wait('!document.querySelector("#signed-out").hidden');
assert.equal(await text('#signed-out'), LINE);
assert.equal(await b.evaluate('document.querySelector("#signed-out a").getAttribute("href")'), `/auth/login?return=${encodeURIComponent(ret)}`);
assert.equal(await shown('#sign-out'), false);
assert.equal(await b.evaluate('document.querySelector("#refresh").disabled'), true);
const n = await reqs();
await b.evaluate('document.querySelector("#refresh").click(), true');
await settle();
assert.equal(await reqs(), n, 'the page fetched after signing out');
};
try {
// app.js api(): the Board read, with sso on from /api/config.
await b.navigate(`${base}?sso&x=1`);
await wait(LOADED);
await b.evaluate(`window.__signIn.push('/api/board'), document.querySelector('#refresh').click(), true`);
await out('/?sso&x=1');
// app.js getJSON: the conversation catalogue behind History.
await b.navigate(`${base}?sso&conv`);
await wait(LOADED);
await b.evaluate(`window.__signIn.push('/api/conversations'), document.querySelector('table.sessions [data-history="proj/agent1"]').click(), true`);
await out('/?sso&conv');
// app.js api(): the board POST behind Seen.
await b.navigate(`${base}?sso&seen`);
await wait(LOADED);
await b.evaluate(`document.querySelector('table.sessions [data-open="proj/agent1"]').click(), true`);
await wait('document.querySelector("#inspection [data-seen]")');
await b.evaluate(`window.__signIn.push('/api/seen'), document.querySelector('#inspection [data-seen]').click(), true`);
await out('/?sso&seen');
// bus.js fetchRead and fetchApi, on a page whose /api/config has no sso: the link comes from
// the 401 itself (F1).
await b.navigate(`${base}?read#/inbox`);
await wait(LOADED);
await b.evaluate(`window.__signIn.push('/api/bus/inbox'), document.querySelector('#refresh').click(), true`);
await out('/?read');
await b.navigate(`${base}?module`);
await wait(LOADED);
await b.evaluate(`window.__signIn.push('/api/queue'), location.hash = '#/queue', true`);
await out('/?module');
// Any other 401 is an error: no sign-out, and the polls go on.
await b.navigate(`${base}?sso&other`);
await wait(LOADED);
await b.evaluate(`window.__other.push('/api/board', '/api/bus/inbox', '/api/queue'), document.querySelector('#refresh').click(), true`);
await settle();
assert.equal(await b.evaluate('document.querySelector("#signed-out").hidden'), true);
assert.equal(await b.evaluate('document.body.classList.contains("signed-out")'), false);
const n = await reqs();
await settle();
assert.ok(await reqs() > n, 'the refresh stopped on another 401');
assert.equal(await shown('#sign-out'), true);
} finally { await done(); }
});
test('the Sign out form shows only when /api/config says sso, and posts to /auth/logout', { timeout: 120000 }, async () => {
const { b, base, wait, shown, done } = await setup();
try {
await b.navigate(base);
await wait(LOADED);
await new Promise(r => setTimeout(r, 300));
assert.equal(await shown('#sign-out'), false);
await b.navigate(`${base}?sso`);
await wait(`${LOADED} && !document.querySelector("#sign-out").hidden`);
assert.equal(await shown('#sign-out'), true);
assert.deepEqual(await b.evaluate('(() => { const f = document.querySelector("#sign-out"); return [f.method, f.getAttribute("action"), f.querySelector("button").textContent, f.querySelector("button").type]; })()'), ['post', '/auth/logout', 'Sign out', 'submit']);
} finally { await done(); }
});
-589
View File
@@ -1,589 +0,0 @@
// Q175 phase 1 (#1690, design agents/sage/work/q175-sso/DESIGN.md section 9): the Console as an
// OIDC relying party (--sso). Every provider is the fake OP in helpers/fake-oidc.mjs on loopback;
// no test reaches a real provider, the live Console or a real config or slot file. The public name
// is console.example.test, sent only as a Host header to the test's own loopback listener. Each
// refusal checks the status, that no session cookie was set and that the send stub never ran.
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { request } from 'node:http';
import { spawn, spawnSync } from 'node:child_process';
import { chmodSync, mkdirSync, mkdtempSync, rmSync, symlinkSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { fileURLToPath } from 'node:url';
import { startServer } from '../src/serve.mjs';
import { createSso, readSlot, SESSION_COOKIE, LOGIN_COOKIE } from '../src/sso.mjs';
import { close } from './fixture.mjs';
import { b64url, ecKey, fakeOidc, jwt, psKey, rsaKey } from './helpers/fake-oidc.mjs';
const HOST = 'console.example.test', ORIGIN = `https://${HOST}`;
const CLI = fileURLToPath(new URL('../src/cli.mjs', import.meta.url));
const MSG = JSON.stringify({ to: 'coder', body: 'Please look at row 192.' });
// Test 19: every log line any test here captured, and every token, code, state, nonce, verifier,
// client secret and session id any test produced. The last test checks the one against the other.
const ALL_LINES = [], PRODUCED = new Set();
// node:http, not fetch: fetch can't set Host, Origin or Sec-Fetch-Site.
function call(port, { method = 'GET', path = '/', headers = {}, body, host = HOST } = {}) {
return new Promise((resolve, reject) => {
const length = body === undefined ? {} : { 'content-length': Buffer.byteLength(body) };
const req = request({ host: '127.0.0.1', port, method, path, headers: { host, ...length, ...headers } }, res => {
let text = '';
res.on('data', c => (text += c));
res.on('end', () => resolve({ status: res.statusCode, headers: res.headers, body: text && (res.headers['content-type'] ?? '').startsWith('application/json') ? JSON.parse(text) : text }));
});
req.on('error', reject);
req.end(body);
});
}
const setCookies = r => [r.headers['set-cookie'] ?? []].flat();
const sessionOf = r => setCookies(r).find(c => c.startsWith(`${SESSION_COOKIE}=`) && !c.includes('Max-Age=0'))?.split(';')[0] ?? null;
const writeHeaders = cookie => ({ ...(cookie ? { cookie } : {}), origin: ORIGIN, 'sec-fetch-site': 'same-origin', 'content-type': 'application/json' });
function providerFor(iss, extra = {}) {
return { id: iss.name, issuer: iss.issuer, clientId: iss.clientId, clientSecret: { slot: iss.name }, algs: [...new Set(iss.keys.map(k => k.alg))], subjects: [{ sub: `user-${iss.name}`, name: `person-${iss.name}` }], ...extra };
}
// A fake OP with `issuers`, createSso over them, and the human listener with a counting send.
async function setup(t, { issuers = [{ name: 'a' }], provider = {}, before = null, retryFirst = 5000, server = {} } = {}) {
const op = await fakeOidc();
t.after(() => op.close());
const iss = issuers.map(o => op.issuer(o.name, o));
for (const i of iss) PRODUCED.add(i.clientSecret);
before?.(op, iss);
const config = { publicOrigin: ORIGIN, providers: iss.map(i => providerFor(i, provider[i.name] ?? {})) };
const lines = [];
const log = line => { lines.push(line); ALL_LINES.push(line); };
const sso = await createSso(config, { secrets: new Map(iss.map(i => [i.name, i.clientSecret])), fetch: op.fetch, log, retryFirst });
t.after(() => sso.close());
const sends = [];
const bus = Object.fromEntries(['inbox', 'tasks', 'agents', 'trail', 'decisions'].map(v => [v, async () => [{ verb: v }]]));
const web = await startServer({ port: 0, board: 'http://127.0.0.1:9', bus, sso, send: async args => { sends.push(args); return { id: 'msg-0001' }; }, log, ...server });
t.after(() => close(web));
return { op, iss, sso, port: web.address().port, lines, sends, config, bus };
}
// GET /auth/login; returns the authorize URL and the login cookie (name=value).
async function begin(env, { provider = null, ret = null, query = null, host = HOST } = {}) {
const q = query ?? new URLSearchParams({ ...(provider ? { provider } : {}), ...(ret !== null ? { return: ret } : {}) }).toString();
const r = await call(env.port, { path: `/auth/login${q ? `?${q}` : ''}`, host });
assert.equal(r.status, 303, `login start: ${r.status} ${r.body}`);
const cookie = setCookies(r)[0].split(';')[0];
const location = r.headers.location;
const params = new URL(location).searchParams;
PRODUCED.add(cookie.split('=')[1]);
for (const k of ['state', 'nonce', 'code_challenge']) PRODUCED.add(params.get(k));
for (const record of env.sso.logins.values()) PRODUCED.add(record.verifier);
return { r, cookie, location, params };
}
function callback(env, begun, { code, state = begun.params.get('state'), extra = {}, cookie = begun.cookie } = {}) {
const q = new URLSearchParams({ ...(code !== undefined ? { code } : {}), state, ...extra });
return call(env.port, { path: `/auth/callback?${q}`, headers: cookie ? { cookie } : {} });
}
function grant(env, begun, iss, opts = {}) {
const g = env.op.grant(iss, begun.location, opts);
PRODUCED.add(g.code);
PRODUCED.add(g.idToken);
return g;
}
// A whole sign-in: start, a code at the OP, the callback. Returns the callback answer and the
// session cookie (name=value) when one was set.
async function signIn(env, { iss = env.iss[0], ret = null, opts = {} } = {}) {
const begun = await begin(env, { provider: env.iss.length > 1 ? iss.name : null, ret });
const g = grant(env, begun, iss, opts);
const r = await callback(env, begun, { code: g.code });
const session = sessionOf(r);
if (session) PRODUCED.add(session.split('=')[1]);
return { begun, g, r, session };
}
function refused(env, r, code) {
assert.equal(r.status, 403, `expected 403, got ${r.status}`);
assert.equal(sessionOf(r), null, 'a session cookie was set');
assert.equal(env.sends.length, 0, 'the send stub ran');
assert.ok(setCookies(r).includes(`${LOGIN_COOKIE}=; Path=/; Secure; HttpOnly; SameSite=Lax; Max-Age=0`), 'the login cookie was not expired');
if (code) assert.match(env.lines.at(-1), new RegExp(` sso refused code=${code} provider=`), env.lines.at(-1));
}
test('positive: an RS256 provider signs in and sends one message, and the session reads', async t => {
const env = await setup(t);
assert.notEqual(env.port, 7332);
const { r, session } = await signIn(env, { ret: '/board?x=1' });
assert.equal(r.status, 303);
assert.equal(r.headers.location, `${ORIGIN}/board?x=1`);
assert.ok(session);
const send = await call(env.port, { method: 'POST', path: '/api/bus/message', headers: writeHeaders(session), body: MSG });
assert.equal(send.status, 200, JSON.stringify(send.body));
assert.deepEqual(env.sends, [{ to: 'coder', body: 'Please look at row 192.' }]);
assert.match(env.lines.at(-1), / send user="person-a" to="coder" id=msg-0001$/);
const read = await call(env.port, { path: '/api/bus/inbox', headers: { cookie: session } });
assert.equal(read.status, 200);
const config = await call(env.port, { path: '/api/config', headers: { cookie: session } });
assert.equal(config.body.sso, true);
assert.equal(config.body.send, true);
});
test('positive: an ES256 provider signs in and sends', async t => {
const env = await setup(t, { issuers: [{ name: 'e', keys: [ecKey()] }] });
assert.deepEqual(env.config.providers[0].algs, ['ES256']);
const { session } = await signIn(env);
assert.ok(session);
assert.equal((await call(env.port, { method: 'POST', path: '/api/bus/message', headers: writeHeaders(session), body: MSG })).status, 200);
assert.equal(env.sends.length, 1);
});
test('positive: two providers each sign in their own subjects, and one provider\'s subject is unknown to the other', async t => {
const env = await setup(t, { issuers: [{ name: 'a' }, { name: 'b', keys: [ecKey()] }] });
const list = await call(env.port, { path: '/auth/login?return=%2Fboard' });
assert.equal(list.status, 200);
assert.match(list.body, /provider=a&#38;return=%2Fboard/);
assert.match(list.body, /provider=b&#38;return=%2Fboard/);
const [a, b] = env.iss;
assert.ok((await signIn(env, { iss: a })).session);
assert.ok((await signIn(env, { iss: b })).session);
const names = [...env.sso.sessions.values()].map(s => s.name);
assert.deepEqual(names, ['person-a', 'person-b']);
const cross = await signIn(env, { iss: b, opts: { sub: 'user-a' } });
refused({ ...env, sends: [] }, cross.r, 'subject-unknown');
});
test('1: a forged x-authentik header without a cookie is 401 on the API and a sign-in redirect on a page', async t => {
const env = await setup(t);
const forged = { 'x-authentik-username': 'jason.woltje', origin: ORIGIN, 'sec-fetch-site': 'same-origin', 'content-type': 'application/json' };
for (const [method, path, body] of [['POST', '/api/bus/message', MSG], ['POST', '/api/settings', '{}'], ['GET', '/api/config', undefined]]) {
const r = await call(env.port, { method, path, headers: forged, body });
assert.equal(r.status, 401, `${method} ${path}`);
assert.deepEqual(r.body, { error: 'sign-in required' });
}
const page = await call(env.port, { path: '/', headers: forged });
assert.equal(page.status, 303);
assert.equal(page.headers.location, '/auth/login?return=%2F');
const deep = await call(env.port, { path: '/board?x=1' });
assert.equal(deep.headers.location, '/auth/login?return=%2Fboard%3Fx%3D1');
assert.equal(env.sends.length, 0);
});
test('2: an HS256 ID token keyed with the client secret and a real RSA kid is refused', async t => {
const env = await setup(t);
const [iss] = env.iss;
const { r } = await signIn(env, { opts: { header: { alg: 'HS256', kid: iss.keys[0].kid } } });
refused(env, r, 'id-token-invalid');
});
test('3: a wrong aud, and an aud list holding another client with no azp, are refused', async t => {
const env = await setup(t);
refused(env, (await signIn(env, { opts: { claims: { aud: 'another-client' } } })).r, 'id-token-invalid');
refused(env, (await signIn(env, { opts: { claims: { aud: [env.iss[0].clientId, 'another-client'] } } })).r, 'id-token-invalid');
});
test('4: a token that expired 10 minutes ago is refused', async t => {
const env = await setup(t);
const now = Math.floor(Date.now() / 1000);
refused(env, (await signIn(env, { opts: { claims: { iat: now - 900, exp: now - 600 } } })).r, 'id-token-invalid');
});
test('5: another issuer, and the issuer without its trailing slash, are refused', async t => {
const env = await setup(t);
const [iss] = env.iss;
refused(env, (await signIn(env, { opts: { claims: { iss: `${env.op.base}/other/` } } })).r, 'id-token-invalid');
refused(env, (await signIn(env, { opts: { claims: { iss: iss.issuer.slice(0, -1) } } })).r, 'id-token-invalid');
});
test('6: a nonce mismatch and a missing nonce are refused', async t => {
const env = await setup(t);
refused(env, (await signIn(env, { opts: { claims: { nonce: 'not-the-nonce' } } })).r, 'id-token-invalid');
refused(env, (await signIn(env, { opts: { claims: { nonce: undefined } } })).r, 'id-token-invalid');
});
test('7: state mismatch, no login cookie, a replay, an expired record and two login cookies are refused', async t => {
const env = await setup(t);
const [iss] = env.iss;
let begun = await begin(env);
refused(env, await callback(env, begun, { code: grant(env, begun, iss).code, state: 'wrong-state' }), 'state-mismatch');
begun = await begin(env);
refused(env, await callback(env, begun, { code: grant(env, begun, iss).code, cookie: null }), 'login-record-missing');
// Replay: the OP takes the code twice, so only the take-and-delete rule refuses the second callback.
iss.acceptTwice = true;
begun = await begin(env);
const g = grant(env, begun, iss);
const first = await callback(env, begun, { code: g.code });
assert.equal(first.status, 303);
PRODUCED.add(sessionOf(first).split('=')[1]);
refused(env, await callback(env, begun, { code: g.code }), 'login-record-missing');
iss.acceptTwice = false;
begun = await begin(env);
for (const record of env.sso.logins.values()) record.expires = Date.now() - 1;
refused(env, await callback(env, begun, { code: grant(env, begun, iss).code }), 'login-record-missing');
begun = await begin(env);
const other = await begin(env);
refused(env, await callback(env, begun, { code: grant(env, begun, iss).code, cookie: `${begun.cookie}; ${other.cookie}` }), 'login-record-missing');
});
test('8: the token request carries the PKCE verifier, and the OP refuses a wrong one', async t => {
const env = await setup(t);
const [iss] = env.iss;
const { r, begun } = await signIn(env);
assert.equal(r.status, 303);
const { form } = iss.tokenRequests.at(-1);
assert.equal(begun.params.get('code_challenge_method'), 'S256');
const { createHash } = await import('node:crypto');
assert.equal(createHash('sha256').update(form.code_verifier).digest('base64url'), begun.params.get('code_challenge'));
refused(env, (await signIn(env, { opts: { challenge: 'not-the-challenge' } })).r, 'grant-failed');
});
test('9: alg none, a changed payload under a real kid, and an unknown kid even after the refetch are refused', async t => {
const env = await setup(t);
const [iss] = env.iss;
// (a) alg none: the alg rule, before any signature check.
refused(env, (await signIn(env, { opts: { header: { alg: 'none' } } })).r, 'id-token-invalid');
// (b) a real kid's signature over another payload: only the signature check catches it.
let begun = await begin(env);
const real = env.op.grant(iss, begun.location);
PRODUCED.add(real.idToken);
const [h, , s] = real.idToken.split('.');
const forged = `${h}.${b64url({ ...real.payload, name: 'changed' })}.${s}`;
refused(env, await callback(env, begun, { code: grant(env, begun, iss, { idToken: forged }).code }), 'signature-invalid');
// (c) a key the JWKS never held, again after the cache is old enough to refetch.
t.mock.timers.enable({ apis: ['Date'], now: Date.now() });
const stranger = rsaKey('not-in-jwks');
refused(env, (await signIn(env, { opts: { key: stranger } })).r, 'signature-invalid');
const fetches = iss.jwksFetches;
t.mock.timers.tick(61000);
refused(env, (await signIn(env, { opts: { key: stranger } })).r, 'signature-invalid');
assert.equal(iss.jwksFetches, fetches + 1, 'no refetch');
});
test('10: a valid token for a subject not configured is 403 with one subject-unknown line', async t => {
const env = await setup(t);
const before = env.lines.length;
const { r } = await signIn(env, { opts: { sub: 'stranger' } });
refused(env, r, 'subject-unknown');
const added = env.lines.slice(before);
assert.equal(added.length, 1);
assert.match(added[0], new RegExp(`^\\S+ sso refused code=subject-unknown provider=a iss=${JSON.stringify(env.iss[0].issuer).replace(/[.*+?^${}()|[\]\\/]/g, '\\$&')} sub="stranger"$`));
});
test('11: start refusals: HS or none algs, reserved names, HS-only discovery, an empty JWKS, no S256, an issuer mismatch, a short RSA key', async t => {
const op = await fakeOidc();
t.after(() => op.close());
const start = (iss, extra = {}) => createSso({ publicOrigin: ORIGIN, providers: [providerFor(iss, extra)] }, { secrets: new Map([[iss.name, iss.clientSecret]]), fetch: op.fetch, log: l => ALL_LINES.push(l) });
const a = op.issuer('a');
PRODUCED.add(a.clientSecret);
for (const [extra, why] of [
[{ algs: ['HS256'] }, /algs\[0\] must be one of .*HS\* and none are refused/],
[{ algs: ['none'] }, /algs\[0\] must be one of/],
[{ subjects: [{ sub: 'u', name: 'tester' }] }, /subjects\[0\]\.name must not be tester or human/],
[{ subjects: [{ sub: 'u', name: 'Human' }] }, /subjects\[0\]\.name must not be tester or human/],
]) await assert.rejects(start(a, extra), why);
const refusedAtDiscovery = /console\.sso provider \w+ refused at discovery/;
// The mosaic-preview shape: HS256 only.
const hs = op.issuer('hs', { discovery: { id_token_signing_alg_values_supported: ['HS256'] } });
await assert.rejects(start(hs, { algs: ['RS256'] }), refusedAtDiscovery);
const empty = op.issuer('empty');
empty.jwksOverride = { keys: [] };
await assert.rejects(start(empty), refusedAtDiscovery);
const plain = op.issuer('plain', { discovery: { code_challenge_methods_supported: ['plain'] } });
await assert.rejects(start(plain), refusedAtDiscovery);
const moved = op.issuer('moved');
moved.discovery = { issuer: `${op.base}/elsewhere/` };
await assert.rejects(start(moved), refusedAtDiscovery);
const small = op.issuer('small', { keys: [rsaKey('small', 1024)] });
await assert.rejects(start(small), refusedAtDiscovery);
// The control: the same OP and provider start.
const sso = await start(a);
assert.deepEqual(sso.health(), { a: 'ready' });
sso.close();
await assert.rejects(startServer({ port: 0, sso, preview: { host: HOST, header: 'remote-user', user: 'jason' } }), /--sso cannot be combined/);
});
test('11: slot files: a symlink, mode 0644, mode 0640 and a file under the repository are refused, and no message holds the value', t => {
const root = mkdtempSync(join(tmpdir(), 'webui-sso-slot-'));
t.after(() => rmSync(root, { recursive: true, force: true }));
const dir = join(root, 'config'), slots = join(dir, 'secrets', 'mosaic-stack');
mkdirSync(slots, { recursive: true });
const provider = slot => ({ id: 'a', clientSecret: { slot } });
const value = 'slot-value-do-not-print';
const opts = { repo: join(root, 'repo'), copies: join(root, 'copies') };
writeFileSync(join(slots, 'good'), `${value}\n`, { mode: 0o600 });
assert.equal(readSlot(dir, provider('good'), opts), value);
writeFileSync(join(root, 'target'), value, { mode: 0o600 });
symlinkSync(join(root, 'target'), join(slots, 'link'));
for (const [slot, mode] of [['wide', 0o644], ['group', 0o640]]) { writeFileSync(join(slots, slot), value); chmodSync(join(slots, slot), mode); }
writeFileSync(join(slots, 'two'), `${value}\nmore\n`, { mode: 0o600 });
writeFileSync(join(slots, 'empty'), '\n', { mode: 0o600 });
const cases = [
['missing', /slot is missing$/, opts],
['link', /must not be a symbolic link$/, opts],
['wide', /must have no group or other permission bits$/, opts],
['group', /must have no group or other permission bits$/, opts],
['two', /must hold one non-empty line$/, opts],
['empty', /must hold one non-empty line$/, opts],
// The config dir itself under the repository, and under a --checkout.
['good', /must not be inside the repository or a stack copy$/, { ...opts, repo: root }],
['good', /must not be inside the repository or a stack copy$/, { ...opts, checkout: dir }],
['good', /must not be inside the repository or a stack copy$/, { ...opts, copies: dir }],
];
for (const [slot, why, o] of cases) {
let err = null;
try { readSlot(dir, provider(slot), o); } catch (e) { err = e; }
assert.ok(err, `${slot} was read`);
assert.match(err.message, /^console\.sso provider a client secret slot /);
assert.match(err.message, why, slot);
assert.equal(err.message.includes(value), false);
assert.equal(err.message.includes(root), false);
}
});
test('11: the CLI refuses --sso with the old flags and without console.sso, and starts with a good one', async t => {
const root = mkdtempSync(join(tmpdir(), 'wsso-'));
t.after(() => rmSync(root, { recursive: true, force: true }));
const env = { ...process.env, MOSAIC_CONFIG: join(root, 'cfg', 'config.json'), XDG_DATA_HOME: join(root, 'xdg') };
for (const flags of [['--auth-header', 'remote-user'], ['--public-host', HOST], ['--allow-user', 'jason'], ['--proof-header', 'x-proof']]) {
const r = spawnSync(process.execPath, [CLI, 'serve', '--port', '0', '--sso', ...flags], { encoding: 'utf8', timeout: 10000, env });
assert.equal(r.status, 2, flags.join(' '));
assert.match(r.stderr, /^refused: --sso cannot be combined with --public-host, --auth-header, --allow-user or --proof-\*$/m);
}
const missing = spawnSync(process.execPath, [CLI, 'serve', '--port', '0', '--sso'], { encoding: 'utf8', timeout: 10000, env });
assert.equal(missing.status, 2);
assert.match(missing.stderr, /^refused: --sso needs the system config \(/);
const op = await fakeOidc();
t.after(() => op.close());
const a = op.issuer('a');
PRODUCED.add(a.clientSecret);
mkdirSync(join(root, 'cfg', 'secrets', 'mosaic-stack'), { recursive: true });
writeFileSync(join(root, 'ca.pem'), op.cert);
const base = { configVersion: 1, environment: 'development', dataRoot: join(root, 'data'), execution: { backend: 'docker', provider: 'zai', model: 'm' } };
writeFileSync(env.MOSAIC_CONFIG, JSON.stringify(base));
const noKey = spawnSync(process.execPath, [CLI, 'serve', '--port', '0', '--sso'], { encoding: 'utf8', timeout: 10000, env });
assert.equal(noKey.status, 2);
assert.match(noKey.stderr, /^refused: --sso needs console\.sso in the system config$/m);
writeFileSync(env.MOSAIC_CONFIG, JSON.stringify({ ...base, console: { sso: { publicOrigin: ORIGIN, providers: [providerFor(a)] } } }));
writeFileSync(join(root, 'cfg', 'secrets', 'mosaic-stack', 'a'), `${a.clientSecret}\n`, { mode: 0o644 });
const wide = spawnSync(process.execPath, [CLI, 'serve', '--port', '0', '--sso'], { encoding: 'utf8', timeout: 10000, env });
assert.equal(wide.status, 2);
assert.match(wide.stderr, /^refused: console\.sso provider a client secret slot must have no group or other permission bits$/m);
chmodSync(join(root, 'cfg', 'secrets', 'mosaic-stack', 'a'), 0o600);
// The child trusts the fake OP's certificate through NODE_EXTRA_CA_CERTS; the code under test has no flag for it.
const child = spawn(process.execPath, [CLI, 'serve', '--port', '0', '--sso'], { env: { ...env, NODE_EXTRA_CA_CERTS: join(root, 'ca.pem') } });
t.after(() => child.kill('SIGKILL'));
let out = '', err = '';
child.stderr.on('data', c => (err += c));
await new Promise((resolve, reject) => {
const timer = setTimeout(() => reject(new Error(`no start line: ${out} ${err}`)), 15000);
child.stdout.on('data', c => { out += c; if (out.includes('Ctrl-C')) { clearTimeout(timer); resolve(); } });
child.on('exit', () => { clearTimeout(timer); reject(new Error(`exited: ${err}`)); });
});
assert.match(out, new RegExp(`^Sign-in: ${ORIGIN}/ \\(--sso\\), read-only$`, 'm'));
assert.match(out, new RegExp(`^Sign-in provider: a issuer ${a.issuer.replace(/[.]/g, '\\.')} subjects 1 \\(ready\\)$`, 'm'));
assert.equal(out.includes(a.clientSecret) || err.includes(a.clientSecret), false);
assert.equal(out.includes(a.clientId), false);
const port = Number(out.match(/^Mosaic Console: http:\/\/127\.0\.0\.1:(\d+)\//m)[1]);
const health = await call(port, { path: '/healthz', host: `127.0.0.1:${port}` });
assert.deepEqual(health.body, { ok: true, sso: { a: 'ready' } });
child.kill('SIGTERM');
});
test('12: under --sso the tester listener refuses each kind of sign-in, has no /auth, and startServer refuses tester with sso', async t => {
const env = await setup(t);
const tester = await startServer({ port: 0, board: 'http://127.0.0.1:9', bus: env.bus, tester: true, testerNoSignIn: true, log: () => {} });
t.after(() => close(tester));
const port = tester.address().port, host = `127.0.0.1:${port}`;
const { session, g, begun } = await signIn(env);
assert.ok(session);
const plain = await call(port, { path: '/api/config', host });
assert.equal(plain.status, 200, 'control: a request with no sign-in is served');
for (const headers of [
{ cookie: session },
{ cookie: `theme=dark; ${begun.cookie}` },
{ cookie: `${SESSION_COOKIE.toLowerCase()}=x` },
{ authorization: `Bearer ${g.idToken}` },
{ 'x-authentik-jwt': g.idToken },
{ 'X-Authentik-Username': 'jason.woltje' },
]) {
const r = await call(port, { path: '/api/config', host, headers });
assert.equal(r.status, 403, JSON.stringify(Object.keys(headers)));
assert.deepEqual(r.body, { error: 'tester listener takes no sign-in' });
}
assert.equal((await call(port, { path: '/auth/login', host })).status, 404);
await assert.rejects(startServer({ port: 0, tester: true, sso: env.sso }), /tester listener cannot use sso/);
});
test('13: idle and absolute expiry, logout then reuse, a cross-origin logout, and background reads that don\'t keep a session', async t => {
const env = await setup(t);
const read = cookie => call(env.port, { path: '/api/bus/inbox', headers: { cookie } });
const record = () => [...env.sso.sessions.values()].at(-1);
// Background reads don't move `seen`; a page load does.
let { session } = await signIn(env);
const old = Date.now() - 59 * 60 * 1000;
record().seen = old;
assert.equal((await read(session)).status, 200);
assert.equal(record().seen, old, 'a background read refreshed the session');
assert.equal((await call(env.port, { path: '/', headers: { cookie: session } })).status, 200);
assert.ok(record().seen > old, 'a page load did not refresh the session');
// Idle: 59 minutes of background reads, then one more past the hour.
record().seen = Date.now() - 61 * 60 * 1000;
assert.equal((await read(session)).status, 401);
// Absolute.
({ session } = await signIn(env));
record().created = Date.now() - 8 * 3600 * 1000;
assert.equal((await call(env.port, { path: '/', headers: { cookie: session } })).headers.location, '/auth/login?return=%2F');
// Logout needs same-origin; refused, the session stays.
({ session } = await signIn(env));
const cross = await call(env.port, { method: 'POST', path: '/auth/logout', headers: { cookie: session, origin: ORIGIN, 'sec-fetch-site': 'cross-site' } });
assert.equal(cross.status, 403);
assert.equal((await read(session)).status, 200);
const noOrigin = await call(env.port, { method: 'POST', path: '/auth/logout', headers: { cookie: session, 'sec-fetch-site': 'same-origin' } });
assert.equal(noOrigin.status, 403);
assert.equal((await read(session)).status, 200);
const out = await call(env.port, { method: 'POST', path: '/auth/logout', headers: { cookie: session, origin: ORIGIN, 'sec-fetch-site': 'same-origin' } });
assert.equal(out.status, 303);
const end = new URL(out.headers.location);
assert.equal(`${end.origin}${end.pathname}`, `${env.iss[0].issuer}end-session`);
assert.equal(end.searchParams.get('post_logout_redirect_uri'), `${ORIGIN}/auth/signed-out`);
assert.equal(end.searchParams.get('client_id'), env.iss[0].clientId);
assert.ok(end.searchParams.get('id_token_hint'));
assert.deepEqual(setCookies(out), [`${SESSION_COOKIE}=; Path=/; Secure; HttpOnly; SameSite=Lax; Max-Age=0`]);
assert.equal((await read(session)).status, 401);
assert.equal((await call(env.port, { method: 'GET', path: '/auth/logout', headers: { cookie: session } })).status, 405);
});
test('13: the CSP lets the Sign out form reach the provider\'s end-session origin, and only under --sso', async t => {
const env = await setup(t);
const r = await call(env.port, { path: '/healthz', host: `127.0.0.1:${env.port}` });
assert.match(r.headers['content-security-policy'], new RegExp(`; form-action 'self' ${env.op.base.replace(/[.]/g, '\\.')}$`));
const plain = await startServer({ port: 0, board: 'http://127.0.0.1:9' });
t.after(() => close(plain));
const p = await call(plain.address().port, { path: '/healthz', host: `127.0.0.1:${plain.address().port}` });
assert.match(p.headers['content-security-policy'], /; form-action 'self'$/);
});
test('14: open redirects land on /, and a path on the Console lands there', async t => {
const env = await setup(t);
for (const [query, where] of [
['return=%2F%2Fevil.example', `${ORIGIN}/`],
['return=https%3A%2F%2Fevil.example', `${ORIGIN}/`],
['return=%2F%5Cevil.example', `${ORIGIN}/`],
['return=/%09/evil.example', `${ORIGIN}/`],
// Dot segments that leave a pathname of //evil.example stay on the Console (webby-0019 B1).
['return=%2F.%2F%2Fevil.example', `${ORIGIN}//evil.example`],
['return=%2Fx%2F..%2F%2Fevil.example', `${ORIGIN}//evil.example`],
[`return=${encodeURIComponent(`${ORIGIN}//evil.example`)}`, `${ORIGIN}//evil.example`],
['return=%2Fboard%2F.%2Fx', `${ORIGIN}/board/x`],
['return=%2Fboard%3Fx%3D1', `${ORIGIN}/board?x=1`],
['', `${ORIGIN}/`],
]) {
const begun = await begin(env, { query });
const r = await callback(env, begun, { code: grant(env, begun, env.iss[0]).code });
assert.equal(r.status, 303);
assert.equal(r.headers.location, where, query);
assert.equal(new URL(r.headers.location).origin, ORIGIN, query);
PRODUCED.add(sessionOf(r).split('=')[1]);
}
});
test('15: a callback for A carrying B\'s code and iss, and a provider= query, is refused', async t => {
const env = await setup(t, { issuers: [{ name: 'a' }, { name: 'b' }] });
const [, b] = env.iss;
for (const extra of [{ iss: b.issuer, provider: 'b' }, { provider: 'b' }]) {
const begun = await begin(env, { provider: 'a' });
// B's code against A's PKCE challenge and nonce, for B's own subject: only the record rule
// (this callback belongs to A) stands in the way.
const g = grant(env, begun, b, { sub: 'user-b' });
const r = await callback(env, begun, { code: g.code, extra });
refused(env, r);
}
assert.equal(b.tokenRequests.length, 0, 'B\'s token endpoint was called');
});
test('16: a provider with RS256 and ES256 takes either, and refuses PS256 with a usable PS256 key in the JWKS', async t => {
const rsa = rsaKey(), ec = ecKey(), ps = psKey();
const env = await setup(t, { issuers: [{ name: 'a', keys: [rsa, ec, ps] }], provider: { a: { algs: ['RS256', 'ES256'] } } });
assert.ok((await signIn(env, { opts: { key: rsa } })).session);
assert.ok((await signIn(env, { opts: { key: ec } })).session);
refused(env, (await signIn(env, { opts: { key: ps } })).r, 'id-token-invalid');
});
test('17: 33 started logins leave 32 records, and the first one\'s callback finds none', async t => {
const env = await setup(t);
const first = await begin(env);
for (let i = 0; i < 32; i++) await begin(env);
assert.equal(env.sso.logins.size, 32);
refused(env, await callback(env, first, { code: grant(env, first, env.iss[0]).code }), 'login-record-missing');
});
test('18: after a key rotation a sign-in works through one refetch, and the removed kid is refused', async t => {
const env = await setup(t);
const [iss] = env.iss;
t.mock.timers.enable({ apis: ['Date'], now: Date.now() });
const old = iss.keys[0];
assert.ok((await signIn(env)).session);
const fetches = iss.jwksFetches;
env.op.rotate(iss);
t.mock.timers.tick(61000);
assert.ok((await signIn(env)).session);
assert.equal(iss.jwksFetches, fetches + 1);
refused(env, (await signIn(env, { opts: { key: old } })).r, 'signature-invalid');
});
test('20 and 21: the store holds no raw session id, and the cookie strings are exact', async t => {
const env = await setup(t);
const begun = await begin(env);
assert.match(setCookies(begun.r)[0], new RegExp(`^${LOGIN_COOKIE}=[A-Za-z0-9_-]{43}; Path=/; Secure; HttpOnly; SameSite=Lax; Max-Age=600$`));
for (const key of env.sso.logins.keys()) assert.match(key, /^[0-9a-f]{64}$/);
const r = await callback(env, begun, { code: grant(env, begun, env.iss[0]).code });
const set = setCookies(r);
assert.equal(set.length, 2);
assert.equal(set[0], `${LOGIN_COOKIE}=; Path=/; Secure; HttpOnly; SameSite=Lax; Max-Age=0`);
assert.match(set[1], new RegExp(`^${SESSION_COOKIE}=[A-Za-z0-9_-]{43}; Path=/; Secure; HttpOnly; SameSite=Lax$`));
const id = set[1].split(';')[0].split('=')[1];
PRODUCED.add(id);
for (const [key, value] of env.sso.sessions) {
assert.match(key, /^[0-9a-f]{64}$/);
assert.notEqual(key, id);
assert.equal(JSON.stringify(value).includes(id), false);
}
});
test('22: a loopback Host is 403 on a page, a read and a write; /healthz on loopback is 200; a redirect_uri never comes from the Host', async t => {
const env = await setup(t);
const { session } = await signIn(env);
const loop = `127.0.0.1:${env.port}`;
for (const [method, path, body] of [['GET', '/', undefined], ['GET', '/api/board', undefined], ['GET', '/api/bus/inbox', undefined], ['POST', '/api/bus/message', MSG]]) {
// A loopback Host on this listener's port; any other Host is already the older non-local refusal.
for (const host of [loop, `localhost:${env.port}`]) {
const r = await call(env.port, { method, path, host, body, headers: { cookie: session, 'content-type': 'application/json' } });
assert.equal(r.status, 403, `${method} ${path} ${host}`);
assert.deepEqual(r.body, { error: 'public Host required' });
}
}
const health = await call(env.port, { path: '/healthz', host: loop });
assert.equal(health.status, 200);
assert.deepEqual(health.body, { ok: true, sso: { a: 'ready' } });
assert.equal(env.sends.length, 0);
const begun = await begin(env, { host: HOST.toUpperCase() });
assert.equal(begun.params.get('redirect_uri'), `${ORIGIN}/auth/callback`);
});
test('23: a provider down at start leaves the Console up and 503, and the retry makes sign-in work', async t => {
let env = await setup(t, { retryFirst: 50, before: (op, [a]) => { a.mode = 'down'; } });
assert.equal((await call(env.port, { path: '/auth/login' })).status, 503);
assert.deepEqual((await call(env.port, { path: '/healthz', host: `127.0.0.1:${env.port}` })).body.sso, { a: 'unreachable' });
assert.match(env.lines.at(-1), / sso refused code=discovery-failed provider=a$/);
env.iss[0].mode = 'ok';
for (let i = 0; i < 100 && env.sso.health().a !== 'ready'; i++) await new Promise(r => setTimeout(r, 20));
assert.deepEqual(env.sso.health(), { a: 'ready' });
assert.ok((await signIn(env)).session);
// A 200 HTML page at the discovery URL is unreachable too, and the Console starts.
env = await setup(t, { before: (op, [a]) => { a.mode = 'html'; } });
assert.deepEqual(env.sso.health(), { a: 'unreachable' });
// HS-only discovery at start refuses the start.
await assert.rejects(setup(t, { before: (op, [a]) => { a.discovery = { id_token_signing_alg_values_supported: ['HS256'] }; } }), /refused at discovery/);
});
test('19: no captured log line holds a token, code, state, nonce, verifier, client secret or session id', () => {
assert.ok(ALL_LINES.length > 20, `only ${ALL_LINES.length} lines`);
assert.ok(PRODUCED.size > 50, `only ${PRODUCED.size} values`);
for (const line of ALL_LINES) {
for (const value of PRODUCED) if (value) assert.equal(line.includes(value), false, `a log line holds a produced value: ${line.slice(0, 80)}`);
assert.match(line, /^\S+ (sso refused code=[a-z-]+ provider=[a-z0-9-]+( iss="[^"]*" sub="[^"]*")?|send user="[^"]+" to="[^"]*" (id=msg-0001|error=[a-z -]+))$/, line);
}
});
+9 -32
View File
@@ -26,7 +26,6 @@ import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import process from "node:process";
import { pathToFileURL } from "node:url";
const SUPPORTED_CONFIG_VERSION = 1;
const SUPPORTED_ENVIRONMENTS = new Set(["development", "production"]);
@@ -107,30 +106,13 @@ function isAncestorOf(ancestor, candidate) {
return rel !== "" && !rel.startsWith("..") && !path.isAbsolute(rel);
}
// Q175 phase 1 (#1690): the optional `console` key, checked for shape only by the Console's own
// checker. It is imported only when the key is there, so a config without it never needs the
// Console's code, and a copy of this file alone still reads one.
async function validateConsole(value) {
let checkConsole;
try {
({ checkConsole } = await import(pathToFileURL(path.join(import.meta.dirname, "..", "packages", "webui", "src", "sso-config.mjs")).href));
} catch {
fail(2, 'the "console" key needs packages/webui/src/sso-config.mjs beside this loader');
}
try {
return checkConsole(value);
} catch (error) {
fail(2, error.message);
}
}
async function validate(document, file) {
function validate(document, file) {
if (!isPlainObject(document)) {
fail(2, "configuration must be a JSON object");
}
rejectUnknownKeys(
document,
["configVersion", "environment", "dataRoot", "execution", "console"],
["configVersion", "environment", "dataRoot", "execution"],
"configuration",
);
@@ -164,10 +146,6 @@ async function validate(document, file) {
fail(2, `unsupported execution.adapter: ${JSON.stringify(adapter)} (supported: ${[...SUPPORTED_ADAPTERS].join(", ")})`);
}
// Last, so the older checks keep their order. The key is in the output only when the file has
// it, so a config without it prints what it always printed.
const consoleKey = Object.hasOwn(document, "console") ? await validateConsole(document.console) : undefined;
return {
configVersion: document.configVersion,
environment: document.environment,
@@ -178,11 +156,10 @@ async function validate(document, file) {
model: document.execution.model,
adapter,
},
...(consoleKey === undefined ? {} : { console: consoleKey }),
};
}
async function load(file) {
function load(file) {
const raw = readRaw(file);
if (raw === null) {
fail(3, `configuration not found: ${file} (run scripts/bootstrap.sh to create it)`);
@@ -193,7 +170,7 @@ async function load(file) {
} catch (error) {
fail(2, `configuration is not valid JSON (${file}): ${error.message}`);
}
return await validate(document, file);
return validate(document, file);
}
function shellQuote(value) {
@@ -217,7 +194,7 @@ const file = configPath();
switch (operation) {
case "bootstrap": {
if (fs.existsSync(file)) {
await load(file); // validate only; never rewrite
load(file); // validate only; never rewrite
process.stderr.write(`mosaic-config: configuration already present, validated without changes: ${file}\n`);
process.exit(0);
}
@@ -229,26 +206,26 @@ switch (operation) {
fs.writeFileSync(fd, `${JSON.stringify(DEFAULT_CONFIG, null, 2)}\n`);
} catch (error) {
if (error.code === "EEXIST") {
await load(file);
load(file);
process.exit(0);
}
fail(1, `unable to create configuration: ${error.message}`);
} finally {
if (fd !== undefined) fs.closeSync(fd);
}
await load(file);
load(file);
process.stderr.write(`mosaic-config: created default configuration: ${file}\n`);
process.exit(0);
}
case "validate": {
const resolved = await load(file);
const resolved = load(file);
process.stdout.write(`${JSON.stringify(resolved, null, 2)}\n`);
process.exit(0);
}
case "env": {
const resolved = await load(file);
const resolved = load(file);
process.stdout.write(
[
`MOSAIC_DATA_ROOT=${shellQuote(resolved.dataRoot)}`,
+22 -215
View File
@@ -4,7 +4,6 @@
# behind a health gate, and puts the old stack back inside the same run when the gate fails.
# Console tester listener flags follow target support (row 176, #1674); so does the row 178 proof
# pair, which activate sets or drops (row 187, #1685). activate --no-send drops --send (row 189, #1687).
# activate --sso swaps the preview set and the pair for the Console's own sign-in (row 200, #1698).
# Design and tests: agents/darkwing/work/queue-162/BUILD.md.
#
# Who runs it. Decision 96 (Mos, mos-0716) lets this script stop and start the live units under
@@ -31,13 +30,13 @@ BIN="$HOME/.npm-global/bin"
UNIT_DIR="${XDG_CONFIG_HOME:-$HOME/.config}/systemd/user"
CONSOLE_UNIT=mosaic-webui.service
TIMEOUT=60 WANT_PM=0 AFTER_HARNESS=0 MIGRATE=0 PLAN=0 RESTORE="" EVENTS="" TESTER_PORT=""
PROOF_HEADER="" PROOF_FILE="" NO_PROOF=0 NO_SEND=0 SSO=0 R_BACKUP="" R_PORT=""
PROOF_HEADER="" PROOF_FILE="" NO_PROOF=0 NO_SEND=0 R_BACKUP="" R_PORT=""
usage() {
cat >&2 <<'USAGE'
usage: stack-release.sh adopt BUSINESS
stack-release.sh activate ID BUSINESS [--pm | --pm-after-harness] [--migrate] [--tester-port N]
[--proof-header NAME --proof-file PATH | --no-proof | --sso] [--no-send] [--timeout S] [--plan]
[--proof-header NAME --proof-file PATH | --no-proof] [--no-send] [--timeout S] [--plan]
stack-release.sh pm-on BUSINESS [--timeout S]
stack-release.sh rollback BUSINESS [--restore ACTIVATION [--events N]] [--timeout S] [--plan]
stack-release.sh status BUSINESS
@@ -87,7 +86,6 @@ parse_opts() {
--proof-file) [ "$OP" = activate ] && [ $# -ge 2 ] && [ -n "$2" ] || usage; PROOF_FILE="$2"; shift 2 ;;
--no-proof) [ "$OP" = activate ] || usage; NO_PROOF=1; shift ;;
--no-send) [ "$OP" = activate ] || usage; NO_SEND=1; shift ;;
--sso) [ "$OP" = activate ] || usage; SSO=1; shift ;;
--migrate) [ "$OP" = activate ] || usage; MIGRATE=1; shift ;;
--restore) [ "$OP" = rollback ] && [ $# -ge 2 ] && [[ "$2" =~ ^[0-9]{8}T[0-9]{6}Z-[0-9a-f]{12}(-[1-9])?$ ]] || usage; RESTORE="$2"; shift 2 ;;
--backup) [ "$OP" = rehearse ] && [ $# -ge 2 ] && { [[ "$2" =~ ^[0-9]{8}T[0-9]{6}Z-[0-9a-f]{12}(-[1-9])?$ ]] || [[ "$2" =~ ^/[A-Za-z0-9/._+-]*$ ]]; } || usage; R_BACKUP="$2"; shift 2 ;;
@@ -105,8 +103,6 @@ parse_opts() {
# The proof pair goes together, and --no-proof goes alone (row 187).
[ "${PROOF_HEADER:+1}" = "${PROOF_FILE:+1}" ] || usage
[ "$NO_PROOF" = 0 ] || [ -z "$PROOF_HEADER" ] || usage
# --sso drops the pair itself, so it takes neither a pair nor --no-proof (row 200).
[ "$SSO" = 0 ] || { [ -z "$PROOF_HEADER" ] && [ "$NO_PROOF" = 0 ]; } || usage
local d
for d in "$COPIES" "$BIN"; do
case "$d" in /*) ;; *) usage ;; esac
@@ -162,9 +158,7 @@ for(const k of open.keys())process.stdout.write(k+"\n");' -- "$LOG"
# Reads the log's ok lines, newest first. `previous` prints rollback's target: the first `to`
# that is not the pointer's copy (release.sh's rule). `backup` prints the backup field of the
# line that made the pointer's copy live, the backup a rollback would restore. `arrival` prints the
# activation and `from` of the line that moved the stack onto the pointer's copy from another one,
# past any same-copy re-pin after it (row 200, sage-1389).
# line that made the pointer's copy live, the backup a rollback would restore.
from_log() {
[ -f "$LOG" ] || return 1
node -e '
@@ -175,7 +169,6 @@ for(const l of lines.reverse()){
if(o.result!=="ok"||!["adopt","activate","rollback"].includes(o.op)||!o.to)continue;
if(mode==="previous"&&o.to!==current){process.stdout.write(o.to+"\n");process.exit(0)}
if(mode==="backup"&&o.to===current){if(o.backup)process.stdout.write(o.backup+"\n");process.exit(o.backup?0:1)}
if(mode==="arrival"&&o.to===current&&o.from!==current){process.stdout.write(`${o.activation??""} ${o.from??""}\n`);process.exit(0)}
}
process.exit(1);' -- "$LOG" "$1" "$P_COPY"
}
@@ -203,23 +196,15 @@ read_units() {
U_CLAUDE=""
read -r -a argv <<< "$env"
for tok in "${argv[@]}"; do case "$tok" in MOSAIC_CLAUDE_BIN=*) U_CLAUDE="${tok#MOSAIC_CLAUDE_BIN=}" ;; esac; done
serve_flags "$console_argv" || return 1
U_CONSOLE=("${SERVE_FLAGS[@]}")
}
# Sets SERVE_FLAGS (array) to the Console argv $1 after `serve`, without --checkout and its value;
# returns 1 with no `serve`.
serve_flags() {
local -a argv
read -r -a argv <<< "$1"
SERVE_FLAGS=()
read -r -a argv <<< "$console_argv"
U_CONSOLE=()
local i serve=0
for ((i = 0; i < ${#argv[@]}; i++)); do
if [ "$serve" = 0 ]; then [ "${argv[i]}" != serve ] || serve=1; continue; fi
if [ "${argv[i]}" = --checkout ]; then i=$((i + 1)); continue; fi
SERVE_FLAGS+=("${argv[i]}")
U_CONSOLE+=("${argv[i]}")
done
[ "$serve" = 1 ]
[ "$serve" = 1 ] || return 1
}
console_port() {
@@ -260,9 +245,9 @@ target_schema() {
in_chain() { local v; for v in "${T_CHAIN[@]}"; do [ "$v" != "$1" ] || return 0; done; return 1; }
# The item 3 checks on the target copy, in order: copy, schema, already-active. An activate that
# sets a proof pair, drops the pointer's pair with --no-proof, drops the pointer's --send with
# --no-send, or turns on --sso, may name the pointer's copy: it re-pins the live copy with new
# Console flags (row 187, Part B's B3 and B-R1; row 189, mos-0972; row 200, the phase 2 window).
# sets a proof pair, drops the pointer's pair with --no-proof, or drops the pointer's --send with
# --no-send, may name the pointer's copy: it re-pins the live copy with new Console flags (row 187,
# Part B's B3 and B-R1; row 189, mos-0972).
check_target() {
copy_exists "$TO" 2>/dev/null || refuse copy-missing "no copy $TO in $COPIES"
copy_ok "$TO" 2>/dev/null || refuse copy-unreachable "$TO is not reachable from refactor and origin/refactor"
@@ -270,7 +255,6 @@ check_target() {
[ "$TO" != "$P_COPY" ] || [ -n "$PROOF_HEADER" ] ||
{ [ "$NO_PROOF" = 1 ] && [[ " ${P_CONSOLE[*]} " == *" --proof-header "* ]]; } ||
{ [ "$NO_SEND" = 1 ] && [[ " ${P_CONSOLE[*]} " == *" --send "* ]]; } ||
{ [ "$SSO" = 1 ] && [[ " ${P_CONSOLE[*]} " != *" --sso "* ]]; } ||
refuse already-active "$TO is the pointer's copy"
}
@@ -683,17 +667,16 @@ flags_drift() {
console_for_target() {
local supported=0 main=7332 inherited="" i
local -a without_tester=()
sso_base
grep -q -- '--tester-port' "$COPIES/$TO/packages/webui/src/cli.mjs" && supported=1
for ((i = 0; i < ${#BASE_CONSOLE[@]}; i++)); do
if [ "${BASE_CONSOLE[i]}" = --tester-port ]; then
i=$((i + 1)); inherited="${BASE_CONSOLE[i]:-}"
for ((i = 0; i < ${#P_CONSOLE[@]}; i++)); do
if [ "${P_CONSOLE[i]}" = --tester-port ]; then
i=$((i + 1)); inherited="${P_CONSOLE[i]:-}"
else
without_tester+=("${BASE_CONSOLE[i]}")
if [ "${BASE_CONSOLE[i]}" = --port ]; then main="${BASE_CONSOLE[i+1]:-7332}"; fi
without_tester+=("${P_CONSOLE[i]}")
if [ "${P_CONSOLE[i]}" = --port ]; then main="${P_CONSOLE[i+1]:-7332}"; fi
fi
done
RUN_CONSOLE=("${BASE_CONSOLE[@]}")
RUN_CONSOLE=("${P_CONSOLE[@]}")
DROP_TESTER=""
if [ -n "$TESTER_PORT" ]; then
# --plan lists both with its other refusals (row 180); outside it they end the run.
@@ -709,179 +692,8 @@ console_for_target() {
RUN_CONSOLE=("${without_tester[@]}")
DROP_TESTER="--tester-port $inherited"
fi
if [ "$SSO" = 1 ]; then sso_for_target; else proof_for_target; fi
proof_for_target
send_for_target
sso_checks
}
sso_supported() { grep -q -- '--sso' "$COPIES/$TO/packages/webui/src/cli.mjs"; }
has_flag() { local f="$1"; shift; [[ " $* " == *" $f "* ]]; }
# Row 200 (#1698, R1): a rollback from an --sso pointer to a copy whose Console has no --sso. The
# pointer no longer holds the preview set that Console ran with, so the flags come from the rollback
# set of the activation that moved the stack onto the pointer's copy from the rollback target. A
# same-copy re-pin after it has a set of its own, holding the same copy's earlier flags, so the
# pointer's own activation is the wrong set (sage-1389). The set's show-mosaic-webui.service.txt is
# the Console unit as it was before that run, checked against the set's SHA256SUMS. Sets
# BASE_CONSOLE (the flags console_for_target starts from), SSO_PLAN and SSO_STEP. SSO_NO_TARGET
# skips sso_checks after a refusal here, so --plan lists that refusal alone.
sso_base() {
BASE_CONSOLE=("${P_CONSOLE[@]}") SSO_PLAN="" SSO_STEP="" SSO_NO_TARGET=""
has_flag --sso "${P_CONSOLE[@]}" || return 0
[ "$SSO" = 1 ] || SSO_PLAN="sso: carried"
[ "$OP" = rollback ] && ! sso_supported || return 0
local f="show-$CONSOLE_UNIT.txt" argv="" why="" act="" from="" set
read -r act from <<< "$(from_log arrival || true)"
set="$STATE/stack-rollback/$act"
if [ "$from" != "$TO" ]; then why="the log names no activation that moved the stack from $TO onto $P_COPY"
elif ! [[ "$act" =~ ^[0-9]{8}T[0-9]{6}Z-[0-9a-f]{12}(-[1-9])?$ ]]; then why="the activation $act that moved the stack onto $P_COPY names no rollback set"
elif [ ! -f "$set/$f" ] || [ ! -f "$set/SHA256SUMS" ]; then why="$set has no $f and SHA256SUMS"
elif [ "$(sum_of "$set" "$f")" != "$(sha256sum < "$set/$f" | cut -d' ' -f1)" ]; then why="$set/$f does not match the set's SHA256SUMS"
else
argv="$(argv_of "$(cat "$set/$f")")"
if ! unquoted "$argv" || ! serve_flags "$argv"; then why="$set/$f holds no Console argv this script can read"
elif has_flag --sso "${SERVE_FLAGS[@]}"; then why="the Console in $set/$f already ran with --sso"
fi
fi
if [ -n "$why" ]; then
SSO_NO_TARGET=1
check_refuse sso-rollback-flags "$TO's Console does not support --sso, and the Console flags it ran with cannot be read: $why"
return 0
fi
# --send and --tester-port follow the pointer, so a --no-send or a tester port set after that
# activation stays (row 189's rule that rollback carries --send off).
local i
BASE_CONSOLE=()
for ((i = 0; i < ${#SERVE_FLAGS[@]}; i++)); do
case "${SERVE_FLAGS[i]}" in
--send) ;;
--tester-port) i=$((i + 1)) ;;
*) BASE_CONSOLE+=("${SERVE_FLAGS[i]}") ;;
esac
done
for ((i = 0; i < ${#P_CONSOLE[@]}; i++)); do
case "${P_CONSOLE[i]}" in
--send) BASE_CONSOLE+=(--send) ;;
--tester-port) i=$((i + 1)); BASE_CONSOLE+=(--tester-port "${P_CONSOLE[i]:-}") ;;
esac
done
SSO_PLAN="sso: dropped; $TO's Console does not support --sso, so the rollback takes the Console flags from $set/$f, with --send and --tester-port as the pointer has them"
SSO_STEP="drop --sso; $TO's Console does not support it, so the Console flags come from $set/$f"
}
# Row 200 (#1698): activate --sso drops the preview set and the proof pair, which serve refuses beside
# it, and puts --sso where webui-service.sh renders it: after the other flags, before --send and
# --tester-port. Sets SSO_DROP (the plan's dropped preview flags), PROOF_PLAN and PROOF_STEP.
sso_for_target() {
local header="" file="" i
local -a kept=()
SSO_DROP=""
for ((i = 0; i < ${#RUN_CONSOLE[@]}; i++)); do
case "${RUN_CONSOLE[i]}" in
--public-host | --auth-header | --allow-user) SSO_DROP+=" ${RUN_CONSOLE[i]} ${RUN_CONSOLE[i + 1]:-}"; i=$((i + 1)) ;;
--proof-header) i=$((i + 1)); header="${RUN_CONSOLE[i]:-}" ;;
--proof-file) i=$((i + 1)); file="${RUN_CONSOLE[i]:-}" ;;
--sso) ;;
*) kept+=("${RUN_CONSOLE[i]}") ;;
esac
done
RUN_CONSOLE=()
for ((i = 0; i < ${#kept[@]}; i++)); do
case "${kept[i]}" in --send | --tester-port) break ;; esac
RUN_CONSOLE+=("${kept[i]}")
done
RUN_CONSOLE+=(--sso "${kept[@]:i}")
PROOF_PLAN="proof: off" PROOF_STEP=""
if [ -n "$header" ]; then
PROOF_PLAN="proof: dropped; --sso drops header $header"
PROOF_STEP="drop --proof-header $header --proof-file $file; --sso was given"
fi
}
# Row 200 item 2: whenever the run's Console flags hold --sso, the checks row 192's --sso start runs,
# before anything stops: a target Console with --sso, console.sso in config.json as the target's own
# checkConsole reads it, and each provider's slot file. The slot gets lstat, stat and realpath
# only; this script never opens it. Also sets the plan's sso: line for activate --sso.
SSO_JS='
const {readFileSync}=require("node:fs");const {pathToFileURL}=require("node:url");
const [copy,file]=process.argv.slice(1);
(async()=>{
const {checkConsole}=await import(pathToFileURL(`${copy}/packages/webui/src/sso-config.mjs`).href);
let doc;
// A JSON.parse message can quote the text, so it is not passed on.
try{doc=JSON.parse(readFileSync(file,"utf8"))}catch{throw new Error(`cannot read ${file} as JSON`)}
if(!doc?.console?.sso)throw new Error("there is no console.sso in the system config");
checkConsole(doc.console);
for(const p of doc.console.sso.providers)process.stdout.write(`${p.id} ${p.clientSecret.slot}\n`);
})().catch((e)=>{process.stdout.write(String(e?.message??e).split("\n")[0]);process.exit(1)});'
sso_checks() {
has_flag --sso "${RUN_CONSOLE[@]}" || return 0
if [ "$SSO" = 1 ]; then
local t carried="" dropped="${SSO_DROP# }"
for t in "${RUN_CONSOLE[@]}"; do [ "$t" = --sso ] || carried+="${carried:+ }$t"; done
SSO_PLAN="sso: on; drops ${dropped:-nothing}; carries ${carried:-nothing}"
SSO_STEP="turn on --sso${SSO_DROP:+; drop$SSO_DROP}"
fi
[ -z "$SSO_NO_TARGET" ] || return 0
sso_supported || { check_refuse sso-target "$TO's Console does not support --sso"; return 0; }
local cfg="${MOSAIC_CONFIG:-$HOME/.config/mosaic-dev/config.json}" out id slot path why
# N4 (webby-0019, sage-1403): a pre-phase-1 loader refuses a console key, so with the key not yet
# written and the stack on another copy, the first step is the re-pin without --sso.
local first=""
if ! out="$(node -e "$SSO_JS" -- "$COPIES/$TO" "$cfg" 2>&1)"; then
out="${out%%$'\n'*}"
[ "$out" != "there is no console.sso in the system config" ] || [ "$P_COPY" = "$TO" ] ||
first="; activate $TO without --sso first, then write console.sso, then run activate $TO --sso"
check_refuse sso-config "$TO's Console would refuse its --sso start: $out$first"
return 0
fi
while read -r id slot; do
path="$(dirname "$cfg")/secrets/mosaic-stack/$slot"
why="$(slot_problem "$path")"
[ -z "$why" ] || check_refuse sso-slot "console.sso provider $id client secret slot $path $why"
done <<< "$out"
}
# Why a slot file can't serve, or nothing: readSlot's checks in packages/webui/src/sso.mjs, in its
# order, from lstat, stat and realpath only, then a size of 0 for its one-non-empty-line rule.
slot_problem() {
local st uid mode size type real copies repo
[ -e "$1" ] || [ -L "$1" ] || { echo "is missing"; return; }
[ ! -L "$1" ] || { echo "is a symbolic link"; return; }
st="$(stat -c '%u:%a:%s:%F' -- "$1" 2>/dev/null)" || { echo "cannot be read with stat"; return; }
uid="${st%%:*}" st="${st#*:}"
mode="${st%%:*}" st="${st#*:}"
size="${st%%:*}" type="${st#*:}"
case "$type" in "regular file" | "regular empty file") ;; *) echo "is not a regular file"; return ;; esac
[ "$uid" = "$(id -u)" ] || { echo "is owned by uid $uid, not $(id -u), the uid this script runs as"; return; }
(( (8#$mode & 8#077) == 0 )) || { echo "has mode $mode, which gives group or other permission bits"; return; }
# REPO is $(pwd), which keeps a symlink in the path the script ran by (webby-0028 N1).
real="$(realpath -e -- "$1")" && copies="$(realpath -m -- "$COPIES")" && repo="$(realpath -m -- "$REPO")" || { echo "cannot be resolved"; return; }
case "$real" in "$repo" | "$repo"/* | "$copies" | "$copies"/*) echo "is inside the repository or the copies"; return ;; esac
[ "$size" != 0 ] || echo "is empty"
}
# Row 200 item 3 and the design's phase 2 rollback order: a config.json with a `console` key must be
# one the target's loader reads, or its bus, Console and seats fail on their next read. Runs the
# target's own scripts/mosaic-config.mjs validate; its output, the resolved config, is not kept.
config_checks() {
local cfg="${MOSAIC_CONFIG:-$HOME/.config/mosaic-dev/config.json}" out loader="$COPIES/$TO/scripts/mosaic-config.mjs" first
# 0: a console key; 1: no key, or no file; 2: the file doesn't parse, so whether it holds the key
# is unknown, and the run refuses rather than pass it (webby-0028 N2). load_config already exits on
# such a file before anything is read or stopped, so this is reached only if the file changes after it.
local key=0
node -e 'const fs=require("fs"),f=process.argv[1];if(!fs.existsSync(f))process.exit(1);let d;try{d=JSON.parse(fs.readFileSync(f,"utf8"))}catch{process.exit(2)}
process.exit(d!==null&&typeof d==="object"&&Object.hasOwn(d,"console")?0:1)' -- "$cfg" 2>/dev/null || key=$?
[ "$key" != 1 ] || return 0
if [ "$key" != 0 ]; then
check_refuse config-console-key "$cfg cannot be read as JSON, so whether $TO's loader reads it is unknown; fix the config first, then run the $OP again"
return 0
fi
if [ ! -f "$loader" ]; then out="it has no scripts/mosaic-config.mjs"
elif out="$(MOSAIC_CONFIG="$cfg" node "$loader" validate 2>&1 >/dev/null)"; then return 0
fi
first="${out%%$'\n'*}"
check_refuse config-console-key "$cfg holds a console key, and $TO's loader refuses it ($first); remove the key first (restore the config.json backup from the window's step 1), then run the $OP again"
}
# Row 189 (mos-0972): --no-send drops --send from RUN_CONSOLE, so the unit and the pointer both lose
@@ -987,7 +799,7 @@ proof_checks() {
# Why the proof file can't serve, or nothing. lstat, stat and realpath only; the order follows
# webui-service.sh render, which refuses the same files.
proof_file_problem() {
local st uid mode type real copies repo
local st uid mode type real copies
case "$1" in /*) ;; *) echo "is not an absolute path"; return ;; esac
case "$1" in *[!A-Za-z0-9/._+-]*) echo "holds a character outside [A-Za-z0-9/._+-]"; return ;; esac
[ ! -L "$1" ] || { echo "is a symlink"; return; }
@@ -995,8 +807,8 @@ proof_file_problem() {
uid="${st%%:*}" st="${st#*:}"
mode="${st%%:*}" type="${st#*:}"
case "$type" in "regular file" | "regular empty file") ;; *) echo "is not a regular file"; return ;; esac
real="$(realpath -e -- "$1")" && copies="$(realpath -m -- "$COPIES")" && repo="$(realpath -m -- "$REPO")" || { echo "cannot be resolved"; return; }
case "$real" in "$repo" | "$repo"/* | "$copies" | "$copies"/*) echo "is inside the repository or the copies"; return ;; esac
real="$(realpath -e -- "$1")" && copies="$(realpath -m -- "$COPIES")" || { echo "cannot be resolved"; return; }
case "$real" in "$REPO" | "$REPO"/* | "$copies" | "$copies"/*) echo "is inside the repository or the copies"; return ;; esac
[ "$mode" = 600 ] || [ "$mode" = 400 ] || { echo "has mode $mode, not 600 or 400"; return; }
[ "$uid" = "$(id -u)" ] || echo "is owned by uid $uid, not $(id -u), the uid this script runs as"
}
@@ -1162,7 +974,6 @@ plan() {
[ -z "$DROP_TESTER" ] || echo "drop $DROP_TESTER; the target Console does not support it"
echo "$PROOF_PLAN"
echo "$SEND_PLAN"
[ -z "$SSO_PLAN" ] || echo "$SSO_PLAN"
echo "Console: scripts/webui-service.sh install --copy $TO --copies $COPIES${RUN_CONSOLE[*]:+ ${RUN_CONSOLE[*]}}"
echo "CLI: scripts/stack-copy.sh wrapper $TO --copies $COPIES --bin $BIN"
local r
@@ -1177,7 +988,6 @@ run_steps() {
[ -z "$DROP_TESTER" ] || say "drop $DROP_TESTER; the target Console does not support it"
[ -z "$PROOF_STEP" ] || say "$PROOF_STEP"
[ -z "$SEND_STEP" ] || say "$SEND_STEP"
[ -z "$SSO_STEP" ] || say "$SSO_STEP"
trap 'after_fail signal' INT TERM HUP
stop_both || rollback_in_run stop-timeout
if [ -e "$STORE" ]; then
@@ -1237,10 +1047,9 @@ do_activate() {
pointer_for_business
run_pm
[ "$P_PENDING" = 0 ] || check_refuse pm-pending "the pointer is pending (activate --pm-after-harness); run pm-on or rollback first"
if [ "$PLAN" = 1 ]; then check_target; console_for_target; plugin_checks; config_checks; plan; exit 0; fi
if [ "$PLAN" = 1 ]; then check_target; console_for_target; plugin_checks; plan; exit 0; fi
precheck
plugin_checks
config_checks
new_activation
run_steps migrate
}
@@ -1250,10 +1059,9 @@ do_rollback() {
pointer_for_business
TO="$(from_log previous)" || refuse no-previous "the log names no earlier copy than $P_COPY"
run_pm
if [ "$PLAN" = 1 ]; then check_target; console_for_target; plugin_checks; config_checks; plan; exit 0; fi
if [ "$PLAN" = 1 ]; then check_target; console_for_target; plugin_checks; plan; exit 0; fi
precheck
plugin_checks
config_checks
[ "$P_SCHEMA" = "$T_SCHEMA" ] || [ -n "$RESTORE" ] || refuse schema-differs "the store is at $P_SCHEMA and $TO expects $T_SCHEMA; pass --restore ACTIVATION"
new_activation
if [ -n "$RESTORE" ]; then
@@ -1703,7 +1511,6 @@ BUS_UNIT="mosaic-bus@$BUSINESS.service"
FROM="" TO="" ACT="" RS="" POST="" BACKUP="" TAKEN="" RESTORED="" STEPS="" COUNT="" UNCLEAN="" FAIL=""
STORE_SCHEMA="" START_EPOCH="" START_MS=0 GATE="" GATE_WHY="" S_CHANGED=""
P_COPY="" P_PM=0 P_PENDING=0 P_SCHEMA="" P_CLAUDE="" P_CONSOLE=() RUN_PM=0 RUN_PENDING=0 RUN_CONSOLE=() DROP_TESTER="" PROOF_PLAN="" PROOF_STEP="" SEND_PLAN="" SEND_STEP="" PLAN_REFUSE=()
BASE_CONSOLE=() SERVE_FLAGS=() SSO_PLAN="" SSO_STEP="" SSO_DROP="" SSO_NO_TARGET=""
R="" R_RC=0 R_EARLY=() R_BK="" R_HOST_PID="" R_CONSOLE_PID="" R_STARTED="" LIVE_CFG="" LIVE_BIZ=""
case "$OP" in
adopt) do_adopt ;;
-46
View File
@@ -151,52 +151,6 @@ else
FAIL=$((FAIL+1)); echo "${C_FAIL}FAIL${C_RESET} env exports resolve correctly"
fi
# --- Q175 phase 1 (#1690), design test 25: the optional console key ---
# Shape only. The slot path is a FIFO, so a loader that opened the slot would block and the
# timeout would fail the case; no case reads a slot file.
mkdir -p "$SANDBOX/secrets/mosaic-stack"
mkfifo "$SANDBOX/secrets/mosaic-stack/console-sso-test"
sso_body() {
printf '{"configVersion":1,"environment":"development","dataRoot":"%s","execution":{"backend":"docker","provider":"zai","model":"m"},"console":%s}' "$DATA_ROOT" "$1"
}
# provider [ALGS] [CLIENT_SECRET]: one provider entry; an empty argument takes the valid value.
provider() {
local algs='["RS256"]' secret='{"slot":"console-sso-test"}'
[ -n "${1:-}" ] && algs="$1"
[ -n "${2:-}" ] && secret="$2"
printf '{"id":"fake","issuer":"https://op.test/app/","clientId":"client-1","clientSecret":%s,"algs":%s,"subjects":[{"sub":"user-1","name":"jason.woltje"}]}' "$secret" "$algs"
}
cfg sso-valid.json "$(sso_body '{"sso":{"publicOrigin":"https://console.test","providers":['"$(provider)"']}}')"
OUT="$(MOSAIC_CONFIG="$SANDBOX/sso-valid.json" timeout 20 $CONFIG_OP validate 2>&1)"
[ $? -eq 0 ] && printf '%s' "$OUT" | grep -q '"publicOrigin": "https://console.test"'
check "valid console.sso loads and is in the output" $?
# expect_refusal NAME PATH-IN-MESSAGE BODY: exit 2, and the message names the bad key path.
expect_refusal() {
cfg sso-bad.json "$2"
local err rc
err="$(MOSAIC_CONFIG="$SANDBOX/sso-bad.json" timeout 20 $CONFIG_OP validate 2>&1 >/dev/null)"
rc=$?
[ "$rc" -eq 2 ] && printf '%s' "$err" | grep -qF -- "$3"
check "$1 (exit $rc)" $?
}
expect_refusal "console.sso HS256 alg exits 2 naming the path" \
"$(sso_body '{"sso":{"publicOrigin":"https://console.test","providers":['"$(provider '["HS256"]')"']}}')" 'console.sso.providers[0].algs[0] must be one of'
expect_refusal "console.sso alg none exits 2 naming the path" \
"$(sso_body '{"sso":{"publicOrigin":"https://console.test","providers":['"$(provider '["RS256","none"]')"']}}')" 'console.sso.providers[0].algs[1] must be one of'
expect_refusal "console.sso missing slot field exits 2 naming the path" \
"$(sso_body '{"sso":{"publicOrigin":"https://console.test","providers":['"$(provider '' '{}')"']}}')" 'console.sso.providers[0].clientSecret.slot is required'
expect_refusal "console.sso slot name outside the pattern exits 2 naming the path" \
"$(sso_body '{"sso":{"publicOrigin":"https://console.test","providers":['"$(provider '' '{"slot":"../x"}')"']}}')" 'console.sso.providers[0].clientSecret.slot must match'
expect_refusal "unknown key under console exits 2 naming the path" \
"$(sso_body '{"sso":{"publicOrigin":"https://console.test","providers":['"$(provider)"']},"extra":1}')" 'console.extra is not a supported key'
# Without the key: the output is exactly what the loader printed before phase 1 (sage-1340, F2).
cfg no-console.json '{"configVersion":1,"environment":"development","dataRoot":"'$DATA_ROOT'","execution":{"backend":"docker","provider":"zai","model":"m"}}'
EXPECTED="$(printf '{\n "configVersion": 1,\n "environment": "development",\n "dataRoot": "%s",\n "execution": {\n "backend": "docker",\n "provider": "zai",\n "model": "m",\n "adapter": "pi"\n }\n}' "$DATA_ROOT")"
[ "$(MOSAIC_CONFIG="$SANDBOX/no-console.json" timeout 20 $CONFIG_OP validate)" = "$EXPECTED" ]
check "config without console loads with unchanged output" $?
# --- validation must not modify the file ---
SUM_INVALID_BEFORE=$(sha256sum "$SANDBOX/invalid.json" | cut -d' ' -f1)
MOSAIC_CONFIG="$SANDBOX/invalid.json" $CONFIG_OP validate >/dev/null 2>&1
+7 -20
View File
@@ -49,9 +49,6 @@
# character set and refused otherwise. --proof-header NAME --proof-file PATH
# go together under preview; only the path is carried here, never the secret.
# render checks ownership, mode and location without opening the proof file.
# --sso (Q175 phase 1, #1690): the Console signs people in itself from
# console.sso in config.json. It refuses the preview set and the proof pair,
# and --send takes it in place of the preview set. Nothing secret is carried.
#
# There is no start, stop, enable or restart verb. The Console reads the
# main listener's bus through the human transport; the optional loopback
@@ -71,7 +68,7 @@ REPO="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
TEMPLATE="$REPO/packages/webui/systemd/mosaic-webui.service.in"
UNIT_NAME="mosaic-webui.service"
USAGE="usage: scripts/webui-service.sh copy COMMIT [--copies DIR] [--allow-worktree] | render [--copy ID [--copies DIR]] [FLAGS] | install --copy ID [--copies DIR] [--dir DIR] [--no-reload] [--allow-worktree] [FLAGS] | uninstall [--dir DIR] [--no-reload] | status [--dir DIR]
FLAGS: [--port N] [--business ID] [--board URL] [--sso [--send] | --public-host NAME --auth-header HEADER --allow-user ID [--send]] [--tester-port N] [--proof-header NAME --proof-file PATH]
FLAGS: [--port N] [--business ID] [--board URL] [--public-host NAME --auth-header HEADER --allow-user ID [--send]] [--tester-port N] [--proof-header NAME --proof-file PATH]
--tester-port: 1024..65535, different from --port and never 7332; loopback tester listener.
--allow-worktree is for the tests only: copy and install otherwise refuse outside the canonical checkout."
@@ -94,7 +91,6 @@ PUBLIC_HOST=""
AUTH_HEADER=""
ALLOW_USER=""
SEND=0
SSO=0
PROOF_HEADER=""
PROOF_FILE=""
PROOF_SET=0
@@ -118,7 +114,6 @@ parse_flags() {
--proof-header) [ $# -ge 2 ] || usage; PROOF_HEADER="$2"; PROOF_SET=1; SERVE_SET=1; shift 2 ;;
--proof-file) [ $# -ge 2 ] || usage; PROOF_FILE="$2"; PROOF_SET=1; SERVE_SET=1; shift 2 ;;
--send) SEND=1; SERVE_SET=1; shift ;;
--sso) SSO=1; SERVE_SET=1; shift ;;
*) usage ;;
esac
done
@@ -171,13 +166,9 @@ serve_flags() {
[ -z "$PUBLIC_HOST" ] || preview=$((preview + 1))
[ -z "$AUTH_HEADER" ] || preview=$((preview + 1))
[ -z "$ALLOW_USER" ] || preview=$((preview + 1))
# serve refuses --sso with the preview set or the proof pair, and --send
# without --sso or the preview set; refuse them here first, so no unit is
# written that would only exit 2 at start.
if [ "$SSO" = 1 ] && { [ "$preview" -ne 0 ] || [ "$PROOF_SET" = 1 ]; }; then
die "--sso cannot be combined with --public-host, --auth-header, --allow-user or --proof-*"
fi
[ "$SEND" = 0 ] || [ "$preview" -eq 3 ] || [ "$SSO" = 1 ] || die "--send needs the preview mode (--public-host, --auth-header and --allow-user) or --sso"
# serve refuses --send without the preview set; refuse it here first,
# so no unit is written that would only exit 2 at start.
[ "$SEND" = 0 ] || [ "$preview" -eq 3 ] || die "--send needs the preview mode (--public-host, --auth-header and --allow-user)"
if [ "$preview" -ne 0 ]; then
[ "$preview" -eq 3 ] || die "--public-host, --auth-header and --allow-user go together: all three or none"
case "$PUBLIC_HOST" in [A-Za-z0-9]*) ;; *) die "public host must be a hostname, no port or scheme: $PUBLIC_HOST" ;; esac
@@ -210,7 +201,6 @@ serve_flags() {
{ [ "$proof_mode" = 600 ] || [ "$proof_mode" = 400 ]; } && [ "$proof_uid" = "$(id -u)" ] || die "invalid proxy proof file metadata"
flags="$flags --proof-header $PROOF_HEADER --proof-file $PROOF_FILE"
fi
[ "$SSO" = 0 ] || flags="$flags --sso"
[ "$SEND" = 0 ] || flags="$flags --send"
[ "$TESTER_SET" = 0 ] || flags="$flags --tester-port $TESTER_PORT"
echo "$flags"
@@ -395,17 +385,14 @@ status_unit() {
else
echo "copy: none; the unit runs from $wd"
fi
# render puts --send after the allowed user and optional proof pair, or
# after --sso, before the tester port. The allowed user
# render puts --send after the allowed user and optional proof pair,
# before the tester port. The allowed user
# starts with a letter or digit (N5), so it is never the value --send.
if grep -Eq '^ExecStart=.*( --allow-user [^ ][^ ]*( --proof-header [^ ]+ --proof-file /[^ ]+)?| --sso) --send( --tester-port [0-9]+)?$' "$target"; then
if grep -Eq '^ExecStart=.* --allow-user [^ ][^ ]*( --proof-header [^ ]+ --proof-file /[^ ]+)? --send( --tester-port [0-9]+)?$' "$target"; then
echo "send: on (--send; human messages to roles)"
else
echo "send: off (read-only)"
fi
if grep -Eq '^ExecStart=.* --sso( --send)?( --tester-port [0-9]+)?$' "$target"; then
echo "sign-in: the Console's own (--sso)"
fi
local tester_port
tester_port="$(sed -n 's/^ExecStart=.* --tester-port \([0-9][0-9]*\)$/\1/p' "$target")"
if [ -n "$tester_port" ]; then