Compare commits

..
12 changed files with 402 additions and 1503 deletions

No files matched your search

+402
View File
@@ -0,0 +1,402 @@
# Row 255 design: a bundled Pocket ID for users with no OIDC provider (Q203, Gitea #1760, Vikunja 458)
Launchpad, draft 1, 2026-10-25, on `launchpad/row255` off refactor 78b69e1e6. Brief
`docs/plans/2026-10-21_bundled-pocket-id.md` (blob 26f34400). Reviewer Webby. Sage checks it against Q203, O6,
REQ-WEB-2 and REQ-INST-1. REQ-INST-2 (PRD 1.10) is post-v1 (mos-1204), and so is everything here.
This is a design only. Nothing here installs, downloads or starts Pocket ID. It changes nothing on .53, .54 or
dragon-lin, which keeps Authentik.
## Sources
- Q203 and O6 as the brief quotes them. PRD `docs/prd/mosaic-stack.md` REQ-WEB-2 (:902), REQ-INST-1 (:1509),
REQ-INST-2 (:1513) and REQ-INST-3 (:1528). Lead decision 44 items 5 and 6
(`docs/plans/2026-09-26_lead-decisions.md:655-663`).
- The Q175 design `agents/sage/work/q175-sso/DESIGN.md` sections 4 and 10, and the code row 192 landed:
`packages/webui/src/sso-config.mjs` (the `console.sso` shape), `packages/webui/src/sso.mjs` (the slot read,
the discovery checks at :105-118, the client at :147-148, the callback at :133, the post-logout URI at :332).
- Researcher's note `agents/researcher/work/2026-10-04_vikunja-pocketid.md` part 2 (v2.17.0).
- A fresh read of upstream on 2026-10-25, from release metadata and source text only: no binary was fetched or
run. The latest stable release is **v2.19.1** (2026-10-22). Source paths below are under
`backend/internal/` at tag v2.19.1 in `github.com/pocket-id/pocket-id`, marked "P". Releases v2.18.0,
v2.19.0 and v2.19.1 are not marked breaking.
## What the Console already needs from any provider
This design keeps the Console's sign-in and start checks exactly as row 192 built them. Pocket ID becomes one
`console.sso.providers` entry, the same shape any compliant OP uses (Q175 section 4, "Several providers"). The
Console gets no new sign-in path, no password and no proxy-header identity (O6, REQ-WEB-2, REQ-INST-2). The
facts that drive the rest of the design:
- `publicOrigin` and every `issuer` must be https (`sso-config.mjs` `httpsURL`). A plain-http Pocket ID can't
be a provider at all.
- Each provider needs 1 to 64 `subjects`. A `console.sso` with no subject refuses to load. So the first
person's subject has to exist before the Console starts with `--sso` (section 5).
- The client authenticates with `client_secret_basic` (`sso.mjs:148`), so the client is confidential.
- The slot is `<configDir>/secrets/mosaic-stack/<slot>`: a regular file, owned by the Console's uid, mode
with no group or other bits, and outside the repo and every stack copy (`readSlot`).
- `config.json` is user-authored. bootstrap.sh creates it and nothing overwrites it (canon invariant 2, Q175
section 4). This matters for sections 1 and 5.
## 1. The choice
The installer is REQ-INST-3's install command (Vikunja #432; today `stack-upgrade.sh` and the per-case
install scripts). It takes one sign-in option per instance:
- `--oidc existing`. The user points the Console at one or more OIDC providers they already have. Nothing
is installed. The user writes `console.sso` themselves, as Q175 step 4 does today. The installer writes
nothing into `console.sso`; it prints the entry shape and the two URIs to register
(`<publicOrigin>/auth/callback`, `<publicOrigin>/auth/signed-out`).
- `--oidc bundled`. The stack installs Pocket ID as an install component (section 2), and
`console.sso.providers` holds exactly one entry, `id: "pocket-id"`, whose `issuer` is the component's
origin.
- No option keeps today's behaviour: no `console.sso`, and the Console runs without `--sso`.
How the bundled choice is recorded. REQ-INST-1 lets a component be switched on "in the organization file
or system config". It has to be the system config. The organization file can be written by agents through
`organizationWriter`, which is the reason Q175 B1 put `console.sso` in config.json, and the same reason
holds for a component that issues the Console's identities. So config.json gets one new optional key:
```json
"components": {
"pocket-id": {
"release": "v2.19.1",
"origin": "https://id.example.internal",
"port": 1411
}
}
```
The loader (`scripts/mosaic-config.mjs`) checks the shape: `release` is a `vX.Y.Z` tag, and the component script refuses one that
differs from the pin file (section 2), `origin` is an https origin, `port` is 1024 to 65535. It also cross-checks: when
`components["pocket-id"]` is present, `console.sso` must have exactly one provider, `id` `pocket-id`,
`issuer` equal to `origin`. A config with `console.sso` providers and no `components` is the existing
choice and loads as today.
**M1, approved by Mos with three conditions (mos-1666 item 3).** `components` is a new top-level config key.
The loader refuses unknown keys today, so this is a config schema change. The conditions:
1. **Rollback trap.** A copy older than P1 refuses a config that has the key, the same trap Q175 section 4
names for `console.sso`. P1 documents the order. On a rollback past P1, first take `components` out of
config.json (restore the backup made before the key went in), then roll back the pointer. The STATE check
after the rollback reads config.json and confirms the key is absent.
2. **Strict inside the key.** The loader refuses any name under `components` other than `pocket-id`, and any
field under `pocket-id` other than `release`, `origin` and `port`. Each refusal names the key path and
quotes no value, as `checkConsole` does.
3. **test-config.sh cases:**
- a valid `components` loads;
- a malformed one fails, naming the bad key path;
- a config without `components` loads;
- the cross-check refuses a `console.sso` whose provider doesn't match `origin`.
What each choice writes into `console.sso` is in section 5. For the bundled choice, Pocket ID is the only
providers entry. The design keeps it that way because REQ-INST-2 says "the Console's one providers entry".
A user who later adds an existing provider moves to `--oidc existing` and removes the component (section 2).
## 2. The component, against REQ-INST-1
**Pinned.** A Pocket ID release is a set of raw binaries plus `checksums.txt` (sha256). From v2.16.0 there is
an SPDX SBOM per binary, and the release workflow attests `checksums.txt` through Sigstore (`actions/attest`
in `.github/workflows/release.yml`). The repo holds one pin file,
`scripts/pocket-id-pins.sh`, sourced the way `scripts/case1-pins.sh` is: the tag, and for each supported platform (linux amd64 and arm64 to
start) the asset name and its sha256. The build row copies the sha256 from `checksums.txt` at the tag and
records the `gh attestation verify` output for each asset as its evidence. The pin is checked by hand at
build time, not trusted at install time. No Pocket ID source and no binary goes in the repo. The binary is
not in any image either, since the container install comes after v1 (REQ-INST-3).
Recommended pin: v2.19.1, the current release. The build row checks it again, because Pocket ID ships a
minor every one to four weeks. Upgrading is a pin file change in a reviewed commit.
**Fetched and checked.** `scripts/component-pocket-id.sh fetch` downloads the pinned asset for the host's
platform over https into a new 0700 staging directory, computes its sha256, and compares it with the pin.
On a mismatch it deletes the file and refuses with `pocket-id-digest-mismatch`, naming the expected value
and the computed one. On a match it installs the binary read-only (0555) at
`<dataRoot>/components/pocket-id/bin/pocket-id-<tag>`. The fetch is the only network step, and the user
runs it at install. Tests never run it against the network (section 9).
**Runs.** A systemd user unit, `mosaic-pocket-id.service` (`mosaic-pocket-id-NAME.service` for a named
instance, as row 190 names the bus and webui units), as the instance's user:
- `ExecStart=<dataRoot>/components/pocket-id/bin/pocket-id-<tag>`.
- `WorkingDirectory=<dataRoot>/components/pocket-id/run`, a directory the unit owns, so Pocket ID's habit of
reading `.env` from the working directory reads nothing we didn't write. The build row checks there is no
`.env` there at start.
- Environment, all from the rendered unit:
- `APP_URL=<origin>` and `HOST=127.0.0.1` (P `common/env_config.go`: HOST defaults to `0.0.0.0`).
- `PORT=<port>`.
- `DB_CONNECTION_STRING` and `UPLOAD_PATH` as absolute paths under `<dataRoot>/components/pocket-id/data/`
(P defaults are `data/pocket-id.db` and `data/uploads`, relative to the working directory).
- `ALLOW_USER_SIGNUPS=disabled` and `ALLOW_INSECURE_CALLBACK_URLS=false`.
- `EMAIL_ONE_TIME_ACCESS_AS_UNAUTHENTICATED_ENABLED=false` and `..._AS_ADMIN_ENABLED=false`.
- The `OUTBOUND_ALLOWED_HOSTS_*` allowlists set empty (added in 2.19.0).
- No `TRUST_PROXY` unless section 6's proxy step sets it to the proxy's address.
- `ENCRYPTION_KEY` (mandatory since v2.0, at least 16 bytes) never goes in the unit text. It lives in slot
`<configDir>/secrets/mosaic-stack/pocket-id-encryption-key`, 0600. The component script generates it from
`/dev/urandom` straight into the file and never prints it. The unit passes it with
`LoadCredential=` and `ENCRYPTION_KEY_FILE=%d/...`. The build row confirms `ENCRYPTION_KEY_FILE` at the pinned
tag; if it isn't there, it refuses rather than putting the key in `Environment=`.
- The unit never carries `STATIC_API_KEY`. That key exists only during the enroll run (section 4).
The data directory holds the SQLite database, Pocket ID's signing key (stored encrypted with
`ENCRYPTION_KEY`), and the passkey registrations. It is 0700. Agent sandboxes bind only an allowlist
(`packages/seat/src/sandbox.mjs`), and `<dataRoot>/components/` is not on it. The build row adds a sandbox
test that pins that.
**Health-checked.** `GET http://127.0.0.1:<port>/healthz` returns 204 (P `controller/healthz_controller.go`).
`component-pocket-id.sh status` reports the unit state, the binary tag against the pin, the binary's sha256
against the pin, and the health result. The Console's own `/healthz` already shows `sso: { "pocket-id": ready
| unreachable | refused }` from its discovery retries, so an operator sees the provider from either side.
**Removable.** `component-pocket-id.sh remove` refuses while `console.sso` still names `pocket-id`, so the
Console can't be left pointing at a provider that's gone. Once the user has taken the entry out (or moved to
`--oidc existing`), the script stops and removes the unit, then moves the data directory to
`<dataRoot>/components/pocket-id.removed-<utc>` with a receipt line in `<dataRoot>/state/components.log`
(append-only). It never deletes the passkey database outright. Deleting the moved directory and the
encryption key slot is a separate `purge` verb that names both paths and needs `--yes`. Removing the
`components` key from config.json is the user's edit.
## 3. The Q175 start checks against the bundled instance
The Console checks are unchanged. Here is each one against Pocket ID, and the setting it needs:
| Check (row 192) | Pocket ID v2.19.1 | Default that fails | Fix |
|---|---|---|---|
| `issuer` is https (loader) | issuer = `APP_URL` exactly (P `controller/well_known_controller.go`) | `APP_URL` defaults to `http://localhost:1411` | `APP_URL=<https origin>`, section 6 |
| discovery `issuer` equals config `issuer` | equal when `APP_URL` has no trailing slash; Pocket ID refuses a path | none | config `issuer` = `origin`, no trailing slash; the loader cross-check compares them |
| `id_token_signing_alg_values_supported` overlaps `algs` | advertises only the current key's alg. RS256 (RSA-2048) by default (P `service/jwt_service.go`) | none | `algs: ["RS256"]` |
| `code_challenge_methods_supported` has `S256` | advertises `plain` and `S256` | none | none. The Console always sends S256 (`sso.mjs:221`) |
| JWKS has a usable key for `algs` | one key at `/.well-known/jwks.json` | none | none. The build row's fake OP pins `use`/`alg`/`kid` as Pocket ID emits them |
| `end_session_endpoint` is https | `<APP_URL>/api/oidc/end-session` | http, if `APP_URL` is http | same fix as row 1 |
| `redirect_uri` is registered | exact match first, then wildcard patterns; loopback callbacks match any port (P `utils/callback_url_util.go`) | `ALLOW_INSECURE_CALLBACK_URLS` defaults to true | register exactly `<publicOrigin>/auth/callback`, with no `*`; set `ALLOW_INSECURE_CALLBACK_URLS=false` |
| post-logout URI | per-client `logoutCallbackURLs`, same matching | none | register exactly `<publicOrigin>/auth/signed-out` |
| ID token alg in `algs`, `iss` match | as above | none | none |
Two points the Console doesn't check, set on the client anyway:
- `pkceEnabled: true`. Then Pocket ID refuses an authorize request that has no `code_challenge`. Pocket ID has
no per-client S256-only setting (P `oidc/provider.go` turns on `plain` for the server). That doesn't relax
anything: the Console sends S256 every time, and `plain` stays available only to other clients.
- `isPublic: false`, which matches `client_secret_basic`.
Key rotation. `pocket-id key-rotate --alg ...` replaces the one signing key (P `cmds/key_rotate.go`). After a
rotation to another alg, the Console's discovery check refuses on the next restart (no overlap with
`algs`), and that is the intended fail-closed result. A rotation is a user step that updates `algs` in the
same edit. The component script never rotates.
## 4. The client, and where its secret goes
The person running the install creates the client through `component-pocket-id.sh enroll`. No agent runs
this verb. It refuses when any agent marker is in its environment: the `MARKERS` list in
`packages/bus/src/human.mjs` (`MOSAIC_RUN_ID`, `MOSAIC_BUS_CAP`, `CLAUDECODE` and the rest) that the human
boundary uses. A test pins the refusal.
Enroll needs admin rights before any person has a passkey. Pocket ID offers two ways to get them, and one of
them is a hazard:
- `/setup` and its backing `POST /api/signup/setup` are unauthenticated: whoever calls first becomes admin
(P `usersignup/service.go`). Sandboxed agents share the host's network namespace (`sandbox.mjs` unshares
user, pid and ipc, not net), so any agent could reach a loopback Pocket ID and take that first admin.
- `STATIC_API_KEY` is a fixed admin key with no expiry (P `apikey/service.go`). It doesn't count as
completing setup, so `/setup` stays open while it's set.
So enroll never lets the real unit serve before an admin person exists. It runs the pinned binary once in a
**private network namespace** (`unshare --user --net`, or `bwrap --unshare-net` with the binary and data
directory bound), where only enroll's own loopback can reach it:
1. Generate a one-run static key into a 0600 temp file in a new 0700 directory, and start Pocket ID inside
the namespace with `STATIC_API_KEY_FILE` pointing at it, plus the unit's environment.
2. Wait for `/healthz` 204.
3. Create the admin user, `POST /api/users` with `isAdmin: true` and the username and email given with
`--admin-user` and `--admin-email`. Keep the returned `id`. That is the OIDC `sub` (P
`oidc/claims_service.go` uses `user.ID`, a UUIDv4 set at creation, so it's stable).
4. Create the Console client: client id `mosaic-console-<instance>`, callback and logout URLs exactly as in
section 3, `pkceEnabled: true`, `isPublic: false`. Supply the secret instead of letting Pocket ID
generate it. Enroll generates 48 random bytes, base64url, and writes them straight to slot
`<configDir>/secrets/mosaic-stack/console-sso-pocket-id` (0600, `O_CREAT|O_EXCL`, not a symlink). It sends
the same bytes in the create body over the namespace's loopback (`dto/oidc_dto.go` accepts a supplied
secret of 16 or more characters). The value is never printed, logged or put in an argv, and a test pins
that it appears in no output.
5. Mint a one-time access token for the admin, `POST /api/users/<id>/one-time-access-token`.
6. Stop the namespaced process, then shred and remove the static key file. The real unit has never had a
static key, so no admin key survives enroll.
7. Start the real unit and wait for `/healthz` 204.
8. Print the sign-in link `<origin>/lc/<token>` to the user's terminal (section 5), and the `console.sso`
entry (section 5).
The build row confirms each route and body against the pinned tag's source before it writes the client (the
upstream read for this design didn't trace the create-client route). If any of steps 1 to 7 fails, enroll
stops the process, removes the static key, leaves no slot file it created, and refuses with the step name.
The secret never enters the repo or an image. It sits in two places only: the slot file and Pocket ID's
database (stored as a hash). The Console's `readSlot` reads it as it does any provider's.
## 5. The first person, and their subject in `console.sso`
Pocket ID is passkey-only for people (REQ-INST-2). The installing user enrolls like this:
1. Enroll prints `<origin>/lc/<token>`. The token is valid for one hour and is a credential, so it goes only
to the terminal of the person who ran enroll. It goes in no log and no file.
2. The user opens it in a browser that can reach `origin` (section 6). Pocket ID signs them in and asks them
to add a passkey. Without one they can't sign in again. If the hour passes, `pocket-id
one-time-access-token <username>` (P `cmds/one_time_access_token.go`), run by the user against the data
directory, prints a new link. `component-pocket-id.sh relink` wraps that call.
3. Enroll already has the subject, the `id` from step 3 of section 4. It doesn't wait for a sign-in or read a
`subject-unknown` log line.
How the subject gets into `console.sso`. config.json is user-authored and never auto-written. Two ways
to respect that; I recommend the first:
- **R1 (recommended).** Enroll prints the full entry and writes nothing:
```json
"console": { "sso": { "publicOrigin": "<publicOrigin>", "providers": [ {
"id": "pocket-id", "issuer": "<origin>", "clientId": "mosaic-console-<instance>",
"clientSecret": { "slot": "console-sso-pocket-id" }, "algs": ["RS256"],
"subjects": [ { "sub": "<id>", "name": "<admin-user>" } ] } ] } },
"components": { "pocket-id": { "release": "<tag>", "origin": "<origin>", "port": <port> } }
```
The user pastes it into config.json, as with Q175 step 4. Rerunning `scripts/bootstrap.sh` (or
`--instance NAME`) then validates it through the loader, including the section 1 cross-check, and writes
nothing. Nothing changes in invariant 2.
- **R2 (rejected, Sage sage-4401: R1 chosen).** `enroll --write-config` would write those two keys itself, with a backup of config.json first, and
refuse if `console` or `components` were already present. That would be the first tool to modify an
existing config.json, against canon invariant 2's "never overwritten" practice. Not built.
Whose subject. Enroll creates one person, the one running it, and only that subject goes in the entry. On
Jason's instances, only his subject goes into `console.sso` (mos-1197). Further people are added with
Pocket ID's admin UI (a user plus a one-time link), and their subjects go into `console.sso` by the user's
edit, never by an agent. Pocket ID users who aren't in `subjects` can sign in to Pocket ID, but the Console
refuses them `subject-unknown`, so REQ-WEB-2's "must map to an authorized human" holds.
## 6. Reach: origin, TLS and proxy
WebAuthn takes its RP id from `APP_URL`'s host name, and its allowed origin is `APP_URL`
(P `webauthn/module.go`). Browsers allow passkeys only on a secure origin. The Console also needs https for
both `publicOrigin` and `issuer`. Pocket ID serves plain http, and I found no TLS listener setting in its
environment config. So:
- Pocket ID always binds `127.0.0.1:<port>`. That is the default, and the component never binds anything
else (`HOST=127.0.0.1`; the status verb refuses a unit whose `HOST` differs).
- `origin` is an https origin served by a TLS front that the user already has for the Console. The Console
needs one anyway, since `publicOrigin` is https. Pocket ID goes behind the same front under **its own host
name**, for example `id.<console host>`. Pocket ID doesn't support a path prefix (`APP_URL` must have no
path). A separate host name also keeps Pocket ID's cookies apart from the Console's
`__Host-mosaic_*` cookies; the two share cookies if they sit on one host name on different ports.
- The front reaches Pocket ID on loopback, so the front has to run on the same host. If it doesn't, Pocket
ID would need a non-loopback bind, and that is a `network.expose` hard wait (REQ-WAIT-1), not a component
option.
- `TRUST_PROXY` is set to the front's address only, never `true`.
- The Console fetches discovery and JWKS from `issuer` over https from the server side, so the Console's
node has to trust the front's certificate. A public CA does that. A private CA needs
`NODE_EXTRA_CA_CERTS` in the Console unit, which the build row adds as a render flag.
These steps are the user's, and on Jason's hosts they are Jason's, because each one changes who can reach
the Console or Pocket ID, or who the host trusts:
- J1: the DNS name for `origin`;
- J2: the certificate for it, or a private CA and its trust in the browser and in `NODE_EXTRA_CA_CERTS`;
- J3: the TLS front's route from `origin` to `127.0.0.1:<port>` (a Traefik change on Jason's hosts);
- J4: any listener off loopback.
The component checks and refuses; it never does them itself. With none of them done, Pocket ID runs on
loopback with an https `APP_URL` that nothing serves yet. Enroll's step 2 health probe goes straight to
`127.0.0.1`, so enroll still works. The person can't add a passkey until J1 to J3 are done.
## 7. Agents
Unchanged. Agents keep their per-role service tokens (lead decision 44 item 6, REQ-INST-2). Pocket ID issues
nothing an agent uses. The Console client is the only client enroll creates. No API, no client-credentials
grant and no admin API key exist after enroll. Residuals, stated plainly:
- Agents can reach `127.0.0.1:<port>`, because the sandbox shares the host network. What they can reach there
is the public sign-in pages, discovery and JWKS. Signup is disabled, email codes are off, `/setup` is
closed because an admin exists, and there is no static key. A test of the rendered unit pins all four
settings.
- The data directory and the two slots are outside every sandbox bind (section 2).
- A seat that ran as the same OS user outside a sandbox could read the data directory. The Console's
existing slot has the same exposure; this design adds no new class of it.
## 8. Instances
One Pocket ID per named instance, never shared:
- Each instance has one system config (row 190), and `components` and `console.sso` live in it. A shared
Pocket ID would make one instance's config depend on a unit, a data directory and an encryption key that
belong to another instance's config and data root.
- Unit names, ports, data directory, slots and removal are all per instance (`mosaic-pocket-id-NAME`,
`<dataRoot of NAME>/components/pocket-id`), so removing one instance's component can't break another's
sign-in.
- Each instance needs its own `origin` (section 6), so passkeys are per instance too. A person enrolls once
per instance.
- An instance that wants to use another instance's Pocket ID can still do it with `--oidc existing`. Then it
is just an OIDC provider, with its own client registered by that instance's owner. Nothing special-cases it.
Two instances on one host each need their own `port`. The loader can't see other instances' configs, so
`component-pocket-id.sh` refuses at fetch and enroll when the port is already bound on loopback.
## 9. Tests, stand-ins only
No test downloads or starts a real Pocket ID, and no test touches auth.diversecanvas.com,
mosaic.woltje.com or 127.0.0.1:7332. The stand-ins:
- **Fake Pocket ID OP**, a node server like row 192's fake OP, shaped from v2.19.1:
- the discovery fields `well_known_controller.go` emits (issuer = origin, `plain` and `S256`,
`end_session_endpoint` at `/api/oidc/end-session`, the three auth methods);
- one RS256 JWK at `/.well-known/jwks.json`;
- a token endpoint that takes only `client_secret_basic`;
- `/healthz` 204.
The Console starts against it under `--sso` with the section 5 entry, with no check changed. Negative
cases:
- an http issuer refuses at load;
- an ES256-only JWKS against `algs: ["RS256"]` refuses at discovery;
- an issuer with a trailing slash refuses on the cross-check.
- **Fake admin API** on the same stand-in: `POST /api/users`, the client create, the one-time token, and a
static-key check. Enroll runs against it with the binary replaced by a stub script that serves the stand-in.
The tests pin:
- the namespace argv;
- the slot file's mode and `O_EXCL`;
- that the secret appears in no stdout, stderr or argv;
- the static key file gone after success and after a failure at each step;
- the refusal under an agent marker in the environment.
- **Fetch**: a local file served by a test https server (self-signed, trusted only by the test) with a
matching pin, a mismatched pin and a truncated file.
- **Unit render**: the environment table in section 2. `HOST=127.0.0.1`, no `STATIC_API_KEY`, no
`ENCRYPTION_KEY` value in the text, and `ALLOW_USER_SIGNUPS=disabled`.
- **Loader**: test-config.sh cases for the `components` key and the cross-check, and the copy-rollback note.
- **Sandbox**: `<dataRoot>/components/` is in no bind list.
## 10. Build rows, in landing order
All are post-v1 (REQ-INST-2). Each row has Webby or Sage's named reviewer and its own gate. None of them
runs on .53, .54 or dragon-lin, and none installs Pocket ID on any host.
1. **P1, loader key.** `components["pocket-id"]` in `mosaic-config.mjs`, strict inside the key, and the
cross-check against `console.sso` (section 1, M1 conditions from mos-1666 item 3). P1 also writes the
rollback order into `docs/TOOLS.md` beside Q175's: remove `components` (restore the backup) before the
pointer goes back, and the post-rollback STATE check confirms the key is absent from config.json. Gate:
test-config.sh green with the four M1 cases.
2. **P2, pin and fetch.** `scripts/pocket-id-pins.sh` with the build-time sha256 and the
attestation evidence, and `component-pocket-id.sh fetch|verify`. Gate: the fetch tests against the local
stand-in; the evidence file shows `gh attestation verify` output for each pinned asset.
3. **P3, unit.** The unit template, `render|install|status|remove|purge`, and the encryption key slot. Gate:
the render and remove tests, and the sandbox bind test.
4. **P4, enroll.** The namespaced bootstrap, the client, the subject, `relink`, and printing the entry (R1),
(R2 rejected). Gate: the enroll tests in section 9, and a secret-leak grep over every test log.
5. **P5, Console against the fake Pocket ID.** The section 9 fake-OP start tests, and the
`NODE_EXTRA_CA_CERTS` render flag for the Console unit. Gate: the webui suite, with no change to
`sso.mjs` checks (a diff of `sso.mjs` and `sso-config.mjs` against the base shows none).
6. **P6, installer option.** `--oidc existing|bundled` on REQ-INST-3's install command, calling P2 to P4 in
order, and the docs: `docs/TOOLS.md`, the host run sheet's J1 to J4 steps. Gate: the installer's own
suite against the stubs; the docs list J1 to J4 as the user's steps.
## Open items for review
- M1 approved by Mos with three conditions (mos-1666 item 3), written into section 1 and P1.
- M2 dropped: Sage chose R1 and rejected R2 (sage-4401).
- **Q1 (Webby):** the private network namespace for enroll (section 4). The other option is to start the
real unit and race `/setup`, which I think is unsafe while agents share the host network.
- **Q2 (Webby):** one-providers-entry for the bundled choice (section 1). REQ-INST-2's wording reads that
way. Mixing a bundled Pocket ID with existing providers would need Sage's ruling.
- Not covered: Pocket ID for Vikunja or Gitea people sign-in (the Researcher note's part 3), and
client-credentials between Mosaic services. Neither is in Q203.
-157
View File
@@ -1,157 +0,0 @@
# Row 258, E01: mail PRD addendum draft and schemas
Ludwig, 2026-10-22. Issue #1763. Assigned by sage-2802 under Q220. Reviewer: Della.
Base: 78b69e1e6 (r3; r1-r2 were on aca16404c). Branch: ludwig/row258-r3.
Sources: brief `docs/plans/2026-10-21_post-v1-row-briefs.md` (blob d7e5eaeea), "Brief: E01" and
"Gate: email rows". Email plan row E01 at origin/della/email-plan 179391055 (approved ludwig-0037).
Q2/Q222 terms (sage-2802): `mailboxScope` defaults to `[]`, changes are human-only, and the default-none
and refusal tests apply.
## Deliverable
| Path | What |
| --- | --- |
| `docs/prd/mail-addendum-draft.md` | REQ-MAIL-1 to 16, a draft. Each requirement names its rows. Two questions for Mos. |
| `packages/mail/schemas/*.schema.json` | source, data, access, service, content-key |
| `packages/mail/src/validate.mjs` | Closed-subset validator, binding, location, file and key-lifecycle checks |
| `packages/mail/tests/schema.test.mjs` | T1-T14, with T10b |
| `packages/mail/{package.json,README.md}` | Package manifest and notes |
Not changed: `docs/prd/mosaic-stack.md`, its requirements JSON and tests, `packages/plugins/src/kinds.mjs`,
`packages/business/src/vocabulary.mjs`, roles, `scripts/stack-copy.sh` COPY_PATHS. No new dependency.
## Choices and tradeoffs
1. **Validator.** A copy of the `packages/project/src/validate.mjs` pattern: a closed keyword set
that throws on an unknown keyword. Added `integer`, `minimum`, `maximum`, `uniqueItems` and `default`.
No JSON Schema library, so there is no dependency review. Tradeoff: two small validators to keep apart.
A shared one is a later refactor, not this row.
2. **Defaults live in the schema** (`default` keyword), read by `defaults(kind)`. One definition per
default and range, as Q222 asks.
3. **Ranges (implementer choice, for the reviewer to challenge).** Defaults come from the plan.
| Setting | Default | Min | Max |
| --- | --- | --- | --- |
| pollSeconds | 60 | 15 | 3600 |
| sliceObservations | 50 | 1 | 500 |
| sliceBytes | 10 MiB | 64 KiB | 64 MiB |
| pendingDeliveries | 1,000 | 1 | 100,000 |
| retainedBytes | 100 MiB | 1 MiB | 10 GiB |
| parserInputBytes | 30 MiB | 1 MiB | 64 MiB |
| mimeDepth | 10 | 1 | 32 |
| mimeParts | 100 | 1 | 1,000 |
| normalizedTextBytes | 256 KiB | 4 KiB | 4 MiB |
| attachmentPartBytes | 10 MiB | 0 | 50 MiB |
| attachmentMessageBytes | 10 MiB | 0 | 100 MiB |
| staleScanIntervals | 3 | 2 | 20 |
| pageSize | 20 | 1 | 100 |
| bodyDraftDays | 30 | 1 | 3,650 |
| attachmentDays | 7 | 1 | 3,650 |
| metadataDays | 365 | 1 | 3,650 |
Cross-checks: part bytes ≤ message bytes; metadataDays ≥ both content durations (tombstones outlive
content, D4).
4. **Fixed false until later rows.** `persistContent` and `automaticPrune` are `const false`: the plan
says metadata only until retention authority exists (E13), and settings never grant `data.delete`.
`sourceMutations` has `maxItems: 0`: no mutation capability exists (Q3). E13 or a later reviewed row
widens these.
5. **Human-only.** Settings records require `changedBy.kind: "human"`. The schema can't tell a real human
from an agent proxy. The E02 writer proves that, as the plan's E02 row says.
6. **Incomplete scopes.** An enabled source needs an adapter. Unattended wake needs at least one exact
sender: wake on with an empty allowlist would be on in name only. Grants need at least one right and
one mailbox. Service scopes are `["mail.read"]` only.
7. **Secret values.** Unknown keys are refused. All string values are also checked against secret
shapes (private key header, JWT, common token prefixes, URL userinfo password, `password=` style).
Messages name the path, never the value. This is a backstop, not a scanner.
8. **Content-key record location.** The plan names the key path. The lifecycle record's own file is left
to E03. `checkKeyLocation` and `checkProtectedFile` apply to both.
9. **Key lifecycle.** active → retired → expired, or active → expired. `keyTransitionAllowed` is a pure
check on one state edge. It doesn't persist state, stop a restored backup rolling a record back, or
destroy key copies; E03 owns that proof (della-0023 N1). `keyUse` is none outside
[createdAt, expiresAt), whatever the state says.
10. **v1 compatibility.** T13 asserts that kinds.mjs names no mail, vocabulary.mjs has no `mail.access`,
and the approved PRD has no REQ-MAIL. E05 and the approved addendum update T13 when they land.
11. **Use helpers fail closed (della-0023 F1).** `effectiveMailboxes` and `keyUse` validate their
records first and read the clock only from a valid `Date`. An invalid record or clock, or a time
outside [issuedAt, expiresAt) or [createdAt, expiresAt), gives no read and no key use. Tradeoff: a
caller with a slightly malformed but otherwise usable grant gets nothing, not a partial read. E02
still owns principal, right, manual-mode and revoke enforcement.
12. **Placement compares resolved paths too (della-0023 F2).** `checkSlotLocation` and
`checkKeyLocation` check the paths as written first, so planned paths that don't exist yet still
work. Then they resolve the nearest existing ancestor of the secret path and of each forbidden root,
and refuse containment. That catches a configDir linked into dataRoot and a root named through an
alias. `checkProtectedFile` now needs the file lexically under configDir, refuses a symbolic link
on configDir itself or any directory below it, and takes the forbidden roots for the same resolved
comparison. Symbolic links above configDir (a linked `/home`, say) stay allowed: they don't move
the secret relative to the roots, and the resolved comparison still covers them. Tradeoff: one
`realpath` per root on each check.
13. **Draft wording (mos-1313).** W1: rulings are cited as "DECISIONS Q…", plan questions as "email
plan Q…", and "Q4" is replaced by what it stands for. W2: retention says expiry stops serving an
item and removal waits for `data.delete`. W3: an invalid mail setting is refused when written and
holds its source with an alert when found at load; no fallback to the default. W4: the questions
section is gone; Mos's two answers are recorded in the status lines.
## Test plan (not run: Gander holds the slot, sage-2802)
`cd packages/mail && node --test tests/schema.test.mjs`. It writes only under `os.tmpdir()`, which the gate
sets to a `mktemp -d /mnt/storage/scratch/tmp/gXXXXXX` directory. No network and no `~/.mosaic-dev`.
Static checks done: schemas written by an uncommitted scratch script (the JSON files are the source),
JSON parses, every keyword is in the validator's closed set, every required key has a property, T13/T14 assertions checked against
the repository text with python3 -B, secret and wildcard patterns checked against every fixture and
sentinel. Node was not run.
Existing suites to run whole, because they scan every package's `src` or copy tracked package files:
- `packages/bus/tests/credential-store.test.mjs` (credential use sites)
- `packages/bus/tests/host-run.test.mjs` (T24 kind lists and use sites)
- `packages/plugins/tests/harness.test.mjs` (harness names outside plugins)
- `packages/cli/tests/stack-copy.test.mjs`, `packages/cli/tests/stack-release.test.mjs` (fixture copies)
- `packages/webui/tests/pinned-copy.test.mjs` (fixture copy of every tracked package file; added in r3)
Mutants, named by Della (della-0023). Each must die on a semantic assertion in the whole
`schema.test.mjs`, run on a CLEAR slot. The wildcard tests stay.
| Mutant | Change | Must fail |
| --- | --- | --- |
| M1 | `mailboxScope` default nonempty | T1/T2 |
| M2 | allow an agent settings author | T3 |
| M3 | remove the source/business binding comparison | T8 |
| M4 | disable secret-shape rejection | T6 |
| M5 | omit the grant intersection | T2 |
| M6 | omit the forbidden-root exclusion | T10 (config under dataRoot) |
| M7 | allow mode 0640 | T11 |
| M8 | allow retired → active | T12 |
| M9 | bypass an enforced numeric lower or upper comparison in `visit` | T9 |
| M10 | omit the malformed-state/time guard | T2/T12 lifetime and malformed cases |
| M11 | omit the canonical or root-symlink guard | T10b/T11 |
## Round 2 (della-0023, mos-1313)
Round 1 was a8f0cf924, CHANGES (della-0023). Round 2 adds F1, F2 and N1 above, and Mos's W1-W4 on the
draft. New cases: T2 lifetime edges and malformed grant or clock, T12 lifetime edges and malformed key
or clock, T10b (configDir linked into dataRoot with a real 0600 key, a planned slot through the link,
a dataRoot alias), T11 (file outside configDir, unnormalized path, linked configDir, root alias).
Static checks only; Node not run (Gander holds the slot).
## Round 3 (sage-4344): rebase onto 78b69e1e6
r1 a8f0cf924 and r2 3094e3bfa cherry-picked onto refactor 78b69e1e6 as new commits on ludwig/row258-r3,
with no conflicts. No refactor commit since aca16404c touches a row path. This file cites no line numbers,
so there were none to move. The mutant targets (`visit`, the guards in `effectiveMailboxes`, `keyUse`,
`keyTransitionAllowed`, `checkSlotLocation`, `checkKeyLocation`, `checkProtectedFile`) are in files the
rebase didn't change.
T13 inputs on the new base: `kinds.mjs` gained the harness `compactions` export (row 307) and still names
no mail. `vocabulary.mjs` grew, and still has no `mail.access`. `mosaic-stack.md` has no REQ-MAIL.
Run list rebuilt from imports and package scans. `validate.mjs` imports only `node:fs` and `node:path`. The test
imports `../src/validate.mjs` and reads `kinds.mjs`, `vocabulary.mjs`, `mosaic-stack.md` and the draft.
No test imports `packages/mail`. Every test or script that walks `packages/` or copies tracked package files
was reread on 78b69e1e6: credential-store, host-run, plugins harness, cli stack-copy, stack-release (and its
fixture), webui pinned-copy. The last was missing from r2's list. queue-checks copies only ledger and seat,
case2-unit reads installed copies, and gate-lane.test uses its own fixture, so none of them change.
`scripts/testing/gate-lane.mjs` adds a `pkg-mail` step to the whole gate by itself. `stack-copy.sh` COPY_PATHS
gained the persona import and model-proxy, still without `packages/mail`. Static checks only, no Node.
-164
View File
@@ -1,164 +0,0 @@
# PRD addendum draft: mail intake and access
- Status: draft, not approved. Ludwig drafted it for email plan row E01
(Vikunja 460, issue #1763). Its wording goes to Mos first, then Jason.
It does not change `docs/prd/mosaic-stack.md`. Once Jason approves it,
it lands there as a new PRD version, and only then does any requirement
here bind.
- Source: `docs/plans/2026-10-21_email-plan.md`, Della's revision
179391055, approved by Ludwig (ludwig-0037). DECISIONS Q217 settles
the mailbox (one agent mailbox on woltje.com at InMotion, which Jason
creates). DECISIONS Q222 settles personal mail (none for now) and the
settings rule: things are tunable, not hardcoded.
- Shape (mos-1313): once approved, this lands as one new PRD version with
a Mail section, in its own reviewed row. `mail.access` stays a hard
wait in that version.
- Scope: post-v1. None of it is needed for 2026-10-27.
- Q numbers: "DECISIONS Q…" is a ruling. "Email plan Q…" is a question
still open in the email plan.
## How to read this draft
Every limit, scope and time below is a setting with a default, unless
Jason fixed it himself. The default is the starting value, not a rule.
The schemas in `packages/mail/schemas/` hold each default and the range a
setting may take. Implementers and reviewers set that range. Widening it
needs a reviewed schema and test change. Only an authenticated human
changes a setting, through the audited CLI and later the Console. An
agent can't change one, not even to narrow it. For mail, an invalid
setting doesn't fall back to its default. A write with an invalid value
is refused. An invalid value found when the service loads holds that
source and raises an alert.
Each requirement lists the build rows that deliver it. The rows are in
the email plan's "Ordered build rows".
## Requirements
### Mail
- **REQ-MAIL-1.** The stack may run one mail service per instance. It is
off by default and opens no connection while off. A disabled source
makes no probe and no read. Each configured source has one leased
owner. Accounts stay separate by instance, business and source. One
service never means one shared credential or mailbox.
Rows: E01, E03, E14.
- **REQ-MAIL-2.** Each source has a mailbox scope, `mailboxScope`. It
defaults to none, which reads nothing, agent ingress included. A human
sets exact mailbox IDs. No wildcard is accepted. The scope is
rechecked before each scan and fetch. A mailbox removed from scope is
fenced before in-flight content returns. A read is the intersection of
the source scope, the provider's rights and the principal's current
grant. Widening a scope grants no agent anything. No personal mailbox
is enrolled until a later consent and grant (DECISIONS Q222).
Rows: E01, E02, E13.
- **REQ-MAIL-3.** A new plug-in kind, `mail-source`, carries providers
(REQ-PLUG-1). Its modules export `descriptor`, `scan`, `fetch` and
`health`. A module only reads: it never sends, deletes, labels, moves
or marks mail read. Microsoft 365 (Graph) and IMAP are the first
modules. POP3, Gmail API and JMAP wait for a named need. The kind
enters `packages/plugins/src/kinds.mjs` with its first production
module. That waits for the approved PRD version, and for the source,
grant and route approvals in REQ-MAIL-4, 7 and 8.
Rows: E05, E06.
- **REQ-MAIL-4.** A source connects through its own service principal,
`mail-service`. This is a new approved extension, not an exception to
REQ-CRED-1. Its record names the business, source, provider, mailbox,
scopes (read only), slot path, rotation date, approval and revision.
The slot sits under
`<configDir>/secrets/mosaic-stack/mail/<business>/<source>/`, never in
the repository or dataRoot. Agents never see the credential. A missing,
expired or revoked slot fails closed. Authentication may retry once,
then stops and alerts.
Rows: E01, E02.
- **REQ-MAIL-5.** Intake keeps a durable record of what it saw. The
provider cursor and the new observations commit together. A message's
identity is its provider ID within business, source and account, never
Message-ID alone. Delivery uses a stable key and a receipt at the
recipient, so a crash causes no second task. First activation starts
from now and acts on no old mail. Importing history is a separate
approved window. Per-source quotas default to 1,000 pending deliveries
and 100 MiB of content. A full quota, a full disk or missing audit
storage holds intake and raises an alert.
Rows: E03.
- **REQ-MAIL-6.** Parsing is bounded. Defaults: 30 MiB input, depth 10,
100 parts, 256 KiB of normalized text. Bad or truncated mail is
quarantined, never dropped. Active content never runs. Attachments
start as metadata. An authorized fetch quarantines the bytes, by
default up to 10 MiB per part and per message. Extracting attachment
content stays Jason's choice (email plan Q8). If he chooses it, extraction runs
isolated with no network and a scanner.
Rows: E04, E16.
- **REQ-MAIL-7.** A route table maps trusted delivery evidence to a Stack
user or role instance. It lives in the organization file and changes
only through the human-approved write path. A route grants no read
right. No agent reroutes mail or widens its own route. Bcc, ambiguous
alias or forwarded mail goes to restricted review. Unknown addresses
create no agent.
Rows: E07, E08.
- **REQ-MAIL-8.** Agent access to mail needs a `mail.access` grant. It is
separate from `credential.mint`. The chief raises it, and the human
approves the exact principal, source, mailboxes, rights and expiry.
Metadata, body, attachment and draft are separate rights. Each read
rechecks the current grant. A revoke fences new reads and in-flight
reads before content returns. Manual mode asks the human for each use.
`mail.access` is a new vocabulary action, so it is a hard wait
(REQ-WAIT-3) until a PRD version Jason approves says otherwise
(REQ-WAIT-2).
Rows: E02, E09.
- **REQ-MAIL-9.** Every harness gets the same mail tools through the
broker: list, get, thread, attachment, acknowledge, create a local
draft and health. A list page defaults to 20 items. Acknowledge
changes Stack state only. A draft is a local unsent record. There is
no send tool and no reroute tool.
Rows: E09.
- **REQ-MAIL-10.** Mail content is untrusted. Tool results carry
`untrusted: true`. A sender can't approve a decision, change policy,
mint a grant or pose as the human. Mail never wakes an agent by
default (`unattendedWake` is off, and its sender allowlist is empty).
A human may turn it on for a route. Then a wake also needs aligned
DMARC, a current grant and admission by the system manager
(DECISIONS Q198).
The woken work runs in a fresh restricted profile, and anything it
starts keeps `source: mail`.
Rows: E10.
- **REQ-MAIL-11.** A mail-triggered decision goes through the stack's one
decision module (DECISIONS Q207). Its input is metadata only, inside a closed
schema. The mail service never calls a model itself. Without a model,
deterministic intake keeps working and model work is held.
Rows: E11.
- **REQ-MAIL-12.** Mail health reports real progress: last scan, last
cursor commit, lag, backlog and credential state. A live process alone
is not healthy. Outages pause only the affected source. Recovery
follows the system manager's protected mode and its recovery latch
(DECISIONS Q198). A health check sends no mail and calls no model.
Rows: E12.
- **REQ-MAIL-13.** Mail data lives under `<dataRoot>/mail/<business>/`
and never enters git or an image. By default the stack keeps metadata
only. Content keys sit under
`<configDir>/secrets/mosaic-stack/mail-content/<business>/`, outside
dataRoot and its backups, mode 600, with no symbolic link. Retention
defaults: bodies and drafts 30 days, attachments 7 days, metadata 365
days. When an item's time runs out, the stack stops serving it. A
retention setting never authorizes deletion: removing the item waits
for `data.delete` or a retention authority Jason approves later. A
restored backup must not bring back expired content or redeliver old
work.
Rows: E01, E03, E13.
- **REQ-MAIL-14.** The mail service never sends. No SMTP, Graph send,
forward, auto-reply or emailed digest. The email specialist reads and
drafts, never sends (R21). A future sender needs its own plan and
`message.external` approval.
Rows: E05, E06, E09, E14.
- **REQ-MAIL-15.** Every mail limit, scope and retention time is a
setting. The schema holds its default and range. Only a human changes
it. A change is versioned and audited with who made it. An invalid
value is refused when written, and holds its source with an alert when
found at load. Settings never
override grants, hard waits or the system manager's contract.
Rows: E01, E04, E13.
- **REQ-MAIL-16.** A live pilot is separate. It needs Jason's mailbox,
the approved PRD version, the source, grant and route approvals in
REQ-MAIL-4, 7 and 8, and a reviewed re-pin. It sends no test mail, and it
is judged on evidence for each message, not counts.
Rows: E15.
-59
View File
@@ -1,59 +0,0 @@
# Mail records — schemas only (email plan row E01)
Post-v1. This package holds the record schemas for mail intake and access,
and a validator. Nothing here opens a connection, reads a secret or
registers a plug-in kind. Core `mail-source` registration waits for the
approved PRD version and the source, grant and route approvals. The addendum draft is
`docs/prd/mail-addendum-draft.md`; it binds nothing until Jason approves it.
| Schema | Record |
| --- | --- |
| `schemas/source.schema.json` | Per-source settings: enabled, adapter, `mailboxScope`, wake, limits |
| `schemas/data.schema.json` | Per-business content and retention settings |
| `schemas/access.schema.json` | A `mail.access` grant to one agent or user |
| `schemas/service.schema.json` | A source's service principal and its slot path |
| `schemas/content-key.schema.json` | A content key's location and lifecycle |
## Settings (DECISIONS Q222)
Every limit, scope and retention time is a setting. Its default and its
range sit in the schema, so they have one definition. `defaults(kind)`
reads them. Implementers and reviewers set the range; widening it is a
reviewed schema and test change. A settings record carries
`changedBy.kind: "human"`, and the validator refuses any other author.
Defaults are off and empty: `sourceEnabled: false`, `mailboxScope: []`,
`sourceMutations: []`, `accessMode: "manual"`, `unattendedWake: false`,
`wakeSenderAllowlist: []`, `persistContent: false` and
`automaticPrune: false`. `persistContent` and `automaticPrune` are fixed
false until retention authority exists (E13).
## Checks
- `validateRecord(kind, value)` returns a list of problems. It refuses
unknown keys, wildcards, secret-shaped values, values outside the
schema range and incomplete scopes. A refusal never echoes the value.
- `checkBinding({source, service, access})` refuses records that name a
different business, source or provider, and a grant past the source's
mailbox scope.
- `effectiveMailboxes(...)` intersects the source scope, the provider's
mailboxes and the grant, from `issuedAt` up to `expiresAt`. The
defaults read nothing. An invalid record or clock reads nothing.
Principal, right, manual-mode and revoke checks belong to E02.
- `checkSlotLocation` and `checkKeyLocation` keep provider slots and
content keys under `<configDir>/secrets/mosaic-stack/`, outside
dataRoot, the repository and any backup root. They compare the paths
as written and again after resolving symbolic links, so a linked
configDir or an aliased root can't hide a secret inside a forbidden root.
- `checkProtectedFile` wants a regular file under configDir, owned, mode
600, with no symbolic link from configDir itself down to the file. Given
the forbidden roots, it compares resolved paths against them too.
- `keyUse` gives active keys encrypt and decrypt, retired keys decrypt
only, and expired keys nothing. An invalid record or clock, or a time
outside `createdAt` to `expiresAt`, gives nothing.
- `keyTransitionAllowed` checks one state edge: active to retired or
expired, retired to expired. It is a pure predicate. It doesn't persist
state, stop a restored backup from rolling a record back, or destroy
key copies. E03 owns that proof.
Tests: `node --test tests/*.test.mjs` from this directory.
-11
View File
@@ -1,11 +0,0 @@
{
"name": "@mosaic/mail",
"version": "0.1.0",
"private": true,
"type": "module",
"license": "UNLICENSED",
"exports": "./src/validate.mjs",
"scripts": {
"test": "node --test tests/*.test.mjs"
}
}
-103
View File
@@ -1,103 +0,0 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "mosaic:mail:1:access",
"type": "object",
"additionalProperties": false,
"required": [
"recordVersion",
"kind",
"business",
"source",
"principal",
"rights",
"mailboxes",
"mode",
"approvalId",
"issuedAt",
"expiresAt",
"revision"
],
"properties": {
"recordVersion": {
"const": 1
},
"kind": {
"const": "mail-access"
},
"business": {
"type": "string",
"pattern": "^[a-z0-9][a-z0-9._-]{0,63}$"
},
"source": {
"type": "string",
"pattern": "^[a-z0-9][a-z0-9._-]{0,63}$"
},
"principal": {
"type": "object",
"additionalProperties": false,
"required": [
"kind",
"id"
],
"properties": {
"kind": {
"enum": [
"agent",
"user"
]
},
"id": {
"type": "string",
"pattern": "^[a-z0-9][a-z0-9._-]{0,63}$"
}
}
},
"rights": {
"type": "array",
"minItems": 1,
"maxItems": 4,
"uniqueItems": true,
"items": {
"enum": [
"metadata",
"body",
"attachment",
"draft"
]
}
},
"mailboxes": {
"type": "array",
"minItems": 1,
"maxItems": 100,
"uniqueItems": true,
"items": {
"type": "string",
"pattern": "^[A-Za-z0-9][A-Za-z0-9._@+-]{0,253}$"
}
},
"mode": {
"enum": [
"manual",
"grant"
]
},
"approvalId": {
"type": "string",
"pattern": "^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$"
},
"issuedAt": {
"type": "string",
"format": "date-time"
},
"expiresAt": {
"type": "string",
"format": "date-time"
},
"revision": {
"type": "integer",
"minimum": 1,
"maximum": 2147483647
}
}
}
@@ -1,57 +0,0 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "mosaic:mail:1:content-key",
"type": "object",
"additionalProperties": false,
"required": [
"recordVersion",
"kind",
"business",
"keyId",
"path",
"state",
"createdAt",
"expiresAt",
"revision"
],
"properties": {
"recordVersion": {
"const": 1
},
"kind": {
"const": "mail-content-key"
},
"business": {
"type": "string",
"pattern": "^[a-z0-9][a-z0-9._-]{0,63}$"
},
"keyId": {
"type": "string",
"pattern": "^[a-z0-9][a-z0-9._-]{0,63}$"
},
"path": {
"type": "string",
"pattern": "^/[^\\u0000]{0,4095}$"
},
"state": {
"enum": [
"active",
"retired",
"expired"
]
},
"createdAt": {
"type": "string",
"format": "date-time"
},
"expiresAt": {
"type": "string",
"format": "date-time"
},
"revision": {
"type": "integer",
"minimum": 1,
"maximum": 2147483647
}
}
}
-83
View File
@@ -1,83 +0,0 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "mosaic:mail:1:data",
"type": "object",
"additionalProperties": false,
"required": [
"recordVersion",
"kind",
"business",
"persistContent",
"bodyDraftDays",
"attachmentDays",
"metadataDays",
"automaticPrune",
"revision",
"changedBy",
"changedAt"
],
"properties": {
"recordVersion": {
"const": 1
},
"kind": {
"const": "mail-data-settings"
},
"business": {
"type": "string",
"pattern": "^[a-z0-9][a-z0-9._-]{0,63}$"
},
"persistContent": {
"const": false,
"default": false
},
"bodyDraftDays": {
"type": "integer",
"minimum": 1,
"maximum": 3650,
"default": 30
},
"attachmentDays": {
"type": "integer",
"minimum": 1,
"maximum": 3650,
"default": 7
},
"metadataDays": {
"type": "integer",
"minimum": 1,
"maximum": 3650,
"default": 365
},
"automaticPrune": {
"const": false,
"default": false
},
"revision": {
"type": "integer",
"minimum": 1,
"maximum": 2147483647
},
"changedBy": {
"type": "object",
"additionalProperties": false,
"required": [
"kind",
"id"
],
"properties": {
"kind": {
"const": "human"
},
"id": {
"type": "string",
"pattern": "^[a-z0-9][a-z0-9._-]{0,63}$"
}
}
},
"changedAt": {
"type": "string",
"format": "date-time"
}
}
}
-73
View File
@@ -1,73 +0,0 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "mosaic:mail:1:service",
"type": "object",
"additionalProperties": false,
"required": [
"recordVersion",
"kind",
"business",
"source",
"provider",
"mailbox",
"scopes",
"slotPath",
"rotateBy",
"approvalId",
"revision"
],
"properties": {
"recordVersion": {
"const": 1
},
"kind": {
"const": "mail-service"
},
"business": {
"type": "string",
"pattern": "^[a-z0-9][a-z0-9._-]{0,63}$"
},
"source": {
"type": "string",
"pattern": "^[a-z0-9][a-z0-9._-]{0,63}$"
},
"provider": {
"enum": [
"m365",
"imap"
]
},
"mailbox": {
"type": "string",
"pattern": "^[A-Za-z0-9][A-Za-z0-9._@+-]{0,253}$"
},
"scopes": {
"type": "array",
"minItems": 1,
"maxItems": 1,
"uniqueItems": true,
"items": {
"enum": [
"mail.read"
]
}
},
"slotPath": {
"type": "string",
"pattern": "^/[^\\u0000]{0,4095}$"
},
"rotateBy": {
"type": "string",
"format": "date-time"
},
"approvalId": {
"type": "string",
"pattern": "^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$"
},
"revision": {
"type": "integer",
"minimum": 1,
"maximum": 2147483647
}
}
}
-222
View File
@@ -1,222 +0,0 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "mosaic:mail:1:source",
"type": "object",
"additionalProperties": false,
"required": [
"recordVersion",
"kind",
"business",
"source",
"provider",
"sourceEnabled",
"sourceAdapter",
"mailboxScope",
"sourceMutations",
"accessMode",
"unattendedWake",
"wakeSenderAllowlist",
"limits",
"revision",
"changedBy",
"changedAt"
],
"properties": {
"recordVersion": {
"const": 1
},
"kind": {
"const": "mail-source-settings"
},
"business": {
"type": "string",
"pattern": "^[a-z0-9][a-z0-9._-]{0,63}$"
},
"source": {
"type": "string",
"pattern": "^[a-z0-9][a-z0-9._-]{0,63}$"
},
"provider": {
"enum": [
"m365",
"imap"
]
},
"sourceEnabled": {
"type": "boolean",
"default": false
},
"sourceAdapter": {
"type": [
"string",
"null"
],
"pattern": "^[a-z][a-z0-9-]{0,63}$",
"default": null
},
"mailboxScope": {
"type": "array",
"maxItems": 100,
"uniqueItems": true,
"default": [],
"items": {
"type": "string",
"pattern": "^[A-Za-z0-9][A-Za-z0-9._@+-]{0,253}$"
}
},
"sourceMutations": {
"type": "array",
"maxItems": 0,
"default": [],
"items": {
"type": "string"
}
},
"accessMode": {
"enum": [
"manual",
"grant"
],
"default": "manual"
},
"unattendedWake": {
"type": "boolean",
"default": false
},
"wakeSenderAllowlist": {
"type": "array",
"maxItems": 100,
"uniqueItems": true,
"default": [],
"items": {
"type": "string",
"pattern": "^[a-z0-9][a-z0-9._+-]{0,63}@[a-z0-9][a-z0-9.-]{0,252}$"
}
},
"limits": {
"type": "object",
"additionalProperties": false,
"required": [
"pollSeconds",
"sliceObservations",
"sliceBytes",
"pendingDeliveries",
"retainedBytes",
"parserInputBytes",
"mimeDepth",
"mimeParts",
"normalizedTextBytes",
"attachmentPartBytes",
"attachmentMessageBytes",
"staleScanIntervals",
"pageSize"
],
"properties": {
"pollSeconds": {
"type": "integer",
"minimum": 15,
"maximum": 3600,
"default": 60
},
"sliceObservations": {
"type": "integer",
"minimum": 1,
"maximum": 500,
"default": 50
},
"sliceBytes": {
"type": "integer",
"minimum": 65536,
"maximum": 67108864,
"default": 10485760
},
"pendingDeliveries": {
"type": "integer",
"minimum": 1,
"maximum": 100000,
"default": 1000
},
"retainedBytes": {
"type": "integer",
"minimum": 1048576,
"maximum": 10737418240,
"default": 104857600
},
"parserInputBytes": {
"type": "integer",
"minimum": 1048576,
"maximum": 67108864,
"default": 31457280
},
"mimeDepth": {
"type": "integer",
"minimum": 1,
"maximum": 32,
"default": 10
},
"mimeParts": {
"type": "integer",
"minimum": 1,
"maximum": 1000,
"default": 100
},
"normalizedTextBytes": {
"type": "integer",
"minimum": 4096,
"maximum": 4194304,
"default": 262144
},
"attachmentPartBytes": {
"type": "integer",
"minimum": 0,
"maximum": 52428800,
"default": 10485760
},
"attachmentMessageBytes": {
"type": "integer",
"minimum": 0,
"maximum": 104857600,
"default": 10485760
},
"staleScanIntervals": {
"type": "integer",
"minimum": 2,
"maximum": 20,
"default": 3
},
"pageSize": {
"type": "integer",
"minimum": 1,
"maximum": 100,
"default": 20
}
}
},
"revision": {
"type": "integer",
"minimum": 1,
"maximum": 2147483647
},
"changedBy": {
"type": "object",
"additionalProperties": false,
"required": [
"kind",
"id"
],
"properties": {
"kind": {
"const": "human"
},
"id": {
"type": "string",
"pattern": "^[a-z0-9][a-z0-9._-]{0,63}$"
}
}
},
"changedAt": {
"type": "string",
"format": "date-time"
}
}
}
-237
View File
@@ -1,237 +0,0 @@
import { lstatSync, readFileSync, realpathSync } from 'node:fs';
import { basename, dirname, isAbsolute, join, normalize, relative, sep } from 'node:path';
// Mail record schemas (email plan row E01). Nothing here reads a secret, opens a connection or
// registers a plug-in kind: core mail-source registration waits for the approved PRD version and the
// source, grant and route approvals.
export const KINDS = Object.freeze(['source', 'data', 'access', 'service', 'content-key']);
const KEYWORDS = new Set(['$schema', '$id', 'type', 'const', 'enum', 'properties', 'required',
'additionalProperties', 'items', 'minItems', 'maxItems', 'uniqueItems', 'minLength', 'maxLength',
'minimum', 'maximum', 'pattern', 'format', 'default']);
const schemas = Object.fromEntries(KINDS.map(kind => [kind,
JSON.parse(readFileSync(new URL(`../schemas/${kind}.schema.json`, import.meta.url), 'utf8')),
]));
// Package-owned schemas use this deliberately small closed subset. Never silently ignore a new constraint.
function known(schema) {
for (const key of Object.keys(schema)) if (!KEYWORDS.has(key)) throw Error(`unsupported schema keyword: ${key}`);
for (const child of Object.values(schema.properties ?? {})) known(child);
if (schema.items) known(schema.items);
}
for (const schema of Object.values(schemas)) known(schema);
const isObject = value => value !== null && typeof value === 'object' && !Array.isArray(value);
function visit(schema, value, path, problems) {
const bad = text => problems.push(`${path}: ${text}`);
if (Object.hasOwn(schema, 'const') && value !== schema.const) bad(`must equal ${JSON.stringify(schema.const)}`);
if (schema.enum && !schema.enum.includes(value)) bad(`must be one of ${schema.enum.join(', ')}`);
const types = schema.type ? [schema.type].flat() : [];
const matches = type => type === 'null' ? value === null : type === 'object' ? isObject(value)
: type === 'array' ? Array.isArray(value) : type === 'integer' ? Number.isSafeInteger(value)
: typeof value === type;
if (types.length && !types.some(matches)) { bad(`must be ${types.join(' or ')}`); return; }
if (typeof value === 'number') {
if (schema.minimum !== undefined && value < schema.minimum) bad(`below the schema bound ${schema.minimum}`);
if (schema.maximum !== undefined && value > schema.maximum) bad(`above the schema bound ${schema.maximum}`);
}
if (typeof value === 'string') {
if (schema.minLength !== undefined && value.length < schema.minLength) bad('too short');
if (schema.maxLength !== undefined && value.length > schema.maxLength) bad('too long');
if (schema.pattern && !new RegExp(schema.pattern).test(value)) bad('invalid format');
if (schema.format === 'date-time' && (!Number.isFinite(Date.parse(value)) ||
new Date(value).toISOString() !== value || value.length !== 24)) bad('invalid canonical UTC date-time');
if (value.includes('\0')) bad('NUL is forbidden');
}
if (Array.isArray(value)) {
if (schema.minItems !== undefined && value.length < schema.minItems) bad('too few items');
if (schema.maxItems !== undefined && value.length > schema.maxItems) bad('too many items');
if (schema.uniqueItems && new Set(value.map(item => JSON.stringify(item))).size !== value.length) bad('duplicate items');
if (schema.items) value.forEach((item, index) => visit(schema.items, item, `${path}[${index}]`, problems));
}
if (isObject(value)) {
for (const key of schema.required ?? []) if (!Object.hasOwn(value, key)) bad(`missing ${key}`);
for (const [key, child] of Object.entries(value)) {
if (Object.hasOwn(schema.properties ?? {}, key)) visit(schema.properties[key], child, `${path}.${key}`, problems);
else if (schema.additionalProperties === false) bad(`unknown key ${key}`);
}
}
}
// Wildcards are refused by every pattern; this names the reason plainly. Scopes are exact IDs only.
const WILDCARD = /[*?%]/;
// Shapes of secret values. A record names a protected slot path, never a credential (invariant 3).
const SECRET_SHAPES = [
/-----BEGIN [A-Z ]*PRIVATE KEY-----/,
/\beyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\./,
/\b(?:gh[pousr]_|glpat-|xox[abprs]-|sk-)[A-Za-z0-9_-]{8,}/,
/:\/\/[^/\s:@]+:[^/\s@]+@/,
/\b(?:password|passwd|secret|token|apikey|api_key|client_secret)\s*[=:]/i,
];
export const looksSecret = text => SECRET_SHAPES.some(shape => shape.test(text));
function strings(value, path, out) {
if (typeof value === 'string') out.push([path, value]);
else if (Array.isArray(value)) value.forEach((item, index) => strings(item, `${path}[${index}]`, out));
else if (isObject(value)) for (const [key, child] of Object.entries(value)) strings(child, `${path}.${key}`, out);
return out;
}
export function validateRecord(kind, value) {
if (!Object.hasOwn(schemas, kind)) return [`unknown record kind: ${kind}`];
const problems = [];
visit(schemas[kind], value, kind, problems);
for (const [path, text] of strings(value, kind, [])) {
if (looksSecret(text)) problems.push(`${path}: looks like a secret value; name a protected slot instead`);
else if (WILDCARD.test(text)) problems.push(`${path}: wildcard not allowed; scopes name exact IDs`);
}
if (problems.length) return problems;
if (kind === 'source') {
if (value.sourceEnabled && value.sourceAdapter === null)
problems.push('source.sourceAdapter: an enabled source needs an enrolled adapter');
if (value.unattendedWake && value.wakeSenderAllowlist.length === 0)
problems.push('source.wakeSenderAllowlist: unattended wake needs at least one exact sender');
if (value.limits.attachmentPartBytes > value.limits.attachmentMessageBytes)
problems.push('source.limits: attachmentPartBytes exceeds attachmentMessageBytes');
}
if (kind === 'data' && value.metadataDays < Math.max(value.bodyDraftDays, value.attachmentDays))
problems.push('data.metadataDays: metadata and tombstones must outlive content');
if (kind === 'access' && Date.parse(value.expiresAt) <= Date.parse(value.issuedAt))
problems.push('access.expiresAt: must be after issuedAt');
if (kind === 'content-key' && Date.parse(value.expiresAt) <= Date.parse(value.createdAt))
problems.push('content-key.expiresAt: must be after createdAt');
return problems;
}
// Defaults come from the schema, so a default and its bounds have one definition (Q222).
function defaultsOf(schema) {
if (Object.hasOwn(schema, 'default')) return structuredClone(schema.default);
if (schema.type !== 'object') return undefined;
const out = {};
for (const [key, child] of Object.entries(schema.properties ?? {})) {
const value = defaultsOf(child);
if (value !== undefined) out[key] = value;
}
return Object.keys(out).length ? out : undefined;
}
export function defaults(kind) {
if (kind !== 'source' && kind !== 'data') throw Error(`no settings defaults for ${kind}`);
return defaultsOf(schemas[kind]);
}
export const schemaBounds = (kind, key) => {
const node = key.split('.').reduce((schema, part) => schema?.properties?.[part], schemas[kind]);
if (!node || node.minimum === undefined) throw Error(`no bounds for ${kind}.${key}`);
return { minimum: node.minimum, maximum: node.maximum, default: node.default };
};
// Records that name one another must agree, and a grant never reaches past the source's mailbox scope.
// (The access schema already limits a grant's principal to an agent or a user, never the service.)
export function checkBinding({ source, service, access }) {
const problems = [];
const same = (label, a, b) => { for (const key of ['business', 'source']) if (a[key] !== b[key]) problems.push(`${label}: ${key} mismatch`); };
if (service) {
same('service', service, source);
if (service.provider !== source.provider) problems.push('service: provider mismatch');
}
if (access) {
same('access', access, source);
const scope = new Set(source.mailboxScope);
for (const mailbox of access.mailboxes) if (!scope.has(mailbox)) problems.push(`access: mailbox ${mailbox} outside the source scope`);
}
return problems;
}
const instant = now => (now instanceof Date && Number.isFinite(now.getTime()) ? now.getTime() : NaN);
// What a principal may read: the source scope, the provider's mailboxes and the grant, intersected.
// A disabled source, the default empty scope, a missing, invalid, not-yet-issued or expired grant, or an
// invalid clock reads nothing. Authenticated principal, right, manual-mode and revoke checks belong to E02.
export function effectiveMailboxes({ source, providerMailboxes = [], access = null, now = new Date() }) {
if (!access || validateRecord('source', source).length || validateRecord('access', access).length) return [];
if (!source.sourceEnabled || !Array.isArray(providerMailboxes)) return [];
const at = instant(now);
if (!(at >= Date.parse(access.issuedAt) && at < Date.parse(access.expiresAt))) return [];
if (access.business !== source.business || access.source !== source.source) return [];
const provider = new Set(providerMailboxes);
const granted = new Set(access.mailboxes);
return source.mailboxScope.filter(mailbox => provider.has(mailbox) && granted.has(mailbox));
}
function inside(root, path) {
const rel = relative(root, path);
return rel === '' || (!rel.startsWith(`..${sep}`) && rel !== '..' && !isAbsolute(rel));
}
// The path with every existing part resolved, so a symbolic link or alias can't hide where it lands.
// Parts that don't exist yet (a planned slot) are kept as written under their nearest existing ancestor.
function canonical(path) {
const rest = [];
for (let at = path; ; at = dirname(at)) {
try { return join(realpathSync(at), ...rest.reverse()); } catch (error) {
if (error.code !== 'ENOENT' || at === dirname(at)) return null;
rest.push(basename(at));
}
}
}
// Lexical checks first, for planned paths. Then the same forbidden roots compared after resolution, so
// a configDir that links into dataRoot, the repository or a backup root is refused too.
function forbidden(label, path, { dataRoot, repoRoot, backupRoots = [] }) {
const problems = [];
const roots = [['dataRoot', dataRoot], ['the repository', repoRoot], ...backupRoots.map(root => ['a backup root', root])]
.filter(([, root]) => root);
for (const [name, root] of roots) if (inside(root, path)) problems.push(`${label}: inside ${name}; mail secrets live outside it`);
if (problems.length) return problems;
const real = canonical(path);
if (real === null) return [`${label}: path can't be resolved`];
for (const [name, root] of roots) {
const realRoot = canonical(root);
if (realRoot === null || inside(realRoot, real)) problems.push(`${label}: resolves inside ${name}; mail secrets live outside it`);
}
return problems;
}
function placed(label, path, expectedDir, roots) {
if (!isAbsolute(path) || normalize(path) !== path) return [`${label}: must be a normalized absolute path`];
const problems = !inside(expectedDir, path) || path === expectedDir ? [`${label}: must sit under ${expectedDir}`] : [];
return [...problems, ...forbidden(label, path, roots)];
}
// A source service's provider credential slot (plan D9).
export const slotDir = ({ configDir, business, source }) => join(configDir, 'secrets', 'mosaic-stack', 'mail', business, source);
export const checkSlotLocation = (service, roots) =>
placed('service.slotPath', service.slotPath, slotDir({ ...roots, ...service }), roots);
// A content key, outside dataRoot and its backup set, apart from provider slots (plan D7).
export const keyPath = ({ configDir, business, keyId }) => join(configDir, 'secrets', 'mosaic-stack', 'mail-content', business, keyId);
export function checkKeyLocation(record, roots) {
const problems = placed('content-key.path', record.path, join(roots.configDir, 'secrets', 'mosaic-stack', 'mail-content', record.business), roots);
if (!problems.length && record.path !== keyPath({ ...roots, ...record })) problems.push(`content-key.path: must be ${keyPath({ ...roots, ...record })}`);
return problems;
}
// A protected file: inside configDir, owned, mode 600, regular, with no symbolic link from configDir
// itself down to the file, and not resolving into a forbidden root.
export function checkProtectedFile(path, { configDir, uid = process.getuid(), ...roots }) {
if (!isAbsolute(path) || normalize(path) !== path || !isAbsolute(configDir) || normalize(configDir) !== configDir)
return [`${path}: must be a normalized absolute path`];
if (!inside(configDir, path) || path === configDir) return [`${path}: must sit under ${configDir}`];
let stat;
try { stat = lstatSync(path); } catch { return [`${path}: not found`]; }
if (stat.isSymbolicLink() || !stat.isFile()) return [`${path}: must be a regular file, not a symbolic link`];
for (let at = dirname(path); ; at = dirname(at)) {
let dir;
try { dir = lstatSync(at); } catch { return [`${path}: path can't be resolved`]; }
if (dir.isSymbolicLink()) return [`${path}: a symbolic link sits on the path at ${at}`];
if (at === configDir) break;
}
const problems = [];
if (stat.uid !== uid) problems.push(`${path}: owned by uid ${stat.uid}, not ${uid}`);
if ((stat.mode & 0o777) !== 0o600) problems.push(`${path}: mode ${(stat.mode & 0o777).toString(8)}; use 600`);
return [...problems, ...forbidden(path, path, roots)];
}
// Key lifecycle states: active keys encrypt and decrypt, retired keys only decrypt, expired keys do
// neither. keyTransitionAllowed is a pure check on one state edge. It doesn't persist state, stop a
// restored backup from rolling a record back, or destroy key copies; E03 owns that proof.
const KEY_STEPS = { active: ['retired', 'expired'], retired: ['expired'], expired: [] };
export const keyTransitionAllowed = (from, to) => Object.hasOwn(KEY_STEPS, from) && KEY_STEPS[from].includes(to);
// The use a record allows now. An invalid record or clock, or a time outside [createdAt, expiresAt), is none.
export function keyUse(record, now = new Date()) {
if (validateRecord('content-key', record).length) return 'none';
const at = instant(now);
if (!(at >= Date.parse(record.createdAt) && at < Date.parse(record.expiresAt))) return 'none';
return record.state === 'active' ? 'encrypt-decrypt' : record.state === 'retired' ? 'decrypt' : 'none';
}
-337
View File
@@ -1,337 +0,0 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { chmodSync, mkdirSync, mkdtempSync, readFileSync, rmSync, symlinkSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import {
checkBinding, checkKeyLocation, checkProtectedFile, checkSlotLocation, defaults, effectiveMailboxes, keyPath,
keyTransitionAllowed, keyUse, schemaBounds, slotDir, validateRecord,
} from '../src/validate.mjs';
// Synthetic records only. No credential, mailbox or provider is real, and nothing here opens a connection.
const HUMAN = { kind: 'human', id: 'jason' };
const AT = '2026-10-22T16:00:00.000Z';
const source = (over = {}) => ({
recordVersion: 1, kind: 'mail-source-settings', business: 'acme', source: 'ingress', provider: 'imap',
...defaults('source'), revision: 1, changedBy: HUMAN, changedAt: AT, ...over,
});
const data = (over = {}) => ({
recordVersion: 1, kind: 'mail-data-settings', business: 'acme', ...defaults('data'),
revision: 1, changedBy: HUMAN, changedAt: AT, ...over,
});
const access = (over = {}) => ({
recordVersion: 1, kind: 'mail-access', business: 'acme', source: 'ingress',
principal: { kind: 'agent', id: 'mail-specialist' }, rights: ['metadata', 'body'],
mailboxes: ['[email protected]'], mode: 'manual', approvalId: 'decision-1',
issuedAt: AT, expiresAt: '2026-10-29T16:00:00.000Z', revision: 1, ...over,
});
const CONFIG = '/home/op/.config/mosaic-acme';
const ROOTS = { configDir: CONFIG, dataRoot: '/home/op/.mosaic-acme', repoRoot: '/src/stack', backupRoots: ['/backup/mosaic'] };
const service = (over = {}) => ({
recordVersion: 1, kind: 'mail-service', business: 'acme', source: 'ingress', provider: 'imap',
mailbox: '[email protected]', scopes: ['mail.read'],
slotPath: `${CONFIG}/secrets/mosaic-stack/mail/acme/ingress/imap`, rotateBy: '2027-01-01T00:00:00.000Z',
approvalId: 'decision-2', revision: 1, ...over,
});
const contentKey = (over = {}) => ({
recordVersion: 1, kind: 'mail-content-key', business: 'acme', keyId: 'k1',
path: `${CONFIG}/secrets/mosaic-stack/mail-content/acme/k1`, state: 'active',
createdAt: AT, expiresAt: '2027-10-22T16:00:00.000Z', revision: 1, ...over,
});
const refused = (kind, value, text) => {
const problems = validateRecord(kind, value);
assert.ok(problems.some(problem => problem.includes(text)), `${kind} should be refused with "${text}": ${JSON.stringify(problems)}`);
};
test('T1 the defaults are off, empty and valid, and every record kind validates', () => {
const settings = defaults('source');
assert.equal(settings.sourceEnabled, false);
assert.deepEqual(settings.mailboxScope, []);
assert.deepEqual(settings.sourceMutations, []);
assert.equal(settings.sourceAdapter, null);
assert.equal(settings.accessMode, 'manual');
assert.equal(settings.unattendedWake, false);
assert.deepEqual(settings.wakeSenderAllowlist, []);
assert.deepEqual(settings.limits, {
pollSeconds: 60, sliceObservations: 50, sliceBytes: 10485760, pendingDeliveries: 1000, retainedBytes: 104857600,
parserInputBytes: 31457280, mimeDepth: 10, mimeParts: 100, normalizedTextBytes: 262144,
attachmentPartBytes: 10485760, attachmentMessageBytes: 10485760, staleScanIntervals: 3, pageSize: 20,
});
assert.deepEqual(defaults('data'), { persistContent: false, bodyDraftDays: 30, attachmentDays: 7, metadataDays: 365, automaticPrune: false });
for (const [kind, value] of [['source', source()], ['data', data()], ['access', access()], ['service', service()], ['content-key', contentKey()]])
assert.deepEqual(validateRecord(kind, value), [], kind);
assert.throws(() => defaults('access'), /no settings defaults/);
assert.deepEqual(validateRecord('mailbox', {}), ['unknown record kind: mailbox']);
});
test('T2 the default scope reads nothing, and a read is the intersection of scope, provider and grant', () => {
const grant = access({ mailboxes: ['[email protected]'] });
const providerMailboxes = ['[email protected]', '[email protected]'];
assert.deepEqual(effectiveMailboxes({ source: source(), providerMailboxes, access: grant }), []);
const enabled = source({ sourceEnabled: true, sourceAdapter: 'imap' });
assert.deepEqual(effectiveMailboxes({ source: enabled, providerMailboxes, access: grant }), [], 'enabled with the default empty scope');
const scoped = source({ sourceEnabled: true, sourceAdapter: 'imap', mailboxScope: ['[email protected]', '[email protected]'] });
assert.deepEqual(effectiveMailboxes({ source: scoped, providerMailboxes, access: grant, now: new Date(AT) }), ['[email protected]']);
assert.deepEqual(effectiveMailboxes({ source: scoped, providerMailboxes, access: null }), [], 'no grant');
assert.deepEqual(effectiveMailboxes({ source: scoped, providerMailboxes: [], access: grant, now: new Date(AT) }), [], 'provider denies');
assert.deepEqual(effectiveMailboxes({ source: scoped, providerMailboxes, access: grant, now: new Date('2026-10-29T16:00:00.000Z') }), [], 'expired grant');
assert.deepEqual(effectiveMailboxes({ source: scoped, providerMailboxes, access: access({ source: 'other' }), now: new Date(AT) }), [], 'grant for another source');
assert.deepEqual(effectiveMailboxes({ source: { ...scoped, sourceEnabled: false }, providerMailboxes, access: grant, now: new Date(AT) }), [], 'disabled');
// Lifetime edges: the grant reads from issuedAt up to, not including, expiresAt.
const read = (over = {}) => effectiveMailboxes({ source: scoped, providerMailboxes, access: grant, now: new Date(AT), ...over });
assert.deepEqual(read({ now: new Date('2026-10-22T15:59:59.999Z') }), [], 'before issuedAt');
assert.deepEqual(read({ now: new Date('2026-10-29T15:59:59.999Z') }), ['[email protected]'], 'the last instant before expiresAt');
// Malformed records or clock fail closed.
assert.deepEqual(read({ now: new Date(Number.NaN) }), [], 'invalid clock');
assert.deepEqual(read({ now: AT }), [], 'a clock that is not a Date');
assert.deepEqual(read({ access: access({ expiresAt: 'not-a-date' }) }), [], 'invalid expiresAt');
assert.deepEqual(read({ access: access({ issuedAt: 'not-a-date' }) }), [], 'invalid issuedAt');
assert.deepEqual(read({ access: access({ issuedAt: '2026-10-23T16:00:00.000Z' }) }), [], 'issued in the future');
const { mailboxes, ...noMailboxes } = grant;
assert.deepEqual(read({ access: noMailboxes }), [], 'grant without mailboxes');
assert.deepEqual(read({ source: { ...scoped, mailboxScope: ['*'] } }), [], 'invalid source');
assert.deepEqual(read({ providerMailboxes: '[email protected]' }), [], 'provider list that is not an array');
});
test('T3 only a human changes settings', () => {
for (const kind of ['source', 'data']) {
const make = kind === 'source' ? source : data;
refused(kind, make({ changedBy: { kind: 'agent', id: 'mail-specialist' } }), 'changedBy.kind: must equal "human"');
refused(kind, make({ changedBy: { kind: 'system', id: 'mail' } }), 'changedBy.kind: must equal "human"');
const { changedBy, ...unsigned } = make();
refused(kind, unsigned, 'missing changedBy');
}
});
test('T4 unknown fields are refused at every level', () => {
refused('source', source({ secretRef: 'x' }), 'unknown key secretRef');
refused('source', source({ limits: { ...defaults('source').limits, unlimited: 1 } }), 'unknown key unlimited');
refused('data', data({ purgeNow: true }), 'unknown key purgeNow');
refused('access', access({ principal: { kind: 'agent', id: 'a', asHuman: true } }), 'unknown key asHuman');
refused('service', service({ password: 'x' }), 'unknown key password');
refused('content-key', contentKey({ material: 'x' }), 'unknown key material');
});
test('T5 wildcards are refused in scopes, grants, allowlists and principals', () => {
refused('source', source({ mailboxScope: ['*'] }), 'wildcard not allowed');
refused('source', source({ mailboxScope: ['%'] }), 'wildcard not allowed');
refused('source', source({ wakeSenderAllowlist: ['*@example.test'] }), 'wildcard not allowed');
refused('access', access({ mailboxes: ['[email protected]'] }), 'wildcard not allowed');
refused('access', access({ principal: { kind: 'agent', id: '*' } }), 'wildcard not allowed');
refused('service', service({ mailbox: '*' }), 'wildcard not allowed');
});
test('T6 secret values are refused wherever they appear', () => {
const sentinels = [
'-----BEGIN OPENSSH PRIVATE KEY-----', 'eyJhbGciOiJub25lIn0.eyJzdWIiOiJzZW50aW5lbCJ9.x',
'ghp_SENTINELSENTINEL', 'imaps://user:[email protected]', 'password=SENTINEL', 'client_secret: SENTINEL',
];
for (const sentinel of sentinels) {
refused('service', service({ slotPath: `${CONFIG}/secrets/mosaic-stack/mail/acme/ingress/${sentinel}` }), 'looks like a secret value');
refused('service', service({ approvalId: sentinel }), 'looks like a secret value');
refused('access', access({ approvalId: sentinel }), 'looks like a secret value');
}
const problems = validateRecord('service', service({ approvalId: 'password=SENTINEL' }));
assert.ok(!problems.join('\n').includes('SENTINEL'), 'a refusal never echoes the value');
});
test('T7 incomplete scopes are refused', () => {
refused('access', access({ rights: [] }), 'access.rights: too few items');
refused('access', access({ mailboxes: [] }), 'access.mailboxes: too few items');
refused('access', access({ rights: ['metadata', 'send'] }), 'must be one of');
refused('access', access({ rights: ['body', 'body'] }), 'duplicate items');
refused('access', access({ expiresAt: AT }), 'must be after issuedAt');
refused('service', service({ scopes: [] }), 'service.scopes: too few items');
refused('service', service({ scopes: ['mail.send'] }), 'must be one of');
refused('source', source({ sourceEnabled: true }), 'an enabled source needs an enrolled adapter');
refused('source', source({ unattendedWake: true }), 'unattended wake needs at least one exact sender');
refused('source', source({ sourceMutations: ['mark-read'] }), 'too many items');
refused('source', source({ mailboxScope: ['[email protected]', '[email protected]'] }), 'duplicate items');
assert.deepEqual(validateRecord('source', source({ unattendedWake: true, wakeSenderAllowlist: ['[email protected]'] })), []);
});
test('T8 mismatched principal and source are refused', () => {
const scoped = source({ mailboxScope: ['[email protected]'] });
assert.deepEqual(checkBinding({ source: scoped, service: service(), access: access() }), []);
assert.deepEqual(checkBinding({ source: scoped, service: service({ source: 'other' }) }), ['service: source mismatch']);
assert.deepEqual(checkBinding({ source: scoped, service: service({ business: 'beta' }) }), ['service: business mismatch']);
assert.deepEqual(checkBinding({ source: scoped, service: service({ provider: 'm365' }) }), ['service: provider mismatch']);
assert.deepEqual(checkBinding({ source: scoped, access: access({ business: 'beta' }) }), ['access: business mismatch']);
assert.deepEqual(checkBinding({ source: scoped, access: access({ mailboxes: ['[email protected]'] }) }),
['access: mailbox [email protected] outside the source scope']);
assert.deepEqual(checkBinding({ source: source(), access: access() }), ['access: mailbox [email protected] outside the source scope'],
'the default empty scope admits no grant');
refused('access', access({ principal: { kind: 'service', id: 'mail' } }), 'must be one of agent, user');
assert.deepEqual(checkSlotLocation(service({ slotPath: `${CONFIG}/secrets/mosaic-stack/mail/acme/other/imap` }), ROOTS),
[`service.slotPath: must sit under ${CONFIG}/secrets/mosaic-stack/mail/acme/ingress`]);
});
test('T9 settings take changed values inside the schema bounds and refuse values outside them', () => {
for (const key of ['pollSeconds', 'parserInputBytes', 'mimeDepth', 'mimeParts', 'normalizedTextBytes', 'pageSize']) {
const { minimum, maximum } = schemaBounds('source', `limits.${key}`);
const at = value => source({ limits: { ...defaults('source').limits, [key]: value } });
assert.deepEqual(validateRecord('source', at(minimum)), [], `${key} at its minimum`);
assert.deepEqual(validateRecord('source', at(maximum)), [], `${key} at its maximum`);
refused('source', at(minimum - 1), 'below the schema bound');
refused('source', at(maximum + 1), 'above the schema bound');
refused('source', at(minimum + 0.5), 'must be integer');
}
assert.equal(schemaBounds('source', 'limits.pageSize').maximum, 100);
refused('source', source({ limits: { ...defaults('source').limits, attachmentPartBytes: 20971520 } }), 'attachmentPartBytes exceeds attachmentMessageBytes');
assert.deepEqual(validateRecord('data', data({ bodyDraftDays: 90, metadataDays: 400 })), []);
refused('data', data({ metadataDays: 10 }), 'metadata and tombstones must outlive content');
refused('data', data({ persistContent: true }), 'persistContent: must equal false');
refused('data', data({ automaticPrune: true }), 'automaticPrune: must equal false');
});
test('T10 slot and key locations stay outside dataRoot, the repository and backups', () => {
assert.equal(slotDir({ configDir: CONFIG, business: 'acme', source: 'ingress' }), `${CONFIG}/secrets/mosaic-stack/mail/acme/ingress`);
assert.deepEqual(checkSlotLocation(service(), ROOTS), []);
assert.deepEqual(checkKeyLocation(contentKey(), ROOTS), []);
const nested = { ...ROOTS, dataRoot: CONFIG };
assert.ok(checkSlotLocation(service(), nested).some(problem => problem.includes('inside dataRoot')));
assert.ok(checkKeyLocation(contentKey(), nested).some(problem => problem.includes('inside dataRoot')));
assert.ok(checkKeyLocation(contentKey(), { ...ROOTS, backupRoots: ['/home/op/.config'] }).some(problem => problem.includes('inside a backup root')));
assert.ok(checkKeyLocation(contentKey(), { ...ROOTS, repoRoot: CONFIG }).some(problem => problem.includes('inside the repository')));
assert.deepEqual(checkKeyLocation(contentKey({ path: '/home/op/.mosaic-acme/mail/acme/k1' }), ROOTS),
[`content-key.path: must sit under ${CONFIG}/secrets/mosaic-stack/mail-content/acme`, 'content-key.path: inside dataRoot; mail secrets live outside it']);
assert.deepEqual(checkKeyLocation(contentKey({ path: `${CONFIG}/secrets/mosaic-stack/mail-content/acme/k2` }), ROOTS),
[`content-key.path: must be ${keyPath({ configDir: CONFIG, business: 'acme', keyId: 'k1' })}`]);
assert.ok(checkKeyLocation(contentKey({ path: `${CONFIG}/secrets/mosaic-stack/mail/acme/k1` }), ROOTS)[0].includes('must sit under'), 'apart from provider slots');
assert.deepEqual(checkKeyLocation(contentKey({ path: `${CONFIG}/secrets/mosaic-stack/mail-content/acme/../acme/k1` }), ROOTS),
['content-key.path: must be a normalized absolute path']);
});
test('T10b a symbolic link or alias can\'t move a slot or key into a forbidden root', () => {
const root = mkdtempSync(join(tmpdir(), 'mail-place-'));
try {
const dataRoot = join(root, 'data');
const roots = { dataRoot, repoRoot: join(root, 'repo'), backupRoots: [join(root, 'backup')] };
// configDir is a symbolic link into dataRoot, with a real 0600 key behind it.
mkdirSync(join(dataRoot, 'cfg'), { recursive: true, mode: 0o700 });
const configDir = join(root, 'config');
symlinkSync(join(dataRoot, 'cfg'), configDir);
const dir = join(configDir, 'secrets', 'mosaic-stack', 'mail-content', 'acme');
mkdirSync(dir, { recursive: true, mode: 0o700 });
const file = join(dir, 'k1');
writeFileSync(file, 'synthetic', { mode: 0o600 });
const key = contentKey({ path: file });
assert.deepEqual(checkKeyLocation(key, { ...roots, configDir }), ['content-key.path: resolves inside dataRoot; mail secrets live outside it']);
// A planned slot that doesn't exist yet resolves through its nearest existing ancestor.
const slot = service({ slotPath: join(configDir, 'secrets', 'mosaic-stack', 'mail', 'acme', 'ingress', 'imap') });
assert.deepEqual(checkSlotLocation(slot, { ...roots, configDir }), ['service.slotPath: resolves inside dataRoot; mail secrets live outside it']);
// A forbidden root named through an alias still covers the real configDir.
const realConfig = join(dataRoot, 'cfg');
const alias = join(root, 'alias');
symlinkSync(dataRoot, alias);
const realKey = contentKey({ path: join(realConfig, 'secrets', 'mosaic-stack', 'mail-content', 'acme', 'k1') });
assert.deepEqual(checkKeyLocation(realKey, { ...roots, dataRoot: alias, configDir: realConfig }),
['content-key.path: resolves inside dataRoot; mail secrets live outside it']);
// The same layout outside every forbidden root passes.
const safeConfig = join(root, 'safe');
mkdirSync(safeConfig, { mode: 0o700 });
const safeKey = contentKey({ path: join(safeConfig, 'secrets', 'mosaic-stack', 'mail-content', 'acme', 'k1') });
assert.deepEqual(checkKeyLocation(safeKey, { ...roots, configDir: safeConfig }), []);
} finally {
rmSync(root, { recursive: true, force: true });
}
});
test('T11 a protected file must be a regular owned 600 file with no symbolic link on its path', () => {
const root = mkdtempSync(join(tmpdir(), 'mail-schema-'));
try {
const configDir = join(root, 'config');
const dir = join(configDir, 'secrets', 'mosaic-stack', 'mail-content', 'acme');
mkdirSync(dir, { recursive: true, mode: 0o700 });
const file = join(dir, 'k1');
writeFileSync(file, 'synthetic', { mode: 0o600 });
assert.deepEqual(checkProtectedFile(file, { configDir }), []);
chmodSync(file, 0o640);
assert.ok(checkProtectedFile(file, { configDir }).some(problem => problem.includes('mode 640')));
chmodSync(file, 0o400);
assert.ok(checkProtectedFile(file, { configDir }).some(problem => problem.includes('mode 400')));
chmodSync(file, 0o600);
assert.ok(checkProtectedFile(file, { configDir, uid: process.getuid() + 1 }).some(problem => problem.includes('owned by uid')));
const link = join(dir, 'k2');
symlinkSync(file, link);
assert.deepEqual(checkProtectedFile(link, { configDir }), [`${link}: must be a regular file, not a symbolic link`]);
const realDir = join(root, 'elsewhere');
mkdirSync(realDir, { mode: 0o700 });
writeFileSync(join(realDir, 'k3'), 'synthetic', { mode: 0o600 });
symlinkSync(realDir, join(configDir, 'secrets', 'mosaic-stack', 'mail-content', 'beta'));
const viaLink = join(configDir, 'secrets', 'mosaic-stack', 'mail-content', 'beta', 'k3');
assert.deepEqual(checkProtectedFile(viaLink, { configDir }),
[`${viaLink}: a symbolic link sits on the path at ${join(configDir, 'secrets', 'mosaic-stack', 'mail-content', 'beta')}`]);
assert.deepEqual(checkProtectedFile(join(dir, 'missing'), { configDir }), [`${join(dir, 'missing')}: not found`]);
mkdirSync(join(dir, 'k4'));
assert.deepEqual(checkProtectedFile(join(dir, 'k4'), { configDir }), [`${join(dir, 'k4')}: must be a regular file, not a symbolic link`]);
// The file must sit under configDir, lexically.
const outside = join(realDir, 'k3');
assert.deepEqual(checkProtectedFile(outside, { configDir }), [`${outside}: must sit under ${configDir}`]);
const dotted = `${configDir}/../elsewhere/k3`;
assert.deepEqual(checkProtectedFile(dotted, { configDir }), [`${dotted}: must be a normalized absolute path`]);
// A symbolic-link configDir is refused, even with a real 0600 file behind it.
const linkedConfig = join(root, 'linked-config');
symlinkSync(configDir, linkedConfig);
const viaConfig = join(linkedConfig, 'secrets', 'mosaic-stack', 'mail-content', 'acme', 'k1');
assert.deepEqual(checkProtectedFile(viaConfig, { configDir: linkedConfig }), [`${viaConfig}: a symbolic link sits on the path at ${linkedConfig}`]);
// A forbidden root named through an alias still covers the file.
const alias = join(root, 'alias');
symlinkSync(root, alias);
assert.deepEqual(checkProtectedFile(file, { configDir, dataRoot: alias }), [`${file}: resolves inside dataRoot; mail secrets live outside it`]);
assert.deepEqual(checkProtectedFile(file, { configDir, dataRoot: join(root, 'data') }), []);
} finally {
rmSync(root, { recursive: true, force: true });
}
});
test('T12 the key lifecycle moves forward only, and an expired key gives no use', () => {
assert.ok(keyTransitionAllowed('active', 'retired'));
assert.ok(keyTransitionAllowed('active', 'expired'));
assert.ok(keyTransitionAllowed('retired', 'expired'));
for (const [from, to] of [['retired', 'active'], ['expired', 'active'], ['expired', 'retired'], ['active', 'active'], ['lost', 'active'], ['constructor', 'active']])
assert.equal(keyTransitionAllowed(from, to), false, `${from} -> ${to}`);
const now = new Date('2027-01-01T00:00:00.000Z');
assert.equal(keyUse(contentKey(), now), 'encrypt-decrypt');
assert.equal(keyUse(contentKey({ state: 'retired' }), now), 'decrypt');
assert.equal(keyUse(contentKey({ state: 'expired' }), now), 'none');
assert.equal(keyUse(contentKey(), new Date('2027-10-22T16:00:00.000Z')), 'none', 'past expiresAt, whatever the state says');
assert.equal(keyUse(contentKey({ state: 'retired' }), new Date('2027-10-22T15:59:59.999Z')), 'decrypt', 'the last instant before expiresAt');
assert.equal(keyUse(contentKey(), new Date(AT)), 'encrypt-decrypt', 'from createdAt');
assert.equal(keyUse(contentKey(), new Date('2026-10-22T15:59:59.999Z')), 'none', 'before createdAt');
// Malformed records or clock fail closed.
assert.equal(keyUse(contentKey({ state: 'escrowed' }), now), 'none', 'unknown state');
assert.equal(keyUse(contentKey({ state: 'constructor' }), now), 'none', 'prototype key as state');
assert.equal(keyUse(contentKey({ createdAt: 'not-a-date' }), now), 'none', 'invalid createdAt');
assert.equal(keyUse(contentKey({ expiresAt: 'not-a-date' }), now), 'none', 'invalid expiresAt');
assert.equal(keyUse(contentKey(), new Date(Number.NaN)), 'none', 'invalid clock');
assert.equal(keyUse(contentKey(), '2027-01-01T00:00:00.000Z'), 'none', 'a clock that is not a Date');
refused('content-key', contentKey({ state: 'escrowed' }), 'must be one of');
refused('content-key', contentKey({ expiresAt: AT }), 'must be after createdAt');
});
test('T13 the disabled schema leaves v1 unchanged: no mail kind, no mail action', () => {
const kinds = readFileSync(new URL('../../plugins/src/kinds.mjs', import.meta.url), 'utf8');
const vocabulary = readFileSync(new URL('../../business/src/vocabulary.mjs', import.meta.url), 'utf8');
assert.ok(!/mail/i.test(kinds), 'core mail-source registration waits for the approved PRD version and the source, grant and route approvals');
assert.ok(!/mail\.access/.test(vocabulary), 'mail.access waits for a Jason-approved PRD version');
const prd = readFileSync(new URL('../../../docs/prd/mosaic-stack.md', import.meta.url), 'utf8');
assert.ok(!/REQ-MAIL-/.test(prd), 'the addendum is a draft until Jason approves it');
});
test('T14 every addendum requirement maps to build rows, and every row E01-E16 is covered', () => {
const draft = readFileSync(new URL('../../../docs/prd/mail-addendum-draft.md', import.meta.url), 'utf8');
assert.match(draft, /^- Status: draft, not approved\./m);
const blocks = draft.split(/^- \*\*(?=REQ-MAIL-)/m).slice(1);
const ids = blocks.map(block => block.match(/^(REQ-MAIL-\d+)\.\*\*/)[1]);
assert.deepEqual(ids, Array.from({ length: ids.length }, (_, index) => `REQ-MAIL-${index + 1}`));
assert.equal((draft.match(/\*\*REQ-MAIL-/g) ?? []).length, ids.length);
const covered = new Set();
for (const [index, block] of blocks.entries()) {
const rows = block.match(/^ {2}Rows: (E\d\d(?:, E\d\d)*)\.$/m);
assert.ok(rows, `${ids[index]} names its rows`);
for (const row of rows[1].split(', ')) covered.add(row);
}
const all = Array.from({ length: 16 }, (_, index) => `E${String(index + 1).padStart(2, '0')}`);
assert.deepEqual([...covered].sort(), all);
});