Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3094e3bfa4 | ||
|
|
a8f0cf9248 |
No files matched your search
@@ -1,120 +0,0 @@
|
||||
# Row 239 (#1745) build notes (append only)
|
||||
|
||||
## 2026-10-22 b1 (Honker)
|
||||
|
||||
Design d1 5da5f28f6 was approved by Duckworth with C1 and C2 and no blockers (duckworth-0165, note
|
||||
agents/duckworth/work/row239/REVIEW-design-d1.md). The code is on honker/row239 from aca16404c. No test
|
||||
has run yet: every run waits for Sage's CLEAR.
|
||||
|
||||
### Duckworth's conditions and answers
|
||||
|
||||
- C1 (option a): `outcome-unknown-revoked` is in the Console's `OPEN` list (webui bus.mjs), so
|
||||
`sendOutcome` answers it as `outcome-unknown` with `sent: 'unknown'` and never "Nothing changed". T6
|
||||
covers it and M3f is its mutant.
|
||||
- C2: `write()` sets `revoked = true` after `beforeWrite()` returns, and a catch around the whole
|
||||
`withLock(...)` call marks the error. So a throw from the lock's release in its finally is marked as
|
||||
well. This is structural and has no test, as Duckworth said.
|
||||
- Q1: `beforeWrite` runs after both validations. Q2: two codes. Q3: M2 removes the `:188` check (now
|
||||
organization.mjs:195).
|
||||
|
||||
### Notes N1-N5
|
||||
|
||||
- N1: `beforeWrite` must not write the same business, because it would find its own lock and refuse
|
||||
`organization-busy`. This is said in the comments on `write()` and `commit()`. Today's revokes (the
|
||||
bus's credential revoke) write no business file.
|
||||
- N2: the lock is held across the revoke. A fire or a delete now keeps other writers of that business
|
||||
out for as long as the revoke takes. They refuse `organization-busy`, as they do for any held lock.
|
||||
- N3: a revoke that fails partway (some tokens revoked, then `credential-revoke-failed`) behaves as
|
||||
today. The error carries no `revoked` mark, because `beforeWrite` didn't return, and nothing is
|
||||
written. This row doesn't change that.
|
||||
- N4: a delete closed with `outcome-unknown-revoked` exits 3 in the CLI, as every closed refusal does.
|
||||
Exit 1 (transport EXIT) applies only to a direct verb answer.
|
||||
- N5: I grepped for "before the write" and similar comments. The ones left (lifecycle-revoke.test:2,
|
||||
instance-settings:56, cli instances.mjs:53, serve.mjs:365) are still true and are unchanged. T21 in
|
||||
bus lifecycle-revoke.test:173 is unchanged.
|
||||
|
||||
### Differences from the design
|
||||
|
||||
- The business tests are in lifecycle-settings.test.mjs, not instance-settings.test.mjs. The fire tests
|
||||
and their fixture already live there. The fixture now returns `dir` and `rolesDir` too.
|
||||
- T3 covers T3b (the delete) in the same test, looping over fire and delete. It also checks that a
|
||||
revoke throwing `credential-revoke-failed` carries no mark and writes nothing.
|
||||
- T4b is the plain-code check. The design's "T4b, outcome-unknown-revoked for an fs-style error" is in
|
||||
T4, which checks EIO with and without the mark for both a fire and a delete.
|
||||
- The bus test's fake org gets `deleteError`, thrown from its `instances.set` branch, so T4 can drive a
|
||||
delete.
|
||||
- transport.test gets one assertion, `busExit("outcome-unknown-revoked") === 1`, for M3g.
|
||||
- A latent Console text: a direct (not closed-request) `revision-conflict-revoked` answer in the
|
||||
Console would still get " Nothing changed." appended, because only `OPEN` codes skip it. That sentence
|
||||
is true there: the file is unchanged. No direct human path gives this code today, since a delete
|
||||
reaches the Console as a closed request's refusal.
|
||||
- The Console's STATUS entry for `outcome-unknown-revoked` (503) can't be reached while the code is in
|
||||
`OPEN`. It stays for symmetry with the CLI and is harmless.
|
||||
|
||||
### What changed
|
||||
|
||||
- packages/business/src/organization.mjs: `write()` takes `beforeWrite`, runs it under the lock after
|
||||
the revision check and both validations, and marks any later error `revoked`. The body is reindented
|
||||
inside the try.
|
||||
- packages/business/src/instance-settings.mjs: `instanceSettings` takes an optional `writer`. `commit()`
|
||||
passes `beforeWrite` to `writer.write` instead of calling it. The early check stays, and its comment
|
||||
is rewritten.
|
||||
- packages/bus/src/broker.mjs: `#organizationFail` maps a marked error to `revision-conflict-revoked` or
|
||||
`outcome-unknown-revoked` before the existing mappings. `#fireJob`'s notice adds "Its credential
|
||||
tokens may already be revoked." for a `-revoked` code.
|
||||
- packages/bus/src/runtime.mjs: comment only.
|
||||
- packages/cli/src/instances.mjs: two REASONS entries. packages/cli/src/transport.mjs: EXIT
|
||||
`outcome-unknown-revoked` 1.
|
||||
- packages/webui/src/serve.mjs: two AGENT_REASONS entries. packages/webui/src/bus.mjs: `OPEN` entry and
|
||||
two STATUS entries.
|
||||
- Tests:
|
||||
- business lifecycle-settings.test: "row 239 T1", "row 239 T2", "row 239 T3".
|
||||
- bus fire-revokes-grants.test: "row 239 T4", "row 239 T4b".
|
||||
- cli instances.test: "row 239 T5 CLI". cli transport.test: one assertion in "busExit and
|
||||
refuseInsideAgent".
|
||||
- webui agents.test: "row 239 T6".
|
||||
- bus channel-proof.test re-pins packages/cli/src/transport.mjs (662642a8...) and
|
||||
packages/webui/src/bus.mjs (1eba9774...), with a comment on each. Neither changes a proof or the
|
||||
transport. The design's file list missed this. The pin grep found it before the first run.
|
||||
### Mutants for the gate (exact-once replacements)
|
||||
|
||||
- M1 (instance-settings.mjs, beforeWrite before the lock): replace
|
||||
` const receipt = writer.write(business, next, { revision, actor, context, beforeWrite });` with
|
||||
` if (beforeWrite) beforeWrite();\n const receipt = writer.write(business, next, { revision, actor, context });`.
|
||||
The kill is lifecycle-settings "row 239 T1" (the revoke spy has a call).
|
||||
- M2 (organization.mjs, the fire refusal dropped): delete the line
|
||||
` if (revision !== before) fail("revision-conflict", "organization revision changed; read again");`.
|
||||
The kill is "row 239 T1" (the write lands over the hand edit).
|
||||
- M3a (organization.mjs, the mark dropped): ` if (revoked && e instanceof Error) e.revoked = true;`
|
||||
becomes ` void revoked;`. The kills are "row 239 T2" and "row 239 T3" (business). T4 drives the mark
|
||||
through a fake org, so it doesn't see M3a.
|
||||
- M3b (broker.mjs): delete the line
|
||||
` if (e.revoked) fail(e.code === "revision-conflict" ? "revision-conflict-revoked" : "outcome-unknown-revoked");`.
|
||||
The kill is fire-revokes-grants "row 239 T4".
|
||||
- M3c (cli instances.mjs): delete the five lines from ` "revision-conflict-revoked": "the business file`
|
||||
through ` "business file again with \`mosaic business instances\` before trying again",`. The kill is
|
||||
instances.test "row 239 T5 CLI".
|
||||
- M3d (webui serve.mjs): delete the two lines starting ` 'revision-conflict-revoked': 'the business file`
|
||||
and ` 'outcome-unknown-revoked': 'the agent\'s tokens`. The kill is agents.test "row 239 T6".
|
||||
- M3e (broker.mjs): ` const tokens = code.endsWith('-revoked') ? ' Its credential tokens may already be revoked.' : '';`
|
||||
becomes ` const tokens = '';`. The kill is "row 239 T4".
|
||||
- M3f (webui bus.mjs, C1): the `OPEN` line loses `, 'outcome-unknown-revoked'`. The kill is "row 239 T6"
|
||||
(the launch POST answers 503 outcome-unknown, sent unknown).
|
||||
- M3g (cli transport.mjs): delete the line ` "outcome-unknown-revoked": 1,`. The kill is transport.test
|
||||
"busExit and refuseInsideAgent".
|
||||
|
||||
A kill counts only as a failed assertion in the named test, not a load error or a different test.
|
||||
|
||||
### Gate plan (mos-1275 timeouts)
|
||||
|
||||
- My row 256 gate (2026-10-22 10:34Z, same machine) took 590s for the cli package, 57s for webui and
|
||||
38s for test-foundation, the longest shell script.
|
||||
- Packages: `timeout -k 10 1800` per package (3x the cli run).
|
||||
- Each scripts/test-*.sh: `timeout -k 10 300` (more than 3x the 38s longest).
|
||||
- Single files and mutants: `timeout -k 10 600` per file run (cli instances.test whole is the longest
|
||||
single file).
|
||||
- Order on Sage's CLEAR: the touched test files whole (business lifecycle-settings, bus
|
||||
fire-revokes-grants and channel-proof, cli instances and transport, webui agents), then M1-M3g, then
|
||||
the full gate.
|
||||
- Before the in-review move: grep packages/*/tests for the changed comment and error text and for
|
||||
64-hex pins on the changed files, and drift-check BASE..refactor against them.
|
||||
@@ -1,159 +0,0 @@
|
||||
# Row 239 design: the revoke under the writer's lock, and a fire test
|
||||
|
||||
Brief: docs/plans/2026-10-20_agent-lifecycle.md, "Brief: row 194 follow-up, the revoke under the writer's
|
||||
lock and a fire test" (blob 3b3b3b2b9), from Duckworth's row 194 b3 N1 and mos-1158. Base aca16404c (row
|
||||
194 landed as fd6dd327c, row 281 as e479521d3). Builder Honker, reviewer Duckworth, gate owner Sage.
|
||||
Draft 1, 2026-10-22.
|
||||
|
||||
## Today
|
||||
|
||||
- `instance-settings.mjs` `commit()` runs the checks, then compares the caller's revision with a fresh
|
||||
`writer.read` (row 194 b3, sage-1634), then calls `beforeWrite` (the fire's or the delete's credential
|
||||
revoke), then `writer.write`.
|
||||
- `writer.write` (`organization.mjs`) takes the lock, runs `recover`, checks the revision again (`:188`),
|
||||
validates, prepares, checks the bytes once more before publication (`:221`), renames and commits.
|
||||
- So two windows are left.
|
||||
- A write that lands between `commit()`'s read and the writer's lock still gets a revoke and then a
|
||||
`revision-conflict`.
|
||||
- Any refusal after the revoke ran (the `:221` check, or an I/O failure the bus reports as
|
||||
`outcome-unknown`) uses the same text as one before it, so nothing says the tokens may be gone.
|
||||
- Row 281 left this path alone. Its fire job (`broker.mjs` `#fireJob`) runs step G (grants), then step B,
|
||||
which is this same `write('instance.fire')`, and reports a step B failure as `fire.failed` along with a
|
||||
human notice that names the code. Its delete job only does step G. The delete itself still commits
|
||||
through `lifecycleDelete` → `instances.set` op delete → `commit()`.
|
||||
|
||||
## What changes
|
||||
|
||||
### 1. `beforeWrite` runs inside `writer.write`, under the lock (organization.mjs)
|
||||
|
||||
`write(id, document, { revision, actor, detail, context, beforeWrite = null })`. The order under the lock:
|
||||
|
||||
1. `recover`, read, then the `:188` revision check. A refusal here revokes nothing.
|
||||
2. Projects-protected and both validations. A refusal here revokes nothing either. I put `beforeWrite`
|
||||
after these rather than straight after `:188`, so a document the writer would refuse anyway never
|
||||
costs a revoke. Q1 asks Duckworth to confirm this.
|
||||
3. `beforeWrite()`. If it throws (today `credential-revoke-failed`), the error propagates as it is and
|
||||
nothing has been written, as now.
|
||||
4. Prepare, the `:221` check, rename and commit, wrapped so that any error thrown after step 3 gets
|
||||
`revoked = true` set on it before it is rethrown. That covers `revision-conflict` from `:221`, an
|
||||
`organization-refused` from `log()`, a barrier throw, and an fs error.
|
||||
|
||||
`record()` and the other writers (`launch-settings`, `plugin-settings`, `credentialVerbs`) pass no
|
||||
`beforeWrite` and behave as before.
|
||||
|
||||
### 2. commit() passes beforeWrite through (instance-settings.mjs)
|
||||
|
||||
`commit()` passes `beforeWrite` to `writer.write` and no longer calls it itself. The row 194 b3 early check
|
||||
stays. It costs one read, keeps T21's refusal before any validation work, and is no longer what protects
|
||||
the revoke. Its comment is rewritten to say so, and the "(BUILD.md follow-up)" sentence goes.
|
||||
|
||||
`instanceSettings({ ..., writer = organizationWriter({ dir, rolesDir }) })` takes an optional writer, so a
|
||||
test can inject one (the brief's "injected writer"). `runtime.mjs` passes none.
|
||||
|
||||
### 3. The bus names a refusal after a revoke (broker.mjs `#organizationFail`)
|
||||
|
||||
The bus answers with a code only, and the CLI and Console look the text up by code. So the "may already be
|
||||
revoked" signal has to be a code. Two new codes:
|
||||
|
||||
- `revision-conflict-revoked`: `e.revoked && e.code === 'revision-conflict'`.
|
||||
- `outcome-unknown-revoked`: `e.revoked` with any other code. The write may or may not have landed, since a
|
||||
failure after the rename is recovered as committed.
|
||||
|
||||
Both are checked before the existing mappings. An error without `revoked` maps exactly as today, so a
|
||||
refusal before any revoke keeps today's code and text. Q2 asks about the names.
|
||||
|
||||
### 4. The texts
|
||||
|
||||
- CLI `instances.mjs` REASONS:
|
||||
- `revision-conflict-revoked`: "the business file changed before the write, after the agent's tokens
|
||||
were revoked; the tokens may already be revoked and the instance can't use them; read the instances
|
||||
again and run the same command again, which is safe".
|
||||
- `outcome-unknown-revoked`: "the write's outcome is unknown and the agent's tokens may already be
|
||||
revoked; read the instances before trying again".
|
||||
- Console `serve.mjs` AGENT_REASONS: the same two, in the Console's wording ("delete again").
|
||||
- `#fireJob`'s `fire.failed` notice: when the code ends in `-revoked`, it adds "Its credential tokens may
|
||||
already be revoked." The chief's synchronous fire (no grants) answers with the code, as every chief verb
|
||||
does.
|
||||
- `transport.mjs` EXIT: `outcome-unknown-revoked` exits 1, as `outcome-unknown` does. Console
|
||||
`bus.mjs` STATUS: `revision-conflict-revoked` 409, `outcome-unknown-revoked` 503. A delete's code
|
||||
reaches both as a closed request's `refusal`, so these two only matter for a direct verb answer.
|
||||
|
||||
## Tests
|
||||
|
||||
- **T1, the brief's fire test** (business `instance-settings.test.mjs`). The fire uses an injected writer
|
||||
that wraps the real one. Its `write` makes one hand edit of the business file (a vars change on another
|
||||
instance) and then calls the real `write`, so the edit lands after `commit()`'s early check and before
|
||||
the lock's `:188` check. Expected:
|
||||
- the fire refuses `revision-conflict` without `revoked`;
|
||||
- the revoke spy has no calls;
|
||||
- the business file's bytes are the hand edit's;
|
||||
- changes.jsonl has no new prepared entry.
|
||||
- **T2** (same file). A fire on a real writer built with `barrier: stage => stage === 'prepared' && edit()`.
|
||||
The revoke runs, then `:221` refuses. Expected: `revision-conflict` with `revoked === true`, one revoke
|
||||
call, the file's bytes are the edit's, and the last receipt is `aborted`.
|
||||
- **T3** (same file). The same, with a `prepared` barrier that throws. Expected: the error has
|
||||
`revoked === true`, one revoke call, and the file unchanged. A delete through the same writer gives the
|
||||
same result (T3b).
|
||||
- **T4** (bus `fire-revokes-grants.test.mjs`, its injected `f.org`). `f.org.fireError` is a
|
||||
`revision-conflict` with `revoked: true`. Expected:
|
||||
- the job's `fire.failed` carries `revision-conflict-revoked`, and the inbox message says "may already
|
||||
be revoked";
|
||||
- the same with no `grants` (the synchronous fire) refuses `revision-conflict-revoked`;
|
||||
- a plain `revision-conflict` still maps to `revision-conflict` and the message lacks the sentence.
|
||||
- T4b: `outcome-unknown-revoked` for an fs-style error with `revoked`.
|
||||
- **T5** (cli `lifecycle.test.mjs` or `instances.test.mjs`, whichever already reads a closed delete's
|
||||
refusal). A delete closed with `revision-conflict-revoked` prints the new text, and one closed with
|
||||
`revision-conflict` prints today's.
|
||||
- **T6** (webui `agents.test.mjs`). The same pair for the Console's reason.
|
||||
- T21 (bus `lifecycle-revoke.test.mjs`) stays as it is, for delete.
|
||||
|
||||
## Mutants
|
||||
|
||||
| Mutant | Change | Killed by |
|
||||
| --- | --- | --- |
|
||||
| M1 | `beforeWrite` before the lock: `commit()` calls `beforeWrite()` itself and passes none to `write` | T1 (the revoke spy has a call) |
|
||||
| M2 | The fire refusal dropped: the `:188` revision check removed | T1 (the write lands over the hand edit) |
|
||||
| M3a | The mark dropped: no `revoked = true` in the catch | T2, T3, and T4 through the codes |
|
||||
| M3b | `#organizationFail` ignores `revoked` | T4 |
|
||||
| M3c | CLI text: the two REASONS entries removed | T5 |
|
||||
| M3d | Console text: the two AGENT_REASONS entries removed | T6 |
|
||||
| M3e | The notice: the `fire.failed` sentence removed | T4 |
|
||||
|
||||
M3a-M3e together are the brief's "drop the may already be revoked text".
|
||||
|
||||
## Files
|
||||
|
||||
- `packages/business/src/organization.mjs`
|
||||
- `packages/business/src/instance-settings.mjs`
|
||||
- `packages/business/tests/instance-settings.test.mjs`
|
||||
- `packages/bus/src/broker.mjs`
|
||||
- `packages/bus/tests/fire-revokes-grants.test.mjs`
|
||||
- `packages/cli/src/instances.mjs`
|
||||
- `packages/cli/src/transport.mjs`
|
||||
- one cli test file
|
||||
- `packages/webui/src/serve.mjs`
|
||||
- `packages/webui/src/bus.mjs`
|
||||
- `packages/webui/tests/agents.test.mjs`
|
||||
- This packet.
|
||||
|
||||
## Gate
|
||||
|
||||
As the brief says:
|
||||
|
||||
- Duckworth approves the manifest.
|
||||
- The bus, business, cli and webui suites and every `scripts/test-*.sh` are green on Sage's slot, with
|
||||
the touched test files run whole.
|
||||
- M1-M3e are killed.
|
||||
|
||||
Before the in-review move I grep the tests for the changed comment and error text, and for hash pins on the
|
||||
changed files.
|
||||
|
||||
## Questions for Duckworth
|
||||
|
||||
- **Q1.** Should `beforeWrite` run after the writer's validations (my pick) or straight after `:188` (the
|
||||
brief's literal place)? Running it later means a document the writer would refuse revokes nothing.
|
||||
- **Q2.** Are two new codes, `revision-conflict-revoked` and `outcome-unknown-revoked`, the right way to
|
||||
carry the signal? The alternative is to give `BusError` a detail field, which every CLI and Console
|
||||
reader would then have to learn. The codes are additive and no existing code changes meaning.
|
||||
- **Q3.** The brief's mutant "drop the fire refusal": I read it as removing the `:188` check (M2). If you
|
||||
meant the fire test's own refusal, say so and I'll redefine it.
|
||||
@@ -0,0 +1,135 @@
|
||||
# Row 258, E01: mail PRD addendum draft and schemas
|
||||
|
||||
Ludwig, 2026-10-22. Issue #1763. Assigned by sage-2802 under Q220. Reviewer: Della.
|
||||
Base: aca16404c. Branch: ludwig/row258.
|
||||
|
||||
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)
|
||||
|
||||
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).
|
||||
@@ -0,0 +1,164 @@
|
||||
# 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.
|
||||
@@ -1947,13 +1947,11 @@ export class Broker {
|
||||
} catch (e) {
|
||||
const code = e instanceof BusError ? e.code : 'outcome-unknown';
|
||||
const list = out.grants.map((g) => g.grant).join(', ') || 'none';
|
||||
// Row 239 (#1745): a step B refusal after the writer's revoke ran.
|
||||
const tokens = code.endsWith('-revoked') ? ' Its credential tokens may already be revoked.' : '';
|
||||
this.#store.transaction(() => {
|
||||
this.#event(sc, 'action.allowed', { action: 'fire.failed', job, step, code }, name);
|
||||
this.#humanNotice(
|
||||
sc.business,
|
||||
`Fire of ${name}: job ${job} revoked its grants (${list}) but stopped at step ${step} (${code}).${tokens} The instance is still firing. Run the fire again once ${code} is cleared.`,
|
||||
`Fire of ${name}: job ${job} revoked its grants (${list}) but stopped at step ${step} (${code}). The instance is still firing. Run the fire again once ${code} is cleared.`,
|
||||
);
|
||||
});
|
||||
}
|
||||
@@ -4407,9 +4405,6 @@ export class Broker {
|
||||
// The bus code for an organization writer's refusal. Anything the writer didn't refuse by name is
|
||||
// outcome-unknown: the write may have committed.
|
||||
#organizationFail(e, verb) {
|
||||
// Row 239 (#1745): the writer marks an error raised after a fire's or a delete's revoke ran, so
|
||||
// the human reads that the tokens may already be revoked. Without the mark, today's code.
|
||||
if (e.revoked) fail(e.code === "revision-conflict" ? "revision-conflict-revoked" : "outcome-unknown-revoked");
|
||||
if (e.code === "revision-conflict") fail("revision-conflict");
|
||||
if (e.code?.startsWith("organization-")) fail(e.code);
|
||||
if (e.code === "invalid-plugins" || e.code === "unknown-plugin") fail(e.code);
|
||||
|
||||
@@ -148,11 +148,11 @@ export async function startBroker({
|
||||
const levels=organization?await harnessLevels():()=>null;
|
||||
const settings=organization?launchSettings({...organization,sessions:()=>readSessions(dataRoot),levels}):null;
|
||||
// Row 194 (#1692, brief item 8, row 199 D13): a fire or a delete revokes the agent's credentials
|
||||
// after the writer's checks and before its write, under the writer's lock (row 239). Fire revokes
|
||||
// each service of the entry and keeps the store directory, delete calls remove (sage-1593). A
|
||||
// service the running bus doesn't know (an agent created since the start) has nothing in memory,
|
||||
// so it goes on, and then no token or tmp file may remain in the agent's store directory
|
||||
// (sage-1592). Any failure stops the verb with the file unchanged.
|
||||
// after the writer's checks and before its write. Fire revokes each service of the entry and keeps
|
||||
// the store directory, delete calls remove (sage-1593). A service the running bus doesn't know (an
|
||||
// agent created since the start) has nothing in memory, so it goes on, and then no token or tmp
|
||||
// file may remain in the agent's store directory (sage-1592). Any failure stops the verb with the
|
||||
// file unchanged.
|
||||
const revokeFailed = (message) => new OrganizationError('credential-revoke-failed', message);
|
||||
function revoke(business, agent, how, services) {
|
||||
const instance = business + '/' + agent;
|
||||
|
||||
@@ -17,8 +17,7 @@ import { Client } from "../src/client.mjs";
|
||||
const baselines = {
|
||||
"packages/bus/src/human.mjs": "0c6d96abe4607cba0b02b0638a2fc217ff8135bac9a47761a0ef015fbe9d3079",
|
||||
"packages/bus/src/human-cli.mjs": "271b9781d73cf31cc562a56180e3ebcd725b5fde148a83347a201fdef2fc28af",
|
||||
// Row 239 (#1745): EXIT outcome-unknown-revoked 1; no proof or transport change.
|
||||
"packages/cli/src/transport.mjs": "662642a8ce739fcb6c188ad07125a94e008c8a305eced127c8041d472c56e55b",
|
||||
"packages/cli/src/transport.mjs": "5129c44b4d9f642e825344df65c9bfb4bac4fc8d6b25343b42d39853dbd40774",
|
||||
// Row 165 (#1662): seven STATUS entries for the role instance routes (organization-instance-exists,
|
||||
// organization-unknown-instance, organization-credentials-out-of-scope, organization-launch-not-configured,
|
||||
// organization-no-change, organization-project-mismatch, organization-unresolvable-instance), no proof or
|
||||
@@ -26,9 +25,7 @@ const baselines = {
|
||||
// Row 171 (#1668): project-name-is-repo maps to 409; no proof or transport change.
|
||||
// Row 194 (#1692, brief item 7, sage-1610 a): organization-tracker-required 400 and
|
||||
// organization-tracker-bot-taken 409; no proof or transport change.
|
||||
// Row 239 (#1745): outcome-unknown-revoked joins OPEN (duckworth-0165 C1), and STATUS gets
|
||||
// revision-conflict-revoked 409 and outcome-unknown-revoked 503; no proof or transport change.
|
||||
"packages/webui/src/bus.mjs": "1eba977443b3e308dbc5b08567b11e1059bb238a5f57939c6917dd775156e5cf",
|
||||
"packages/webui/src/bus.mjs": "15e8123ffdbb6d067e8a516f5823efdb5de3c869963764f27cd14bb9e4ce3313",
|
||||
};
|
||||
const repoFile = path => new URL("../../../" + path, import.meta.url);
|
||||
|
||||
|
||||
@@ -133,9 +133,8 @@ async function setup(t) {
|
||||
},
|
||||
};
|
||||
// The fake organization writer. A fire delists the name and a delete drops the role, each with a
|
||||
// reload, as the runtime reloads after a commit. `fireError` makes the fire's write refuse, and
|
||||
// `deleteError` the delete's (row 239).
|
||||
const org = { log: [], fireError: null, deleteError: null };
|
||||
// reload, as the runtime reloads after a commit. `fireError` makes the fire's write refuse.
|
||||
const org = { log: [], fireError: null };
|
||||
const sw = { state: { version: 1, halted: false, generation: GENERATION } };
|
||||
let broker;
|
||||
const organization = (business, op, args) => {
|
||||
@@ -147,7 +146,6 @@ async function setup(t) {
|
||||
return { revision: REV, transaction: 't-' + org.log.length };
|
||||
}
|
||||
if (op === 'instances.set') {
|
||||
if (org.deleteError) throw org.deleteError;
|
||||
delete doc.roles[args.instance];
|
||||
doc.launch.instances = doc.launch.instances.filter((n) => n !== args.instance);
|
||||
broker.reloadBusiness('demo', doc);
|
||||
@@ -954,44 +952,3 @@ test("row 281 T14 (N1, sage-2653): a host removed under an undecided decision st
|
||||
assert.ok(!f.doc.launch.instances.includes('coder'));
|
||||
assert.equal(f.grantEvents().filter((e) => e.action === 'grant.revoke.refused').length, 1);
|
||||
});
|
||||
|
||||
// Row 239 (#1745): the writer marks an error raised after its own revoke ran (revoked: true), and the
|
||||
// bus names it, so the human reads that the tokens may already be revoked. Unmarked, today's code.
|
||||
test("row 239 T4: a refusal after the writer's revoke is named -revoked, in the job's fire.failed and notice, a synchronous fire and a delete", async (t) => {
|
||||
const f = await setup(t);
|
||||
await f.approveHost('coder', 'web1');
|
||||
f.org.fireError = Object.assign(new Error('refused'), { code: 'revision-conflict', revoked: true });
|
||||
const out = f.fire('coder');
|
||||
await f.broker.grantsIdle();
|
||||
const [failed] = f.allowed('fire.failed');
|
||||
assert.deepEqual([failed.body.job, failed.body.step, failed.body.code], [out.job, 'B', 'revision-conflict-revoked']);
|
||||
const [message] = f.inbox();
|
||||
assert.ok(message.includes('(revision-conflict-revoked). Its credential tokens may already be revoked. The instance is still firing.'), message);
|
||||
// helper has no grant, so its fire writes synchronously and answers with the code.
|
||||
await rejects(() => f.fire('helper'), 'revision-conflict-revoked');
|
||||
// Any other marked error: the write may have landed.
|
||||
f.org.fireError = Object.assign(new Error('EIO: i/o error'), { code: 'EIO', revoked: true });
|
||||
await rejects(() => f.fire('helper'), 'outcome-unknown-revoked');
|
||||
f.org.fireError = Object.assign(new Error('refused'), { code: 'revision-conflict' });
|
||||
await rejects(() => f.fire('helper'), 'revision-conflict');
|
||||
f.org.fireError = null;
|
||||
f.org.deleteError = Object.assign(new Error('refused'), { code: 'revision-conflict', revoked: true });
|
||||
assert.equal(f.remove('helper').code, 'revision-conflict-revoked');
|
||||
f.org.deleteError = Object.assign(new Error('EIO: i/o error'), { code: 'EIO', revoked: true });
|
||||
assert.equal(f.remove('helper').code, 'outcome-unknown-revoked');
|
||||
f.org.deleteError = Object.assign(new Error('refused'), { code: 'revision-conflict' });
|
||||
assert.equal(f.remove('helper').code, 'revision-conflict');
|
||||
});
|
||||
|
||||
test("row 239 T4b: an unmarked step B refusal keeps its code, and the notice says nothing of the tokens", async (t) => {
|
||||
const f = await setup(t);
|
||||
await f.approveHost('coder', 'web1');
|
||||
f.org.fireError = Object.assign(new Error('refused'), { code: 'revision-conflict' });
|
||||
const out = f.fire('coder');
|
||||
await f.broker.grantsIdle();
|
||||
const [failed] = f.allowed('fire.failed');
|
||||
assert.deepEqual([failed.body.job, failed.body.code], [out.job, 'revision-conflict']);
|
||||
const [message] = f.inbox();
|
||||
assert.ok(message.includes('(revision-conflict). The instance is still firing.'), message);
|
||||
assert.ok(!message.includes('may already be revoked'), message);
|
||||
});
|
||||
@@ -144,8 +144,9 @@ function createdCredentials(old, instance, definition, args) {
|
||||
// checkRoutes, row 65). Row 194 (brief item 8, row 199 D13): revoke(business, instance, how,
|
||||
// services) revokes the instance's credentials for a fire or a delete and throws
|
||||
// credential-revoke-failed when it can't. Null in a writer with no bus (the CLI's), which revokes
|
||||
// nothing. Row 239 (#1745): writer is the organization writer, injected only by tests.
|
||||
export function instanceSettings({ dir, rolesDir, system, levels = () => null, revoke = null, writer = organizationWriter({ dir, rolesDir }) }) {
|
||||
// nothing.
|
||||
export function instanceSettings({ dir, rolesDir, system, levels = () => null, revoke = null }) {
|
||||
const writer = organizationWriter({ dir, rolesDir });
|
||||
|
||||
// The definitions in rolesDir and whether add and set accept each one.
|
||||
function definitions() {
|
||||
@@ -378,9 +379,8 @@ export function instanceSettings({ dir, rolesDir, system, levels = () => null, r
|
||||
}
|
||||
}
|
||||
|
||||
// The checks every write runs, then the write, which runs beforeWrite (a fire's or a delete's
|
||||
// revoke) under its lock (row 239). A check that refuses revokes nothing, and a revoke that throws
|
||||
// writes nothing. beforeWrite must not write this business: the writer holds its lock.
|
||||
// The checks every write runs, then beforeWrite (a fire's or a delete's revoke), then the write. A
|
||||
// check that refuses revokes nothing, and a revoke that throws writes nothing.
|
||||
function commit(business, old, next, { revision, actor, context, beforeWrite = null }) {
|
||||
if (canonicalJson(next) === canonicalJson(old)) throw new OrganizationError("organization-no-change");
|
||||
|
||||
@@ -410,11 +410,12 @@ export function instanceSettings({ dir, rolesDir, system, levels = () => null, r
|
||||
}
|
||||
}
|
||||
// Row 194 N1 (sage-1634): a delete writes on the revision its request read when it was raised,
|
||||
// so a write since then makes it stale. This early check refuses it without taking the lock. The
|
||||
// writer checks again under its lock, before the revoke (row 239), and that check is what keeps
|
||||
// a stale fire or delete from revoking.
|
||||
// so a write since then makes it stale. The revision is checked here, before the revoke, because
|
||||
// the writer's own check comes after it. A write between this read and the writer's lock still
|
||||
// revokes and then refuses (BUILD.md follow-up).
|
||||
if (writer.read(business).revision !== revision) throw new OrganizationError("revision-conflict", "organization revision changed; read again");
|
||||
const receipt = writer.write(business, next, { revision, actor, context, beforeWrite });
|
||||
if (beforeWrite) beforeWrite();
|
||||
const receipt = writer.write(business, next, { revision, actor, context });
|
||||
return { ...read(business, actor), transaction: receipt.transaction, restart: false };
|
||||
}
|
||||
|
||||
|
||||
@@ -177,70 +177,55 @@ export function organizationWriter({ dir, rolesDir, barrier = () => {} }) {
|
||||
// Row 199 (#1697, design section 6): `detail` says what a credential verb did (verb, agent, kind,
|
||||
// date, reason), never a value; the other setters pass none. context, when given, is logged with
|
||||
// the change: row 194's hire and fire carry the chief's run and the decision there (D2.2).
|
||||
// Row 239 (#1745): beforeWrite (a fire's or a delete's credential revoke) runs under the lock,
|
||||
// after the revision check and the validations, so a refusal there revokes nothing. An error
|
||||
// after it returns carries revoked: true, the lock's release included (duckworth-0165 C2), and the
|
||||
// bus names it so the human reads that the tokens may already be revoked. beforeWrite must not
|
||||
// write this business itself: it would find its own lock and refuse organization-busy.
|
||||
function write(id, document, { revision, actor, detail, context, beforeWrite = null }) {
|
||||
function write(id, document, { revision, actor, detail, context }) {
|
||||
requireId(actor, "authenticated actor");
|
||||
requireId(id, "organization id");
|
||||
let revoked = false;
|
||||
try {
|
||||
return withLock(id, ({ home, parent }) => {
|
||||
recover(id);
|
||||
const file = businessFilePath(id, dir);
|
||||
const oldBytes = regular(file);
|
||||
const before = sha256(oldBytes);
|
||||
if (revision !== before) fail("revision-conflict", "organization revision changed; read again");
|
||||
const old = JSON.parse(oldBytes);
|
||||
// C2-04 M9: every organization setter preserves the whole project
|
||||
// mapping, including library playgrounds and hybrid universes.
|
||||
if (canonicalJson(document?.projects) !== canonicalJson(old.projects))
|
||||
fail('organization-projects-protected');
|
||||
validateBusinessDocument(document, file, { rolesDir });
|
||||
const bytes = JSON.stringify(document, null, 2) + "\n";
|
||||
// Check and log exactly what lands on disk, for every setter: a value JSON
|
||||
// can't carry (a Date, a Map) serializes as something else (row 131, sage-0314).
|
||||
const written = JSON.parse(bytes);
|
||||
if (canonicalJson(written.projects) !== canonicalJson(old.projects))
|
||||
fail('organization-projects-protected');
|
||||
validateBusinessDocument(written, file, { rolesDir });
|
||||
if (beforeWrite) {
|
||||
beforeWrite();
|
||||
revoked = true;
|
||||
}
|
||||
const after = sha256(bytes);
|
||||
const transaction = randomUUID();
|
||||
const tx = join(home, transaction);
|
||||
directory(tx);
|
||||
const previous = join(tx, "previous.json");
|
||||
durable(previous, oldBytes);
|
||||
const candidate = join(parent, "." + id + "." + transaction + ".tmp");
|
||||
durable(candidate, bytes);
|
||||
syncDir(tx);
|
||||
const event = {
|
||||
transaction, actor, ...(context === undefined ? {} : { context }), business: id, before, after,
|
||||
changes: changes(old, written), ...(detail === undefined ? {} : { detail }), at: new Date().toISOString(),
|
||||
};
|
||||
log(id, { ...event, state: "prepared" });
|
||||
syncDir(home);
|
||||
barrier("prepared");
|
||||
if (sha256(regular(file)) !== before) {
|
||||
log(id, { ...event, state: "aborted" });
|
||||
rmSync(candidate);
|
||||
fail("revision-conflict", "organization revision changed before publication");
|
||||
}
|
||||
renameSync(candidate, file);
|
||||
syncDir(parent);
|
||||
barrier("renamed");
|
||||
log(id, { ...event, state: "committed" });
|
||||
return { revision: after, transaction, previous };
|
||||
});
|
||||
} catch (e) {
|
||||
if (revoked && e instanceof Error) e.revoked = true;
|
||||
throw e;
|
||||
}
|
||||
return withLock(id, ({ home, parent }) => {
|
||||
recover(id);
|
||||
const file = businessFilePath(id, dir);
|
||||
const oldBytes = regular(file);
|
||||
const before = sha256(oldBytes);
|
||||
if (revision !== before) fail("revision-conflict", "organization revision changed; read again");
|
||||
const old = JSON.parse(oldBytes);
|
||||
// C2-04 M9: every organization setter preserves the whole project
|
||||
// mapping, including library playgrounds and hybrid universes.
|
||||
if (canonicalJson(document?.projects) !== canonicalJson(old.projects))
|
||||
fail('organization-projects-protected');
|
||||
validateBusinessDocument(document, file, { rolesDir });
|
||||
const bytes = JSON.stringify(document, null, 2) + "\n";
|
||||
// Check and log exactly what lands on disk, for every setter: a value JSON
|
||||
// can't carry (a Date, a Map) serializes as something else (row 131, sage-0314).
|
||||
const written = JSON.parse(bytes);
|
||||
if (canonicalJson(written.projects) !== canonicalJson(old.projects))
|
||||
fail('organization-projects-protected');
|
||||
validateBusinessDocument(written, file, { rolesDir });
|
||||
const after = sha256(bytes);
|
||||
const transaction = randomUUID();
|
||||
const tx = join(home, transaction);
|
||||
directory(tx);
|
||||
const previous = join(tx, "previous.json");
|
||||
durable(previous, oldBytes);
|
||||
const candidate = join(parent, "." + id + "." + transaction + ".tmp");
|
||||
durable(candidate, bytes);
|
||||
syncDir(tx);
|
||||
const event = {
|
||||
transaction, actor, ...(context === undefined ? {} : { context }), business: id, before, after,
|
||||
changes: changes(old, written), ...(detail === undefined ? {} : { detail }), at: new Date().toISOString(),
|
||||
};
|
||||
log(id, { ...event, state: "prepared" });
|
||||
syncDir(home);
|
||||
barrier("prepared");
|
||||
if (sha256(regular(file)) !== before) {
|
||||
log(id, { ...event, state: "aborted" });
|
||||
rmSync(candidate);
|
||||
fail("revision-conflict", "organization revision changed before publication");
|
||||
}
|
||||
renameSync(candidate, file);
|
||||
syncDir(parent);
|
||||
barrier("renamed");
|
||||
log(id, { ...event, state: "committed" });
|
||||
return { revision: after, transaction, previous };
|
||||
});
|
||||
}
|
||||
|
||||
// Row 199: a receipt with no document change, for a credential verb that changes only the agents'
|
||||
|
||||
@@ -4,10 +4,9 @@
|
||||
import test, { after } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { spawnSync } from "node:child_process";
|
||||
import { existsSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { readFileSync, rmSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { instanceSettings } from "../src/instance-settings.mjs";
|
||||
import { OrganizationError, organizationWriter } from "../src/organization.mjs";
|
||||
import { CREATIVE_DEFINITIONS } from "../src/vocabulary.mjs";
|
||||
import { businessDoc, REPO, rolesCopy, systemConfig, systemFor, tmp, writeJson } from "./helpers.mjs";
|
||||
|
||||
@@ -37,7 +36,7 @@ function fixture(mutate) {
|
||||
const log = () => readFileSync(settings.writer.logFile("acme"), "utf8").trim().split("\n").map(line => JSON.parse(line));
|
||||
const onDisk = () => JSON.parse(readFileSync(file, "utf8"));
|
||||
const revision = () => settings.read("acme").revision;
|
||||
return { root, dir, rolesDir, file, settings, validate, log, onDisk, revision };
|
||||
return { root, file, settings, validate, log, onDisk, revision };
|
||||
}
|
||||
|
||||
// Each refusal: its code (or the validator's BusinessError), and the file's bytes unchanged.
|
||||
@@ -245,73 +244,3 @@ test("row 256 checkDelete passes the human's creative delete and refuses another
|
||||
assert.throws(() => f.settings.checkFire("acme", "bean"), e => e.code === "organization-definition-refused");
|
||||
assert.equal(readFileSync(f.file, "utf8"), bytes);
|
||||
});
|
||||
|
||||
// Row 239 (#1745): a fire's or a delete's revoke runs inside the writer, under its lock, after the
|
||||
// revision check. revoking(f, writer, fail) builds settings on f's files with a spy revoke and that writer.
|
||||
function revoking(f, writer, fail = null) {
|
||||
const calls = [];
|
||||
const revoke = (business, agent, how, services) => {
|
||||
calls.push([business, agent, how, services]);
|
||||
if (fail) throw fail;
|
||||
};
|
||||
const settings = instanceSettings({ dir: f.dir, rolesDir: f.rolesDir, system: systemFor(f.root), revoke, ...(writer ? { writer } : {}) });
|
||||
return { settings, calls };
|
||||
}
|
||||
const receipts = f => (existsSync(f.settings.writer.logFile("acme")) ? f.log() : []);
|
||||
// A hand edit: the same document in other bytes, so the revision changes and the file still validates.
|
||||
const handEdit = f => {
|
||||
const bytes = JSON.stringify(f.onDisk()) + "\n";
|
||||
writeFileSync(f.file, bytes);
|
||||
return bytes;
|
||||
};
|
||||
|
||||
test("row 239 T1 a fire on a revision that goes stale before the writer's lock refuses there and revokes nothing", () => {
|
||||
const f = fixture();
|
||||
const real = organizationWriter({ dir: f.dir, rolesDir: f.rolesDir });
|
||||
let edited = null;
|
||||
// The edit lands after commit()'s early check and before the writer takes its lock.
|
||||
const writer = { ...real, write: (id, document, options) => { edited = handEdit(f); return real.write(id, document, options); } };
|
||||
const { settings, calls } = revoking(f, writer);
|
||||
const before = receipts(f).length;
|
||||
assert.throws(() => settings.fire("acme", { name: "sec-1" }, { actor: "chief", context: CONTEXT }),
|
||||
e => e.code === "revision-conflict" && e.revoked === undefined);
|
||||
assert.deepEqual(calls, []);
|
||||
assert.equal(readFileSync(f.file, "utf8"), edited);
|
||||
assert.equal(receipts(f).length, before);
|
||||
});
|
||||
|
||||
test("row 239 T2 a write after the revoke refused before publication carries revoked, and the file keeps the edit", () => {
|
||||
const f = fixture();
|
||||
let edited = null;
|
||||
const writer = organizationWriter({ dir: f.dir, rolesDir: f.rolesDir, barrier: stage => { if (stage === "prepared") edited = handEdit(f); } });
|
||||
const { settings, calls } = revoking(f, writer);
|
||||
assert.throws(() => settings.fire("acme", { name: "sec-1" }, { actor: "chief", context: CONTEXT }),
|
||||
e => e.code === "revision-conflict" && e.revoked === true);
|
||||
assert.deepEqual(calls, [["acme", "sec-1", "fire", []]]);
|
||||
assert.equal(readFileSync(f.file, "utf8"), edited);
|
||||
assert.equal(receipts(f).at(-1).state, "aborted");
|
||||
});
|
||||
|
||||
test("row 239 T3 any error after the revoke carries revoked, for a fire and a delete; a revoke that throws carries nothing", () => {
|
||||
for (const [how, act] of [
|
||||
["fire", settings => settings.fire("acme", { name: "sec-1" }, { actor: "chief", context: CONTEXT })],
|
||||
["delete", (settings, f) => settings.set("acme", { revision: f.revision(), instance: "sec-1", op: "delete" }, "jason")],
|
||||
]) {
|
||||
const f = fixture();
|
||||
const bytes = readFileSync(f.file, "utf8");
|
||||
const writer = organizationWriter({ dir: f.dir, rolesDir: f.rolesDir, barrier: stage => { if (stage === "prepared") throw new Error("stopped at prepared"); } });
|
||||
const { settings, calls } = revoking(f, writer);
|
||||
assert.throws(() => act(settings, f), e => e.message === "stopped at prepared" && e.revoked === true, how);
|
||||
assert.equal(calls.length, 1, how);
|
||||
assert.equal(calls[0][2], how);
|
||||
assert.equal(readFileSync(f.file, "utf8"), bytes, how);
|
||||
|
||||
const g = fixture();
|
||||
const gBytes = readFileSync(g.file, "utf8");
|
||||
const failing = revoking(g, null, new OrganizationError("credential-revoke-failed"));
|
||||
assert.throws(() => act(failing.settings, g), e => e.code === "credential-revoke-failed" && e.revoked === undefined, how);
|
||||
assert.equal(failing.calls.length, 1, how);
|
||||
assert.equal(readFileSync(g.file, "utf8"), gBytes, how);
|
||||
assert.deepEqual(receipts(g), [], how);
|
||||
}
|
||||
});
|
||||
@@ -55,12 +55,6 @@ export const REASONS = {
|
||||
"credential-revoke-failed": "a credential couldn't be revoked, so the business file is unchanged; tokens already revoked " +
|
||||
"stay revoked and the instance can't use them; clear what else the agent's store directory holds and run the same " +
|
||||
"command again, which is safe",
|
||||
// Row 239 (#1745): a fire's or a delete's revoke runs under the writer's lock; these follow one that ran.
|
||||
"revision-conflict-revoked": "the business file changed after the agent's tokens were revoked and before the write, so " +
|
||||
"the file is unchanged; the tokens may already be revoked and the instance can't use them; read it again with " +
|
||||
"`mosaic business instances` and run the same command again, which is safe",
|
||||
"outcome-unknown-revoked": "the agent's tokens may already be revoked and the write's outcome is unknown; read the " +
|
||||
"business file again with `mosaic business instances` before trying again",
|
||||
"capacity-full": "a launch limit is reached; `mosaic limits --business BUSINESS` shows them",
|
||||
"limit-reached": "a launch limit is reached; `mosaic limits --business BUSINESS` shows them",
|
||||
"unknown-role": "the business has no such instance",
|
||||
|
||||
@@ -42,7 +42,6 @@ const EXIT = {
|
||||
"tester-off": 3,
|
||||
"tester-refused": 3,
|
||||
"outcome-unknown": 1,
|
||||
"outcome-unknown-revoked": 1,
|
||||
"response-too-large": 1,
|
||||
"invalid-response": 1,
|
||||
};
|
||||
|
||||
@@ -316,28 +316,6 @@ test("row 194 CLI V5: delete polls to the transaction and the read lists the nam
|
||||
assert.match(failed.err, new RegExp(`^mosaic: workspace-rename-failed: .*run the same delete again to retry the rename \\(request [^,]+, transaction ${s.log().at(-1).transaction}\\)$`, "m"));
|
||||
});
|
||||
|
||||
// Row 239 (#1745): a fire's or a delete's revoke runs under the writer's lock. A delete closed after
|
||||
// that revoke ran says the tokens may already be revoked; a plain revision-conflict keeps its text.
|
||||
test("row 239 T5 CLI: a delete closed -revoked says the tokens may already be revoked; a plain revision-conflict doesn't", async (t) => {
|
||||
let refusal = "revision-conflict-revoked";
|
||||
const s = await setup(t, { hook: closing(() => ({ refusal })) });
|
||||
const added = await s.mosaic(["instance", "add", "acme", "sec-1", "--definition", "security-reviewer", "--revision", await s.revision()]);
|
||||
assert.equal(added.code, 0, added.err);
|
||||
const del = async () => s.mosaic(["instance", "delete", "acme", "sec-1", "--revision", await s.revision()]);
|
||||
let r = await del();
|
||||
assert.equal(r.code, 3, r.err);
|
||||
assert.match(r.err, /^mosaic: revision-conflict-revoked: the business file changed after the agent's tokens were revoked .*; the tokens may already be revoked .*which is safe \(request [^)]+\)$/m);
|
||||
refusal = "outcome-unknown-revoked";
|
||||
r = await del();
|
||||
assert.equal(r.code, 3, r.err);
|
||||
assert.match(r.err, /^mosaic: outcome-unknown-revoked: the agent's tokens may already be revoked and the write's outcome is unknown; .* \(request [^)]+\)$/m);
|
||||
refusal = "revision-conflict";
|
||||
r = await del();
|
||||
assert.equal(r.code, 3, r.err);
|
||||
assert.match(r.err, /^mosaic: revision-conflict: the business file changed since that revision; .* \(request [^)]+\)$/m);
|
||||
assert.doesNotMatch(r.err, /may already be revoked/);
|
||||
});
|
||||
|
||||
test("row 194 CLI: lifecycle and veto usage errors exit 4 before any bus call", async (t) => {
|
||||
const s = await setup(t);
|
||||
const rev = "a".repeat(64);
|
||||
|
||||
@@ -45,8 +45,6 @@ test("a bus code on stderr becomes the exit code; garbage and timeouts are outco
|
||||
test("busExit and refuseInsideAgent", () => {
|
||||
assert.equal(busExit("unauthenticated"), 3);
|
||||
assert.equal(busExit("invalid-request"), 2);
|
||||
// Row 239 (#1745): a write whose outcome is unknown after its revoke exits as outcome-unknown does.
|
||||
assert.equal(busExit("outcome-unknown-revoked"), 1);
|
||||
assert.doesNotThrow(() => refuseInsideAgent(humanEnv()));
|
||||
assert.throws(() => refuseInsideAgent({ PI_AGENT_DIR: "/x" }), (e) => e.exitCode === 3 && /PI_AGENT_DIR/.test(e.message));
|
||||
});
|
||||
@@ -0,0 +1,59 @@
|
||||
# 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.
|
||||
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"name": "@mosaic/mail",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"license": "UNLICENSED",
|
||||
"exports": "./src/validate.mjs",
|
||||
"scripts": {
|
||||
"test": "node --test tests/*.test.mjs"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
{
|
||||
"$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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
{
|
||||
"$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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
{
|
||||
"$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"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
{
|
||||
"$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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,222 @@
|
||||
{
|
||||
"$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"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,237 @@
|
||||
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';
|
||||
}
|
||||
@@ -0,0 +1,337 @@
|
||||
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);
|
||||
});
|
||||
@@ -75,8 +75,7 @@ export const busReader = options => views({ call: busCaller(options) });
|
||||
// A send's error, sorted. The broker writes in one transaction, so any code it
|
||||
// returns means nothing was written. A transport that lost the answer, or an
|
||||
// answer that can't be read, leaves the outcome open: the message may exist.
|
||||
// Row 239 (duckworth-0165 C1): so does a write that failed after its revoke.
|
||||
const OPEN = ['outcome-unknown', 'invalid-response', 'response-too-large', 'outcome-unknown-revoked'];
|
||||
const OPEN = ['outcome-unknown', 'invalid-response', 'response-too-large'];
|
||||
export function sendOutcome(err) {
|
||||
const code = err instanceof BusReadError ? err.code : 'outcome-unknown';
|
||||
return OPEN.includes(code) ? { code: 'outcome-unknown', sent: 'unknown' } : { code, sent: false };
|
||||
@@ -88,8 +87,6 @@ const STATUS = {
|
||||
'human-required': 403, 'peercred-required': 403, unauthenticated: 403, 'unknown-business': 403, 'read-only': 403,
|
||||
'outcome-unknown': 503, 'no-bus-host': 503, 'not-configured': 503,
|
||||
'revision-conflict':409,'organization-busy':409,'organization-uncertain':503,
|
||||
// Row 239 (#1745): a fire's or a delete's write refused after its revoke ran.
|
||||
'revision-conflict-revoked': 409, 'outcome-unknown-revoked': 503,
|
||||
'invalid-request': 400, 'unknown-role': 400, 'message-not-found': 400, 'request-too-large': 400,
|
||||
// Row 148: the spec and docs editor. A stale base, a busy or dirty repository and an unchanged
|
||||
// file are the user's to resolve by reloading; a path or content the project refuses is a 400.
|
||||
|
||||
@@ -364,9 +364,6 @@ const AGENT_REASONS = {
|
||||
'workspace-rename-failed': 'the business file is changed and the name retired, but the workspace stayed where it was; delete it again to retry the rename',
|
||||
// Brief item 8, deviation 11 (sage-1620): the revoke runs before the write, so a retry is safe.
|
||||
'credential-revoke-failed': 'a credential couldn\'t be revoked, so the business file is unchanged. Tokens already revoked stay revoked and the instance can\'t use them. Clear what else the agent\'s store directory holds and delete again, which is safe',
|
||||
// Row 239 (#1745): a fire's or a delete's revoke runs under the writer's lock; these follow one that ran.
|
||||
'revision-conflict-revoked': 'the business file changed after the agent\'s tokens were revoked and before the write, so the file is unchanged. The tokens may already be revoked and the instance can\'t use them. Read the role instances again and delete again, which is safe',
|
||||
'outcome-unknown-revoked': 'the agent\'s tokens may already be revoked and the write\'s outcome is unknown. Read the role instances again before trying',
|
||||
'capacity-full': 'a launch limit is reached',
|
||||
'limit-reached': 'a launch limit is reached',
|
||||
'no-route': 'the instance has no harness, model and thinking yet; set its route first',
|
||||
|
||||
@@ -220,21 +220,3 @@ test('row 194 N5: agents.js is served as JavaScript and index.html loads it once
|
||||
const html = await get(s, '/', { host: 'console.example', 'x-user': 'jason' });
|
||||
assert.equal(html.text.match(/<script src="\/agents\.js" defer><\/script>/g)?.length, 1);
|
||||
});
|
||||
|
||||
// Row 239 (#1745): a fire's or a delete's revoke runs under the writer's lock. A delete closed after
|
||||
// that revoke ran says the tokens may already be revoked; a plain revision-conflict keeps its text.
|
||||
// duckworth-0165 C1: a direct answer of outcome-unknown-revoked is an open outcome, never "Nothing changed".
|
||||
test('row 239 T6: a delete closed -revoked says the tokens may already be revoked, and outcome-unknown-revoked is an open outcome', async t => {
|
||||
let refusal = 'revision-conflict-revoked';
|
||||
const s = await setup(t, { agent: async (verb, args) => { if (verb !== 'instance.request') throw new BusReadError('outcome-unknown-revoked'); return { ...ANSWERS['instance.request'](args), done: { run: null, transaction: null, refusal } }; } });
|
||||
const reason = async () => (await get(s, `/api/agent/request?id=${ID}`)).body.reason;
|
||||
assert.match(await reason(), /business file changed after the agent's tokens were revoked .*\. The tokens may already be revoked .* delete again, which is safe$/);
|
||||
refusal = 'outcome-unknown-revoked';
|
||||
assert.match(await reason(), /tokens may already be revoked and the write's outcome is unknown\. Read the role instances again before trying$/);
|
||||
refusal = 'revision-conflict';
|
||||
assert.match(await reason(), /the business file changed since you read it/);
|
||||
assert.doesNotMatch(await reason(), /may already be revoked/);
|
||||
const unknown = await send(s, '/api/agent/launch', { instance: 'coder' });
|
||||
assert.deepEqual([unknown.status, unknown.body.error, unknown.body.sent], [503, 'outcome-unknown', 'unknown']);
|
||||
assert.doesNotMatch(unknown.body.message, /Nothing changed/);
|
||||
});
|
||||
Reference in new issue
Block a user