feat: SSO accounts sessions and deployed-path reconciliation (M3, #1873) #1918
No files matched your search
+84
-6
@@ -807,13 +807,52 @@ the port and the backup's `SHA256SUMS`. Then it makes
|
||||
Roles come from the target copy's `roles/`, as the live bus reads them.
|
||||
- `steps.log` holds the same PASS and FAIL lines the run prints, with times;
|
||||
`host.log`, `console.log` and, after a migration, `migrate.log` sit beside it.
|
||||
- When the live config has `console.sso` (M3, #1873), the scratch copy keeps
|
||||
its provider ids, client ids, algorithms and subjects and moves the public
|
||||
origin to `rehearsal.invalid`, a name that never resolves. For a stamped
|
||||
target each issuer becomes `ORIGIN/ID/` at the rehearsal provider below;
|
||||
otherwise it moves to `rehearsal.invalid` too. Each client secret slot is a 0600 file under `config/secrets/mosaic-stack/`
|
||||
holding `rehearsal-placeholder-secret`; the live secret is never read.
|
||||
|
||||
A stamped target copy (one with `.stack-build.json`) serves only with `--sso`,
|
||||
so its rehearsal needs the live instance's human-made SSO steps. Without a live
|
||||
`console.sso` it refuses `sso-not-prepared`; without the live `accounts.json`
|
||||
beside the config it refuses `accounts-not-initialized`. Both name the run-sheet
|
||||
step. Otherwise it copies the live `accounts.json` into `config/`, writes
|
||||
`data/.deployed` with the target copy's own `writeDeployment`, prints
|
||||
`PASS deployment`, and starts the Console with `--sso`.
|
||||
|
||||
Before that, a stamped run starts a rehearsal provider and prints
|
||||
`PASS provider-start`. It is a node process on `127.0.0.1` port 0 that serves
|
||||
HTTPS with a certificate made for this run, under `provider/` (mode 0700). For
|
||||
each live provider id it serves discovery and a JWKS whose public key it made
|
||||
in memory for the first algorithm it can make from that provider's list. Its
|
||||
authorize and token routes answer 403 to any method and query: it issues no
|
||||
code and no token, and
|
||||
it reads no live secret. It logs each request to `provider/requests.log`,
|
||||
deletes its TLS key file once it has read it, and exits on SIGTERM or when
|
||||
the rehearsal's process is gone. `provider-start` fails when it exits early,
|
||||
writes no origin within the timeout, writes an origin that is not
|
||||
`https://127.0.0.1:PORT`, or leaves its key file. Only the scratch Console gets
|
||||
`NODE_EXTRA_CA_CERTS` naming `provider/cert.pem`; the host does not.
|
||||
|
||||
The scratch Console runs every startup check against that provider. The gate
|
||||
adds `sso-ready`: `/healthz` must report each provider `ready`. After the gate,
|
||||
`provider-discovery` requires a 200 discovery and JWKS request per provider and
|
||||
no authorize or token request. The end stops the provider with the Console and
|
||||
host and prints `provider-stopped`. The live `accounts.json` joins the end check
|
||||
`live-config-unchanged`. The rehearsal adds no subject and no account.
|
||||
|
||||
A guard then loads the scratch config with the target's code and refuses
|
||||
with a named check: `data-root-live` when the scratch data root resolves to
|
||||
the live one, `sessions-live` when a `sessions.json` there names a running
|
||||
pid (the run never deletes the file), `data-root-scratch`, `notify-off`,
|
||||
`tracker-configured` when `bootConfig` still sets a tracker, and
|
||||
`credential-ref-live` for an env reference or a file outside `placeholders/`.
|
||||
`tracker-configured` when `bootConfig` still sets a tracker,
|
||||
`credential-ref-live` for an env reference or a file outside `placeholders/`,
|
||||
and `sso-isolated` when a `console.sso` origin is not on `rehearsal.invalid`,
|
||||
an issuer is not `ORIGIN/ID/` at the rehearsal provider (not on
|
||||
`rehearsal.invalid` for an unstamped target), or a slot is not its scratch
|
||||
placeholder.
|
||||
When the backup's schema differs from the target's, the run checks that the
|
||||
target refuses the store, migrates it, opens it, migrates it again as a
|
||||
no-op, and that the pointer's copy refuses the migrated store.
|
||||
@@ -822,7 +861,8 @@ It starts `scripts/mosaic bus start BUSINESS --no-launch` and the Console's
|
||||
`serve --port N --business BUSINESS` from the target copy, without `--send`.
|
||||
The gate needs the host's `launches disabled` line, a live `bus status` host
|
||||
with this run's pid and a boot from this start, the Console's address line,
|
||||
`/healthz` 200, and both command lines on the target copy. Then it checks
|
||||
`/healthz` 200 (and `sso-ready` for a stamped target), and both command lines
|
||||
on the target copy. Then it checks
|
||||
that the host wrote no `sessions.json` and no `launches`, stops the Console
|
||||
and the host (SIGTERM, then SIGKILL and a FAIL after the timeout), checks that
|
||||
the host released `writer.lock`, and compares the backup and the live config
|
||||
@@ -834,6 +874,21 @@ Usage exits 4. Nothing prunes rehearsal directories.
|
||||
starts no thread ticker, and leaves an earlier host's `sessions.json` and
|
||||
its runs alone. No unit passes it, and `--pm` with it is a usage error.
|
||||
|
||||
An accounts instance runs system units (M3, #1873). Without `--units external`,
|
||||
`activate`, `rollback` and `pm-on` refuse `accounts-user-unit`, before
|
||||
anything stops and before `--plan` prints, when the target copy holds a
|
||||
`.stack-build.json` (a stamped copy), when `accounts.json` sits beside the
|
||||
config, when the config holds `console.sso`, or when `<dataRoot>/.deployed`
|
||||
exists. A config that cannot be read as JSON refuses as well. The message
|
||||
names the first sign found and says root renders the units with
|
||||
`case2-unit.sh`. For `activate` and `rollback` it adds that the run takes
|
||||
`--units external`. For `pm-on` it says the PM is a flag in root's bus unit.
|
||||
So a user-unit run never turns `--sso` on any more. The `--sso`, `sso-slot`
|
||||
and `--no-sso` text above describes the pointers that pre-M3 hosts left, and
|
||||
the Console's own start checks the slot (`readSlot` in
|
||||
`packages/webui/src/sso.mjs`). A rollback or `--no-sso` from such a pointer
|
||||
runs once `console.sso` is gone and no other sign is present.
|
||||
|
||||
#### Root-owned units (`--units external`, row 232)
|
||||
|
||||
On a host whose bus and Console are root's system units, the stack user runs
|
||||
@@ -894,8 +949,9 @@ the target migrates the store and the run lacks `--migrate` (row 233). Under
|
||||
|
||||
```
|
||||
scripts/stack-upgrade.sh upgrade --instance dev --bundle FILE --sha SHA [--migrate] [--no-rehearsal]
|
||||
[--rehearse-port N] [--timeout S]
|
||||
scripts/stack-upgrade.sh rollback --instance dev --to ID [--restore ACT [--events N]] [--timeout S]
|
||||
[--rehearse-port N] [--next-args] [--timeout S]
|
||||
scripts/stack-upgrade.sh rollback --instance dev --to ID [--restore ACT [--events N]] [--next-args]
|
||||
[--timeout S]
|
||||
```
|
||||
|
||||
Root runs it from a root-owned checkout of the reviewed commit, never from a
|
||||
@@ -935,7 +991,13 @@ user's clone `<home>/src/mosaic-stack`:
|
||||
(`args-differ`), and
|
||||
`activate SHA BUSINESS --units external --plan --expect-from OLD` exits 0
|
||||
(`plan-refused`, which covers `open-activation`, `migrate-required` and
|
||||
`pointer-differs`).
|
||||
`pointer-differs`). Then the render checks (#1873 M3):
|
||||
`case2-unit.sh --check` renders OLD from the args file and compares the
|
||||
installed units with it, so a rollback could render them, then checks
|
||||
SHA's render the same way. Either refusal is `render-refused`. A re-pin
|
||||
that changes the serve flags fails the second check, because an
|
||||
installed unit must equal the render but for the copy id (row 195); it
|
||||
takes `--next-args`, below.
|
||||
4. The rehearsal, `rehearse SHA BUSINESS --backup last --port 7339`
|
||||
(`--rehearse-port`). Exit 0 goes on. Exit 3 means no backup in the log
|
||||
and goes on only with `--no-rehearsal`, logging `rehearsal-skipped`;
|
||||
@@ -950,6 +1012,22 @@ user's clone `<home>/src/mosaic-stack`:
|
||||
still names the old one, and root moves the new file (the result line
|
||||
names it) into place by hand.
|
||||
|
||||
`--next-args` changes the serve flags, as the SSO migration does
|
||||
(`docs/guides/host-run-sheet.md` section 7). The run reads
|
||||
`/etc/mosaic-stack/dev.args.next` as it reads the args file
|
||||
(`args-unreadable`). It must equal the args file before `--` but for
|
||||
`--copy`'s value (`next-args-differ`) and differ from it after `--`
|
||||
(`next-args-same`). The target's check is `case2-unit.sh --check-new`, a
|
||||
first install's checks from the next args, without the installed units'.
|
||||
After A3, root moves the installed unit files into a new 0700 directory
|
||||
`/etc/mosaic-stack/dev.replaced.XXXXXX` (step `units-aside`), so A4 renders
|
||||
as a first install. A failure from there moves them back unchanged
|
||||
(`units-back`), runs `daemon-reload` and starts the old units with no render.
|
||||
After A5 the old args file is copied into the replaced directory, the next
|
||||
args file is removed (step `next-args`), and the new args file, made from
|
||||
the next args, moves into place. The result line names the replaced
|
||||
directory. Nothing removes it.
|
||||
|
||||
A failure in A2 or a stage that exits 3 starts the old units again and gates
|
||||
them (A2f). A later failure stops both units, runs `revert`, renders the old
|
||||
copy and runs `confirm --rollback OLD --cause A3|A4|A5`. `rollback` is the
|
||||
|
||||
@@ -386,10 +386,15 @@ then the bus, then the proxy, re-render all three onto the selected copy, and st
|
||||
then the Console. Recovery after a failed upgrade restores all three to the old copy. A missing or
|
||||
disagreeing proxy unit, or an installed proxy without `--proxy` in the args file, refuses before anything stops.
|
||||
|
||||
For the `library` profile, the serve argv after `--` keeps the proxy-preview trio and required proof pair
|
||||
but omits `--send`. That Console is read-only until Case 2 gains real OIDC in #412; `case2-unit.sh`
|
||||
explicitly refuses `--send`. Proxy preview and proof-protected reads remain available. The `factory`
|
||||
profile and its private tester socket are unchanged.
|
||||
A stamped copy, which is every copy `stack-copy.sh` makes, signs in through SSO accounts in both profiles
|
||||
(M3, #1873; Jason's ruling 2026-11-10). After `--`, the `factory` profile takes `--sso --send` with its
|
||||
private tester socket. The `library` profile takes a bare `--sso`, still without `--send`, so its Console
|
||||
stays read-only. A stamped library copy refuses the proxy-preview trio (`--public-host`, `--auth-header`,
|
||||
`--allow-user`) and the proof pair (`--proof-header`, `--proof-file`): proxy-preview admission does not
|
||||
apply to a stamped copy. Only an unstamped library copy, which no host install makes, keeps the old
|
||||
proxy-preview argv and refuses `--sso`. Before writing anything, `case2-unit.sh` refuses a stamped copy
|
||||
that lacks `--sso` or any prerequisite and names each missing step. The migration below lists them in
|
||||
order.
|
||||
|
||||
A library host installed before #385 has `--send` in two places, because the earlier `case2-unit.sh`
|
||||
required it: a `--send` line after `--` in the args file, and `--send` in the installed Console unit's
|
||||
@@ -418,10 +423,77 @@ grep -n '^ExecStart=' /etc/systemd/system/mosaic-webui.service | grep -c -- ' --
|
||||
lead with the command's output. Removing only the args line does not help: the run then refuses
|
||||
`console-unit-legacy`.
|
||||
|
||||
The Console stays read-only after the upgrade, until #412 (#385).
|
||||
The library Console stays read-only after the upgrade (#385). From M3 it signs in through SSO
|
||||
accounts, as the migration below sets up.
|
||||
|
||||
Precheck line: none. `stack-upgrade.sh` refuses `args-unreadable` or `console-unit-legacy` on any of these
|
||||
rules, before it stops a unit.
|
||||
### Migrating a host to SSO accounts (M3, #1873)
|
||||
|
||||
A host re-pinned onto an M3 copy migrates first, in this order. The order is the same for the `factory`
|
||||
(Case 1 and .53) and `library` (.54) profiles. Each step is a live step that needs Mos's go. Nothing in the
|
||||
stack picks or invents a subject: the subjects come from `console.sso`, which a human prepares, and
|
||||
`accounts.json` comes only from the human verb `mosaic accounts init`.
|
||||
|
||||
1. Check or create `.deployed` through the installer. The stack's user's `case2-install.sh` (section 6)
|
||||
writes `<dataRoot>/.deployed` naming the instance at its step 2. It never replaces an existing one, and
|
||||
it refuses one that is invalid or names another instance. Check it read-only:
|
||||
`stat -c '%U %a' <dataRoot>/.deployed` prints `mosaic 444`.
|
||||
2. Prepare SSO. A human adds `console.sso` to `/srv/mosaic/.config/mosaic-dev/config.json`, with its
|
||||
providers, their subjects and its client-secret slots, per
|
||||
`docs/plans/2026-11-10_accounts-design.md`. Jason places the client secrets (section 9).
|
||||
3. Run `mosaic accounts init --name <name>` as `mosaic`. This is a human verb, run from a human shell and
|
||||
never by an agent. It creates the one operator account from the configured `console.sso` subjects and
|
||||
refuses if `accounts.json` exists.
|
||||
4. Provision the dedicated uid. `mosaic` must keep section 1's shape: a non-login shell, the only member
|
||||
of its primary group, and owner of its config directory and data root. An accounts instance never runs
|
||||
under a human or development seat uid, and never under the `webui-service.sh` user units.
|
||||
5. Re-pin with `--next-args`. Leave `/etc/mosaic-stack/dev.args` as it is. Write the new flags to
|
||||
`/etc/mosaic-stack/dev.args.next`, root's, mode 0640: the same lines before `--`, and after `--` the
|
||||
serve flags with `--sso` added (`factory`: `--sso --send`). For `library`, also remove the
|
||||
proxy-preview trio (`--public-host`, `--auth-header`, `--allow-user`) and the proof pair
|
||||
(`--proof-header`, `--proof-file`). Then run
|
||||
`stack-upgrade.sh upgrade --instance dev --bundle FILE --sha SHA --next-args`.
|
||||
|
||||
A plain re-pin refuses `render-refused` here, because an installed unit must equal the render but for
|
||||
the copy id. With `--next-args`, before anything stops, the driver checks the installed copy against
|
||||
`dev.args` and the target as a first install from `dev.args.next`. It refuses `next-args-differ` when
|
||||
the files differ before `--` (other than `--copy`), and `next-args-same` when they do not differ after
|
||||
it. After the stage it moves the installed unit files into a new 0700 directory
|
||||
`/etc/mosaic-stack/dev.replaced.XXXXXX` and renders the target as a first install. A failure from
|
||||
there moves them back unchanged and starts the old copy again. After `confirm` passes,
|
||||
`dev.args.next` becomes `dev.args`, and the replaced directory keeps the old unit files and the old
|
||||
`dev.args`. The result line names the directory. Nothing removes it; root removes it by hand once no
|
||||
rollback needs it.
|
||||
|
||||
To roll back past the migration, put the replaced args back as the next args and roll back with
|
||||
`--next-args`:
|
||||
|
||||
```sh
|
||||
install -m 0640 -o root -g root /etc/mosaic-stack/dev.replaced.XXXXXX/dev.args /etc/mosaic-stack/dev.args.next
|
||||
stack-upgrade.sh rollback --instance dev --to <old copy id> --next-args
|
||||
```
|
||||
|
||||
For `library` this is the only rollback to a copy older than M3: `case2-unit.sh` refuses `--sso` for
|
||||
an unstamped library copy (#416), so the old copy comes back with its old flags, proxy-preview
|
||||
admission included, as before the migration. A `factory` rollback may instead keep `--sso --send`
|
||||
with a plain `rollback`.
|
||||
|
||||
If a step is missing, `case2-unit.sh` refuses before it writes a unit and prints the missing steps,
|
||||
numbered in this order, for example:
|
||||
|
||||
```text
|
||||
case2-unit: refusing the units for <sha>: a stamped copy's Console starts only with SSO accounts; missing steps, in order:
|
||||
1. check or create .deployed: mosaic runs case2-install.sh, whose deployment step writes <dataRoot>/.deployed
|
||||
2. prepare SSO: console.sso in mosaic's system config /srv/mosaic/.config/mosaic-dev/config.json, with its client secret slots
|
||||
3. run `mosaic accounts init` as mosaic, a human verb: no /srv/mosaic/.config/mosaic-dev/accounts.json
|
||||
4. add --sso to the serve flags, then re-pin
|
||||
```
|
||||
|
||||
Without `console.sso`, the render does not check `accounts.json`, because its subjects name providers SSO
|
||||
has not configured yet. The next render after step 2 checks it. A rollback never removes accounts.json,
|
||||
its receipts or `.deployed` (`docs/plans/2026-11-10_accounts-design.md`, section 7).
|
||||
|
||||
Precheck line: none. `stack-upgrade.sh` refuses `args-unreadable`, `console-unit-legacy`, `render-refused`,
|
||||
`next-args-differ` or `next-args-same` on any of these rules, before it stops a unit.
|
||||
|
||||
## 8. Enrollment, once per host
|
||||
|
||||
@@ -450,7 +522,7 @@ file with `stat -c '%U %a %n' PATH`, never `cat`. `<business>` is the host's bus
|
||||
| Path | File | Mode | Owner | Profile | Precheck line |
|
||||
|---|---|---|---|---|---|
|
||||
| `/srv/mosaic/.config/mosaic-dev/secrets/` | `claude-session.env`, one line `ZAI_API_KEY=...` | 0600 | mosaic | library | `<path> sets ZAI_API_KEY` |
|
||||
| `/srv/mosaic/.config/mosaic-dev/secrets/` | the Console's proof file, the path the args file's `--proof-file` names: one base64url line, at least 32 `[A-Za-z0-9_-]` characters, with one optional trailing LF (`packages/webui/src/serve.mjs:263-264`) | 0600 or 0400 | mosaic | library | none. Render checks metadata only and never opens the file (`scripts/case2-unit.sh:397-408`). Bad bytes refuse at Console start with exit 2 (`packages/webui/src/serve.mjs:267`, `packages/webui/src/cli.mjs:138-140`). Precheck line missing. |
|
||||
| `/srv/mosaic/.config/mosaic-dev/secrets/` | before M3 only, and an unstamped copy only: the Console's proof file, the path the args file's `--proof-file` names. A stamped copy refuses `--proof-file` (section 7). The file is one base64url line, at least 32 `[A-Za-z0-9_-]` characters, with one optional trailing LF (`packages/webui/src/serve.mjs:263-264`) | 0600 or 0400 | mosaic | library | none. Render checks metadata only and never opens the file (`scripts/case2-unit.sh:397-408`). Bad bytes refuse at Console start with exit 2 (`packages/webui/src/serve.mjs:267`, `packages/webui/src/cli.mjs:138-140`). Precheck line missing. |
|
||||
| `/srv/mosaic/.config/mosaic-dev/secrets/<business>/` | `library-gitea` | 0600 | mosaic | library | After a unit start, wait for loopback `/healthz` with the bounded retry in section 10 before `case2-precheck.sh --business <business> --library-status`: `library-last-error library-credential-missing` until it's placed, then `library <project> last fetched ...` |
|
||||
| `/srv/mosaic/.config/mosaic-dev/secrets/agents/<business>/<agent>/` | one file per service | 0600, dirs 0700 | mosaic | both | none. Filled only by `mosaic credentials issue`, or Approve after #436. Never by hand. |
|
||||
|
||||
|
||||
@@ -108,7 +108,10 @@ configured subject (`packages/cli/src/config.mjs:141`), and
|
||||
`packages/webui/src/cli.mjs serve` from that copy. Its flag set is
|
||||
`--port`, `--business`, `--board`, `--sso`, `--send` and the header
|
||||
preview set (`webui-service.sh:98`). It has no dev-owner flag.
|
||||
`scripts/case2-unit.sh` renders Case 2 units the same way.
|
||||
`scripts/case2-unit.sh` renders Case 2 units the same way. At BASE its
|
||||
library profile takes the preview set and refuses `--sso`. Under the
|
||||
owner ruling of 2026-11-10 a stamped library copy signs in through SSO
|
||||
accounts instead (section 6, item 5).
|
||||
|
||||
### Organizations and who may read them
|
||||
|
||||
@@ -1231,7 +1234,13 @@ The executable boundary:
|
||||
this instance, and accounts.json loads. The Case 1 and Case 2 installers
|
||||
write `.deployed`.
|
||||
5. **The unit renderers have no dev flag.** `webui-service.sh` and
|
||||
`case2-unit.sh` accept no dev flag today (`webui-service.sh:98`). M3 adds
|
||||
`case2-unit.sh` accept no dev flag today (`webui-service.sh:98`). For a
|
||||
stamped copy, `case2-unit.sh` renders either profile, factory or library,
|
||||
only with `--sso`, `console.sso`, a loading accounts.json and a valid
|
||||
`.deployed`, and refuses before any write, naming each missing step. A
|
||||
stamped library unit refuses proxy-preview admission and its proof flags:
|
||||
proxy-preview does not apply to a stamped copy. The dev and unstamped
|
||||
paths keep the preview set. M3 adds
|
||||
a test that pins it.
|
||||
|
||||
The instance boundary, which also covers a checkout started by mistake on a
|
||||
@@ -1289,13 +1298,22 @@ business file.
|
||||
|
||||
### Rollback
|
||||
|
||||
Rollback keeps OIDC. Nothing in it removes `--sso` or turns sign-in off.
|
||||
Rollback keeps OIDC. Nothing in it removes `--sso` or turns sign-in off,
|
||||
except the library rollback past the migration below.
|
||||
|
||||
- The supported rollback is a pinned code and config rollback: install the
|
||||
earlier stack copy with the same unit flags, including `--sso`, through
|
||||
`webui-service.sh install --copy <earlier id>`, as the unit script
|
||||
already describes. Leave accounts.json, its receipts and `.deployed` in
|
||||
`stack-upgrade.sh rollback --instance NAME --to <earlier id>` for the
|
||||
dedicated system units. The accounts instance must not use the
|
||||
`webui-service.sh` user-unit path. Leave accounts.json, its receipts and `.deployed` in
|
||||
place. BASE code ignores them, and the next roll-forward needs them.
|
||||
- A library host rolled back to a copy older than M3 is the exception.
|
||||
`case2-unit.sh` refuses `--sso` for an unstamped library copy (#416), so
|
||||
that rollback renders the old copy from the args file the migration
|
||||
replaced, through `stack-upgrade.sh rollback ... --next-args`
|
||||
(`docs/guides/host-run-sheet.md` section 7). The old copy comes back with
|
||||
its old flags, proxy-preview admission included, as before the migration.
|
||||
accounts.json and `.deployed` stay in place.
|
||||
- Before the first accounts org exists, that rollback restores today's
|
||||
behavior completely. `console.sso` owners sign in as before, and FED R6
|
||||
users keep their root grants. Account-only users and pending invites stop
|
||||
@@ -1450,6 +1468,9 @@ Acceptance tests, against a fake OIDC provider on port 0:
|
||||
- `--dev-local-owner` from a checkout refuses with accounts.json present,
|
||||
with `console.sso`, and with `.deployed`.
|
||||
- `webui-service.sh render` and `case2-unit.sh` refuse a dev flag.
|
||||
- For a stamped copy, `case2-unit.sh` renders a library or factory unit
|
||||
only with `--sso` and the prerequisites, names each missing step before
|
||||
any write, and refuses the proxy-preview and proof flags.
|
||||
- `case2-unit.sh` accepts the host run sheet's `mosaic` account fixture only
|
||||
when its shell is non-login, its primary group has no other members, and
|
||||
it owns the config directory and data root. Fixtures for a human login and
|
||||
@@ -1666,6 +1687,8 @@ and draft 3 applies them. No question from draft 2 remains open.
|
||||
| May an invite bind to an email domain? | "agreed" | No domain binding. Section 4 binding, test in M6. |
|
||||
| May members read the board and write tasks? | "Similar to 1. grantable right via RBAC. Default owner R/W, member R" | The `board.read` and `task.write` rights. `member` holds `board.read` only. Section 3, slices M1, M5 and M7. |
|
||||
|
||||
The library Console signs in through SSO accounts like every deployed Console; proxy-preview admission does not apply to a stamped copy.
|
||||
|
||||
### Choices made under the rulings
|
||||
|
||||
These are decided in this draft, not open. Each one is a single reviewed
|
||||
|
||||
+12
-2
@@ -1304,8 +1304,10 @@ inactive. Project settings changes are append-only and require human proof.
|
||||
`mosaic accounts` administers the instance-level accounts store locally, as the
|
||||
effective uid owning the config directory. It requires a human shell, rejects `--tester`,
|
||||
and takes no business selector or bus connection. It has no organization rights
|
||||
verbs. Console sign-in, sessions and membership evaluation are later Phase M
|
||||
slices; these commands alone do not turn accounts mode on.
|
||||
verbs. Console OIDC sign-in re-reads these accounts on every request. A disabled
|
||||
account or changed authentication generation loses account authority immediately;
|
||||
a legacy access.json root grant is checked independently. Organization membership
|
||||
evaluation follows in later Phase M slices.
|
||||
|
||||
```sh
|
||||
mosaic accounts init --name Operator
|
||||
@@ -1327,3 +1329,11 @@ organization (including archived organizations) or any registry entry lacks its
|
||||
owner metadata during creation, and removes its memberships in
|
||||
the same write. List reports rollback rows needing repair; its JSON omits invite
|
||||
bindings and hashes. Receipts record effective process uid and verb and omit subjects and secrets.
|
||||
|
||||
Managed accounts instances use the system units from `scripts/case2-unit.sh`.
|
||||
The selected uid must use a non-login shell, be the only member of its primary
|
||||
group, and own its config directory and data root. The shared Case 1/Case 2
|
||||
installer writes `<dataRoot>/.deployed` as mode 0444 naming the instance.
|
||||
`webui-service.sh` refuses accounts and deployed instances because its user
|
||||
manager runs under a human or development seat uid. The receipt uid identifies
|
||||
the invoking process; it does not attribute a human or resist tampering by that uid.
|
||||
@@ -8,6 +8,7 @@
|
||||
// host (src/host.mjs) the systemd unit mosaic-bus@<business> runs; with
|
||||
// --pm it also launches the business's PM (src/launcher.mjs).
|
||||
|
||||
import { assertBuildStart, assertDevInstance } from './deployment.mjs';
|
||||
import { readFileSync } from "node:fs";
|
||||
import { basename, join } from "node:path";
|
||||
import { createInterface } from "node:readline/promises";
|
||||
@@ -614,8 +615,10 @@ async function bus(rest, io, deps) {
|
||||
const parsed = parse(args, { flags: ["--pm", "--no-launch", "--dev-local-owner", "--detach", "--internal-host"] });
|
||||
if (parsed.positional.length !== 1) throw usage("bus start takes one organization");
|
||||
if (parsed.flags.has("--no-launch") && parsed.flags.has("--pm")) throw usage("bus start takes --pm or --no-launch, not both");
|
||||
assertBuildStart({ devOwner: parsed.flags.has("--dev-local-owner") });
|
||||
if (parsed.flags.has("--dev-local-owner")) devOwnerInstance(io.env);
|
||||
const ctx = organizationContext(io, deps);
|
||||
if (parsed.flags.has("--dev-local-owner")) assertDevInstance(ctx.system, io.env);
|
||||
const businessId = parsed.positional[0];
|
||||
if (!parsed.flags.has("--internal-host")) {
|
||||
const receipt = await busStart(ctx, businessId, { detach: parsed.flags.has("--detach"), devLocalOwner: parsed.flags.has("--dev-local-owner"), pm: parsed.flags.has("--pm"), noLaunch: parsed.flags.has("--no-launch") });
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
// M3: executable and instance boundaries. Presence is checked with lstat, so
|
||||
// broken links and unreadable metadata never turn a deployed path into dev.
|
||||
import { constants, lstatSync, openSync, fstatSync, readFileSync, closeSync, writeFileSync, fchmodSync, mkdirSync } from 'node:fs';
|
||||
import { basename, dirname, join, resolve } from 'node:path';
|
||||
import { homedir } from 'node:os';
|
||||
const configDir = (env = process.env) => dirname(resolve(env.MOSAIC_CONFIG || join(homedir(), '.config/mosaic-dev/config.json')));
|
||||
|
||||
const CODE_ROOT = resolve(import.meta.dirname, '../../..');
|
||||
export function present(file) {
|
||||
try { lstatSync(file); return true; } catch (e) { if (e.code === 'ENOENT') return false; throw e; }
|
||||
}
|
||||
function metadata(file, label, check) {
|
||||
let fd;
|
||||
try {
|
||||
fd = openSync(file, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK);
|
||||
const st = fstatSync(fd);
|
||||
if (!st.isFile() || st.size > 4096 || !check(st)) throw new Error('metadata refused');
|
||||
return JSON.parse(readFileSync(fd, 'utf8'));
|
||||
} catch { throw new Error(`${label} is missing or invalid`); }
|
||||
finally { if (fd !== undefined) closeSync(fd); }
|
||||
}
|
||||
export function buildStamp(root = CODE_ROOT) {
|
||||
const file = join(root, '.stack-build.json');
|
||||
if (!present(file)) return null;
|
||||
const doc = metadata(file, 'build stamp', st => !(st.mode & 0o222));
|
||||
if (!doc || Object.keys(doc).length !== 1 || !/^[0-9a-f]{40}$/.test(doc.commit) || doc.commit !== basename(root)) throw new Error('build stamp does not name this copy commit');
|
||||
return doc;
|
||||
}
|
||||
export function assertBuildStart({ serve = false, sso = false, devOwner = false, devBind = null, devNames = [] } = {}) {
|
||||
const stamp = buildStamp();
|
||||
if (stamp && (devOwner || devBind !== null || devNames.length)) throw new Error('stamped build refuses dev-local-owner and LAN flags');
|
||||
if (stamp && serve && !sso) throw new Error('stamped build serve requires --sso');
|
||||
return stamp;
|
||||
}
|
||||
export function assertDevInstance(system, env = process.env) {
|
||||
if (present(join(configDir(env), 'accounts.json'))) throw new Error('--dev-local-owner refuses accounts.json');
|
||||
if (system.console?.sso) throw new Error('--dev-local-owner refuses console.sso');
|
||||
if (present(join(system.dataRoot, '.deployed'))) throw new Error('--dev-local-owner refuses .deployed');
|
||||
}
|
||||
const instanceName = dir => /^mosaic-([a-z][a-z0-9]{1,15})$/.exec(basename(dir))?.[1] ?? 'dev';
|
||||
export function assertDeployed(system, dir = configDir()) {
|
||||
const doc = metadata(join(system.dataRoot, '.deployed'), '.deployed', st => (st.mode & 0o777) === 0o444 && st.uid === process.geteuid());
|
||||
if (!doc || Object.keys(doc).length !== 1 || doc.instance !== instanceName(dir)) throw new Error('.deployed does not name this instance');
|
||||
}
|
||||
// Both managed host cases install through case2-install.sh. Existing metadata
|
||||
// must validate; an install never replaces it or follows a destination link.
|
||||
export function writeDeployment(system, dir = configDir()) {
|
||||
// Default-instance bootstrap creates the config before its data directory.
|
||||
mkdirSync(system.dataRoot, { recursive: true, mode: 0o700 });
|
||||
const root = lstatSync(system.dataRoot);
|
||||
if (!root.isDirectory() || root.uid !== process.geteuid() || (root.mode & 0o022)) throw new Error('.deployed data root must be an owned directory without public write');
|
||||
const file = join(system.dataRoot, '.deployed');
|
||||
if (present(file)) {
|
||||
const doc = metadata(file, '.deployed', st => (st.mode & 0o777) === 0o444 && st.uid === process.geteuid());
|
||||
if (!doc || Object.keys(doc).length !== 1 || doc.instance !== instanceName(dir)) throw new Error('.deployed does not name this instance');
|
||||
return;
|
||||
}
|
||||
const fd = openSync(file, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | constants.O_NOFOLLOW, 0o444);
|
||||
try { writeFileSync(fd, JSON.stringify({ instance: instanceName(dir) }) + '\n'); fchmodSync(fd, 0o444); } finally { closeSync(fd); }
|
||||
}
|
||||
@@ -69,3 +69,19 @@ test('host boundary and receipt use effective uid, not real uid (current design
|
||||
try { await s.run(['init']); await s.run(['list']); const receipt = JSON.parse(readFileSync(join(s.configDir, 'accounts-receipts.jsonl'), 'utf8')); assert.equal(receipt.uid, effectiveUid); }
|
||||
finally { process.getuid = realUid; }
|
||||
});
|
||||
|
||||
// The uid identifies this process, not the human who invoked it. The instance
|
||||
// uid can rewrite its receipt; this is neither human attribution nor tamper evidence.
|
||||
test('M3 CLI integration refuses foreign effective uid before any account mutation, and receipts identify the process', async t => {
|
||||
const { spawnSync } = await import('node:child_process'); const { fixture } = await import('./helpers.mjs');
|
||||
const root=mkdtempSync(join(tmpdir(),'ai-')); t.after(()=>rmSync(root,{recursive:true,force:true})); const f=fixture(root);
|
||||
const config=JSON.parse(readFileSync(f.env.MOSAIC_CONFIG)); config.console={sso:{publicOrigin:'https://console.test',providers:[{id:'pocket',issuer:'https://id.test',clientId:'test',clientSecret:{slot:'pocket'},algs:['RS256'],subjects:[{sub:'owner',name:'Owner'}]}]}};
|
||||
writeFileSync(f.env.MOSAIC_CONFIG,JSON.stringify(config));
|
||||
const prelude=join(root,'uid.mjs'); const cli=new URL('../src/cli.mjs',import.meta.url).pathname;
|
||||
const run=args=>spawnSync(process.execPath,['--import',prelude,cli,'accounts',...args],{env:{...process.env,...f.env},encoding:'utf8',timeout:10000});
|
||||
writeFileSync(prelude,`process.geteuid=()=>${process.geteuid()+1};`); const refused=run(['init']); assert.equal(refused.status,3,refused.stderr); assert.match(refused.stderr,/config directory must belong to uid/);
|
||||
for(const name of ['accounts.json','accounts.lock','accounts-receipts.jsonl']) assert.equal(existsSync(join(root,'config',name)),false);
|
||||
// Different real uid, same effective uid: the effective identity is recorded.
|
||||
writeFileSync(prelude,`process.getuid=()=>${process.geteuid()+2};`); const ok=run(['init']); assert.equal(ok.status,0,ok.stderr);
|
||||
const receipt=JSON.parse(readFileSync(join(root,'config/accounts-receipts.jsonl'),'utf8')); assert.equal(receipt.uid,process.geteuid());
|
||||
});
|
||||
@@ -148,6 +148,8 @@ test("the library install writes the business file, personas and slot dir, makes
|
||||
const x = world(t);
|
||||
const r = install(x);
|
||||
assert.equal(r.code, 0, r.out);
|
||||
assert.equal(mode(join(x.dataRoot, '.deployed')), 0o444);
|
||||
assert.deepEqual(JSON.parse(readFileSync(join(x.dataRoot, '.deployed'), 'utf8')), { instance: 'dev' });
|
||||
assert.equal(mode(x.business), 0o600);
|
||||
const doc = JSON.parse(readFileSync(x.business, "utf8"));
|
||||
for (const [instance, name] of [["bean", "Bean"], ["chet", "Chet"]]) {
|
||||
@@ -494,6 +496,9 @@ function dependencyFreeClone(t) {
|
||||
for (const file of ["case2-install.sh", "case2-persona-import.mjs", "selector.sh"]) {
|
||||
cpSync(join(REPO, "scripts", file), join(clone, "scripts", file));
|
||||
}
|
||||
mkdirSync(join(clone, 'packages/cli/src'), { recursive: true });
|
||||
cpSync(join(REPO, 'packages/cli/src/deployment.mjs'), join(clone, 'packages/cli/src/deployment.mjs'));
|
||||
writeJson(x.config, { dataRoot: x.dataRoot });
|
||||
// Isolate the persona step; bootstrap and business validation are covered above.
|
||||
script(join(clone, "scripts", "bootstrap.sh"), "exit 0");
|
||||
script(join(clone, "scripts", "mosaic"), "exit 0");
|
||||
@@ -565,3 +570,14 @@ test('row334 install Next tells root to select proxy only when configured', t =>
|
||||
assert.equal(r.code, 0, r.out);
|
||||
assert.match(r.out, /Next: root runs case2-unit.sh[^\n]+add --proxy when the config has a proxy section/);
|
||||
});
|
||||
|
||||
test('M3 install Next names the SSO, accounts-init and dedicated-uid steps before root re-pins with --sso', t => {
|
||||
const x = world(t), r = install(x);
|
||||
assert.equal(r.code, 0, r.out);
|
||||
const done = r.out.split('\n').findIndex(l => l.startsWith('case2-install: done. .deployed names this instance'));
|
||||
assert.ok(done >= 0, r.out);
|
||||
assert.match(r.out.split('\n')[done], /in order \(run sheet section 7\): prepare console\.sso and its client secret slots in .+; a human runs `mosaic accounts init` as [^;]+; [^ ]+ stays a dedicated uid with a non-login shell, the only member of its primary group\.$/);
|
||||
assert.match(r.out.split('\n')[done + 1], /^case2-install: Next: root runs case2-unit\.sh --copy [0-9a-f]{40} --business \S+ with --sso in the serve flags \(factory: --sso --send;/);
|
||||
// The installer names the human step; it never runs it.
|
||||
assert.equal(existsSync(join(x.configDir, 'accounts.json')), false);
|
||||
});
|
||||
@@ -66,7 +66,7 @@ function hub(t, { uid = UID } = {}) {
|
||||
const bin = join(root, "bin");
|
||||
const log = join(root, "systemctl.log");
|
||||
script(join(bin, "getent"), `case "$1" in
|
||||
passwd) [ "$2" = mosaic ] && echo "mosaic:x:${uid}:${GID}::${home}:/bin/bash" ;;
|
||||
passwd) { [ -z "$2" ] || [ "$2" = mosaic ]; } && echo "mosaic:x:${uid}:${GID}::${home}:/usr/sbin/nologin" ;;
|
||||
group) [ "$2" = "${GID}" ] && echo "mosaic:x:${GID}:" ;;
|
||||
*) exit 2 ;;
|
||||
esac`);
|
||||
@@ -364,7 +364,7 @@ test("the unit takes --org or --business on either side of --, renders --busines
|
||||
test("the unit refuses a bad selector in either scope, and an outer and serve pair that differ, before writing a unit", (t) => {
|
||||
const h = hub(t);
|
||||
const serve = ["--port", "443", "--business", "acme"];
|
||||
const usage = "usage: case2-unit.sh --user NAME --home DIR --business ID --copy ID --node PATH [--profile factory|library] [--proxy] [--pm] [--claude PATH] -- SERVE FLAGS\n";
|
||||
const usage = "usage: case2-unit.sh [--check|--check-new] --user NAME --home DIR --business ID --copy ID --node PATH [--profile factory|library] [--proxy] [--pm] [--claude PATH] -- SERVE FLAGS\n";
|
||||
const head = ["--user", "mosaic", "--home", h.home, "--copy", COPY_A, "--node", USR_NODE, "--profile", "factory"];
|
||||
for (const [label, argv, code, err] of [
|
||||
["outer unequal, old first", [...head, "--business", "acme", "--org", "beta", "--", ...serve], 4, "case2-unit: --business acme and --org beta conflict\n"],
|
||||
@@ -415,6 +415,8 @@ test("the read-only library profile explicitly refuses --send before writing a u
|
||||
test("PATH starts with the --node directory, for a node outside the default list and for /usr/bin/node", (t) => {
|
||||
const h = hub(t);
|
||||
const node = outsideNode();
|
||||
// This metadata-only test uses a root-owned executable as a path stand-in.
|
||||
writeFileSync(join(h.root, 'query-output'), JSON.stringify({ dataRoot: h.dataRoot, socket: true })); writeFileSync(join(h.root, 'query-status'), '0');
|
||||
assert.equal(unit(h, { node }).code, 0);
|
||||
for (const f of [busFile(h), webuiFile(h)]) assert.deepEqual(lines(read(f), "Environment"), [`PATH=${dirname(node)}:/usr/local/bin:/usr/bin:/bin`]);
|
||||
assert.match(lines(read(webuiFile(h)), "ExecStart")[0], new RegExp(`^${node} `));
|
||||
@@ -550,6 +552,7 @@ test("a --node that is relative, not root-owned or under <home> refuses", (t) =>
|
||||
test("a re-install with a different --node or different serve flags refuses", (t) => {
|
||||
const h = hub(t);
|
||||
assert.equal(unit(h).code, 0);
|
||||
writeFileSync(join(h.root, 'query-output'), JSON.stringify({ dataRoot: h.dataRoot, socket: true })); writeFileSync(join(h.root, 'query-status'), '0');
|
||||
// A new node moves PATH too, so the installed unit fails its own Environment= check first.
|
||||
refuses(unit(h, { node: outsideNode() }), /installed .*: the Environment= lines must be exactly/);
|
||||
const flags = libraryFlags(h).map((f) => (f === "melanie" ? "someone" : f));
|
||||
@@ -705,7 +708,9 @@ test("#415 factory --sso refuses before any unit write when the stack user's con
|
||||
noUnits(h);
|
||||
});
|
||||
|
||||
test("#416 library still refuses --sso, alone and with --send, before writing a unit", (t) => {
|
||||
// M3 (#1873; Jason's ruling 2026-11-10): only an unstamped library copy keeps proxy preview. The
|
||||
// stamped copies below sign in through SSO accounts.
|
||||
test("#416 an unstamped library copy still refuses --sso, alone and with --send, before writing a unit", (t) => {
|
||||
const h = hub(t);
|
||||
ssoConfig(h);
|
||||
refuses(unit(h, { flags: [...libraryFlags(h), "--sso"] }), /refusing --sso: the Console signs people in through the proxy/);
|
||||
@@ -713,6 +718,233 @@ test("#416 library still refuses --sso, alone and with --send, before writing a
|
||||
noUnits(h);
|
||||
});
|
||||
|
||||
// ---- M3 (#1873): stamped copies ----
|
||||
// A stamped copy (stack-copy.sh's read-only .stack-build.json) serves only with --sso, console.sso,
|
||||
// an accounts.json that loads and a .deployed naming the default instance. The render asks the
|
||||
// stack user's copy through the bounded sudo query, with the copy's real deployment.mjs and
|
||||
// organization accounts.mjs, and refuses before any write, naming every missing step in order.
|
||||
// No real host, config or credential: the issuer is on .invalid and nothing connects to it.
|
||||
const ISSUER = "https://idp.invalid/a";
|
||||
const STAMP_ACCOUNTS = { accountsVersion: 1, revision: 1, users: [{ id: "u-000000000001", subjects: [{ provider: "a", sub: "owner" }], name: "Owner", instanceRole: "operator", state: "active", authGen: 1, created: "2026-11-10T00:00:00Z" }], memberships: [], invites: [] };
|
||||
const accountsFile = (h) => join(h.home, ".config", "mosaic-dev", "accounts.json");
|
||||
const deployedFile = (h) => join(h.dataRoot, ".deployed");
|
||||
function stamp(h, id = COPY_A) {
|
||||
const dir = copyDir(h, id);
|
||||
cpSync(join(REPO, "packages/cli/src/deployment.mjs"), join(dir, "packages/cli/src/deployment.mjs"));
|
||||
for (const pkg of ["organization", "seat"]) symlinkSync(join(REPO, "packages", pkg), join(dir, "packages", pkg));
|
||||
writeFileSync(join(dir, ".stack-build.json"), JSON.stringify({ commit: id }) + "\n");
|
||||
chmodSync(join(dir, ".stack-build.json"), 0o444);
|
||||
}
|
||||
const stampSso = (h) => writeJson(h.query, { dataRoot: h.dataRoot, console: { sso: { publicOrigin: "https://console.invalid", providers: [{ id: "a", issuer: ISSUER }] } } });
|
||||
function stampAccounts(h, doc = STAMP_ACCOUNTS) {
|
||||
writeFileSync(accountsFile(h), JSON.stringify(doc));
|
||||
chmodSync(accountsFile(h), 0o600);
|
||||
}
|
||||
function stampDeployed(h, instance = "dev") {
|
||||
writeFileSync(deployedFile(h), JSON.stringify({ instance }) + "\n");
|
||||
chmodSync(deployedFile(h), 0o444);
|
||||
}
|
||||
function stamped(t, { sso = true, accounts = true, deployed = true } = {}) {
|
||||
const h = hub(t);
|
||||
stamp(h);
|
||||
if (sso) stampSso(h);
|
||||
if (accounts) stampAccounts(h);
|
||||
if (deployed) stampDeployed(h);
|
||||
return h;
|
||||
}
|
||||
const STAMP_LIBRARY = ["--port", "443", "--business", "acme", "--sso"];
|
||||
const STAMP_FACTORY = ["--port", "443", "--business", "acme", "--tester-socket", "--sso", "--send"];
|
||||
const STEP = {
|
||||
deployed: (h) => `check or create .deployed: mosaic runs case2-install.sh, whose deployment step writes ${h.dataRoot}/.deployed`,
|
||||
deployedBad: (h) => `check .deployed: ${h.dataRoot}/.deployed is invalid or names another instance; case2-install.sh's deployment step reports why and never replaces it`,
|
||||
sso: (h) => `prepare SSO: console.sso in mosaic's system config ${h.home}/.config/mosaic-dev/config.json, with its client secret slots`,
|
||||
accounts: (h) => `run \`mosaic accounts init\` as mosaic, a human verb: no ${accountsFile(h)}`,
|
||||
accountsBad: (h) => `fix ${accountsFile(h)}: it does not load (its owner or mode, an active operator, or a subject console.sso does not name); a human fixes it with the accounts verbs`,
|
||||
library: () => "add --sso to the serve flags, then re-pin",
|
||||
factory: () => "add --sso --send to the serve flags, then re-pin",
|
||||
};
|
||||
// The exact refusal: one header line, then the steps numbered in the run sheet's order.
|
||||
function refusesSteps(h, r, steps) {
|
||||
assert.equal(r.code, 1, `expected a refusal, got ${r.code}: ${r.out}${r.err}`);
|
||||
const want = [`case2-unit: refusing the units for ${COPY_A}: a stamped copy's Console starts only with SSO accounts; missing steps, in order:`, ...steps.map((s, i) => ` ${i + 1}. ${s(h)}`)].join("\n") + "\n";
|
||||
assert.equal(r.err, want);
|
||||
}
|
||||
// Nothing the render must not write: no unit, no systemctl, and none of the prerequisites.
|
||||
function wroteNothing(h, { accounts = false, deployed = false } = {}) {
|
||||
noUnits(h);
|
||||
assert.equal(existsSync(accountsFile(h)), accounts, "the render never writes accounts.json");
|
||||
assert.equal(existsSync(deployedFile(h)), deployed, "the render never writes .deployed");
|
||||
}
|
||||
|
||||
test("M3 a stamped library unit renders only with --sso and the prerequisites, and runs the Console without proxy preview", (t) => {
|
||||
const h = stamped(t);
|
||||
const r = unit(h, { flags: STAMP_LIBRARY });
|
||||
assert.equal(r.code, 0, r.out + r.err);
|
||||
const webui = read(webuiFile(h));
|
||||
assert.deepEqual(lines(webui, "ExecStart"), [`${USR_NODE} ${copyDir(h)}/packages/webui/src/cli.mjs serve --port 443 --business acme --sso`]);
|
||||
assert.doesNotMatch(webui.split("\n").filter((l) => !l.startsWith("#")).join("\n"), /--public-host|--auth-header|--allow-user|--proof-header|--proof-file|--send/);
|
||||
assert.deepEqual(lines(read(busFile(h)), "ExecStart"), [`${copyDir(h)}/scripts/mosaic bus start %i`]);
|
||||
// The prerequisites were asked as the stack user, through the copy's own modules.
|
||||
const sudo = read(h.sudoLog);
|
||||
assert.match(sudo, new RegExp(`^-u mosaic -H env -i HOME=${h.home} PATH=`, "m"));
|
||||
assert.match(sudo, /a\.loadAccounts\(.*d\.assertDeployed\(s,dir\)/);
|
||||
assert.ok(sudo.includes(`${copyDir(h)}/packages/organization/src/accounts.mjs`));
|
||||
assert.match(r.out, /enabled and started mosaic-bus@acme\.service, then mosaic-webui\.service\n$/);
|
||||
// The render read them and left each as it was.
|
||||
assert.deepEqual(JSON.parse(read(accountsFile(h))), STAMP_ACCOUNTS);
|
||||
assert.equal(statSync(deployedFile(h)).mode & 0o777, 0o444);
|
||||
});
|
||||
|
||||
test("M3 a stamped library unit refuses proxy-preview admission, its proof and --send, before any write", (t) => {
|
||||
const h = stamped(t);
|
||||
for (const [flag, value] of [["--public-host", "books.example"], ["--auth-header", "X-Auth-User"], ["--allow-user", "melanie"], ["--proof-header", "X-Proxy-Proof"], ["--proof-file", h.proof]]) {
|
||||
const r = unit(h, { flags: [...STAMP_LIBRARY, flag, value] });
|
||||
assert.equal(r.code, 1, flag);
|
||||
assert.equal(r.err, `case2-unit: refusing ${flag}: ${COPY_A} is a stamped copy, and proxy-preview admission and its proof do not apply to a stamped copy; its Console signs in through SSO accounts with --sso\n`);
|
||||
}
|
||||
// The whole old preview set refuses at its first flag, with or without --sso.
|
||||
refuses(unit(h, { flags: libraryFlags(h) }), /refusing --public-host: .* is a stamped copy, and proxy-preview admission and its proof do not apply/);
|
||||
refuses(unit(h, { flags: [...STAMP_LIBRARY, "--send"] }), /the library profile is read-only; refusing --send \(a stamped library Console signs in with --sso and stays read-only\)/);
|
||||
wroteNothing(h, { accounts: true, deployed: true });
|
||||
assert.equal(existsSync(h.sudoLog), false, "a flag refusal comes before any query");
|
||||
});
|
||||
|
||||
test("M3 a stamped library unit without its prerequisites refuses before any write and names every missing step in order", (t) => {
|
||||
const none = stamped(t, { sso: false, accounts: false, deployed: false });
|
||||
refusesSteps(none, unit(none, { flags: ["--port", "443", "--business", "acme"] }), [STEP.deployed, STEP.sso, STEP.accounts, STEP.library]);
|
||||
wroteNothing(none);
|
||||
refusesSteps(none, unit(none, { flags: STAMP_LIBRARY }), [STEP.deployed, STEP.sso, STEP.accounts]);
|
||||
wroteNothing(none);
|
||||
// Each prerequisite alone.
|
||||
for (const [name, opts, steps] of [
|
||||
["no .deployed", { deployed: false }, [STEP.deployed]],
|
||||
["no console.sso", { sso: false }, [STEP.sso]],
|
||||
["no accounts.json", { accounts: false }, [STEP.accounts]],
|
||||
]) {
|
||||
const h = stamped(t, opts);
|
||||
refusesSteps(h, unit(h, { flags: STAMP_LIBRARY }), steps);
|
||||
wroteNothing(h, { accounts: opts.accounts !== false, deployed: opts.deployed !== false });
|
||||
}
|
||||
const flagless = stamped(t);
|
||||
refusesSteps(flagless, unit(flagless, { flags: ["--port", "443", "--business", "acme"] }), [STEP.library]);
|
||||
wroteNothing(flagless, { accounts: true, deployed: true });
|
||||
});
|
||||
|
||||
test("M3 a stamped copy refuses an invalid .deployed or accounts.json with the step that names it", (t) => {
|
||||
const cases = [
|
||||
[".deployed names another instance", (h) => { rmSync(deployedFile(h)); stampDeployed(h, "other"); }, [STEP.deployedBad]],
|
||||
[".deployed writable", (h) => chmodSync(deployedFile(h), 0o644), [STEP.deployedBad]],
|
||||
[".deployed broken link", (h) => { rmSync(deployedFile(h)); symlinkSync(join(h.root, "missing"), deployedFile(h)); }, [STEP.deployedBad]],
|
||||
["accounts without an active operator", (h) => stampAccounts(h, { ...STAMP_ACCOUNTS, users: [{ ...STAMP_ACCOUNTS.users[0], state: "disabled" }] }), [STEP.accountsBad]],
|
||||
["accounts subject on an unconfigured provider", (h) => stampAccounts(h, { ...STAMP_ACCOUNTS, users: [{ ...STAMP_ACCOUNTS.users[0], subjects: [{ provider: "b", sub: "owner" }] }] }), [STEP.accountsBad]],
|
||||
["accounts writable by group", (h) => chmodSync(accountsFile(h), 0o620), [STEP.accountsBad]],
|
||||
["accounts broken link", (h) => { rmSync(accountsFile(h)); symlinkSync(join(h.root, "missing"), accountsFile(h)); }, [STEP.accountsBad]],
|
||||
];
|
||||
for (const [name, change, steps] of cases) {
|
||||
for (const [profile, flags] of [["library", STAMP_LIBRARY], ["factory", STAMP_FACTORY]]) {
|
||||
const h = stamped(t);
|
||||
change(h);
|
||||
refusesSteps(h, unit(h, { profile, flags }), steps);
|
||||
noUnits(h);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test("M3 a stamped factory or Case 1 unit renders only with --sso --send and the prerequisites, and names each missing step", (t) => {
|
||||
const h = stamped(t);
|
||||
const r = unit(h, { profile: "factory", flags: STAMP_FACTORY });
|
||||
assert.equal(r.code, 0, r.out + r.err);
|
||||
assert.deepEqual(lines(read(webuiFile(h)), "ExecStart"), [`${USR_NODE} ${copyDir(h)}/packages/webui/src/cli.mjs serve ${STAMP_FACTORY.join(" ")}`]);
|
||||
const none = stamped(t, { sso: false, accounts: false, deployed: false });
|
||||
refusesSteps(none, unit(none, { profile: "factory" }), [STEP.deployed, STEP.sso, STEP.accounts, STEP.factory]);
|
||||
wroteNothing(none);
|
||||
refusesSteps(none, unit(none, { profile: "factory", flags: STAMP_FACTORY }), [STEP.deployed, STEP.sso, STEP.accounts]);
|
||||
wroteNothing(none);
|
||||
// A stamped copy without the flag pair names it as the last step, with the prerequisites present.
|
||||
const flagless = stamped(t);
|
||||
refusesSteps(flagless, unit(flagless, { profile: "factory" }), [STEP.factory]);
|
||||
refusesSteps(flagless, unit(flagless, { profile: "factory", flags: [...factoryFlags(), "--send"] }), [STEP.factory]);
|
||||
wroteNothing(flagless, { accounts: true, deployed: true });
|
||||
// The pair stays a pair: --sso alone still refuses, as for an unstamped copy.
|
||||
refuses(unit(flagless, { profile: "factory", flags: [...factoryFlags(), "--sso"] }), /refusing --sso without --send in the factory profile/);
|
||||
noUnits(flagless);
|
||||
});
|
||||
|
||||
test("M3 a stamped copy's query refuses on noise or a failed query, before any write", (t) => {
|
||||
// The copy's accounts module stands in for the stack user's code: noise on stdout, a failed exit.
|
||||
for (const [body, pattern] of [
|
||||
['console.log("forged");export const loadAccounts=()=>{};', /stamped copy query has invalid facts/],
|
||||
["process.exit(1);export const loadAccounts=()=>{};", /stamped copy query inner-command/],
|
||||
]) {
|
||||
const h = stamped(t);
|
||||
const org = join(copyDir(h), "packages/organization");
|
||||
rmSync(org);
|
||||
mkdirSync(join(org, "src"), { recursive: true });
|
||||
writeFileSync(join(org, "src/accounts.mjs"), body);
|
||||
refuses(unit(h, { flags: STAMP_LIBRARY }), pattern);
|
||||
noUnits(h);
|
||||
}
|
||||
});
|
||||
|
||||
// #1873 M3: stack-upgrade.sh checks each render before it stops anything. --check runs a render's
|
||||
// checks, the installed units' included, and writes nothing; --check-new skips only the installed
|
||||
// units' checks, for --next-args, which moves them aside before its render.
|
||||
const checkArgv = (h, { mode = "--check", profile = "library", copy = COPY_A, flags } = {}) =>
|
||||
[mode, "--user", "mosaic", "--home", h.home, "--business", "acme", "--copy", copy, "--node", USR_NODE, "--profile", profile, "--",
|
||||
...(flags ?? (profile === "factory" ? factoryFlags() : libraryFlags(h)))];
|
||||
const unitsNow = (h) => [busFile(h), webuiFile(h)].map((f) => (existsSync(f) ? read(f) : null));
|
||||
const logNow = (h) => (existsSync(h.log) ? read(h.log) : "");
|
||||
|
||||
test("#1873 --check passes on a matching install and on a first install, refuses an edited unit, and never writes or calls systemctl", (t) => {
|
||||
const h = hub(t);
|
||||
const empty = unit(h, { argv: checkArgv(h) });
|
||||
assert.equal(empty.code, 0, empty.out + empty.err);
|
||||
assert.equal(empty.out, `case2-unit: check passed: the units for copy ${COPY_A} render, and the installed ones match them; nothing was written\n`);
|
||||
noUnits(h);
|
||||
assert.equal(unit(h).code, 0);
|
||||
const units = unitsNow(h), log = logNow(h);
|
||||
for (const copy of [COPY_A, COPY_B]) {
|
||||
const r = unit(h, { argv: checkArgv(h, { copy }) });
|
||||
assert.equal(r.code, 0, r.out + r.err);
|
||||
assert.deepEqual(unitsNow(h), units, copy);
|
||||
assert.equal(logNow(h), log, `${copy}: no systemctl call`);
|
||||
}
|
||||
// The installed-unit match stays: an edit, or other serve flags, refuses as the render would.
|
||||
writeFileSync(busFile(h), units[0].replace(/^Description=.*$/m, "Description=edited"));
|
||||
refuses(unit(h, { argv: checkArgv(h, { copy: COPY_B }) }), /differs from the render in more than the copy id/);
|
||||
writeFileSync(busFile(h), units[0]);
|
||||
const flags = libraryFlags(h).map((f) => (f === "melanie" ? "someone" : f));
|
||||
refuses(unit(h, { argv: checkArgv(h, { copy: COPY_B, flags }) }), /installed .*: ExecStart= must be exactly once/);
|
||||
assert.deepEqual(unitsNow(h), units);
|
||||
assert.equal(logNow(h), log);
|
||||
});
|
||||
|
||||
for (const profile of ["library", "factory"]) {
|
||||
test(`#1873 an installed pre-M3 ${profile} unit: --check and the render refuse a stamped copy's SSO flags, and --check-new passes with the prerequisites, writing nothing`, (t) => {
|
||||
const h = hub(t);
|
||||
assert.equal(unit(h, { profile }).code, 0);
|
||||
stamp(h, COPY_B);
|
||||
stampSso(h);
|
||||
stampAccounts(h);
|
||||
stampDeployed(h);
|
||||
const flags = profile === "factory" ? STAMP_FACTORY : STAMP_LIBRARY;
|
||||
const units = unitsNow(h), log = logNow(h);
|
||||
refuses(unit(h, { argv: checkArgv(h, { profile, copy: COPY_B, flags }) }), /installed .*: ExecStart= must be exactly once/);
|
||||
refuses(unit(h, { profile, copy: COPY_B, flags }), /installed .*: ExecStart= must be exactly once/);
|
||||
const r = unit(h, { argv: checkArgv(h, { mode: "--check-new", profile, copy: COPY_B, flags }) });
|
||||
assert.equal(r.code, 0, r.out + r.err);
|
||||
assert.equal(r.out, `case2-unit: check passed: the units for copy ${COPY_B} render as a first install; the installed ones were not compared; nothing was written\n`);
|
||||
// --check-new still runs the stamped copy's checks: the old flags, or a missing prerequisite, refuse.
|
||||
refuses(unit(h, { argv: checkArgv(h, { mode: "--check-new", profile, copy: COPY_B }) }), /a stamped copy's Console starts only with SSO accounts|is a stamped copy, and proxy-preview admission/);
|
||||
rmSync(accountsFile(h));
|
||||
refuses(unit(h, { argv: checkArgv(h, { mode: "--check-new", profile, copy: COPY_B, flags }) }), /run `mosaic accounts init` as mosaic, a human verb/);
|
||||
assert.deepEqual(unitsNow(h), units);
|
||||
assert.equal(logNow(h), log, "no systemctl call");
|
||||
assert.equal(existsSync(accountsFile(h)), false, "the check never writes accounts.json");
|
||||
assert.equal(statSync(deployedFile(h)).mode & 0o777, 0o444);
|
||||
});
|
||||
}
|
||||
|
||||
test("row253 factory refuses retired TCP and a value after the socket marker", (t) => {
|
||||
const h = hub(t);
|
||||
refuses(unit(h, { profile: "factory", flags: ['--port','443','--business','acme','--tester-port','7333'] }), /case2-unit.sh retires tester TCP; use --tester-socket/);
|
||||
@@ -1320,3 +1552,26 @@ test('row334 proxy installed path rejects symlink and non-regular file', t => {
|
||||
mkdirSync(proxyFile(h));
|
||||
refuses(proxyUnit(h), /is not a regular file/);
|
||||
});
|
||||
|
||||
// M3: the managed uid is a dedicated non-login service identity. These are
|
||||
// database fixtures only: no real passwd/group or instance directories change.
|
||||
test('M3 dedicated host account: login shells, other primary/group members and wrong directory owners refuse', t => {
|
||||
for (const [kind, shell, extraPasswd, members] of [
|
||||
['human login','/bin/bash','',''], ['interactive seat','/usr/bin/zsh','',''],
|
||||
['another primary member','/usr/sbin/nologin',`human:x:${UID+1}:${GID}::/fictional:/bin/bash`,''],
|
||||
['supplementary primary-group member','/usr/sbin/nologin','','human'],
|
||||
]) {
|
||||
const h = hub(t);
|
||||
script(join(h.bin,'getent'), `case "$1" in
|
||||
passwd) echo 'mosaic:x:${UID}:${GID}::${h.home}:${shell}'; [ -n "$2" ] || echo '${extraPasswd}';;
|
||||
group) echo 'mosaic:x:${GID}:${members}';;
|
||||
esac`);
|
||||
const r = unit(h); assert.notEqual(r.code,0,kind); assert.match(r.err,/non-login|sole.member/,kind); assert.equal(existsSync(webuiFile(h)),false);
|
||||
}
|
||||
const h = hub(t); for (const flag of ['--dev-local-owner','--dev-insecure-bind','--dev-allow-host']) { const r = unit(h, { flags: [...libraryFlags(h), flag, '127.0.0.1'] }); assert.notEqual(r.code,0); assert.match(r.err,/dev-local-owner|serve flag/); }
|
||||
});
|
||||
test('M3 host config and data directories belong to the selected uid and both units use it', t => {
|
||||
const h = hub(t); const ok = unit(h); assert.equal(ok.code,0,ok.err);
|
||||
for (const file of [busFile(h),webuiFile(h)]) { assert.deepEqual(lines(read(file),'User'),['mosaic']); assert.deepEqual(lines(read(file),'Group'),['mosaic']); }
|
||||
const wrong = hub(t,{uid:UID+1}); const r = unit(wrong); assert.notEqual(r.code,0); assert.match(r.err,/owned by.*uid|directory.*owner/);
|
||||
});
|
||||
@@ -0,0 +1,94 @@
|
||||
// M3 executable boundary: copies and instances are fixtures only, no live ports.
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import { cpSync, mkdirSync, mkdtempSync, writeFileSync, chmodSync, rmSync, symlinkSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { fixture } from './helpers.mjs';
|
||||
const REPO = fileURLToPath(new URL('../../../', import.meta.url));
|
||||
const SHA = 'a'.repeat(40);
|
||||
function setup(t) {
|
||||
const root = mkdtempSync(join(tmpdir(), 'db-')); t.after(() => rmSync(root, { recursive: true, force: true }));
|
||||
const copy = join(root, SHA); mkdirSync(copy);
|
||||
for (const dir of ['packages', 'scripts', 'roles', 'adapters']) cpSync(join(REPO, dir), join(copy, dir), { recursive: true, filter: p => !p.split('/').some(x => ['tests', 'node_modules'].includes(x)) });
|
||||
writeFileSync(join(copy, 'package.json'), '{"type":"module"}');
|
||||
const stamp = join(copy, '.stack-build.json'); writeFileSync(stamp, JSON.stringify({ commit: SHA }), { mode: 0o444 }); chmodSync(stamp, 0o444);
|
||||
const f = fixture(join(root, 'instance')); const home = join(root, 'home'); const dir = join(home, '.config/mosaic-test'); mkdirSync(dir, { recursive: true });
|
||||
const config = join(dir, 'config.json'); cpSync(f.env.MOSAIC_CONFIG, config);
|
||||
writeFileSync(join(f.dataRoot, 'instance.json'), JSON.stringify({ instanceVersion: 1, name: 'test' }));
|
||||
const env = { ...process.env, HOME: home, MOSAIC_CONFIG: config };
|
||||
const run = (args, from = copy) => spawnSync(process.execPath, [join(from, args[0]), ...args.slice(1)], { env, encoding: 'utf8', timeout: 5000 });
|
||||
return { root, copy, stamp, f, env, config, dir, run };
|
||||
}
|
||||
const serve = flags => ['packages/webui/src/cli.mjs', 'serve', '--port', '0', ...flags];
|
||||
test('stamped serve refuses dev owner, LAN flags, missing --sso and missing .deployed before listening', t => {
|
||||
const s = setup(t);
|
||||
for (const flags of [['--dev-local-owner'], ['--dev-insecure-bind','0.0.0.0'], ['--dev-allow-host','test.example'], []]) {
|
||||
const r = s.run(serve(flags)); assert.equal(r.status, 2); assert.match(r.stderr, /stamped build/); assert.doesNotMatch(r.stdout, /Mosaic Console:/);
|
||||
}
|
||||
const r = s.run(serve(['--sso'])); assert.equal(r.status, 2); assert.match(r.stderr, /\.deployed/);
|
||||
const bus = s.run(['packages/cli/src/cli.mjs','bus','start','acme','--dev-local-owner']); assert.notEqual(bus.status, 0); assert.match(bus.stderr, /stamped build/);
|
||||
});
|
||||
test('writable, linked, malformed, wrong-commit and directory stamps refuse every start', t => {
|
||||
const s = setup(t);
|
||||
for (const kind of ['writable','symlink','wrong','malformed','directory']) {
|
||||
rmSync(s.stamp, { force: true, recursive: true });
|
||||
if (kind === 'symlink') { const other = join(s.root, 'stamp'); writeFileSync(other, JSON.stringify({ commit: SHA }), { mode: 0o444 }); symlinkSync(other, s.stamp); }
|
||||
else if (kind === 'directory') mkdirSync(s.stamp);
|
||||
else { writeFileSync(s.stamp, kind === 'malformed' ? '{' : JSON.stringify({ commit: kind === 'wrong' ? 'b'.repeat(40) : SHA })); chmodSync(s.stamp, kind === 'writable' ? 0o644 : 0o444); }
|
||||
for (const args of [serve([]), serve(['--sso']), ['packages/cli/src/cli.mjs','bus','start','acme']]) { const r = s.run(args); assert.notEqual(r.status, 0, kind); assert.match(r.stderr, /build stamp/, `${kind}: ${r.stderr}`); }
|
||||
}
|
||||
});
|
||||
test('stamped serve requires 0444 metadata naming its instance and a loadable accounts file', t => {
|
||||
const s = setup(t), file = join(s.f.dataRoot, '.deployed');
|
||||
for (const [mode, instance] of [[0o644,'test'], [0o444,'other']]) {
|
||||
writeFileSync(file, JSON.stringify({ instance })); chmodSync(file, mode);
|
||||
const r = s.run(serve(['--sso'])); assert.equal(r.status, 2); assert.match(r.stderr, /\.deployed/);
|
||||
}
|
||||
chmodSync(file, 0o644); writeFileSync(file, JSON.stringify({ instance: 'test' })); chmodSync(file, 0o444);
|
||||
const r = s.run(serve(['--sso'])); assert.equal(r.status, 2); assert.match(r.stderr, /accounts/);
|
||||
});
|
||||
test('checkout dev owner refuses accounts, SSO or deployment presence, including dangling links', t => {
|
||||
const s = setup(t);
|
||||
for (const name of ['accounts.json', '.deployed']) {
|
||||
const file = join(name === 'accounts.json' ? s.dir : s.f.dataRoot, name); symlinkSync(join(s.root, 'missing'), file);
|
||||
for (const args of [serve(['--dev-local-owner']), ['packages/cli/src/cli.mjs','bus','start','acme','--dev-local-owner']]) { const r = s.run(args, REPO); assert.notEqual(r.status, 0); assert.match(r.stderr, /dev-local-owner.*refuses/); }
|
||||
rmSync(file);
|
||||
}
|
||||
const config = JSON.parse((awaitRead(s.config))); config.console = { sso: { publicOrigin: 'https://console.test', providers: [{ id:'pocket', issuer:'https://id.test',clientId:'test',clientSecret:{slot:'test'},algs:['RS256'],subjects:[{sub:'owner',name:'Owner'}] }] } };
|
||||
writeFileSync(s.config, JSON.stringify(config));
|
||||
for (const args of [serve(['--dev-local-owner']), ['packages/cli/src/cli.mjs','bus','start','acme','--dev-local-owner']]) { const r = s.run(args, REPO); assert.notEqual(r.status, 0); assert.match(r.stderr, /dev-local-owner.*refuses.*console.sso/); }
|
||||
});
|
||||
import { readFileSync as awaitRead } from 'node:fs';
|
||||
|
||||
test('deployment writer preserves matching metadata and refuses linked or foreign destinations', t => {
|
||||
const s = setup(t), system = { dataRoot: s.f.dataRoot }, file = join(s.f.dataRoot, '.deployed');
|
||||
rmSync(system.dataRoot, { recursive: true, force: true });
|
||||
writeDeployment(system, s.dir); assert.deepEqual(JSON.parse(awaitRead(file)), { instance: 'test' });
|
||||
assert.equal(statSync(system.dataRoot).mode & 0o777, 0o700);
|
||||
assert.equal(statSync(file).mode & 0o777, 0o444); const inode = statSync(file).ino; writeDeployment(system, s.dir); assert.equal(statSync(file).ino, inode);
|
||||
rmSync(file); symlinkSync(join(s.root, 'absent'), file); assert.throws(() => writeDeployment(system, s.dir), /\.deployed/); rmSync(file);
|
||||
writeFileSync(file, JSON.stringify({ instance: 'other' }), { mode: 0o444 }); assert.throws(() => writeDeployment(system, s.dir), /\.deployed/);
|
||||
});
|
||||
import { statSync } from 'node:fs';
|
||||
import { writeDeployment } from '../src/deployment.mjs';
|
||||
|
||||
test('valid stamped accounts Console starts with --sso and answers health on its own port zero', async t => {
|
||||
const { spawn } = await import('node:child_process');
|
||||
const { once } = await import('node:events');
|
||||
const { fakeOidc } = await import('../../webui/tests/helpers/fake-oidc.mjs');
|
||||
const s = setup(t), op = await fakeOidc(); t.after(() => op.close()); const iss = op.issuer('a');
|
||||
symlinkSync(join(REPO, 'node_modules'), join(s.copy, 'node_modules'));
|
||||
const config = JSON.parse(awaitRead(s.config)); config.console = { sso: { publicOrigin: 'https://console.test', providers: [{ id:'a',issuer:iss.issuer,clientId:iss.clientId,clientSecret:{slot:'a'},algs:['RS256'],subjects:[{sub:'owner',name:'Owner'}] }] } };
|
||||
writeFileSync(s.config, JSON.stringify(config)); mkdirSync(join(s.dir, 'secrets/mosaic-stack'), { recursive: true, mode: 0o700 });
|
||||
writeFileSync(join(s.dir, 'secrets/mosaic-stack/a'), iss.clientSecret, { mode: 0o600 });
|
||||
writeFileSync(join(s.dir, 'accounts.json'), JSON.stringify({ accountsVersion:1,revision:1,users:[{id:'u-000000000001',subjects:[{provider:'a',sub:'owner'}],name:'Owner',instanceRole:'operator',state:'active',authGen:1,created:'2026-11-10T00:00:00Z'}], memberships:[],invites:[] }), { mode: 0o600 });
|
||||
writeDeployment({ dataRoot: s.f.dataRoot }, s.dir); const ca = join(s.root, 'ca.pem'); writeFileSync(ca, op.cert);
|
||||
const child = spawn(process.execPath, [join(s.copy,'packages/webui/src/cli.mjs'), 'serve','--port','0','--sso','--board','http://127.0.0.1:9'], { env: { ...s.env, NODE_EXTRA_CA_CERTS: ca } });
|
||||
let out = '', err = ''; child.stderr.on('data', c => err += c);
|
||||
t.after(async () => { if (child.exitCode === null && child.signalCode === null) { const done = once(child, 'close'); child.kill(); await done; } });
|
||||
await new Promise((resolve, reject) => { const timer = setTimeout(() => reject(new Error('start timeout: '+err)), 10000); child.stdout.on('data', c => { out += c; if(out.includes('Ctrl-C')) {clearTimeout(timer);resolve();} }); child.on('exit', () => {clearTimeout(timer);reject(new Error(err));}); });
|
||||
const port = Number(out.match(/127\.0\.0\.1:(\d+)\//)[1]); assert.ok(port); assert.equal((await fetch(`http://127.0.0.1:${port}/healthz`)).status, 200);
|
||||
});
|
||||
@@ -1,3 +1,4 @@
|
||||
import { ownerAccounts } from '../../webui/tests/helpers/owner-accounts.mjs';
|
||||
import assert from 'node:assert/strict';
|
||||
import { fork } from 'node:child_process';
|
||||
import { once } from 'node:events';
|
||||
@@ -119,7 +120,7 @@ export async function lifecycleFixture(t) {
|
||||
const issuer = op.issuer('fixture');
|
||||
sso = await createSso({ publicOrigin: ORIGIN, providers: [{ id: 'fixture', issuer: issuer.issuer, clientId: issuer.clientId,
|
||||
clientSecret: { slot: 'fixture' }, algs: ['RS256'], subjects: [{ sub: 'owner', name: 'jason' }] }] },
|
||||
{ secrets: new Map([['fixture', issuer.clientSecret]]), fetch: op.fetch, log: () => {} });
|
||||
{ accounts: ownerAccounts({ providers: [{ id: 'fixture', subjects: [{ sub: 'owner', name: 'jason' }] }] }), secrets: new Map([['fixture', issuer.clientSecret]]), fetch: op.fetch, log: () => {} });
|
||||
const transport = async (verb, args, ctx) => {
|
||||
try { return await rpc('console', { verb, args, subject: ctx.subject }); }
|
||||
catch (e) { appendFileSync(join(root, 'console-errors.jsonl'), JSON.stringify({ verb, code: e.code, detail: e.detail })+'\n'); throw new BusReadError(e.code ?? 'outcome-unknown', 503); }
|
||||
|
||||
@@ -12,6 +12,8 @@ import {createOwnedRunner} from './fixtures/owned-child.mjs';
|
||||
import {pathToFileURL} from 'node:url';
|
||||
import {fixture,notifyConfig,BUSINESSES} from './helpers.mjs';
|
||||
import {reserveOrg,activateOrg} from '../../business/src/org-registry.mjs';
|
||||
import {writeDeployment} from '../src/deployment.mjs';
|
||||
import {fakeOidc} from '../../webui/tests/helpers/fake-oidc.mjs';
|
||||
const ROOT=fileURLToPath(new URL('../../..',import.meta.url)).replace(/\/$/,'');
|
||||
const git=(cwd,...args)=>execFileSync('git',['-c','user.name=fixture','-c','user.email=fixture@invalid','-c','commit.gpgsign=false','-c','core.hooksPath=/dev/null','-C',cwd,...args],{encoding:'utf8'}).trim();
|
||||
export function setup(t, realPin=false){
|
||||
@@ -58,6 +60,7 @@ test('a copy carries every file the harness modules read from it',async t=>{
|
||||
test('shared copy: npm ci, pushed reachability, readonly/existing copy, webui delegation',async t=>{
|
||||
const s=setup(t),r=(await s.run('stack-copy.sh',['copy',s.id,'--copies',s.copies]));assert.equal(r.status,0,r.stderr);
|
||||
const copy=join(s.copies,s.id);assert.equal(statSync(copy).mode&0o777,0o555);
|
||||
assert.deepEqual(JSON.parse(readFileSync(join(copy,'.stack-build.json'),'utf8')), {commit:s.id}); assert.equal(statSync(join(copy,'.stack-build.json')).mode&0o222,0);
|
||||
for(const f of ['scripts/mosaic','adapters/pi/adapter.sh','packages/queue/src/cli.mjs','package.json','package-lock.json','packages/tasks/requirements.json','packages/project/src/index.mjs','packages/library/src/index.mjs','packages/project/schemas/project.schema.json','packages/project/templates/north-star.md','packages/plugins/src/kinds.mjs','packages/plugins/src/loader.mjs','packages/processing-control/src/state.mjs','packages/processing-control/src/containers.mjs','packages/processing-control/src/effects.mjs'])assert.ok(existsSync(join(copy,f)),f);
|
||||
const npm=join(s.bin,'npm');writeFileSync(npm,'#!/bin/sh\nexit 99\n');chmodSync(npm,0o755);
|
||||
const existing=(await s.run('webui-service.sh',['copy',s.id,'--copies',s.copies]));assert.equal(existing.status,0);assert.equal(existing.stdout,`exists: ${copy}\n`);unlinkSync(npm);
|
||||
@@ -139,7 +142,7 @@ async function childResult(command,args,env,input){
|
||||
const timer=setTimeout(()=>p.kill('SIGKILL'),15000);
|
||||
try{const [status]=await once(p,'close');return {status,stdout,stderr};}finally{clearTimeout(timer)}
|
||||
}
|
||||
test('real pinned install loads bus and Console; same-copy mosaic talk passes proof, other trees refuse', {timeout:180000},async t=>{
|
||||
test('real pinned install loads bus and Console; the Console starts only with --sso and its deployed prerequisites; same-copy mosaic talk passes proof, other trees refuse', {timeout:180000},async t=>{
|
||||
const s=setup(t,true);
|
||||
const copied=(await s.run('stack-copy.sh',['copy',s.id,'--copies',s.copies]));assert.equal(copied.status,0,copied.stderr);
|
||||
const root=join(s.copies,s.id),url=f=>pathToFileURL(join(root,f)).href;
|
||||
@@ -156,7 +159,24 @@ test('real pinned install loads bus and Console; same-copy mosaic talk passes pr
|
||||
host.stdout.on('data',c=>hostOut+=c);host.stderr.on('data',c=>hostErr+=c);
|
||||
try{await until(()=>hostOut.includes('\"org\":\"acme\"')||host.exitCode!==null);assert.match(hostOut,/\"org\":\"acme\"/,hostErr);}
|
||||
finally{if(host.exitCode===null&&host.signalCode===null){const done=once(host,'close');const stop=spawnSync(join(root,'scripts/mosaic'),['bus','stop','acme'],{env,encoding:'utf8'});assert.equal(stop.status,0,stop.stderr);await done;}}
|
||||
const web=spawn(process.execPath,[join(root,'packages/webui/src/cli.mjs'),'serve','--port','0','--business','acme','--board','http://127.0.0.1:9'],{env,stdio:['ignore','pipe','pipe']});let webOut='',webErr='';
|
||||
// The copy is stamped (M3): its Console starts only with --sso, console.sso and its slot, .deployed and accounts.json.
|
||||
const serveArgs=sso=>[join(root,'packages/webui/src/cli.mjs'),'serve','--port','0','--business','acme','--board','http://127.0.0.1:9',...(sso?['--sso']:[])];
|
||||
const op=await fakeOidc();t.after(()=>op.close());const iss=op.issuer('a');
|
||||
const ca=join(s.root,'ca.pem');writeFileSync(ca,op.cert);const webEnv={...env,NODE_EXTRA_CA_CERTS:ca};
|
||||
const startRefused=async(sso,why)=>{const r=await childResult(process.execPath,serveArgs(sso),webEnv,'');assert.equal(r.status,2,r.stderr);assert.match(r.stderr,why);assert.doesNotMatch(r.stdout,/Mosaic Console:/);};
|
||||
await startRefused(false,/stamped build serve requires --sso/);
|
||||
await startRefused(true,/\.deployed is missing or invalid/);
|
||||
writeDeployment({dataRoot:f.dataRoot},configDir);
|
||||
await startRefused(true,/accounts file not found/);
|
||||
writeFileSync(join(configDir,'accounts.json'),JSON.stringify({accountsVersion:1,revision:1,users:[{id:'u-000000000001',subjects:[{provider:'a',sub:'owner'}],name:'Owner',instanceRole:'operator',state:'active',authGen:1,created:'2026-11-10T00:00:00Z'}],memberships:[],invites:[]}),{mode:0o600});
|
||||
await startRefused(true,/must be a configured provider/);
|
||||
const config=JSON.parse(readFileSync(env.MOSAIC_CONFIG,'utf8'));
|
||||
config.console={sso:{publicOrigin:'https://console.test',providers:[{id:'a',issuer:iss.issuer,clientId:iss.clientId,clientSecret:{slot:'a'},algs:['RS256'],subjects:[{sub:'owner',name:'Owner'}]}]}};
|
||||
writeFileSync(env.MOSAIC_CONFIG,JSON.stringify(config));
|
||||
await startRefused(true,/client secret slot is missing/);
|
||||
mkdirSync(join(configDir,'secrets','mosaic-stack'),{recursive:true,mode:0o700});
|
||||
const slot=join(configDir,'secrets','mosaic-stack','a');writeFileSync(slot,iss.clientSecret,{mode:0o600});chmodSync(slot,0o600);
|
||||
const web=spawn(process.execPath,serveArgs(true),{env:webEnv,stdio:['ignore','pipe','pipe']});let webOut='',webErr='';
|
||||
web.stdout.on('data',c=>webOut+=c);web.stderr.on('data',c=>webErr+=c);
|
||||
try{await until(()=>/Mosaic Console: http:/.test(webOut)||web.exitCode!==null);const port=webOut.match(/127\.0\.0\.1:(\d+)/)?.[1];assert.ok(port,webErr);assert.equal((await fetch(`http://127.0.0.1:${port}/healthz`)).status,200);}
|
||||
finally{if(web.exitCode===null&&web.signalCode===null){const done=once(web,'close');web.kill();await done;}}
|
||||
|
||||
@@ -1555,51 +1555,110 @@ function setEdit(set,name,from,to,sums=true){
|
||||
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`))}
|
||||
}
|
||||
|
||||
// M3 (#1873): console.sso marks an accounts instance, which runs system units, so a user-unit run
|
||||
// with it refuses accounts-user-unit before anything stops. An --sso pointer now comes two ways:
|
||||
// - a pre-M3 host's user-unit run. LEGACY is the fixture repo with the M3 base's stack-release.sh,
|
||||
// webui-service.sh and stack-copy.sh, so legacy(e) builds the state such a host left behind;
|
||||
// - the system path: root stops the units, stack-release stages with --units external, root
|
||||
// installs the units and starts them, and confirm pins the pointer to root's Console flags.
|
||||
const M3_BASE='c3c192677defa518eabeb324aac82f0757e398c3';
|
||||
const LEGACY=join(base,'legacy-repo');
|
||||
cpSync(repo,LEGACY,{recursive:true});
|
||||
for(const f of ['scripts/stack-release.sh','scripts/webui-service.sh','scripts/stack-copy.sh'])writeFileSync(join(LEGACY,f),execFileSync('git',['-C',ROOT,'show',`${M3_BASE}:${f}`]),{mode:0o755});
|
||||
const legacy=(e)=>({...e,sr:join(LEGACY,'scripts','stack-release.sh')});
|
||||
const X=(e)=>({...e,flags:[...e.flags,'--units','external']});
|
||||
const AUU=(e,op)=>`${e.cfg} holds console.sso; an accounts instance runs system units, so root renders them with case2-unit.sh and the ${op} runs with --units external`;
|
||||
// Root's part. rootUp installs the bus unit for COPY and writes the Console unit case2-unit.sh
|
||||
// renders for COPY with FLAGS (webui-service.sh refuses an accounts instance), then starts both.
|
||||
const rootDown=(e)=>{sysctl(e,'stop',e.con);sysctl(e,'stop',e.bus)};
|
||||
function rootUp(e,copy,flags){
|
||||
execFileSync('bash',[join(repo,'scripts','bus-service.sh'),'install',...e.flags,'--copy',copy,'--copies',COPIES,'--claude',e.claude],{env:e.env,stdio:'ignore'});
|
||||
const f=join(e.units,e.con),s=readFileSync(f,'utf8'),node=/^ExecStart=(\S+) /m.exec(s)[1],dir=join(COPIES,copy);
|
||||
writeFileSync(f,s.replace(/^WorkingDirectory=.*$/m,`WorkingDirectory=${dir}`).replace(/^Documentation=.*$/m,`Documentation=file://${dir}/packages/webui/README.md`)
|
||||
.replace(/^ExecStart=.*$/m,`ExecStart=${node} ${dir}/packages/webui/src/cli.mjs serve --checkout ${repo} ${flags.join(' ')}`));
|
||||
execFileSync(join(SBIN,'systemctl'),['--user','start',e.bus],{env:{...e.env,SD_CLOCK_OFFSET:'0'}});sysctl(e,'start',e.con);
|
||||
}
|
||||
// One system-path run: OP (activate TO, or rollback --to TO) planned and staged from the pointer's
|
||||
// copy, root's units for TO with FLAGS, confirm. The plan, the staged set and both log lines are this
|
||||
// script's output; the unit flags are root's render (case2-unit.test.mjs pins that render), so the
|
||||
// pointer pinning them is confirm's read of root's unit, not a conversion of this script's.
|
||||
async function viaSystem(e,op,to,flags,...more){
|
||||
const x=X(e),from=pointer(e).copy,p0=readFileSync(e.pointer,'utf8'),w0=sha(join(e.bin,e.wrapper));
|
||||
const args=op==='activate'?['activate',to,'acme']:['rollback','acme','--to',to],inst=e.flags.length?`${e.flags.join(' ')} `:'';
|
||||
rootDown(e);
|
||||
const con0=sha(join(e.units,e.con));
|
||||
const out=await planOnly(x,0,...args,...more),plan=out.join('\n');
|
||||
assert.deepEqual([out.find((l)=>l.startsWith('plan: ')),out.find((l)=>l.startsWith('from: ')),out.find((l)=>l.startsWith('to: '))],[`plan: ${op} for acme`,`from: ${from}`,`to: ${to}`],plan);
|
||||
assert.ok(out.includes(`units: external; root installs the units for ${to} and starts them, then confirm gates them`),plan);
|
||||
assert.ok(out.includes(`CLI: scripts/stack-copy.sh wrapper ${to} ${inst}--copies ${COPIES} --bin ${e.bin}`),plan);
|
||||
// Root renders the units, so the plan names no unit, Console flag or refusal of this script's.
|
||||
assert.deepEqual(out.filter((l)=>/^(bus|pm|proof|send|sso|Console):|^(drop|refuses) /.test(l)),[],plan);
|
||||
let r=await prun(x,...args,...more);assert.equal(r.code,0,r.out+r.err);
|
||||
const st=logLines(e).at(-1),act=st.activation,rs=setOf(e,act);
|
||||
assert.deepEqual([st.op,st.from,st.to,st.result,st.units],[op,from,to,'started','external']);
|
||||
assert.equal(r.out.split('\n').at(-2),`stack-release: ${op} staged ${to} for acme as ${act}; root installs and starts the units, then runs confirm ${to} acme --units external`);
|
||||
// Staged, not live: the pointer stays; the set holds the pointer, the wrapper and the Console unit root stopped.
|
||||
assert.equal(readFileSync(e.pointer,'utf8'),p0);
|
||||
assert.equal(readFileSync(join(rs,'AWAITING-UNITS'),'utf8'),`${to}\n`);
|
||||
assert.equal(readFileSync(join(rs,'pointer'),'utf8'),p0);assert.equal(sha(join(rs,'wrapper')),w0);
|
||||
assert.ok(readFileSync(join(rs,'UNITS.sha256'),'utf8').includes(`${con0} ${join(e.units,e.con)}\n`));
|
||||
assert.ok(readFileSync(join(e.bin,e.wrapper),'utf8').includes(join(COPIES,to)),'the wrapper names the target copy');
|
||||
rootUp(e,to,flags);
|
||||
r=await prun(x,'confirm',to,'acme');assert.equal(r.code,0,r.out+r.err);
|
||||
assert.equal(r.out.split('\n').at(-2),`stack-release: ${op} ok: ${to} is live for acme`);
|
||||
const l=logLines(e).at(-1);assert.deepEqual([l.op,l.from,l.to,l.result,l.units,l.activation],[op,from,to,'ok','external',act]);
|
||||
assert.ok(!existsSync(join(rs,'AWAITING-UNITS')));
|
||||
assert.deepEqual([pointer(e).copy,pointer(e).console],[to,flags]);
|
||||
return act;
|
||||
}
|
||||
|
||||
// --plan with an accounts sign: exit 3 with the refusal alone; nothing written, started or stopped, and no log line.
|
||||
async function planRefused(e,args,code,msg){
|
||||
const before=fp(e),data=tree(e.data),n=calls(e).length,logs=logLines(e).length;
|
||||
const r=await prun(e,...args,'--plan');assert.equal(r.code,3,r.out+r.err);assert.equal(r.out,'');
|
||||
assert.equal(r.err,`stack-release: refusing ${code}: ${msg}\n`);
|
||||
assert.deepEqual(fp(e),before);assert.deepEqual(tree(e.data),data);assert.deepEqual(startsStops(e,n),[]);assert.equal(logLines(e).length,logs);
|
||||
}
|
||||
// The user-unit run and its --plan both refuse accounts-user-unit, before anything stops.
|
||||
async function auu(e,args,op,msg=AUU(e,op)){await refused(e,args,'accounts-user-unit',msg);await planRefused(e,args,'accounts-user-unit',msg)}
|
||||
const rootStart=(e)=>{execFileSync(join(SBIN,'systemctl'),['--user','start',e.bus],{env:{...e.env,SD_CLOCK_OFFSET:'0'}});sysctl(e,'start',e.con)};
|
||||
const shown=(e,act,show=SHOW)=>readFileSync(join(setOf(e,act),show),'utf8');
|
||||
|
||||
test('T57 row 200: activate --sso, its refusals before any stop, and the rollback to a Console without --sso',async(t)=>{
|
||||
await t.test('item 1: a pre-SSO activation drops legacy send; --sso then drops preview and proof before the tester port',async(t)=>{
|
||||
await t.test('item 1 (M3): a pre-SSO activation drops legacy send; with console.sso a user-unit --sso refuses accounts-user-unit, and the system path pins root\'s --sso Console',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),'--tester-port','7333']);
|
||||
await ssoConfig(e);
|
||||
await auu(e,['activate',C1,'acme','--sso'],'activate');
|
||||
// Root renders the stamped Console with --sso and no preview or proof flags (case2-unit.test.mjs pins that render).
|
||||
const want=['--port',e.port,'--business','acme','--sso','--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: off',
|
||||
`sso: on; drops ${DROPS}; carries --port ${e.port} --business acme --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);
|
||||
const act=await viaSystem(e,'activate',C1,want);
|
||||
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);
|
||||
// The activation's set holds the proxy-preview Console it replaced, pair and tester port included.
|
||||
assert.ok(shown(e,act).includes(` ${[...pre,...pair(f),'--tester-port','7333'].join(' ')}`),shown(e,act));
|
||||
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),...sendLines(out),...ssoLines(out)],['proof: off','send: off',`sso: on; drops ${DROPS}; carries --port ${e.port} --business acme`]);
|
||||
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']);
|
||||
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`);
|
||||
r=await prun(e,'activate',C1,'acme','--send');assert.equal(r.code,0,r.out+r.err);
|
||||
assert.deepEqual(pointer(e).console,['--port',e.port,'--business','acme','--sso','--send']);
|
||||
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/);
|
||||
await t.test('item 1 (M3): while console.sso is set every user-unit activate refuses accounts-user-unit; the system path carries root\'s --sso --send Console to another copy',async(t)=>{
|
||||
const e=await mk(t);await seedPreview(e,undefined,['--send']);await ssoConfig(e);
|
||||
for(const args of [['activate',C1,'acme'],['activate',C1,'acme','--sso'],['activate',C1,'acme','--send'],['activate',C1,'acme','--sso','--no-send'],['activate',C,'acme','--migrate']])await auu(e,args,'activate');
|
||||
const sso=['--port',e.port,'--business','acme','--sso','--send'];
|
||||
await viaSystem(e,'activate',C1,sso);
|
||||
assert.deepEqual(pointer(e).console,sso);
|
||||
await auu(e,['activate',C1,'acme','--no-send'],'activate');
|
||||
await auu(e,['rollback','acme'],'rollback');
|
||||
const act=await viaSystem(e,'activate',C,sso,'--migrate');
|
||||
assert.equal(pointer(e).copy,C);assert.deepEqual(pointer(e).console,sso);
|
||||
assert.ok(conExec(e).endsWith(` --checkout ${repo} ${sso.join(' ')}`),conExec(e));
|
||||
assert.match(shown(e,act),/ --sso --send /);
|
||||
let r=await prun(e,'status','acme');assert.equal(r.code,0,r.err);assert.match(r.out,/unit flags: match the pointer/);
|
||||
// Root's unit without --send: the pointer pins what root rendered.
|
||||
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);
|
||||
await viaSystem(g,'activate',C1,['--port',g.port,'--business','acme','--sso']);
|
||||
assert.deepEqual(pointer(g).console,['--port',g.port,'--business','acme','--sso']);
|
||||
assert.ok(stepText(g).includes('Z drop --send; --no-send was given\n'));
|
||||
r=await prun(g,'status','acme');assert.equal(r.code,0,r.err);assert.match(r.out,/unit flags: match the pointer/);
|
||||
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)=>{
|
||||
@@ -1613,53 +1672,49 @@ test('T57 row 200: activate --sso, its refusals before any stop, and the rollbac
|
||||
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 t.test('item 2 (M3): without console.sso, a target without --sso and the missing key refuse before any stop; with console.sso, valid or not and whatever its slot, the user-unit run refuses accounts-user-unit first',async(t)=>{
|
||||
const e=await mk(t);await seedPreview(e);
|
||||
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,'t57-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);
|
||||
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-config: ${C}'s Console would refuse its --sso start: there is no console.sso in the system config; activate ${C} without --sso first, then write console.sso, then run activate ${C} --sso`]);
|
||||
// With console.sso the slot is the Console start's check (packages/webui/tests/sso.test.mjs, readSlot);
|
||||
// no slot state and no bad key gets a user-unit run past accounts-user-unit.
|
||||
await ssoConfig(e,{slot:false});
|
||||
const slot=slotPath(e),secrets=dirname(slot),put=(mode=0o600,text='x\n')=>{rmSync(slot,{force:true,recursive:true});writeFileSync(slot,text,{mode});chmodSync(slot,mode)};
|
||||
const inRepo=mkdtempSync(join(repo,'t57-slot-'));t.after(()=>rmSync(inRepo,{recursive:true,force:true}));
|
||||
writeFileSync(join(inRepo,SLOT),'x\n',{mode:0o600});chmodSync(join(inRepo,SLOT),0o600);
|
||||
const elsewhere=join(e.root,'elsewhere');writeFileSync(elsewhere,'x\n',{mode:0o600});
|
||||
const states=[()=>{},()=>symlinkSync(elsewhere,slot),()=>{rmSync(slot);mkdirSync(slot,{mode:0o700})},()=>put(0o644),()=>put(0o640),()=>put(0o604),()=>put(0o600,''),
|
||||
()=>{rmSync(secrets,{recursive:true});symlinkSync(inRepo,secrets)},()=>{rmSync(secrets);mkdirSync(secrets,{mode:0o700});put()}];
|
||||
// A bad tester port is a usage error outside --plan (row 180), so that case runs --plan only:
|
||||
// accounts-user-unit stops the plan before it lists tester-port-invalid.
|
||||
for(const s of states){s();for(const args of [['activate',C,'acme','--sso'],['activate',B,'acme','--sso']])await auu(e,args,'activate');await planRefused(e,['activate',C,'acme','--sso','--tester-port','80'],'accounts-user-unit',AUU(e,'activate'))}
|
||||
setKey(e,{sso:{...SSO_KEY.sso,providers:[{...SSO_KEY.sso.providers[0],algs:['RS256']}]}});
|
||||
await auu(e,['activate',C,'acme','--sso'],'activate');
|
||||
setKey(e,undefined);
|
||||
r=await prun(e,'activate',C,'acme','--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);
|
||||
await t.test('item 3 (M3): a config whose console key the target loader refuses stops an external activate or rollback before any stop, naming the first step; a user-unit run refuses accounts-user-unit first',async(t)=>{
|
||||
const e=await mk(t);await seedPreview(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');
|
||||
await ssoConfig(e);
|
||||
await auu(e,['activate',B,'acme'],'activate');await auu(e,['rollback','acme'],'rollback');
|
||||
// Root stopped the units; the staged run checks the config before it writes anything.
|
||||
rootDown(e);
|
||||
const x=X(e),why='mosaic-config: unsupported configuration key: "console"';
|
||||
await refused(x,['activate',B,'acme'],'config-console-key',KEY_MSG(e,B,'activate',why));
|
||||
await refused(x,['rollback','acme','--to',B],'config-console-key',KEY_MSG(e,B,'rollback',why));
|
||||
const out=await planOnly(x,3,'rollback','acme','--to',B);
|
||||
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);
|
||||
await refused(x,['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);rootStart(e);
|
||||
r=await prun(e,'rollback','acme');assert.equal(r.code,0,r.out+r.err);assert.equal(pointer(e).copy,B);
|
||||
await noLeak(e);
|
||||
});
|
||||
@@ -1676,17 +1731,17 @@ test('T57 row 200: activate --sso, its refusals before any stop, and the rollbac
|
||||
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);
|
||||
await t.test('item 3 (M3): from a pre-M3 --sso pointer the user-unit rollback refuses accounts-user-unit; without console.sso 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']);
|
||||
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','--send');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);
|
||||
// A pre-M3 host turned --sso on in place, then re-pinned twice.
|
||||
await ssoConfig(e);
|
||||
for(const f of ['--sso','--send','--no-send']){r=await prun(legacy(e),'activate',C1,'acme',f);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.match(shown(e,pointer(e).activation),/ --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"'));
|
||||
await auu(e,['rollback','acme'],'rollback');
|
||||
setKey(e,undefined);
|
||||
const want=[...noSend(base.slice(0,-2)),'--tester-port','7333'];
|
||||
const out=await planOnly(e,0,'rollback','acme');
|
||||
@@ -1701,15 +1756,17 @@ test('T57 row 200: activate --sso, its refusals before any stop, and the rollbac
|
||||
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);
|
||||
r=await prun(legacy(g),'activate',C1,'acme','--sso');assert.equal(r.code,0,r.out+r.err);
|
||||
await auu(g,['rollback','acme'],'rollback');
|
||||
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)]);
|
||||
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)=>{
|
||||
await t.test('item 3 (M3): from a pre-M3 --sso pointer, 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);
|
||||
let r=await prun(legacy(e),'activate',C1,'acme','--sso');assert.equal(r.code,0,r.out+r.err);
|
||||
await auu(e,['rollback','acme'],'rollback');
|
||||
setKey(e,undefined);
|
||||
const set=arrivalSet(e,C1),show=join(set,SHOW),text=readFileSync(show,'utf8');
|
||||
setEdit(set,SHOW,' --public-host ',' --sso --public-host ');
|
||||
@@ -1731,17 +1788,18 @@ test('T57 row 200: activate --sso, its refusals before any stop, and the rollbac
|
||||
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)=>{
|
||||
await t.test('round 2 (webby-0028 N1, M3): run through a symlinked repository path, a proof file inside the repository still refuses, and console.sso with a slot inside the repository refuses accounts-user-unit',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,'t57-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);
|
||||
await ssoConfig(e,{slot:false});
|
||||
const inRepo=mkdtempSync(join(repo,'t57-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`);
|
||||
// The Console's start refuses the slot (sso.test.mjs: inside the repository or a stack copy).
|
||||
await auu(e,['activate',C1,'acme','--sso'],'activate');
|
||||
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)=>{
|
||||
@@ -1758,24 +1816,21 @@ test('T57 row 200: activate --sso, its refusals before any stop, and the rollbac
|
||||
const r=await prun(e,'activate',C1,'acme');assert.equal(r.code,0,r.out+r.err);
|
||||
await noLeak(e);
|
||||
});
|
||||
await t.test('round 3 (sage-1556): a named instance turns --sso on with its own config, slot and Console unit, and rolls back from its own set',async(t)=>{
|
||||
await t.test('round 3 (sage-1556, M3): a named instance refuses a user-unit --sso with its own config, turns --sso on through the system path on its own Console unit, and rolls back from its own set',async(t)=>{
|
||||
const e=await mk(t),a=await named(e,'alpha');await seed(a);
|
||||
const show=`show-${a.con}.txt`,unit=()=>readFileSync(join(a.units,a.con),'utf8').split('\n').find((l)=>l.startsWith('ExecStart='));
|
||||
assert.ok(slotPath(a).startsWith(join(a.env.HOME,'.config','mosaic-alpha','secrets','mosaic-stack')+'/'),slotPath(a));
|
||||
await ssoConfig(a,{slot:false});
|
||||
await refused(a,['activate',C1,'acme','--sso'],'sso-slot',`console.sso provider a client secret slot ${slotPath(a)} is missing`);
|
||||
await proofFile(a,SLOT,0o600,dirname(slotPath(a)));
|
||||
await ssoConfig(a);
|
||||
await auu(a,['activate',C1,'acme','--sso'],'activate');
|
||||
const want=['--port',a.port,'--business','acme','--sso'];
|
||||
const out=await planOnly(a,0,'activate',C1,'acme','--sso');
|
||||
assert.equal(out.find((l)=>l.startsWith('Console: ')),`Console: scripts/webui-service.sh install --instance alpha --copy ${C1} --copies ${COPIES} ${want.join(' ')}`);
|
||||
let r=await prun(a,'activate',C1,'acme','--sso');assert.equal(r.code,0,r.out+r.err);
|
||||
const act=await viaSystem(a,'activate',C1,want);
|
||||
assert.equal(pointer(a).copy,C1);assert.deepEqual(pointer(a).console,want);
|
||||
assert.ok(unit().endsWith(` ${want.join(' ')}`),unit());
|
||||
assert.ok(readFileSync(join(a.units,a.con),'utf8').includes(`\nEnvironment=MOSAIC_CONFIG=${a.cfg}\n`));
|
||||
assert.ok(!existsSync(join(e.units,CON)),'the default Console unit was written');
|
||||
r=await prun(a,'status','acme');assert.equal(r.code,0,r.err);assert.match(r.out,/unit flags: match the pointer/);
|
||||
let r=await prun(a,'status','acme');assert.equal(r.code,0,r.err);assert.match(r.out,/unit flags: match the pointer/);
|
||||
setKey(a,undefined);
|
||||
const set=arrivalSet(a,C1);assert.ok(existsSync(join(set,show)),show);
|
||||
const set=setOf(a,act);assert.equal(arrivalSet(a,C1),set);assert.ok(existsSync(join(set,show)),show);
|
||||
assert.deepEqual(ssoLines(await planOnly(a,0,'rollback','acme')),[`sso: dropped; ${B}'s Console does not support --sso, so the rollback takes the Console flags from ${set}/${show}, with --tester-port as the pointer has it`]);
|
||||
r=await prun(a,'rollback','acme');assert.equal(r.code,0,r.out+r.err);
|
||||
assert.equal(pointer(a).copy,B);assert.deepEqual(pointer(a).console,want.slice(0,-1));
|
||||
@@ -1794,44 +1849,35 @@ test('T57 row 200: activate --sso, its refusals before any stop, and the rollbac
|
||||
|
||||
// Row 273 (#1778, mos-1246 F39): activate --send turns send on only beside --sso, given or carried, and
|
||||
// activate --no-sso turns --sso off with the Console flags from the set of the activation that turned
|
||||
// it on, dropping --send. The tester follows the pointer.
|
||||
// it on, dropping --send. The tester follows the pointer. M3 (#1873): with console.sso these are
|
||||
// accounts-user-unit refusals; a pre-M3 --sso pointer comes from legacy(e), and --no-sso runs once
|
||||
// console.sso is gone.
|
||||
const SEND_MSG="--send needs --sso in the run's Console flags, given or carried; under forward auth any same-uid process could send (F39)";
|
||||
const NOT_ON_MSG="the pointer's Console flags hold no --sso, so --no-sso has nothing to turn off";
|
||||
const OFF_MSG=(why)=>`the Console flags from before --sso cannot be read: ${why}`;
|
||||
const OFF_PLAN=(set,restores)=>`sso: off; Console flags from ${set}/${SHOW}; restores ${restores}`;
|
||||
const NOTE=(to)=>`note: ${to} is the pointer's copy; the run re-pins it and only the Console flags change`;
|
||||
test('T58 row 273: activate --send and --no-sso',async(t)=>{
|
||||
await t.test('T1: --sso --send turns send on after --sso and before the tester port, and the plan says so',async(t)=>{
|
||||
await t.test('T1 (M3): a user-unit --sso --send refuses accounts-user-unit; the system path pins root\'s --sso --send before the tester port',async(t)=>{
|
||||
const e=await mk(t);await seedPreview(e,undefined,['--tester-port','7333']);await ssoConfig(e);
|
||||
await auu(e,['activate',C1,'acme','--sso','--send'],'activate');
|
||||
const want=['--port',e.port,'--business','acme','--sso','--send','--tester-port','7333'];
|
||||
const out=await planOnly(e,0,'activate',C1,'acme','--sso','--send');
|
||||
assert.deepEqual([...sendLines(out),...ssoLines(out)],['send: on; --send was given',`sso: on; drops ${DROPS}; carries --port ${e.port} --business acme --send --tester-port 7333`]);
|
||||
assert.equal(out.find((l)=>l.startsWith('Console: ')),`Console: scripts/webui-service.sh install --copy ${C1} --copies ${COPIES} ${want.join(' ')}`);
|
||||
let r=await prun(e,'activate',C1,'acme','--sso','--send');assert.equal(r.code,0,r.out+r.err);
|
||||
await viaSystem(e,'activate',C1,want);
|
||||
assert.equal(pointer(e).copy,C1);assert.deepEqual(pointer(e).console,want);
|
||||
assert.ok(conExec(e).endsWith(` --checkout ${repo} ${want.join(' ')}`),conExec(e));
|
||||
const steps=stepText(e);
|
||||
assert.ok(steps.includes('Z turn on --send; --send was given\n'),steps);assert.ok(steps.includes(`Z turn on --sso; drop ${DROPS}\n`),steps);
|
||||
r=await prun(e,'status','acme');assert.equal(r.code,0,r.err);assert.match(r.out,/unit flags: match the pointer/);
|
||||
// The same copy with send on again is already active.
|
||||
await refused(e,['activate',C1,'acme','--send'],'already-active',`${C1} is the pointer's copy`);
|
||||
const r=await prun(e,'status','acme');assert.equal(r.code,0,r.err);assert.match(r.out,/unit flags: match the pointer/);
|
||||
await auu(e,['activate',C1,'acme','--send'],'activate');
|
||||
await noLeak(e);
|
||||
});
|
||||
await t.test('T1: --send beside a carried --sso re-pins the live copy once, and the plan names the carried --sso',async(t)=>{
|
||||
await t.test('T1 (M3): beside a pre-M3 carried --sso, a user-unit --send refuses accounts-user-unit; the system path carries root\'s --sso --send to another copy',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);
|
||||
let r=await prun(legacy(e),'activate',C1,'acme','--sso');assert.equal(r.code,0,r.out+r.err);
|
||||
assert.deepEqual(pointer(e).console,['--port',e.port,'--business','acme','--sso']);
|
||||
const out=await planOnly(e,0,'activate',C1,'acme','--send');
|
||||
assert.deepEqual([...sendLines(out),...ssoLines(out)],['send: on; --send was given','sso: carried']);
|
||||
assert.ok(out.includes(NOTE(C1)),out.join('\n'));
|
||||
r=await prun(e,'activate',C1,'acme','--send');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']);
|
||||
await auu(e,['activate',C1,'acme','--send'],'activate');
|
||||
await auu(e,['activate',C,'acme','--send','--migrate'],'activate');
|
||||
const want=['--port',e.port,'--business','acme','--sso','--send'];
|
||||
assert.deepEqual(pointer(e).console,want);assert.ok(conExec(e).endsWith(` --checkout ${repo} ${want.join(' ')}`),conExec(e));
|
||||
assert.ok(stepText(e).includes('Z turn on --send; --send was given\n'));assert.doesNotMatch(stepText(e),/^\S+Z .*sso/m);
|
||||
await refused(e,['activate',C1,'acme','--send'],'already-active',`${C1} is the pointer's copy`);
|
||||
// Another copy carries both.
|
||||
assert.deepEqual(sendLines(await planOnly(e,0,'activate',C,'acme','--send')),['send: carried']);
|
||||
await viaSystem(e,'activate',C,want,'--migrate');
|
||||
assert.equal(pointer(e).copy,C);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/);
|
||||
await noLeak(e);
|
||||
});
|
||||
@@ -1848,11 +1894,12 @@ test('T58 row 273: activate --send and --no-sso',async(t)=>{
|
||||
await refused(g,['activate',B,'acme','--send'],'already-active',`${B} is the pointer's copy`);
|
||||
for(const x of [e,g])await noLeak(x);
|
||||
});
|
||||
await t.test('T2 (sage-2315 B1): a rollback from an --sso --send pointer to a Console without --sso ends with neither flag',async(t)=>{
|
||||
await t.test('T2 (sage-2315 B1, M3): from a pre-M3 --sso --send pointer the user-unit rollback refuses accounts-user-unit; without console.sso the rollback to a Console without --sso ends with neither flag',async(t)=>{
|
||||
const e=await mk(t),base=await seedPreview(e);await ssoConfig(e);
|
||||
let r=await prun(e,'activate',C1,'acme','--sso','--send');assert.equal(r.code,0,r.out+r.err);
|
||||
let r=await prun(legacy(e),'activate',C1,'acme','--sso','--send');assert.equal(r.code,0,r.out+r.err);
|
||||
assert.deepEqual(pointer(e).console,['--port',e.port,'--business','acme','--sso','--send']);
|
||||
const set=arrivalSet(e,C1);
|
||||
await auu(e,['rollback','acme'],'rollback');
|
||||
setKey(e,undefined);
|
||||
const out=await planOnly(e,0,'rollback','acme');
|
||||
assert.deepEqual([...sendLines(out),...ssoLines(out)],['send: dropped; the run drops --sso, and --send needs it (F39)',ROLLBACK_PLAN(set)]);
|
||||
@@ -1866,7 +1913,7 @@ test('T58 row 273: activate --send and --no-sso',async(t)=>{
|
||||
});
|
||||
await t.test('T2 and T4: --send with --no-send, --no-sso with --sso, --send, a pair or --no-proof, and either flag on another verb, is a usage error',async(t)=>{
|
||||
const e=await mk(t);await seedPreview(e);await ssoConfig(e);const f=await proofFile(e);
|
||||
let r=await prun(e,'activate',C1,'acme','--sso','--send');assert.equal(r.code,0,r.out+r.err);
|
||||
let r=await prun(legacy(e),'activate',C1,'acme','--sso','--send');assert.equal(r.code,0,r.out+r.err);
|
||||
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','--send','--no-send'],['activate',C1,'acme','--no-send','--send','--plan'],['activate',C1,'acme','--sso','--send','--no-send'],
|
||||
['activate',C1,'acme','--no-sso','--sso'],['activate',C1,'acme','--sso','--no-sso','--plan'],['activate',C1,'acme','--no-sso','--send'],
|
||||
@@ -1879,15 +1926,18 @@ test('T58 row 273: activate --send and --no-sso',async(t)=>{
|
||||
assert.deepEqual(fp(e),before);assert.deepEqual(startsStops(e,n),[]);assert.equal(logLines(e).length,logs);
|
||||
await noLeak(e);
|
||||
});
|
||||
await t.test('T3: --no-sso on the same copy returns the unit and the pointer to the flags from before --sso, without --send',async(t)=>{
|
||||
const e=await mk(t),base=await seedPreview(e,undefined,['--send','--tester-port','7333']);await ssoConfig(e);
|
||||
await t.test('T3 (M3): --no-sso refuses accounts-user-unit while console.sso is set; without it, on the same copy it returns the unit and the pointer to the flags from before --sso, without --send',async(t)=>{
|
||||
const e=await mk(t),base=await seedPreview(e,undefined,['--send','--tester-port','7333']);
|
||||
let r=await prun(e,'activate',C1,'acme');assert.equal(r.code,0,r.out+r.err);
|
||||
const pre=conExec(e),want=noSend(base);
|
||||
assert.ok(pre.endsWith(` ${want.join(' ')}`),pre);
|
||||
r=await prun(e,'activate',C1,'acme','--sso');assert.equal(r.code,0,r.out+r.err);
|
||||
await ssoConfig(e);
|
||||
r=await prun(legacy(e),'activate',C1,'acme','--sso');assert.equal(r.code,0,r.out+r.err);
|
||||
const set=setOf(e,pointer(e).activation);
|
||||
r=await prun(e,'activate',C1,'acme','--send');assert.equal(r.code,0,r.out+r.err);
|
||||
r=await prun(legacy(e),'activate',C1,'acme','--send');assert.equal(r.code,0,r.out+r.err);
|
||||
assert.deepEqual(pointer(e).console,['--port',e.port,'--business','acme','--sso','--send','--tester-port','7333']);
|
||||
await auu(e,['activate',C1,'acme','--no-sso'],'activate');
|
||||
setKey(e,undefined);
|
||||
const out=await planOnly(e,0,'activate',C1,'acme','--no-sso');
|
||||
assert.deepEqual([...proofLines(out),...sendLines(out),...ssoLines(out)],['proof: off','send: dropped; --no-sso drops --send (F39)',OFF_PLAN(set,want.slice(0,-2).join(' '))]);
|
||||
assert.ok(out.includes(NOTE(C1)),out.join('\n'));
|
||||
@@ -1904,14 +1954,16 @@ test('T58 row 273: activate --send and --no-sso',async(t)=>{
|
||||
await refused(e,['activate',C1,'acme','--no-sso'],'sso-not-on',NOT_ON_MSG);
|
||||
await noLeak(e);
|
||||
});
|
||||
await t.test('T3: the set walk passes later re-pins that ran with --sso, and the tester follows the pointer',async(t)=>{
|
||||
await t.test('T3 (M3): from pre-M3 re-pins, the set walk passes later re-pins that ran with --sso, and the tester follows the pointer',async(t)=>{
|
||||
const e=await mk(t),base=await seedPreview(e,undefined,['--send','--tester-port','7333']);await ssoConfig(e);
|
||||
// The activation that turns --sso on also moves the tester to 7334, so the set and the pointer differ.
|
||||
let r=await prun(e,'activate',C1,'acme','--sso','--tester-port','7334');assert.equal(r.code,0,r.out+r.err);
|
||||
let r=await prun(legacy(e),'activate',C1,'acme','--sso','--tester-port','7334');assert.equal(r.code,0,r.out+r.err);
|
||||
const set=setOf(e,pointer(e).activation);
|
||||
r=await prun(e,'activate',C1,'acme','--no-send');assert.equal(r.code,0,r.out+r.err);
|
||||
assert.match(readFileSync(join(setOf(e,pointer(e).activation),SHOW),'utf8'),/ --sso /);
|
||||
r=await prun(legacy(e),'activate',C1,'acme','--no-send');assert.equal(r.code,0,r.out+r.err);
|
||||
assert.match(shown(e,pointer(e).activation),/ --sso /);
|
||||
assert.deepEqual(pointer(e).console,['--port',e.port,'--business','acme','--sso','--tester-port','7334']);
|
||||
await auu(e,['activate',C1,'acme','--no-sso'],'activate');
|
||||
setKey(e,undefined);
|
||||
const restores=base.slice(0,-3),want=[...restores,'--tester-port','7334'];
|
||||
const out=await planOnly(e,0,'activate',C1,'acme','--no-sso');
|
||||
assert.deepEqual([...sendLines(out),...ssoLines(out)],['send: off; --no-sso was given and the pointer holds no --send',OFF_PLAN(set,restores.join(' '))]);
|
||||
@@ -1921,17 +1973,20 @@ test('T58 row 273: activate --send and --no-sso',async(t)=>{
|
||||
r=await prun(e,'status','acme');assert.equal(r.code,0,r.err);assert.match(r.out,/unit flags: match the pointer/);
|
||||
await noLeak(e);
|
||||
});
|
||||
await t.test('T3 (sage-2305 a): after --sso, two later re-pins that carry it, then --no-sso restores the flags from before --sso byte for byte',async(t)=>{
|
||||
const e=await mk(t),base=await seedPreview(e,undefined,['--tester-port','7333']);await ssoConfig(e);
|
||||
await t.test('T3 (sage-2305 a, M3): after a pre-M3 --sso and two re-pins that carry it, --no-sso without console.sso restores the flags from before --sso byte for byte',async(t)=>{
|
||||
const e=await mk(t),base=await seedPreview(e,undefined,['--tester-port','7333']);
|
||||
let r=await prun(e,'activate',C1,'acme');assert.equal(r.code,0,r.out+r.err);
|
||||
const pre=conExec(e);assert.ok(pre.endsWith(` ${base.join(' ')}`),pre);
|
||||
r=await prun(e,'activate',C1,'acme','--sso');assert.equal(r.code,0,r.out+r.err);
|
||||
await ssoConfig(e);
|
||||
r=await prun(legacy(e),'activate',C1,'acme','--sso');assert.equal(r.code,0,r.out+r.err);
|
||||
const set=setOf(e,pointer(e).activation);
|
||||
// Two same-copy re-pins carry --sso, so the two newest sets both hold a Console with it.
|
||||
r=await prun(e,'activate',C1,'acme','--send');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);
|
||||
r=await prun(legacy(e),'activate',C1,'acme','--send');assert.equal(r.code,0,r.out+r.err);
|
||||
r=await prun(legacy(e),'activate',C1,'acme','--no-send');assert.equal(r.code,0,r.out+r.err);
|
||||
const acts=logLines(e).filter((l)=>l.result==='ok'&&l.op==='activate').map((l)=>l.activation).slice(-2);
|
||||
for(const a of acts)assert.match(readFileSync(join(setOf(e,a),SHOW),'utf8'),/ --sso /);
|
||||
for(const a of acts)assert.match(shown(e,a),/ --sso /);
|
||||
await auu(e,['activate',C1,'acme','--no-sso'],'activate');
|
||||
setKey(e,undefined);
|
||||
const out=await planOnly(e,0,'activate',C1,'acme','--no-sso');
|
||||
assert.deepEqual(ssoLines(out),[OFF_PLAN(set,base.slice(0,-2).join(' '))]);
|
||||
r=await prun(e,'activate',C1,'acme','--no-sso');assert.equal(r.code,0,r.out+r.err);
|
||||
@@ -1939,27 +1994,31 @@ test('T58 row 273: activate --send and --no-sso',async(t)=>{
|
||||
r=await prun(e,'status','acme');assert.equal(r.code,0,r.err);assert.match(r.out,/unit flags: match the pointer/);
|
||||
await noLeak(e);
|
||||
});
|
||||
await t.test('T4 (sage-2305 b): a log in which every set holds a Console with --sso refuses before any unit stops',async(t)=>{
|
||||
await t.test('T4 (sage-2305 b, M3): a log in which every set holds a Console with --sso refuses before any unit stops',async(t)=>{
|
||||
const e=await mk(t);await seedPreview(e,undefined,['--send']);await ssoConfig(e);
|
||||
let r=await prun(e,'activate',C1,'acme','--sso');assert.equal(r.code,0,r.out+r.err);
|
||||
let r=await prun(legacy(e),'activate',C1,'acme','--sso');assert.equal(r.code,0,r.out+r.err);
|
||||
const first=setOf(e,pointer(e).activation);
|
||||
r=await prun(e,'activate',C1,'acme','--no-send');assert.equal(r.code,0,r.out+r.err);
|
||||
assert.match(readFileSync(join(setOf(e,pointer(e).activation),SHOW),'utf8'),/ --sso /);
|
||||
r=await prun(legacy(e),'activate',C1,'acme','--no-send');assert.equal(r.code,0,r.out+r.err);
|
||||
assert.match(shown(e,pointer(e).activation),/ --sso /);
|
||||
// The set of the activation that turned --sso on now claims its Console ran with it too, sums intact.
|
||||
setEdit(first,SHOW,' --public-host ',' --sso --public-host ');
|
||||
await auu(e,['activate',C1,'acme','--no-sso'],'activate');
|
||||
setKey(e,undefined);
|
||||
const why=OFF_MSG(`the Console in ${join(first,SHOW)} already ran with --sso, and the log names no earlier activation`);
|
||||
await refused(e,['activate',C1,'acme','--no-sso'],'sso-off-flags',why);
|
||||
assert.deepEqual((await planOnly(e,3,'activate',C1,'acme','--no-sso')).filter((l)=>l.startsWith('refuses ')),[`refuses sso-off-flags: ${why}`]);
|
||||
await noLeak(e);
|
||||
});
|
||||
await t.test('T4: --no-sso refuses before any stop on a pointer without --sso, a tampered or missing set, a set that ran with --sso, an ok line with no activation, and a log with no set',async(t)=>{
|
||||
await t.test('T4 (M3): --no-sso refuses before any stop on a pointer without --sso, a tampered or missing set, a set that ran with --sso, an ok line with no activation, and a log with no set',async(t)=>{
|
||||
const e=await mk(t);await seedPreview(e,undefined,['--send']);
|
||||
await refused(e,['activate',B,'acme','--no-sso'],'sso-not-on',NOT_ON_MSG);
|
||||
await refused(e,['activate',C1,'acme','--no-sso'],'sso-not-on',NOT_ON_MSG);
|
||||
assert.deepEqual((await planOnly(e,3,'activate',C1,'acme','--no-sso')).filter((l)=>l.startsWith('refuses ')),[`refuses sso-not-on: ${NOT_ON_MSG}`]);
|
||||
// One set, from the activation that turned --sso on.
|
||||
// One set, from the pre-M3 activation that turned --sso on.
|
||||
await ssoConfig(e);
|
||||
let r=await prun(e,'activate',C1,'acme','--sso');assert.equal(r.code,0,r.out+r.err);
|
||||
let r=await prun(legacy(e),'activate',C1,'acme','--sso');assert.equal(r.code,0,r.out+r.err);
|
||||
await auu(e,['activate',C1,'acme','--no-sso'],'activate');
|
||||
setKey(e,undefined);
|
||||
const set=setOf(e,pointer(e).activation),show=join(set,SHOW),text=readFileSync(show,'utf8');
|
||||
// An ok line with no activation stops the walk (sage-2315 N1); skipping it would reach the set above.
|
||||
const logText=readFileSync(e.log,'utf8');
|
||||
@@ -1985,6 +2044,62 @@ test('T58 row 273: activate --send and --no-sso',async(t)=>{
|
||||
});
|
||||
});
|
||||
|
||||
// M3 (#1873) item 2: each accounts sign refuses a user-unit activate, rollback or pm-on before
|
||||
// anything stops; without them the user path runs, and with them the system path runs.
|
||||
const AUU_TAIL=(op)=>`; an accounts instance runs system units, so root renders them with case2-unit.sh and the ${op} runs with --units external`;
|
||||
test('M3 accounts-user-unit: each accounts sign refuses a user-unit run before any stop; the user path without them and the system path with them both run',async(t)=>{
|
||||
await t.test('a stamped target, accounts.json, console.sso and .deployed each refuse activate and rollback, run and --plan',async(t)=>{
|
||||
const e=await mk(t);await seedPreview(e);
|
||||
let r=await prun(e,'activate',C1,'acme');assert.equal(r.code,0,r.out+r.err);
|
||||
const accounts=join(dirname(e.cfg),'accounts.json'),deployed=join(e.data,'.deployed');
|
||||
const stamp=(copy)=>{const d=join(COPIES,copy),f=join(d,'.stack-build.json'),m=statSync(d).mode&0o777;chmodSync(d,0o755);writeFileSync(f,'{}\n');chmodSync(d,m);
|
||||
return ()=>{chmodSync(d,0o755);rmSync(f);chmodSync(d,m)}};
|
||||
const signs=[
|
||||
[()=>stamp(C),()=>stamp(B),`${join(COPIES,C)} is a stamped copy`,`${join(COPIES,B)} is a stamped copy`],
|
||||
[()=>{writeFileSync(accounts,'{}\n',{mode:0o600});return ()=>rmSync(accounts)},null,`${accounts} is present`],
|
||||
[()=>{setKey(e,SSO_KEY);return ()=>setKey(e,undefined)},null,`${e.cfg} holds console.sso`],
|
||||
[()=>{writeFileSync(deployed,'{}\n');return ()=>rmSync(deployed)},null,`${deployed} is present`],
|
||||
];
|
||||
for(const [on,onRb,why,whyRb] of signs){
|
||||
let off=on();t.after(()=>{try{off()}catch{}});
|
||||
await auu(e,['activate',C,'acme','--migrate'],'activate',why+AUU_TAIL('activate'));
|
||||
if(onRb){off();off=onRb()}
|
||||
await auu(e,['rollback','acme'],'rollback',(whyRb??why)+AUU_TAIL('rollback'));
|
||||
off();
|
||||
}
|
||||
// No sign left: the user path runs both ways.
|
||||
r=await prun(e,'rollback','acme');assert.equal(r.code,0,r.out+r.err);assert.equal(pointer(e).copy,B);
|
||||
r=await prun(e,'activate',C1,'acme');assert.equal(r.code,0,r.out+r.err);assert.equal(pointer(e).copy,C1);
|
||||
r=await prun(e,'status','acme');assert.equal(r.code,0,r.err);assert.match(r.out,/unit flags: match the pointer/);
|
||||
await noLeak(e);
|
||||
});
|
||||
await t.test('pm-on on an accounts instance refuses before it captures or stops anything; without the sign it runs',async(t)=>{
|
||||
const e=await mk(t);await pending(e);biz(e,HARNESS);
|
||||
const accounts=join(dirname(e.cfg),'accounts.json');writeFileSync(accounts,'{}\n',{mode:0o600});
|
||||
// The harness is set, so only the accounts sign stands between pm-on and the user bus unit.
|
||||
await refused(e,['pm-on','acme'],'accounts-user-unit',`${accounts} is present; an accounts instance runs system units, so root renders them with case2-unit.sh, and the PM is a flag in root's bus unit`);
|
||||
rmSync(accounts);
|
||||
const r=await run(e,'pm-on','acme');assert.equal(r.code,0,r.out+r.err);
|
||||
assert.ok(r.out.endsWith(`stack-release: pm-on ok: the PM is live on ${C1} for acme\n`),r.out);
|
||||
});
|
||||
await t.test('with accounts.json and .deployed the system path activates, confirms and rolls back, and the user path still refuses',async(t)=>{
|
||||
const e=await mk(t),base=await seedPreview(e);
|
||||
writeFileSync(join(dirname(e.cfg),'accounts.json'),'{}\n',{mode:0o600});writeFileSync(join(e.data,'.deployed'),'{}\n');
|
||||
await ssoConfig(e);
|
||||
const sso=['--port',e.port,'--business','acme','--sso','--send'];
|
||||
await viaSystem(e,'activate',C1,sso);
|
||||
assert.deepEqual([pointer(e).copy,pointer(e).console],[C1,sso]);
|
||||
let r=await prun(e,'status','acme');assert.equal(r.code,0,r.err);assert.match(r.out,/unit flags: match the pointer/);
|
||||
// B's loader refuses the console key, so a rollback onto it drops console.sso first; the other signs stay.
|
||||
setKey(e,undefined);
|
||||
await auu(e,['rollback','acme'],'rollback',`${join(dirname(e.cfg),'accounts.json')} is present`+AUU_TAIL('rollback'));
|
||||
await viaSystem(e,'rollback',B,base);
|
||||
assert.deepEqual([pointer(e).copy,pointer(e).console],[B,base]);
|
||||
r=await prun(e,'status','acme');assert.equal(r.code,0,r.err);assert.match(r.out,/unit flags: match the pointer/);
|
||||
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
|
||||
@@ -2009,6 +2124,7 @@ function rHost(){
|
||||
}
|
||||
if(a[0]!=='bus'||a[1]!=='start'||!a.slice(3).every((f)=>flags.includes(f))){console.error(`fixture host: ${a.join(' ')}`);process.exit(4)}
|
||||
fs.appendFileSync(path.join(home,'host-args.log'),`${a.join(' ')}\n`);
|
||||
fs.appendFileSync(path.join(home,'host-ca.log'),`${process.env.NODE_EXTRA_CA_CERTS??'none'}\n`);
|
||||
if(fault(`host-fails-${id}`)){console.error('fixture host: refused');process.exit(1)}
|
||||
const b=a[2],off=a.includes('--no-launch'),lock=path.join(data,'bus','writer.lock'),at=new Date().toISOString();
|
||||
fs.writeFileSync(lock,String(process.pid),{flag:'wx'});
|
||||
@@ -2029,14 +2145,39 @@ function rHost(){
|
||||
}
|
||||
|
||||
// The Console: serve on the given loopback port; /healthz answers 200 unless healthz-<copy> is set.
|
||||
function rConsole(){
|
||||
// M3 (#1873): under --sso it runs its copy's real createSso on the scratch console.sso, with the
|
||||
// scratch slot placeholders, as the real Console does, and /healthz carries sso.health(). Faults:
|
||||
// console-hold waits for $HOME/release before it starts; console-no-sso reports every provider
|
||||
// ready without asking any; console-asks-token POSTs to provider a's token endpoint, then asks its
|
||||
// authorize endpoint with a query, once each.
|
||||
async function rConsole(){
|
||||
const fs=process.getBuiltinModule('node:fs'),path=process.getBuiltinModule('node:path'),http=process.getBuiltinModule('node:http');
|
||||
const id=path.basename(path.resolve(import.meta.dirname,'..','..','..')),home=process.env.HOME,a=process.argv.slice(2);
|
||||
const fault=(n)=>fs.existsSync(path.join(home,'faults',n));
|
||||
const c=JSON.parse(fs.readFileSync(process.env.MOSAIC_CONFIG,'utf8'));
|
||||
fs.appendFileSync(path.join(home,'console-args.log'),`${a.join(' ')}\n`);
|
||||
fs.appendFileSync(path.join(home,'console-context.jsonl'),JSON.stringify({argv:a,dataRoot:JSON.parse(fs.readFileSync(process.env.MOSAIC_CONFIG,'utf8')).dataRoot})+'\n');
|
||||
fs.appendFileSync(path.join(home,'console-context.jsonl'),JSON.stringify({argv:a,dataRoot:c.dataRoot,ca:process.env.NODE_EXTRA_CA_CERTS??null})+'\n');
|
||||
if(a[0]!=='serve'){console.error('fixture Console: refused');process.exit(1)}
|
||||
const s=http.createServer((q,r)=>{r.writeHead(q.url==='/healthz'&&!fault(`healthz-${id}`)?200:500);r.end()});
|
||||
while(fault('console-hold')&&!fs.existsSync(path.join(home,'release')))await new Promise((r)=>setTimeout(r,50));
|
||||
let health=null;
|
||||
if(a.includes('--sso')){
|
||||
const ps=c.console.sso.providers;
|
||||
if(fault('console-no-sso'))health=()=>Object.fromEntries(ps.map((p)=>[p.id,'ready']));
|
||||
else{
|
||||
const {createSso}=await import(path.join(import.meta.dirname,'sso.mjs'));
|
||||
const secrets=new Map(ps.map((p)=>[p.id,fs.readFileSync(path.join(path.dirname(process.env.MOSAIC_CONFIG),'secrets','mosaic-stack',p.clientSecret.slot),'utf8')]));
|
||||
let sso;try{sso=await createSso(c.console.sso,{secrets,log:()=>{}})}catch(err){console.error(`fixture Console: ${err.message}`);process.exit(2)}
|
||||
health=()=>sso.health();
|
||||
}
|
||||
if(fault('console-asks-token')){
|
||||
await fetch(`${ps[0].issuer}token`,{method:'POST',headers:{'content-type':'application/x-www-form-urlencoded'},body:'grant_type=authorization_code&code=x'}).catch(()=>{});
|
||||
await fetch(`${ps[0].issuer}authorize?client_id=rehearsal&response_type=code`).catch(()=>{});
|
||||
}
|
||||
}
|
||||
const s=http.createServer((q,r)=>{
|
||||
const ok=q.url==='/healthz'&&!fault(`healthz-${id}`);r.writeHead(ok?200:500,{'content-type':'application/json'});
|
||||
r.end(ok&&health?JSON.stringify({ok:true,sso:health()}):'');
|
||||
});
|
||||
s.listen(Number(a[a.indexOf('--port')+1]),'127.0.0.1',()=>console.log(`Mosaic Console: http://127.0.0.1:${s.address().port}/\nBoard: fixture`));
|
||||
process.on('SIGTERM',()=>process.exit(0));
|
||||
}
|
||||
@@ -2079,6 +2220,24 @@ for(const [id,v,froms] of [[B,'3g',['3f']],[C,'3h',['3f','3g']],[A,'3g',['3f']],
|
||||
for(const [f,text] of [['packages/cli/src/cli.mjs',id===A?'// fixture host from before row 163\nprocess.exit(1);\n':script(rHost)],['packages/webui/src/cli.mjs',script(rConsole)],['packages/bus/src/store.mjs',rStore(v,froms)],['packages/bus/src/migrate-schema.mjs',script(rMigrate)],['packages/bus/src/broker.mjs','export class BusError extends Error { constructor(code) { super(code); this.code = code; } }\nexport function requestArchived() { return false; }\n']]){mkdirSync(dirname(join(d,f)),{recursive:true});writeFileSync(join(d,f),text)}
|
||||
execFileSync('chmod',['-R','a-w',d]);
|
||||
}
|
||||
// M3 (#1873): SCOPIES holds B as a stamped copy: RCOPIES's B with a read-only .stack-build.json
|
||||
// naming its commit, the Console's sso-config.mjs the config loader needs for a console key, its
|
||||
// sso.mjs the stand-in Console runs, and the deployment.mjs whose writeDeployment the rehearsal runs.
|
||||
const SCOPIES=join(base,'scopies');mkdirSync(SCOPIES);
|
||||
{
|
||||
const d=join(SCOPIES,B);cpSync(join(RCOPIES,B),d,{recursive:true});execFileSync('chmod',['-R','u+w',d]);
|
||||
for(const p of ['packages/webui/src/sso-config.mjs','packages/webui/src/sso.mjs','packages/cli/src/deployment.mjs'])cpSync(join(repo,p),join(d,p));
|
||||
// sso.mjs imports oauth4webapi.
|
||||
symlinkSync(join(ROOT,'node_modules'),join(d,'node_modules'));
|
||||
writeFileSync(join(d,'.stack-build.json'),JSON.stringify({commit:B}),{mode:0o444});
|
||||
execFileSync('chmod',['-R','a-w',d]);
|
||||
}
|
||||
// UCOPIES holds B unstamped but able to load a console key: SCOPIES's B without .stack-build.json.
|
||||
const UCOPIES=join(base,'ucopies');mkdirSync(UCOPIES);
|
||||
{
|
||||
const d=join(UCOPIES,B);cpSync(join(SCOPIES,B),d,{recursive:true,verbatimSymlinks:true});execFileSync('chmod',['-R','u+w',d]);
|
||||
rmSync(join(d,'.stack-build.json'));execFileSync('chmod',['-R','a-w',d]);
|
||||
}
|
||||
|
||||
const TESTER='7499';
|
||||
// Vikunja #782: this leases a port, closes it and hands the number to `stack-release.sh rehearse`, which
|
||||
@@ -2091,12 +2250,13 @@ const rfault=(e,name)=>{mkdirSync(rh(e,'faults'),{recursive:true});writeFileSync
|
||||
|
||||
// A root for one rehearsal: a stopped live store at `store`, a pointer by hand on `copy` with the
|
||||
// Console on e.port and the tester listener on TESTER, and a backup as backup_store writes one.
|
||||
// A rehearsal's leftovers, found by their scratch MOSAIC_CONFIG, are killed after the test.
|
||||
// A rehearsal's leftovers, found by their scratch MOSAIC_CONFIG or a rehearsal path in their command
|
||||
// line (the rehearsal provider), are killed after the test.
|
||||
async function rmk(t,{copy=B,store='3g'}={}){
|
||||
const e=await mk(t);
|
||||
t.after(()=>{
|
||||
for(const pid of readdirSync('/proc').filter((n)=>/^\d+$/.test(n)))
|
||||
try{if(readFileSync(`/proc/${pid}/environ`,'utf8').includes(`MOSAIC_CONFIG=${e.data}/rehearsals/`))process.kill(Number(pid),'SIGKILL')}catch{}
|
||||
try{if(readFileSync(`/proc/${pid}/environ`,'utf8').includes(`MOSAIC_CONFIG=${e.data}/rehearsals/`)||readFileSync(`/proc/${pid}/cmdline`,'utf8').includes(`${e.data}/rehearsals/`))process.kill(Number(pid),'SIGKILL')}catch{}
|
||||
});
|
||||
const db=new DatabaseSync(e.store);
|
||||
db.exec('PRAGMA journal_mode=WAL');
|
||||
@@ -2121,7 +2281,7 @@ async function rmk(t,{copy=B,store='3g'}={}){
|
||||
const untouched=(e)=>JSON.stringify([...[e.store,e.cfg,e.biz,e.pointer].map((f)=>[f,sha(f)]),existsSync(e.log),...readdirSync(e.backup,{recursive:true}).sort().map((f)=>join(e.backup,f)).filter((f)=>statSync(f).isFile()).map((f)=>[f,sha(f)])]);
|
||||
|
||||
async function rehearse(e,id,...args){
|
||||
const ch=spawn('bash',[e.sr??SR,'rehearse',id,'acme',...args,'--copies',RCOPIES,'--bin',e.bin],{env:e.env,stdio:['ignore','pipe','pipe']});
|
||||
const ch=spawn('bash',[e.sr??SR,'rehearse',id,'acme',...args,'--copies',e.copies??RCOPIES,'--bin',e.bin],{env:e.env,stdio:['ignore','pipe','pipe']});
|
||||
let out='',err='';ch.stdout.on('data',(d)=>{out+=d});ch.stderr.on('data',(d)=>{err+=d});
|
||||
e.during?.(ch);
|
||||
const timer=setTimeout(()=>ch.kill('SIGKILL'),120000);
|
||||
@@ -2137,7 +2297,7 @@ function ownScript(e,from,to){
|
||||
const s=readFileSync(e.sr,'utf8');assert.ok(s.includes(from),`${e.sr} has no ${from}`);writeFileSync(e.sr,s.replace(from,()=>to));
|
||||
}
|
||||
const rDirs=(e)=>existsSync(join(e.data,'rehearsals'))?readdirSync(join(e.data,'rehearsals')):[];
|
||||
const PRE=['PASS copy','PASS port','PASS backup','PASS scratch-root','PASS data-root-live','PASS sessions-live','PASS data-root-scratch','PASS notify-off','PASS tracker-configured','PASS credential-ref-live'];
|
||||
const PRE=['PASS copy','PASS port','PASS backup','PASS scratch-root','PASS data-root-live','PASS sessions-live','PASS data-root-scratch','PASS notify-off','PASS tracker-configured','PASS credential-ref-live','PASS sso-isolated'];
|
||||
const POST=['PASS console-stopped','PASS host-stopped','PASS host-released-store','PASS backup-unchanged','PASS live-config-unchanged'];
|
||||
|
||||
test('R1 rehearse passes on the same schema; the scratch root holds its files and the live files stay byte-identical',async(t)=>{
|
||||
@@ -2374,6 +2534,229 @@ test('R15 a rehearsal stopped by TERM, INT or HUP fails signal and still stops t
|
||||
}
|
||||
});
|
||||
|
||||
// --- M3 (#1873): rehearsing a stamped copy (the brief's item 1) ---
|
||||
// A stamped copy serves only with --sso, console.sso, accounts.json and a .deployed naming its
|
||||
// instance. The rehearsal gives its scratch Console all four: the live console.sso with its origin
|
||||
// on rehearsal.invalid and each issuer at the rehearsal's own provider, a placeholder per slot, a
|
||||
// copy of the live accounts.json, and a .deployed from the copy's writeDeployment. The live slot
|
||||
// has a read watch: it is never opened.
|
||||
const ACCOUNTS={accountsVersion:1,revision:1,users:[{id:'u-000000000001',subjects:[{provider:'a',sub:'subject-1'}],name:'Jason',instanceRole:'operator',state:'active',authGen:1,created:'2026-11-10T00:00:00Z'}],memberships:[],invites:[]};
|
||||
async function smk(t,{sso=true,accounts=true}={}){
|
||||
const e=await rmk(t);e.copies=SCOPIES;
|
||||
if(sso)await ssoConfig(e);
|
||||
e.accounts=join(dirname(e.cfg),'accounts.json');
|
||||
if(accounts)writeFileSync(e.accounts,JSON.stringify(ACCOUNTS),{mode:0o600});
|
||||
return e;
|
||||
}
|
||||
const SPRE=[...PRE.slice(0,3),'PASS provider-start',PRE[3],'PASS deployment',...PRE.slice(4)];
|
||||
const SPOST=['PASS console-stopped','PASS host-stopped','PASS provider-stopped',...POST.slice(2)];
|
||||
// The rehearsal provider's origin and pid, from its provider-start line.
|
||||
const provOf=(out)=>{const m=/^PASS provider-start: pid (\d+) at (https:\/\/127\.0\.0\.1:\d+): /m.exec(out);return m?{pid:Number(m[1]),origin:m[2]}:null};
|
||||
// A real stamped copy of this checkout, as deployment-boundary.test.mjs builds one.
|
||||
function stampedCopy(t){
|
||||
const sha40='a'.repeat(40),root=mkdtempSync(join(tmpdir(),'rs-'));t.after(()=>rmSync(root,{recursive:true,force:true}));
|
||||
const copy=join(root,sha40);mkdirSync(copy);
|
||||
for(const dir of ['packages','scripts','roles','adapters'])cpSync(join(ROOT,dir),join(copy,dir),{recursive:true,filter:(q)=>!q.split('/').some((x)=>['tests','node_modules'].includes(x))});
|
||||
writeFileSync(join(copy,'package.json'),'{"type":"module"}');
|
||||
writeFileSync(join(copy,'.stack-build.json'),JSON.stringify({commit:sha40}),{mode:0o444});chmodSync(join(copy,'.stack-build.json'),0o444);
|
||||
symlinkSync(join(ROOT,'node_modules'),join(copy,'node_modules'));
|
||||
return copy;
|
||||
}
|
||||
// Starts the copy's real Console on port 0 against the rehearsal's scratch config. Resolves with
|
||||
// {code,out,err} on exit, or with the bound port once it prints its address line.
|
||||
async function realServe(t,copy,e,dir,more={}){
|
||||
const ch=spawn(process.execPath,[join(copy,'packages/webui/src/cli.mjs'),'serve','--port','0','--sso','--board','http://127.0.0.1:9'],{env:{HOME:e.env.HOME,PATH:e.env.PATH,TMPDIR:e.env.TMPDIR,MOSAIC_CONFIG:join(dir,'config','config.json'),...more},stdio:['ignore','pipe','pipe']});
|
||||
t.after(async()=>{if(ch.exitCode===null&&ch.signalCode===null){const done=once(ch,'close');ch.kill('SIGKILL');await done}});
|
||||
let out='',err='';ch.stderr.on('data',(d)=>{err+=d});
|
||||
return new Promise((res)=>{
|
||||
const timer=setTimeout(()=>{ch.kill('SIGKILL');res({code:'timeout',out,err})},20000);
|
||||
ch.stdout.on('data',(d)=>{out+=d;const m=/Mosaic Console: http:\/\/127\.0\.0\.1:(\d+)\//.exec(out);if(m&&out.includes('Ctrl-C')){clearTimeout(timer);res({port:Number(m[1]),out,err,ch})}});
|
||||
ch.on('close',(code)=>{clearTimeout(timer);res({code,out,err})});
|
||||
});
|
||||
}
|
||||
|
||||
test('M3 R16 a stamped copy rehearses with --sso against its own provider, a scratch accounts copy and its own .deployed; the live files stay as they were',async(t)=>{
|
||||
const st=(f)=>{const x=statSync(f);return [x.ino,x.size,x.mtimeMs,x.mode]};
|
||||
const e=await smk(t),before=untouched(e),accounts=sha(e.accounts),slot=st(slotPath(e)),port=await freePort();
|
||||
const r=await rehearse(e,B,'--backup',e.act,'--port',port,'--timeout','10');
|
||||
assert.equal(r.code,0,r.out+r.err);
|
||||
assert.deepEqual(r.names,[...SPRE,'PASS migration','PASS gate','PASS no-session','PASS provider-discovery',...SPOST]);
|
||||
const R=r.dir,prov=provOf(r.out);assert.ok(prov,r.out);
|
||||
assert.ok(r.out.includes(`PASS provider-start: pid ${prov.pid} at ${prov.origin}: discovery and a JWKS from keys made for this run, no code and no token; its certificate ${join(R,'provider','cert.pem')} is the trust the scratch Console alone gets\n`),r.out);
|
||||
assert.ok(r.out.includes(`PASS deployment: ${B} is stamped: ${join(R,'data','.deployed')} from its writeDeployment, the live accounts.json copied to ${join(R,'config')}, the Console starts with --sso\n`),r.out);
|
||||
assert.ok(r.out.includes(`PASS sso-isolated: 1 provider(s), each at the rehearsal provider ${prov.origin} with a placeholder secret\n`),r.out);
|
||||
assert.ok(r.out.includes(`PASS gate: host-up, bus-status, console-up, healthz, sso-ready and copy-match on ${B}\n`),r.out);
|
||||
assert.ok(r.out.includes(`PASS provider-discovery: the Console fetched each provider's discovery and JWKS from ${prov.origin} and asked for no code or token\n`),r.out);
|
||||
// The Console started with --sso, only on the scratch config, and alone trusts the provider's
|
||||
// certificate; the host gets no added trust.
|
||||
assert.deepEqual(lines(rh(e,'console-args.log')),[`serve --port ${port} --business acme --sso`]);
|
||||
assert.deepEqual(lines(rh(e,'console-context.jsonl')).map((l)=>JSON.parse(l).ca),[join(R,'provider','cert.pem')]);
|
||||
assert.deepEqual(lines(rh(e,'host-ca.log')),['none']);
|
||||
// The provider served discovery and the JWKS, got no other request, kept no TLS key and is gone.
|
||||
assert.deepEqual(lines(join(R,'provider','requests.log')).sort(),['GET /a/.well-known/openid-configuration 200','GET /a/jwks 200']);
|
||||
assert.deepEqual(readdirSync(join(R,'provider')).sort(),['cert.pem','origin','provider.log','requests.log']);
|
||||
assert.equal(statSync(join(R,'provider')).mode&0o777,0o700);
|
||||
assert.equal(existsSync(`/proc/${prov.pid}`),false,`provider pid ${prov.pid} is still there`);
|
||||
// The scratch config: the live one with dataRoot, origin and issuer moved; provider id, client id,
|
||||
// algorithms, slot name and subjects as they were. No subject is added.
|
||||
const live=JSON.parse(readFileSync(e.cfg,'utf8')),sc=JSON.parse(readFileSync(join(R,'config','config.json'),'utf8'));
|
||||
const want=structuredClone(live);want.dataRoot=join(R,'data');want.console.sso.publicOrigin='https://rehearsal.invalid';want.console.sso.providers[0].issuer=`${prov.origin}/a/`;
|
||||
assert.deepEqual(sc,want);assert.deepEqual(sc.console.sso.providers[0].subjects,SSO_KEY.sso.providers[0].subjects);
|
||||
// The slot is a scratch placeholder, 0600 in a 0700 directory.
|
||||
const ph=join(R,'config','secrets','mosaic-stack',SLOT);
|
||||
assert.equal(readFileSync(ph,'utf8'),'rehearsal-placeholder-secret\n');assert.equal(statSync(ph).mode&0o777,0o600);
|
||||
assert.equal(statSync(dirname(ph)).mode&0o777,0o700);assert.deepEqual(readdirSync(dirname(ph)),[SLOT]);
|
||||
// accounts.json is the live file's bytes; .deployed names the instance the scratch config dir gives.
|
||||
assert.equal(readFileSync(join(R,'config','accounts.json'),'utf8'),readFileSync(e.accounts,'utf8'));
|
||||
const dep=join(R,'data','.deployed');
|
||||
assert.deepEqual(JSON.parse(readFileSync(dep,'utf8')),{instance:'dev'});assert.equal(statSync(dep).mode&0o777,0o444);
|
||||
// The live store, config, business, pointer, backup, accounts and slot are as they were; the run
|
||||
// never opened the live slot and its sentinel is nowhere under the data root.
|
||||
assert.equal(untouched(e),before);assert.equal(sha(e.accounts),accounts);assert.deepEqual(st(slotPath(e)),slot);
|
||||
assert.ok(readFileSync(join(R,'live.before'),'utf8').includes(e.accounts),'the end check sums the live accounts.json');
|
||||
await noLeak(e);
|
||||
});
|
||||
|
||||
test('M3 R17 the real stamped Console finds the rehearsal provider ready with the scratch prerequisites, starts degraded once it is gone, and refuses without .deployed or accounts.json',async(t)=>{
|
||||
const e=await smk(t);rfault(e,'console-hold');
|
||||
const copy=stampedCopy(t);
|
||||
// While the stand-in Console holds, the rehearsal provider is up: the real Console of a stamped
|
||||
// copy passes every startup check (stamp, --sso, .deployed, accounts, console.sso and its slot)
|
||||
// and, trusting only the provider's certificate, finds provider a ready.
|
||||
let during=null;
|
||||
e.during=async()=>{
|
||||
try{
|
||||
for(let i=0;i<400&&!existsSync(rh(e,'console-args.log'));i++)await new Promise((r)=>setTimeout(r,50));
|
||||
const dir=join(e.data,'rehearsals',rDirs(e)[0]);
|
||||
const up=await realServe(t,copy,e,dir,{NODE_EXTRA_CA_CERTS:join(dir,'provider','cert.pem')});
|
||||
during={up:up.port?await (await fetch(`http://127.0.0.1:${up.port}/healthz`)).json():null,why:`${up.code} ${up.out} ${up.err}`};
|
||||
if(up.ch){const done=once(up.ch,'close');up.ch.kill('SIGTERM');await done}
|
||||
}finally{writeFileSync(rh(e,'release'),'')}
|
||||
};
|
||||
const r=await rehearse(e,B,'--backup',e.act,'--port',await freePort(),'--timeout','30');
|
||||
assert.equal(r.code,0,r.out+r.err);
|
||||
assert.deepEqual(during?.up,{ok:true,sso:{a:'ready'}},during?.why);
|
||||
// After the rehearsal its provider is gone: the same Console serves degraded and /healthz says so.
|
||||
const up=await realServe(t,copy,e,r.dir);
|
||||
assert.ok(up.port,`${up.code} ${up.out} ${up.err}`);
|
||||
const h=await fetch(`http://127.0.0.1:${up.port}/healthz`);
|
||||
assert.equal(h.status,200);assert.deepEqual(await h.json(),{ok:true,sso:{a:'unreachable'}});
|
||||
const done=once(up.ch,'close');up.ch.kill('SIGTERM');await done;
|
||||
// The same start without each prerequisite refuses before it listens.
|
||||
const dep=join(r.dir,'data','.deployed'),acc=join(r.dir,'config','accounts.json'),keep=readFileSync(dep);
|
||||
rmSync(dep);
|
||||
let q=await realServe(t,copy,e,r.dir);assert.equal(q.code,2,q.out+q.err);assert.match(q.err,/\.deployed is missing or invalid/);assert.doesNotMatch(q.out,/Mosaic Console:/);
|
||||
writeFileSync(dep,keep,{mode:0o444});rmSync(acc);
|
||||
q=await realServe(t,copy,e,r.dir);assert.equal(q.code,2,q.out+q.err);assert.match(q.err,/accounts/);assert.doesNotMatch(q.out,/Mosaic Console:/);
|
||||
});
|
||||
|
||||
test('M3 R18 a stamped copy refuses sso-not-prepared without a live console.sso and accounts-not-initialized without a live accounts.json, before any rehearsal directory',async(t)=>{
|
||||
for(const [opts,code,msg] of [
|
||||
[{sso:false},'sso-not-prepared',(e)=>`${B} is a stamped copy, which serves only with --sso, and ${e.cfg} has no console.sso; prepare SSO for this instance (run sheet) before re-pinning to it`],
|
||||
[{accounts:false},'accounts-not-initialized',(e)=>`${B} is a stamped copy, which needs an accounts file, and there is no ${e.accounts}; a human runs mosaic accounts init (run sheet) before re-pinning to it`],
|
||||
]){
|
||||
const e=await smk(t,opts),before=untouched(e);
|
||||
const r=await rehearse(e,B,'--backup',e.act,'--port',await freePort(),'--timeout','3');
|
||||
assert.equal(r.code,1,code+r.out+r.err);
|
||||
assert.ok(r.out.includes(`FAIL ${code}: ${msg(e)}\nREHEARSAL_FAIL\n`),r.out);
|
||||
assert.deepEqual(r.names.filter((n)=>n.startsWith('FAIL')),[`FAIL ${code}`]);
|
||||
assert.deepEqual(rDirs(e),[]);assert.equal(existsSync(rh(e,'host-args.log')),false);assert.equal(existsSync(rh(e,'console-args.log')),false);
|
||||
assert.equal(existsSync(e.accounts),code!=='accounts-not-initialized');
|
||||
assert.equal(untouched(e),before);
|
||||
}
|
||||
// An unstamped copy needs neither: B from RCOPIES rehearses without console.sso or accounts.json,
|
||||
// and its Console gets no --sso.
|
||||
const u=await rmk(t),port=await freePort();
|
||||
const r=await rehearse(u,B,'--backup',u.act,'--port',port,'--timeout','10');
|
||||
assert.equal(r.code,0,r.out+r.err);assert.ok(!r.names.includes('PASS deployment'));
|
||||
assert.ok(r.out.includes('PASS sso-isolated: no console.sso\n'),r.out);
|
||||
assert.deepEqual(lines(rh(u,'console-args.log')),[`serve --port ${port} --business acme`]);
|
||||
assert.equal(existsSync(join(r.dir,'data','.deployed')),false);
|
||||
});
|
||||
|
||||
test('M3 R19 sso-isolated fails before the host starts when the scratch origin, an issuer or a slot is not the rehearsal\'s',async(t)=>{
|
||||
// stamped: the issuer must be the rehearsal provider's; unstamped (no provider): on rehearsal.invalid.
|
||||
for(const [stamped,from,to,why] of [
|
||||
[true,'sso.publicOrigin="https://rehearsal.invalid";','','console.sso.publicOrigin is https://console.example.test, not https://rehearsal.invalid'],
|
||||
[true,'p.issuer=`${op||','p.issuer=p.issuer||`${op||','console.sso provider a issuer is https://op.example.test/, not ORIGIN/a/ at the rehearsal provider'],
|
||||
[false,'p.issuer=`${op||"https://rehearsal.invalid"}','p.issuer=`${op||"https://op.example.test"}','console.sso provider a issuer is https://op.example.test/a, not on rehearsal.invalid'],
|
||||
[true,'"rehearsal-placeholder-secret\\n",{mode:0o600,flag:"wx"}','"other\\n",{mode:0o600,flag:"wx"}','console.sso provider a has no scratch placeholder secret'],
|
||||
]){
|
||||
let e;if(stamped)e=await smk(t);else{e=await rmk(t);e.copies=UCOPIES;await ssoConfig(e)}
|
||||
ownScript(e,from,to);
|
||||
const r=await rehearse(e,B,'--backup',e.act,'--port',await freePort(),'--timeout','3');
|
||||
assert.equal(r.code,1,why+r.out+r.err);
|
||||
const prov=provOf(r.out);assert.equal(!!prov,stamped,r.out);
|
||||
assert.ok(r.out.includes(`FAIL sso-isolated: ${why.replace('ORIGIN',prov?.origin)}\n`),r.out);
|
||||
assert.deepEqual(r.names.slice(stamped?-4:-3),['FAIL sso-isolated',...(stamped?['PASS provider-stopped']:[]),'PASS backup-unchanged','PASS live-config-unchanged']);
|
||||
assert.equal(existsSync(rh(e,'host-args.log')),false);assert.equal(existsSync(rh(e,'console-args.log')),false);
|
||||
if(prov)assert.equal(existsSync(`/proc/${prov.pid}`),false);
|
||||
await noLeak(e);
|
||||
}
|
||||
});
|
||||
|
||||
test('M3 R20 the rehearsal provider\'s failure controls: no start, no trust, no discovery, a token request, a signal and a SIGKILL each fail and stop it',async(t)=>{
|
||||
const ends=['PASS backup-unchanged','PASS live-config-unchanged'];
|
||||
// The provider cannot start, leaves its TLS key, offers a non-loopback origin or never writes one:
|
||||
// provider-start fails before the scratch build, and no host or Console starts.
|
||||
for(const [from,to,why] of [
|
||||
['spawnSync("openssl"','spawnSync("openssl-missing-for-r20"','the rehearsal provider exited: rehearsal provider: cannot make the certificate: openssl exit ENOENT'],
|
||||
['finally{fs.rmSync(tls,{force:true})}','finally{}','the rehearsal provider left its TLS key at RDIR/provider/tls-key.pem'],
|
||||
['origin=`https://127.0.0.1:${','origin=`https://localhost:${','the rehearsal provider\'s origin https://localhost:PORT is not https on 127.0.0.1'],
|
||||
['fs.renameSync(path.join(dir,"origin.tmp"),path.join(dir,"origin"));','','the rehearsal provider wrote no origin within 3s'],
|
||||
]){
|
||||
const e=await smk(t);ownScript(e,from,to);
|
||||
const r=await rehearse(e,B,'--backup',e.act,'--port',await freePort(),'--timeout','3');
|
||||
assert.equal(r.code,1,why+r.out+r.err);assert.ok(r.dir,r.out+r.err);
|
||||
const line=r.out.split('\n').find((l)=>l.startsWith('FAIL provider-start: '))??'';
|
||||
const re=new RegExp(`^FAIL provider-start: ${why.replace(/[.*+?^${}()|[\]\\]/g,'\\$&').replace('RDIR',r.dir.replace(/[.*+?^${}()|[\]\\]/g,'\\$&')).replace('PORT','\\d+')}$`);
|
||||
assert.match(line,re,r.out);
|
||||
assert.deepEqual(r.names,[...PRE.slice(0,3),'FAIL provider-start',...(why.includes('exited')?[]:['PASS provider-stopped']),...ends],r.out);
|
||||
assert.equal(existsSync(join(r.dir,'config')),false);
|
||||
assert.equal(existsSync(rh(e,'host-args.log')),false);assert.equal(existsSync(rh(e,'console-args.log')),false);
|
||||
}
|
||||
// The Console cannot reach the provider (its discovery drops the connection, or the Console has
|
||||
// no trust for its certificate): it starts degraded and the gate fails sso-ready.
|
||||
for(const [from,to] of [
|
||||
[' send(200,{issuer,authorization_endpoint',' return q.socket.destroy();send(200,{issuer,authorization_endpoint'],
|
||||
['r_ca=(NODE_EXTRA_CA_CERTS="$R/provider/cert.pem")','r_ca=()'],
|
||||
]){
|
||||
const e=await smk(t);ownScript(e,from,to);
|
||||
const r=await rehearse(e,B,'--backup',e.act,'--port',await freePort(),'--timeout','5');
|
||||
assert.equal(r.code,1,from+r.out+r.err);
|
||||
assert.ok(r.out.includes('FAIL sso-ready: provider a is unreachable, not ready\n'),r.out);
|
||||
assert.deepEqual(r.names,[...SPRE,'PASS migration','FAIL sso-ready',...SPOST],r.out);
|
||||
assert.equal(existsSync(`/proc/${provOf(r.out).pid}`),false);
|
||||
}
|
||||
// A Console that reports ready without asking the provider, or asks it for a token: the gate
|
||||
// passes and provider-discovery fails.
|
||||
for(const [f,why] of [
|
||||
['console-no-sso','no GET /a/.well-known/openid-configuration 200; no GET /a/jwks 200'],
|
||||
['console-asks-token','a request for POST /a/token 403; a request for GET /a/authorize?client_id=rehearsal&response_type=code 403'],
|
||||
]){
|
||||
const e=await smk(t);rfault(e,f);
|
||||
const r=await rehearse(e,B,'--backup',e.act,'--port',await freePort(),'--timeout','10');
|
||||
assert.equal(r.code,1,f+r.out+r.err);
|
||||
assert.ok(r.out.includes(`FAIL provider-discovery: ${why}; see ${join(r.dir,'provider','requests.log')}\n`),r.out);
|
||||
assert.deepEqual(r.names,[...SPRE,'PASS migration','PASS gate','PASS no-session','FAIL provider-discovery',...SPOST],r.out);
|
||||
}
|
||||
// A signal while the gate waits stops the Console, host and provider.
|
||||
const e=await smk(t);rfault(e,`healthz-${B}`);
|
||||
e.during=async(ch)=>{for(let i=0;i<200&&!existsSync(rh(e,'console-args.log'));i++)await new Promise((r)=>setTimeout(r,50));ch.kill('SIGTERM')};
|
||||
const r=await rehearse(e,B,'--backup',e.act,'--port',await freePort(),'--timeout','30');
|
||||
assert.equal(r.code,1,r.out+r.err);
|
||||
assert.deepEqual(r.names,[...SPRE,'PASS migration','FAIL signal',...SPOST],r.out);
|
||||
assert.equal(existsSync(`/proc/${provOf(r.out).pid}`),false);
|
||||
// SIGKILL leaves no trap to run: the provider sees its parent gone and exits by itself.
|
||||
const k=await smk(t);rfault(k,`healthz-${B}`);
|
||||
k.during=async(ch)=>{for(let i=0;i<200&&!existsSync(rh(k,'console-args.log'));i++)await new Promise((r)=>setTimeout(r,50));ch.kill('SIGKILL')};
|
||||
const rk=await rehearse(k,B,'--backup',k.act,'--port',await freePort(),'--timeout','30');
|
||||
assert.equal(rk.code,null,rk.out+rk.err);
|
||||
const pk=provOf(rk.out);assert.ok(pk,rk.out);
|
||||
for(let i=0;i<60&&existsSync(`/proc/${pk.pid}`);i++)await new Promise((r)=>setTimeout(r,100));
|
||||
assert.equal(existsSync(`/proc/${pk.pid}`),false,`provider pid ${pk.pid} outlived the killed rehearsal`);
|
||||
});
|
||||
|
||||
// --- Row 233 (#1735): --backup last, the newest backup the log records (sage-1650, sage-1653) ---
|
||||
|
||||
// The log's lines, as the runs write them, with only the fields from_log reads set.
|
||||
@@ -2601,7 +2984,8 @@ test('row196 explicit socket conversion refuses inherited TCP before stop and ro
|
||||
assert.equal(r.code,3,r.out+r.err);assert.match(r.err,/tester-socket-path/);assert.deepEqual(startsStops(long,before),[]);
|
||||
});
|
||||
|
||||
// Row196 merge guard: without --send, SSO insertion must still precede the socket flag.
|
||||
// Row196 merge guard, M3 (#1873): a user-unit --sso --tester-socket refuses accounts-user-unit; root's
|
||||
// unit for the copy carries --sso before the socket flag, and the pointer and status pin that order.
|
||||
test('row196 SSO socket conversion pins pointer and unit argv order without send',async t=>{
|
||||
const e=await mk(t);await seedPreview(e,{copy:B});await ssoConfig(e);
|
||||
const file=join(COPIES,C1,'packages/webui/src/cli.mjs'),original=readFileSync(file,'utf8');
|
||||
@@ -2611,8 +2995,9 @@ test('row196 SSO socket conversion pins pointer and unit argv order without send
|
||||
chmodSync(dirname(module),0o755);
|
||||
writeFileSync(module,'export const inspectInterpreter=()=>({path:"/usr/bin/python3",sha256:"fixture"});export const verifyInterpreterReceipt=()=>true;\n');chmodSync(module,0o444);chmodSync(dirname(module),0o555);
|
||||
t.after(()=>{chmodSync(dirname(module),0o755);rmSync(module);chmodSync(dirname(module),0o555);});
|
||||
const r=await prun(e,'activate',C1,'acme','--sso','--tester-socket');assert.equal(r.code,0,r.out+r.err);
|
||||
await auu(e,['activate',C1,'acme','--sso','--tester-socket'],'activate');
|
||||
const want=['--port',e.port,'--business','acme','--sso','--tester-socket'];
|
||||
await viaSystem(e,'activate',C1,want);
|
||||
assert.deepEqual(pointer(e).console,want);
|
||||
assert.ok(conExec(e).endsWith(` --checkout ${repo} ${want.join(' ')}`),conExec(e));
|
||||
assert.doesNotMatch(conExec(e),/ --send(?: |$)|--tester-port|--public-host|--proof-header/);
|
||||
|
||||
@@ -47,12 +47,19 @@ function fakeSudo(K){
|
||||
|
||||
// case2-unit.sh: logs its argv, then installs the copy's units as the fixture's user units (its
|
||||
// WorkingDirectory under the stack's copies) and starts the optional proxy, bus, then Console,
|
||||
// as case2-unit.sh does. The business is --business's value, or --org's when that is the spelling. faults/render-fails-<copy> exits 1 first; faults/render-probe-<copy> (one-shot) first runs
|
||||
// as case2-unit.sh does. The business is --business's value, or --org's when that is the spelling. A first
|
||||
// --check or --check-new (#1873) logs its argv and exits 1 under faults/check-fails-<copy>, else 0,
|
||||
// writing nothing. faults/render-fails-<copy> exits 1 first; faults/render-probe-<copy> (one-shot) first runs
|
||||
// probe.sh, a second driver, and records its exit and stderr.
|
||||
function fakeRender(K){
|
||||
const fs=process.getBuiltinModule('node:fs'),path=process.getBuiltinModule('node:path'),cp=process.getBuiltinModule('node:child_process');
|
||||
const a=process.argv.slice(2),sd=process.env.SD_STATE,f=(n)=>path.join(sd,'faults',n);
|
||||
fs.appendFileSync(path.join(sd,'render.log'),JSON.stringify(a)+'\n');
|
||||
if(a[0]==='--check'||a[0]==='--check-new'){
|
||||
const c=a[a.indexOf('--copy')+1];
|
||||
if(fs.existsSync(f(`check-fails-${c}`))){console.error(`fixture render: ${a[0]} ${c} fails`);process.exit(1)}
|
||||
process.exit(0);
|
||||
}
|
||||
const cut=a.indexOf('--'),pre=a.slice(0,cut),serve=a.slice(cut+1),get=(k)=>pre[pre.indexOf(k)+1];
|
||||
const copy=get('--copy'),copies=path.join(get('--home'),'.local','share','mosaic-stack','copies');
|
||||
if(fs.existsSync(f(`render-probe-${copy}`))){
|
||||
@@ -147,7 +154,7 @@ const arm=(e,key,text)=>writeFileSync(join(e.sd,'arm',key),text);
|
||||
|
||||
// The step log: every line `<time> <step> rc=N ...` or the result line, root:root 0640 (here the
|
||||
// test user's), and nothing the stack's user printed. Returns the lines without the time.
|
||||
const STEP=/^\d{4}-\d\d-\d\dT\d\d:\d\d:\d\dZ (start|fetch|ff|copy|hostcheck|units|plan|rehearse|rehearse-store|rehearsal-skipped|stop-console|stop-bus|stop-proxy|wait-down|stage|render|confirm|args|revert|render-old|confirm-rollback|gate|start-proxy|start-bus|start-console|stop-units) rc=\d+ |^\d{4}-\d\d-\d\dT\d\d:\d\d:\d\dZ result exit=[0-35] [a-z-]+ /;
|
||||
const STEP=/^\d{4}-\d\d-\d\dT\d\d:\d\d:\d\dZ (start|fetch|ff|copy|hostcheck|units|plan|rehearse|rehearse-store|rehearsal-skipped|render-old-check|render-check|stop-console|stop-bus|stop-proxy|wait-down|units-aside|units-back|daemon-reload|next-args|stage|render|confirm|args|revert|render-old|confirm-rollback|gate|start-proxy|start-bus|start-console|stop-units) rc=\d+ |^\d{4}-\d\d-\d\dT\d\d:\d\d:\d\dZ result exit=[0-35] [a-z-]+ /;
|
||||
function slog(e){
|
||||
const l=lines(e.slog);
|
||||
for(const x of l){assert.match(x,STEP);assert.ok(!/stack-release:|fixture rehearse|PASS |FAIL |REHEARSAL/.test(x),x)}
|
||||
@@ -165,7 +172,7 @@ const live=(e)=>[units(e)[e.bus].state,units(e)[e.con].state,units(e)[e.bus].wd.
|
||||
// Nothing the run did reached the args file: the same bytes and mode, and no temporary file left.
|
||||
const argsKept=(e)=>{assert.equal(readFileSync(e.args,'utf8'),e.argsText);assert.equal(statSync(e.args).mode&0o777,0o640);assert.deepEqual(readdirSync(e.etc),['dev.args'])};
|
||||
const argsCopy=(e)=>{const l=readFileSync(e.args,'utf8').split('\n');return l[l.indexOf('--copy')+1]};
|
||||
const PRE=['start','fetch','ff','copy','hostcheck','units','plan','rehearse','rehearse-store'];
|
||||
const PRE=['start','fetch','ff','copy','hostcheck','units','plan','render-old-check','render-check','rehearse','rehearse-store'];
|
||||
const A2=['stop-console','stop-bus','wait-down','wait-down'];
|
||||
const R=[...A2,'revert','render-old','confirm-rollback'];
|
||||
|
||||
@@ -394,7 +401,7 @@ test('row 233: sequence 7: two upgrades and a rollback --to the previous copy wi
|
||||
const rb=await drive(e,C,'rollback','--instance','dev','--to',C1,'--restore',a2,...TO);assert.equal(rb.code,0,rb.all);
|
||||
assert.equal(rb.out.split('\n').at(-2),`stack-upgrade: rollback ok: ${C1} is live for acme`);
|
||||
// A rollback fetches, copies and rehearses nothing.
|
||||
assert.deepEqual(names(e).slice(n),['start','hostcheck','units','plan',...A2,'stage','render','confirm','args','result']);
|
||||
assert.deepEqual(names(e).slice(n),['start','hostcheck','units','plan','render-old-check','render-check',...A2,'stage','render','confirm','args','result']);
|
||||
const ra=lastAct(e),l3=final(e,ra);assert.deepEqual([l3.op,l3.result,l3.from,l3.to,l3.backup],['rollback','ok',C,C1,a2]);
|
||||
assert.deepEqual([pointer(e).copy,pointer(e).schema],[C1,'3g']);assert.equal(storeVersion(e.store,true),'3g');
|
||||
assert.equal(argsCopy(e),C1);assert.deepEqual(live(e),['active','active',C1,C1]);
|
||||
@@ -632,7 +639,9 @@ test('rename r2: an upgrade from an args file that spells both selectors --org k
|
||||
const after=e.argsText.replace(`--copy\n${B}\n`,`--copy\n${C1}\n`);assert.notEqual(after,e.argsText);
|
||||
assert.equal(readFileSync(e.args,'utf8'),after);
|
||||
assert.equal(statSync(e.args).mode&0o777,0o640);assert.deepEqual(readdirSync(e.etc),['dev.args']);
|
||||
assert.deepEqual(lines(join(e.sd,'render.log')).map((x)=>JSON.parse(x)),[after.trimEnd().split('\n')]);
|
||||
// #1873: before anything stops, --check renders the installed copy, then the target, from the same lines.
|
||||
const al=after.trimEnd().split('\n');
|
||||
assert.deepEqual(lines(join(e.sd,'render.log')).map((x)=>JSON.parse(x)),[['--check',...e.argsText.trimEnd().split('\n')],['--check',...al],al]);
|
||||
// The stack's user's steps get the business as a word, never a spelling.
|
||||
for(const k of ['activate-plan','rehearse','activate','confirm'])assert.ok(JSON.parse(lines(join(e.sd,'sudo.log')).find((x)=>JSON.parse(x).key===k)).cmd.includes('acme'),k);
|
||||
assert.ok(!lines(join(e.sd,'sudo.log')).some((x)=>JSON.parse(x).cmd.includes('--org')));
|
||||
@@ -718,12 +727,25 @@ const baseCase2=()=>{
|
||||
// read. args(copy, extra) is a library args file's lines without --send; legacy() renders base's pair into dir.
|
||||
function case2Host(e,dir){
|
||||
const home=e.env.HOME,secrets=join(home,'.config','mosaic-dev','secrets');mkdirSync(secrets,{recursive:true,mode:0o700});
|
||||
copyFileSync(e.cfg,join(home,'.config','mosaic-dev','config.json'));
|
||||
const proof=join(secrets,'proxy-proof');writeFileSync(proof,'made-up-proof\n');chmodSync(proof,0o600);
|
||||
const c2=join(e.root,'case2'),bin=join(c2,'bin'),log=join(c2,'systemctl.log');mkdirSync(bin,{recursive:true});mkdirSync(dir,{recursive:true});
|
||||
const uid=process.getuid(),gid=process.getgid();
|
||||
writeFileSync(join(bin,'getent'),`#!/bin/sh\ncase "$1 $2" in\n "passwd fixture") echo "fixture:x:${uid}:${gid}::${home}:/bin/bash" ;;\n "group ${gid}") echo "fixture:x:${gid}:" ;;\n *) exit 2 ;;\nesac\n`,{mode:0o755});
|
||||
writeFileSync(join(bin,'getent'),`#!/bin/sh\ncase "$1 $2" in\n "passwd fixture"|"passwd ") echo "fixture:x:${uid}:${gid}::${home}:/usr/sbin/nologin" ;;\n "group ${gid}") echo "fixture:x:${gid}:" ;;\n *) exit 2 ;;\nesac\n`,{mode:0o755});
|
||||
writeFileSync(join(bin,'systemctl'),`#!/bin/sh\necho "$*" >> '${log}'\n[ "$1" != is-active ] || exit 3\n`,{mode:0o755});
|
||||
const env={CASE2_UNIT_DIR:dir,CASE2_GETENT:join(bin,'getent'),CASE2_SYSTEMCTL:join(bin,'systemctl')};
|
||||
// M3: the current renderer queries the config under the selected uid for every
|
||||
// profile. These copies deliberately stub the CLI; give that query its own
|
||||
// fixture loader and sudo rather than invoking the machine's sudo.
|
||||
for(const id of [B,C1,C]){
|
||||
const d=join(COPIES,id),packages=join(d,'packages'),cli=join(packages,'cli'),src=join(cli,'src');
|
||||
if(!existsSync(join(src,'config.mjs'))){
|
||||
chmodSync(d,0o755);chmodSync(packages,0o755);mkdirSync(src,{recursive:true});
|
||||
writeFileSync(join(src,'config.mjs'),`import fs from 'node:fs';import os from 'node:os';import path from 'node:path';export const loadSystem=()=>JSON.parse(fs.readFileSync(path.join(os.homedir(),'.config/mosaic-dev/config.json'),'utf8'));`);
|
||||
chmodSync(join(src,'config.mjs'),0o444);chmodSync(src,0o555);chmodSync(cli,0o555);chmodSync(packages,0o555);chmodSync(d,0o555);
|
||||
}
|
||||
}
|
||||
writeFileSync(join(bin,'sudo'),'#!/bin/sh\n[ "$1" = -u ] && [ "$2" = fixture ] && [ "$3" = -H ] || exit 97\nshift 3\nexec "$@"\n',{mode:0o755});
|
||||
const env={PATH:`${bin}:${e.env.PATH}`,CASE2_UNIT_DIR:dir,CASE2_GETENT:join(bin,'getent'),CASE2_SYSTEMCTL:join(bin,'systemctl')};
|
||||
const serve=['--port','443','--business','acme','--public-host','books.example','--auth-header','X-Auth-User','--allow-user','melanie','--proof-header','X-Proxy-Proof','--proof-file',proof];
|
||||
const args=(copy=B,extra=[])=>['--user','fixture','--home',home,'--business','acme','--copy',copy,'--node',RENDER_NODE,...extra,'--',...serve];
|
||||
const render=(script,a)=>{const r=spawnSync('bash',[script,...a],{env:{PATH:e.env.PATH,...env},encoding:'utf8'});return {code:r.status,out:r.stdout,err:r.stderr}};
|
||||
@@ -829,3 +851,101 @@ test('row 233: usage exits 4 before any log or lock',async(t)=>{
|
||||
}
|
||||
assert.ok(!existsSync(dirname(e.slog)));assert.ok(!existsSync(e.run));
|
||||
});
|
||||
|
||||
// --- #1873 M3: render checks before anything stops, and --next-args ---
|
||||
|
||||
// The installed units' bytes, for the byte-for-byte checks across a moved-aside render.
|
||||
const unitBytes=(e)=>Object.fromEntries([e.tmpl,e.con].map((u)=>[u,readFileSync(join(e.units,u),'utf8')]));
|
||||
const renders=(e)=>lines(join(e.sd,'render.log')).map((l)=>JSON.parse(l));
|
||||
const copyOf=(a)=>a[a.indexOf('--copy')+1];
|
||||
// The next args: the args file's lines with the serve flags in another order, root's 0640.
|
||||
const nextText=(e)=>e.argsText.replace(`--\n--port\n${e.port}\n--business\nacme\n`,`--\n--business\nacme\n--port\n${e.port}\n`);
|
||||
const writeNext=(e,text=nextText(e),mode=0o640)=>{const f=join(e.etc,'dev.args.next');writeFileSync(f,text);chmodSync(f,mode);return f};
|
||||
const replacedDirs=(e)=>readdirSync(e.etc).filter((n)=>/^dev\.replaced\.[A-Za-z0-9]{6}$/.test(n)).sort();
|
||||
|
||||
test('#1873: a target whose render check fails refuses render-refused, naming --next-args, before anything stops',async(t)=>{
|
||||
const e=await useed(t);
|
||||
fault(e,`check-fails-${C1}`);
|
||||
const r=await upgrade(e,C1);assert.equal(r.code,3,r.all);
|
||||
assert.deepEqual(names(e),['start','fetch','ff','copy','hostcheck','units','plan','render-old-check','render-check','result']);
|
||||
assert.match(result(e),new RegExp(`^result exit=3 render-refused case2-unit.sh --check refuses ${C1}'s units from .*dev\\.args; .*takes --next-args .*\\(nothing was stopped\\)$`));
|
||||
assert.deepEqual(renders(e).map((a)=>[a[0],copyOf(a)]),[['--check',B],['--check',C1]]);
|
||||
assert.ok(!existsSync(join(e.sd,'root-systemctl.log')));assert.deepEqual(live(e),['active','active',B,B]);argsKept(e);
|
||||
});
|
||||
|
||||
test('#1873: an installed copy whose render check fails refuses at render-old-check, before the target\'s check',async(t)=>{
|
||||
const e=await useed(t);
|
||||
fault(e,`check-fails-${B}`);
|
||||
const r=await upgrade(e,C1);assert.equal(r.code,3,r.all);
|
||||
assert.deepEqual(names(e),['start','fetch','ff','copy','hostcheck','units','plan','render-old-check','result']);
|
||||
assert.match(result(e),new RegExp(`^result exit=3 render-refused case2-unit.sh --check refuses ${B}'s units from .*dev\\.args, so a rollback could not render them`));
|
||||
assert.deepEqual(renders(e).map((a)=>[a[0],copyOf(a)]),[['--check',B]]);
|
||||
assert.ok(!existsSync(join(e.sd,'root-systemctl.log')));assert.deepEqual(live(e),['active','active',B,B]);argsKept(e);
|
||||
});
|
||||
|
||||
test('#1873: --next-args moves the old units aside, renders the new flags as a first install, keeps the old units and args, and rolls back the same way',async(t)=>{
|
||||
const e=await useed(t);
|
||||
const before=unitBytes(e),next=nextText(e);writeNext(e);
|
||||
const r=await upgrade(e,C1,'--next-args');assert.equal(r.code,0,r.all);
|
||||
assert.deepEqual(names(e),[...PRE,...A2,'stage','units-aside','render','confirm','next-args','args','result']);
|
||||
const [d]=replacedDirs(e);assert.ok(d,readdirSync(e.etc).join(' '));
|
||||
assert.equal(result(e),`result exit=0 ok upgrade ok: ${C1} is live for acme; the replaced units and args file are in ${join(e.etc,d)}`);
|
||||
assert.deepEqual(readdirSync(e.etc).sort(),['dev.args',d]);
|
||||
assert.equal(readFileSync(e.args,'utf8'),next.replace(`--copy\n${B}\n`,`--copy\n${C1}\n`));assert.equal(statSync(e.args).mode&0o777,0o640);
|
||||
const rd=join(e.etc,d);assert.equal(statSync(rd).mode&0o777,0o700);
|
||||
assert.deepEqual(readdirSync(rd).sort(),['dev.args',e.tmpl,e.con].sort());
|
||||
for(const [u,text] of Object.entries(before))assert.equal(readFileSync(join(rd,u),'utf8'),text,u);
|
||||
assert.equal(readFileSync(join(rd,'dev.args'),'utf8'),e.argsText);assert.equal(statSync(join(rd,'dev.args')).mode&0o777,0o640);
|
||||
const serve=(a)=>a.slice(a.indexOf('--')+1);
|
||||
const rl=renders(e);assert.deepEqual(rl.map((a)=>[a[0],copyOf(a)]),[['--check',B],['--check-new',C1],['--user',C1]]);
|
||||
assert.deepEqual(serve(rl[0]),['--port',String(e.port),'--business','acme']);
|
||||
assert.deepEqual(serve(rl[1]),['--business','acme','--port',String(e.port)]);assert.deepEqual(serve(rl[2]),serve(rl[1]));
|
||||
assert.deepEqual(live(e),['active','active',C1,C1]);
|
||||
// The run sheet's rollback: the replaced args file as the next args, then rollback --next-args.
|
||||
copyFileSync(join(rd,'dev.args'),join(e.etc,'dev.args.next'));chmodSync(join(e.etc,'dev.args.next'),0o640);
|
||||
const after=unitBytes(e),m=slog(e).length,n=renders(e).length;
|
||||
const rb=await drive(e,C1,'rollback','--instance','dev','--to',B,'--next-args',...TO);assert.equal(rb.code,0,rb.all);
|
||||
assert.deepEqual(names(e).slice(m),['start','hostcheck','units','plan','render-old-check','render-check',...A2,'stage','units-aside','render','confirm','next-args','args','result']);
|
||||
assert.deepEqual(renders(e).slice(n).map((a)=>[a[0],copyOf(a)]),[['--check',C1],['--check-new',B],['--user',B]]);
|
||||
assert.equal(readFileSync(e.args,'utf8'),e.argsText);assert.deepEqual(live(e),['active','active',B,B]);
|
||||
const d2=replacedDirs(e).find((x)=>x!==d);assert.ok(d2);assert.deepEqual(readdirSync(e.etc).sort(),['dev.args',d,d2].sort());
|
||||
for(const [u,text] of Object.entries(after))assert.equal(readFileSync(join(e.etc,d2,u),'utf8'),text,u);
|
||||
assert.equal(readFileSync(join(e.etc,d2,'dev.args'),'utf8'),next.replace(`--copy\n${B}\n`,`--copy\n${C1}\n`));
|
||||
});
|
||||
|
||||
test('#1873: a failed render under --next-args moves the old units back unchanged and restarts them, with no render of the old copy: exit 1',async(t)=>{
|
||||
const e=await useed(t);
|
||||
const before=unitBytes(e),next=nextText(e);writeNext(e);
|
||||
fault(e,`render-fails-${C1}`);
|
||||
const r=await upgrade(e,C1,'--next-args');assert.equal(r.code,1,r.all);
|
||||
assert.deepEqual(names(e),[...PRE,...A2,'stage','units-aside','render',...A2,'revert','units-back','daemon-reload','start-bus','start-console','confirm-rollback','result']);
|
||||
assert.match(result(e),new RegExp(`^result exit=1 failed A4 failed; rolled back to ${B}`));
|
||||
assert.deepEqual(renders(e).map((a)=>[a[0],copyOf(a)]),[['--check',B],['--check-new',C1],['--user',C1]]);
|
||||
for(const [u,text] of Object.entries(before))assert.equal(readFileSync(join(e.units,u),'utf8'),text,u);
|
||||
assert.deepEqual(readdirSync(e.etc).sort(),['dev.args','dev.args.next']);
|
||||
assert.equal(readFileSync(e.args,'utf8'),e.argsText);assert.equal(readFileSync(join(e.etc,'dev.args.next'),'utf8'),next);
|
||||
assert.equal(final(e,lastAct(e)).rollback,'ok');assert.deepEqual(live(e),['active','active',B,B]);
|
||||
});
|
||||
|
||||
test('#1873: the next args must be root\'s, equal before -- but for --copy, differ after it, and render as a first install',async(t)=>{
|
||||
const e=await useed(t);
|
||||
const tryNext=async(text,mode,code,why,at='plan')=>{
|
||||
if(text===null)rmSync(join(e.etc,'dev.args.next'),{force:true});else writeNext(e,text,mode);
|
||||
const n=existsSync(e.slog)?slog(e).length:0;
|
||||
const r=await upgrade(e,C1,'--next-args');assert.equal(r.code,3,`${why}: ${r.all}`);
|
||||
assert.match(slog(e).at(-1),new RegExp(`^result exit=3 ${code} `),why);
|
||||
const ran=names(e).slice(n);assert.ok(!ran.some((x)=>/^(stop|stage|units-aside|render$|confirm)/.test(x)),`${why}: ${ran}`);
|
||||
if(at)assert.equal(ran.at(-2),at,`${why}: ${ran}`);
|
||||
};
|
||||
await tryNext(null,0,'args-unreadable','no next file','start');
|
||||
await tryNext(nextText(e),0o660,'args-unreadable','group write','start');
|
||||
await tryNext(nextText(e).replace('--home\n','--pm\n--home\n'),0o640,'next-args-differ','a flag before --','start');
|
||||
await tryNext(nextText(e).replace('--user\nfixture\n','--user\nother\n'),0o640,'next-args-differ','another user','start');
|
||||
await tryNext(e.argsText,0o640,'next-args-same','the same lines','start');
|
||||
await tryNext(e.argsText.replace(`--copy\n${B}\n`,`--copy\n${C}\n`),0o640,'next-args-same','only --copy differs','start');
|
||||
fault(e,`check-fails-${C1}`);
|
||||
await tryNext(nextText(e),0o640,'render-refused','the first-install check','render-check');
|
||||
assert.match(result(e),new RegExp(`^result exit=3 render-refused case2-unit.sh --check-new refuses ${C1}'s units from .*dev\\.args\\.next`));
|
||||
assert.ok(!existsSync(join(e.sd,'root-systemctl.log')));assert.deepEqual(live(e),['active','active',B,B]);
|
||||
assert.equal(readFileSync(e.args,'utf8'),e.argsText);assert.deepEqual(replacedDirs(e),[]);
|
||||
});
|
||||
@@ -226,6 +226,28 @@ design is `agents/sage/work/q175-sso/DESIGN.md`. `--sso` can't be combined
|
||||
with `--public-host`, `--auth-header`, `--allow-user` or `--proof-*`; `--send`
|
||||
requires `--sso`. Header-preview mode cannot enable the human writer.
|
||||
|
||||
Phase M3 sign-in requires an active account for account authority. Run
|
||||
`mosaic accounts init` before the first accounts sign-in; configured SSO subjects
|
||||
without accounts are refused. Legacy access.json users can still reach the root
|
||||
business under their live grants. A session records account, legacy user and root
|
||||
ownership separately. Only an active operator whose subject is configured as a
|
||||
root owner gets the root human transport; an account alone gets 404 on root data
|
||||
routes. Restarting the Console requires the instance operator role.
|
||||
|
||||
Authentication generations are checked on every request: disable/re-enable,
|
||||
role or subject changes and `user signout` invalidate earlier account sessions.
|
||||
Legacy root grants remain independent. There are at most 512 sessions and 128
|
||||
pending logins, with eight sessions per account; a ninth evicts that account's
|
||||
oldest. Sign-out removes the current session entirely.
|
||||
|
||||
Every stack copy carries a read-only `.stack-build.json` naming its commit.
|
||||
A stamped Console requires `--sso`, a valid mode-0444 `.deployed` naming its
|
||||
instance, and a loadable accounts file. A malformed stamp refuses every start.
|
||||
Stamped Console and bus starts refuse dev flags. Checkout development still
|
||||
supports the named local dev owner, but refuses it if accounts.json, console.sso
|
||||
or `.deployed` is present. Accounts instances use `case2-unit.sh` system units;
|
||||
`webui-service.sh` user units remain a checkout development path.
|
||||
|
||||
- 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
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
#!/usr/bin/env node
|
||||
import { assertBuildStart, assertDevInstance, assertDeployed } from '../../cli/src/deployment.mjs';
|
||||
// MOSAIC_TESTER_SOCKET_V1: private tester ingress, no tester TCP.
|
||||
import { createChat } from './chat.mjs';
|
||||
import { startServer, previewOptions, proofOptions, DEFAULT_BOARD } from './serve.mjs';
|
||||
@@ -21,6 +22,7 @@ import { loadRootAgents } from '../../organization/src/agent-levels.mjs';
|
||||
import { consoleReads, redactor } from './reads.mjs';
|
||||
import { createDevOwner } from './dev-owner.mjs';
|
||||
import { devOwnerInstance, devLanOptions } from '../../cli/src/dev-local-owner.mjs';
|
||||
import { loadAccounts } from '../../organization/src/accounts.mjs';
|
||||
import { createSso, readSlot } from './sso.mjs';
|
||||
import { readFileSync, statSync } from 'node:fs';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
@@ -98,6 +100,7 @@ try {
|
||||
} else if (flag === '--dev-allow-host') devNames.push(value);
|
||||
else throw new Error(`unknown option: ${flag}`);
|
||||
}
|
||||
const stamp = assertBuildStart({ serve: true, sso: ssoOn, devOwner, devBind, devNames });
|
||||
if (selector.given) options.business = selector.value;
|
||||
if (devOwner && (ssoOn || testerSocket || Object.keys(preview).length || Object.keys(proofFlags).length)) throw new Error('--dev-local-owner cannot be combined with --sso, preview, proof or --tester-socket');
|
||||
const lan = devLanOptions({ devOwner, bind: devBind, names: devNames });
|
||||
@@ -118,6 +121,11 @@ try {
|
||||
let processing = null, ownerCall = null;
|
||||
try {
|
||||
system = loadSystem({ instance });
|
||||
if (devOwner) assertDevInstance(system);
|
||||
if (stamp) {
|
||||
assertDeployed(system);
|
||||
loadAccounts(join(configDir(), 'accounts.json'), { providers: system.console?.sso?.providers ?? [] });
|
||||
}
|
||||
const { dataRoot } = system;
|
||||
const call = ownerCall = busCaller({ dataRoot, socket: socketPath(dataRoot), business: options.business ?? null });
|
||||
// FED R6 (#1800): the bus-backed options, shared with each granted user's session (console-bus.mjs).
|
||||
@@ -151,7 +159,7 @@ try {
|
||||
load: () => loadAccess(accessFilePath(configDir()), accessContextFor(system, process.env)),
|
||||
business: busBusiness({ dataRoot: system.dataRoot, business: options.business ?? null }),
|
||||
});
|
||||
sso = await createSso(system.console.sso, { secrets, users });
|
||||
sso = await createSso(system.console.sso, { secrets, users, accounts: { load: () => loadAccounts(join(configDir(), 'accounts.json'), { providers: system.console.sso.providers }) } });
|
||||
// Each request of a granted user's session gets the bus options over that user's own
|
||||
// user-cli transport. The human transport above serves only the chat's `plugins` read (C6).
|
||||
userOptions = userOptionsFor({ owner: ownerCall, send: sendOn, transport: user => userCaller({ dataRoot: system.dataRoot, socket: socketPath(system.dataRoot), business: options.business ?? null, user }) });
|
||||
|
||||
@@ -22,9 +22,9 @@ async function emptyBody(req) {
|
||||
return value && typeof value === 'object' && !Array.isArray(value) && Object.keys(value).length === 0 ? null : 'the body is {}';
|
||||
}
|
||||
|
||||
export async function consoleRestartHTTP(req, res, { session = null, owner = false, send = false, restart = null, trail = null, boot, log = () => {} }) {
|
||||
export async function consoleRestartHTTP(req, res, { session = null, operator = false, send = false, restart = null, trail = null, boot, log = () => {} }) {
|
||||
if (req.method !== 'POST') return json(res, 405, { error: 'method not allowed' });
|
||||
if (!owner || !session) return json(res, 403, { error: 'owner-only', message: 'restarting the Console is the owner\'s' });
|
||||
if (!operator || !session) return json(res, 403, { error: 'owner-only', message: 'restarting the Console requires an instance operator' });
|
||||
if (!send) return json(res, 403, { error: 'read-only', message: 'start the Console with --send to make changes' });
|
||||
const invalid = await emptyBody(req);
|
||||
if (invalid) return json(res, invalid === 'JSON body required' ? 415 : 400, { error: 'invalid-request', message: invalid });
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { DEV_OWNER } from '../../cli/src/dev-local-owner.mjs';
|
||||
|
||||
const session = Object.freeze({ ...DEV_OWNER, name: 'Local operator' });
|
||||
const session = Object.freeze({ ...DEV_OWNER, name: 'Local operator', rootOwner: true, instanceRole: 'operator' });
|
||||
export function createDevOwner() {
|
||||
return Object.freeze({ devOwner: true, session: () => session, formTargets: () => [],
|
||||
return Object.freeze({ devOwner: true, mode: 'dev', session: () => session, formTargets: () => [],
|
||||
handle: (_req, res) => { res.writeHead(404); res.end(); },
|
||||
summary: () => [], health: () => ({}), close: () => {} });
|
||||
}
|
||||
@@ -790,6 +790,9 @@ export async function startServer({ host = '127.0.0.1', port = 7330, board = DEF
|
||||
// names (without a port) a remote browser may use; only the dev owner takes them, and only with
|
||||
// them may it bind a non-loopback IPv4 address. SSO, preview and tester listeners stay loopback.
|
||||
if (!Array.isArray(devAllowHosts) || !devAllowHosts.every(devHostName)) throw new Error('dev allowed hosts must be host names or IPv4 addresses without a port');
|
||||
// The CLI also refuses SSO/dev-owner combinations and LAN flags without the
|
||||
// dev owner. Keep this guard for callers that construct a session source here.
|
||||
if ((sso?.mode === 'accounts') && (sso?.devOwner || devAllowHosts.length)) throw new Error('accounts mode refuses the dev LAN same-origin path');
|
||||
if (devAllowHosts.length && !sso?.devOwner) throw new Error('dev allowed hosts need the dev local owner');
|
||||
const devLan = Boolean(sso?.devOwner) && devAllowHosts.length > 0;
|
||||
if (sso?.devOwner && host !== '127.0.0.1' && !(devLan && isIP(host) === 4)) throw new Error(devLan ? 'dev local owner binds an IPv4 address' : 'dev local owner must bind 127.0.0.1');
|
||||
@@ -935,10 +938,15 @@ export async function startServer({ host = '127.0.0.1', port = 7330, board = DEF
|
||||
}
|
||||
if (session) signer = { sso: true, user: session.name, host: sso.publicHost, origin: localOrigin ?? sso.publicOrigin };
|
||||
// Row 252: only the owner's session answers a decision. A granted user stops at userRoute below.
|
||||
if (session && session.user === undefined) ssoSession = session;
|
||||
if (session?.rootOwner === true) ssoSession = session;
|
||||
// FED R6 (#1800): a granted user. The Console's gate first; behind it no human-transport
|
||||
// object is left, so a route past the gate reaches the broker only as this user.
|
||||
if (session?.user !== undefined) {
|
||||
const instanceAction = originalPath === '/api/console/restart';
|
||||
if (session && !session.rootOwner && session.user === undefined && orgId === null && !orgList && !hook?.publicRead && !instanceAction && path !== '/api/config' && path !== '/healthz' && !files.has(path)) return json(res, 404, { error: 'not-found' });
|
||||
if (session && !session.rootOwner && session.user === undefined) {
|
||||
bus = reads = send = settingsWrite = projectFiles = projectFile = projectSave = libraryStatus = libraryRevert = chat = nameWrite = assistantStop = instancesWrite = routesWrite = agentWrite = proxyCall = processing = decisionWrite = requestRead = requestWrite = systemTypesWrite = upstream = null;
|
||||
}
|
||||
if (session?.user !== undefined && !session.rootOwner && !instanceAction) {
|
||||
if (!organizations && !userRoute(path, req.method)) return json(res, 403, { error: 'owner-only', message: 'this part of the Console is the business owner\'s' });
|
||||
if (!organizations && userOptions === null) return json(res, 503, { error: 'not-configured', message: 'the Console has no user transport' });
|
||||
if (!organizations) ({ bus, send, settingsWrite, projectFiles, projectFile, projectSave, chat, nameWrite, assistantStop, instancesWrite, routesWrite, agentWrite, processing } = userOptions(session));
|
||||
@@ -982,11 +990,12 @@ export async function startServer({ host = '127.0.0.1', port = 7330, board = DEF
|
||||
return json(res, 403, { error: 'unavailable', sent: false });
|
||||
}
|
||||
if (hook) {
|
||||
if (!hook.publicRead && (!currentSession || !named || (granted && req.method !== 'GET'))) return json(res, 403, { error: 'owner-only' });
|
||||
const instanceAction = originalPath === '/api/console/restart';
|
||||
if (!hook.publicRead && (!currentSession || !named || (granted && req.method !== 'GET' && !instanceAction))) return json(res, 403, { error: 'owner-only' });
|
||||
const origin = localOrigin ?? sso?.publicOrigin;
|
||||
if (req.method !== 'GET' && one(req, 'origin') !== origin) return json(res, 403, { error: 'cross-origin request refused' });
|
||||
if (req.method !== 'GET' && one(req, 'sec-fetch-site') !== 'same-origin') return json(res, 403, { error: 'cross-site request refused' });
|
||||
return hook.handler(req, res, { path: originalPath, search, session: ssoSession, orgId, origin, owner: !tester && !granted && named && sso !== null, send: owner.send !== null });
|
||||
return hook.handler(req, res, { path: originalPath, search, session: currentSession, orgId, origin, owner: !tester && !granted && named && currentSession?.rootOwner === true, operator: !tester && named && currentSession?.instanceRole === 'operator', send: owner.send !== null });
|
||||
}
|
||||
const writer = tester ? { tester: true, user: 'tester', origin: TESTER_ORIGIN } : signer;
|
||||
// F3: the owner's routing and agent changes go to the action trail too (who, when, what).
|
||||
@@ -1012,7 +1021,7 @@ export async function startServer({ host = '127.0.0.1', port = 7330, board = DEF
|
||||
if (decisionPath) {
|
||||
if (tester) return decisionRefuse(res, 'not-found');
|
||||
const decide = decisionWrite && send && named && sso ? { call: decisionWrite, origin: localOrigin ?? sso.publicOrigin } : null;
|
||||
return decisionHTTP(req, res, { path, decide, session: ssoSession, live: () => sso.session(req), confirmations, log });
|
||||
return decisionHTTP(req, res, { path, decide, session: ssoSession, live: () => { const live = sso.session(req); return live?.rootOwner === true ? live : null; }, confirmations, log });
|
||||
}
|
||||
// #797 / #1839 Task 3: the owner's request intake controls. The tester listener never has them, and a
|
||||
// granted user stopped at userRoute above. Reads need the read transport; the writes also --send.
|
||||
@@ -1023,18 +1032,18 @@ export async function startServer({ host = '127.0.0.1', port = 7330, board = DEF
|
||||
if (!(named && sso !== null))
|
||||
return requestHTTP(req, res, { path, requestRead: null, requestWrite: null, session: null, live: () => null, origin: null, log });
|
||||
return requestHTTP(req, res, { path, requestRead, requestWrite: send ? requestWrite : null,
|
||||
session: ssoSession, live: () => sso.session(req), origin: localOrigin ?? sso.publicOrigin, log });
|
||||
session: ssoSession, live: () => { const live = sso.session(req); return live?.rootOwner === true ? live : null; }, origin: localOrigin ?? sso.publicOrigin, log });
|
||||
}
|
||||
if (remotePath) {
|
||||
if (tester || granted || !named || sso === null) return json(res, 403, { error: 'owner-only' });
|
||||
return projectRemoteHTTP(req, res, { path, read: requestRead, write: send ? requestWrite : null,
|
||||
session: ssoSession, live: () => sso.session(req), origin: localOrigin ?? sso.publicOrigin });
|
||||
session: ssoSession, live: () => { const live = sso.session(req); return live?.rootOwner === true ? live : null; }, origin: localOrigin ?? sso.publicOrigin });
|
||||
}
|
||||
// Phase P2 (#1873): the owner's System types read and writes on the selected organization.
|
||||
if (systemTypesPath) {
|
||||
if (tester || granted || !named || sso === null) return json(res, 403, { error: 'owner-only' });
|
||||
return systemTypesHTTP(req, res, { path, read: requestRead, write: send ? systemTypesWrite : null,
|
||||
session: ssoSession, live: () => sso.session(req), origin: localOrigin ?? sso.publicOrigin });
|
||||
session: ssoSession, live: () => { const live = sso.session(req); return live?.rootOwner === true ? live : null; }, origin: localOrigin ?? sso.publicOrigin });
|
||||
}
|
||||
if (tester && ['/project-remote.js', '/project-remote.css', '/files.js', '/files.css', '/system-types.js', '/system-types.css'].includes(path)) return json(res, 403, { error: 'owner-only' });
|
||||
// Row 194 (#1692, D4.1): the tester also launches and terminates.
|
||||
@@ -1185,7 +1194,7 @@ export async function startServer({ host = '127.0.0.1', port = 7330, board = DEF
|
||||
// `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.
|
||||
// `user` and `federation` (FED R6) only when there is one, so the owner's config is unchanged.
|
||||
if (path === '/api/config') return json(res, 200, { board: upstream, ...(organizations && preselect ? { preselect } : {}), ...(tester ? { tester: true } : {}), ...(sso ? { sso: true } : {}), ...(sso?.devOwner ? { devOwner: true } : {}), ...(granted ? { user: true, chatGrants } : {}), ...(!tester && !granted && named && sso !== null && requestRead ? { requests: true, requestWrites: Boolean(send && requestWrite), workspaceFiles: true, projectRemotes: true, projectRemoteWrites: Boolean(send && requestWrite) } : {}), ...(hub ? { federation: hub } : {}), ...(organizations ? { orgSetup: !tester && !granted && named && sso !== null && owner.send !== null } : {}), send: !organizations && !tester && Boolean(send) && named, ...(organizations && orgId ? { chatSend: !tester && Boolean(send) && named && orgBusHealthy, settingsSend: !tester && !granted && Boolean(send) && named && orgBusHealthy } : {}), boot, consoleRestart: !tester && !granted && named && sso !== null && owner.send !== null && restart !== null && trail !== null, decide: (!organizations || (orgId !== null && orgBusHealthy)) && !tester && !granted && Boolean(send) && Boolean(decisionWrite) && named && sso !== null, edit: !organizations && !tester && !granted && Boolean(send) && Boolean(projectSave) && named, northStarApprove: (!organizations || (orgId !== null && orgBusHealthy)) && !tester && !granted && Boolean(send) && Boolean(projectSave) && Boolean(projectFile) && named });
|
||||
if (path === '/api/config') return json(res, 200, { board: upstream, ...(organizations && preselect ? { preselect } : {}), ...(tester ? { tester: true } : {}), ...(sso ? { sso: true } : {}), ...(sso?.devOwner ? { devOwner: true } : {}), ...(granted ? { user: true, chatGrants } : {}), ...(!tester && !granted && named && sso !== null && requestRead ? { requests: true, requestWrites: Boolean(send && requestWrite), workspaceFiles: true, projectRemotes: true, projectRemoteWrites: Boolean(send && requestWrite) } : {}), ...(hub ? { federation: hub } : {}), ...(organizations ? { orgSetup: !tester && !granted && named && sso !== null && owner.send !== null } : {}), send: !organizations && !tester && Boolean(send) && named, ...(organizations && orgId ? { chatSend: !tester && Boolean(send) && named && orgBusHealthy, settingsSend: !tester && !granted && Boolean(send) && named && orgBusHealthy } : {}), boot, consoleRestart: !tester && named && currentSession?.instanceRole === 'operator' && owner.send !== null && restart !== null && trail !== null, decide: (!organizations || (orgId !== null && orgBusHealthy)) && !tester && !granted && Boolean(send) && Boolean(decisionWrite) && named && sso !== null, edit: !organizations && !tester && !granted && Boolean(send) && Boolean(projectSave) && named, northStarApprove: (!organizations || (orgId !== null && orgBusHealthy)) && !tester && !granted && Boolean(send) && Boolean(projectSave) && Boolean(projectFile) && named });
|
||||
if (path === '/healthz') return json(res, 200, { ok: true, ...(sso ? { sso: sso.health() } : {}) });
|
||||
if (path === '/favicon.ico') { res.writeHead(204); return res.end(); }
|
||||
const file = files.get(path);
|
||||
|
||||
+32
-18
@@ -19,10 +19,10 @@ 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 MAX_SESSIONS = 512, MAX_LOGINS = 128, MAX_ACCOUNT_SESSIONS = 8;
|
||||
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', 'user-revoked', 'discovery-failed', 'jwks-failed']);
|
||||
const CODES = new Set(['login-record-missing', 'state-mismatch', 'auth-error-response', 'grant-failed', 'id-token-invalid', 'signature-invalid', 'alg-refused', 'issuer-mismatch', 'subject-unknown', 'user-revoked', 'account-revoked', 'discovery-failed', 'jwks-failed']);
|
||||
|
||||
const sha256 = value => createHash('sha256').update(value).digest('hex');
|
||||
const escape = text => String(text).replace(/[&<>"']/g, c => `&#${c.charCodeAt(0)};`);
|
||||
@@ -125,7 +125,7 @@ async function discover(p, fetchOptions) {
|
||||
// `retryFirst` is for tests too; production waits 5 s, doubling, capped at 5 minutes.
|
||||
// `users` (FED R6, #1800) is consoleAccess() from access.mjs, or null: a subject that is not the
|
||||
// owner's signs in as the access-file user with a live grant, and each request checks it again.
|
||||
export async function createSso(config, { secrets, fetch: customFetch = null, log = line => console.log(line), retryFirst = 5000, users = null } = {}) {
|
||||
export async function createSso(config, { secrets, fetch: customFetch = null, log = line => console.log(line), retryFirst = 5000, users = null, accounts = null } = {}) {
|
||||
checkConsole({ sso: config });
|
||||
oauth ??= await import('oauth4webapi');
|
||||
const publicOrigin = config.publicOrigin;
|
||||
@@ -280,18 +280,25 @@ export async function createSso(config, { secrets, fetch: customFetch = null, lo
|
||||
try { header = JSON.parse(Buffer.from(result.id_token.split('.')[0], 'base64url').toString('utf8')); } catch { header = null; }
|
||||
if (!header || !entry.config.algs.includes(header.alg)) { refuse('alg-refused', pid); return failed(res, clear); }
|
||||
if (claims.iss !== entry.config.issuer) { refuse('issuer-mismatch', pid); return failed(res, clear); }
|
||||
let name = typeof claims.sub === 'string' ? entry.subjects.get(claims.sub) : undefined;
|
||||
// FED R6: console.sso subjects and access-file subjects are disjoint (R1), so the owner's
|
||||
// list is asked first and a user is asked only on a miss.
|
||||
const user = name === undefined && typeof claims.sub === 'string' ? users?.find({ provider: pid, sub: claims.sub }) ?? null : null;
|
||||
if (user !== null) name = user;
|
||||
if (name === undefined) {
|
||||
// Account authority and the legacy root grant are independent. A configured
|
||||
// owner subject alone is never an account and cannot bypass a refused store.
|
||||
let account = null;
|
||||
try { account = accounts?.load().users.find(u => u.state === 'active' && u.subjects.some(s => s.provider === pid && s.sub === claims.sub)) ?? null; } catch { /* fail closed for accounts */ }
|
||||
const user = typeof claims.sub === 'string' ? users?.find({ provider: pid, sub: claims.sub }) ?? null : null;
|
||||
const rootOwner = account?.instanceRole === 'operator' && entry.subjects.has(claims.sub);
|
||||
if (!account && user === null) {
|
||||
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 name = account?.name ?? user;
|
||||
const id = randomBytes(32).toString('base64url'), now = Date.now();
|
||||
remember(sessions, sha256(id), { provider: pid, iss: claims.iss, sub: claims.sub, name, ...(user !== null ? { user } : {}), idToken: result.id_token, created: now, seen: now }, MAX_SESSIONS);
|
||||
if (account) {
|
||||
const own = [...sessions].filter(([, s]) => s.account === account.id);
|
||||
while (own.length >= MAX_ACCOUNT_SESSIONS) sessions.delete(own.shift()[0]);
|
||||
}
|
||||
remember(sessions, sha256(id), { provider: pid, iss: claims.iss, sub: claims.sub, name,
|
||||
...(account ? { account: account.id, instanceRole: account.instanceRole, authGen: account.authGen } : {}),
|
||||
rootOwner, ...(user !== null ? { user } : {}), idToken: result.id_token, created: now, seen: now }, MAX_SESSIONS);
|
||||
return redirect(res, returnURL(record.ret), [...clear, `${SESSION_COOKIE}=${id}; ${ATTRS}`]);
|
||||
}
|
||||
|
||||
@@ -304,13 +311,20 @@ export async function createSso(config, { secrets, fetch: customFetch = null, lo
|
||||
if (!s) return null;
|
||||
const now = Date.now();
|
||||
if (now - s.created >= SESSION_ABSOLUTE || now - s.seen >= SESSION_IDLE) { sessions.delete(key); return null; }
|
||||
// FED R6 (design 3.1 D5): a user's grant is read again on every request, so a user revoked,
|
||||
// removed or expired since sign-in has no session from the next request on.
|
||||
if (s.user !== undefined && !users?.check(s)) {
|
||||
sessions.delete(key);
|
||||
refuse('user-revoked', s.provider, ` user=${JSON.stringify(s.user)}`);
|
||||
return null;
|
||||
if (s.account !== undefined) {
|
||||
let account;
|
||||
try { account = accounts?.load().users.find(u => u.id === s.account); } catch { /* refuse */ }
|
||||
if (!account || account.state !== 'active' || account.authGen !== s.authGen || account.instanceRole !== s.instanceRole || !account.subjects.some(x => x.provider === s.provider && x.sub === s.sub)) {
|
||||
refuse('account-revoked', s.provider);
|
||||
// Never reacquire account authority on this cookie, even after re-enable.
|
||||
delete s.account; delete s.instanceRole; delete s.authGen; s.rootOwner = false;
|
||||
}
|
||||
}
|
||||
if (s.user !== undefined && !users?.check(s)) {
|
||||
refuse('user-revoked', s.provider, ` user=${JSON.stringify(s.user)}`);
|
||||
delete s.user;
|
||||
}
|
||||
if (s.account === undefined && s.user === undefined) { sessions.delete(key); return null; }
|
||||
if (touch) s.seen = now;
|
||||
return s;
|
||||
}
|
||||
@@ -351,7 +365,7 @@ export async function createSso(config, { secrets, fetch: customFetch = null, lo
|
||||
}
|
||||
|
||||
return {
|
||||
publicOrigin, publicHost, sessions, logins, handle, session, returnURL,
|
||||
mode: 'accounts', publicOrigin, publicHost, sessions, logins, handle, session, returnURL,
|
||||
chatGrants: session => users?.chatGrants?.(session) ?? [],
|
||||
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))],
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
// M3: real accounts store, real OIDC code flow, isolated port-zero providers.
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { request } from 'node:http';
|
||||
import { mkdtempSync, rmSync, writeFileSync, chmodSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { loadAccounts, withAccounts } from '../../organization/src/accounts.mjs';
|
||||
import { createSso, SESSION_COOKIE } from '../src/sso.mjs';
|
||||
import { startServer } from '../src/serve.mjs';
|
||||
import { consoleAccess } from '../src/access.mjs';
|
||||
import { fakeOidc } from './helpers/fake-oidc.mjs';
|
||||
import { close } from './fixture.mjs';
|
||||
const HOST = 'accounts.example.test', ORIGIN = `https://${HOST}`;
|
||||
function call(port, path, { cookie, method = 'GET', body } = {}) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const req = request({ host: '127.0.0.1', port, path, method, headers: { host: HOST, ...(cookie ? { cookie } : {}), ...(body ? { origin: ORIGIN, 'sec-fetch-site': 'same-origin', 'content-type': 'application/json' } : {}) } }, res => {
|
||||
let text = ''; res.on('data', c => text += c); res.on('end', () => resolve({ status: res.statusCode, headers: res.headers, text }));
|
||||
}); req.on('error', reject); req.end(body);
|
||||
});
|
||||
}
|
||||
async function setup(t, { legacy = false } = {}) {
|
||||
const dir = mkdtempSync(join(tmpdir(), 'as-')); chmodSync(dir, 0o700); t.after(() => rmSync(dir, { recursive: true, force: true }));
|
||||
const op = await fakeOidc(); t.after(() => op.close()); const iss = op.issuer('a');
|
||||
const config = { publicOrigin: ORIGIN, providers: [{ id: 'a', issuer: iss.issuer, clientId: iss.clientId, clientSecret: { slot: 'a' }, algs: ['RS256'], subjects: [{ sub: 'owner', name: 'Owner' }, { sub: 'drift', name: 'Drift' }] }] };
|
||||
const ctx = { configDir: dir, providers: config.providers }, file = join(dir, 'accounts.json');
|
||||
const row = (id, sub, instanceRole) => ({ id, subjects: [{ provider: 'a', sub }], name: sub, instanceRole, state: 'active', authGen: 1, created: '2026-11-10T00:00:00Z' });
|
||||
await withAccounts(ctx, { init: true, revision: 0, verb: 'init' }, d => { d.users.push(row('u-000000000001', 'owner', 'operator'), row('u-000000000002', 'pat', 'user'), row('u-000000000003', 'op', 'operator')); });
|
||||
let access = { users: [{ id: 'legacy', subjects: [{ provider: 'a', sub: 'pat' }] }], grants: legacy ? [{ user: 'legacy', business: 'demo', projects: ['*'], instances: ['*'], level: 'collaborator' }] : [] };
|
||||
const lines = [], sso = await createSso(config, { secrets: new Map([['a', iss.clientSecret]]), fetch: op.fetch, log: l => lines.push(l), accounts: { load: () => loadAccounts(file, ctx) }, users: consoleAccess({ load: () => access, business: () => 'demo' }) });
|
||||
t.after(() => sso.close()); let restarts = 0;
|
||||
const web = await startServer({ port: 0, board: 'http://127.0.0.1:9', sso, bus: { inbox: async () => [] }, send: async () => ({}), restart: () => restarts++, trail: () => {}, userOptions: () => ({ bus: { inbox: async () => [] }, chat: { projects: async () => [] } }), log: () => {} }); t.after(() => close(web)); const port = web.address().port;
|
||||
async function login(sub = 'pat') {
|
||||
const begin = await call(port, '/auth/login'); const loc = begin.headers.location; const grant = op.grant(iss, loc, { sub });
|
||||
const r = await call(port, `/auth/callback?${new URLSearchParams({ code: grant.code, state: new URL(loc).searchParams.get('state') })}`, { cookie: begin.headers['set-cookie'][0].split(';')[0] });
|
||||
const cookie = r.headers['set-cookie']?.find(c => c.startsWith(SESSION_COOKIE + '=') && !c.includes('Max-Age=0'))?.split(';')[0]; return { ...r, cookie };
|
||||
}
|
||||
const read = () => loadAccounts(file, ctx);
|
||||
const change = (fn, verb = 'user.update') => withAccounts(ctx, { revision: read().revision, verb }, fn);
|
||||
return { login, sso, lines, file, port, read, change, revoke: () => { access.grants = []; }, restarts: () => restarts };
|
||||
}
|
||||
test('account sign-in records separate authority; unknown and drifted owner subjects refuse', async t => {
|
||||
const s = await setup(t); const p = await s.login(); assert.equal(p.status, 303);
|
||||
const session = s.sso.session({ headers: { cookie: p.cookie } }); assert.equal(session.account, 'u-000000000002'); assert.equal(session.authGen, 1); assert.equal(session.instanceRole, 'user'); assert.equal(session.rootOwner, false); assert.equal(session.user, undefined);
|
||||
for (const sub of ['unknown', 'drift']) { const r = await s.login(sub); assert.equal(r.status, 403); assert.equal(r.cookie, undefined); assert.match(s.lines.at(-1), /subject-unknown/); }
|
||||
const owner = await s.login('owner'); assert.equal(s.sso.session({ headers: { cookie: owner.cookie } }).rootOwner, true);
|
||||
});
|
||||
test('disabled, removed, signout, changed subject and role generations invalidate without resurrection', async t => {
|
||||
for (const mutation of ['disable', 'disable-enable', 'role-roundtrip', 'signout', 'subject', 'remove', 'missing', 'invalid']) await t.test(mutation, async t => {
|
||||
const s = await setup(t), r = await s.login(); assert.ok(r.cookie, 'account sign-in required before revocation');
|
||||
if (mutation.startsWith('disable')) { await s.change(d => d.users[1].state = 'disabled'); if (mutation.endsWith('enable')) await s.change(d => d.users[1].state = 'active'); }
|
||||
if (mutation === 'role-roundtrip') { await s.change(d => d.users[1].instanceRole = 'operator'); await s.change(d => d.users[1].instanceRole = 'user'); }
|
||||
if (mutation === 'signout') await s.change(d => d.users[1].authGen++, 'user.signout');
|
||||
if (mutation === 'subject') await s.change(d => d.users[1].subjects[0].sub = 'changed');
|
||||
if (mutation === 'remove') await s.change(d => d.users.splice(1, 1), 'user.remove');
|
||||
if (mutation === 'missing') rmSync(s.file); if (mutation === 'invalid') writeFileSync(s.file, '{}');
|
||||
assert.equal(s.sso.session({ headers: { cookie: r.cookie } }), null);
|
||||
assert.equal((await call(s.port, '/api/bus/inbox', { cookie: r.cookie })).status, 401);
|
||||
});
|
||||
});
|
||||
test('the ninth account session evicts only that account oldest; global caps are 512/128', async t => {
|
||||
const s = await setup(t), other = await s.login('owner'), cookies = [];
|
||||
for (let i = 0; i < 9; i++) cookies.push((await s.login()).cookie);
|
||||
assert.equal(s.sso.session({ headers: { cookie: cookies[0] } }), null);
|
||||
for (const cookie of [...cookies.slice(1), other.cookie]) assert.ok(s.sso.session({ headers: { cookie } }));
|
||||
for (let i = 0; i < 129; i++) await call(s.port, '/auth/login'); assert.equal(s.sso.logins.size, 128);
|
||||
});
|
||||
test('root routes refuse account-only sessions and restart checks operator independently of root ownership', async t => {
|
||||
const s = await setup(t), pat = await s.login(), op = await s.login('op');
|
||||
for (const path of ['/api/bus/inbox', '/api/settings', '/api/chat/projects', '/board']) assert.equal((await call(s.port, path, { cookie: pat.cookie })).status, 404, path);
|
||||
assert.equal((await call(s.port, '/api/console/restart', { cookie: pat.cookie, method: 'POST', body: '{}' })).status, 403);
|
||||
assert.equal(s.restarts(), 0);
|
||||
assert.equal((await call(s.port, '/api/console/restart', { cookie: op.cookie, method: 'POST', body: '{}' })).status, 202);
|
||||
assert.equal(s.restarts(), 1);
|
||||
});
|
||||
test('legacy root grant and account authority revoke independently and never return on an old cookie', async t => {
|
||||
const s = await setup(t, { legacy: true }), r = await s.login();
|
||||
let session = s.sso.session({ headers: { cookie: r.cookie } }); assert.equal(session.user, 'legacy'); assert.equal(session.account, 'u-000000000002');
|
||||
assert.equal((await call(s.port, '/api/chat/projects', { cookie: r.cookie })).status, 200);
|
||||
await s.change(d => d.users[1].state = 'disabled');
|
||||
session = s.sso.session({ headers: { cookie: r.cookie } }); assert.ok(session); assert.equal(session.user, 'legacy'); assert.equal(session.account, undefined); assert.equal(session.instanceRole, undefined);
|
||||
assert.equal((await call(s.port, '/api/chat/projects', { cookie: r.cookie })).status, 200);
|
||||
await s.change(d => d.users[1].state = 'active'); assert.equal(s.sso.session({ headers: { cookie: r.cookie } }).account, undefined);
|
||||
s.revoke(); assert.equal(s.sso.session({ headers: { cookie: r.cookie } }), null);
|
||||
assert.equal((await call(s.port, '/api/chat/projects', { cookie: r.cookie })).status, 401);
|
||||
const fresh = await s.login(); assert.ok(fresh.cookie); assert.equal(s.sso.session({ headers: { cookie: fresh.cookie } }).user, undefined);
|
||||
assert.equal((await call(s.port, '/api/chat/projects', { cookie: fresh.cookie })).status, 404);
|
||||
});
|
||||
|
||||
test('accounts mode refuses any start that could reach the dev LAN same-origin fake', async () => {
|
||||
const { createDevOwner } = await import('../src/dev-owner.mjs');
|
||||
await assert.rejects(startServer({ port: 0, sso: { ...createDevOwner(), mode: 'accounts' }, devAllowHosts: ['accounts.test'] }), /accounts mode refuses the dev LAN/);
|
||||
await assert.rejects(startServer({ port: 0, sso: { ...createDevOwner(), mode: 'accounts' } }), /accounts mode refuses the dev LAN/);
|
||||
});
|
||||
@@ -23,7 +23,8 @@ async function fixture(t, { send = true, granted = false } = {}) {
|
||||
const rolesDir = rolesCopy(root);
|
||||
const file = writeJson(join(root, 'businesses/demo.json'), businessDoc(root, 'demo'));
|
||||
const trail = [];
|
||||
const session = { provider: 'fixture', iss: 'https://issuer.example', sub: 'owner-sub', name: 'jason' };
|
||||
// M3 (#1873): the hook fence's owner flag is the session's root authority (rootOwner), as sso.mjs sets it.
|
||||
const session = { provider: 'fixture', iss: 'https://issuer.example', sub: 'owner-sub', name: 'jason', rootOwner: true };
|
||||
const sso = { publicOrigin: '', publicHost: '', formTargets: () => [], session: () => session, handle: () => {}, health: () => ({}) };
|
||||
const organizations = {
|
||||
list: async () => [{ id: 'demo', label: 'Demo', busHealthy: true }],
|
||||
|
||||
@@ -80,7 +80,7 @@ test('C2-09a metadata stays behind the SSO session check and a GET never refresh
|
||||
const headers = { host: sso.publicHost };
|
||||
assert.equal((await get(server, { headers })).status, 401);
|
||||
assert.equal(reads, 0);
|
||||
session = { name: 'jason' };
|
||||
session = { name: 'jason', rootOwner: true };
|
||||
assert.equal((await get(server, { headers })).status, 200);
|
||||
assert.equal(reads, 1);
|
||||
assert.deepEqual(touches, [false, false]);
|
||||
|
||||
@@ -309,6 +309,7 @@ test('configured Console process injects chat through its existing human transpo
|
||||
clientSecret: { slot: issuer.name }, algs: ['RS256'], subjects: [{ sub: 'owner-sub', name: 'jason' }],
|
||||
}] } };
|
||||
writeFileSync(env.MOSAIC_CONFIG, JSON.stringify(config));
|
||||
writeFileSync(join(dirname(env.MOSAIC_CONFIG), 'accounts.json'), JSON.stringify({ accountsVersion: 1, revision: 1, users: [{ id: 'u-000000000001', subjects: [{ provider: issuer.name, sub: 'owner-sub' }], name: 'jason', instanceRole: 'operator', state: 'active', authGen: 1, created: '2026-11-10T00:00:00Z' }], memberships: [], invites: [] }), { mode: 0o600 });
|
||||
const secrets = join(dirname(env.MOSAIC_CONFIG), 'secrets', 'mosaic-stack');
|
||||
mkdirSync(secrets, { recursive: true });
|
||||
const secret = join(secrets, issuer.name); writeFileSync(secret, `${issuer.clientSecret}\n`, { mode: 0o600 }); chmodSync(secret, 0o600);
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { ownerAccounts } from './helpers/owner-accounts.mjs';
|
||||
// Row 252 (#1753, design sections 4 and 5): the Console decision routes on the human listener, signed
|
||||
// in through the fake OP in helpers/fake-oidc.mjs on loopback. The bus transport is a recording stub,
|
||||
// so this covers the listener's gates and the session it passes; decision-http.test.mjs covers the
|
||||
@@ -53,7 +54,7 @@ async function setup(t, server = {}, access = null) {
|
||||
const config = { publicOrigin: ORIGIN, providers: [{ id: 'a', issuer: iss.issuer, clientId: iss.clientId, clientSecret: { slot: 'a' }, algs: ['RS256'], subjects: [{ sub: 'user-a', name: 'person-a' }] }] };
|
||||
const lines = [];
|
||||
const users = access && { users: consoleAccess({ load: () => access, business: () => 'demo' }) };
|
||||
const sso = await createSso(config, { secrets: new Map([['a', iss.clientSecret]]), fetch: op.fetch, log: l => lines.push(l), ...users });
|
||||
const sso = await createSso(config, { accounts: ownerAccounts(config), secrets: new Map([['a', iss.clientSecret]]), fetch: op.fetch, log: l => lines.push(l), ...users });
|
||||
t.after(() => sso.close());
|
||||
const calls = [];
|
||||
const decisionWrite = async (verb, args, ctx) => {
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { ownerAccounts } from './helpers/owner-accounts.mjs';
|
||||
// FED R6 (#1800, federation design 3.1 D3-D5, 3.4 and 3.7): a granted user signs in to the
|
||||
// Console. Every provider is the fake OP in helpers/fake-oidc.mjs on loopback; no test reaches a
|
||||
// real provider, the live Console or a real config, slot or access file. The broker is an
|
||||
@@ -92,7 +93,7 @@ async function setup(t, { users = [ALICE], grants = [grant()] } = {}) {
|
||||
const lines = [];
|
||||
const log = line => lines.push(line);
|
||||
const config = { publicOrigin: ORIGIN, providers: [{ id: 'a', issuer: iss.issuer, clientId: iss.clientId, clientSecret: { slot: 'a' }, algs: ['RS256'], subjects: [{ sub: 'owner-sub', name: 'jason' }] }] };
|
||||
const sso = await createSso(config, { secrets: new Map([['a', iss.clientSecret]]), fetch: op.fetch, log, users: consoleAccess({ load, business: () => 'demo' }) });
|
||||
const sso = await createSso(config, { accounts: ownerAccounts(config), secrets: new Map([['a', iss.clientSecret]]), fetch: op.fetch, log, users: consoleAccess({ load, business: () => 'demo' }) });
|
||||
t.after(() => sso.close());
|
||||
const stub = name => async () => { counts.owner++; return name === 'send' ? { id: 'msg-0001' } : [{ verb: name }]; };
|
||||
const ownerBus = Object.fromEntries(['inbox', 'tasks', 'agents', 'trail', 'decisions', 'names', 'instances', 'routes', 'thread.claims'].map(v => [v, stub(v)]));
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
// Existing OIDC regression fixtures explicitly initialize their configured owners.
|
||||
// Production never infers account authority from console.sso alone.
|
||||
export const ownerAccounts = config => ({ load: () => ({ users: config.providers.flatMap((p, i) => p.subjects.map((s, j) => ({
|
||||
id: `u-${(i * 64 + j + 1).toString(16).padStart(12, '0')}`, subjects: [{ provider: p.id, sub: s.sub }], name: s.name, state: 'active', instanceRole: 'operator', authGen: 1,
|
||||
}))) }) });
|
||||
@@ -1,3 +1,4 @@
|
||||
import { ownerAccounts } from './owner-accounts.mjs';
|
||||
// Q284 / #385: real OIDC sessions for writer-route tests. The provider is the existing
|
||||
// in-process fake and is shared only within one test-file process; each test gets its own
|
||||
// createSso instance and completes the real login, code and callback flow.
|
||||
@@ -58,6 +59,7 @@ export async function createSsoSession(t, {
|
||||
subjects: configuredSubjects,
|
||||
}],
|
||||
}, {
|
||||
accounts: ownerAccounts({ providers: [{ id: issuer.name, subjects: configuredSubjects }] }),
|
||||
secrets: new Map([[issuer.name, issuer.clientSecret]]),
|
||||
fetch: op.fetch,
|
||||
log,
|
||||
|
||||
@@ -34,7 +34,7 @@ export async function realRemoteFixture(t, mutate = () => {}) {
|
||||
};
|
||||
await attach();
|
||||
const subject = { provider: 'fixture', iss: 'https://issuer.example', sub: 'owner' };
|
||||
const session = { ...subject, name: 'jason' };
|
||||
const session = { rootOwner: true, instanceRole: 'operator', ...subject, name: 'jason' };
|
||||
let active = true;
|
||||
const sso = { publicOrigin: '', publicHost: '', formTargets: () => [], session: () => active ? session : null, handle: () => {}, health: () => ({}) };
|
||||
const transport = (verb, args, ctx) => new Client({ path, console: { subject: ctx.subject } }).call(verb, args);
|
||||
|
||||
@@ -2,11 +2,14 @@ import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { execFileSync, spawn } from 'node:child_process';
|
||||
import { once } from 'node:events';
|
||||
import { cpSync, existsSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs';
|
||||
import { chmodSync, cpSync, existsSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync, statSync, symlinkSync, writeFileSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { dirname, join } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { fixture } from '../../cli/tests/helpers.mjs';
|
||||
import { writeDeployment } from '../../cli/src/deployment.mjs';
|
||||
import { fakeOidc } from './helpers/fake-oidc.mjs';
|
||||
import { request as httpRequest } from 'node:http';
|
||||
import { consoleReads } from '../src/reads.mjs';
|
||||
|
||||
// Row 77 (#1571, lead decision 112): `scripts/webui-service.sh copy` and
|
||||
@@ -75,7 +78,7 @@ test('copy writes the Console paths at the full id, read-only, and leaves an exi
|
||||
assert.equal(r.status, 0, r.stderr);
|
||||
assert.match(r.stdout, new RegExp(`^copied: ${target}$`, 'm'));
|
||||
assert.deepEqual(readdirSync(copies), [head], 'no temp directory left behind');
|
||||
assert.deepEqual(readdirSync(target).sort(), ['RELEASE', 'adapters', 'package-lock.json', 'package.json', 'packages', 'roles', 'scripts', 'tools']);
|
||||
assert.deepEqual(readdirSync(target).sort(), ['.stack-build.json', 'RELEASE', 'adapters', 'package-lock.json', 'package.json', 'packages', 'roles', 'scripts', 'tools']);
|
||||
assert.deepEqual(readdirSync(join(target, 'scripts')), ['case2-persona-import.mjs', 'mosaic', 'mosaic-config.mjs']);
|
||||
assert.ok(existsSync(join(target, 'packages', 'webui', 'README.md')), "the unit's Documentation=");
|
||||
assert.equal(statSync(join(target, 'packages', 'webui', 'README.md')).mode & 0o222, 0, 'README.md is read-only');
|
||||
@@ -227,36 +230,133 @@ test('a copy that meets an empty target directory replaces it by rename, not by
|
||||
|
||||
// serve from a copy: /healthz, the config check, roles and RELEASE from the
|
||||
// copy, and the queue read from --checkout or, without it, from the copy.
|
||||
// A copy is stamped, so it starts only with --sso and the deployed
|
||||
// prerequisites (M3): console.sso and its slot, .deployed and accounts.json.
|
||||
async function serve(t, cli, args, env) {
|
||||
const child = spawn(process.execPath, [cli, 'serve', '--port', '0', '--board', 'http://127.0.0.1:9', ...args], { env, stdio: ['ignore', 'pipe', 'pipe'] });
|
||||
t.after(() => child.kill());
|
||||
const exited = once(child, 'exit');
|
||||
// Cleanup waits for the Console to exit, so no child outlives the test.
|
||||
t.after(async () => { if (child.exitCode === null && child.signalCode === null) child.kill('SIGKILL'); await exited; });
|
||||
let out = '', err = '';
|
||||
child.stderr.on('data', (c) => { err += c; });
|
||||
const timer = setTimeout(() => child.kill('SIGKILL'), 60_000);
|
||||
const port = await new Promise((resolve, reject) => {
|
||||
child.stdout.on('data', (c) => { out += c; const m = out.match(/^Mosaic Console: http:\/\/127\.0\.0\.1:(\d+)\/$/m); if (m && out.includes('Ctrl-C')) resolve(Number(m[1])); });
|
||||
child.on('exit', (code) => reject(new Error(`exited ${code}: ${out}${err}`)));
|
||||
});
|
||||
const get = async (path) => { const res = await fetch(`http://127.0.0.1:${port}${path}`); return { status: res.status, body: await res.json() }; };
|
||||
return { out, get };
|
||||
child.on('exit', (code, signal) => reject(new Error(`exited ${code ?? signal} before listening: ${out}${err}`)));
|
||||
}).finally(() => clearTimeout(timer));
|
||||
return { out, port };
|
||||
}
|
||||
|
||||
test('serve runs from a copy: /healthz 200, the config check, roles and RELEASE from the copy, the queue from --checkout', async (t) => {
|
||||
// A start that refuses: exit 2, the refusal on stderr, and no listener line.
|
||||
// A start that listens instead is killed at its listener line (or after 60s),
|
||||
// so a removed check fails here rather than holding the file open.
|
||||
async function refusedStart(cli, args, env) {
|
||||
const child = spawn(process.execPath, [cli, 'serve', '--port', '0', '--board', 'http://127.0.0.1:9', ...args], { env, stdio: ['ignore', 'pipe', 'pipe'] });
|
||||
let out = '', err = '';
|
||||
const timer = setTimeout(() => child.kill('SIGKILL'), 60_000);
|
||||
child.stdout.on('data', (c) => { out += c; if (/Mosaic Console:/.test(out)) child.kill('SIGKILL'); });
|
||||
child.stderr.on('data', (c) => { err += c; });
|
||||
const [code] = await once(child, 'close');
|
||||
clearTimeout(timer);
|
||||
assert.doesNotMatch(out, /Mosaic Console:/);
|
||||
return { code, err };
|
||||
}
|
||||
|
||||
test('a stamped copy refuses the former unauthenticated Console start', async t => {
|
||||
const { dir, script, head } = fixtureRepo(t);
|
||||
const copies = join(dir, 'copies');
|
||||
assert.equal((await run(dir, script, ['copy', 'refactor', '--copies', copies])).status, 0);
|
||||
const cli = join(copies, head, 'packages/webui/src/cli.mjs');
|
||||
const { env } = fixture(join(dir, 'home'));
|
||||
const { code, err } = await refusedStart(cli, ['--business', 'acme'], env);
|
||||
assert.equal(code, 2);
|
||||
assert.match(err, /stamped build serve requires --sso/);
|
||||
});
|
||||
|
||||
const PUBLIC = 'console.test';
|
||||
// Each request is bounded: a stall, an aborted or incomplete response, or a
|
||||
// body that is not JSON rejects with the path instead of hanging the test.
|
||||
function request(port, path, headers = {}) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const req = httpRequest({ host: '127.0.0.1', port, path, headers: { host: PUBLIC, ...headers } }, (res) => {
|
||||
let text = '';
|
||||
res.setEncoding('utf8');
|
||||
res.on('data', (c) => { text += c; });
|
||||
res.on('aborted', () => reject(new Error(`${path}: response aborted`)));
|
||||
res.on('error', reject);
|
||||
res.on('end', () => {
|
||||
if (!res.complete) return reject(new Error(`${path}: incomplete response`));
|
||||
let body = null;
|
||||
try { body = text ? JSON.parse(text) : null; } catch (e) { return reject(new Error(`${path}: ${res.statusCode} body is not JSON: ${text.slice(0, 200)}`)); }
|
||||
resolve({ status: res.statusCode, headers: res.headers, body });
|
||||
});
|
||||
});
|
||||
req.setTimeout(10_000, () => req.destroy(new Error(`${path}: no response within 10s`)));
|
||||
req.on('error', reject); req.end();
|
||||
});
|
||||
}
|
||||
|
||||
// The owner signs in through the fake provider; reads carry the session on the public Host.
|
||||
async function signIn(port, op, iss) {
|
||||
const login = await request(port, '/auth/login');
|
||||
assert.equal(login.status, 303);
|
||||
const cookie = [login.headers['set-cookie']].flat()[0].split(';')[0];
|
||||
const grant = op.grant(iss, login.headers.location, { sub: 'owner' });
|
||||
const state = new URL(login.headers.location).searchParams.get('state');
|
||||
const callback = await request(port, `/auth/callback?${new URLSearchParams({ code: grant.code, state })}`, { cookie });
|
||||
assert.equal(callback.status, 303);
|
||||
const session = [callback.headers['set-cookie']].flat().find((v) => v.startsWith('__Host-mosaic_session=') && !v.includes('Max-Age=0')).split(';')[0];
|
||||
return async (path) => { const r = await request(port, path, { cookie: session }); return { status: r.status, body: r.body }; };
|
||||
}
|
||||
|
||||
test('serve runs from a copy: /healthz 200, the config check, roles and RELEASE from the copy, the queue from --checkout', { timeout: 240_000 }, async (t) => {
|
||||
const { dir, script, head } = fixtureRepo(t);
|
||||
const copies = join(dir, 'copies');
|
||||
assert.equal((await run(dir, script, ['copy', 'refactor', '--copies', copies])).status, 0);
|
||||
const cli = join(copies, head, 'packages', 'webui', 'src', 'cli.mjs');
|
||||
// A deployed copy gets node_modules from npm ci; the fixture links this tree's (the SSO client needs it).
|
||||
const mode = statSync(join(copies, head)).mode & 0o777; chmodSync(join(copies, head), mode | 0o200); symlinkSync(join(REPO, 'node_modules'), join(copies, head, 'node_modules')); chmodSync(join(copies, head), mode);
|
||||
// A checkout whose queue read says where it ran.
|
||||
const checkout = join(dir, 'checkout');
|
||||
mkdirSync(join(checkout, 'packages', 'webui', 'src'), { recursive: true });
|
||||
writeFileSync(join(checkout, 'packages', 'webui', 'src', 'queue-read.mjs'), `process.stdout.write(JSON.stringify({ rows: [], notes: [process.cwd() === ${JSON.stringify(checkout)} ? 'read in the checkout' : 'read elsewhere'] }));\n`);
|
||||
const { env } = fixture(join(dir, 'home'));
|
||||
const f = fixture(join(dir, 'home'));
|
||||
const env = { ...f.env };
|
||||
delete env.MOSAIC_ROLES_DIR;
|
||||
const { out, get } = await serve(t, cli, ['--business', 'acme', '--checkout', checkout], env);
|
||||
const args = ['--business', 'acme', '--sso', '--checkout', checkout];
|
||||
// Each prerequisite missing in turn refuses the --sso start before it listens.
|
||||
const op = await fakeOidc(); t.after(() => op.close());
|
||||
const iss = op.issuer('a');
|
||||
const ca = join(dir, 'ca.pem'); writeFileSync(ca, op.cert);
|
||||
env.NODE_EXTRA_CA_CERTS = ca;
|
||||
const cfgDir = dirname(f.config);
|
||||
let r = await refusedStart(cli, args, env);
|
||||
assert.equal(r.code, 2, r.err); assert.match(r.err, /\.deployed is missing or invalid/);
|
||||
writeDeployment({ dataRoot: f.dataRoot }, cfgDir);
|
||||
r = await refusedStart(cli, args, env);
|
||||
assert.equal(r.code, 2, r.err); assert.match(r.err, /accounts file not found/);
|
||||
writeFileSync(join(cfgDir, 'accounts.json'), JSON.stringify({ accountsVersion: 1, revision: 1, users: [{ id: 'u-000000000001', subjects: [{ provider: 'a', sub: 'owner' }], name: 'Owner', instanceRole: 'operator', state: 'active', authGen: 1, created: '2026-11-10T00:00:00Z' }], memberships: [], invites: [] }), { mode: 0o600 });
|
||||
r = await refusedStart(cli, args, env);
|
||||
// Without console.sso no provider is configured, so the accounts file's subject has none.
|
||||
assert.equal(r.code, 2, r.err); assert.match(r.err, /must be a configured provider/);
|
||||
const config = JSON.parse(readFileSync(f.config, 'utf8'));
|
||||
config.console = { sso: { publicOrigin: `https://${PUBLIC}`, providers: [{ id: 'a', issuer: iss.issuer, clientId: iss.clientId, clientSecret: { slot: 'a' }, algs: ['RS256'], subjects: [{ sub: 'owner', name: 'Owner' }] }] } };
|
||||
writeFileSync(f.config, JSON.stringify(config));
|
||||
r = await refusedStart(cli, args, env);
|
||||
assert.equal(r.code, 2, r.err); assert.match(r.err, /client secret slot is missing/);
|
||||
mkdirSync(join(cfgDir, 'secrets', 'mosaic-stack'), { recursive: true, mode: 0o700 });
|
||||
const slot = join(cfgDir, 'secrets', 'mosaic-stack', 'a'); writeFileSync(slot, iss.clientSecret, { mode: 0o600 }); chmodSync(slot, 0o600);
|
||||
const { out, port } = await serve(t, cli, args, env);
|
||||
assert.match(out, /^Bus: acme$/m, 'the config check ran from the copy');
|
||||
assert.match(out, /^Queue: read from the --checkout directory$/m);
|
||||
assert.deepEqual(await get('/healthz'), { status: 200, body: { ok: true } });
|
||||
const health = await fetch(`http://127.0.0.1:${port}/healthz`, { signal: AbortSignal.timeout(10_000) });
|
||||
assert.equal(health.status, 200);
|
||||
// Under --sso the body adds each provider's state beside ok (serve.mjs /healthz).
|
||||
const hb = await health.json();
|
||||
assert.equal(hb.ok, true); assert.deepEqual(Object.keys(hb), ['ok', 'sso']); assert.deepEqual(Object.keys(hb.sso), ['a']);
|
||||
const get = await signIn(port, op, iss);
|
||||
const settings = await get('/api/settings');
|
||||
assert.equal(settings.status, 200);
|
||||
assert.equal(settings.status, 200, JSON.stringify(settings.body));
|
||||
assert.equal(settings.body.release, '0.0.0-fixture', 'RELEASE from the copy');
|
||||
const business = await get('/api/business');
|
||||
assert.equal(business.status, 200, JSON.stringify(business.body));
|
||||
@@ -270,10 +370,10 @@ test('serve runs from a copy: /healthz 200, the config check, roles and RELEASE
|
||||
assert.deepEqual(fields.body, JSON.parse(readFileSync(join(REPO, 'packages', 'tasks', 'fields.json'), 'utf8')));
|
||||
// Without --checkout the copy's own queue read runs, and the copy holds
|
||||
// no packages/queue, so it fails closed.
|
||||
const plain = await serve(t, cli, ['--business', 'acme'], env);
|
||||
const plain = await serve(t, cli, ['--business', 'acme', '--sso'], env);
|
||||
assert.doesNotMatch(plain.out, /^Queue:/m);
|
||||
assert.equal((await plain.get('/healthz')).status, 200);
|
||||
const none = await plain.get('/api/queue');
|
||||
assert.equal((await fetch(`http://127.0.0.1:${plain.port}/healthz`, { signal: AbortSignal.timeout(10_000) })).status, 200);
|
||||
const none = await (await signIn(plain.port, op, iss))('/api/queue');
|
||||
assert.equal(none.status, 503);
|
||||
assert.equal(none.body.error, 'queue-refused');
|
||||
});
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { ownerAccounts } from './helpers/owner-accounts.mjs';
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync } from 'node:fs';
|
||||
@@ -82,7 +83,7 @@ test('real SSO refuses viewer and collaborator and sign-out prevents the saved w
|
||||
{ id: 'g-2', user: 'bob', business: 'demo', projects: ['*'], instances: ['assistant'], level: 'collaborator' },
|
||||
] };
|
||||
const config = { publicOrigin: origin, providers: [{ id: 'a', issuer: issuer.issuer, clientId: issuer.clientId, clientSecret: { slot: 'a' }, algs: ['RS256'], subjects: [{ sub: 'owner-sub', name: 'jason' }] }] };
|
||||
const sso = await createSso(config, { secrets: new Map([['a', issuer.clientSecret]]), fetch: op.fetch, users: consoleAccess({ load: () => access, business: () => 'demo' }), log: () => {} });
|
||||
const sso = await createSso(config, { accounts: ownerAccounts(config), secrets: new Map([['a', issuer.clientSecret]]), fetch: op.fetch, users: consoleAccess({ load: () => access, business: () => 'demo' }), log: () => {} });
|
||||
t.after(() => sso.close());
|
||||
const f = await realRemoteFixture(t), original = readFileSync(f.file);
|
||||
let writes = 0;
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { ownerAccounts } from './helpers/owner-accounts.mjs';
|
||||
// #797 / #1839 Task 3: the Console's request intake routes. The first half drives requestHTTP with a
|
||||
// fake request, response and bus transport. The second half signs in through the fake OP on loopback
|
||||
// and checks the listener's gates, as decide-sso.test.mjs does for decisions. The bus side is
|
||||
@@ -253,7 +254,7 @@ async function listener(t, server = {}, access = null) {
|
||||
const config = { publicOrigin: LISTEN, providers: [{ id: 'a', issuer: iss.issuer, clientId: iss.clientId, clientSecret: { slot: 'a' }, algs: ['RS256'], subjects: [{ sub: 'user-a', name: 'person-a' }] }] };
|
||||
const lines = [];
|
||||
const users = access && { users: consoleAccess({ load: () => access, business: () => 'demo' }) };
|
||||
const sso = await createSso(config, { secrets: new Map([['a', iss.clientSecret]]), fetch: op.fetch, log: l => lines.push(l), ...users });
|
||||
const sso = await createSso(config, { accounts: ownerAccounts(config), secrets: new Map([['a', iss.clientSecret]]), fetch: op.fetch, log: l => lines.push(l), ...users });
|
||||
t.after(() => sso.close());
|
||||
const reads = [], writes = [];
|
||||
const requestRead = async (verb, args, ctx) => { reads.push({ verb, args, ctx }); return { ...STATE, request: args.request }; };
|
||||
|
||||
@@ -867,3 +867,21 @@ test('row196 socket flag renders only for supporting targets and refuses mixed t
|
||||
const both=await run(dir,['render','--tester-socket','--tester-port','7333']);
|
||||
assert.equal(both.status,4);
|
||||
});
|
||||
|
||||
// M3 follow-up 1: this user-manager path cannot represent the dedicated
|
||||
// instance uid required by accounts deployments. No systemctl start is used.
|
||||
test('M3 user-unit path refuses accounts and deployment metadata while checkout development render works', async t => {
|
||||
for (const kind of ['accounts', 'deployed', 'sso', 'stamp']) {
|
||||
const dir = tmp(t), configDir = join(dir,'.config/mosaic-dev'), dataRoot = join(dir,'data'); mkdirSync(configDir,{recursive:true}); mkdirSync(dataRoot);
|
||||
const config = {configVersion:1,environment:'development',dataRoot,execution:{backend:'docker',provider:'zai',model:'test'}};
|
||||
if(kind==='sso') config.console={sso:{publicOrigin:'https://console.test',providers:[{id:'pocket',issuer:'https://id.test',clientId:'test',clientSecret:{slot:'pocket'},algs:['RS256'],subjects:[{sub:'owner',name:'Owner'}]}]}};
|
||||
writeFileSync(join(configDir,'config.json'),JSON.stringify(config));
|
||||
if(kind==='accounts') writeFileSync(join(configDir,'accounts.json'),'{}');
|
||||
if(kind==='deployed') writeFileSync(join(dataRoot,'.deployed'),'{"instance":"dev"}',{mode:0o444});
|
||||
const args = ['render','--port','17890'];
|
||||
if(kind==='stamp') { const copy=fakeCopy(dir); writeFileSync(join(copy,'.stack-build.json'),JSON.stringify({commit:COPY_ID}),{mode:0o444}); args.push('--copy',COPY_ID); }
|
||||
const r=await run(dir,args); assert.notEqual(r.status,0,kind); assert.match(r.stderr,/system units.*case2-unit|accounts.*user.unit/,kind); assert.equal(r.stdout,'');
|
||||
}
|
||||
const dir=tmp(t); const dev=await run(dir,['render','--port','17890']); assert.equal(dev.status,0,dev.stderr);
|
||||
for(const flag of ['--dev-local-owner','--dev-insecure-bind','--dev-allow-host']) {const r=await run(dir,['render',flag,'127.0.0.1']);assert.notEqual(r.status,0,flag);assert.equal(r.stdout,'');}
|
||||
});
|
||||
@@ -1,3 +1,4 @@
|
||||
import { ownerAccounts } from './helpers/owner-accounts.mjs';
|
||||
// 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
|
||||
@@ -56,7 +57,7 @@ async function setup(t, { issuers = [{ name: 'a' }], provider = {}, before = nul
|
||||
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 });
|
||||
const sso = await createSso(config, { accounts: ownerAccounts(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 }]]));
|
||||
@@ -297,7 +298,7 @@ test('11: start refusals: HS or none algs, reserved names, HS-only discovery, an
|
||||
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 => {
|
||||
test('11: slot files: a symlink, a directory, mode 0644, 0640 or 0604 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');
|
||||
@@ -309,24 +310,34 @@ test('11: slot files: a symlink, mode 0644, mode 0640 and a file under the repos
|
||||
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); }
|
||||
for (const [slot, mode] of [['wide', 0o644], ['group', 0o640], ['other', 0o604]]) { writeFileSync(join(slots, slot), value); chmodSync(join(slots, slot), mode); }
|
||||
mkdirSync(join(slots, 'dir'), { mode: 0o700 });
|
||||
// M3 (#1873): a config whose secrets directory is a link into the repository, as stack-release's T57 once pinned.
|
||||
const linked = join(root, 'linked'), inRepo = join(opts.repo, 'secrets');
|
||||
mkdirSync(join(inRepo, 'mosaic-stack'), { recursive: true });
|
||||
mkdirSync(linked);
|
||||
writeFileSync(join(inRepo, 'mosaic-stack', 'good'), `${value}\n`, { mode: 0o600 });
|
||||
symlinkSync(inRepo, join(linked, 'secrets'));
|
||||
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],
|
||||
['dir', /must be a regular file$/, opts],
|
||||
['wide', /must have no group or other permission bits$/, opts],
|
||||
['group', /must have no group or other permission bits$/, opts],
|
||||
['other', /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 }],
|
||||
['good', /must not be inside the repository or a stack copy$/, opts, linked],
|
||||
];
|
||||
for (const [slot, why, o] of cases) {
|
||||
for (const [slot, why, o, cfg = dir] of cases) {
|
||||
let err = null;
|
||||
try { readSlot(dir, provider(slot), o); } catch (e) { err = e; }
|
||||
try { readSlot(cfg, 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);
|
||||
@@ -365,6 +376,7 @@ test('11: the CLI refuses --sso with the old flags and without console.sso, then
|
||||
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);
|
||||
writeFileSync(join(root, 'cfg', 'accounts.json'), JSON.stringify({ accountsVersion: 1, revision: 1, users: ownerAccounts({ providers: [providerFor(a)] }).load().users.map(u => ({ ...u, created: '2026-11-10T00:00:00Z' })), memberships: [], invites: [] }), { mode: 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', '--send'], { env: { ...env, NODE_EXTRA_CA_CERTS: join(root, 'ca.pem') } });
|
||||
t.after(() => child.kill('SIGKILL'));
|
||||
@@ -522,11 +534,11 @@ test('16: a provider with RS256 and ES256 takes either, and refuses PS256 with a
|
||||
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 => {
|
||||
test('17: 129 started logins leave 128 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);
|
||||
for (let i = 0; i < 128; i++) await begin(env);
|
||||
assert.equal(env.sso.logins.size, 128);
|
||||
refused(env, await callback(env, first, { code: grant(env, first, env.iss[0]).code }), 'login-record-missing');
|
||||
});
|
||||
|
||||
|
||||
@@ -46,7 +46,7 @@ async function fixture(t, { writer = 'owner', send = true, afterRead = null, bef
|
||||
// afterRead and beforeWrite let a test act between the route's read and its write.
|
||||
const requestRead = async (verb, args, ctx) => { calls.push(['read', verb]); const r = await new Client({ path, console: { subject: ctx.subject } }).call(verb, args); await afterRead?.(); return r; };
|
||||
const systemTypesWrite = async (verb, args) => { await beforeWrite?.(verb); calls.push(['write', verb]); return runtime.broker.request(cap, { verb, args }); };
|
||||
const session = { provider: 'fixture', iss: 'https://issuer.example', sub: 'owner', name: 'jason' };
|
||||
const session = { provider: 'fixture', iss: 'https://issuer.example', sub: 'owner', name: 'jason', rootOwner: true };
|
||||
let active = true;
|
||||
const sso = { publicOrigin: '', publicHost: '', formTargets: () => [], session: () => active ? session : null, handle: () => {}, health: () => ({}) };
|
||||
const organizations = {
|
||||
@@ -61,7 +61,7 @@ async function fixture(t, { writer = 'owner', send = true, afterRead = null, bef
|
||||
const project = runtime.broker.request(owner, { verb: 'project.create', args: { name: 'example' } }).id;
|
||||
const onDisk = () => JSON.parse(readFileSync(file)).plugins['system-type'];
|
||||
const ownerRequest = (verb, args) => runtime.broker.request(owner, { verb, args });
|
||||
return { base, file, project, calls, onDisk, ownerRequest, signOut: () => { active = false; } };
|
||||
return { base, file, project, calls, onDisk, ownerRequest, signOut: () => { active = false; }, revokeOwner: () => { session.rootOwner = false; session.user = 'legacy-user'; } };
|
||||
}
|
||||
|
||||
function call(f, path, method = 'GET', body, headers = {}) {
|
||||
@@ -147,6 +147,21 @@ test('a sign-out while the read runs dispatches no write on either route', async
|
||||
assert.deepEqual(f.onDisk(), ['coding']);
|
||||
});
|
||||
|
||||
test('root authority revoked during a read cannot write through a surviving user session', async t => {
|
||||
for (const projectRoute of [false, true]) {
|
||||
let revokeOnRead = false;
|
||||
const f = await fixture(t, { afterRead: () => { if (revokeOnRead) f.revokeOwner(); } });
|
||||
const [, { revision }] = await call(f, ORG);
|
||||
revokeOnRead = true;
|
||||
const path = projectRoute ? `${ORG}/projects/${f.project}` : ORG;
|
||||
const body = projectRoute ? { systemTypes: ['coding'] } : { revision, enabled: [] };
|
||||
const [status, result] = await call(f, path, 'POST', body);
|
||||
assert.deepEqual([status, result.error], [401, 'sign-in-required'], path);
|
||||
assert.equal(f.calls.filter(c => c[0] === 'write' && c[1] !== 'plugins').length, 0);
|
||||
assert.deepEqual(f.onDisk(), ['coding']);
|
||||
}
|
||||
});
|
||||
|
||||
test('the enabled set and its revision come from one read, so a change in between is not undone', async t => {
|
||||
let raced = false;
|
||||
const f = await fixture(t, { beforeWrite: async verb => {
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { ownerAccounts } from './helpers/owner-accounts.mjs';
|
||||
// R6-N1 (#1807, row 294 N1): the user-session option swap in serve.mjs, driven through HTTP.
|
||||
// granted-user.test.mjs G4 stops every owner route at the userRoute gate, so it never reaches the
|
||||
// swap behind it. Here serve.mjs loads with that gate stubbed open (a load hook keyed on one query
|
||||
@@ -87,7 +88,7 @@ async function setup(t) {
|
||||
const userOptions = userOptionsFor({ owner: ownerCall, send: true, transport: name => { opened.push(name); return userTransport(name); } });
|
||||
const lines = [];
|
||||
const config = { publicOrigin: ORIGIN, providers: [{ id: 'a', issuer: iss.issuer, clientId: iss.clientId, clientSecret: { slot: 'a' }, algs: ['RS256'], subjects: [{ sub: 'owner-sub', name: 'jason' }] }] };
|
||||
const sso = await createSso(config, { secrets: new Map([['a', iss.clientSecret]]), fetch: op.fetch, log: l => lines.push(l), users: consoleAccess({ load, business: () => 'demo' }) });
|
||||
const sso = await createSso(config, { accounts: ownerAccounts(config), secrets: new Map([['a', iss.clientSecret]]), fetch: op.fetch, log: l => lines.push(l), users: consoleAccess({ load, business: () => 'demo' }) });
|
||||
t.after(() => sso.close());
|
||||
// Every owner option is a stub that records its own name.
|
||||
const stub = name => async () => { owner.push(['option', name]); return name === 'send' ? { id: 'msg-0001' } : [{ verb: name }]; };
|
||||
|
||||
@@ -203,6 +203,9 @@ if [ ! -e "$CONFIG_DIR" ]; then
|
||||
fi
|
||||
out="$("$REPO/scripts/bootstrap.sh" 2>&1)" || refuse "bootstrap: $(printf '%s' "$out" | tail -n 1)"
|
||||
say "config $CONFIG"
|
||||
# Case 1 and Case 2 share this installer. Deployment identity is instance-scoped,
|
||||
# never a switch that can be silently removed to enable the local dev owner.
|
||||
out="$(cd "$REPO" && node --input-type=module -e 'import { readFileSync } from "node:fs"; import { writeDeployment } from "./packages/cli/src/deployment.mjs"; writeDeployment(JSON.parse(readFileSync(process.argv[1], "utf8")));' "$CONFIG" 2>&1)" || refuse "deployment: $(printf '%s' "$out" | tail -n 1)"
|
||||
|
||||
# 3. The stack copy row 195's units run from.
|
||||
out="$("$STACK_COPY" copy "$COMMIT" --copies "$COPIES" 2>&1)" || refuse "stack-copy: $(printf '%s' "$out" | tail -n 1)"
|
||||
@@ -250,8 +253,12 @@ if ! out="$("$REPO/scripts/mosaic" business validate "$BUSINESS" 2>&1)"; then
|
||||
fi
|
||||
say "business $BUSINESS validates"
|
||||
|
||||
# 8. What comes next. Root's steps run from root's own checkout.
|
||||
say "done. Next: root runs case2-unit.sh --copy $COMMIT --business $BUSINESS (add --proxy when the config has a proxy section; design 10 step 2c), then 2d and 3."
|
||||
# 8. What comes next. Root's steps run from root's own checkout. The copy is stamped and .deployed
|
||||
# names this instance (step 2), so its Console starts only with SSO accounts (M3, #1873). The steps
|
||||
# before root's are the run sheet's (section 7), and nothing here does them: SSO is the operator's
|
||||
# config, accounts.json comes only from the human verb, and case2-unit.sh refuses until both exist.
|
||||
say "done. .deployed names this instance and the copy is stamped, so its Console starts only with SSO accounts. Before the units, in order (run sheet section 7): prepare console.sso and its client secret slots in $CONFIG; a human runs \`mosaic accounts init\` as $(id -un); $(id -un) stays a dedicated uid with a non-login shell, the only member of its primary group."
|
||||
say "Next: root runs case2-unit.sh --copy $COMMIT --business $BUSINESS with --sso in the serve flags (factory: --sso --send; add --proxy when the config has a proxy section; design 10 step 2c), then 2d and 3. It refuses before any write and names each step still missing."
|
||||
if [ "$PROFILE" = library ]; then
|
||||
say "Then Jason places the library token in $SLOT (step 4), and <user> runs case2-precheck.sh --business $BUSINESS --library-status (step 4a)."
|
||||
fi
|
||||
|
||||
+112
-13
@@ -5,9 +5,15 @@
|
||||
# mosaic-proxy.service under --proxy, from the existing templates, which it
|
||||
# doesn't change.
|
||||
#
|
||||
# case2-unit.sh --user NAME --home DIR --business ID --copy ID --node PATH
|
||||
# [--profile factory|library] [--proxy] [--pm] [--claude PATH]
|
||||
# -- SERVE FLAGS
|
||||
# case2-unit.sh [--check|--check-new] --user NAME --home DIR --business ID
|
||||
# --copy ID --node PATH [--profile factory|library] [--proxy]
|
||||
# [--pm] [--claude PATH] -- SERVE FLAGS
|
||||
#
|
||||
# --check runs every check a render runs, the installed units' included, then
|
||||
# exits 0 without writing, enabling or starting anything. --check-new does the
|
||||
# same as a first install would, without the installed units' checks; it is for
|
||||
# stack-upgrade.sh --next-args, which moves the installed units aside before its
|
||||
# render (docs/guides/host-run-sheet.md section 7). Neither writes a file.
|
||||
#
|
||||
# --org ID is the same option as --business ID (rename design r2), in each of
|
||||
# two scopes: this script's own flags before --, and the serve flags after it.
|
||||
@@ -36,6 +42,10 @@
|
||||
# --proof-header NAME --proof-file PATH
|
||||
# It is proxy-preview read-only until Case 2 supports --sso (#412); --send
|
||||
# and --sso are explicitly refused. The proof still protects proxy preview reads.
|
||||
# That holds for an unstamped copy only. A stamped library copy (M3, #1873;
|
||||
# Jason's ruling 2026-11-10) signs in through SSO accounts like every
|
||||
# deployed Console: it takes --port, --business and a bare --sso, refuses
|
||||
# every proxy-preview and proof flag, and still refuses --send.
|
||||
# The factory profile takes --port N (1..1023) and --business ID, and an
|
||||
# optional bare --tester-socket marker. It also takes the paired bare
|
||||
# --sso --send (#415): the Console signs its owner in from console.sso, which
|
||||
@@ -43,6 +53,15 @@
|
||||
# preview and proof flags still refuse. Retired tester TCP refuses. Only
|
||||
# factory takes --pm and --claude.
|
||||
#
|
||||
# A stamped copy (.stack-build.json, from stack-copy.sh) starts only with
|
||||
# --sso, console.sso, a loadable accounts.json and a .deployed naming the
|
||||
# default instance (packages/webui/src/cli.mjs, M3 #1873). For a stamped copy,
|
||||
# in either profile, the render asks the stack user's own copy for those
|
||||
# facts and refuses before any write, naming every missing step in the run
|
||||
# sheet's order (docs/guides/host-run-sheet.md section 7). It writes none of
|
||||
# them and picks no subject: .deployed comes from case2-install.sh, and
|
||||
# accounts.json from the human verb `mosaic accounts init`.
|
||||
#
|
||||
# On a re-install the installed units must equal the render in everything
|
||||
# but the copy id, and all selected units must name one copy, so a re-pin or rollback
|
||||
# re-renders them together. A unit that was already running stays on its
|
||||
@@ -72,7 +91,7 @@ PROXY_UNIT=mosaic-proxy.service
|
||||
BUS_UNIT=[email protected]
|
||||
WEBUI_UNIT=mosaic-webui.service
|
||||
|
||||
usage() { echo 'usage: case2-unit.sh --user NAME --home DIR --business ID --copy ID --node PATH [--profile factory|library] [--proxy] [--pm] [--claude PATH] -- SERVE FLAGS' >&2; exit 4; }
|
||||
usage() { echo 'usage: case2-unit.sh [--check|--check-new] --user NAME --home DIR --business ID --copy ID --node PATH [--profile factory|library] [--proxy] [--pm] [--claude PATH] -- SERVE FLAGS' >&2; exit 4; }
|
||||
die() { echo "case2-unit: $*" >&2; exit 1; }
|
||||
# A value the unit carries as itself: no space, %, $, quote, backslash, & or |.
|
||||
plain() { case "$1" in *[!A-Za-z0-9/._+@-]*) return 1 ;; esac; }
|
||||
@@ -103,7 +122,8 @@ serve_refused() {
|
||||
*) die "serve $2" ;;
|
||||
esac
|
||||
}
|
||||
PROFILE=library USER_NAME='' HOME_DIR='' BUSINESS='' COPY_ID='' NODE='' PROXY=0 PM=0 CLAUDE='' CLAUDE_SET=0
|
||||
PROFILE=library USER_NAME='' HOME_DIR='' BUSINESS='' COPY_ID='' NODE='' PROXY=0 PM=0 CLAUDE='' CLAUDE_SET=0 CHECK=''
|
||||
case "${1-}" in --check|--check-new) CHECK="$1"; shift ;; esac
|
||||
while [ $# -gt 0 ]; do
|
||||
case "$1" in
|
||||
--business|--org) BUSINESS="${2-}"; selector_take outer id outer_refused business_id_ok "$1" "${@:2:1}"; shift 2 ;;
|
||||
@@ -136,12 +156,19 @@ fi
|
||||
# The user, its uid, primary group and home, from the passwd database.
|
||||
[[ "$USER_NAME" =~ ^[a-z_][a-z0-9_-]{0,31}$ ]] || die "--user must be a user name: $USER_NAME"
|
||||
PW="$("$GETENT" passwd "$USER_NAME")" || die "no user $USER_NAME in the passwd database"
|
||||
IFS=: read -r _ _ USER_UID USER_GID _ PW_HOME _ <<<"$PW"
|
||||
IFS=: read -r PW_NAME _ USER_UID USER_GID _ PW_HOME PW_SHELL <<<"$PW"
|
||||
[[ "$USER_UID" =~ ^[0-9]+$ ]] && [ "$USER_UID" -ne 0 ] || die "--user must not be root: $USER_NAME"
|
||||
[ "$PW_NAME" = "$USER_NAME" ] || die "passwd name does not match --user"
|
||||
case "$PW_SHELL" in /usr/sbin/nologin|/sbin/nologin|/bin/false|/usr/bin/false) ;; *) die "--user requires a non-login shell" ;; esac
|
||||
abs_plain "$HOME_DIR" '--home'
|
||||
[ "$HOME_DIR" = "$PW_HOME" ] || die "--home $HOME_DIR is not $USER_NAME's home in the passwd database ($PW_HOME)"
|
||||
GR="$("$GETENT" group "$USER_GID")" || die "no group $USER_GID in the group database"
|
||||
GROUP="${GR%%:*}"
|
||||
IFS=: read -r GROUP _ GROUP_GID GROUP_MEMBERS <<<"$GR"
|
||||
[ "$GROUP_GID" = "$USER_GID" ] || die "primary group id mismatch"
|
||||
IFS=, read -r -a PRIMARY_MEMBERS <<<"$GROUP_MEMBERS"
|
||||
for member in "${PRIMARY_MEMBERS[@]}"; do [ -z "$member" ] || [ "$member" = "$USER_NAME" ] || die "primary group must be sole-member"; done
|
||||
PASSWD_ALL="$("$GETENT" passwd)" || die "cannot enumerate primary group members"
|
||||
printf '%s\n' "$PASSWD_ALL" | awk -F: -v gid="$USER_GID" -v user="$USER_NAME" '$4 == gid && $1 != user { bad=1 } $4 == gid && $1 == user { seen++ } END { exit bad || seen != 1 }' || die "primary group must be sole-member"
|
||||
[[ "$GROUP" =~ ^[a-z_][a-z0-9_-]{0,31}$ ]] || die "the primary group of $USER_NAME is not a group name: $GROUP"
|
||||
|
||||
case "$BUSINESS" in [a-z0-9]*) ;; *) die "--business must be a business id: $BUSINESS" ;; esac
|
||||
@@ -154,6 +181,9 @@ COPIES="$HOME_DIR/.local/share/mosaic-stack/copies"
|
||||
COPY="$COPIES/$COPY_ID"
|
||||
[ -d "$COPY" ] && [ ! -L "$COPY" ] || die "no copy directory $COPY"
|
||||
[ -f "$COPY/scripts/mosaic" ] || die "no copy $COPY_ID in $COPIES: $COPY/scripts/mosaic is missing"
|
||||
# M3: presence alone decides, as the Console's buildStamp does (lstat), so a broken link counts.
|
||||
STAMPED=0
|
||||
if [ -e "$COPY/.stack-build.json" ] || [ -L "$COPY/.stack-build.json" ]; then STAMPED=1; fi
|
||||
|
||||
# Owned by root and writable by no one else, the file and every directory above it.
|
||||
root_only() {
|
||||
@@ -382,11 +412,13 @@ serve_flags() {
|
||||
--tester-socket) [ "$PROFILE" = factory ] || die "the library profile refuses --tester-socket" ;;
|
||||
--checkout) die "refusing --checkout: Case 2 has no queue panel" ;;
|
||||
--board) die "refusing --board: Case 2 runs no control board" ;;
|
||||
--sso) [ "$PROFILE" = factory ] || die "refusing --sso: the Console signs people in through the proxy, with the preview set and the proof pair (Q175 phase 2 is a follow-up)" ;;
|
||||
--send) [ "$PROFILE" = factory ] || die "the library profile is read-only; refusing --send until Case 2 supports --sso (#412)" ;;
|
||||
--sso) [ "$PROFILE" = factory ] || [ "$STAMPED" = 1 ] || die "refusing --sso: the Console signs people in through the proxy, with the preview set and the proof pair (Q175 phase 2 is a follow-up)" ;;
|
||||
--send) [ "$PROFILE" = factory ] || [ "$STAMPED" = 0 ] || die "the library profile is read-only; refusing --send (a stamped library Console signs in with --sso and stays read-only)"
|
||||
[ "$PROFILE" = factory ] || die "the library profile is read-only; refusing --send until Case 2 supports --sso (#412)" ;;
|
||||
--port|--business|--org) ;;
|
||||
--public-host|--auth-header|--allow-user|--proof-header|--proof-file)
|
||||
[ "$PROFILE" = library ] || die "the factory profile refuses $name: its host has no proxy" ;;
|
||||
[ "$PROFILE" = library ] || die "the factory profile refuses $name: its host has no proxy"
|
||||
[ "$STAMPED" = 0 ] || die "refusing $name: $COPY_ID is a stamped copy, and proxy-preview admission and its proof do not apply to a stamped copy; its Console signs in through SSO accounts with --sso" ;;
|
||||
*) die "unknown serve flag: $name" ;;
|
||||
esac
|
||||
[ -z "${seen[$name]:-}" ] || die "serve flag $name given twice"
|
||||
@@ -427,7 +459,7 @@ serve_flags() {
|
||||
i=$((i + 2))
|
||||
done
|
||||
local required=(--port --business)
|
||||
[ "$PROFILE" = factory ] || required+=(--public-host --auth-header --allow-user --proof-header --proof-file)
|
||||
[ "$PROFILE" = factory ] || [ "$STAMPED" = 1 ] || required+=(--public-host --auth-header --allow-user --proof-header --proof-file)
|
||||
for name in "${required[@]}"; do
|
||||
[ -n "${seen[$name]:-}" ] || { [ "$name" = --business ] && [ -n "${seen[--org]:-}" ]; } || die "serve flag $name is required"
|
||||
done
|
||||
@@ -436,8 +468,10 @@ serve_flags() {
|
||||
# proof file is inferred. The Console refuses --sso without console.sso
|
||||
# at start; this asks the stack user's config first, before any write.
|
||||
[ "$sso" = 0 ] || [ -n "${seen[--send]:-}" ] || die "refusing --sso without --send in the factory profile"
|
||||
[ -z "${seen[--send]:-}" ] || [ "$sso" = 1 ] || die "the factory profile refuses --send without --sso"
|
||||
if [ "$sso" = 1 ]; then
|
||||
# A stamped copy without --sso is a missing step, which stamped_checks names with the others.
|
||||
[ -z "${seen[--send]:-}" ] || [ "$sso" = 1 ] || [ "$STAMPED" = 1 ] || die "the factory profile refuses --send without --sso"
|
||||
# A stamped copy's console.sso is one of stamped_checks' steps, asked in the same query.
|
||||
if [ "$sso" = 1 ] && [ "$STAMPED" = 0 ]; then
|
||||
local answer
|
||||
answer="$(tester_capture 5 7 4096 "$USER_NAME" "$HOME_DIR" "$UNIT_PATH" "$NODE" --input-type=module -e "$SSO_QUERY" "$COPY/packages/cli/src/config.mjs")" || die "sso config query $(tester_capture_reason "$?")"
|
||||
[ "$answer" != '{"sso":false}' ] || die "--sso needs console.sso in $USER_NAME's system config"
|
||||
@@ -452,6 +486,8 @@ serve_flags() {
|
||||
fi
|
||||
echo "${flags# }"; return 0
|
||||
fi
|
||||
# A stamped library copy has no proxy-preview flag left to check; stamped_checks follows.
|
||||
if [ "$STAMPED" = 1 ]; then echo "${flags# }"; return 0; fi
|
||||
# Lead decision 101: the uid header carries the Authentik uid.
|
||||
if [ "${auth_header,,}" = x-authentik-uid ] && ! [[ "$allow_user" =~ ^[0-9a-f]{64}$ ]]; then
|
||||
die "with --auth-header $auth_header the allowed user must be an Authentik uid, 64 lower-case hex characters (lead decision 101): $allow_user"
|
||||
@@ -478,6 +514,61 @@ NODE_DIR="$(dirname -- "$NODE")"
|
||||
UNIT_PATH="$NODE_DIR"
|
||||
for d in /usr/local/bin /usr/bin /bin; do [ "$d" = "$NODE_DIR" ] || UNIT_PATH="$UNIT_PATH:$d"; done
|
||||
FLAGS="$(serve_flags)" || exit 1
|
||||
SSO_FLAG=0
|
||||
case " $FLAGS " in *" --sso "*) SSO_FLAG=1 ;; esac
|
||||
|
||||
# The config query runs after dropping uid, through the bounded supervisor.
|
||||
# Root reads only checked facts and stats the two owned directories.
|
||||
HOST_FACTS="$(tester_capture 5 7 4096 "$USER_NAME" "$HOME_DIR" "$UNIT_PATH" "$NODE" --input-type=module -e "$TESTER_QUERY" "$COPY/packages/cli/src/config.mjs" "$COPY/packages/webui/src/cli.mjs")" || die "host config query $(tester_capture_reason "$?")"
|
||||
tester_facts "$HOST_FACTS" || die "host config query has invalid facts"
|
||||
owned_directory() {
|
||||
local dir="$1" what="$2" real st
|
||||
abs_plain "$dir" "$what"
|
||||
[ -d "$dir" ] && [ ! -L "$dir" ] || die "$what directory must exist without a symlink"
|
||||
real="$(realpath -e -- "$dir")" || die "cannot resolve $what directory"
|
||||
[ "$real" = "$dir" ] || die "$what directory contains a symlink"
|
||||
st="$(stat -c '%u %a' -- "$dir")" || die "cannot stat $what directory"
|
||||
[ "${st% *}" = "$USER_UID" ] || die "$what directory must be owned by $USER_NAME uid $USER_UID"
|
||||
(( (8#${st#* } & 8#022) == 0 )) || die "$what directory must not be group or other writable"
|
||||
}
|
||||
owned_directory "$HOME_DIR/.config/mosaic-dev" config
|
||||
owned_directory "$TS_DATA" data-root
|
||||
|
||||
# M3 (#1873): a stamped copy's Console starts only with --sso, console.sso, accounts.json that
|
||||
# loads and a .deployed naming the default instance (packages/webui/src/cli.mjs). The stack user's
|
||||
# own copy answers, through the same bounded supervisor, with its loadSystem, loadAccounts and
|
||||
# assertDeployed, so the render asks exactly what the start will. Root reads three checked facts.
|
||||
# Every missing step is named, in the run sheet's order, before any write. Without console.sso an
|
||||
# accounts.json is "unchecked": its subjects name providers SSO has not configured yet, so the SSO
|
||||
# step comes first and the next render checks it. Nothing here writes .deployed or accounts.json,
|
||||
# or picks a subject.
|
||||
STAMP_QUERY='import {join} from "node:path";import {homedir} from "node:os";import {pathToFileURL} from "node:url";const u=(f)=>import(pathToFileURL(f).href);const [c,d,a]=await Promise.all(process.argv.slice(1,4).map(u));const s=c.loadSystem();const dir=join(homedir(),".config/mosaic-dev");const fact=(file,check)=>{if(!d.present(file))return "missing";try{check();return "ok"}catch{return "invalid"}};const accounts=!s.console?.sso&&d.present(join(dir,"accounts.json"))?"unchecked":fact(join(dir,"accounts.json"),()=>a.loadAccounts(join(dir,"accounts.json"),{providers:s.console?.sso?.providers??[]}));const deployed=fact(join(s.dataRoot,".deployed"),()=>d.assertDeployed(s,dir));console.log(JSON.stringify({sso:Boolean(s.console&&s.console.sso),accounts,deployed}));'
|
||||
stamped_checks() {
|
||||
[ "$STAMPED" = 1 ] || return 0
|
||||
local answer pattern='^\{"sso":(true|false),"accounts":"(ok|missing|invalid|unchecked)","deployed":"(ok|missing|invalid)"\}$'
|
||||
local -a steps=()
|
||||
answer="$(tester_capture 5 7 4096 "$USER_NAME" "$HOME_DIR" "$UNIT_PATH" "$NODE" --input-type=module -e "$STAMP_QUERY" "$COPY/packages/cli/src/config.mjs" "$COPY/packages/cli/src/deployment.mjs" "$COPY/packages/organization/src/accounts.mjs")" || die "stamped copy query $(tester_capture_reason "$?")"
|
||||
[[ "$answer" =~ $pattern ]] || die "stamped copy query has invalid facts"
|
||||
local sso="${BASH_REMATCH[1]}" accounts="${BASH_REMATCH[2]}" deployed="${BASH_REMATCH[3]}"
|
||||
case "$deployed" in
|
||||
missing) steps+=("check or create .deployed: $USER_NAME runs case2-install.sh, whose deployment step writes $TS_DATA/.deployed") ;;
|
||||
invalid) steps+=("check .deployed: $TS_DATA/.deployed is invalid or names another instance; case2-install.sh's deployment step reports why and never replaces it") ;;
|
||||
esac
|
||||
[ "$sso" = true ] || steps+=("prepare SSO: console.sso in $USER_NAME's system config $HOME_DIR/.config/mosaic-dev/config.json, with its client secret slots")
|
||||
case "$accounts" in
|
||||
missing) steps+=("run \`mosaic accounts init\` as $USER_NAME, a human verb: no $HOME_DIR/.config/mosaic-dev/accounts.json") ;;
|
||||
invalid) steps+=("fix $HOME_DIR/.config/mosaic-dev/accounts.json: it does not load (its owner or mode, an active operator, or a subject console.sso does not name); a human fixes it with the accounts verbs") ;;
|
||||
esac
|
||||
if [ "$SSO_FLAG" = 0 ]; then
|
||||
if [ "$PROFILE" = factory ]; then steps+=("add --sso --send to the serve flags, then re-pin")
|
||||
else steps+=("add --sso to the serve flags, then re-pin"); fi
|
||||
fi
|
||||
[ "${#steps[@]}" -gt 0 ] || return 0
|
||||
local text="refusing the units for $COPY_ID: a stamped copy's Console starts only with SSO accounts; missing steps, in order:" i=1 step
|
||||
for step in "${steps[@]}"; do text+=$'\n'" $i. $step"; i=$((i + 1)); done
|
||||
die "$text"
|
||||
}
|
||||
stamped_checks
|
||||
|
||||
HARDENING="ProtectSystem=strict
|
||||
ReadWritePaths=$HOME_DIR
|
||||
@@ -636,6 +727,7 @@ else
|
||||
|| die "installed $PROXY_UNIT requires --proxy; nothing was changed"
|
||||
fi
|
||||
for pair in "${PAIRS[@]}"; do
|
||||
[ "$CHECK" != --check-new ] || break
|
||||
IFS=: read -r kind name var <<<"$pair"
|
||||
path="$UNIT_DIR/$name"
|
||||
[ ! -L "$path" ] || die "refusing: $path is a symlink"
|
||||
@@ -653,6 +745,13 @@ read -r -a ids <<<"$installed_ids"
|
||||
for old in "${ids[@]}"; do
|
||||
[ "$old" = "${ids[0]}" ] || die "the installed units name different copies, ${ids[0]} and $old"
|
||||
done
|
||||
if [ "$CHECK" = --check ]; then
|
||||
echo "case2-unit: check passed: the units for copy $COPY_ID render, and the installed ones match them; nothing was written"
|
||||
exit 0
|
||||
elif [ "$CHECK" = --check-new ]; then
|
||||
echo "case2-unit: check passed: the units for copy $COPY_ID render as a first install; the installed ones were not compared; nothing was written"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# `systemctl start` leaves a running unit on the copy it started from, so
|
||||
# note which units run before the write (row 195 owns the restart).
|
||||
|
||||
@@ -81,6 +81,7 @@ copy_tree() {
|
||||
git -C "$1" archive --format=tar "$full" -- "${COPY_PATHS[@]}" | tar -xf - -C "$tmp" || die "git archive failed; no copy written"
|
||||
[ -f "$tmp/package.json" ] && [ -f "$tmp/package-lock.json" ] || die 'package.json or package-lock.json missing in temp copy; npm ci not run'
|
||||
(cd "$tmp" && npm ci --no-audit --no-fund) >&2 || die 'npm ci failed; no copy written'
|
||||
printf '{"commit":"%s"}\n' "$full" > "$tmp/.stack-build.json"
|
||||
find "$tmp" -type f -exec chmod a-w {} +
|
||||
find "$tmp" -type d -exec chmod 0555 {} +
|
||||
if ! mv -T "$tmp" "$target" 2>/dev/null; then
|
||||
|
||||
+218
-17
@@ -354,6 +354,25 @@ check_target() {
|
||||
refuse already-active "$TO is the pointer's copy"
|
||||
}
|
||||
|
||||
# M3 (#1873): an accounts instance runs dedicated system units, never the operator's systemd user
|
||||
# manager. webui-service.sh install refuses the same four signs, but only after the run stopped the
|
||||
# units; this refuses before anything stops. The run goes through --units external: root renders
|
||||
# the units with case2-unit.sh, and activate, confirm, revert and rollback stage and gate them.
|
||||
# pm-on has no external form, so it passes its own ending ($1). TO names the copy to check.
|
||||
user_units_allowed() {
|
||||
local why
|
||||
why="$(node -e '
|
||||
const fs=require("fs"),p=require("path"),[root,dir,file]=process.argv.slice(1);
|
||||
const present=(f)=>{try{fs.lstatSync(f);return true}catch(e){if(e.code==="ENOENT")return false;throw e}};
|
||||
if(present(p.join(root,".stack-build.json"))){console.log(root+" is a stamped copy");process.exit(1)}
|
||||
if(present(p.join(dir,"accounts.json"))){console.log(p.join(dir,"accounts.json")+" is present");process.exit(1)}
|
||||
if(present(file)){let s;try{s=JSON.parse(fs.readFileSync(file,"utf8"))}catch(e){console.log(file+" cannot be read as JSON");process.exit(1)}
|
||||
if(s.console?.sso){console.log(file+" holds console.sso");process.exit(1)}
|
||||
if(typeof s.dataRoot==="string"&&present(p.join(s.dataRoot,".deployed"))){console.log(p.join(s.dataRoot,".deployed")+" is present");process.exit(1)}}
|
||||
' -- "$COPIES/$TO" "$MOSAIC_INSTANCE_CONFIG_DIR" "$MOSAIC_INSTANCE_CONFIG" 2>&1)" ||
|
||||
refuse accounts-user-unit "${why:-the accounts signs cannot be read}; an accounts instance runs system units, so root renders them with case2-unit.sh${1:- and the $OP runs with --units external}"
|
||||
}
|
||||
|
||||
# --- Plug-in kinds (row 169) ---
|
||||
|
||||
# Unlike copy_schema, these import the copy's own code, as a business-check does: its kinds.mjs
|
||||
@@ -1552,6 +1571,7 @@ do_activate() {
|
||||
run_external migrate
|
||||
return
|
||||
fi
|
||||
user_units_allowed
|
||||
if [ "$PLAN" = 1 ]; then check_target; console_for_target; ports_unclaimed; plugin_checks; config_checks; plan; exit 0; fi
|
||||
precheck
|
||||
plugin_checks
|
||||
@@ -1583,6 +1603,7 @@ do_rollback() {
|
||||
run_external restore
|
||||
return
|
||||
fi
|
||||
user_units_allowed
|
||||
if [ "$PLAN" = 1 ]; then check_target; console_for_target; ports_unclaimed; plugin_checks; config_checks; plan; exit 0; fi
|
||||
precheck
|
||||
plugin_checks
|
||||
@@ -1935,6 +1956,8 @@ do_pm_on() {
|
||||
out="$(plugins harness "$P_COPY" 2>&1)" || refuse harness-unset "$P_COPY's loader refuses the business file: $out"
|
||||
[ "$out" != none ] || refuse harness-unset "the business file sets no harness; Jason sets it with plugins set first"
|
||||
node -e 'const h=JSON.parse(process.argv[1]);process.exit(Array.isArray(h)&&h.length>0?0:1)' -- "$out" 2>/dev/null || refuse harness-unset "the business file's harness is $out, not a non-empty list"
|
||||
TO="$P_COPY"
|
||||
user_units_allowed ", and the PM is a flag in root's bus unit"
|
||||
instance_busy
|
||||
drop_in_override
|
||||
read_units || refuse unit-flags-drift "cannot read $BUS_UNIT or $CONSOLE_UNIT"
|
||||
@@ -2018,12 +2041,16 @@ r_gone() {
|
||||
while r_alive "$1"; do [ "$SECONDS" -lt "$deadline" ] || return 1; sleep 1; done
|
||||
}
|
||||
|
||||
# Stops the Console, then the host, with SIGTERM within --timeout; one still there gets SIGKILL
|
||||
# and a FAIL line. The host has to leave the store's writer.lock behind it.
|
||||
# Stops the Console, the host, then the rehearsal provider, with SIGTERM within --timeout; one
|
||||
# still there gets SIGKILL and a FAIL line. The host has to leave the store's writer.lock behind it.
|
||||
r_stop() {
|
||||
local name pid
|
||||
for name in console host; do
|
||||
if [ "$name" = console ]; then pid="$R_CONSOLE_PID"; R_CONSOLE_PID=""; else pid="$R_HOST_PID"; R_HOST_PID=""; fi
|
||||
for name in console host provider; do
|
||||
case "$name" in
|
||||
console) pid="$R_CONSOLE_PID"; R_CONSOLE_PID="" ;;
|
||||
host) pid="$R_HOST_PID"; R_HOST_PID="" ;;
|
||||
provider) pid="$R_PROVIDER_PID"; R_PROVIDER_PID="" ;;
|
||||
esac
|
||||
[ -n "$pid" ] || continue
|
||||
kill -TERM "$pid" 2>/dev/null || true
|
||||
if r_gone "$pid"; then r_pass "$name-stopped" "pid $pid"
|
||||
@@ -2045,7 +2072,7 @@ r_end() {
|
||||
r_stop
|
||||
if [ -n "$R" ]; then
|
||||
if [ "$(r_tree_sums "$R_BK")" = "$(cat "$R/backup.before")" ]; then r_pass backup-unchanged; else r_fail backup-unchanged "$R_BK differs from $R/backup.before"; fi
|
||||
if [ "$(sha256sum "$LIVE_CFG" "$LIVE_BIZ" 2>&1)" = "$(cat "$R/live.before")" ]; then r_pass live-config-unchanged; else r_fail live-config-unchanged "$LIVE_CFG or $LIVE_BIZ differs from $R/live.before"; fi
|
||||
if [ "$(sha256sum "${LIVE_FILES[@]}" 2>&1)" = "$(cat "$R/live.before")" ]; then r_pass live-config-unchanged; else r_fail live-config-unchanged "${LIVE_FILES[*]} differs from $R/live.before"; fi
|
||||
fi
|
||||
if [ "$R_RC" = 0 ]; then r_line REHEARSAL_PASS; else r_line REHEARSAL_FAIL; fi
|
||||
[ -z "$R" ] || echo "rehearsal directory: $R"
|
||||
@@ -2056,10 +2083,96 @@ r_port_free() {
|
||||
node -e 'const s=require("node:net").createServer();s.once("error",()=>process.exit(1));s.listen(Number(process.argv[1]),"127.0.0.1",()=>s.close(()=>process.exit(0)));' -- "$1"
|
||||
}
|
||||
|
||||
# The scratch config: the live one with dataRoot set to the scratch data root.
|
||||
# The scratch config: the live one with dataRoot set to the scratch data root. M3 (#1873): a
|
||||
# console.sso keeps its provider ids, client ids, algorithms and subjects; the public origin moves to
|
||||
# rehearsal.invalid, a name that never resolves (RFC 6761). For a stamped copy, whose Console signs
|
||||
# in, every issuer moves to the rehearsal's own provider (R_PROVIDER_JS, the origin argument). An
|
||||
# unstamped copy's Console runs without --sso, so its issuers move to rehearsal.invalid too. No
|
||||
# subject is added; the live config's own are the only ones.
|
||||
R_CONFIG_JS='
|
||||
const fs=require("node:fs");const [file,data]=process.argv.slice(1);
|
||||
const c=JSON.parse(fs.readFileSync(file,"utf8"));c.dataRoot=data;process.stdout.write(JSON.stringify(c,null,2)+"\n");'
|
||||
const fs=require("node:fs");const [file,data,op]=process.argv.slice(1);
|
||||
const c=JSON.parse(fs.readFileSync(file,"utf8"));c.dataRoot=data;
|
||||
const sso=c.console?.sso;
|
||||
if(sso&&typeof sso==="object"){
|
||||
sso.publicOrigin="https://rehearsal.invalid";
|
||||
for(const p of Array.isArray(sso.providers)?sso.providers:[])if(p&&typeof p==="object")p.issuer=`${op||"https://rehearsal.invalid"}/${encodeURIComponent(String(p.id))}${op?"/":""}`;
|
||||
}
|
||||
process.stdout.write(JSON.stringify(c,null,2)+"\n");'
|
||||
|
||||
# M3 (#1873): the rehearsal's own OpenID provider, for a stamped copy's --sso Console. An https
|
||||
# server on 127.0.0.1, port 0, with a certificate openssl makes for this run in DIR. Its TLS key is
|
||||
# read into memory and its file removed before the server listens; cert.pem stays for the scratch
|
||||
# Console's NODE_EXTRA_CA_CERTS. Per configured provider, at /<id>/, it serves discovery and a JWKS
|
||||
# holding one public key made in memory for the first of the provider's algorithms it can make.
|
||||
# Authorization and token answer 403 to any method and query; another method on discovery or the
|
||||
# JWKS is 405 and any other route 404: it issues no code, no token and no sign-in. Each request is a line in requests.log. It writes origin when listening and exits when
|
||||
# PARENT (the rehearsal) is gone, on SIGTERM, or on an error.
|
||||
R_PROVIDER_JS='
|
||||
const fs=require("node:fs"),path=require("node:path"),https=require("node:https"),crypto=require("node:crypto"),{spawnSync}=require("node:child_process");
|
||||
const [dir,cfg,parent]=process.argv.slice(1);
|
||||
const die=(m)=>{console.error(`rehearsal provider: ${m}`);process.exit(1)};
|
||||
const sso=JSON.parse(fs.readFileSync(cfg,"utf8")).console?.sso;
|
||||
if(!sso||!Array.isArray(sso.providers)||!sso.providers.length)die("the live config has no console.sso providers");
|
||||
const GEN={RS:["rsa",{modulusLength:2048}],PS:["rsa",{modulusLength:2048}],ES256:["ec",{namedCurve:"P-256"}],ES384:["ec",{namedCurve:"P-384"}],ES512:["ec",{namedCurve:"P-521"}],Ed25519:["ed25519",{}],EdDSA:["ed25519",{}]};
|
||||
const keyFor=(alg)=>{
|
||||
const g=GEN[alg]??GEN[String(alg).slice(0,2)]??(/^ML-DSA-(44|65|87)$/.test(alg)?[alg.toLowerCase(),{}]:null);
|
||||
if(!g)return null;
|
||||
try{const {publicKey}=crypto.generateKeyPairSync(...g);return {...publicKey.export({format:"jwk"}),kid:`rehearsal-${crypto.randomBytes(4).toString("hex")}`,use:"sig",alg}}catch{return null}
|
||||
};
|
||||
const providers=new Map();
|
||||
for(const p of sso.providers){
|
||||
const id=String(p?.id),algs=Array.isArray(p?.algs)?p.algs:[];
|
||||
let jwk=null;for(const a of algs){jwk=keyFor(a);if(jwk)break}
|
||||
if(!jwk)die(`provider ${id}: none of ${algs.join(", ")||"no algorithms"} is one this provider can make a key for`);
|
||||
providers.set(encodeURIComponent(id),jwk);
|
||||
}
|
||||
const tls=path.join(dir,"tls-key.pem"),certFile=path.join(dir,"cert.pem");
|
||||
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",tls,"-out",certFile],{encoding:"utf8"});
|
||||
let key,cert;
|
||||
try{if(r.status!==0){const why=String(r.stderr??"").trim().split("\n").pop();throw new Error(`openssl exit ${r.status??r.error?.code}${why?`: ${why}`:""}`)};key=fs.readFileSync(tls);cert=fs.readFileSync(certFile);fs.chmodSync(certFile,0o600)}
|
||||
catch(e){die(`cannot make the certificate: ${e.message}`)}
|
||||
finally{fs.rmSync(tls,{force:true})}
|
||||
const log=(q,status)=>fs.appendFileSync(path.join(dir,"requests.log"),`${q.method} ${q.url} ${status}\n`);
|
||||
let origin;
|
||||
const server=https.createServer({key,cert,requestTimeout:5000,headersTimeout:5000},(q,res)=>{
|
||||
const send=(status,body)=>{log(q,status);res.writeHead(status,{"content-type":"application/json","cache-control":"no-store"});res.end(JSON.stringify(body))};
|
||||
let at;try{at=new URL(q.url,origin).pathname}catch{at=""}
|
||||
const m=/^\/([^/]+)\/(\.well-known\/openid-configuration|jwks|authorize|token)$/.exec(at);
|
||||
const jwk=m?providers.get(m[1]):undefined;
|
||||
if(!jwk)return send(404,{error:"not found"});
|
||||
if(m[2]==="authorize"||m[2]==="token")return send(403,{error:"the rehearsal provider issues no code and no token"});
|
||||
if(q.method!=="GET")return send(405,{error:"GET only"});
|
||||
const issuer=`${origin}/${m[1]}/`;
|
||||
if(m[2]==="jwks")return send(200,{keys:[jwk]});
|
||||
send(200,{issuer,authorization_endpoint:`${issuer}authorize`,token_endpoint:`${issuer}token`,jwks_uri:`${issuer}jwks`,response_types_supported:["code"],subject_types_supported:["public"],id_token_signing_alg_values_supported:[jwk.alg],code_challenge_methods_supported:["S256"]});
|
||||
});
|
||||
server.on("error",(e)=>die(e.message));
|
||||
server.listen(0,"127.0.0.1",()=>{
|
||||
origin=`https://127.0.0.1:${server.address().port}`;
|
||||
fs.writeFileSync(path.join(dir,"origin.tmp"),`${origin}\n`,{mode:0o600});fs.renameSync(path.join(dir,"origin.tmp"),path.join(dir,"origin"));
|
||||
console.log(`rehearsal provider: ${origin}, ${providers.size} provider(s)`);
|
||||
});
|
||||
const stop=()=>{server.close();server.closeAllConnections();process.exit(0)};
|
||||
process.on("SIGTERM",stop);process.on("SIGINT",stop);process.on("SIGHUP",stop);
|
||||
setInterval(()=>{if(!fs.existsSync(`/proc/${parent}`))stop()},1000);'
|
||||
|
||||
# M3 (#1873): one 0600 placeholder per console.sso client secret slot, in the scratch config
|
||||
# directory where readSlot looks. Never the live slot: the rehearsal reads no secret.
|
||||
R_SLOTS_JS='
|
||||
const fs=require("node:fs"),path=require("node:path");const [file]=process.argv.slice(1);
|
||||
const dir=path.join(path.dirname(file),"secrets","mosaic-stack");
|
||||
fs.mkdirSync(dir,{recursive:true,mode:0o700});
|
||||
for(const p of JSON.parse(fs.readFileSync(file,"utf8")).console.sso.providers){
|
||||
const slot=p?.clientSecret?.slot;
|
||||
if(typeof slot!=="string"||!/^[a-z0-9-]{1,64}$/.test(slot))throw new Error("a console.sso provider has no valid clientSecret.slot");
|
||||
try{fs.writeFileSync(path.join(dir,slot),"rehearsal-placeholder-secret\n",{mode:0o600,flag:"wx"})}catch(e){if(e.code!=="EEXIST")throw e}
|
||||
}'
|
||||
|
||||
# M3 (#1873): the scratch .deployed, written by the new copy's own writeDeployment, so it names
|
||||
# the instance that copy derives from the scratch config directory.
|
||||
R_DEPLOYED_JS='
|
||||
const {pathToFileURL}=require("node:url");const [copy,data,dir]=process.argv.slice(1);
|
||||
import(pathToFileURL(`${copy}/packages/cli/src/deployment.mjs`).href).then((d)=>d.writeDeployment({dataRoot:data},dir));'
|
||||
|
||||
# The scratch business file (mos-0962, mos-0963): the live file without its tracker.* variables,
|
||||
# business and role, and with every credential reference, the roles and tracker.sync, moved to its
|
||||
@@ -2094,10 +2207,13 @@ fs.writeFileSync(out,JSON.stringify(b,null,2)+"\n",{mode:0o600,flag:"wx"});'
|
||||
# notify-off the notifier config is {"notifyVersion":1,"binding":null}, 0600 (mos-0962)
|
||||
# tracker-configured bootConfig sets no trackers (mos-0962)
|
||||
# credential-ref-live every credential reference is a file under placeholders/ (mos-0963)
|
||||
# sso-isolated the console.sso origin is rehearsal.invalid, every issuer is the
|
||||
# rehearsal provider's (OP, a stamped copy) or on rehearsal.invalid, and every
|
||||
# client secret slot is a scratch placeholder (M3, #1873)
|
||||
# Exit 0 when all pass, 1 on the first FAIL.
|
||||
R_GUARD_JS='
|
||||
const fs=require("node:fs"),path=require("node:path");const {pathToFileURL}=require("node:url");
|
||||
const [copy,id,live,scratch,ph]=process.argv.slice(1);
|
||||
const [copy,id,live,scratch,ph,op]=process.argv.slice(1);
|
||||
const real=(p)=>{try{return fs.realpathSync(p)}catch{return null}};
|
||||
const alive=(pid)=>{try{const s=fs.readFileSync(`/proc/${pid}/stat`,"utf8");return s[s.lastIndexOf(")")+2]!=="Z"}catch{return false}};
|
||||
const pass=(n,w)=>console.log(`PASS ${n}${w?": "+w:""}`);
|
||||
@@ -2136,6 +2252,19 @@ const fail=(n,w)=>{console.log(`FAIL ${n}: ${w}`);process.exit(1)};
|
||||
if(f===null||phr===null||path.dirname(f)!==phr)fail("credential-ref-live",`${what} is ${ref?.file}, not a file in ${ph}`);
|
||||
}
|
||||
pass("credential-ref-live",`${refs.length} reference(s), each a placeholder`);
|
||||
const sso=system.console?.sso;
|
||||
if(!sso){pass("sso-isolated","no console.sso");return}
|
||||
const host=(u)=>{try{return new URL(u).hostname}catch{return null}};
|
||||
if(host(sso.publicOrigin)!=="rehearsal.invalid")fail("sso-isolated",`console.sso.publicOrigin is ${sso.publicOrigin}, not https://rehearsal.invalid`);
|
||||
const sd=real(path.join(path.dirname(path.resolve(process.env.MOSAIC_CONFIG)),"secrets","mosaic-stack"));
|
||||
for(const p of sso.providers){
|
||||
const want=op?`${op}/${encodeURIComponent(String(p.id))}/`:null;
|
||||
if(op&&p.issuer!==want)fail("sso-isolated",`console.sso provider ${p.id} issuer is ${p.issuer}, not ${want} at the rehearsal provider`);
|
||||
if(!op&&host(p.issuer)!=="rehearsal.invalid")fail("sso-isolated",`console.sso provider ${p.id} issuer is ${p.issuer}, not on rehearsal.invalid`);
|
||||
const f=real(path.join(path.dirname(path.resolve(process.env.MOSAIC_CONFIG)),"secrets","mosaic-stack",p.clientSecret.slot));
|
||||
if(f===null||sd===null||path.dirname(f)!==sd||fs.readFileSync(f,"utf8")!=="rehearsal-placeholder-secret\n")fail("sso-isolated",`console.sso provider ${p.id} has no scratch placeholder secret`);
|
||||
}
|
||||
pass("sso-isolated",`${sso.providers.length} provider(s), each ${op?`at the rehearsal provider ${op}`:"at rehearsal.invalid"} with a placeholder secret`);
|
||||
})().catch((e)=>fail("guard",String(e?.message??e).split("\n")[0]));'
|
||||
|
||||
# Opens a data root's store with a copy's Store, as the bus does at start, with no migration
|
||||
@@ -2203,6 +2332,18 @@ process.stdout.write(why);' -- "$out" "$BUSINESS" "$R_HOST_PID" "$START_MS" 2>&1
|
||||
R_GATE=healthz
|
||||
node -e '
|
||||
fetch(`http://127.0.0.1:${process.argv[1]}/healthz`,{signal:AbortSignal.timeout(5000)}).then((r)=>process.exit(r.status===200?0:1),()=>process.exit(1));' -- "$R_PORT" || return 1
|
||||
# M3 (#1873): a stamped copy's Console must have found every configured provider at the
|
||||
# rehearsal provider, not started degraded.
|
||||
if [ -n "$R_PROVIDER" ]; then
|
||||
R_GATE=sso-ready
|
||||
R_WHY="$(node -e '
|
||||
const ids=JSON.parse(require("node:fs").readFileSync(process.argv[2],"utf8")).console.sso.providers.map((p)=>String(p.id));
|
||||
fetch(`http://127.0.0.1:${process.argv[1]}/healthz`,{signal:AbortSignal.timeout(5000)}).then((r)=>r.json()).then((h)=>{
|
||||
const s=h?.sso&&typeof h.sso==="object"?h.sso:{};
|
||||
process.stdout.write(ids.filter((id)=>s[id]!=="ready").map((id)=>`provider ${id} is ${s[id]??"missing"}, not ready`).join("; "));
|
||||
},(e)=>process.stdout.write(`healthz unreadable: ${e.message}`));' -- "$R_PORT" "$R/config/config.json" 2>&1)" || R_WHY="${R_WHY:-healthz unreadable}"
|
||||
[ -z "$R_WHY" ] || return 1
|
||||
fi
|
||||
R_GATE=copy-match
|
||||
tr '\0' ' ' < "/proc/$R_HOST_PID/cmdline" | grep -qF " $copy/packages/cli/src/cli.mjs bus start $BUSINESS --no-launch" || { R_WHY="the host's command line is not $copy's cli.mjs bus start $BUSINESS --no-launch"; return 1; }
|
||||
tr '\0' ' ' < "/proc/$R_CONSOLE_PID/cmdline" | grep -qF " $copy/packages/webui/src/cli.mjs serve " || { R_WHY="the Console's command line is not $copy's webui cli.mjs serve"; return 1; }
|
||||
@@ -2215,7 +2356,43 @@ r_gate() {
|
||||
[ "$SECONDS" -lt "$deadline" ] || { r_fail "$R_GATE" "${R_WHY:-no answer within ${TIMEOUT}s}"; return 1; }
|
||||
sleep 1
|
||||
done
|
||||
r_pass gate "host-up, bus-status, console-up, healthz and copy-match on $TO"
|
||||
r_pass gate "host-up, bus-status, console-up, healthz${R_PROVIDER:+, sso-ready} and copy-match on $TO"
|
||||
}
|
||||
|
||||
# M3 (#1873): starts R_PROVIDER_JS for a stamped copy, in $R/provider, and waits for its origin.
|
||||
r_provider() {
|
||||
local deadline=$((SECONDS + TIMEOUT))
|
||||
mkdir -m 700 "$R/provider" || r_abort provider-start "cannot make $R/provider"
|
||||
env -i HOME="$HOME" PATH="$PATH" node -e "$R_PROVIDER_JS" -- "$R/provider" "$LIVE_CFG" "$$" > "$R/provider/provider.log" 2>&1 &
|
||||
R_PROVIDER_PID=$!
|
||||
until [ -s "$R/provider/origin" ]; do
|
||||
r_alive "$R_PROVIDER_PID" || { wait "$R_PROVIDER_PID" 2>/dev/null || true; R_PROVIDER_PID=""; r_abort provider-start "the rehearsal provider exited: $(tail -n 1 "$R/provider/provider.log" 2>/dev/null)"; }
|
||||
[ "$SECONDS" -lt "$deadline" ] || r_abort provider-start "the rehearsal provider wrote no origin within ${TIMEOUT}s"
|
||||
sleep 0.2
|
||||
done
|
||||
R_PROVIDER="$(cat "$R/provider/origin")"
|
||||
[[ "$R_PROVIDER" =~ ^https://127\.0\.0\.1:[0-9]{1,5}$ ]] || r_abort provider-start "the rehearsal provider's origin $R_PROVIDER is not https on 127.0.0.1"
|
||||
[ ! -e "$R/provider/tls-key.pem" ] || r_abort provider-start "the rehearsal provider left its TLS key at $R/provider/tls-key.pem"
|
||||
r_pass provider-start "pid $R_PROVIDER_PID at $R_PROVIDER: discovery and a JWKS from keys made for this run, no code and no token; its certificate $R/provider/cert.pem is the trust the scratch Console alone gets"
|
||||
}
|
||||
|
||||
# M3 (#1873): the Console fetched each provider's discovery and JWKS from the rehearsal provider and
|
||||
# asked it for no code and no token.
|
||||
r_provider_seen() {
|
||||
local why
|
||||
why="$(node -e '
|
||||
const fs=require("node:fs");const [cfg,log]=process.argv.slice(1);
|
||||
const lines=fs.existsSync(log)?fs.readFileSync(log,"utf8").split("\n").filter(Boolean):[];
|
||||
const why=[];
|
||||
for(const p of JSON.parse(fs.readFileSync(cfg,"utf8")).console.sso.providers){
|
||||
const seg=encodeURIComponent(String(p.id));
|
||||
for(const what of [".well-known/openid-configuration","jwks"])if(!lines.includes(`GET /${seg}/${what} 200`))why.push(`no GET /${seg}/${what} 200`);
|
||||
}
|
||||
for(const l of lines)if(/\/(authorize|token)\b/.test(l))why.push(`a request for ${l}`);
|
||||
process.stdout.write(why.join("; "));' -- "$R/config/config.json" "$R/provider/requests.log" 2>&1)" || why="${why:-cannot read $R/provider/requests.log}"
|
||||
if [ -z "$why" ]; then r_pass provider-discovery "the Console fetched each provider's discovery and JWKS from $R_PROVIDER and asked for no code or token"
|
||||
else r_fail provider-discovery "$why; see $R/provider/requests.log"
|
||||
fi
|
||||
}
|
||||
|
||||
# No role session: the host wrote no sessions.json and no launches directory or log.
|
||||
@@ -2258,30 +2435,50 @@ do_rehearse() {
|
||||
LIVE_CFG="$(realpath -m "${MOSAIC_CONFIG:-$HOME/.config/mosaic-dev/config.json}")"
|
||||
LIVE_BIZ="$(dirname "$LIVE_CFG")/businesses/$BUSINESS.json"
|
||||
[ -f "$LIVE_BIZ" ] || r_abort business-missing "no $LIVE_BIZ"
|
||||
# M3 (#1873): a stamped copy serves only with --sso, console.sso, a loadable accounts.json and
|
||||
# a .deployed naming its instance. The rehearsal gives its Console all four in scratch. The
|
||||
# live instance's console.sso and accounts.json are a human's steps; without them the stamped
|
||||
# copy cannot start live either, so the rehearsal refuses and names the step.
|
||||
LIVE_FILES=("$LIVE_CFG" "$LIVE_BIZ") R_STAMPED=""
|
||||
if [ -e "$COPIES/$TO/.stack-build.json" ] || [ -L "$COPIES/$TO/.stack-build.json" ]; then
|
||||
R_STAMPED=1
|
||||
node -e 'process.exit(JSON.parse(require("node:fs").readFileSync(process.argv[1],"utf8")).console?.sso?0:1)' -- "$LIVE_CFG" 2>/dev/null ||
|
||||
r_abort sso-not-prepared "$TO is a stamped copy, which serves only with --sso, and $LIVE_CFG has no console.sso; prepare SSO for this instance (run sheet) before re-pinning to it"
|
||||
[ -f "$(dirname "$LIVE_CFG")/accounts.json" ] ||
|
||||
r_abort accounts-not-initialized "$TO is a stamped copy, which needs an accounts file, and there is no $(dirname "$LIVE_CFG")/accounts.json; a human runs mosaic accounts init (run sheet) before re-pinning to it"
|
||||
LIVE_FILES+=("$(dirname "$LIVE_CFG")/accounts.json")
|
||||
fi
|
||||
|
||||
local stamp before live
|
||||
before="$(r_tree_sums "$R_BK")" && live="$(sha256sum "$LIVE_CFG" "$LIVE_BIZ" 2>&1)" || r_abort sums "cannot read $R_BK or the live config files"
|
||||
before="$(r_tree_sums "$R_BK")" && live="$(sha256sum "${LIVE_FILES[@]}" 2>&1)" || r_abort sums "cannot read $R_BK or the live config files"
|
||||
stamp="$(date -u +%Y%m%dT%H%M%SZ)-${TO:0:12}"
|
||||
(umask 077; mkdir -p "$DATA/rehearsals" && mkdir "$DATA/rehearsals/$stamp") || r_abort rehearsal-dir "cannot make $DATA/rehearsals/$stamp (one rehearsal per second and copy)"
|
||||
R="$DATA/rehearsals/$stamp" STEPS="$DATA/rehearsals/$stamp/steps.log"
|
||||
printf '%s\n' "${R_EARLY[@]}" > "$STEPS" && printf '%s\n' "$before" > "$R/backup.before" && printf '%s\n' "$live" > "$R/live.before" ||
|
||||
r_abort scratch-build "cannot write the sums to $R"
|
||||
[ -z "$R_STAMPED" ] || r_provider
|
||||
(
|
||||
umask 077
|
||||
mkdir "$R/config" "$R/config/businesses" "$R/placeholders" "$R/data" "$R/data/bus" "$R/data/notify" "$R/data/notify/$BUSINESS" &&
|
||||
cp "$R_BK"/bus/* "$R/data/bus/" &&
|
||||
node -e "$R_CONFIG_JS" -- "$LIVE_CFG" "$R/data" > "$R/config/config.json" &&
|
||||
node -e "$R_CONFIG_JS" -- "$LIVE_CFG" "$R/data" "$R_PROVIDER" > "$R/config/config.json" &&
|
||||
node -e "$R_BUSINESS_JS" -- write "$LIVE_BIZ" "$R/config/businesses/$BUSINESS.json" "$R/placeholders" &&
|
||||
printf '{"notifyVersion":1,"binding":null}\n' > "$R/data/notify/$BUSINESS/notify.json"
|
||||
printf '{"notifyVersion":1,"binding":null}\n' > "$R/data/notify/$BUSINESS/notify.json" &&
|
||||
{ ! node -e 'process.exit(JSON.parse(require("node:fs").readFileSync(process.argv[1],"utf8")).console?.sso?0:1)' -- "$R/config/config.json" ||
|
||||
node -e "$R_SLOTS_JS" -- "$R/config/config.json"; } &&
|
||||
{ [ -z "$R_STAMPED" ] || {
|
||||
cp "$(dirname "$LIVE_CFG")/accounts.json" "$R/config/accounts.json" &&
|
||||
r_env node -e "$R_DEPLOYED_JS" -- "$COPIES/$TO" "$R/data" "$R/config"; }; }
|
||||
) 2>> "$STEPS" || r_abort scratch-build "cannot build the scratch root under $R; see $STEPS"
|
||||
local rc=0
|
||||
diff -u --label "$LIVE_BIZ (as JSON.stringify writes it)" --label "$R/config/businesses/$BUSINESS.json" \
|
||||
<(node -e "$R_BUSINESS_JS" -- normalize "$LIVE_BIZ") "$R/config/businesses/$BUSINESS.json" > "$R/business.diff" || rc=$?
|
||||
[ "$rc" -le 1 ] || r_abort scratch-build "cannot write $R/business.diff"
|
||||
r_pass scratch-root "$R/data from the backup, config and business file under $R/config, business.diff beside them"
|
||||
[ -z "$R_STAMPED" ] || r_pass deployment "$TO is stamped: $R/data/.deployed from its writeDeployment, the live accounts.json copied to $R/config, the Console starts with --sso"
|
||||
|
||||
local out
|
||||
rc=0; out="$(r_env node -e "$R_GUARD_JS" -- "$COPIES/$TO" "$BUSINESS" "$DATA" "$R/data" "$R/placeholders" 2>&1)" || rc=$?
|
||||
rc=0; out="$(r_env node -e "$R_GUARD_JS" -- "$COPIES/$TO" "$BUSINESS" "$DATA" "$R/data" "$R/placeholders" "$R_PROVIDER" 2>&1)" || rc=$?
|
||||
while IFS= read -r i; do r_line "$i"; done <<< "$out"
|
||||
[ "$rc" = 0 ] || { R_RC=1; r_end; }
|
||||
|
||||
@@ -2295,9 +2492,13 @@ do_rehearse() {
|
||||
R_HOST_PID=$!
|
||||
local -a tester_rehearsal=()
|
||||
if grep -q 'MOSAIC_TESTER_SOCKET_V1' "$COPIES/$TO/packages/webui/src/cli.mjs"; then tester_rehearsal=(--tester-socket); fi
|
||||
env -i HOME="$HOME" PATH="$PATH" MOSAIC_CONFIG="$R/config/config.json" node "$COPIES/$TO/packages/webui/src/cli.mjs" serve --port "$R_PORT" --business "$BUSINESS" "${tester_rehearsal[@]}" > "$R/console.log" 2>&1 &
|
||||
[ -z "$R_STAMPED" ] || tester_rehearsal+=(--sso)
|
||||
# M3 (#1873): the rehearsal provider's certificate is trusted by the scratch Console only.
|
||||
local -a r_ca=()
|
||||
[ -z "$R_PROVIDER" ] || r_ca=(NODE_EXTRA_CA_CERTS="$R/provider/cert.pem")
|
||||
env -i HOME="$HOME" PATH="$PATH" MOSAIC_CONFIG="$R/config/config.json" "${r_ca[@]}" node "$COPIES/$TO/packages/webui/src/cli.mjs" serve --port "$R_PORT" --business "$BUSINESS" "${tester_rehearsal[@]}" > "$R/console.log" 2>&1 &
|
||||
R_CONSOLE_PID=$!
|
||||
if r_gate; then r_no_session; fi
|
||||
if r_gate; then r_no_session; [ -z "$R_PROVIDER" ] || r_provider_seen; fi
|
||||
r_end
|
||||
}
|
||||
|
||||
@@ -2335,7 +2536,7 @@ FROM="" TO="" ACT="" RS="" POST="" BACKUP="" TAKEN="" RESTORED="" STEPS="" COUNT
|
||||
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=""
|
||||
R="" R_RC=0 R_EARLY=() R_BK="" R_HOST_PID="" R_CONSOLE_PID="" R_PROVIDER_PID="" R_PROVIDER="" R_STARTED="" LIVE_CFG="" LIVE_BIZ="" LIVE_FILES=() R_STAMPED=""
|
||||
O_ACT="" FLOOR=0
|
||||
case "$OP" in
|
||||
adopt) do_adopt ;;
|
||||
|
||||
+128
-18
@@ -6,8 +6,9 @@
|
||||
# agents/darkwing/work/queue-233/BUILD.md.
|
||||
#
|
||||
# stack-upgrade.sh upgrade --instance dev --bundle FILE --sha SHA [--migrate] [--no-rehearsal]
|
||||
# [--rehearse-port N] [--timeout S]
|
||||
# stack-upgrade.sh rollback --instance dev --to ID [--restore ACT [--events N]] [--timeout S]
|
||||
# [--rehearse-port N] [--next-args] [--timeout S]
|
||||
# stack-upgrade.sh rollback --instance dev --to ID [--restore ACT [--events N]] [--next-args]
|
||||
# [--timeout S]
|
||||
#
|
||||
# Root runs it from a root-owned checkout of the reviewed commit, never from a copy, because the
|
||||
# stack's user can write the copies. The unit values come from /etc/mosaic-stack/<instance>.args,
|
||||
@@ -20,6 +21,20 @@
|
||||
# output but exit codes and the rehearsal's BACKUP_STORE line (sage-1653), which it copies into its
|
||||
# step log after a match against a fixed pattern and decides nothing on.
|
||||
#
|
||||
# Before anything stops, case2-unit.sh --check renders the installed copy from the args file and
|
||||
# compares the installed units with it, then checks the target's render the same way; either
|
||||
# refusal is render-refused, with the units left running (#1873 M3).
|
||||
#
|
||||
# --next-args changes the serve flags, which a re-render otherwise refuses (row 195's installed-unit
|
||||
# match): /etc/mosaic-stack/<instance>.args.next, root's with no group or other write, must equal
|
||||
# the args file before `--` but for --copy's value, and differ from it after `--`. The target
|
||||
# renders from it through case2-unit.sh --check-new before anything stops. After A3, root moves the
|
||||
# installed unit files into a new <instance>.replaced.XXXXXX directory beside the args file, and A4
|
||||
# renders from the next args as a first install. A failure from there moves the replaced files back
|
||||
# unchanged, restarts them on the old copy and gates it, with no render. After confirm, the next
|
||||
# args become the args file, and the replaced directory keeps the old unit files and args file for
|
||||
# a later rollback with --next-args (docs/guides/host-run-sheet.md section 7).
|
||||
#
|
||||
# --no-rehearsal is for a stack whose log records no backup. The rehearsal runs either way: exit 3
|
||||
# from it (no-backup) goes on only with the flag, and exit 0 with the flag refuses
|
||||
# rehearsal-required (sage-1650, sage-1652).
|
||||
@@ -47,8 +62,8 @@ REPO="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
|
||||
usage() {
|
||||
cat >&2 <<'USAGE'
|
||||
usage: stack-upgrade.sh upgrade --instance dev --bundle FILE --sha SHA [--migrate] [--no-rehearsal] [--rehearse-port N] [--timeout S]
|
||||
stack-upgrade.sh rollback --instance dev --to ID [--restore ACT [--events N]] [--timeout S]
|
||||
usage: stack-upgrade.sh upgrade --instance dev --bundle FILE --sha SHA [--migrate] [--no-rehearsal] [--rehearse-port N] [--next-args] [--timeout S]
|
||||
stack-upgrade.sh rollback --instance dev --to ID [--restore ACT [--events N]] [--next-args] [--timeout S]
|
||||
USAGE
|
||||
exit 4
|
||||
}
|
||||
@@ -70,7 +85,7 @@ ARGV=("$@")
|
||||
[ $# -gt 0 ] || usage
|
||||
OP="$1"; shift
|
||||
case "$OP" in upgrade|rollback) ;; *) usage ;; esac
|
||||
INSTANCE="" BUNDLE="" SHA="" MIGRATE=0 NO_REHEARSAL=0 R_PORT=7339 TIMEOUT=60 TO="" RESTORE="" EVENTS=""
|
||||
INSTANCE="" BUNDLE="" SHA="" MIGRATE=0 NO_REHEARSAL=0 R_PORT=7339 TIMEOUT=60 TO="" RESTORE="" EVENTS="" NEXT=0 ASIDE="" MOVED=() REPLACED_NOTE=""
|
||||
while [ $# -gt 0 ]; do
|
||||
case "$1" in
|
||||
--instance) [ $# -ge 2 ] || usage; INSTANCE="$2"; shift 2 ;;
|
||||
@@ -79,6 +94,7 @@ while [ $# -gt 0 ]; do
|
||||
--migrate) [ "$OP" = upgrade ] || usage; MIGRATE=1; shift ;;
|
||||
--no-rehearsal) [ "$OP" = upgrade ] || usage; NO_REHEARSAL=1; shift ;;
|
||||
--rehearse-port) [ "$OP" = upgrade ] && [ $# -ge 2 ] && [[ "$2" =~ ^[1-9][0-9]{3,4}$ ]] && [ "$2" -le 65535 ] || usage; R_PORT="$2"; shift 2 ;;
|
||||
--next-args) NEXT=1; shift ;;
|
||||
--timeout) [ $# -ge 2 ] && [[ "$2" =~ ^[1-9][0-9]{0,4}$ ]] || usage; TIMEOUT="$2"; shift 2 ;;
|
||||
--to) [ "$OP" = rollback ] && [ $# -ge 2 ] && [[ "$2" =~ ^[0-9a-f]{40}$ ]] || usage; TO="$2"; shift 2 ;;
|
||||
--restore) [ "$OP" = rollback ] && [ $# -ge 2 ] && [[ "$2" =~ $ID_RE ]] || usage; RESTORE="$2"; shift 2 ;;
|
||||
@@ -125,8 +141,8 @@ logl "start rc=0 argv:$(printf ' %q' "$0" "${ARGV[@]}")"
|
||||
# The selector scopes: selector.sh defines functions only, from this root-owned checkout, as RENDER is.
|
||||
. "$REPO/scripts/selector.sh" || refuse checkout-unreadable "cannot read $REPO/scripts/selector.sh"
|
||||
args_business_ok() { [[ "$1" =~ ^[a-z0-9][a-z0-9-]*$ ]]; }
|
||||
args_outer_refused() { refuse args-unreadable "$ETC/$INSTANCE.args before --: $2"; }
|
||||
args_serve_refused() { refuse args-unreadable "$ETC/$INSTANCE.args after --: $2"; }
|
||||
args_outer_refused() { refuse args-unreadable "$ARGS_FILE before --: $2"; }
|
||||
args_serve_refused() { refuse args-unreadable "$ARGS_FILE after --: $2"; }
|
||||
|
||||
# I2's file: one case2-unit.sh argument per line, owned by root with no group or other write. Sets
|
||||
# ARGS (the lines), S_USER, S_HOME, BUSINESS, NODE, ARGS_COPY, ARGS_PROFILE and ARGS_PROXY, and the paths derived
|
||||
@@ -140,7 +156,8 @@ args_serve_refused() { refuse args-unreadable "$ETC/$INSTANCE.args after --: $2"
|
||||
# says which of them take a value, so a value is never taken for a selector; a token that grammar
|
||||
# lacks refuses, as the render would.
|
||||
read_args() {
|
||||
local f="$ETC/$INSTANCE.args" st i sep copies=0 v profile=library served
|
||||
ARGS_FILE="${1:-$ETC/$INSTANCE.args}"
|
||||
local f="$ARGS_FILE" st i sep copies=0 v profile=library served
|
||||
[ -f "$f" ] && [ ! -L "$f" ] || refuse args-unreadable "no regular file $f"
|
||||
st="$(stat -c '%u %a' "$f")" || refuse args-unreadable "cannot stat $f"
|
||||
[ "${st% *}" = "$(id -u)" ] && (( (8#${st#* } & 8#022) == 0 )) || refuse args-unreadable "$f must be $(id -un)'s with no group or other write (it is uid ${st% *}, mode ${st#* })"
|
||||
@@ -224,17 +241,82 @@ legacy_console() {
|
||||
done
|
||||
}
|
||||
|
||||
# RENDER_ARGS: the args file's lines with copy $1 in place of --copy's value.
|
||||
# RENDER_ARGS: the lines of array $2 (default ARGS) with copy $1 in place of --copy's value.
|
||||
args_with() {
|
||||
local i past=""
|
||||
local -n lines_="${2:-ARGS}"
|
||||
RENDER_ARGS=()
|
||||
for ((i = 0; i < ${#ARGS[@]}; i++)); do
|
||||
RENDER_ARGS+=("${ARGS[i]}")
|
||||
[ "${ARGS[i]}" != -- ] || past=1
|
||||
if [ -z "$past" ] && [ "${ARGS[i]}" = --copy ]; then RENDER_ARGS+=("$1"); i=$((i + 1)); fi
|
||||
for ((i = 0; i < ${#lines_[@]}; i++)); do
|
||||
RENDER_ARGS+=("${lines_[i]}")
|
||||
[ "${lines_[i]}" != -- ] || past=1
|
||||
if [ -z "$past" ] && [ "${lines_[i]}" = --copy ]; then RENDER_ARGS+=("$1"); i=$((i + 1)); fi
|
||||
done
|
||||
}
|
||||
|
||||
# OLD_ARGS: the args file's lines, which the installed units were rendered from. Under --next-args,
|
||||
# ARGS becomes the next file's lines, which must equal OLD_ARGS before -- but for --copy's value and
|
||||
# differ after it.
|
||||
read_next() {
|
||||
OLD_ARGS=("${ARGS[@]}")
|
||||
[ "$NEXT" = 1 ] || return 0
|
||||
local f="$ETC/$INSTANCE.args.next" a b i copy="$ARGS_COPY"
|
||||
unset SELECTOR_SEEN_outer SELECTOR_VALUE_outer SELECTOR_SEEN_serve SELECTOR_VALUE_serve
|
||||
read_args "$f"
|
||||
# read_old checks the installed units against the args file's copy, not the next file's.
|
||||
ARGS_COPY="$copy"
|
||||
args_with COPY OLD_ARGS; a=("${RENDER_ARGS[@]}")
|
||||
args_with COPY; b=("${RENDER_ARGS[@]}")
|
||||
for ((i = 0; i < ${#a[@]} && i < ${#b[@]}; i++)); do
|
||||
[ "${a[i]}" = "${b[i]}" ] || refuse next-args-differ "$f must equal $ETC/$INSTANCE.args before -- but for --copy's value; line $((i + 1)) differs"
|
||||
[ "${a[i]}" != -- ] || break
|
||||
done
|
||||
[ "${a[*]}" != "${b[*]}" ] || [ "${#a[@]}" != "${#b[@]}" ] ||
|
||||
refuse next-args-same "$f has the same serve flags as $ETC/$INSTANCE.args; run without --next-args"
|
||||
}
|
||||
|
||||
# Before anything stops: the installed units against OLD's render from the args file, so a rollback
|
||||
# render-old works, then copy $1's render, against the installed units or, under --next-args, as a
|
||||
# first install from the next args.
|
||||
render_checks() {
|
||||
args_with "$OLD" OLD_ARGS
|
||||
step render-old-check "$RENDER" --check "${RENDER_ARGS[@]}" ||
|
||||
refuse render-refused "case2-unit.sh --check refuses $OLD's units from $ETC/$INSTANCE.args, so a rollback could not render them; its message is above"
|
||||
args_with "$1"
|
||||
if [ "$NEXT" = 1 ]; then
|
||||
step render-check "$RENDER" --check-new "${RENDER_ARGS[@]}" ||
|
||||
refuse render-refused "case2-unit.sh --check-new refuses $1's units from $ETC/$INSTANCE.args.next; its message is above"
|
||||
else
|
||||
step render-check "$RENDER" --check "${RENDER_ARGS[@]}" ||
|
||||
refuse render-refused "case2-unit.sh --check refuses $1's units from $ETC/$INSTANCE.args; its message is above. A re-pin that changes the serve flags takes --next-args (docs/guides/host-run-sheet.md section 7)"
|
||||
fi
|
||||
}
|
||||
|
||||
# --next-args, after A3: the installed unit files into a new directory beside the args file, so A4
|
||||
# renders as a first install. MOVED lists the files moved, for units_back.
|
||||
units_aside() {
|
||||
local u rc=0
|
||||
local -a files=([email protected] "$CON_UNIT")
|
||||
[ "$ARGS_PROXY" = 0 ] || files+=("$PROXY_UNIT")
|
||||
ASIDE="$(mktemp -d "$ETC/$INSTANCE.replaced.XXXXXX")" && chmod 0700 "$ASIDE" || rc=1
|
||||
if [ "$rc" = 0 ]; then
|
||||
for u in "${files[@]}"; do
|
||||
mv -T -- "$UNIT_DIR/$u" "$ASIDE/$u" || { rc=1; break; }
|
||||
MOVED+=("$u")
|
||||
done
|
||||
fi
|
||||
logl "units-aside rc=$rc dir: ${ASIDE:-none} moved: ${MOVED[*]}"
|
||||
return "$rc"
|
||||
}
|
||||
|
||||
# The moved files back, unchanged, over whatever A4 wrote, then the replaced directory removed.
|
||||
units_back() {
|
||||
local u rc=0
|
||||
for u in "${MOVED[@]}"; do mv -fT -- "$ASIDE/$u" "$UNIT_DIR/$u" || rc=1; done
|
||||
[ "$rc" != 0 ] || rmdir -- "$ASIDE" || rc=1
|
||||
logl "units-back rc=$rc dir: $ASIDE moved: ${MOVED[*]}"
|
||||
return "$rc"
|
||||
}
|
||||
|
||||
# The new args file, written beside the old one before anything stops, moved into place only after
|
||||
# confirm passes. $1 is the copy.
|
||||
prepare_args() {
|
||||
@@ -334,9 +416,25 @@ rollback_to_old() {
|
||||
stop_wait || { stop_units; finish 2 rollback-failed "$cause failed and the units did not stop for the rollback; $STOPPED_UNITS are stopped"; }
|
||||
step revert "${AS_USER[@]}" "$SR" revert "$id" "$BUSINESS" --units external --copies "$COPIES_DIR" ||
|
||||
{ stop_units; finish 2 rollback-failed "$cause failed and revert failed; $STOPPED_UNITS are stopped"; }
|
||||
args_with "$OLD"
|
||||
step render-old "$RENDER" "${RENDER_ARGS[@]}" ||
|
||||
{ stop_units; finish 2 rollback-failed "$cause failed and the render of $OLD failed; $STOPPED_UNITS are stopped"; }
|
||||
if [ -n "$ASIDE" ]; then
|
||||
# --next-args: the replaced files back as they were, then the start render-old would do.
|
||||
units_back ||
|
||||
{ stop_units; finish 2 rollback-failed "$cause failed and the replaced units in $ASIDE did not all move back; $STOPPED_UNITS are stopped"; }
|
||||
step daemon-reload "$SYSTEMCTL" daemon-reload ||
|
||||
{ stop_units; finish 2 rollback-failed "$cause failed and daemon-reload failed after the replaced units moved back; $STOPPED_UNITS are stopped"; }
|
||||
if [ "$ARGS_PROXY" = 1 ]; then
|
||||
step start-proxy "$SYSTEMCTL" start "$PROXY_UNIT" ||
|
||||
{ stop_units; finish 2 rollback-failed "$cause failed and the proxy did not start for $OLD; $STOPPED_UNITS are stopped"; }
|
||||
fi
|
||||
step start-bus "$SYSTEMCTL" start "$BUS_UNIT" ||
|
||||
{ stop_units; finish 2 rollback-failed "$cause failed and the bus did not start for $OLD; $STOPPED_UNITS are stopped"; }
|
||||
step start-console "$SYSTEMCTL" start "$CON_UNIT" ||
|
||||
{ stop_units; finish 2 rollback-failed "$cause failed and the Console did not start for $OLD; $STOPPED_UNITS are stopped"; }
|
||||
else
|
||||
args_with "$OLD" OLD_ARGS
|
||||
step render-old "$RENDER" "${RENDER_ARGS[@]}" ||
|
||||
{ stop_units; finish 2 rollback-failed "$cause failed and the render of $OLD failed; $STOPPED_UNITS are stopped"; }
|
||||
fi
|
||||
# confirm --rollback exits 1 when OLD passed its gate and 2 when it failed; anything else is a
|
||||
# refusal, with OLD's gate never run.
|
||||
local rc=0
|
||||
@@ -360,18 +458,27 @@ run_steps() {
|
||||
*) rollback_to_old "$id" A3 ;;
|
||||
esac
|
||||
# A4
|
||||
if [ "$NEXT" = 1 ]; then units_aside || rollback_to_old "$id" A4; fi
|
||||
args_with "$id"
|
||||
step render "$RENDER" "${RENDER_ARGS[@]}" || rollback_to_old "$id" A4
|
||||
# A5
|
||||
step confirm "${AS_USER[@]}" "$SR" confirm "$id" "$BUSINESS" --units external --copies "$COPIES_DIR" --timeout "$TIMEOUT" ||
|
||||
rollback_to_old "$id" A5
|
||||
if [ "$NEXT" = 1 ]; then
|
||||
# The old args file joins the replaced units before the next one takes its place.
|
||||
cp -p -- "$ETC/$INSTANCE.args" "$ASIDE/$INSTANCE.args" && rm -f -- "$ETC/$INSTANCE.args.next" &&
|
||||
logl "next-args rc=0 replaced: $ASIDE" ||
|
||||
{ logl "next-args rc=1 replaced: $ASIDE"; finish 5 args-failed "$id is live and confirmed, but the old args file did not reach $ASIDE; $ETC/$INSTANCE.args still names $OLD; copy it there, then put $NEW_ARGS in its place and remove $ETC/$INSTANCE.args.next"; }
|
||||
fi
|
||||
if mv -f "$NEW_ARGS" "$ETC/$INSTANCE.args"; then logl "args rc=0 copy: $id"
|
||||
else logl "args rc=1 copy: $id"; finish 5 args-failed "$id is live and confirmed, but $ETC/$INSTANCE.args still names $OLD; put $NEW_ARGS in its place"
|
||||
fi
|
||||
REPLACED_NOTE="${ASIDE:+; the replaced units and args file are in $ASIDE}"
|
||||
}
|
||||
|
||||
do_upgrade() {
|
||||
read_args
|
||||
read_next
|
||||
legacy_console
|
||||
# Before A1: the bundle into the stack's user's clone, then the copy from it (I1).
|
||||
step fetch "${AS_USER[@]}" git -C "$CLONE" fetch -q "$BUNDLE" refs/heads/refactor:refs/remotes/origin/refactor ||
|
||||
@@ -390,9 +497,10 @@ do_upgrade() {
|
||||
local -a migrate=()
|
||||
[ "$MIGRATE" = 0 ] || migrate=(--migrate)
|
||||
plan activate "$SHA" "$BUSINESS" "${migrate[@]}"
|
||||
render_checks "$SHA"
|
||||
rehearse
|
||||
run_steps "$SHA" activate "$SHA" "$BUSINESS" "${migrate[@]}"
|
||||
finish 0 ok "upgrade ok: $SHA is live for $BUSINESS"
|
||||
finish 0 ok "upgrade ok: $SHA is live for $BUSINESS$REPLACED_NOTE"
|
||||
}
|
||||
|
||||
# The rehearsal against the log's newest backup, before anything stops. Its output goes to the
|
||||
@@ -419,6 +527,7 @@ rehearse() {
|
||||
# check: the target is an older copy than this checkout, and no rehearsal.
|
||||
do_rollback() {
|
||||
read_args
|
||||
read_next
|
||||
legacy_console
|
||||
[ -x "$COPIES_DIR/$TO/scripts/mosaic" ] || refuse copy-missing "no $COPIES_DIR/$TO/scripts/mosaic"
|
||||
hostcheck
|
||||
@@ -427,8 +536,9 @@ do_rollback() {
|
||||
[ -z "$RESTORE" ] || restore=(--restore "$RESTORE")
|
||||
[ -z "$EVENTS" ] || restore+=(--events "$EVENTS")
|
||||
plan rollback "$BUSINESS" --to "$TO" "${restore[@]}"
|
||||
render_checks "$TO"
|
||||
run_steps "$TO" rollback "$BUSINESS" --to "$TO" "${restore[@]}"
|
||||
finish 0 ok "rollback ok: $TO is live for $BUSINESS"
|
||||
finish 0 ok "rollback ok: $TO is live for $BUSINESS$REPLACED_NOTE"
|
||||
}
|
||||
|
||||
case "$OP" in
|
||||
|
||||
@@ -318,6 +318,20 @@ render() {
|
||||
case "$REPO" in *[!A-Za-z0-9/._+-]*) die "repository path contains a character the unit cannot carry: $REPO" ;; esac
|
||||
local root
|
||||
root="$(unit_root)" || exit 1
|
||||
# Accounts instances use dedicated system services, never a human/seat's
|
||||
# systemd user manager. The check runs before rendering or installing.
|
||||
node --input-type=module -e '
|
||||
import { lstatSync, readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
const [root, dir, file] = process.argv.slice(1);
|
||||
const present = p => { try { lstatSync(p); return true; } catch(e) { if(e.code === "ENOENT") return false; throw e; } };
|
||||
let accounts = present(join(root, ".stack-build.json")) || present(join(dir, "accounts.json"));
|
||||
if (present(file)) {
|
||||
const s = JSON.parse(readFileSync(file, "utf8"));
|
||||
accounts ||= Boolean(s.console?.sso) || present(join(s.dataRoot, ".deployed"));
|
||||
}
|
||||
if (accounts) { console.error("webui-service: accounts user-unit path refused; use system units from case2-unit.sh"); process.exit(1); }
|
||||
' "$root" "$MOSAIC_INSTANCE_CONFIG_DIR" "$MOSAIC_INSTANCE_CONFIG" || exit 1
|
||||
if [ "$TESTER_SOCKET" = 1 ]; then
|
||||
grep -q 'MOSAIC_TESTER_SOCKET_V1' "$root/packages/webui/src/cli.mjs" || die "tester socket target lacks support"
|
||||
fi
|
||||
|
||||
Reference in new issue
Block a user