Row 258 (E01, #1763): mail PRD addendum draft (REQ-MAIL-1 to 16, not approved) and packages/mail schemas, validator and schema tests; Q222 settings with schema defaults and bounds, mailboxScope default none, human-only changes (sage-2802, Vikunja 460)

This commit is contained in:
jason.woltje committed 2026-10-22 11:45:45 -05:00
1 parent aca16404cf
commit a8f0cf9248
11 files changed
+1317

No files matched your search

+99
View File
@@ -0,0 +1,99 @@
# 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 |
| `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. No step backwards, so a restored
record can't revive a key. Past `expiresAt`, use is none 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.
## 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)
Suggested mutants (the reviewer names the final set):
- M1 `defaults`: `mailboxScope` default `["*"]` or a fixture mailbox → T1, T2 fail.
- M2 drop `const: "human"` from `changedBy.kind` → T3 fails.
- M3 drop the wildcard check → T5 fails on its message.
- M4 drop `looksSecret` → T6 fails.
- M5 `effectiveMailboxes` ignores the grant's mailboxes → T2 fails.
- M6 `inside()` returns false → T10 fails.
- M7 `checkProtectedFile` accepts mode 640 → T11 fails.
- M8 allow `retired → active` → T12 fails.
- M9 raise `pageSize` maximum to 101 → T9 fails.
+162
View File
@@ -0,0 +1,162 @@
# 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). Q217 settles the mailbox
(one agent mailbox on woltje.com at InMotion, which Jason creates).
Q222 settles personal mail (none for now) and the settings rule: things
are tunable, not hardcoded.
- Scope: post-v1. None of it is needed for 2026-10-27.
## 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. An invalid setting fails
closed.
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 (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, after this addendum is approved and Q4 is answered.
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 (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 (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 (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
(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. A retention setting never authorizes deletion. Deleting needs
`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. 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 Q4 approvals and a reviewed re-pin. It sends no test mail, and it
is judged on evidence for each message, not counts.
Rows: E15.
## Questions for Mos
1. REQ-MAIL-8 keeps `mail.access` a hard wait. The plan leaves routing it
out to a later PRD version Jason approves. Recommendation: keep it a
hard wait in the first approved version, and decide routing after the
pilot.
2. This draft becomes one new PRD version, with REQ-MAIL-1 to 16 as a new
"Mail" section. The requirements test and its JSON would grow from 85
to 101 then. Recommendation: land it that way, rather than as a
separate addendum file that the requirements test doesn't read.
+49
View File
@@ -0,0 +1,49 @@
# 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 addendum and Q4. 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 (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. The defaults read nothing.
- `checkSlotLocation` and `checkKeyLocation` keep provider slots and
content keys under `<configDir>/secrets/mosaic-stack/`, outside
dataRoot, the repository and any backup root.
- `checkProtectedFile` wants a regular file, owned, mode 600, with no
symbolic link between configDir and the file.
- `keyTransitionAllowed` and `keyUse` move a key forward only: active,
retired (decrypt only), expired (no use).
Tests: `node --test tests/*.test.mjs` from this directory.
+11
View File
@@ -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"
}
}
+103
View File
@@ -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
}
}
}
+83
View File
@@ -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"
}
}
}
+73
View File
@@ -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
}
}
}
+222
View File
@@ -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"
}
}
}
+196
View File
@@ -0,0 +1,196 @@
import { lstatSync, readFileSync, realpathSync } from 'node:fs';
import { 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 addendum and Q4.
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;
}
// What a principal may read: the source scope, the provider's mailboxes and the grant, intersected.
// A disabled source, the default empty scope or a missing grant reads nothing.
export function effectiveMailboxes({ source, providerMailboxes = [], access = null, now = new Date() }) {
if (!source.sourceEnabled || !access) return [];
if (Date.parse(access.expiresAt) <= now.getTime()) 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));
}
function placed(label, path, expectedDir, { dataRoot, repoRoot, backupRoots = [] }) {
const problems = [];
if (!isAbsolute(path) || normalize(path) !== path) return [`${label}: must be a normalized absolute path`];
if (!inside(expectedDir, path) || path === expectedDir) problems.push(`${label}: must sit under ${expectedDir}`);
for (const [name, root] of [['dataRoot', dataRoot], ['the repository', repoRoot], ...backupRoots.map(root => ['a backup root', root])])
if (root && inside(root, path)) problems.push(`${label}: inside ${name}; mail secrets live outside it`);
return problems;
}
// 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: owned, mode 600, regular, no symbolic link between configDir and the file.
export function checkProtectedFile(path, { configDir, uid = process.getuid() }) {
let stat;
try { stat = lstatSync(path); } catch { return [`${path}: not found`]; }
const problems = [];
if (stat.isSymbolicLink() || !stat.isFile()) return [`${path}: must be a regular file, not a symbolic link`];
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`);
let real = null;
let realConfig = null;
try { real = realpathSync(path); realConfig = realpathSync(configDir); } catch { real = null; problems.push(`${path}: path can't be resolved`); }
if (real && real !== join(realConfig, relative(configDir, path))) problems.push(`${path}: a symbolic link sits on the path`);
return problems;
}
// Key lifecycle: active keys encrypt and decrypt, retired keys only decrypt, expired keys do neither and
// every copy is destroyed. No state moves backwards, so a restored backup can't revive a key.
const KEY_STEPS = { active: ['retired', 'expired'], retired: ['expired'], expired: [] };
export const keyTransitionAllowed = (from, to) => Object.hasOwn(KEY_STEPS, from) && KEY_STEPS[from].includes(to);
export const keyUse = (record, now = new Date()) =>
record.state === 'expired' || Date.parse(record.expiresAt) <= now.getTime() ? 'none'
: record.state === 'retired' ? 'decrypt' : 'encrypt-decrypt';
+262
View File
@@ -0,0 +1,262 @@
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');
});
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('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`]);
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`]);
} 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');
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 addendum and Q4');
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);
});