From a8f0cf92487cc732cbf0fe798a2ea5719f2a073b Mon Sep 17 00:00:00 2001 From: Jason Woltje Date: Thu, 22 Oct 2026 11:45:45 -0500 Subject: [PATCH] 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) --- agents/ludwig/work/queue-258/BUILD.md | 99 +++++++ docs/prd/mail-addendum-draft.md | 162 +++++++++++ packages/mail/README.md | 49 ++++ packages/mail/package.json | 11 + packages/mail/schemas/access.schema.json | 103 +++++++ packages/mail/schemas/content-key.schema.json | 57 ++++ packages/mail/schemas/data.schema.json | 83 ++++++ packages/mail/schemas/service.schema.json | 73 +++++ packages/mail/schemas/source.schema.json | 222 +++++++++++++++ packages/mail/src/validate.mjs | 196 +++++++++++++ packages/mail/tests/schema.test.mjs | 262 ++++++++++++++++++ 11 files changed, 1317 insertions(+) create mode 100644 agents/ludwig/work/queue-258/BUILD.md create mode 100644 docs/prd/mail-addendum-draft.md create mode 100644 packages/mail/README.md create mode 100644 packages/mail/package.json create mode 100644 packages/mail/schemas/access.schema.json create mode 100644 packages/mail/schemas/content-key.schema.json create mode 100644 packages/mail/schemas/data.schema.json create mode 100644 packages/mail/schemas/service.schema.json create mode 100644 packages/mail/schemas/source.schema.json create mode 100644 packages/mail/src/validate.mjs create mode 100644 packages/mail/tests/schema.test.mjs diff --git a/agents/ludwig/work/queue-258/BUILD.md b/agents/ludwig/work/queue-258/BUILD.md new file mode 100644 index 000000000..2e5cae4ef --- /dev/null +++ b/agents/ludwig/work/queue-258/BUILD.md @@ -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. diff --git a/docs/prd/mail-addendum-draft.md b/docs/prd/mail-addendum-draft.md new file mode 100644 index 000000000..2e1c1bdbd --- /dev/null +++ b/docs/prd/mail-addendum-draft.md @@ -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 + `/secrets/mosaic-stack/mail///`, 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 `/mail//` + and never enters git or an image. By default the stack keeps metadata + only. Content keys sit under + `/secrets/mosaic-stack/mail-content//`, 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. diff --git a/packages/mail/README.md b/packages/mail/README.md new file mode 100644 index 000000000..b335a0c40 --- /dev/null +++ b/packages/mail/README.md @@ -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 `/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. diff --git a/packages/mail/package.json b/packages/mail/package.json new file mode 100644 index 000000000..937408f84 --- /dev/null +++ b/packages/mail/package.json @@ -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" + } +} diff --git a/packages/mail/schemas/access.schema.json b/packages/mail/schemas/access.schema.json new file mode 100644 index 000000000..f891b1346 --- /dev/null +++ b/packages/mail/schemas/access.schema.json @@ -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 + } + } +} diff --git a/packages/mail/schemas/content-key.schema.json b/packages/mail/schemas/content-key.schema.json new file mode 100644 index 000000000..f9075685d --- /dev/null +++ b/packages/mail/schemas/content-key.schema.json @@ -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 + } + } +} diff --git a/packages/mail/schemas/data.schema.json b/packages/mail/schemas/data.schema.json new file mode 100644 index 000000000..cd031b4c9 --- /dev/null +++ b/packages/mail/schemas/data.schema.json @@ -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" + } + } +} diff --git a/packages/mail/schemas/service.schema.json b/packages/mail/schemas/service.schema.json new file mode 100644 index 000000000..f247fc5d7 --- /dev/null +++ b/packages/mail/schemas/service.schema.json @@ -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 + } + } +} diff --git a/packages/mail/schemas/source.schema.json b/packages/mail/schemas/source.schema.json new file mode 100644 index 000000000..d6fcfe29d --- /dev/null +++ b/packages/mail/schemas/source.schema.json @@ -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" + } + } +} diff --git a/packages/mail/src/validate.mjs b/packages/mail/src/validate.mjs new file mode 100644 index 000000000..aa8e6e9e9 --- /dev/null +++ b/packages/mail/src/validate.mjs @@ -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'; diff --git a/packages/mail/tests/schema.test.mjs b/packages/mail/tests/schema.test.mjs new file mode 100644 index 000000000..f48e579d0 --- /dev/null +++ b/packages/mail/tests/schema.test.mjs @@ -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: ['agent@example.test'], 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: 'agent@example.test', 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: ['agent@example.test'] }); + const providerMailboxes = ['agent@example.test', 'other@example.test']; + 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: ['agent@example.test', 'other@example.test'] }); + assert.deepEqual(effectiveMailboxes({ source: scoped, providerMailboxes, access: grant, now: new Date(AT) }), ['agent@example.test']); + 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: ['agent?@example.test'] }), '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:SENTINEL@mail.example.test', '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: ['a@example.test', 'a@example.test'] }), 'duplicate items'); + assert.deepEqual(validateRecord('source', source({ unattendedWake: true, wakeSenderAllowlist: ['boss@example.test'] })), []); +}); + +test('T8 mismatched principal and source are refused', () => { + const scoped = source({ mailboxScope: ['agent@example.test'] }); + 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: ['other@example.test'] }) }), + ['access: mailbox other@example.test outside the source scope']); + assert.deepEqual(checkBinding({ source: source(), access: access() }), ['access: mailbox agent@example.test 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); +});