From 4d21e3ceaa0a4c4bed67b38d43181cefffba069b Mon Sep 17 00:00:00 2001 From: Sawyer Date: Thu, 17 Sep 2026 12:25:52 -0700 Subject: [PATCH 01/17] Vendor @intx/mailbox, mime, types at 692c3106 (unpublished) --- VENDORED.md | 32 + bun.lock | 100 +- package.json | 13 +- vendor/intx-mailbox/LICENSE | 176 + vendor/intx-mailbox/package.json | 35 + vendor/intx-mailbox/src/fetch.ts | 250 ++ vendor/intx-mailbox/src/headers.ts | 5 + vendor/intx-mailbox/src/index.ts | 11 + vendor/intx-mailbox/src/mailbox.ts | 192 ++ vendor/intx-mailbox/src/search.ts | 208 ++ vendor/intx-mailbox/src/thread.ts | 275 ++ vendor/intx-mime/LICENSE | 176 + vendor/intx-mime/package.json | 34 + vendor/intx-mime/src/index.ts | 44 + vendor/intx-mime/src/mail-builder.ts | 516 +++ vendor/intx-mime/src/mime.ts | 1334 ++++++++ vendor/intx-mime/src/pgp-sign.ts | 29 + vendor/intx-types/LICENSE | 176 + vendor/intx-types/package.json | 78 + vendor/intx-types/src/agent-address.ts | 34 + vendor/intx-types/src/agent-data.ts | 43 + vendor/intx-types/src/approvals.ts | 38 + vendor/intx-types/src/assets.ts | 42 + vendor/intx-types/src/attachments.ts | 66 + vendor/intx-types/src/audit.ts | 49 + vendor/intx-types/src/authz.ts | 49 + vendor/intx-types/src/base64.ts | 22 + vendor/intx-types/src/base64url.ts | 22 + vendor/intx-types/src/capabilities.ts | 59 + vendor/intx-types/src/catalog.ts | 257 ++ vendor/intx-types/src/common.ts | 34 + vendor/intx-types/src/concat.ts | 19 + vendor/intx-types/src/content-type.ts | 20 + vendor/intx-types/src/credential-cipher.ts | 55 + vendor/intx-types/src/credentials.ts | 132 + vendor/intx-types/src/grant-snapshot.ts | 37 + vendor/intx-types/src/grant-wire.ts | 29 + vendor/intx-types/src/grants.ts | 114 + vendor/intx-types/src/has-code.ts | 11 + vendor/intx-types/src/hex.ts | 25 + vendor/intx-types/src/index.ts | 38 + vendor/intx-types/src/instances.ts | 77 + vendor/intx-types/src/me.ts | 60 + vendor/intx-types/src/mediated-credential.ts | 117 + vendor/intx-types/src/message-id.ts | 82 + vendor/intx-types/src/models.ts | 39 + vendor/intx-types/src/oauth-clients.ts | 47 + vendor/intx-types/src/observability.ts | 64 + vendor/intx-types/src/offerings.ts | 67 + vendor/intx-types/src/package-json.ts | 98 + vendor/intx-types/src/principals.ts | 57 + vendor/intx-types/src/providers.ts | 56 + vendor/intx-types/src/roles.ts | 21 + vendor/intx-types/src/runtime-capabilities.ts | 135 + vendor/intx-types/src/runtime.ts | 2891 +++++++++++++++++ vendor/intx-types/src/sessions.ts | 151 + vendor/intx-types/src/sidecar-allocation.ts | 32 + vendor/intx-types/src/sidecar-capabilities.ts | 61 + .../src/sidecar-oauth-login.test.ts | 116 + vendor/intx-types/src/sidecar.ts | 1056 ++++++ vendor/intx-types/src/signals.ts | 96 + vendor/intx-types/src/signer-identity.ts | 33 + vendor/intx-types/src/tenants.ts | 44 + vendor/intx-types/src/tool-packages.ts | 312 ++ vendor/intx-types/src/wallets.ts | 59 + vendor/intx-types/src/wire-definition-hash.ts | 82 + vendor/intx-types/src/wire-workflow.ts | 170 + vendor/intx-types/src/workflow-run-id.ts | 40 + vendor/intx-types/src/workflow-sources.ts | 74 + vendor/intx-types/src/workflows.ts | 48 + 70 files changed, 11052 insertions(+), 12 deletions(-) create mode 100644 VENDORED.md create mode 100644 vendor/intx-mailbox/LICENSE create mode 100644 vendor/intx-mailbox/package.json create mode 100644 vendor/intx-mailbox/src/fetch.ts create mode 100644 vendor/intx-mailbox/src/headers.ts create mode 100644 vendor/intx-mailbox/src/index.ts create mode 100644 vendor/intx-mailbox/src/mailbox.ts create mode 100644 vendor/intx-mailbox/src/search.ts create mode 100644 vendor/intx-mailbox/src/thread.ts create mode 100644 vendor/intx-mime/LICENSE create mode 100644 vendor/intx-mime/package.json create mode 100644 vendor/intx-mime/src/index.ts create mode 100644 vendor/intx-mime/src/mail-builder.ts create mode 100644 vendor/intx-mime/src/mime.ts create mode 100644 vendor/intx-mime/src/pgp-sign.ts create mode 100644 vendor/intx-types/LICENSE create mode 100644 vendor/intx-types/package.json create mode 100644 vendor/intx-types/src/agent-address.ts create mode 100644 vendor/intx-types/src/agent-data.ts create mode 100644 vendor/intx-types/src/approvals.ts create mode 100644 vendor/intx-types/src/assets.ts create mode 100644 vendor/intx-types/src/attachments.ts create mode 100644 vendor/intx-types/src/audit.ts create mode 100644 vendor/intx-types/src/authz.ts create mode 100644 vendor/intx-types/src/base64.ts create mode 100644 vendor/intx-types/src/base64url.ts create mode 100644 vendor/intx-types/src/capabilities.ts create mode 100644 vendor/intx-types/src/catalog.ts create mode 100644 vendor/intx-types/src/common.ts create mode 100644 vendor/intx-types/src/concat.ts create mode 100644 vendor/intx-types/src/content-type.ts create mode 100644 vendor/intx-types/src/credential-cipher.ts create mode 100644 vendor/intx-types/src/credentials.ts create mode 100644 vendor/intx-types/src/grant-snapshot.ts create mode 100644 vendor/intx-types/src/grant-wire.ts create mode 100644 vendor/intx-types/src/grants.ts create mode 100644 vendor/intx-types/src/has-code.ts create mode 100644 vendor/intx-types/src/hex.ts create mode 100644 vendor/intx-types/src/index.ts create mode 100644 vendor/intx-types/src/instances.ts create mode 100644 vendor/intx-types/src/me.ts create mode 100644 vendor/intx-types/src/mediated-credential.ts create mode 100644 vendor/intx-types/src/message-id.ts create mode 100644 vendor/intx-types/src/models.ts create mode 100644 vendor/intx-types/src/oauth-clients.ts create mode 100644 vendor/intx-types/src/observability.ts create mode 100644 vendor/intx-types/src/offerings.ts create mode 100644 vendor/intx-types/src/package-json.ts create mode 100644 vendor/intx-types/src/principals.ts create mode 100644 vendor/intx-types/src/providers.ts create mode 100644 vendor/intx-types/src/roles.ts create mode 100644 vendor/intx-types/src/runtime-capabilities.ts create mode 100644 vendor/intx-types/src/runtime.ts create mode 100644 vendor/intx-types/src/sessions.ts create mode 100644 vendor/intx-types/src/sidecar-allocation.ts create mode 100644 vendor/intx-types/src/sidecar-capabilities.ts create mode 100644 vendor/intx-types/src/sidecar-oauth-login.test.ts create mode 100644 vendor/intx-types/src/sidecar.ts create mode 100644 vendor/intx-types/src/signals.ts create mode 100644 vendor/intx-types/src/signer-identity.ts create mode 100644 vendor/intx-types/src/tenants.ts create mode 100644 vendor/intx-types/src/tool-packages.ts create mode 100644 vendor/intx-types/src/wallets.ts create mode 100644 vendor/intx-types/src/wire-definition-hash.ts create mode 100644 vendor/intx-types/src/wire-workflow.ts create mode 100644 vendor/intx-types/src/workflow-run-id.ts create mode 100644 vendor/intx-types/src/workflow-sources.ts create mode 100644 vendor/intx-types/src/workflows.ts diff --git a/VENDORED.md b/VENDORED.md new file mode 100644 index 0000000..2703e95 --- /dev/null +++ b/VENDORED.md @@ -0,0 +1,32 @@ +# Vendored code + +`@corbits/mailbox` consumes Interchange as published packages wherever a +publish covers the needed capability. `@intx/mailbox` has never been +published, so it is hand-copied here as a sanctioned escape hatch — never a +submodule, never touched upstream. Its two compile-time dependencies that +are published only at an older API surface (`@intx/mime`, `@intx/types`) +are vendored alongside it at the same commit so the tree never mixes pins; +`@intx/crypto`, byte-identical between npm `0.3.0` and this commit, is +consumed as an ordinary npm dependency instead. + +## Rules + +- Vendoring is hand-copied files only — never a git submodule. +- Every vendored path has exactly one row below, with a kill date and a + dated test that fails after it. +- The upstream repository is never modified, committed to, or pushed to. +- Retiring a vendored copy closes the entry: delete the row and the files + together. + +## Ledger + +| Path | Contents | Upstream | Local delta | Kill condition | +| --- | --- | --- | --- | --- | +| `vendor/intx-mailbox` | `@intx/mailbox` source (`src/`, `package.json`, `LICENSE`) | [faremeter/interchange](https://github.com/faremeter/interchange) @ `692c3106` (origin/main, 2026-09-03), copied from Workbench's own `vendor/intx/mailbox` pin | Package-manager pins only: `catalog:` ranges resolved to this repo's fixed versions (`arktype` 2.1.29, `typescript` 5.7.2, `@types/bun` 1.1.14); dependency versions repointed to `workspace:*` for `@intx/mime`/`@intx/types`. No source delta. | First npm publish of `@intx/mailbox` | +| `vendor/intx-mime` | `@intx/mime` source at the `@intx/mailbox` pin | same commit as above | Same pin/catalog cleanup as above. No source delta. Needed because npm `0.3.0` predates `buildMessageHeaders` and other exports `@intx/mailbox` at this pin imports. | Retired when `@intx/mailbox` publishes against a released `@intx/mime` that carries these exports | +| `vendor/intx-types` | `@intx/types` source at the `@intx/mailbox` pin | same commit as above | Same pin/catalog cleanup as above. No source delta. Needed because npm `0.3.0` predates the runtime types (`InterchangeType`, `Thread`, `SearchQuery`, `base64Decode`) `@intx/mailbox`/`@intx/mime` at this pin import. | Retired when `@intx/mailbox` publishes against a released `@intx/types` that carries these exports | + +## Upstream ask + +Publishing `@intx/mailbox` (and refreshing the `@intx/mime`/`@intx/types` +npm releases to the pin it needs) retires all three rows in one move. diff --git a/bun.lock b/bun.lock index 785ac9b..2d480c7 100644 --- a/bun.lock +++ b/bun.lock @@ -12,9 +12,11 @@ "hono-openapi": "1.3.1", }, "devDependencies": { + "@intx/crypto": "0.3.0", "@intx/log": "0.2.2", - "@intx/mime": "0.2.2", - "@intx/types": "0.2.2", + "@intx/mailbox": "workspace:*", + "@intx/mime": "workspace:*", + "@intx/types": "workspace:*", "@types/bun": "1.1.14", "@types/json-schema": "7.0.15", "@types/node": "22.10.5", @@ -26,8 +28,8 @@ }, "peerDependencies": { "@intx/log": "^0.2.2", - "@intx/mime": "^0.2.2", - "@intx/types": "^0.2.2", + "@intx/mime": "^0.3.0", + "@intx/types": "^0.3.0", "drizzle-orm": "^0.45.2", "hono": "^4.12.0", "postgres": "^3.4.0", @@ -49,6 +51,46 @@ "typescript": "5.7.2", }, }, + "vendor/intx-mailbox": { + "name": "@intx/mailbox", + "version": "0.3.0", + "dependencies": { + "@intx/crypto": "0.3.0", + "@intx/mime": "workspace:*", + "@intx/types": "workspace:*", + "arktype": "2.1.29", + }, + "devDependencies": { + "@types/bun": "1.1.14", + "typescript": "5.7.2", + }, + }, + "vendor/intx-mime": { + "name": "@intx/mime", + "version": "0.3.0", + "dependencies": { + "@intx/crypto": "0.3.0", + "@intx/types": "workspace:*", + "arktype": "2.1.29", + }, + "devDependencies": { + "@types/bun": "1.1.14", + "typescript": "5.7.2", + }, + }, + "vendor/intx-types": { + "name": "@intx/types", + "version": "0.3.0", + "dependencies": { + "arktype": "2.1.29", + "semver": "^7.7.2", + }, + "devDependencies": { + "@types/bun": "1.1.14", + "@types/semver": "^7.7.1", + "typescript": "5.7.2", + }, + }, }, "overrides": { "drizzle-orm": "0.45.2", @@ -86,7 +128,7 @@ "@intx/authz": ["@intx/authz@0.2.2", "", { "dependencies": { "@intx/types": "0.2.2" } }, "sha512-LDHF/3u2duVFGOPzbAFP9WuRlouNqVW+os/o7Y2Mj/9vld92DaemDkL1BPO8m6xF2vSctykpLZFnw4fVcC9oQw=="], - "@intx/crypto": ["@intx/crypto@0.2.2", "", { "dependencies": { "@intx/types": "0.2.2" } }, "sha512-RhqDZ+Yyt24xDmFrBRm58vGyFT20AK/OF5L6GxXUwu/8TRVf406pfpMuRMNzgGxd0mDopdv/Fb+gWc1/PULfFQ=="], + "@intx/crypto": ["@intx/crypto@0.3.0", "", { "dependencies": { "@intx/types": "0.3.0" } }, "sha512-NsRzvkFGb0Pcsm9uLFFSXNl6Cbu+ii6VkYAw81qs9IbZzbAFfsZSUVQaX6EZuPNyJ8KK4KmBtqmioq1mhXsJww=="], "@intx/db": ["@intx/db@0.2.2", "", { "dependencies": { "@intx/log": "0.2.2", "@intx/types": "0.2.2", "arktype": "^2.1.29", "drizzle-orm": "^0.45.1", "postgres": "^3.4.8" } }, "sha512-xqYOF8/1Ay1e/eX7GrZV0kzGgdis8SZRaqE77Ncz9hofTnwusPt83Gc1RvtfasKPici4nP6Gq9EBrrf7aRShqg=="], @@ -100,7 +142,9 @@ "@intx/log": ["@intx/log@0.2.2", "", { "dependencies": { "@logtape/hono": "^2.0.2", "@logtape/logtape": "^2.0.2" }, "peerDependencies": { "hono": "^4.0.0" }, "optionalPeers": ["hono"] }, "sha512-Rlkd4pvyXwlqkm6hui97VwuTytqgOmenvbdaHAFdYr1e6DHHhGu7ZodUEsLiF4993qJmWNHGlNbHEgQY1LNPrQ=="], - "@intx/mime": ["@intx/mime@0.2.2", "", { "dependencies": { "@intx/crypto": "0.2.2", "@intx/types": "0.2.2", "arktype": "^2.1.29" } }, "sha512-uSrPWqEi8GurKbawtqk3S3KOHi/o1DmxCEeUb3un3fsZC2RkZrhqtqkTpY8a8q5r5gIJCqW/jmi3hyPyjZMakw=="], + "@intx/mailbox": ["@intx/mailbox@workspace:vendor/intx-mailbox"], + + "@intx/mime": ["@intx/mime@workspace:vendor/intx-mime"], "@intx/pack-transport": ["@intx/pack-transport@0.2.2", "", { "dependencies": { "@intx/types": "0.2.2" } }, "sha512-/bRh5QXj9cZJ3eU9/D7nnWY2Jqr8ega7lCfgKeAkm/xhVZLfUxVFA4eGxfrFkMoqh9oRmKbgdGafYZV4W4BvpQ=="], @@ -108,7 +152,7 @@ "@intx/tool-packaging": ["@intx/tool-packaging@0.2.2", "", { "dependencies": { "@intx/agent": "0.2.2", "@intx/log": "0.2.2", "@intx/types": "0.2.2", "arktype": "^2.1.29", "npm-package-arg": "^12.0.2", "npm-pick-manifest": "^10.0.0", "npm-registry-fetch": "^19.0.0", "semver": "^7.7.2", "ssri": "^12.0.0", "tar": "^7.5.1" } }, "sha512-luyHVkZSLMN/bWfTD2nLF7l6h7w/NQfFhNX1vfNhl/8DsG0lUFEn5NCjK/07eyF6iyVDMDGl3Cuq9r7B2y7deA=="], - "@intx/types": ["@intx/types@0.2.2", "", { "dependencies": { "arktype": "^2.1.29", "semver": "^7.7.2" } }, "sha512-9DYTXLuf6ARsHWOWnRuOaA/+5haM3yJVUhvzlps/eQTH6zjk3CMOlbgKUtBYZ164MhC5IbXCvJTLGyM1grpMqw=="], + "@intx/types": ["@intx/types@workspace:vendor/intx-types"], "@intx/workflow": ["@intx/workflow@0.2.2", "", { "dependencies": { "@intx/agent": "0.2.2", "@intx/inference": "0.2.2", "arktype": "^2.1.29" } }, "sha512-/vx8UDtPFjY0K0KoIaHmojg3up0/iOCF9CzTIludHVFWUF/Pc224ZxkqMkL5ncwgUbBQpoIqAAUAmuYMp8JT5Q=="], @@ -144,6 +188,8 @@ "@types/node": ["@types/node@22.10.5", "", { "dependencies": { "undici-types": "~6.20.0" } }, "sha512-F8Q+SeGimwOo86fiovQh8qiXfFEh2/ocYv7tU5pJ3EXMSSxk1Joj5wefpFK2fHTf/N6HKGSxIDBT9f3gCxXPkQ=="], + "@types/semver": ["@types/semver@7.8.0", "", {}, "sha512-1mAINjtQCXXeLkJ9ehXkwOcBpqtLxiVtKhpUf83DdRNdQKV0iXZpaHYqRr7nj+wvxuJzoAmAwXI+sCNMv1CzLQ=="], + "@types/ws": ["@types/ws@8.5.14", "", { "dependencies": { "@types/node": "*" } }, "sha512-bd/YFLW+URhBzMXurx7lWByOu+xzU9+kb3RboOteXYDfW+tr+JZa99OyNmPINEGB/ahzKrEuc8rcv4gnpJmxTw=="], "abort-controller": ["abort-controller@3.0.0", "", { "dependencies": { "event-target-shim": "^5.0.0" } }, "sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg=="], @@ -386,6 +432,42 @@ "zod": ["zod@4.4.3", "", {}, "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ=="], + "@intx/agent/@intx/mime": ["@intx/mime@0.2.2", "", { "dependencies": { "@intx/crypto": "0.2.2", "@intx/types": "0.2.2", "arktype": "^2.1.29" } }, "sha512-uSrPWqEi8GurKbawtqk3S3KOHi/o1DmxCEeUb3un3fsZC2RkZrhqtqkTpY8a8q5r5gIJCqW/jmi3hyPyjZMakw=="], + + "@intx/agent/@intx/types": ["@intx/types@0.2.2", "", { "dependencies": { "arktype": "^2.1.29", "semver": "^7.7.2" } }, "sha512-9DYTXLuf6ARsHWOWnRuOaA/+5haM3yJVUhvzlps/eQTH6zjk3CMOlbgKUtBYZ164MhC5IbXCvJTLGyM1grpMqw=="], + + "@intx/authz/@intx/types": ["@intx/types@0.2.2", "", { "dependencies": { "arktype": "^2.1.29", "semver": "^7.7.2" } }, "sha512-9DYTXLuf6ARsHWOWnRuOaA/+5haM3yJVUhvzlps/eQTH6zjk3CMOlbgKUtBYZ164MhC5IbXCvJTLGyM1grpMqw=="], + + "@intx/crypto/@intx/types": ["@intx/types@0.3.0", "", { "dependencies": { "arktype": "^2.1.29", "semver": "^7.7.2" } }, "sha512-PJ+v3IhtfZ4J7ZqJ8E39TteRMnC6Y503Q0JeCJdskkK+jhcjx01z7TJSozucagauAlpyh8Hn/9CyWFl53Ji0DQ=="], + + "@intx/db/@intx/types": ["@intx/types@0.2.2", "", { "dependencies": { "arktype": "^2.1.29", "semver": "^7.7.2" } }, "sha512-9DYTXLuf6ARsHWOWnRuOaA/+5haM3yJVUhvzlps/eQTH6zjk3CMOlbgKUtBYZ164MhC5IbXCvJTLGyM1grpMqw=="], + + "@intx/hub-api/@intx/crypto": ["@intx/crypto@0.2.2", "", { "dependencies": { "@intx/types": "0.2.2" } }, "sha512-RhqDZ+Yyt24xDmFrBRm58vGyFT20AK/OF5L6GxXUwu/8TRVf406pfpMuRMNzgGxd0mDopdv/Fb+gWc1/PULfFQ=="], + + "@intx/hub-api/@intx/mime": ["@intx/mime@0.2.2", "", { "dependencies": { "@intx/crypto": "0.2.2", "@intx/types": "0.2.2", "arktype": "^2.1.29" } }, "sha512-uSrPWqEi8GurKbawtqk3S3KOHi/o1DmxCEeUb3un3fsZC2RkZrhqtqkTpY8a8q5r5gIJCqW/jmi3hyPyjZMakw=="], + + "@intx/hub-api/@intx/types": ["@intx/types@0.2.2", "", { "dependencies": { "arktype": "^2.1.29", "semver": "^7.7.2" } }, "sha512-9DYTXLuf6ARsHWOWnRuOaA/+5haM3yJVUhvzlps/eQTH6zjk3CMOlbgKUtBYZ164MhC5IbXCvJTLGyM1grpMqw=="], + + "@intx/hub-common/@intx/types": ["@intx/types@0.2.2", "", { "dependencies": { "arktype": "^2.1.29", "semver": "^7.7.2" } }, "sha512-9DYTXLuf6ARsHWOWnRuOaA/+5haM3yJVUhvzlps/eQTH6zjk3CMOlbgKUtBYZ164MhC5IbXCvJTLGyM1grpMqw=="], + + "@intx/hub-sessions/@intx/crypto": ["@intx/crypto@0.2.2", "", { "dependencies": { "@intx/types": "0.2.2" } }, "sha512-RhqDZ+Yyt24xDmFrBRm58vGyFT20AK/OF5L6GxXUwu/8TRVf406pfpMuRMNzgGxd0mDopdv/Fb+gWc1/PULfFQ=="], + + "@intx/hub-sessions/@intx/mime": ["@intx/mime@0.2.2", "", { "dependencies": { "@intx/crypto": "0.2.2", "@intx/types": "0.2.2", "arktype": "^2.1.29" } }, "sha512-uSrPWqEi8GurKbawtqk3S3KOHi/o1DmxCEeUb3un3fsZC2RkZrhqtqkTpY8a8q5r5gIJCqW/jmi3hyPyjZMakw=="], + + "@intx/hub-sessions/@intx/types": ["@intx/types@0.2.2", "", { "dependencies": { "arktype": "^2.1.29", "semver": "^7.7.2" } }, "sha512-9DYTXLuf6ARsHWOWnRuOaA/+5haM3yJVUhvzlps/eQTH6zjk3CMOlbgKUtBYZ164MhC5IbXCvJTLGyM1grpMqw=="], + + "@intx/inference/@intx/types": ["@intx/types@0.2.2", "", { "dependencies": { "arktype": "^2.1.29", "semver": "^7.7.2" } }, "sha512-9DYTXLuf6ARsHWOWnRuOaA/+5haM3yJVUhvzlps/eQTH6zjk3CMOlbgKUtBYZ164MhC5IbXCvJTLGyM1grpMqw=="], + + "@intx/pack-transport/@intx/types": ["@intx/types@0.2.2", "", { "dependencies": { "arktype": "^2.1.29", "semver": "^7.7.2" } }, "sha512-9DYTXLuf6ARsHWOWnRuOaA/+5haM3yJVUhvzlps/eQTH6zjk3CMOlbgKUtBYZ164MhC5IbXCvJTLGyM1grpMqw=="], + + "@intx/storage-isogit/@intx/mime": ["@intx/mime@0.2.2", "", { "dependencies": { "@intx/crypto": "0.2.2", "@intx/types": "0.2.2", "arktype": "^2.1.29" } }, "sha512-uSrPWqEi8GurKbawtqk3S3KOHi/o1DmxCEeUb3un3fsZC2RkZrhqtqkTpY8a8q5r5gIJCqW/jmi3hyPyjZMakw=="], + + "@intx/storage-isogit/@intx/types": ["@intx/types@0.2.2", "", { "dependencies": { "arktype": "^2.1.29", "semver": "^7.7.2" } }, "sha512-9DYTXLuf6ARsHWOWnRuOaA/+5haM3yJVUhvzlps/eQTH6zjk3CMOlbgKUtBYZ164MhC5IbXCvJTLGyM1grpMqw=="], + + "@intx/tool-packaging/@intx/types": ["@intx/types@0.2.2", "", { "dependencies": { "arktype": "^2.1.29", "semver": "^7.7.2" } }, "sha512-9DYTXLuf6ARsHWOWnRuOaA/+5haM3yJVUhvzlps/eQTH6zjk3CMOlbgKUtBYZ164MhC5IbXCvJTLGyM1grpMqw=="], + + "@intx/workflow-deploy/@intx/types": ["@intx/types@0.2.2", "", { "dependencies": { "arktype": "^2.1.29", "semver": "^7.7.2" } }, "sha512-9DYTXLuf6ARsHWOWnRuOaA/+5haM3yJVUhvzlps/eQTH6zjk3CMOlbgKUtBYZ164MhC5IbXCvJTLGyM1grpMqw=="], + "@npmcli/agent/lru-cache": ["lru-cache@11.5.2", "", {}, "sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g=="], "bun-types/@types/node": ["@types/node@20.12.14", "", { "dependencies": { "undici-types": "~5.26.4" } }, "sha512-scnD59RpYD91xngrQQLGkE+6UrHUPzeKZWhhjBSa3HSkwjbQc38+q3RoIVEwxQGRw3M+j5hpNAM+lgV3cVormg=="], @@ -408,6 +490,10 @@ "path-scurry/lru-cache": ["lru-cache@11.5.2", "", {}, "sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g=="], + "@intx/agent/@intx/mime/@intx/crypto": ["@intx/crypto@0.2.2", "", { "dependencies": { "@intx/types": "0.2.2" } }, "sha512-RhqDZ+Yyt24xDmFrBRm58vGyFT20AK/OF5L6GxXUwu/8TRVf406pfpMuRMNzgGxd0mDopdv/Fb+gWc1/PULfFQ=="], + + "@intx/storage-isogit/@intx/mime/@intx/crypto": ["@intx/crypto@0.2.2", "", { "dependencies": { "@intx/types": "0.2.2" } }, "sha512-RhqDZ+Yyt24xDmFrBRm58vGyFT20AK/OF5L6GxXUwu/8TRVf406pfpMuRMNzgGxd0mDopdv/Fb+gWc1/PULfFQ=="], + "bun-types/@types/node/undici-types": ["undici-types@5.26.5", "", {}, "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA=="], "minipass-flush/minipass/yallist": ["yallist@4.0.0", "", {}, "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A=="], diff --git a/package.json b/package.json index 457b562..480e336 100644 --- a/package.json +++ b/package.json @@ -30,7 +30,8 @@ "minimumIntxVersion": "0.2.2" }, "workspaces": [ - "examples/*" + "examples/*", + "vendor/*" ], "main": "./dist/index.js", "types": "./dist/index.d.ts", @@ -67,16 +68,18 @@ }, "peerDependencies": { "@intx/log": "^0.2.2", - "@intx/mime": "^0.2.2", - "@intx/types": "^0.2.2", + "@intx/mime": "^0.3.0", + "@intx/types": "^0.3.0", "drizzle-orm": "^0.45.2", "hono": "^4.12.0", "postgres": "^3.4.0" }, "devDependencies": { + "@intx/crypto": "0.3.0", "@intx/log": "0.2.2", - "@intx/mime": "0.2.2", - "@intx/types": "0.2.2", + "@intx/mailbox": "workspace:*", + "@intx/mime": "workspace:*", + "@intx/types": "workspace:*", "@types/bun": "1.1.14", "@types/json-schema": "7.0.15", "@types/node": "22.10.5", diff --git a/vendor/intx-mailbox/LICENSE b/vendor/intx-mailbox/LICENSE new file mode 100644 index 0000000..c6487f4 --- /dev/null +++ b/vendor/intx-mailbox/LICENSE @@ -0,0 +1,176 @@ +GNU LESSER GENERAL PUBLIC LICENSE + +Version 2.1, February 1999 + +Copyright (C) 1991, 1999 Free Software Foundation, Inc. +51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA + +Everyone is permitted to copy and distribute verbatim copies of this license document, but changing it is not allowed. + +[This is the first released version of the Lesser GPL. It also counts as the successor of the GNU Library Public License, version 2, hence the version number 2.1.] + +Preamble + +The licenses for most software are designed to take away your freedom to share and change it. By contrast, the GNU General Public Licenses are intended to guarantee your freedom to share and change free software--to make sure the software is free for all its users. + +This license, the Lesser General Public License, applies to some specially designated software packages--typically libraries--of the Free Software Foundation and other authors who decide to use it. You can use it too, but we suggest you first think carefully about whether this license or the ordinary General Public License is the better strategy to use in any particular case, based on the explanations below. + +When we speak of free software, we are referring to freedom of use, not price. Our General Public Licenses are designed to make sure that you have the freedom to distribute copies of free software (and charge for this service if you wish); that you receive source code or can get it if you want it; that you can change the software and use pieces of it in new free programs; and that you are informed that you can do these things. + +To protect your rights, we need to make restrictions that forbid distributors to deny you these rights or to ask you to surrender these rights. These restrictions translate to certain responsibilities for you if you distribute copies of the library or if you modify it. + +For example, if you distribute copies of the library, whether gratis or for a fee, you must give the recipients all the rights that we gave you. You must make sure that they, too, receive or can get the source code. If you link other code with the library, you must provide complete object files to the recipients, so that they can relink them with the library after making changes to the library and recompiling it. And you must show them these terms so they know their rights. + +We protect your rights with a two-step method: (1) we copyright the library, and (2) we offer you this license, which gives you legal permission to copy, distribute and/or modify the library. + +To protect each distributor, we want to make it very clear that there is no warranty for the free library. Also, if the library is modified by someone else and passed on, the recipients should know that what they have is not the original version, so that the original author's reputation will not be affected by problems that might be introduced by others. + +Finally, software patents pose a constant threat to the existence of any free program. We wish to make sure that a company cannot effectively restrict the users of a free program by obtaining a restrictive license from a patent holder. Therefore, we insist that any patent license obtained for a version of the library must be consistent with the full freedom of use specified in this license. + +Most GNU software, including some libraries, is covered by the ordinary GNU General Public License. This license, the GNU Lesser General Public License, applies to certain designated libraries, and is quite different from the ordinary General Public License. We use this license for certain libraries in order to permit linking those libraries into non-free programs. + +When a program is linked with a library, whether statically or using a shared library, the combination of the two is legally speaking a combined work, a derivative of the original library. The ordinary General Public License therefore permits such linking only if the entire combination fits its criteria of freedom. The Lesser General Public License permits more lax criteria for linking other code with the library. + +We call this license the "Lesser" General Public License because it does Less to protect the user's freedom than the ordinary General Public License. It also provides other free software developers Less of an advantage over competing non-free programs. These disadvantages are the reason we use the ordinary General Public License for many libraries. However, the Lesser license provides advantages in certain special circumstances. + +For example, on rare occasions, there may be a special need to encourage the widest possible use of a certain library, so that it becomes a de-facto standard. To achieve this, non-free programs must be allowed to use the library. A more frequent case is that a free library does the same job as widely used non-free libraries. In this case, there is little to gain by limiting the free library to free software only, so we use the Lesser General Public License. + +In other cases, permission to use a particular library in non-free programs enables a greater number of people to use a large body of free software. For example, permission to use the GNU C Library in non-free programs enables many more people to use the whole GNU operating system, as well as its variant, the GNU/Linux operating system. + +Although the Lesser General Public License is Less protective of the users' freedom, it does ensure that the user of a program that is linked with the Library has the freedom and the wherewithal to run that program using a modified version of the Library. + +The precise terms and conditions for copying, distribution and modification follow. Pay close attention to the difference between a "work based on the library" and a "work that uses the library". The former contains code derived from the library, whereas the latter must be combined with the library in order to run. + +GNU LESSER GENERAL PUBLIC LICENSE +TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION + +0. This License Agreement applies to any software library or other program which contains a notice placed by the copyright holder or other authorized party saying it may be distributed under the terms of this Lesser General Public License (also called "this License"). Each licensee is addressed as "you". + +A "library" means a collection of software functions and/or data prepared so as to be conveniently linked with application programs (which use some of those functions and data) to form executables. + +The "Library", below, refers to any such software library or work which has been distributed under these terms. A "work based on the Library" means either the Library or any derivative work under copyright law: that is to say, a work containing the Library or a portion of it, either verbatim or with modifications and/or translated straightforwardly into another language. (Hereinafter, translation is included without limitation in the term "modification".) + +"Source code" for a work means the preferred form of the work for making modifications to it. For a library, complete source code means all the source code for all modules it contains, plus any associated interface definition files, plus the scripts used to control compilation and installation of the library. + +Activities other than copying, distribution and modification are not covered by this License; they are outside its scope. The act of running a program using the Library is not restricted, and output from such a program is covered only if its contents constitute a work based on the Library (independent of the use of the Library in a tool for writing it). Whether that is true depends on what the Library does and what the program that uses the Library does. + +1. You may copy and distribute verbatim copies of the Library's complete source code as you receive it, in any medium, provided that you conspicuously and appropriately publish on each copy an appropriate copyright notice and disclaimer of warranty; keep intact all the notices that refer to this License and to the absence of any warranty; and distribute a copy of this License along with the Library. + +You may charge a fee for the physical act of transferring a copy, and you may at your option offer warranty protection in exchange for a fee. + +2. You may modify your copy or copies of the Library or any portion of it, thus forming a work based on the Library, and copy and distribute such modifications or work under the terms of Section 1 above, provided that you also meet all of these conditions: + + a) The modified work must itself be a software library. + + b) You must cause the files modified to carry prominent notices stating that you changed the files and the date of any change. + + c) You must cause the whole of the work to be licensed at no charge to all third parties under the terms of this License. + + d) If a facility in the modified Library refers to a function or a table of data to be supplied by an application program that uses the facility, other than as an argument passed when the facility is invoked, then you must make a good faith effort to ensure that, in the event an application does not supply such function or table, the facility still operates, and performs whatever part of its purpose remains meaningful. + +(For example, a function in a library to compute square roots has a purpose that is entirely well-defined independent of the application. Therefore, Subsection 2d requires that any application-supplied function or table used by this function must be optional: if the application does not supply it, the square root function must still compute square roots.) + +These requirements apply to the modified work as a whole. If identifiable sections of that work are not derived from the Library, and can be reasonably considered independent and separate works in themselves, then this License, and its terms, do not apply to those sections when you distribute them as separate works. But when you distribute the same sections as part of a whole which is a work based on the Library, the distribution of the whole must be on the terms of this License, whose permissions for other licensees extend to the entire whole, and thus to each and every part regardless of who wrote it. + +Thus, it is not the intent of this section to claim rights or contest your rights to work written entirely by you; rather, the intent is to exercise the right to control the distribution of derivative or collective works based on the Library. + +In addition, mere aggregation of another work not based on the Library with the Library (or with a work based on the Library) on a volume of a storage or distribution medium does not bring the other work under the scope of this License. + +3. You may opt to apply the terms of the ordinary GNU General Public License instead of this License to a given copy of the Library. To do this, you must alter all the notices that refer to this License, so that they refer to the ordinary GNU General Public License, version 2, instead of to this License. (If a newer version than version 2 of the ordinary GNU General Public License has appeared, then you can specify that version instead if you wish.) Do not make any other change in these notices. + +Once this change is made in a given copy, it is irreversible for that copy, so the ordinary GNU General Public License applies to all subsequent copies and derivative works made from that copy. + +This option is useful when you wish to copy part of the code of the Library into a program that is not a library. + +4. You may copy and distribute the Library (or a portion or derivative of it, under Section 2) in object code or executable form under the terms of Sections 1 and 2 above provided that you accompany it with the complete corresponding machine-readable source code, which must be distributed under the terms of Sections 1 and 2 above on a medium customarily used for software interchange. + +If distribution of object code is made by offering access to copy from a designated place, then offering equivalent access to copy the source code from the same place satisfies the requirement to distribute the source code, even though third parties are not compelled to copy the source along with the object code. + +5. A program that contains no derivative of any portion of the Library, but is designed to work with the Library by being compiled or linked with it, is called a "work that uses the Library". Such a work, in isolation, is not a derivative work of the Library, and therefore falls outside the scope of this License. + +However, linking a "work that uses the Library" with the Library creates an executable that is a derivative of the Library (because it contains portions of the Library), rather than a "work that uses the library". The executable is therefore covered by this License. Section 6 states terms for distribution of such executables. + +When a "work that uses the Library" uses material from a header file that is part of the Library, the object code for the work may be a derivative work of the Library even though the source code is not. Whether this is true is especially significant if the work can be linked without the Library, or if the work is itself a library. The threshold for this to be true is not precisely defined by law. + +If such an object file uses only numerical parameters, data structure layouts and accessors, and small macros and small inline functions (ten lines or less in length), then the use of the object file is unrestricted, regardless of whether it is legally a derivative work. (Executables containing this object code plus portions of the Library will still fall under Section 6.) + +Otherwise, if the work is a derivative of the Library, you may distribute the object code for the work under the terms of Section 6. Any executables containing that work also fall under Section 6, whether or not they are linked directly with the Library itself. + +6. As an exception to the Sections above, you may also combine or link a "work that uses the Library" with the Library to produce a work containing portions of the Library, and distribute that work under terms of your choice, provided that the terms permit modification of the work for the customer's own use and reverse engineering for debugging such modifications. + +You must give prominent notice with each copy of the work that the Library is used in it and that the Library and its use are covered by this License. You must supply a copy of this License. If the work during execution displays copyright notices, you must include the copyright notice for the Library among them, as well as a reference directing the user to the copy of this License. Also, you must do one of these things: + + a) Accompany the work with the complete corresponding machine-readable source code for the Library including whatever changes were used in the work (which must be distributed under Sections 1 and 2 above); and, if the work is an executable linked with the Library, with the complete machine-readable "work that uses the Library", as object code and/or source code, so that the user can modify the Library and then relink to produce a modified executable containing the modified Library. (It is understood that the user who changes the contents of definitions files in the Library will not necessarily be able to recompile the application to use the modified definitions.) + + b) Use a suitable shared library mechanism for linking with the Library. A suitable mechanism is one that (1) uses at run time a copy of the library already present on the user's computer system, rather than copying library functions into the executable, and (2) will operate properly with a modified version of the library, if the user installs one, as long as the modified version is interface-compatible with the version that the work was made with. + + c) Accompany the work with a written offer, valid for at least three years, to give the same user the materials specified in Subsection 6a, above, for a charge no more than the cost of performing this distribution. + + d) If distribution of the work is made by offering access to copy from a designated place, offer equivalent access to copy the above specified materials from the same place. + + e) Verify that the user has already received a copy of these materials or that you have already sent this user a copy. + +For an executable, the required form of the "work that uses the Library" must include any data and utility programs needed for reproducing the executable from it. However, as a special exception, the materials to be distributed need not include anything that is normally distributed (in either source or binary form) with the major components (compiler, kernel, and so on) of the operating system on which the executable runs, unless that component itself accompanies the executable. + +It may happen that this requirement contradicts the license restrictions of other proprietary libraries that do not normally accompany the operating system. Such a contradiction means you cannot use both them and the Library together in an executable that you distribute. + +7. You may place library facilities that are a work based on the Library side-by-side in a single library together with other library facilities not covered by this License, and distribute such a combined library, provided that the separate distribution of the work based on the Library and of the other library facilities is otherwise permitted, and provided that you do these two things: + + a) Accompany the combined library with a copy of the same work based on the Library, uncombined with any other library facilities. This must be distributed under the terms of the Sections above. + + b) Give prominent notice with the combined library of the fact that part of it is a work based on the Library, and explaining where to find the accompanying uncombined form of the same work. + +8. You may not copy, modify, sublicense, link with, or distribute the Library except as expressly provided under this License. Any attempt otherwise to copy, modify, sublicense, link with, or distribute the Library is void, and will automatically terminate your rights under this License. However, parties who have received copies, or rights, from you under this License will not have their licenses terminated so long as such parties remain in full compliance. + +9. You are not required to accept this License, since you have not signed it. However, nothing else grants you permission to modify or distribute the Library or its derivative works. These actions are prohibited by law if you do not accept this License. Therefore, by modifying or distributing the Library (or any work based on the Library), you indicate your acceptance of this License to do so, and all its terms and conditions for copying, distributing or modifying the Library or works based on it. + +10. Each time you redistribute the Library (or any work based on the Library), the recipient automatically receives a license from the original licensor to copy, distribute, link with or modify the Library subject to these terms and conditions. You may not impose any further restrictions on the recipients' exercise of the rights granted herein. You are not responsible for enforcing compliance by third parties with this License. + +11. If, as a consequence of a court judgment or allegation of patent infringement or for any other reason (not limited to patent issues), conditions are imposed on you (whether by court order, agreement or otherwise) that contradict the conditions of this License, they do not excuse you from the conditions of this License. If you cannot distribute so as to satisfy simultaneously your obligations under this License and any other pertinent obligations, then as a consequence you may not distribute the Library at all. For example, if a patent license would not permit royalty-free redistribution of the Library by all those who receive copies directly or indirectly through you, then the only way you could satisfy both it and this License would be to refrain entirely from distribution of the Library. + +If any portion of this section is held invalid or unenforceable under any particular circumstance, the balance of the section is intended to apply, and the section as a whole is intended to apply in other circumstances. + +It is not the purpose of this section to induce you to infringe any patents or other property right claims or to contest validity of any such claims; this section has the sole purpose of protecting the integrity of the free software distribution system which is implemented by public license practices. Many people have made generous contributions to the wide range of software distributed through that system in reliance on consistent application of that system; it is up to the author/donor to decide if he or she is willing to distribute software through any other system and a licensee cannot impose that choice. + +This section is intended to make thoroughly clear what is believed to be a consequence of the rest of this License. + +12. If the distribution and/or use of the Library is restricted in certain countries either by patents or by copyrighted interfaces, the original copyright holder who places the Library under this License may add an explicit geographical distribution limitation excluding those countries, so that distribution is permitted only in or among countries not thus excluded. In such case, this License incorporates the limitation as if written in the body of this License. + +13. The Free Software Foundation may publish revised and/or new versions of the Lesser General Public License from time to time. Such new versions will be similar in spirit to the present version, but may differ in detail to address new problems or concerns. + +Each version is given a distinguishing version number. If the Library specifies a version number of this License which applies to it and "any later version", you have the option of following the terms and conditions either of that version or of any later version published by the Free Software Foundation. If the Library does not specify a license version number, you may choose any version ever published by the Free Software Foundation. + +14. If you wish to incorporate parts of the Library into other free programs whose distribution conditions are incompatible with these, write to the author to ask for permission. For software which is copyrighted by the Free Software Foundation, write to the Free Software Foundation; we sometimes make exceptions for this. Our decision will be guided by the two goals of preserving the free status of all derivatives of our free software and of promoting the sharing and reuse of software generally. + +NO WARRANTY + +15. BECAUSE THE LIBRARY IS LICENSED FREE OF CHARGE, THERE IS NO WARRANTY FOR THE LIBRARY, TO THE EXTENT PERMITTED BY APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR OTHER PARTIES PROVIDE THE LIBRARY "AS IS" WITHOUT WARRANTY OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE LIBRARY IS WITH YOU. SHOULD THE LIBRARY PROVE DEFECTIVE, YOU ASSUME THE COST OF ALL NECESSARY SERVICING, REPAIR OR CORRECTION. + +16. IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MAY MODIFY AND/OR REDISTRIBUTE THE LIBRARY AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE USE OR INABILITY TO USE THE LIBRARY (INCLUDING BUT NOT LIMITED TO LOSS OF DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD PARTIES OR A FAILURE OF THE LIBRARY TO OPERATE WITH ANY OTHER SOFTWARE), EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH DAMAGES. + +END OF TERMS AND CONDITIONS + +How to Apply These Terms to Your New Libraries + +If you develop a new library, and you want it to be of the greatest possible use to the public, we recommend making it free software that everyone can redistribute and change. You can do so by permitting redistribution under these terms (or, alternatively, under the terms of the ordinary General Public License). + +To apply these terms, attach the following notices to the library. It is safest to attach them to the start of each source file to most effectively convey the exclusion of warranty; and each file should have at least the "copyright" line and a pointer to where the full notice is found. + + one line to give the library's name and an idea of what it does. + Copyright (C) year name of author + + This library is free software; you can redistribute it and/or modify it under the terms of the GNU Lesser General Public License as published by the Free Software Foundation; either version 2.1 of the License, or (at your option) any later version. + + This library is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public License for more details. + + You should have received a copy of the GNU Lesser General Public License along with this library; if not, write to the Free Software Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA Also add information on how to contact you by electronic and paper mail. + +You should also get your employer (if you work as a programmer) or your school, if any, to sign a "copyright disclaimer" for the library, if necessary. Here is a sample; alter the names: + +Yoyodyne, Inc., hereby disclaims all copyright interest in +the library `Frob' (a library for tweaking knobs) written +by James Random Hacker. + +signature of Ty Coon, 1 April 1990 +Ty Coon, President of Vice +That's all there is to it! diff --git a/vendor/intx-mailbox/package.json b/vendor/intx-mailbox/package.json new file mode 100644 index 0000000..19bb135 --- /dev/null +++ b/vendor/intx-mailbox/package.json @@ -0,0 +1,35 @@ +{ + "name": "@intx/mailbox", + "description": "Storage-agnostic IMAP mailbox model with search, threading, and fetch projections", + "version": "0.3.0", + "license": "LGPL-2.1-only", + "type": "module", + "exports": { + ".": { + "types": "./src/index.ts", + "default": "./src/index.ts" + } + }, + "scripts": { + "typecheck": "tsc --noEmit" + }, + "dependencies": { + "@intx/crypto": "0.3.0", + "@intx/mime": "workspace:*", + "@intx/types": "workspace:*", + "arktype": "2.1.29" + }, + "devDependencies": { + "@types/bun": "1.1.14", + "typescript": "5.7.2" + }, + "files": [ + "src", + "README.md", + "LICENSE" + ], + "sideEffects": false, + "publishConfig": { + "access": "public" + } +} diff --git a/vendor/intx-mailbox/src/fetch.ts b/vendor/intx-mailbox/src/fetch.ts new file mode 100644 index 0000000..792c7d6 --- /dev/null +++ b/vendor/intx-mailbox/src/fetch.ts @@ -0,0 +1,250 @@ +/* eslint-disable @typescript-eslint/no-non-null-assertion -- MIME multipart parsing with bounds checks */ +import { type } from "arktype"; +import type { + MessageHeaders, + BodyStructure, + MessagePart, + InboundMessage, + SignatureStatus, + CryptoProvider, + MessageRef, +} from "@intx/types/runtime"; +import { InterchangeType } from "@intx/types/runtime"; +import { base64Decode } from "@intx/types"; +import type { MailboxStore } from "./mailbox"; +import { requireMessage } from "./mailbox"; +import { + parseHeaderSection, + parseMimePart, + extractBoundary, + parseMultipart, + extractPartByPath, + extractAttachments, +} from "@intx/mime"; +import { buildMessageHeaders } from "./headers"; +import { verifyDetachedSignature } from "@intx/crypto"; + +const MessagePayload = type({ + type: InterchangeType, + version: "string", + body: "Record", +}); + +/** + * Parse the full RFC 2822 headers of a stored message. Reads the message's raw + * bytes on demand: the parsed set is a superset of the pre-parsed envelope (it + * carries `cc`, `mimeVersion`, trace headers, ...), so it cannot be served from + * the envelope metadata alone. + */ +export async function fetchHeaders( + ref: MessageRef, + store: MailboxStore, +): Promise { + requireMessage(store, ref.uid, ref.mailbox); + const raw = await store.readRaw(ref.uid); + const { headers } = parseHeaderSection(raw); + return buildMessageHeaders(headers); +} + +/** + * Compute the MIME tree structure (BODYSTRUCTURE) without transferring content. + */ +export async function fetchStructure( + ref: MessageRef, + store: MailboxStore, +): Promise { + requireMessage(store, ref.uid, ref.mailbox); + const raw = await store.readRaw(ref.uid); + const { headers, bodyOffset } = parseHeaderSection(raw); + const body = raw.slice(bodyOffset); + const contentType = headers.get("content-type") ?? "application/octet-stream"; + return buildStructure(body, contentType); +} + +/** + * Fetch a single MIME part by dot-separated path. + */ +export async function fetchPart( + ref: MessageRef, + partPath: string, + store: MailboxStore, +): Promise { + requireMessage(store, ref.uid, ref.mailbox); + const raw = await store.readRaw(ref.uid); + const partBytes = extractPartByPath(raw, partPath); + const part = parseMimePart(partBytes); + + const enc = part.headers.get("content-transfer-encoding") ?? "7bit"; + let content: Uint8Array; + + if (enc.toLowerCase() === "base64") { + const b64 = new TextDecoder().decode(part.body).replace(/\s/g, ""); + content = base64Decode(b64); + } else { + content = part.body; + } + + const result: MessagePart = { + contentType: part.contentType, + content, + }; + if (enc !== "7bit") result.encoding = enc; + return result; +} + +/** + * Fetch a complete message, verify its PGP/MIME signature, and return + * a fully parsed InboundMessage. + */ +export async function fetchFull( + ref: MessageRef, + store: MailboxStore, + getCrypto: (fromAddress: string) => CryptoProvider | undefined, +): Promise { + const msg = requireMessage(store, ref.uid, ref.mailbox); + const raw = await store.readRaw(ref.uid); + const { headers } = parseHeaderSection(raw); + const parsedHeaders = buildMessageHeaders(headers); + + const rawType = parsedHeaders.interchangeType; + const isConversation = + rawType === "conversation.message" || + rawType === "conversation.join" || + rawType === "conversation.leave" || + rawType === undefined; + + const signatureStatus = await verifyMessageSignature( + raw, + parsedHeaders.from, + getCrypto, + ); + + const result: InboundMessage = { + ref, + headers: parsedHeaders, + flags: Array.from(msg.flags), + signatureStatus, + }; + + try { + if (isConversation) { + const part1 = parseMimePart(extractPartByPath(raw, "1")); + const part1Mime = part1.contentType.split(";")[0]!.trim().toLowerCase(); + if (part1Mime.startsWith("multipart/")) { + // Conversation shape: multipart/mixed with the text body at 1.1. + const textPart = parseMimePart(extractPartByPath(raw, "1.1")); + result.content = new TextDecoder("utf-8", { fatal: false }).decode( + textPart.body, + ); + } else { + // A conversation message is "literally a signed email", so a sender + // (e.g. a plain mail client) may sign a bare text/plain part with no + // multipart/mixed wrapper. This branch reads that body directly. Our + // own assembler always emits multipart/mixed; without this branch a + // bare text/plain message would fail the 1.1 lookup and silently lose + // its content to the catch below. + result.content = new TextDecoder("utf-8", { fatal: false }).decode( + part1.body, + ); + } + } else { + // Structured messages carry their JSON payload at 1.1. Attachments on + // structured messages are intentionally not parsed: they have no + // producer today, so parsing them would handle a shape nobody sends. + const part11Bytes = extractPartByPath(raw, "1.1"); + const part11 = parseMimePart(part11Bytes); + const jsonText = new TextDecoder("utf-8", { fatal: false }).decode( + part11.body, + ); + const validated = MessagePayload(JSON.parse(jsonText)); + if (validated instanceof type.errors) { + throw new Error(`invalid message payload: ${validated.summary}`); + } + result.payload = validated; + } + } catch { + // If we can't parse the content, return what we have with the signature status. + } + + // Attachment parsing is deliberately outside the catch above: a malformed + // attachment must surface as a thrown error, not be silently dropped. + if (isConversation) { + const attachments = extractAttachments(raw); + if (attachments.length > 0) { + result.attachments = attachments; + } + } + + return result; +} + +async function verifyMessageSignature( + raw: Uint8Array, + fromAddress: string, + getCrypto: (fromAddress: string) => CryptoProvider | undefined, +): Promise { + const senderCrypto = getCrypto(fromAddress); + if (senderCrypto === undefined) { + return "unknown"; + } + + try { + const { headers, bodyOffset } = parseHeaderSection(raw); + const body = raw.slice(bodyOffset); + const contentType = headers.get("content-type") ?? ""; + + if (!contentType.toLowerCase().includes("multipart/signed")) { + return "missing"; + } + + const boundary = extractBoundary(contentType); + if (boundary === undefined) return "missing"; + + const parts = parseMultipart(body, boundary); + if (parts.length < 2) return "missing"; + + const signedContentBytes = parts[0]!; + const sigPartBytes = parts[1]!; + const sigPart = parseMimePart(sigPartBytes); + + if ( + !sigPart.contentType.toLowerCase().includes("application/pgp-signature") + ) { + return "missing"; + } + + const publicKey = senderCrypto.getPublicKey(); + const valid = await verifyDetachedSignature( + signedContentBytes, + sigPart.body, + publicKey, + ); + + return valid ? "valid" : "invalid"; + } catch { + return "invalid"; + } +} + +function buildStructure(body: Uint8Array, contentType: string): BodyStructure { + const ct = contentType.toLowerCase(); + if (!ct.startsWith("multipart/")) { + return { contentType, size: body.length }; + } + + const boundary = extractBoundary(contentType); + if (boundary === undefined) { + return { contentType, size: body.length }; + } + + const parts = parseMultipart(body, boundary); + const subStructures: BodyStructure[] = parts.map((partBytes) => { + const { headers, bodyOffset } = parseHeaderSection(partBytes); + const partBody = partBytes.slice(bodyOffset); + const partContentType = + headers.get("content-type") ?? "application/octet-stream"; + return buildStructure(partBody, partContentType); + }); + + return { contentType, parts: subStructures }; +} diff --git a/vendor/intx-mailbox/src/headers.ts b/vendor/intx-mailbox/src/headers.ts new file mode 100644 index 0000000..0e40211 --- /dev/null +++ b/vendor/intx-mailbox/src/headers.ts @@ -0,0 +1,5 @@ +// `buildMessageHeaders` now lives in `@intx/mime` alongside the rest of the +// MIME/header parsing (it is also what the `decodeMail` decoder builds its +// typed header subset with). Re-exported here so mail-memory's callers keep +// their existing import path. +export { buildMessageHeaders } from "@intx/mime"; diff --git a/vendor/intx-mailbox/src/index.ts b/vendor/intx-mailbox/src/index.ts new file mode 100644 index 0000000..efc103b --- /dev/null +++ b/vendor/intx-mailbox/src/index.ts @@ -0,0 +1,11 @@ +export { + DEFAULT_MAILBOXES, + createInMemoryMailboxStore, + requireMessage, +} from "./mailbox"; +export type { MailboxStore, StoredMessage, StoredEnvelope } from "./mailbox"; + +export { executeSearch } from "./search"; +export { executeThread } from "./thread"; +export { fetchHeaders, fetchStructure, fetchPart, fetchFull } from "./fetch"; +export { buildMessageHeaders } from "./headers"; diff --git a/vendor/intx-mailbox/src/mailbox.ts b/vendor/intx-mailbox/src/mailbox.ts new file mode 100644 index 0000000..05a3e94 --- /dev/null +++ b/vendor/intx-mailbox/src/mailbox.ts @@ -0,0 +1,192 @@ +/** + * Pre-parsed envelope extracted from MIME headers at delivery time. + * Avoids re-parsing raw bytes for every search operation. + */ +export type StoredEnvelope = { + messageId: string; + from: string; + to: string[]; + subject: string; + date: Date; + inReplyTo: string | undefined; + references: string[]; + interchangeType: string | undefined; + interchangeCorrelationId: string | undefined; +}; + +/** + * A single stored message's resident model: its uid, the IMAP counters, its + * flags, and the pre-parsed envelope. The complete RFC 2822 bytes are NOT + * resident here; they are read on demand through `MailboxStore.readRaw`, so a + * backing can bound its in-memory footprint to metadata and keep the raw bytes + * on disk (the substrate backing) or retain them itself (the in-memory + * backing). The projections that need the bytes -- `fetchFull`, `fetchPart`, + * `fetchStructure`, `fetchHeaders`, and the raw-scanning search predicates -- + * route through `readRaw`, which returns the verbatim bytes so signature + * verification stays byte-exact. + */ +export type StoredMessage = { + uid: number; + modseq: number; + flags: Set; + envelope: StoredEnvelope; +}; + +/** + * Storage-agnostic per-mailbox model. A backing owns how the message list and + * the uid/modseq/uidValidity counters are stored; the pure query and + * projection functions (search, thread, fetch, bodystructure, headers) read + * the message snapshot the backing exposes through `messages`, and read a + * message's raw bytes on demand through `readRaw`. + * + * The counters follow IMAP semantics: `uidNext` is the UID that the next + * `append` will assign (UIDNEXT), `highestModSeq` is the largest MODSEQ + * currently assigned (HIGHESTMODSEQ), and `uidValidity` is stable for the + * lifetime of the mailbox (UIDVALIDITY). + */ +export interface MailboxStore { + readonly uidValidity: number; + readonly uidNext: number; + readonly highestModSeq: number; + readonly messages: readonly StoredMessage[]; + + /** + * Store a message, assigning it the next UID and MODSEQ. Returns the + * assigned UID. The backing decides whether to retain `raw` in memory or + * persist it and serve it from disk through `readRaw`. + */ + append(raw: Uint8Array, envelope: StoredEnvelope, flags: string[]): number; + + /** + * Read a stored message's verbatim RFC 2822 bytes. Resolves the bytes from + * wherever the backing keeps them (memory or disk). Throws if no message has + * the given UID. + */ + readRaw(uid: number): Promise; + + /** Locate a stored message by UID, or `undefined` if none matches. */ + find(uid: number): StoredMessage | undefined; + + /** + * Add flags to a stored message and advance its MODSEQ. Returns the updated + * message. Throws if no message has the given UID. + */ + addFlags(uid: number, flags: string[]): StoredMessage; + + /** + * Remove flags from a stored message and advance its MODSEQ. Returns the + * updated message. Throws if no message has the given UID. + */ + removeFlags(uid: number, flags: string[]): StoredMessage; + + /** Drop a stored message by UID. Throws if no message has the given UID. */ + remove(uid: number): void; +} + +/** + * The default set of mailboxes created for a freshly registered address. + */ +export const DEFAULT_MAILBOXES = [ + "INBOX", + "Sent", + "Drafts", + "Archive", + "Trash", +] as const; + +/** + * Create an in-memory `MailboxStore` backing. Messages, counters, and + * uidValidity live in process memory for the lifetime of the returned store. + */ +export function createInMemoryMailboxStore(): MailboxStore { + const messages: StoredMessage[] = []; + // The in-memory backing is its own durable store, so it legitimately retains + // every message's raw bytes. `readRaw` returns them; the metadata mirror in + // `messages` stays free of the bytes so the read model matches the + // disk-backed backing. + const rawByUid = new Map(); + let uidCounter = 1; + let modseqCounter = 1; + const uidValidity = Date.now(); + + function find(uid: number): StoredMessage | undefined { + return messages.find((m) => m.uid === uid); + } + + function require(uid: number): StoredMessage { + const msg = find(uid); + if (msg === undefined) { + throw new Error(`Message UID ${uid} not found`); + } + return msg; + } + + return { + uidValidity, + get uidNext() { + return uidCounter; + }, + get highestModSeq() { + return modseqCounter - 1; + }, + get messages() { + return messages; + }, + append(raw, envelope, flags) { + const uid = uidCounter++; + const modseq = modseqCounter++; + messages.push({ uid, modseq, flags: new Set(flags), envelope }); + rawByUid.set(uid, raw); + return uid; + }, + readRaw(uid) { + const raw = rawByUid.get(uid); + if (raw === undefined) { + return Promise.reject(new Error(`Message UID ${uid} not found`)); + } + return Promise.resolve(raw); + }, + find, + addFlags(uid, flags) { + const msg = require(uid); + for (const flag of flags) { + msg.flags.add(flag); + } + msg.modseq = modseqCounter++; + return msg; + }, + removeFlags(uid, flags) { + const msg = require(uid); + for (const flag of flags) { + msg.flags.delete(flag); + } + msg.modseq = modseqCounter++; + return msg; + }, + remove(uid) { + const idx = messages.findIndex((m) => m.uid === uid); + if (idx === -1) { + throw new Error(`Message UID ${uid} not found`); + } + messages.splice(idx, 1); + rawByUid.delete(uid); + }, + }; +} + +/** + * Locate a stored message by UID, throwing a mailbox-qualified error when it + * is absent. Used by the fetch projections, which resolve a `MessageRef` + * against a specific mailbox. + */ +export function requireMessage( + store: MailboxStore, + uid: number, + mailboxName: string, +): StoredMessage { + const msg = store.find(uid); + if (msg === undefined) { + throw new Error(`Message UID ${uid} not found in mailbox "${mailboxName}"`); + } + return msg; +} diff --git a/vendor/intx-mailbox/src/search.ts b/vendor/intx-mailbox/src/search.ts new file mode 100644 index 0000000..4efc54c --- /dev/null +++ b/vendor/intx-mailbox/src/search.ts @@ -0,0 +1,208 @@ +import type { SearchQuery, MessageRef } from "@intx/types/runtime"; +import type { MailboxStore, StoredMessage } from "./mailbox"; +import { parseHeaderSection } from "@intx/mime"; + +/** + * Execute an IMAP SEARCH-equivalent query over a mailbox. + * + * Supports: from, to, cc, bcc, header (field match), before/after/on, + * sentBefore/sentAfter/sentOn, hasFlags, missingFlags, body, text, + * largerThan, smallerThan, and boolean and/or/not composition. + * + * The envelope- and flag-based predicates (from, to, dates, flags, boolean + * composition) resolve from metadata alone. The predicates that inspect + * headers the envelope does not carry (cc, bcc, arbitrary `header`), the body, + * or the raw size (body, text, largerThan, smallerThan) read a message's raw + * bytes on demand through `store.readRaw`, memoized per message so a query that + * touches raw reads each candidate's blob at most once. A query with no + * raw-scanning predicate never reads a blob. + * + * Returns MessageRef[] for all matching messages, ordered by UID. + */ +export async function executeSearch( + mailboxName: string, + store: MailboxStore, + query: SearchQuery, +): Promise { + const results: MessageRef[] = []; + for (const msg of store.messages) { + if (await matchMessage(msg, query, makeRawReader(store, msg.uid))) { + results.push({ uid: msg.uid, mailbox: mailboxName }); + } + } + return results; +} + +/** + * A per-message memoized reader for the raw bytes. The first raw-scanning + * predicate reads the blob through `store.readRaw`; every later predicate on + * the same message reuses the resolved bytes. + */ +function makeRawReader( + store: MailboxStore, + uid: number, +): () => Promise { + let pending: Promise | undefined; + return () => { + if (pending === undefined) pending = store.readRaw(uid); + return pending; + }; +} + +async function matchMessage( + msg: StoredMessage, + query: SearchQuery, + readRaw: () => Promise, +): Promise { + if (query.from !== undefined) { + if (!msg.envelope.from.toLowerCase().includes(query.from.toLowerCase())) { + return false; + } + } + + if (query.to !== undefined) { + const queryTo = query.to; + const toMatch = msg.envelope.to.some((addr) => + addr.toLowerCase().includes(queryTo.toLowerCase()), + ); + if (!toMatch) return false; + } + + if (query.cc !== undefined) { + const headers = await lazyHeaders(msg, readRaw); + const ccHeader = headers.get("cc") ?? ""; + if (!ccHeader.toLowerCase().includes(query.cc.toLowerCase())) { + return false; + } + } + + if (query.bcc !== undefined) { + const headers = await lazyHeaders(msg, readRaw); + const bccHeader = headers.get("bcc") ?? ""; + if (!bccHeader.toLowerCase().includes(query.bcc.toLowerCase())) { + return false; + } + } + + if (query.header !== undefined) { + const { field, contains } = query.header; + const headers = await lazyHeaders(msg, readRaw); + const value = headers.get(field.toLowerCase()) ?? ""; + if (!value.toLowerCase().includes(contains.toLowerCase())) { + return false; + } + } + + if (query.before !== undefined) { + if (msg.envelope.date >= query.before) return false; + } + if (query.after !== undefined) { + if (msg.envelope.date <= query.after) return false; + } + if (query.on !== undefined) { + const d = msg.envelope.date; + const q = query.on; + if ( + d.getUTCFullYear() !== q.getUTCFullYear() || + d.getUTCMonth() !== q.getUTCMonth() || + d.getUTCDate() !== q.getUTCDate() + ) { + return false; + } + } + + // Sent date filters use the Date header (same as envelope date here). + if (query.sentBefore !== undefined) { + if (msg.envelope.date >= query.sentBefore) return false; + } + if (query.sentAfter !== undefined) { + if (msg.envelope.date <= query.sentAfter) return false; + } + if (query.sentOn !== undefined) { + const d = msg.envelope.date; + const q = query.sentOn; + if ( + d.getUTCFullYear() !== q.getUTCFullYear() || + d.getUTCMonth() !== q.getUTCMonth() || + d.getUTCDate() !== q.getUTCDate() + ) { + return false; + } + } + + if (query.hasFlags !== undefined) { + for (const flag of query.hasFlags) { + if (!msg.flags.has(flag)) return false; + } + } + + if (query.missingFlags !== undefined) { + for (const flag of query.missingFlags) { + if (msg.flags.has(flag)) return false; + } + } + + if (query.largerThan !== undefined) { + if ((await readRaw()).length <= query.largerThan) return false; + } + if (query.smallerThan !== undefined) { + if ((await readRaw()).length >= query.smallerThan) return false; + } + + if (query.body !== undefined || query.text !== undefined) { + const raw = await readRaw(); + const rawText = new TextDecoder("utf-8", { fatal: false }).decode(raw); + if (query.body !== undefined) { + const { bodyOffset } = parseHeaderSection(raw); + const bodyText = new TextDecoder("utf-8", { fatal: false }).decode( + raw.slice(bodyOffset), + ); + if (!bodyText.toLowerCase().includes(query.body.toLowerCase())) { + return false; + } + } + if (query.text !== undefined) { + if (!rawText.toLowerCase().includes(query.text.toLowerCase())) { + return false; + } + } + } + + if (query.and !== undefined) { + for (const sub of query.and) { + if (!(await matchMessage(msg, sub, readRaw))) return false; + } + } + + if (query.or !== undefined) { + if (query.or.length > 0) { + let anyMatch = false; + for (const sub of query.or) { + if (await matchMessage(msg, sub, readRaw)) { + anyMatch = true; + break; + } + } + if (!anyMatch) return false; + } + } + + if (query.not !== undefined) { + if (await matchMessage(msg, query.not, readRaw)) return false; + } + + return true; +} + +const headerCache = new WeakMap>(); + +async function lazyHeaders( + msg: StoredMessage, + readRaw: () => Promise, +): Promise> { + const cached = headerCache.get(msg); + if (cached !== undefined) return cached; + const { headers } = parseHeaderSection(await readRaw()); + headerCache.set(msg, headers); + return headers; +} diff --git a/vendor/intx-mailbox/src/thread.ts b/vendor/intx-mailbox/src/thread.ts new file mode 100644 index 0000000..0245579 --- /dev/null +++ b/vendor/intx-mailbox/src/thread.ts @@ -0,0 +1,275 @@ +/* eslint-disable @typescript-eslint/no-non-null-assertion -- Map.get()! after has() checks in threading algorithm */ +import type { Thread, SearchQuery } from "@intx/types/runtime"; +import type { MailboxStore, StoredMessage } from "./mailbox"; +import { executeSearch } from "./search"; + +/** + * RFC 5256 REFERENCES threading algorithm. + * + * Builds parent-child relationships from In-Reply-To and References headers. + * The algorithm: + * 1. For each message, collect its References chain (oldest → newest ancestor). + * 2. Link messages into a tree using these chains. + * 3. Create dummy containers for referenced messages not present in the set. + * 4. Prune dummy containers with no children; promote children of childless dummies. + * 5. Gather root-level containers with the same base subject (skipped here — + * we implement only the parent/child linking portion which is what this + * transport needs; subject-based gathering is optional for our use case). + * 6. Sort threads at each level. + * + * Note: RFC 5256 also defines an ORDEREDSUBJECT algorithm. For that, messages + * are sorted by subject and date without reference tracking. + */ + +type Container = { + messageId: string; + message: StoredMessage | null; + parent: Container | null; + children: Container[]; +}; + +export async function executeThread( + mailboxName: string, + store: MailboxStore, + algorithm: "references" | "orderedsubject", + query?: SearchQuery, +): Promise { + let messages: StoredMessage[]; + + if (query !== undefined) { + const refs = await executeSearch(mailboxName, store, query); + const uidSet = new Set(refs.map((r) => r.uid)); + messages = store.messages.filter((m) => uidSet.has(m.uid)); + } else { + messages = [...store.messages]; + } + + if (messages.length === 0) return []; + + if (algorithm === "orderedsubject") { + return orderedSubjectThread(mailboxName, messages); + } + + return referencesThread(mailboxName, messages); +} + +/** + * RFC 5256 ORDEREDSUBJECT: sort by base subject, then date. + * All messages with the same base subject form one thread; the first by date + * is the root, the rest are direct children. + */ +function orderedSubjectThread( + mailboxName: string, + messages: StoredMessage[], +): Thread[] { + const bySubject = new Map(); + + for (const msg of messages) { + const base = baseSubject(msg.envelope.subject); + const bucket = bySubject.get(base); + if (bucket === undefined) { + bySubject.set(base, [msg]); + } else { + bucket.push(msg); + } + } + + const threads: Thread[] = []; + for (const [, msgs] of bySubject) { + const sorted = msgs.sort( + (a, b) => a.envelope.date.getTime() - b.envelope.date.getTime(), + ); + const root = sorted[0]!; + const rootThread: Thread = { + ref: { uid: root.uid, mailbox: mailboxName }, + children: sorted.slice(1).map((m) => ({ + ref: { uid: m.uid, mailbox: mailboxName }, + children: [], + })), + }; + threads.push(rootThread); + } + + return threads.sort((a, b) => { + const aMsg = messages.find((m) => m.uid === a.ref.uid)!; + const bMsg = messages.find((m) => m.uid === b.ref.uid)!; + return aMsg.envelope.date.getTime() - bMsg.envelope.date.getTime(); + }); +} + +/** + * RFC 5256 REFERENCES algorithm. + * + * Step 1: For each message, create a container. Walk its References list + * (and In-Reply-To if not already in References) and link containers + * as parent-child in left-to-right order. + * + * Step 2: Build the id_table mapping Message-IDs to containers. + * + * Step 3: Prune empty containers (those with no message). + * + * Step 4: Collect root containers. + * + * Step 5: Sort each container's children by date. + */ +function referencesThread( + mailboxName: string, + messages: StoredMessage[], +): Thread[] { + const idTable = new Map(); + + function getOrCreate(msgId: string): Container { + const existing = idTable.get(msgId); + if (existing !== undefined) return existing; + const c: Container = { + messageId: msgId, + message: null, + parent: null, + children: [], + }; + idTable.set(msgId, c); + return c; + } + + // Step 1 & 2: Build containers and link parent-child relationships. + for (const msg of messages) { + const container = getOrCreate(msg.envelope.messageId); + container.message = msg; + + // Build the reference list: References + In-Reply-To (deduplicated). + const refs = buildRefList(msg.envelope.references, msg.envelope.inReplyTo); + + // Link: refs[i] is parent of refs[i+1], last ref is parent of this message. + let prevContainer: Container | null = null; + for (const refId of refs) { + const refContainer = getOrCreate(refId); + + if ( + prevContainer !== null && + refContainer.parent === null && + !isAncestor(refContainer, prevContainer) + ) { + prevContainer.children.push(refContainer); + refContainer.parent = prevContainer; + } + + prevContainer = refContainer; + } + + // Link the last reference as parent of this message (if no circular reference). + if ( + prevContainer !== null && + container.parent === null && + !isAncestor(container, prevContainer) + ) { + prevContainer.children.push(container); + container.parent = prevContainer; + } + } + + // Step 3: Find root containers (no parent). + const roots: Container[] = []; + for (const [, c] of idTable) { + if (c.parent === null) { + roots.push(c); + } + } + + // Step 4: Prune dummy containers (containers with no message). + // A dummy with no children is dropped. + // A dummy with children: the children are promoted to the dummy's parent level. + const prunedRoots = pruneContainers(roots); + + // Step 5: Sort and convert to Thread[]. + return containersToThreads(mailboxName, prunedRoots); +} + +function buildRefList(references: string[], inReplyTo?: string): string[] { + const seen = new Set(); + const result: string[] = []; + + for (const ref of references) { + if (ref && !seen.has(ref)) { + seen.add(ref); + result.push(ref); + } + } + + if (inReplyTo !== undefined && inReplyTo !== "" && !seen.has(inReplyTo)) { + result.push(inReplyTo); + } + + return result; +} + +function isAncestor(potentialAncestor: Container, of: Container): boolean { + let cur: Container | null = of; + while (cur !== null) { + if (cur === potentialAncestor) return true; + cur = cur.parent; + } + return false; +} + +function pruneContainers(containers: Container[]): Container[] { + const result: Container[] = []; + for (const c of containers) { + if (c.message === null && c.children.length === 0) { + // Dummy with no children: drop it. + continue; + } + if (c.message === null && c.children.length > 0) { + // Dummy with children: promote children (skip the dummy). + const promotedChildren = pruneContainers(c.children); + result.push(...promotedChildren); + } else { + // Real message: recurse into children. + c.children = pruneContainers(c.children); + result.push(c); + } + } + return result; +} + +function containerDate(c: Container): number { + if (c.message !== null) { + return c.message.envelope.date.getTime(); + } + // For dummy containers, use the earliest child date. + let earliest = Infinity; + for (const child of c.children) { + const d = containerDate(child); + if (d < earliest) earliest = d; + } + return earliest === Infinity ? 0 : earliest; +} + +function containersToThreads( + mailboxName: string, + containers: Container[], +): Thread[] { + // Sort by date of the container (or earliest descendant for dummies). + const sorted = containers.sort((a, b) => containerDate(a) - containerDate(b)); + + return sorted + .filter((c) => c.message !== null) + .map((c) => ({ + ref: { uid: c.message!.uid, mailbox: mailboxName }, + children: containersToThreads(mailboxName, c.children), + })); +} + +function baseSubject(subject: string): string { + // Strip "Re:", "Fwd:", "Fw:" prefixes (case-insensitive) repeatedly. + let s = subject.trim(); + let changed = true; + while (changed) { + changed = false; + const m = s.match(/^(?:re|fwd?)\s*:\s*/i); + if (m !== null) { + s = s.slice(m[0].length).trim(); + changed = true; + } + } + return s; +} diff --git a/vendor/intx-mime/LICENSE b/vendor/intx-mime/LICENSE new file mode 100644 index 0000000..c6487f4 --- /dev/null +++ b/vendor/intx-mime/LICENSE @@ -0,0 +1,176 @@ +GNU LESSER GENERAL PUBLIC LICENSE + +Version 2.1, February 1999 + +Copyright (C) 1991, 1999 Free Software Foundation, Inc. +51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA + +Everyone is permitted to copy and distribute verbatim copies of this license document, but changing it is not allowed. + +[This is the first released version of the Lesser GPL. It also counts as the successor of the GNU Library Public License, version 2, hence the version number 2.1.] + +Preamble + +The licenses for most software are designed to take away your freedom to share and change it. By contrast, the GNU General Public Licenses are intended to guarantee your freedom to share and change free software--to make sure the software is free for all its users. + +This license, the Lesser General Public License, applies to some specially designated software packages--typically libraries--of the Free Software Foundation and other authors who decide to use it. You can use it too, but we suggest you first think carefully about whether this license or the ordinary General Public License is the better strategy to use in any particular case, based on the explanations below. + +When we speak of free software, we are referring to freedom of use, not price. Our General Public Licenses are designed to make sure that you have the freedom to distribute copies of free software (and charge for this service if you wish); that you receive source code or can get it if you want it; that you can change the software and use pieces of it in new free programs; and that you are informed that you can do these things. + +To protect your rights, we need to make restrictions that forbid distributors to deny you these rights or to ask you to surrender these rights. These restrictions translate to certain responsibilities for you if you distribute copies of the library or if you modify it. + +For example, if you distribute copies of the library, whether gratis or for a fee, you must give the recipients all the rights that we gave you. You must make sure that they, too, receive or can get the source code. If you link other code with the library, you must provide complete object files to the recipients, so that they can relink them with the library after making changes to the library and recompiling it. And you must show them these terms so they know their rights. + +We protect your rights with a two-step method: (1) we copyright the library, and (2) we offer you this license, which gives you legal permission to copy, distribute and/or modify the library. + +To protect each distributor, we want to make it very clear that there is no warranty for the free library. Also, if the library is modified by someone else and passed on, the recipients should know that what they have is not the original version, so that the original author's reputation will not be affected by problems that might be introduced by others. + +Finally, software patents pose a constant threat to the existence of any free program. We wish to make sure that a company cannot effectively restrict the users of a free program by obtaining a restrictive license from a patent holder. Therefore, we insist that any patent license obtained for a version of the library must be consistent with the full freedom of use specified in this license. + +Most GNU software, including some libraries, is covered by the ordinary GNU General Public License. This license, the GNU Lesser General Public License, applies to certain designated libraries, and is quite different from the ordinary General Public License. We use this license for certain libraries in order to permit linking those libraries into non-free programs. + +When a program is linked with a library, whether statically or using a shared library, the combination of the two is legally speaking a combined work, a derivative of the original library. The ordinary General Public License therefore permits such linking only if the entire combination fits its criteria of freedom. The Lesser General Public License permits more lax criteria for linking other code with the library. + +We call this license the "Lesser" General Public License because it does Less to protect the user's freedom than the ordinary General Public License. It also provides other free software developers Less of an advantage over competing non-free programs. These disadvantages are the reason we use the ordinary General Public License for many libraries. However, the Lesser license provides advantages in certain special circumstances. + +For example, on rare occasions, there may be a special need to encourage the widest possible use of a certain library, so that it becomes a de-facto standard. To achieve this, non-free programs must be allowed to use the library. A more frequent case is that a free library does the same job as widely used non-free libraries. In this case, there is little to gain by limiting the free library to free software only, so we use the Lesser General Public License. + +In other cases, permission to use a particular library in non-free programs enables a greater number of people to use a large body of free software. For example, permission to use the GNU C Library in non-free programs enables many more people to use the whole GNU operating system, as well as its variant, the GNU/Linux operating system. + +Although the Lesser General Public License is Less protective of the users' freedom, it does ensure that the user of a program that is linked with the Library has the freedom and the wherewithal to run that program using a modified version of the Library. + +The precise terms and conditions for copying, distribution and modification follow. Pay close attention to the difference between a "work based on the library" and a "work that uses the library". The former contains code derived from the library, whereas the latter must be combined with the library in order to run. + +GNU LESSER GENERAL PUBLIC LICENSE +TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION + +0. This License Agreement applies to any software library or other program which contains a notice placed by the copyright holder or other authorized party saying it may be distributed under the terms of this Lesser General Public License (also called "this License"). Each licensee is addressed as "you". + +A "library" means a collection of software functions and/or data prepared so as to be conveniently linked with application programs (which use some of those functions and data) to form executables. + +The "Library", below, refers to any such software library or work which has been distributed under these terms. A "work based on the Library" means either the Library or any derivative work under copyright law: that is to say, a work containing the Library or a portion of it, either verbatim or with modifications and/or translated straightforwardly into another language. (Hereinafter, translation is included without limitation in the term "modification".) + +"Source code" for a work means the preferred form of the work for making modifications to it. For a library, complete source code means all the source code for all modules it contains, plus any associated interface definition files, plus the scripts used to control compilation and installation of the library. + +Activities other than copying, distribution and modification are not covered by this License; they are outside its scope. The act of running a program using the Library is not restricted, and output from such a program is covered only if its contents constitute a work based on the Library (independent of the use of the Library in a tool for writing it). Whether that is true depends on what the Library does and what the program that uses the Library does. + +1. You may copy and distribute verbatim copies of the Library's complete source code as you receive it, in any medium, provided that you conspicuously and appropriately publish on each copy an appropriate copyright notice and disclaimer of warranty; keep intact all the notices that refer to this License and to the absence of any warranty; and distribute a copy of this License along with the Library. + +You may charge a fee for the physical act of transferring a copy, and you may at your option offer warranty protection in exchange for a fee. + +2. You may modify your copy or copies of the Library or any portion of it, thus forming a work based on the Library, and copy and distribute such modifications or work under the terms of Section 1 above, provided that you also meet all of these conditions: + + a) The modified work must itself be a software library. + + b) You must cause the files modified to carry prominent notices stating that you changed the files and the date of any change. + + c) You must cause the whole of the work to be licensed at no charge to all third parties under the terms of this License. + + d) If a facility in the modified Library refers to a function or a table of data to be supplied by an application program that uses the facility, other than as an argument passed when the facility is invoked, then you must make a good faith effort to ensure that, in the event an application does not supply such function or table, the facility still operates, and performs whatever part of its purpose remains meaningful. + +(For example, a function in a library to compute square roots has a purpose that is entirely well-defined independent of the application. Therefore, Subsection 2d requires that any application-supplied function or table used by this function must be optional: if the application does not supply it, the square root function must still compute square roots.) + +These requirements apply to the modified work as a whole. If identifiable sections of that work are not derived from the Library, and can be reasonably considered independent and separate works in themselves, then this License, and its terms, do not apply to those sections when you distribute them as separate works. But when you distribute the same sections as part of a whole which is a work based on the Library, the distribution of the whole must be on the terms of this License, whose permissions for other licensees extend to the entire whole, and thus to each and every part regardless of who wrote it. + +Thus, it is not the intent of this section to claim rights or contest your rights to work written entirely by you; rather, the intent is to exercise the right to control the distribution of derivative or collective works based on the Library. + +In addition, mere aggregation of another work not based on the Library with the Library (or with a work based on the Library) on a volume of a storage or distribution medium does not bring the other work under the scope of this License. + +3. You may opt to apply the terms of the ordinary GNU General Public License instead of this License to a given copy of the Library. To do this, you must alter all the notices that refer to this License, so that they refer to the ordinary GNU General Public License, version 2, instead of to this License. (If a newer version than version 2 of the ordinary GNU General Public License has appeared, then you can specify that version instead if you wish.) Do not make any other change in these notices. + +Once this change is made in a given copy, it is irreversible for that copy, so the ordinary GNU General Public License applies to all subsequent copies and derivative works made from that copy. + +This option is useful when you wish to copy part of the code of the Library into a program that is not a library. + +4. You may copy and distribute the Library (or a portion or derivative of it, under Section 2) in object code or executable form under the terms of Sections 1 and 2 above provided that you accompany it with the complete corresponding machine-readable source code, which must be distributed under the terms of Sections 1 and 2 above on a medium customarily used for software interchange. + +If distribution of object code is made by offering access to copy from a designated place, then offering equivalent access to copy the source code from the same place satisfies the requirement to distribute the source code, even though third parties are not compelled to copy the source along with the object code. + +5. A program that contains no derivative of any portion of the Library, but is designed to work with the Library by being compiled or linked with it, is called a "work that uses the Library". Such a work, in isolation, is not a derivative work of the Library, and therefore falls outside the scope of this License. + +However, linking a "work that uses the Library" with the Library creates an executable that is a derivative of the Library (because it contains portions of the Library), rather than a "work that uses the library". The executable is therefore covered by this License. Section 6 states terms for distribution of such executables. + +When a "work that uses the Library" uses material from a header file that is part of the Library, the object code for the work may be a derivative work of the Library even though the source code is not. Whether this is true is especially significant if the work can be linked without the Library, or if the work is itself a library. The threshold for this to be true is not precisely defined by law. + +If such an object file uses only numerical parameters, data structure layouts and accessors, and small macros and small inline functions (ten lines or less in length), then the use of the object file is unrestricted, regardless of whether it is legally a derivative work. (Executables containing this object code plus portions of the Library will still fall under Section 6.) + +Otherwise, if the work is a derivative of the Library, you may distribute the object code for the work under the terms of Section 6. Any executables containing that work also fall under Section 6, whether or not they are linked directly with the Library itself. + +6. As an exception to the Sections above, you may also combine or link a "work that uses the Library" with the Library to produce a work containing portions of the Library, and distribute that work under terms of your choice, provided that the terms permit modification of the work for the customer's own use and reverse engineering for debugging such modifications. + +You must give prominent notice with each copy of the work that the Library is used in it and that the Library and its use are covered by this License. You must supply a copy of this License. If the work during execution displays copyright notices, you must include the copyright notice for the Library among them, as well as a reference directing the user to the copy of this License. Also, you must do one of these things: + + a) Accompany the work with the complete corresponding machine-readable source code for the Library including whatever changes were used in the work (which must be distributed under Sections 1 and 2 above); and, if the work is an executable linked with the Library, with the complete machine-readable "work that uses the Library", as object code and/or source code, so that the user can modify the Library and then relink to produce a modified executable containing the modified Library. (It is understood that the user who changes the contents of definitions files in the Library will not necessarily be able to recompile the application to use the modified definitions.) + + b) Use a suitable shared library mechanism for linking with the Library. A suitable mechanism is one that (1) uses at run time a copy of the library already present on the user's computer system, rather than copying library functions into the executable, and (2) will operate properly with a modified version of the library, if the user installs one, as long as the modified version is interface-compatible with the version that the work was made with. + + c) Accompany the work with a written offer, valid for at least three years, to give the same user the materials specified in Subsection 6a, above, for a charge no more than the cost of performing this distribution. + + d) If distribution of the work is made by offering access to copy from a designated place, offer equivalent access to copy the above specified materials from the same place. + + e) Verify that the user has already received a copy of these materials or that you have already sent this user a copy. + +For an executable, the required form of the "work that uses the Library" must include any data and utility programs needed for reproducing the executable from it. However, as a special exception, the materials to be distributed need not include anything that is normally distributed (in either source or binary form) with the major components (compiler, kernel, and so on) of the operating system on which the executable runs, unless that component itself accompanies the executable. + +It may happen that this requirement contradicts the license restrictions of other proprietary libraries that do not normally accompany the operating system. Such a contradiction means you cannot use both them and the Library together in an executable that you distribute. + +7. You may place library facilities that are a work based on the Library side-by-side in a single library together with other library facilities not covered by this License, and distribute such a combined library, provided that the separate distribution of the work based on the Library and of the other library facilities is otherwise permitted, and provided that you do these two things: + + a) Accompany the combined library with a copy of the same work based on the Library, uncombined with any other library facilities. This must be distributed under the terms of the Sections above. + + b) Give prominent notice with the combined library of the fact that part of it is a work based on the Library, and explaining where to find the accompanying uncombined form of the same work. + +8. You may not copy, modify, sublicense, link with, or distribute the Library except as expressly provided under this License. Any attempt otherwise to copy, modify, sublicense, link with, or distribute the Library is void, and will automatically terminate your rights under this License. However, parties who have received copies, or rights, from you under this License will not have their licenses terminated so long as such parties remain in full compliance. + +9. You are not required to accept this License, since you have not signed it. However, nothing else grants you permission to modify or distribute the Library or its derivative works. These actions are prohibited by law if you do not accept this License. Therefore, by modifying or distributing the Library (or any work based on the Library), you indicate your acceptance of this License to do so, and all its terms and conditions for copying, distributing or modifying the Library or works based on it. + +10. Each time you redistribute the Library (or any work based on the Library), the recipient automatically receives a license from the original licensor to copy, distribute, link with or modify the Library subject to these terms and conditions. You may not impose any further restrictions on the recipients' exercise of the rights granted herein. You are not responsible for enforcing compliance by third parties with this License. + +11. If, as a consequence of a court judgment or allegation of patent infringement or for any other reason (not limited to patent issues), conditions are imposed on you (whether by court order, agreement or otherwise) that contradict the conditions of this License, they do not excuse you from the conditions of this License. If you cannot distribute so as to satisfy simultaneously your obligations under this License and any other pertinent obligations, then as a consequence you may not distribute the Library at all. For example, if a patent license would not permit royalty-free redistribution of the Library by all those who receive copies directly or indirectly through you, then the only way you could satisfy both it and this License would be to refrain entirely from distribution of the Library. + +If any portion of this section is held invalid or unenforceable under any particular circumstance, the balance of the section is intended to apply, and the section as a whole is intended to apply in other circumstances. + +It is not the purpose of this section to induce you to infringe any patents or other property right claims or to contest validity of any such claims; this section has the sole purpose of protecting the integrity of the free software distribution system which is implemented by public license practices. Many people have made generous contributions to the wide range of software distributed through that system in reliance on consistent application of that system; it is up to the author/donor to decide if he or she is willing to distribute software through any other system and a licensee cannot impose that choice. + +This section is intended to make thoroughly clear what is believed to be a consequence of the rest of this License. + +12. If the distribution and/or use of the Library is restricted in certain countries either by patents or by copyrighted interfaces, the original copyright holder who places the Library under this License may add an explicit geographical distribution limitation excluding those countries, so that distribution is permitted only in or among countries not thus excluded. In such case, this License incorporates the limitation as if written in the body of this License. + +13. The Free Software Foundation may publish revised and/or new versions of the Lesser General Public License from time to time. Such new versions will be similar in spirit to the present version, but may differ in detail to address new problems or concerns. + +Each version is given a distinguishing version number. If the Library specifies a version number of this License which applies to it and "any later version", you have the option of following the terms and conditions either of that version or of any later version published by the Free Software Foundation. If the Library does not specify a license version number, you may choose any version ever published by the Free Software Foundation. + +14. If you wish to incorporate parts of the Library into other free programs whose distribution conditions are incompatible with these, write to the author to ask for permission. For software which is copyrighted by the Free Software Foundation, write to the Free Software Foundation; we sometimes make exceptions for this. Our decision will be guided by the two goals of preserving the free status of all derivatives of our free software and of promoting the sharing and reuse of software generally. + +NO WARRANTY + +15. BECAUSE THE LIBRARY IS LICENSED FREE OF CHARGE, THERE IS NO WARRANTY FOR THE LIBRARY, TO THE EXTENT PERMITTED BY APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR OTHER PARTIES PROVIDE THE LIBRARY "AS IS" WITHOUT WARRANTY OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE LIBRARY IS WITH YOU. SHOULD THE LIBRARY PROVE DEFECTIVE, YOU ASSUME THE COST OF ALL NECESSARY SERVICING, REPAIR OR CORRECTION. + +16. IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MAY MODIFY AND/OR REDISTRIBUTE THE LIBRARY AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE USE OR INABILITY TO USE THE LIBRARY (INCLUDING BUT NOT LIMITED TO LOSS OF DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD PARTIES OR A FAILURE OF THE LIBRARY TO OPERATE WITH ANY OTHER SOFTWARE), EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH DAMAGES. + +END OF TERMS AND CONDITIONS + +How to Apply These Terms to Your New Libraries + +If you develop a new library, and you want it to be of the greatest possible use to the public, we recommend making it free software that everyone can redistribute and change. You can do so by permitting redistribution under these terms (or, alternatively, under the terms of the ordinary General Public License). + +To apply these terms, attach the following notices to the library. It is safest to attach them to the start of each source file to most effectively convey the exclusion of warranty; and each file should have at least the "copyright" line and a pointer to where the full notice is found. + + one line to give the library's name and an idea of what it does. + Copyright (C) year name of author + + This library is free software; you can redistribute it and/or modify it under the terms of the GNU Lesser General Public License as published by the Free Software Foundation; either version 2.1 of the License, or (at your option) any later version. + + This library is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public License for more details. + + You should have received a copy of the GNU Lesser General Public License along with this library; if not, write to the Free Software Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA Also add information on how to contact you by electronic and paper mail. + +You should also get your employer (if you work as a programmer) or your school, if any, to sign a "copyright disclaimer" for the library, if necessary. Here is a sample; alter the names: + +Yoyodyne, Inc., hereby disclaims all copyright interest in +the library `Frob' (a library for tweaking knobs) written +by James Random Hacker. + +signature of Ty Coon, 1 April 1990 +Ty Coon, President of Vice +That's all there is to it! diff --git a/vendor/intx-mime/package.json b/vendor/intx-mime/package.json new file mode 100644 index 0000000..603ab68 --- /dev/null +++ b/vendor/intx-mime/package.json @@ -0,0 +1,34 @@ +{ + "name": "@intx/mime", + "description": "RFC 2822 message assembly and parsing with PGP detached signatures", + "version": "0.3.0", + "license": "LGPL-2.1-only", + "type": "module", + "exports": { + ".": { + "types": "./src/index.ts", + "default": "./src/index.ts" + } + }, + "scripts": { + "typecheck": "tsc --noEmit" + }, + "dependencies": { + "@intx/crypto": "0.3.0", + "@intx/types": "workspace:*", + "arktype": "2.1.29" + }, + "devDependencies": { + "@types/bun": "1.1.14", + "typescript": "5.7.2" + }, + "files": [ + "src", + "README.md", + "LICENSE" + ], + "sideEffects": false, + "publishConfig": { + "access": "public" + } +} diff --git a/vendor/intx-mime/src/index.ts b/vendor/intx-mime/src/index.ts new file mode 100644 index 0000000..a202956 --- /dev/null +++ b/vendor/intx-mime/src/index.ts @@ -0,0 +1,44 @@ +export { + assembleSignedContent, + assembleMessage, + extractAddrSpec, + formatRFC2822Date, + generateMessageId, + parseHeaderSection, + parseMimePart, + parseMultipart, + extractBoundary, + extractPartByPath, + parseMailToEmail, + extractAttachments, + buildMessageHeaders, + decodeMail, +} from "./mime"; + +export type { + MessageHeaders, + ConversationContent, + MimeAssemblyInput, + StructuredContent, + ParsedMimePart, + ParsedMimeMessage, + JMAPEmail, + JMAPAddress, + JMAPBodyValue, + JMAPBodyPart, + JMAPAttachment, +} from "./mime"; + +export { createDetachedSignatureFromProvider } from "./pgp-sign"; + +export { + createInboundMessage, + createOutboundMessage, + isMessageId, +} from "./mail-builder"; + +export type { + CreateInboundMessageOpts, + CreateOutboundMessageOpts, + InboundPayloadInput, +} from "./mail-builder"; diff --git a/vendor/intx-mime/src/mail-builder.ts b/vendor/intx-mime/src/mail-builder.ts new file mode 100644 index 0000000..65c3ff3 --- /dev/null +++ b/vendor/intx-mime/src/mail-builder.ts @@ -0,0 +1,516 @@ +/** + * Builders for InboundMessage and OutboundMessage shapes. + * + * Constructing these by hand requires assembling MessageRef, MessageHeaders, + * payload envelopes, signature status, and other mail-shaped fields that the + * transport normally produces after parsing wire bytes. These builders + * collapse that boilerplate behind two factories with sensible defaults. + * + * The builders use the parsed-shape MessageHeaders from + * @intx/types/runtime (where date is an ISO string), NOT the + * wire-shape MessageHeaders local to this package (where date is a Date + * object and headers are serialised to RFC 2822 bytes via assembleMessage). + * + * Consumers import the message types from @intx/types directly; the + * @intx/mime barrel does not re-export them. + */ + +import { type } from "arktype"; +import type { + InboundMessage, + MessageAttachment, + MessageHeaders, + MessageRef, + OutboundMessage, +} from "@intx/types/runtime"; +import { InterchangeType, SignatureStatus } from "@intx/types/runtime"; +import { generateMessageId } from "./mime"; + +/** + * Default schema version for structured payloads. Matches + * docs/MESSAGE.md § Payload Structure, which specifies "version": "1" as + * the current schema version for every Interchange payload type. Audit + * this default whenever the documented schema version increments. + */ +const DEFAULT_PAYLOAD_VERSION = "1"; + +const MESSAGE_ID_RE = /^<[^<>\s@]+@[^<>\s@]+>$/; +const ADDRESS_RE = /^[^@\s]+@[^@\s]+$/; + +const CONVERSATION_TYPE_PREFIX = "conversation."; + +// --------------------------------------------------------------------------- +// InboundMessage builder +// --------------------------------------------------------------------------- + +/** + * Structured payload envelope for an inbound message. `version` defaults to + * the current schema version per docs/MESSAGE.md. + */ +export type InboundPayloadInput = { + type: InterchangeType; + body: Record; + version?: string; +}; + +export type CreateInboundMessageOpts = { + from: string; + to: string | string[]; + + /** Plain-text body. Mutually exclusive with `payload`. */ + content?: string; + + /** Structured JSON envelope. Mutually exclusive with `content`. */ + payload?: InboundPayloadInput; + + cc?: string | string[]; + subject?: string; + + /** + * Defaults to `new Date().toISOString()`. Accepts Date or any string + * parseable by `new Date(...)`; stored as an ISO 8601 string. + */ + date?: Date | string; + + /** Defaults to `generateMessageId(from)`. Must be of the form ``. */ + messageId?: string; + + inReplyTo?: string; + references?: string[]; + listId?: string; + + /** + * Interchange-Type header value. Auto-derived from `payload.type` when a + * payload is supplied; throws if explicitly set to a value that conflicts + * with `payload.type`. + */ + interchangeType?: InterchangeType; + + correlationId?: string; + tenantId?: string; + agentId?: string; + sessionId?: string; + offeringId?: string; + schemaVersion?: string; + traceparent?: string; + tracestate?: string; + + attachments?: MessageAttachment[]; + + /** Merged with `{ uid: 1, mailbox: "INBOX" }`. */ + ref?: Partial; + + flags?: string[]; + + /** Defaults to `"missing"`. */ + signatureStatus?: SignatureStatus; +}; + +export function createInboundMessage( + opts: CreateInboundMessageOpts, +): InboundMessage { + const fn = "createInboundMessage"; + + requireAddress(opts.from, "from", fn); + const to = normalizeAndValidateAddressArray(opts.to, "to", fn); + + validateBodyExclusivity(opts.content, opts.payload, fn); + + if (opts.payload !== undefined) { + validateInterchangeType(opts.payload.type, "payload.type", fn); + if (isConversationType(opts.payload.type)) { + throw new Error( + `${fn}: conversation types must use \`content\` instead of \`payload\`; got \`payload.type\`: ${opts.payload.type}`, + ); + } + validatePayloadBody(opts.payload.body, "payload.body", fn); + if (opts.payload.version !== undefined) { + if ( + typeof opts.payload.version !== "string" || + opts.payload.version.length === 0 + ) { + throw new Error( + `${fn}: \`payload.version\`, when provided, must be a non-empty string`, + ); + } + } + } + + if (opts.interchangeType !== undefined) { + validateInterchangeType(opts.interchangeType, "interchangeType", fn); + if ( + opts.payload !== undefined && + opts.interchangeType !== opts.payload.type + ) { + throw new Error( + `${fn}: \`interchangeType\` (${opts.interchangeType}) conflicts with \`payload.type\` (${opts.payload.type})`, + ); + } + } + + if (opts.messageId !== undefined) { + validateMessageId(opts.messageId, "messageId", fn); + } + if (opts.inReplyTo !== undefined) { + validateMessageId(opts.inReplyTo, "inReplyTo", fn); + } + if (opts.references !== undefined) { + if (opts.references.length === 0) { + throw new Error( + `${fn}: \`references\`, when provided, must contain at least one entry`, + ); + } + opts.references.forEach((ref, i) => { + validateMessageId(ref, `references[${i}]`, fn); + }); + } + + const cc = + opts.cc === undefined + ? undefined + : normalizeAndValidateAddressArray(opts.cc, "cc", fn); + + rejectEmptyStringIfPresent(opts.content, "content", fn); + rejectEmptyStringIfPresent(opts.subject, "subject", fn); + rejectEmptyStringIfPresent(opts.listId, "listId", fn); + rejectEmptyStringIfPresent(opts.correlationId, "correlationId", fn); + rejectEmptyStringIfPresent(opts.tenantId, "tenantId", fn); + rejectEmptyStringIfPresent(opts.agentId, "agentId", fn); + rejectEmptyStringIfPresent(opts.sessionId, "sessionId", fn); + rejectEmptyStringIfPresent(opts.offeringId, "offeringId", fn); + rejectEmptyStringIfPresent(opts.schemaVersion, "schemaVersion", fn); + rejectEmptyStringIfPresent(opts.traceparent, "traceparent", fn); + rejectEmptyStringIfPresent(opts.tracestate, "tracestate", fn); + + if (opts.flags !== undefined) { + opts.flags.forEach((flag, i) => { + if (typeof flag !== "string" || flag.length === 0) { + throw new Error(`${fn}: \`flags[${i}]\` must be a non-empty string`); + } + }); + } + + const signatureStatus = opts.signatureStatus ?? "missing"; + const validatedStatus = SignatureStatus(signatureStatus); + if (validatedStatus instanceof type.errors) { + throw new Error( + `${fn}: \`signatureStatus\` is not a recognised SignatureStatus: ${validatedStatus.summary}`, + ); + } + + const date = normalizeDate(opts.date, "date", fn); + const messageId = opts.messageId ?? generateMessageId(opts.from); + const derivedInterchangeType = opts.interchangeType ?? opts.payload?.type; + + const headers: MessageHeaders = { from: opts.from, to, date, messageId }; + if (cc !== undefined) headers.cc = cc; + if (opts.subject !== undefined) headers.subject = opts.subject; + if (opts.inReplyTo !== undefined) headers.inReplyTo = opts.inReplyTo; + if (opts.references !== undefined) headers.references = opts.references; + if (opts.listId !== undefined) headers.listId = opts.listId; + if (derivedInterchangeType !== undefined) { + headers.interchangeType = derivedInterchangeType; + } + if (opts.correlationId !== undefined) { + headers.interchangeCorrelationId = opts.correlationId; + } + if (opts.tenantId !== undefined) headers.interchangeTenantId = opts.tenantId; + if (opts.agentId !== undefined) headers.interchangeAgentId = opts.agentId; + if (opts.sessionId !== undefined) { + headers.interchangeSessionId = opts.sessionId; + } + if (opts.offeringId !== undefined) { + headers.interchangeOfferingId = opts.offeringId; + } + if (opts.schemaVersion !== undefined) { + headers.interchangeSchemaVersion = opts.schemaVersion; + } + if (opts.traceparent !== undefined) headers.traceparent = opts.traceparent; + if (opts.tracestate !== undefined) headers.tracestate = opts.tracestate; + + if (opts.ref?.uid !== undefined) { + if ( + typeof opts.ref.uid !== "number" || + !Number.isInteger(opts.ref.uid) || + !Number.isFinite(opts.ref.uid) || + opts.ref.uid < 1 + ) { + throw new Error( + `${fn}: \`ref.uid\`, when provided, must be a positive integer (IMAP UID)`, + ); + } + } + const ref: MessageRef = { + uid: opts.ref?.uid ?? 1, + mailbox: opts.ref?.mailbox ?? "INBOX", + }; + if (typeof ref.mailbox !== "string" || ref.mailbox.length === 0) { + throw new Error( + `${fn}: \`ref.mailbox\`, when provided, must be a non-empty string`, + ); + } + + const result: InboundMessage = { + ref, + headers, + flags: opts.flags ?? [], + signatureStatus, + }; + if (opts.content !== undefined) result.content = opts.content; + if (opts.payload !== undefined) { + result.payload = { + type: opts.payload.type, + version: opts.payload.version ?? DEFAULT_PAYLOAD_VERSION, + body: opts.payload.body, + }; + } + if (opts.attachments !== undefined && opts.attachments.length > 0) { + result.attachments = opts.attachments; + } + + return result; +} + +// --------------------------------------------------------------------------- +// OutboundMessage builder +// --------------------------------------------------------------------------- + +export type CreateOutboundMessageOpts = { + to: string | string[]; + + /** Interchange payload type. Determines content vs payload semantics. */ + type: InterchangeType; + + /** Plain-text body. Mutually exclusive with `payload`. */ + content?: string; + + /** Structured JSON envelope body. Mutually exclusive with `content`. */ + payload?: Record; + + cc?: string | string[]; + subject?: string; + + /** Human-readable summary used as the text/plain part for structured types. */ + summary?: string; + + inReplyTo?: string; + references?: string[]; + correlationId?: string; + sessionId?: string; + tenantId?: string; + + attachments?: MessageAttachment[]; +}; + +export function createOutboundMessage( + opts: CreateOutboundMessageOpts, +): OutboundMessage { + const fn = "createOutboundMessage"; + + validateInterchangeType(opts.type, "type", fn); + // Validate addresses without mutating the source shape; the OutboundMessage + // type preserves `string | string[]` and downstream consumers handle both. + normalizeAndValidateAddressArray(opts.to, "to", fn); + if (opts.cc !== undefined) { + normalizeAndValidateAddressArray(opts.cc, "cc", fn); + } + + validateBodyExclusivity(opts.content, opts.payload, fn); + + if (isConversationType(opts.type)) { + if (opts.payload !== undefined) { + throw new Error( + `${fn}: conversation \`type\` ${opts.type} must use \`content\` instead of \`payload\``, + ); + } + if (opts.content === undefined) { + throw new Error( + `${fn}: conversation \`type\` ${opts.type} requires \`content\``, + ); + } + } else { + if (opts.content !== undefined) { + throw new Error( + `${fn}: non-conversation \`type\` ${opts.type} must use \`payload\` instead of \`content\``, + ); + } + if (opts.payload === undefined) { + throw new Error( + `${fn}: non-conversation \`type\` ${opts.type} requires \`payload\``, + ); + } + } + if (opts.payload !== undefined) { + validatePayloadBody(opts.payload, "payload", fn); + } + + if (opts.inReplyTo !== undefined) { + validateMessageId(opts.inReplyTo, "inReplyTo", fn); + } + if (opts.references !== undefined) { + if (opts.references.length === 0) { + throw new Error( + `${fn}: \`references\`, when provided, must contain at least one entry`, + ); + } + opts.references.forEach((ref, i) => { + validateMessageId(ref, `references[${i}]`, fn); + }); + } + rejectEmptyStringIfPresent(opts.content, "content", fn); + rejectEmptyStringIfPresent(opts.subject, "subject", fn); + rejectEmptyStringIfPresent(opts.summary, "summary", fn); + rejectEmptyStringIfPresent(opts.correlationId, "correlationId", fn); + rejectEmptyStringIfPresent(opts.sessionId, "sessionId", fn); + rejectEmptyStringIfPresent(opts.tenantId, "tenantId", fn); + + const result: OutboundMessage = { to: opts.to, type: opts.type }; + if (opts.cc !== undefined) result.cc = opts.cc; + if (opts.subject !== undefined) result.subject = opts.subject; + if (opts.content !== undefined) result.content = opts.content; + if (opts.payload !== undefined) result.payload = opts.payload; + if (opts.summary !== undefined) result.summary = opts.summary; + if (opts.attachments !== undefined && opts.attachments.length > 0) { + result.attachments = opts.attachments; + } + if (opts.inReplyTo !== undefined) result.inReplyTo = opts.inReplyTo; + if (opts.references !== undefined) result.references = opts.references; + if (opts.correlationId !== undefined) { + result.correlationId = opts.correlationId; + } + if (opts.sessionId !== undefined) result.sessionId = opts.sessionId; + if (opts.tenantId !== undefined) result.tenantId = opts.tenantId; + return result; +} + +// --------------------------------------------------------------------------- +// Validation helpers +// --------------------------------------------------------------------------- + +function rejectEmptyStringIfPresent( + value: string | undefined, + field: string, + fn: string, +): void { + if (value !== undefined && value.length === 0) { + throw new Error( + `${fn}: \`${field}\`, when provided, must be a non-empty string`, + ); + } +} + +function requireAddress(value: unknown, field: string, fn: string): void { + if (typeof value !== "string" || value.length === 0) { + throw new Error(`${fn}: \`${field}\` must be a non-empty string`); + } + if (!ADDRESS_RE.test(value)) { + throw new Error( + `${fn}: \`${field}\` must be an RFC 5322 address of the form \`local@domain\`; got: ${value}`, + ); + } +} + +function normalizeAndValidateAddressArray( + input: string | string[], + field: string, + fn: string, +): string[] { + if (typeof input === "string") { + requireAddress(input, field, fn); + return [input]; + } + if (!Array.isArray(input) || input.length === 0) { + throw new Error( + `${fn}: \`${field}\` must contain at least one recipient address`, + ); + } + input.forEach((entry, i) => { + requireAddress(entry, `${field}[${i}]`, fn); + }); + return input; +} + +function validatePayloadBody(value: unknown, field: string, fn: string): void { + if (typeof value !== "object" || value === null || Array.isArray(value)) { + throw new Error( + `${fn}: \`${field}\` must be a plain object (got ${ + value === null ? "null" : Array.isArray(value) ? "array" : typeof value + })`, + ); + } +} + +function isConversationType(t: InterchangeType): boolean { + return t.startsWith(CONVERSATION_TYPE_PREFIX); +} + +function validateInterchangeType( + value: unknown, + field: string, + fn: string, +): void { + const validated = InterchangeType(value); + if (validated instanceof type.errors) { + throw new Error( + `${fn}: \`${field}\` is not a valid InterchangeType: ${validated.summary}`, + ); + } +} + +function validateMessageId(value: string, field: string, fn: string): void { + if (!MESSAGE_ID_RE.test(value)) { + throw new Error( + `${fn}: \`${field}\` must be an RFC 2822 message identifier of the form \`\`; got: ${value}`, + ); + } +} + +/** + * Non-throwing predicate for the RFC 2822 message-identifier form ``. + * A caller forwarding a `messageId`/`inReplyTo`/`references` value into + * `createInboundMessage` (which rejects a malformed identifier) uses this to + * decide whether the value is safe to forward: inbound mail can carry a + * headerless-derived (sha256) or otherwise malformed Message-Id that is a + * valid claim-check key but not a valid RFC identifier. + */ +export function isMessageId(value: string): boolean { + return MESSAGE_ID_RE.test(value); +} + +function normalizeDate( + input: Date | string | undefined, + field: string, + fn: string, +): string { + if (input === undefined) return new Date().toISOString(); + if (input instanceof Date) { + if (Number.isNaN(input.getTime())) { + throw new Error(`${fn}: \`${field}\` is an Invalid Date`); + } + return input.toISOString(); + } + if (typeof input !== "string" || input.length === 0) { + throw new Error( + `${fn}: \`${field}\`, when provided, must be a Date or a non-empty string`, + ); + } + const parsed = new Date(input); + if (Number.isNaN(parsed.getTime())) { + throw new Error( + `${fn}: \`${field}\` is not a parseable date string: ${input}`, + ); + } + return parsed.toISOString(); +} + +function validateBodyExclusivity( + content: unknown, + payload: unknown, + fn: string, +): void { + if (content !== undefined && payload !== undefined) { + throw new Error( + `${fn}: \`content\` and \`payload\` are mutually exclusive; provide at most one`, + ); + } +} diff --git a/vendor/intx-mime/src/mime.ts b/vendor/intx-mime/src/mime.ts new file mode 100644 index 0000000..adb9021 --- /dev/null +++ b/vendor/intx-mime/src/mime.ts @@ -0,0 +1,1334 @@ +/* eslint-disable @typescript-eslint/no-non-null-assertion -- MIME parser uses bounded array access throughout */ +/** + * MIME byte construction and parsing for Interchange messages. + * + * Implements exactly two message shapes per MESSAGE.md: + * 1. Conversation: multipart/mixed (text/plain plus zero or more + * attachment parts) in multipart/signed + * 2. Structured: application/vnd.interchange+json in multipart/mixed in multipart/signed + * + * Produces real RFC 2822 / RFC 2046 / RFC 3156 bytes. The signed content + * part is produced in MIME canonical form (CRLF line endings) so PGP/MIME + * verification operates on the same bytes regardless of platform. + * + * RFC references verified: + * - RFC 2822 §2.1.1: lines MUST NOT exceed 998 chars; recommended 78 + * - RFC 2046 §5.1.1: boundary MUST be <= 70 chars; CRLF before each boundary + * - RFC 3156 §5: multipart/signed; protocol="application/pgp-signature"; + * micalg=pgp-sha512; first part = signed content; second part = signature + * - Message-IDs: — valid per RFC 2822 §3.6.4 (dot-atom local-part) + */ + +import { type } from "arktype"; +import { base64Decode, base64Encode } from "@intx/types"; +import type { + MessageAttachment, + MessageHeaders as ParsedMessageHeaders, + MessagePart, +} from "@intx/types/runtime"; +import { InterchangeType } from "@intx/types/runtime"; + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +export type MessageHeaders = { + from: string; + to: string[]; + cc: string[] | undefined; + date: Date; + messageId: string; + subject: string | undefined; + inReplyTo: string | undefined; + references: string[] | undefined; + mimeVersion: "1.0"; + interchangeType: string | undefined; + interchangeCorrelationId: string | undefined; + interchangeTenantId: string | undefined; + interchangeAgentId: string | undefined; + interchangeSessionId: string | undefined; + interchangeOfferingId: string | undefined; + interchangeSchemaVersion: string | undefined; + traceparent: string | undefined; + tracestate: string | undefined; +}; + +export type ConversationContent = { + kind: "conversation"; + text: string; + attachments?: MessageAttachment[]; +}; + +export type StructuredContent = { + kind: "structured"; + json: Record; + summary?: string; +}; + +export type MimeAssemblyInput = { + headers: MessageHeaders; + content: ConversationContent | StructuredContent; +}; + +export type ParsedMimePart = { + contentType: string; + headers: Map; + body: Uint8Array; +}; + +export type ParsedMimeMessage = { + headers: Map; + parts: ParsedMimePart[]; +}; + +// --------------------------------------------------------------------------- +// JMAP Email types (RFC 8621) +// --------------------------------------------------------------------------- + +export type JMAPAddress = { + name: string | null; + email: string; +}; + +export type JMAPBodyValue = { + value: string; + isEncodingProblem: boolean; +}; + +export type JMAPBodyPart = { + partId: string; + type: string; +}; + +export type JMAPAttachment = { + blobId: string; + name: string | null; + type: string; + size: number; +}; + +export type JMAPEmail = { + from: JMAPAddress[]; + to: JMAPAddress[]; + subject: string | null; + sentAt: string | null; + bodyValues: Record; + textBody: JMAPBodyPart[]; + htmlBody: JMAPBodyPart[]; + attachments: JMAPAttachment[]; + headers: Record; +}; + +// --------------------------------------------------------------------------- +// Message-ID generation +// --------------------------------------------------------------------------- + +export function generateMessageId(address: string): string { + const domain = address.includes("@") ? address.split("@")[1]! : "local"; + const uuid = crypto.randomUUID(); + return `<${uuid}@${domain}>`; +} + +// --------------------------------------------------------------------------- +// Address normalization +// --------------------------------------------------------------------------- + +/** + * Extract the bare addr-spec (local-part@domain) from a single RFC 5322 + * address value. Strips any display name and surrounding angle brackets, + * then lowercases the result so case-insensitive comparison falls out + * naturally. + * + * Accepted inputs (single-address only — do not pass comma-separated lists): + * `"Display Name" ` → `user@host` + * `Display Name ` → `user@host` + * `` → `user@host` + * `user@host` → `user@host` + * ` User@Host ` → `user@host` + * + * Rejected (throws) inputs: + * - empty or whitespace-only + * - input with no `@` + * - input that produces an empty local-part or domain + * - quoted local-parts (e.g. `"a@b"@host`) — technically valid per RFC + * 5321 §4.1.2 but rare in practice; the simple split below would + * misinterpret the inner `@`, so we refuse rather than guess + * - content after the closing `>` in an angle-bracketed form + * (e.g. `Name (comment)`) — would silently fall through to a + * misparsed bare-form attempt, so we refuse instead + * + * Per RFC 5321 §2.4 the local-part is technically case-sensitive, but no + * production system honors that; matching case-insensitively is the + * correct call for routing and identity checks. + */ +export function extractAddrSpec(addressLine: string): string { + const trimmed = addressLine.trim(); + if (trimmed === "") { + throw new Error("extractAddrSpec: address is empty"); + } + + let candidate: string; + const angleOpen = trimmed.lastIndexOf("<"); + if (angleOpen !== -1) { + // Angle-bracketed form. Require the `>` to be the trailing + // non-whitespace character so that input like `Name (comment)` + // is refused rather than re-parsed as a bare addr-spec. + if (!trimmed.endsWith(">")) { + throw new Error( + `extractAddrSpec: trailing content after '>' in ${JSON.stringify(addressLine)}`, + ); + } + candidate = trimmed.slice(angleOpen + 1, -1).trim(); + } else { + candidate = trimmed; + } + + // Reject quoted local-parts: the parser below splits on the first `@`, + // which would corrupt a quoted form whose local-part contains `@`. + if (candidate.includes('"')) { + throw new Error( + `extractAddrSpec: quoted local-parts are not supported: ${JSON.stringify(addressLine)}`, + ); + } + + const atIndex = candidate.indexOf("@"); + if (atIndex === -1) { + throw new Error( + `extractAddrSpec: address has no '@': ${JSON.stringify(addressLine)}`, + ); + } + + // Reject any further `@` in the candidate — a well-formed addr-spec + // has exactly one. Multiple `@` is either a quoted form (rejected + // above) or simply malformed. + if (candidate.indexOf("@", atIndex + 1) !== -1) { + throw new Error( + `extractAddrSpec: multiple '@' in ${JSON.stringify(addressLine)}`, + ); + } + + const local = candidate.slice(0, atIndex); + const domain = candidate.slice(atIndex + 1); + if (local === "" || domain === "") { + throw new Error( + `extractAddrSpec: empty local-part or domain in ${JSON.stringify(addressLine)}`, + ); + } + + return `${local.toLowerCase()}@${domain.toLowerCase()}`; +} + +// --------------------------------------------------------------------------- +// RFC 2822 date formatting +// --------------------------------------------------------------------------- + +const DAYS = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"] as const; +const MONTHS = [ + "Jan", + "Feb", + "Mar", + "Apr", + "May", + "Jun", + "Jul", + "Aug", + "Sep", + "Oct", + "Nov", + "Dec", +] as const; + +export function formatRFC2822Date(date: Date): string { + const day = DAYS[date.getUTCDay()]!; + const d = String(date.getUTCDate()).padStart(2, "0"); + const mon = MONTHS[date.getUTCMonth()]!; + const year = date.getUTCFullYear(); + const h = String(date.getUTCHours()).padStart(2, "0"); + const m = String(date.getUTCMinutes()).padStart(2, "0"); + const s = String(date.getUTCSeconds()).padStart(2, "0"); + return `${day}, ${d} ${mon} ${year} ${h}:${m}:${s} +0000`; +} + +// --------------------------------------------------------------------------- +// Boundary generation +// --------------------------------------------------------------------------- + +function generateBoundary(): string { + const bytes = new Uint8Array(18); + crypto.getRandomValues(bytes); + return ( + "----=_Part_" + + Array.from(bytes) + .map((b) => b.toString(16).padStart(2, "0")) + .join("") + ); +} + +// --------------------------------------------------------------------------- +// Header serialization (RFC 2822) +// --------------------------------------------------------------------------- + +const CRLF = "\r\n"; + +function hdr(name: string, value: string): string { + return `${name}: ${value}${CRLF}`; +} + +function serializeMessageHeaders( + h: MessageHeaders, + contentType: string, +): string { + let out = ""; + out += hdr("From", h.from); + out += hdr("To", Array.isArray(h.to) ? h.to.join(", ") : (h.to as string)); + if (h.cc && h.cc.length > 0) { + out += hdr("Cc", h.cc.join(", ")); + } + out += hdr("Date", formatRFC2822Date(h.date)); + out += hdr("Message-ID", h.messageId); + if (h.subject !== undefined) { + out += hdr("Subject", h.subject); + } + if (h.inReplyTo !== undefined) { + out += hdr("In-Reply-To", h.inReplyTo); + } + if (h.references !== undefined && h.references.length > 0) { + out += hdr("References", h.references.join(" ")); + } + out += hdr("MIME-Version", "1.0"); + out += hdr("Content-Type", contentType); + + // Interchange headers + if (h.interchangeType !== undefined) { + out += hdr("Interchange-Type", h.interchangeType); + } + if (h.interchangeCorrelationId !== undefined) { + out += hdr("Interchange-Correlation-ID", h.interchangeCorrelationId); + } + if (h.interchangeTenantId !== undefined) { + out += hdr("Interchange-Tenant-ID", h.interchangeTenantId); + } + if (h.interchangeAgentId !== undefined) { + out += hdr("Interchange-Agent-ID", h.interchangeAgentId); + } + if (h.interchangeSessionId !== undefined) { + out += hdr("Interchange-Session-ID", h.interchangeSessionId); + } + if (h.interchangeOfferingId !== undefined) { + out += hdr("Interchange-Offering-ID", h.interchangeOfferingId); + } + if (h.interchangeSchemaVersion !== undefined) { + out += hdr("Interchange-Schema-Version", h.interchangeSchemaVersion); + } + if (h.traceparent !== undefined) { + out += hdr("traceparent", h.traceparent); + } + if (h.tracestate !== undefined) { + out += hdr("tracestate", h.tracestate); + } + + return out; +} + +// --------------------------------------------------------------------------- +// MIME part assembly +// --------------------------------------------------------------------------- + +/** + * Reject values that would break out of a MIME header. CR/LF in a header + * value is a header-injection vector; a double quote breaks the quoted + * `filename="..."` / `name="..."` forms the parser relies on. The MIME + * layer owns header well-formedness, so it fails loudly here rather than + * emitting a corrupt envelope. + */ +function assertHeaderSafe(value: string, field: string): void { + if (/[\r\n]/.test(value)) { + throw new Error( + `${field} must not contain CR or LF: ${JSON.stringify(value)}`, + ); + } + if (value.includes('"')) { + throw new Error( + `${field} must not contain a double quote: ${JSON.stringify(value)}`, + ); + } +} + +/** + * Encode bytes as base64, wrapped at 76 columns per RFC 2045. Returns the + * empty string for empty input. + */ +function base64Lines(bytes: Uint8Array): string { + const b64 = base64Encode(bytes); + const lines: string[] = []; + for (let i = 0; i < b64.length; i += 76) { + lines.push(b64.slice(i, i + 76)); + } + return lines.join(CRLF); +} + +/** + * Assemble the signed content for a conversation message. + * + * The shape is always multipart/mixed: one text/plain part (BODY[1.1]) + * followed by zero or more binary attachment parts (BODY[1.2..N]). The + * shape is unconditional — there is no bare text/plain branch — so the + * writer, the parser, and the signed-bytes contract have one form each. + * + * This is the exact bytes that will be hashed for the PGP/MIME signature. + */ +function assembleConversationSignedPart( + text: string, + attachments: readonly MessageAttachment[] = [], +): Uint8Array { + const boundary = generateBoundary(); + + // Canonicalize the text part: CRLF line endings, strip trailing + // whitespace per line. + const lines = text.split(/\r\n|\r|\n/); + const canonLines = lines.map((l) => l.replace(/[ \t]+$/, "")); + const canonical = canonLines.join(CRLF); + + let body = `Content-Type: multipart/mixed; boundary="${boundary}"${CRLF}${CRLF}`; + + // Text part (BODY[1.1]) + body += `--${boundary}${CRLF}`; + body += `Content-Type: text/plain; charset=utf-8${CRLF}`; + body += `Content-Transfer-Encoding: 7bit${CRLF}`; + body += `${CRLF}`; + body += `${canonical}${CRLF}`; + + // Attachment parts (BODY[1.2..N]) + for (const att of attachments) { + assertHeaderSafe(att.contentType, "attachment contentType"); + assertHeaderSafe(att.name, "attachment name"); + body += `--${boundary}${CRLF}`; + body += `Content-Type: ${att.contentType}${CRLF}`; + body += `Content-Transfer-Encoding: base64${CRLF}`; + body += `Content-Disposition: attachment; filename="${att.name}"${CRLF}`; + body += `${CRLF}`; + body += `${base64Lines(att.data)}${CRLF}`; + } + + body += `--${boundary}--${CRLF}`; + return new TextEncoder().encode(body); +} + +/** + * Assemble the signed content for a structured message (multipart/mixed). + * + * This is the exact bytes that will be hashed for the PGP/MIME signature. + */ +function assembleStructuredSignedPart( + json: Record, + summary?: string, +): Uint8Array { + const boundary = generateBoundary(); + const jsonStr = JSON.stringify(json); + + let body = `Content-Type: multipart/mixed; boundary="${boundary}"${CRLF}${CRLF}`; + + // JSON payload part + body += `--${boundary}${CRLF}`; + body += `Content-Type: application/vnd.interchange+json; charset=utf-8${CRLF}`; + body += `Content-Transfer-Encoding: 7bit${CRLF}`; + body += `${CRLF}`; + body += `${jsonStr}${CRLF}`; + + // Optional human-readable summary + if (summary !== undefined) { + body += `--${boundary}${CRLF}`; + body += `Content-Type: text/plain; charset=utf-8${CRLF}`; + body += `Content-Transfer-Encoding: 7bit${CRLF}`; + body += `${CRLF}`; + const lines = summary.split(/\r\n|\r|\n/); + const canonLines = lines.map((l) => l.replace(/[ \t]+$/, "")); + body += `${canonLines.join(CRLF)}${CRLF}`; + } + + body += `--${boundary}--${CRLF}`; + return new TextEncoder().encode(body); +} + +/** + * Wrap content part and PGP signature into multipart/signed per RFC 3156. + * + * RFC 3156 §5: The multipart/signed body MUST consist of exactly two parts. + * The first part contains the signed data. The second part contains the + * detached PGP signature in application/pgp-signature. + * + * The boundary delimiter lines use CRLF as required by RFC 2046. + */ +function wrapInMultipartSigned( + signedContentBytes: Uint8Array, + signatureBytes: Uint8Array, + boundary: string, +): Uint8Array { + const signedContent = new TextDecoder().decode(signedContentBytes); + const signature = new TextDecoder().decode(signatureBytes); + + const enc = new TextEncoder(); + + // Per RFC 2046: boundary delimiter = "--" + boundary parameter. + // The CRLF preceding the boundary belongs to the boundary, not the part. + // Each part is preceded by: CRLF + "--" + boundary + CRLF + // The closing delimiter: CRLF + "--" + boundary + "--" + CRLF + const body = + `--${boundary}${CRLF}` + + `${signedContent}` + + `${CRLF}--${boundary}${CRLF}` + + `Content-Type: application/pgp-signature${CRLF}` + + `${CRLF}` + + `${signature}${CRLF}` + + `--${boundary}--${CRLF}`; + + return enc.encode(body); +} + +// --------------------------------------------------------------------------- +// Full message assembly +// --------------------------------------------------------------------------- + +/** + * Assemble a complete RFC 2822 message from headers, content, and signature + * bytes. Returns the raw message bytes for storage. + * + * The signature bytes must be produced by signing the signed content part + * bytes (the result of assembleSignedContentPart below). + */ +export function assembleMessage( + headers: MessageHeaders, + signedContentBytes: Uint8Array, + signatureBytes: Uint8Array, +): Uint8Array { + const outerBoundary = generateBoundary(); + + const contentType = + `multipart/signed; protocol="application/pgp-signature"; ` + + `micalg=pgp-sha512; boundary="${outerBoundary}"`; + + const headerSection = serializeMessageHeaders(headers, contentType); + const bodyBytes = wrapInMultipartSigned( + signedContentBytes, + signatureBytes, + outerBoundary, + ); + + const enc = new TextEncoder(); + const headerBytes = enc.encode(headerSection + CRLF); + + const result = new Uint8Array(headerBytes.length + bodyBytes.length); + result.set(headerBytes, 0); + result.set(bodyBytes, headerBytes.length); + return result; +} + +/** + * Build the signed content bytes for a message. These exact bytes are + * what the CryptoProvider signs. The transport calls this, then signs, + * then calls assembleMessage with both. + */ +export function assembleSignedContent( + content: ConversationContent | StructuredContent, +): Uint8Array { + if (content.kind === "conversation") { + return assembleConversationSignedPart(content.text, content.attachments); + } + return assembleStructuredSignedPart(content.json, content.summary); +} + +// --------------------------------------------------------------------------- +// MIME parsing (for fetchHeaders, fetchStructure, fetchPart, fetchFull) +// --------------------------------------------------------------------------- + +const CRLF_CRLF = new Uint8Array([0x0d, 0x0a, 0x0d, 0x0a]); +const LF_LF = new Uint8Array([0x0a, 0x0a]); + +function findByteSequence(haystack: Uint8Array, needle: Uint8Array): number { + if (needle.length === 0) return 0; + const limit = haystack.length - needle.length; + outer: for (let i = 0; i <= limit; i++) { + for (let j = 0; j < needle.length; j++) { + if (haystack[i + j] !== needle[j]) continue outer; + } + return i; + } + return -1; +} + +/** + * Parse the header section of a raw RFC 2822 message. + * Returns a map of lowercase header names to their values, and the + * byte offset where the body starts. + */ +export function parseHeaderSection(raw: Uint8Array): { + headers: Map; + bodyOffset: number; + headerEnd: number; +} { + const headers = new Map(); + + // Search for the blank line separator in byte space so the returned + // offset is valid for Uint8Array.slice() even when headers contain + // multi-byte UTF-8 characters. + const crlfIdx = findByteSequence(raw, CRLF_CRLF); + const lfIdx = findByteSequence(raw, LF_LF); + + let bodyOffset = raw.length; + let headerEnd = raw.length; + + if (crlfIdx !== -1 && (lfIdx === -1 || crlfIdx <= lfIdx)) { + headerEnd = crlfIdx; + bodyOffset = crlfIdx + 4; + } else if (lfIdx !== -1) { + headerEnd = lfIdx; + bodyOffset = lfIdx + 2; + } + + const headerText = new TextDecoder("utf-8", { fatal: false }).decode( + raw.subarray(0, headerEnd), + ); + parseHeaders(headerText, headers); + + return { headers, bodyOffset, headerEnd }; +} + +function parseHeaders(headerSection: string, out: Map): void { + // Unfold continuation lines (lines starting with whitespace per RFC 2822). + const unfolded = headerSection + .replace(/\r\n[ \t]+/g, " ") + .replace(/\n[ \t]+/g, " "); + const lines = unfolded.split(/\r\n|\n/); + for (const line of lines) { + if (line.trim() === "") continue; + const colon = line.indexOf(":"); + if (colon === -1) continue; + const name = line.slice(0, colon).trim().toLowerCase(); + const value = line.slice(colon + 1).trim(); + // For repeated headers (like Received), keep the first value. + if (!out.has(name)) { + out.set(name, value); + } + } +} + +/** + * Extract the boundary parameter from a Content-Type header value. + */ +export function extractBoundary(contentTypeValue: string): string | undefined { + const match = + contentTypeValue.match(/boundary="([^"]+)"/i) ?? + contentTypeValue.match(/boundary=([^\s;]+)/i); + return match?.[1]; +} + +/** + * Parse a multipart body into individual parts. + * + * Each part is returned as raw bytes (headers + blank line + body) for + * further parsing. + */ +export function parseMultipart( + body: Uint8Array, + boundary: string, +): Uint8Array[] { + const text = new TextDecoder("utf-8", { fatal: false }).decode(body); + const delimiter = `--${boundary}`; + const parts: Uint8Array[] = []; + const enc = new TextEncoder(); + + let pos = 0; + while (pos < text.length) { + // Find next delimiter. + const delimIdx = text.indexOf(delimiter, pos); + if (delimIdx === -1) break; + + // Check if it's the closing delimiter. + const afterDelim = delimIdx + delimiter.length; + if (text.slice(afterDelim, afterDelim + 2) === "--") break; + + // Skip past the delimiter line (to end of CRLF or LF). + let partStart = afterDelim; + if (text[partStart] === "\r") partStart++; + if (text[partStart] === "\n") partStart++; + + // Find the next delimiter to know where this part ends. + const nextDelimIdx = text.indexOf("\n" + delimiter, partStart); + if (nextDelimIdx === -1) break; + + // Part body excludes the trailing CRLF before the next boundary. + let partEnd = nextDelimIdx; + // Account for the \n we searched for. + // We want to include only up to (but not including) the CRLF before "--boundary". + // nextDelimIdx points to the \n before the delimiter. The part ends before + // the preceding \r\n (or just \n). + if (partEnd > partStart && text[partEnd - 1] === "\r") { + partEnd--; + } + + const partText = text.slice(partStart, partEnd); + parts.push(enc.encode(partText)); + + pos = nextDelimIdx + 1; + } + + return parts; +} + +/** + * Parse a single MIME part into its headers and body. + */ +export function parseMimePart(partBytes: Uint8Array): ParsedMimePart { + const { headers, bodyOffset } = parseHeaderSection(partBytes); + const contentType = headers.get("content-type") ?? "application/octet-stream"; + const body = partBytes.slice(bodyOffset); + return { contentType, headers, body }; +} + +/** + * Extract a MIME part by dot-separated path from a multipart/signed message. + * + * Path "1" returns the signed content part (text/plain or multipart/mixed). + * Path "1.1" returns the first sub-part of the signed content (JSON payload). + * Path "2" returns the application/pgp-signature part. + * + * This follows IMAP FETCH section specifier semantics (RFC 9051). + */ +export function extractPartByPath( + raw: Uint8Array, + partPath: string, +): Uint8Array { + const { headers, bodyOffset } = parseHeaderSection(raw); + const body = raw.slice(bodyOffset); + const contentType = headers.get("content-type") ?? ""; + + const steps = partPath.split(".").map((s) => { + const n = parseInt(s, 10); + if (isNaN(n) || n < 1) { + throw new Error(`Invalid part path segment: "${s}"`); + } + return n; + }); + + return walkParts(body, contentType, steps, 0); +} + +function walkParts( + body: Uint8Array, + contentType: string, + steps: number[], + depth: number, +): Uint8Array { + const step = steps[depth]; + if (step === undefined) { + throw new Error("Part path has no more segments"); + } + + if (!contentType.toLowerCase().startsWith("multipart/")) { + throw new Error( + `Cannot index into non-multipart content type: ${contentType}`, + ); + } + + const boundary = extractBoundary(contentType); + if (boundary === undefined) { + throw new Error(`No boundary found in Content-Type: ${contentType}`); + } + + const parts = parseMultipart(body, boundary); + if (step > parts.length) { + throw new Error(`Part ${step} does not exist (only ${parts.length} parts)`); + } + + const partBytes = parts[step - 1]!; + + if (depth + 1 === steps.length) { + return partBytes; + } + + // Need to descend further. + const part = parseMimePart(partBytes); + return walkParts(part.body, part.contentType, steps, depth + 1); +} + +// --------------------------------------------------------------------------- +// JMAP Email parsing +// --------------------------------------------------------------------------- + +/** + * Parse a RFC 2822 address value into structured JMAP address objects. + * + * Handles both "Display Name" and bare email@example.com + * forms, as well as comma-separated address lists. + */ +function parseAddressList(value: string): JMAPAddress[] { + const results: JMAPAddress[] = []; + // Split on commas that are not inside quoted strings or angle brackets. + // We handle the two common forms: + // 1. "Display Name" + // 2. Display Name + // 3. + // 4. email + const segments = splitAddressList(value); + for (const segment of segments) { + const addr = parseOneAddress(segment.trim()); + if (addr !== null) { + results.push(addr); + } + } + return results; +} + +function splitAddressList(value: string): string[] { + const segments: string[] = []; + let current = ""; + let depth = 0; + let inQuote = false; + + for (const ch of value) { + if (ch === '"' && !inQuote) { + inQuote = true; + current += ch; + } else if (ch === '"' && inQuote) { + inQuote = false; + current += ch; + } else if (ch === "<" && !inQuote) { + depth++; + current += ch; + } else if (ch === ">" && !inQuote) { + depth--; + current += ch; + } else if (ch === "," && depth === 0 && !inQuote) { + segments.push(current); + current = ""; + } else { + current += ch; + } + } + if (current.trim() !== "") { + segments.push(current); + } + return segments; +} + +function parseOneAddress(segment: string): JMAPAddress | null { + if (segment === "") return null; + + // "Display Name" or Display Name + const angleMatch = segment.match(/^(.*?)<([^>]+)>\s*$/); + if (angleMatch !== null) { + const rawName = angleMatch[1]!.trim(); + const email = angleMatch[2]!.trim(); + // Strip surrounding quotes from display name if present + const name = + rawName === "" ? null : rawName.replace(/^"(.*)"$/, "$1").trim() || null; + return { name, email }; + } + + // Bare email address + const bare = segment.trim(); + if (bare !== "") { + return { name: null, email: bare }; + } + + return null; +} + +/** + * Parse the MIME Date header into an ISO 8601 string. + * + * Returns null if the header is missing or the value cannot be parsed. + */ +function parseDateHeader(value: string | undefined): string | null { + if (value === undefined) return null; + const date = new Date(value); + if (isNaN(date.getTime())) return null; + return date.toISOString(); +} + +/** + * Decode a MIME body part, handling Content-Transfer-Encoding. + */ +function decodeBodyBytes( + body: Uint8Array, + headers: Map, +): { value: string; isEncodingProblem: boolean } { + const cte = (headers.get("content-transfer-encoding") ?? "7bit") + .trim() + .toLowerCase(); + + if (cte === "base64") { + try { + const raw = new TextDecoder("utf-8", { fatal: false }).decode(body); + const cleaned = raw.replace(/\s+/g, ""); + const binaryStr = atob(cleaned); + return { value: binaryStr, isEncodingProblem: false }; + } catch { + return { + value: new TextDecoder("utf-8", { fatal: false }).decode(body), + isEncodingProblem: true, + }; + } + } + + if (cte === "quoted-printable") { + const raw = new TextDecoder("utf-8", { fatal: false }).decode(body); + return { value: decodeQuotedPrintable(raw), isEncodingProblem: false }; + } + + // 7bit, 8bit, binary — decode as UTF-8 + return { + value: new TextDecoder("utf-8", { fatal: false }).decode(body), + isEncodingProblem: false, + }; +} + +function decodeQuotedPrintable(text: string): string { + return text + .replace(/=\r\n/g, "") + .replace(/=\n/g, "") + .replace(/=([0-9A-Fa-f]{2})/g, (_match, hex: string) => + String.fromCharCode(parseInt(hex, 16)), + ); +} + +/** + * Determine whether a MIME part is an attachment based on Content-Disposition + * and content type. + */ +function isAttachmentPart( + contentType: string, + headers: Map, +): boolean { + const disposition = headers.get("content-disposition") ?? ""; + if (disposition.toLowerCase().startsWith("attachment")) return true; + + const ct = contentType.toLowerCase().split(";")[0]!.trim(); + if (ct === "text/plain" || ct === "text/html") return false; + + // Non-text types are treated as attachments unless they are multipart. + if (ct.startsWith("multipart/")) return false; + + return true; +} + +function extractContentTypeMime(contentType: string): string { + return contentType.split(";")[0]!.trim().toLowerCase(); +} + +function extractFilename(headers: Map): string | null { + const disposition = headers.get("content-disposition") ?? ""; + const nameMatch = + disposition.match(/filename="([^"]+)"/i) ?? + disposition.match(/filename=([^\s;]+)/i); + if (nameMatch !== null) return nameMatch[1]!; + + const ct = headers.get("content-type") ?? ""; + const ctNameMatch = + ct.match(/name="([^"]+)"/i) ?? ct.match(/name=([^\s;]+)/i); + if (ctNameMatch !== null) return ctNameMatch[1]!; + + return null; +} + +type WalkContext = { + mailId: string; + bodyValues: Record; + textBody: JMAPBodyPart[]; + htmlBody: JMAPBodyPart[]; + attachments: JMAPAttachment[]; +}; + +/** + * Recursively walk MIME parts, populating body values and attachment lists. + * + * partPath uses IMAP-style dot-separated numbering (e.g., "1", "1.1", "2.3"). + */ +function walkMimePart( + partBytes: Uint8Array, + partPath: string, + ctx: WalkContext, +): void { + const part = parseMimePart(partBytes); + const mime = extractContentTypeMime(part.contentType); + + if (mime.startsWith("multipart/")) { + const boundary = extractBoundary(part.contentType); + if (boundary === undefined) return; + const subParts = parseMultipart(part.body, boundary); + subParts.forEach((subPartBytes, idx) => { + walkMimePart(subPartBytes, `${partPath}.${idx + 1}`, ctx); + }); + return; + } + + if (isAttachmentPart(part.contentType, part.headers)) { + const blobId = `blob_${ctx.mailId}_${partPath}`; + ctx.attachments.push({ + blobId, + name: extractFilename(part.headers), + type: mime, + size: part.body.length, + }); + return; + } + + const decoded = decodeBodyBytes(part.body, part.headers); + ctx.bodyValues[partPath] = decoded; + + if (mime === "text/plain") { + ctx.textBody.push({ partId: partPath, type: mime }); + } else if (mime === "text/html") { + ctx.htmlBody.push({ partId: partPath, type: mime }); + } +} + +/** + * Convert raw MIME bytes into a JMAP Email-shaped object. + * + * Handles text/plain, multipart/mixed, and multipart/signed message shapes. + * For multipart/signed (RFC 3156), the signed content part (part 1) is + * parsed for body and attachments. Signature verification is not performed. + * + * @param raw - Raw RFC 2822 message bytes + * @param mailId - Opaque mail record ID used to generate blob IDs + */ +export function parseMailToEmail(raw: Uint8Array, mailId: string): JMAPEmail { + const { headers: msgHeaders, bodyOffset } = parseHeaderSection(raw); + const body = raw.slice(bodyOffset); + const contentType = msgHeaders.get("content-type") ?? "text/plain"; + const mime = extractContentTypeMime(contentType); + + const ctx: WalkContext = { + mailId, + bodyValues: {}, + textBody: [], + htmlBody: [], + attachments: [], + }; + + if (mime === "multipart/signed") { + // RFC 3156: part 1 is the signed content, part 2 is the signature. + // Parse the content part through to extract body and attachments. + const boundary = extractBoundary(contentType); + if (boundary !== undefined) { + const outerParts = parseMultipart(body, boundary); + const contentPart = outerParts[0]; + if (contentPart !== undefined) { + // The content part may itself be text/plain or multipart/mixed. + // We assign it path "1" and walk it. + walkMimePart(contentPart, "1", ctx); + } + } + } else if (mime.startsWith("multipart/")) { + const boundary = extractBoundary(contentType); + if (boundary !== undefined) { + const parts = parseMultipart(body, boundary); + parts.forEach((partBytes, idx) => { + walkMimePart(partBytes, `${idx + 1}`, ctx); + }); + } + } else { + // Single-part message (e.g. text/plain). + // Reconstruct minimal part bytes with content-type header so parseMimePart works. + const enc = new TextEncoder(); + const ctHeader = `Content-Type: ${contentType}\r\n\r\n`; + const partBytes = new Uint8Array(enc.encode(ctHeader).length + body.length); + partBytes.set(enc.encode(ctHeader), 0); + partBytes.set(body, enc.encode(ctHeader).length); + walkMimePart(partBytes, "1", ctx); + } + + // Extract Interchange-specific headers. + const interchangeHeaders: Record = {}; + for (const [name, value] of msgHeaders) { + if (name.startsWith("interchange-")) { + interchangeHeaders[name] = value; + } + } + + return { + from: parseAddressList(msgHeaders.get("from") ?? ""), + to: parseAddressList(msgHeaders.get("to") ?? ""), + subject: msgHeaders.get("subject") ?? null, + sentAt: parseDateHeader(msgHeaders.get("date")), + bodyValues: ctx.bodyValues, + textBody: ctx.textBody, + htmlBody: ctx.htmlBody, + attachments: ctx.attachments, + headers: interchangeHeaders, + }; +} + +/** + * Decode a MIME part body into raw bytes, honoring Content-Transfer-Encoding. + * + * Unlike `decodeBodyBytes` (which produces a JMAP string value), this returns + * the actual bytes for reconstructing a `MessageAttachment`. A malformed + * base64 body surfaces as a thrown error rather than a silent best-effort + * decode — attachment integrity is load-bearing. + */ +function decodeAttachmentBytes( + body: Uint8Array, + headers: Map, +): Uint8Array { + const cte = (headers.get("content-transfer-encoding") ?? "7bit") + .trim() + .toLowerCase(); + + if (cte === "base64") { + const raw = new TextDecoder("utf-8", { fatal: false }).decode(body); + return base64Decode(raw.replace(/\s+/g, "")); + } + + if (cte === "quoted-printable") { + const raw = new TextDecoder("utf-8", { fatal: false }).decode(body); + const decoded = decodeQuotedPrintable(raw); + const out = new Uint8Array(decoded.length); + for (let i = 0; i < decoded.length; i++) { + out[i] = decoded.charCodeAt(i); + } + return out; + } + + if (cte === "7bit" || cte === "8bit" || cte === "binary") { + return body; + } + + throw new Error( + `decodeAttachmentBytes: unsupported content-transfer-encoding "${cte}"`, + ); +} + +/** + * Extract conversation attachments from raw message bytes as + * `MessageAttachment[]` with decoded payloads. + * + * The conversation signed content is a multipart/mixed whose first part is + * the text body and whose remaining attachment parts (Content-Disposition: + * attachment) carry the binary payloads. Returns an empty array for any + * shape without attachment parts — a bare text/plain signed part, a + * non-multipart/signed message, or a multipart/mixed with only the text + * part — so callers can use it unconditionally. + * + * Counterpart to `assembleConversationSignedPart`: assemble then extract + * round-trips a `MessageAttachment[]`. + */ +export function extractAttachments(raw: Uint8Array): MessageAttachment[] { + const { headers, bodyOffset } = parseHeaderSection(raw); + const body = raw.slice(bodyOffset); + const mime = extractContentTypeMime(headers.get("content-type") ?? ""); + + if (mime !== "multipart/signed") return []; + const outerBoundary = extractBoundary(headers.get("content-type") ?? ""); + if (outerBoundary === undefined) return []; + + const contentPart = parseMultipart(body, outerBoundary)[0]; + if (contentPart === undefined) return []; + + const signed = parseMimePart(contentPart); + if (!extractContentTypeMime(signed.contentType).startsWith("multipart/")) { + return []; + } + const innerBoundary = extractBoundary(signed.contentType); + if (innerBoundary === undefined) return []; + + const attachments: MessageAttachment[] = []; + for (const subPartBytes of parseMultipart(signed.body, innerBoundary)) { + const subPart = parseMimePart(subPartBytes); + if (!isAttachmentPart(subPart.contentType, subPart.headers)) continue; + attachments.push({ + name: extractFilename(subPart.headers) ?? "attachment", + contentType: extractContentTypeMime(subPart.contentType), + data: decodeAttachmentBytes(subPart.body, subPart.headers), + }); + } + return attachments; +} + +// --------------------------------------------------------------------------- +// Decoded-mail model (Mail / MessagePart) — lossless inbound decoding +// --------------------------------------------------------------------------- + +function isInterchangeType(s: string): s is InterchangeType { + return !(InterchangeType(s) instanceof type.errors); +} + +/** + * Build the typed, ergonomic `MessageHeaders` subset from a parsed header map. + * Optional fields are included only when present (exactOptionalPropertyTypes- + * safe). The full, lossless header set is carried separately as `rawHeaders`. + */ +export function buildMessageHeaders( + headers: Map, +): ParsedMessageHeaders { + const from = headers.get("from") ?? ""; + const toRaw = headers.get("to") ?? ""; + const to = toRaw + ? toRaw + .split(",") + .map((s) => s.trim()) + .filter(Boolean) + : []; + + const date = headers.get("date") ?? ""; + const messageId = headers.get("message-id") ?? ""; + + const result: ParsedMessageHeaders = { from, to, date, messageId }; + + const ccRaw = headers.get("cc"); + if (ccRaw !== undefined) { + const cc = ccRaw + .split(",") + .map((s) => s.trim()) + .filter(Boolean); + if (cc.length > 0) result.cc = cc; + } + + const refsRaw = headers.get("references"); + if (refsRaw !== undefined) { + const refs = refsRaw.split(/\s+/).filter(Boolean); + if (refs.length > 0) result.references = refs; + } + + const inReplyTo = headers.get("in-reply-to"); + if (inReplyTo !== undefined) result.inReplyTo = inReplyTo; + + const subject = headers.get("subject"); + if (subject !== undefined) result.subject = subject; + + const listId = headers.get("list-id"); + if (listId !== undefined) result.listId = listId; + + const rawType = headers.get("interchange-type"); + if (rawType !== undefined && isInterchangeType(rawType)) { + result.interchangeType = rawType; + } + + const corrId = headers.get("interchange-correlation-id"); + if (corrId !== undefined) result.interchangeCorrelationId = corrId; + + const tenantId = headers.get("interchange-tenant-id"); + if (tenantId !== undefined) result.interchangeTenantId = tenantId; + + const agentId = headers.get("interchange-agent-id"); + if (agentId !== undefined) result.interchangeAgentId = agentId; + + const sessionId = headers.get("interchange-session-id"); + if (sessionId !== undefined) result.interchangeSessionId = sessionId; + + const offeringId = headers.get("interchange-offering-id"); + if (offeringId !== undefined) result.interchangeOfferingId = offeringId; + + const schemaVersion = headers.get("interchange-schema-version"); + if (schemaVersion !== undefined) + result.interchangeSchemaVersion = schemaVersion; + + const traceparent = headers.get("traceparent"); + if (traceparent !== undefined) result.traceparent = traceparent; + + const tracestate = headers.get("tracestate"); + if (tracestate !== undefined) result.tracestate = tracestate; + + return result; +} + +/** + * Parse every header line in the message's header section into a raw, + * lossless map of lowercased name to its ordered values. Repeated headers + * (e.g. `Received`) keep all occurrences; folded continuation lines are + * unfolded onto the preceding header. Bounded to the header section via + * `headerEnd` so the whole message body is never decoded here. + */ +function parseRawHeaders( + raw: Uint8Array, + headerEnd: number, +): Record { + const text = new TextDecoder("utf-8", { fatal: false }).decode( + raw.subarray(0, headerEnd), + ); + const out: Record = {}; + let current: { name: string; value: string } | null = null; + const flush = (): void => { + if (current === null) return; + const key = current.name.trim().toLowerCase(); + (out[key] ??= []).push(current.value.trim()); + current = null; + }; + for (const line of text.split(/\r\n|\n/)) { + if (line === "") break; + if ((line.startsWith(" ") || line.startsWith("\t")) && current !== null) { + current.value += ` ${line.trim()}`; + continue; + } + const idx = line.indexOf(":"); + if (idx === -1) continue; + flush(); + current = { name: line.slice(0, idx), value: line.slice(idx + 1) }; + } + flush(); + return out; +} + +function parseDisposition( + headers: Map, +): "inline" | "attachment" | undefined { + const d = (headers.get("content-disposition") ?? "").trim().toLowerCase(); + if (d.startsWith("attachment")) return "attachment"; + if (d.startsWith("inline")) return "inline"; + return undefined; +} + +/** + * Recursively collect the decoded leaf parts of a MIME part. A multipart part + * recurses into its children; a leaf part is decoded (transfer-encoding undone) + * into a `MessagePart`. The PGP/MIME signature part is transport plumbing, not + * content, so it is skipped -- which unwraps the `multipart/signed` envelope + * (its two children are the signed content and the signature) for free. + */ +function collectLeafParts(partBytes: Uint8Array): MessagePart[] { + const part = parseMimePart(partBytes); + const mime = extractContentTypeMime(part.contentType); + if (mime === "application/pgp-signature") return []; + if (mime.startsWith("multipart/")) { + const boundary = extractBoundary(part.contentType); + // A multipart part with no boundary is undecodable: its children cannot + // be located. Silently returning [] would drop that content and break the + // lossless contract, so surface it as a decode failure the caller drops. + if (boundary === undefined) { + throw new Error( + `decodeMail: ${mime} part has no boundary parameter; cannot decode its children`, + ); + } + return parseMultipart(part.body, boundary).flatMap(collectLeafParts); + } + const result: MessagePart = { + contentType: mime, + content: decodeAttachmentBytes(part.body, part.headers), + }; + const filename = extractFilename(part.headers); + if (filename !== null) result.filename = filename; + const disposition = parseDisposition(part.headers); + if (disposition !== undefined) result.disposition = disposition; + return [result]; +} + +/** + * Decode a raw inbound MIME message into its lossless parts: the typed header + * subset, the full raw header map, and the flat list of decoded leaf parts + * (the PGP/MIME signature and multipart wrappers removed). This is the + * in-memory form; a caller commits each part's bytes to durable storage to + * produce a JSON-safe `Mail`. Reused across the standalone and deployed + * ingest paths so both see the same decoding. + */ +export function decodeMail(raw: Uint8Array): { + headers: ParsedMessageHeaders; + rawHeaders: Record; + parts: MessagePart[]; +} { + const { headers: singleMap, headerEnd } = parseHeaderSection(raw); + const rawHeaders = parseRawHeaders(raw, headerEnd); + const headers = buildMessageHeaders(singleMap); + const parts = collectLeafParts(raw); + return { headers, rawHeaders, parts }; +} diff --git a/vendor/intx-mime/src/pgp-sign.ts b/vendor/intx-mime/src/pgp-sign.ts new file mode 100644 index 0000000..bb89b48 --- /dev/null +++ b/vendor/intx-mime/src/pgp-sign.ts @@ -0,0 +1,29 @@ +/** + * PGP/MIME signing via CryptoProvider. + * + * createDetachedSignature in @intx/crypto signs with raw private key bytes, + * but callers that only hold a CryptoProvider (which does not expose the + * private key) need this variant. It delegates to the crypto package's + * signer-function primitive, handing it the provider's raw Ed25519 sign + * operation. The OpenPGP packet assembly lives entirely in @intx/crypto; + * this module only adapts a CryptoProvider into the signer the primitive + * expects. + */ + +import { createDetachedSignatureWithSigner } from "@intx/crypto"; +import type { CryptoProvider } from "@intx/types/runtime"; + +/** + * Produce a PGP/MIME detached signature using a CryptoProvider. + * + * Mirrors createDetachedSignature from @intx/crypto but accepts a + * CryptoProvider instead of raw private key bytes. + */ +export async function createDetachedSignatureFromProvider( + content: Uint8Array, + provider: CryptoProvider, +): Promise { + return createDetachedSignatureWithSigner(content, (input) => + provider.sign(input), + ); +} diff --git a/vendor/intx-types/LICENSE b/vendor/intx-types/LICENSE new file mode 100644 index 0000000..c6487f4 --- /dev/null +++ b/vendor/intx-types/LICENSE @@ -0,0 +1,176 @@ +GNU LESSER GENERAL PUBLIC LICENSE + +Version 2.1, February 1999 + +Copyright (C) 1991, 1999 Free Software Foundation, Inc. +51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA + +Everyone is permitted to copy and distribute verbatim copies of this license document, but changing it is not allowed. + +[This is the first released version of the Lesser GPL. It also counts as the successor of the GNU Library Public License, version 2, hence the version number 2.1.] + +Preamble + +The licenses for most software are designed to take away your freedom to share and change it. By contrast, the GNU General Public Licenses are intended to guarantee your freedom to share and change free software--to make sure the software is free for all its users. + +This license, the Lesser General Public License, applies to some specially designated software packages--typically libraries--of the Free Software Foundation and other authors who decide to use it. You can use it too, but we suggest you first think carefully about whether this license or the ordinary General Public License is the better strategy to use in any particular case, based on the explanations below. + +When we speak of free software, we are referring to freedom of use, not price. Our General Public Licenses are designed to make sure that you have the freedom to distribute copies of free software (and charge for this service if you wish); that you receive source code or can get it if you want it; that you can change the software and use pieces of it in new free programs; and that you are informed that you can do these things. + +To protect your rights, we need to make restrictions that forbid distributors to deny you these rights or to ask you to surrender these rights. These restrictions translate to certain responsibilities for you if you distribute copies of the library or if you modify it. + +For example, if you distribute copies of the library, whether gratis or for a fee, you must give the recipients all the rights that we gave you. You must make sure that they, too, receive or can get the source code. If you link other code with the library, you must provide complete object files to the recipients, so that they can relink them with the library after making changes to the library and recompiling it. And you must show them these terms so they know their rights. + +We protect your rights with a two-step method: (1) we copyright the library, and (2) we offer you this license, which gives you legal permission to copy, distribute and/or modify the library. + +To protect each distributor, we want to make it very clear that there is no warranty for the free library. Also, if the library is modified by someone else and passed on, the recipients should know that what they have is not the original version, so that the original author's reputation will not be affected by problems that might be introduced by others. + +Finally, software patents pose a constant threat to the existence of any free program. We wish to make sure that a company cannot effectively restrict the users of a free program by obtaining a restrictive license from a patent holder. Therefore, we insist that any patent license obtained for a version of the library must be consistent with the full freedom of use specified in this license. + +Most GNU software, including some libraries, is covered by the ordinary GNU General Public License. This license, the GNU Lesser General Public License, applies to certain designated libraries, and is quite different from the ordinary General Public License. We use this license for certain libraries in order to permit linking those libraries into non-free programs. + +When a program is linked with a library, whether statically or using a shared library, the combination of the two is legally speaking a combined work, a derivative of the original library. The ordinary General Public License therefore permits such linking only if the entire combination fits its criteria of freedom. The Lesser General Public License permits more lax criteria for linking other code with the library. + +We call this license the "Lesser" General Public License because it does Less to protect the user's freedom than the ordinary General Public License. It also provides other free software developers Less of an advantage over competing non-free programs. These disadvantages are the reason we use the ordinary General Public License for many libraries. However, the Lesser license provides advantages in certain special circumstances. + +For example, on rare occasions, there may be a special need to encourage the widest possible use of a certain library, so that it becomes a de-facto standard. To achieve this, non-free programs must be allowed to use the library. A more frequent case is that a free library does the same job as widely used non-free libraries. In this case, there is little to gain by limiting the free library to free software only, so we use the Lesser General Public License. + +In other cases, permission to use a particular library in non-free programs enables a greater number of people to use a large body of free software. For example, permission to use the GNU C Library in non-free programs enables many more people to use the whole GNU operating system, as well as its variant, the GNU/Linux operating system. + +Although the Lesser General Public License is Less protective of the users' freedom, it does ensure that the user of a program that is linked with the Library has the freedom and the wherewithal to run that program using a modified version of the Library. + +The precise terms and conditions for copying, distribution and modification follow. Pay close attention to the difference between a "work based on the library" and a "work that uses the library". The former contains code derived from the library, whereas the latter must be combined with the library in order to run. + +GNU LESSER GENERAL PUBLIC LICENSE +TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION + +0. This License Agreement applies to any software library or other program which contains a notice placed by the copyright holder or other authorized party saying it may be distributed under the terms of this Lesser General Public License (also called "this License"). Each licensee is addressed as "you". + +A "library" means a collection of software functions and/or data prepared so as to be conveniently linked with application programs (which use some of those functions and data) to form executables. + +The "Library", below, refers to any such software library or work which has been distributed under these terms. A "work based on the Library" means either the Library or any derivative work under copyright law: that is to say, a work containing the Library or a portion of it, either verbatim or with modifications and/or translated straightforwardly into another language. (Hereinafter, translation is included without limitation in the term "modification".) + +"Source code" for a work means the preferred form of the work for making modifications to it. For a library, complete source code means all the source code for all modules it contains, plus any associated interface definition files, plus the scripts used to control compilation and installation of the library. + +Activities other than copying, distribution and modification are not covered by this License; they are outside its scope. The act of running a program using the Library is not restricted, and output from such a program is covered only if its contents constitute a work based on the Library (independent of the use of the Library in a tool for writing it). Whether that is true depends on what the Library does and what the program that uses the Library does. + +1. You may copy and distribute verbatim copies of the Library's complete source code as you receive it, in any medium, provided that you conspicuously and appropriately publish on each copy an appropriate copyright notice and disclaimer of warranty; keep intact all the notices that refer to this License and to the absence of any warranty; and distribute a copy of this License along with the Library. + +You may charge a fee for the physical act of transferring a copy, and you may at your option offer warranty protection in exchange for a fee. + +2. You may modify your copy or copies of the Library or any portion of it, thus forming a work based on the Library, and copy and distribute such modifications or work under the terms of Section 1 above, provided that you also meet all of these conditions: + + a) The modified work must itself be a software library. + + b) You must cause the files modified to carry prominent notices stating that you changed the files and the date of any change. + + c) You must cause the whole of the work to be licensed at no charge to all third parties under the terms of this License. + + d) If a facility in the modified Library refers to a function or a table of data to be supplied by an application program that uses the facility, other than as an argument passed when the facility is invoked, then you must make a good faith effort to ensure that, in the event an application does not supply such function or table, the facility still operates, and performs whatever part of its purpose remains meaningful. + +(For example, a function in a library to compute square roots has a purpose that is entirely well-defined independent of the application. Therefore, Subsection 2d requires that any application-supplied function or table used by this function must be optional: if the application does not supply it, the square root function must still compute square roots.) + +These requirements apply to the modified work as a whole. If identifiable sections of that work are not derived from the Library, and can be reasonably considered independent and separate works in themselves, then this License, and its terms, do not apply to those sections when you distribute them as separate works. But when you distribute the same sections as part of a whole which is a work based on the Library, the distribution of the whole must be on the terms of this License, whose permissions for other licensees extend to the entire whole, and thus to each and every part regardless of who wrote it. + +Thus, it is not the intent of this section to claim rights or contest your rights to work written entirely by you; rather, the intent is to exercise the right to control the distribution of derivative or collective works based on the Library. + +In addition, mere aggregation of another work not based on the Library with the Library (or with a work based on the Library) on a volume of a storage or distribution medium does not bring the other work under the scope of this License. + +3. You may opt to apply the terms of the ordinary GNU General Public License instead of this License to a given copy of the Library. To do this, you must alter all the notices that refer to this License, so that they refer to the ordinary GNU General Public License, version 2, instead of to this License. (If a newer version than version 2 of the ordinary GNU General Public License has appeared, then you can specify that version instead if you wish.) Do not make any other change in these notices. + +Once this change is made in a given copy, it is irreversible for that copy, so the ordinary GNU General Public License applies to all subsequent copies and derivative works made from that copy. + +This option is useful when you wish to copy part of the code of the Library into a program that is not a library. + +4. You may copy and distribute the Library (or a portion or derivative of it, under Section 2) in object code or executable form under the terms of Sections 1 and 2 above provided that you accompany it with the complete corresponding machine-readable source code, which must be distributed under the terms of Sections 1 and 2 above on a medium customarily used for software interchange. + +If distribution of object code is made by offering access to copy from a designated place, then offering equivalent access to copy the source code from the same place satisfies the requirement to distribute the source code, even though third parties are not compelled to copy the source along with the object code. + +5. A program that contains no derivative of any portion of the Library, but is designed to work with the Library by being compiled or linked with it, is called a "work that uses the Library". Such a work, in isolation, is not a derivative work of the Library, and therefore falls outside the scope of this License. + +However, linking a "work that uses the Library" with the Library creates an executable that is a derivative of the Library (because it contains portions of the Library), rather than a "work that uses the library". The executable is therefore covered by this License. Section 6 states terms for distribution of such executables. + +When a "work that uses the Library" uses material from a header file that is part of the Library, the object code for the work may be a derivative work of the Library even though the source code is not. Whether this is true is especially significant if the work can be linked without the Library, or if the work is itself a library. The threshold for this to be true is not precisely defined by law. + +If such an object file uses only numerical parameters, data structure layouts and accessors, and small macros and small inline functions (ten lines or less in length), then the use of the object file is unrestricted, regardless of whether it is legally a derivative work. (Executables containing this object code plus portions of the Library will still fall under Section 6.) + +Otherwise, if the work is a derivative of the Library, you may distribute the object code for the work under the terms of Section 6. Any executables containing that work also fall under Section 6, whether or not they are linked directly with the Library itself. + +6. As an exception to the Sections above, you may also combine or link a "work that uses the Library" with the Library to produce a work containing portions of the Library, and distribute that work under terms of your choice, provided that the terms permit modification of the work for the customer's own use and reverse engineering for debugging such modifications. + +You must give prominent notice with each copy of the work that the Library is used in it and that the Library and its use are covered by this License. You must supply a copy of this License. If the work during execution displays copyright notices, you must include the copyright notice for the Library among them, as well as a reference directing the user to the copy of this License. Also, you must do one of these things: + + a) Accompany the work with the complete corresponding machine-readable source code for the Library including whatever changes were used in the work (which must be distributed under Sections 1 and 2 above); and, if the work is an executable linked with the Library, with the complete machine-readable "work that uses the Library", as object code and/or source code, so that the user can modify the Library and then relink to produce a modified executable containing the modified Library. (It is understood that the user who changes the contents of definitions files in the Library will not necessarily be able to recompile the application to use the modified definitions.) + + b) Use a suitable shared library mechanism for linking with the Library. A suitable mechanism is one that (1) uses at run time a copy of the library already present on the user's computer system, rather than copying library functions into the executable, and (2) will operate properly with a modified version of the library, if the user installs one, as long as the modified version is interface-compatible with the version that the work was made with. + + c) Accompany the work with a written offer, valid for at least three years, to give the same user the materials specified in Subsection 6a, above, for a charge no more than the cost of performing this distribution. + + d) If distribution of the work is made by offering access to copy from a designated place, offer equivalent access to copy the above specified materials from the same place. + + e) Verify that the user has already received a copy of these materials or that you have already sent this user a copy. + +For an executable, the required form of the "work that uses the Library" must include any data and utility programs needed for reproducing the executable from it. However, as a special exception, the materials to be distributed need not include anything that is normally distributed (in either source or binary form) with the major components (compiler, kernel, and so on) of the operating system on which the executable runs, unless that component itself accompanies the executable. + +It may happen that this requirement contradicts the license restrictions of other proprietary libraries that do not normally accompany the operating system. Such a contradiction means you cannot use both them and the Library together in an executable that you distribute. + +7. You may place library facilities that are a work based on the Library side-by-side in a single library together with other library facilities not covered by this License, and distribute such a combined library, provided that the separate distribution of the work based on the Library and of the other library facilities is otherwise permitted, and provided that you do these two things: + + a) Accompany the combined library with a copy of the same work based on the Library, uncombined with any other library facilities. This must be distributed under the terms of the Sections above. + + b) Give prominent notice with the combined library of the fact that part of it is a work based on the Library, and explaining where to find the accompanying uncombined form of the same work. + +8. You may not copy, modify, sublicense, link with, or distribute the Library except as expressly provided under this License. Any attempt otherwise to copy, modify, sublicense, link with, or distribute the Library is void, and will automatically terminate your rights under this License. However, parties who have received copies, or rights, from you under this License will not have their licenses terminated so long as such parties remain in full compliance. + +9. You are not required to accept this License, since you have not signed it. However, nothing else grants you permission to modify or distribute the Library or its derivative works. These actions are prohibited by law if you do not accept this License. Therefore, by modifying or distributing the Library (or any work based on the Library), you indicate your acceptance of this License to do so, and all its terms and conditions for copying, distributing or modifying the Library or works based on it. + +10. Each time you redistribute the Library (or any work based on the Library), the recipient automatically receives a license from the original licensor to copy, distribute, link with or modify the Library subject to these terms and conditions. You may not impose any further restrictions on the recipients' exercise of the rights granted herein. You are not responsible for enforcing compliance by third parties with this License. + +11. If, as a consequence of a court judgment or allegation of patent infringement or for any other reason (not limited to patent issues), conditions are imposed on you (whether by court order, agreement or otherwise) that contradict the conditions of this License, they do not excuse you from the conditions of this License. If you cannot distribute so as to satisfy simultaneously your obligations under this License and any other pertinent obligations, then as a consequence you may not distribute the Library at all. For example, if a patent license would not permit royalty-free redistribution of the Library by all those who receive copies directly or indirectly through you, then the only way you could satisfy both it and this License would be to refrain entirely from distribution of the Library. + +If any portion of this section is held invalid or unenforceable under any particular circumstance, the balance of the section is intended to apply, and the section as a whole is intended to apply in other circumstances. + +It is not the purpose of this section to induce you to infringe any patents or other property right claims or to contest validity of any such claims; this section has the sole purpose of protecting the integrity of the free software distribution system which is implemented by public license practices. Many people have made generous contributions to the wide range of software distributed through that system in reliance on consistent application of that system; it is up to the author/donor to decide if he or she is willing to distribute software through any other system and a licensee cannot impose that choice. + +This section is intended to make thoroughly clear what is believed to be a consequence of the rest of this License. + +12. If the distribution and/or use of the Library is restricted in certain countries either by patents or by copyrighted interfaces, the original copyright holder who places the Library under this License may add an explicit geographical distribution limitation excluding those countries, so that distribution is permitted only in or among countries not thus excluded. In such case, this License incorporates the limitation as if written in the body of this License. + +13. The Free Software Foundation may publish revised and/or new versions of the Lesser General Public License from time to time. Such new versions will be similar in spirit to the present version, but may differ in detail to address new problems or concerns. + +Each version is given a distinguishing version number. If the Library specifies a version number of this License which applies to it and "any later version", you have the option of following the terms and conditions either of that version or of any later version published by the Free Software Foundation. If the Library does not specify a license version number, you may choose any version ever published by the Free Software Foundation. + +14. If you wish to incorporate parts of the Library into other free programs whose distribution conditions are incompatible with these, write to the author to ask for permission. For software which is copyrighted by the Free Software Foundation, write to the Free Software Foundation; we sometimes make exceptions for this. Our decision will be guided by the two goals of preserving the free status of all derivatives of our free software and of promoting the sharing and reuse of software generally. + +NO WARRANTY + +15. BECAUSE THE LIBRARY IS LICENSED FREE OF CHARGE, THERE IS NO WARRANTY FOR THE LIBRARY, TO THE EXTENT PERMITTED BY APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR OTHER PARTIES PROVIDE THE LIBRARY "AS IS" WITHOUT WARRANTY OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE LIBRARY IS WITH YOU. SHOULD THE LIBRARY PROVE DEFECTIVE, YOU ASSUME THE COST OF ALL NECESSARY SERVICING, REPAIR OR CORRECTION. + +16. IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MAY MODIFY AND/OR REDISTRIBUTE THE LIBRARY AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE USE OR INABILITY TO USE THE LIBRARY (INCLUDING BUT NOT LIMITED TO LOSS OF DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD PARTIES OR A FAILURE OF THE LIBRARY TO OPERATE WITH ANY OTHER SOFTWARE), EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH DAMAGES. + +END OF TERMS AND CONDITIONS + +How to Apply These Terms to Your New Libraries + +If you develop a new library, and you want it to be of the greatest possible use to the public, we recommend making it free software that everyone can redistribute and change. You can do so by permitting redistribution under these terms (or, alternatively, under the terms of the ordinary General Public License). + +To apply these terms, attach the following notices to the library. It is safest to attach them to the start of each source file to most effectively convey the exclusion of warranty; and each file should have at least the "copyright" line and a pointer to where the full notice is found. + + one line to give the library's name and an idea of what it does. + Copyright (C) year name of author + + This library is free software; you can redistribute it and/or modify it under the terms of the GNU Lesser General Public License as published by the Free Software Foundation; either version 2.1 of the License, or (at your option) any later version. + + This library is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public License for more details. + + You should have received a copy of the GNU Lesser General Public License along with this library; if not, write to the Free Software Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA Also add information on how to contact you by electronic and paper mail. + +You should also get your employer (if you work as a programmer) or your school, if any, to sign a "copyright disclaimer" for the library, if necessary. Here is a sample; alter the names: + +Yoyodyne, Inc., hereby disclaims all copyright interest in +the library `Frob' (a library for tweaking knobs) written +by James Random Hacker. + +signature of Ty Coon, 1 April 1990 +Ty Coon, President of Vice +That's all there is to it! diff --git a/vendor/intx-types/package.json b/vendor/intx-types/package.json new file mode 100644 index 0000000..c560849 --- /dev/null +++ b/vendor/intx-types/package.json @@ -0,0 +1,78 @@ +{ + "name": "@intx/types", + "description": "Runtime validators, API contract types, runtime interfaces, and sidecar wire frames for Interchange", + "version": "0.3.0", + "license": "LGPL-2.1-only", + "type": "module", + "exports": { + ".": { + "types": "./src/index.ts", + "default": "./src/index.ts" + }, + "./authz": { + "types": "./src/authz.ts", + "default": "./src/authz.ts" + }, + "./audit": { + "types": "./src/audit.ts", + "default": "./src/audit.ts" + }, + "./content-type": { + "types": "./src/content-type.ts", + "default": "./src/content-type.ts" + }, + "./runtime": { + "types": "./src/runtime.ts", + "default": "./src/runtime.ts" + }, + "./runtime-capabilities": { + "types": "./src/runtime-capabilities.ts", + "default": "./src/runtime-capabilities.ts" + }, + "./sidecar": { + "types": "./src/sidecar.ts", + "default": "./src/sidecar.ts" + }, + "./grant-wire": { + "types": "./src/grant-wire.ts", + "default": "./src/grant-wire.ts" + }, + "./tool-packages": { + "types": "./src/tool-packages.ts", + "default": "./src/tool-packages.ts" + }, + "./package-json": { + "types": "./src/package-json.ts", + "default": "./src/package-json.ts" + }, + "./wire-definition-hash": { + "types": "./src/wire-definition-hash.ts", + "default": "./src/wire-definition-hash.ts" + }, + "./workflow-sources": { + "types": "./src/workflow-sources.ts", + "default": "./src/workflow-sources.ts" + } + }, + "scripts": { + "typecheck": "tsc --noEmit" + }, + "dependencies": { + "arktype": "2.1.29", + "semver": "^7.7.2" + }, + "devDependencies": { + "@types/bun": "1.1.14", + "@types/semver": "^7.7.1", + "typescript": "5.7.2" + }, + "files": [ + "src", + "README.md", + "LICENSE" + ], + "sideEffects": false, + "publishConfig": { + "access": "public" + } +} diff --git a/vendor/intx-types/src/agent-address.ts b/vendor/intx-types/src/agent-address.ts new file mode 100644 index 0000000..2af45e5 --- /dev/null +++ b/vendor/intx-types/src/agent-address.ts @@ -0,0 +1,34 @@ +// Run addresses are "@" where runId is the local part: the +// `run_`-prefixed identifier that names the run. These helpers are the single +// source of truth for that format. +// +// The shape of the right-hand side of the "@" is not validated beyond the +// requirement that it be non-empty: tightening the contract (DNS-ish +// validation, normalisation, etc.) is a separate follow-up. +// +// `@intx/hub-sessions`'s `parseAgentId` is the canonical throwing wrapper +// over `parseRunAddress` — call it when a `null` return would +// propagate as a silent bug, and keep this parser's `null` return +// reserved for callers that already have a structured fallback. + +const RUN_PREFIX = "run_"; + +export function formatRunAddress(runId: string, domain: string): string { + return `${runId}@${domain}`; +} + +export function parseRunAddress( + address: string, +): { runId: string; domain: string } | null { + const atIdx = address.indexOf("@"); + if (atIdx <= 0) return null; + const runId = address.slice(0, atIdx); + const domain = address.slice(atIdx + 1); + if (!runId.startsWith(RUN_PREFIX)) return null; + if (domain.length === 0) return null; + return { runId, domain }; +} + +export function isRunAddress(address: string): boolean { + return parseRunAddress(address) !== null; +} diff --git a/vendor/intx-types/src/agent-data.ts b/vendor/intx-types/src/agent-data.ts new file mode 100644 index 0000000..7788119 --- /dev/null +++ b/vendor/intx-types/src/agent-data.ts @@ -0,0 +1,43 @@ +import { type } from "arktype"; + +export const FileEntry = type({ + path: "string", + type: "'file' | 'directory'", + "size?": "number | null", + "modifiedAt?": "string | null", +}); + +export const FileContent = type({ + path: "string", + content: "string", + "encoding?": "'utf-8' | 'base64'", +}); + +export const HistoryEntry = type({ + ref: "string", + message: "string", + author: "string", + timestamp: "string", + "filesChanged?": "number", +}); + +export const CommitDetail = type({ + ref: "string", + message: "string", + author: "string", + timestamp: "string", + changes: type({ + path: "string", + status: "'added' | 'modified' | 'deleted'", + "additions?": "number", + "deletions?": "number", + }).array(), +}); + +export const BranchInfo = type({ + name: "string", + "isCurrent?": "boolean", + "lastCommitRef?": "string | null", + "lastCommitMessage?": "string | null", + "lastCommitAt?": "string | null", +}); diff --git a/vendor/intx-types/src/approvals.ts b/vendor/intx-types/src/approvals.ts new file mode 100644 index 0000000..a7f6a0e --- /dev/null +++ b/vendor/intx-types/src/approvals.ts @@ -0,0 +1,38 @@ +import { type } from "arktype"; + +export const ApprovalResponse = type({ + id: "string", + tenantId: "string", + anchorRunId: type("string").describe( + "The anchor run the approval originates from. Every approval is raised during a workflow run; there is no launched single agent or agent-definition row behind it.", + ), + runId: "string", + agentAddress: "string", + correlationId: type("string").describe( + "Ties the approval to the suspension it resolves. The parked run awaits the control signal keyed by this id.", + ), + toolDefinition: type("Record").describe( + "The approver-facing tool snapshot (name, description, input schema) captured at suspend time.", + ), + toolArguments: "Record", + scope: "'once' | 'always' | null", + status: "'pending' | 'approved' | 'rejected' | 'timeout' | 'expired'", + timeoutAt: type("string | null").describe( + "Deadline after which the approval expires. Null records a hold-indefinitely approval with no deadline.", + ), + resolvedAt: "string | null", + createdAt: "string", + updatedAt: "string", +}); + +export const ApproveAction = type({ + scope: "'once' | 'always'", +}); + +export const RejectAction = type({ + // Optional so a plain one-time rejection (the default) stays a bare body. + // Scope 'always' records a standing rejection: the tool's ask gate is set to + // a standing deny for the run, so it is blocked without asking again. + "scope?": "'once' | 'always'", + "message?": "string", +}); diff --git a/vendor/intx-types/src/assets.ts b/vendor/intx-types/src/assets.ts new file mode 100644 index 0000000..9f713cf --- /dev/null +++ b/vendor/intx-types/src/assets.ts @@ -0,0 +1,42 @@ +import { type } from "arktype"; + +const assetKindDescription = + "Category of the asset, used together with `name` to address it. The (kind, name) pair is what callers resolve against, and it is unique within a tenant."; + +export const AssetResponse = type({ + id: "string", + tenantId: "string", + kind: type("string").describe(assetKindDescription), + name: "string", + displayName: "string | null", + creatorPrincipalId: "string | null", + createdAt: "string", + updatedAt: "string", +}); + +/** + * `AssetResponse` extended with the tenant that supplied the row. The + * inherited-list endpoint stamps every row with this tag so callers can + * distinguish locally-defined assets from inherited ones without + * issuing a second round-trip per row. + */ +export const AssetWithOriginResponse = type({ + id: "string", + tenantId: "string", + kind: type("string").describe(assetKindDescription), + name: "string", + displayName: "string | null", + creatorPrincipalId: "string | null", + createdAt: "string", + updatedAt: "string", + origin: type({ + tenantId: type("string").describe( + "The tenant that supplied this row -- either the queried tenant itself or an ancestor it inherits from.", + ), + direct: type("boolean").describe( + "True when the asset is declared on the queried tenant itself; false when it is inherited from an ancestor tenant.", + ), + }).describe( + "Which tenant in the hierarchy this asset row came from, distinguishing locally-defined assets from inherited ones.", + ), +}); diff --git a/vendor/intx-types/src/attachments.ts b/vendor/intx-types/src/attachments.ts new file mode 100644 index 0000000..ffae817 --- /dev/null +++ b/vendor/intx-types/src/attachments.ts @@ -0,0 +1,66 @@ +// Attachment allowlist — the system-level source of truth for which MIME +// types the hub accepts as conversation attachments and which ContentBlock +// category each maps to. Adding a MIME type is a one-line change here. +// +// This is the hard capability ceiling: a type is only useful if the pipeline +// can produce the right ContentBlock and an adapter can marshal it. Per-agent +// or per-workflow narrowing rides on top of this ceiling — it narrows the +// accepted set, it never widens past what the adapters support. + +export const ATTACHMENT_CATEGORIES = [ + "image", + "video", + "audio", + "document", +] as const; +export type AttachmentCategory = (typeof ATTACHMENT_CATEGORIES)[number]; + +export const ATTACHMENT_ALLOWLIST = { + "image/png": "image", + "image/jpeg": "image", + "image/gif": "image", + "image/webp": "image", + "image/heic": "image", + "image/heif": "image", + "video/mp4": "video", + "video/webm": "video", + "video/quicktime": "video", + "audio/mpeg": "audio", + "audio/wav": "audio", + "audio/ogg": "audio", + "audio/webm": "audio", + "application/pdf": "document", + "application/json": "document", + "text/plain": "document", + "text/csv": "document", + "text/markdown": "document", +} as const satisfies Record; + +export type AllowedMimeType = keyof typeof ATTACHMENT_ALLOWLIST; + +export function isAllowedMimeType( + mimeType: string, +): mimeType is AllowedMimeType { + return mimeType in ATTACHMENT_ALLOWLIST; +} + +/** + * The ContentBlock category for an allowlisted MIME type, or `undefined` + * when the type is not on the allowlist. Callers decide how to treat an + * unknown type (the route rejects it at the boundary; turn construction + * surfaces it as a text marker). + */ +export function attachmentCategory( + mimeType: string, +): AttachmentCategory | undefined { + if (isAllowedMimeType(mimeType)) { + return ATTACHMENT_ALLOWLIST[mimeType]; + } + return undefined; +} + +// Default size limits, on decoded bytes. These are the system-level +// ceiling; a future per-agent/per-workflow policy resolves an effective +// limit that defaults to these. +export const PER_ATTACHMENT_LIMIT_BYTES = 10 * 1024 * 1024; +export const PER_MESSAGE_TOTAL_LIMIT_BYTES = 30 * 1024 * 1024; diff --git a/vendor/intx-types/src/audit.ts b/vendor/intx-types/src/audit.ts new file mode 100644 index 0000000..42cbad1 --- /dev/null +++ b/vendor/intx-types/src/audit.ts @@ -0,0 +1,49 @@ +import { type } from "arktype"; + +import { MatchedGrant, grantEffects } from "./grants"; + +const Effect = type.enumerated(...grantEffects); + +export const AuditAuthz = type({ + effect: Effect.or("null").describe( + "The authorization outcome the runtime resolved for this tool call: `allow`, `deny`, or `ask`, or `null` when no grant matched.", + ), + "resolvedBy?": MatchedGrant.or("null").describe( + "The single grant whose effect determined the outcome (the most specific match), or `null` when nothing matched.", + ), + matchingGrants: MatchedGrant.array(), + blocked: type("boolean").describe( + "True when the runtime prevented the tool call from executing because authorization did not resolve to `allow`.", + ), + "blockReason?": "string", +}); +export type AuditAuthz = typeof AuditAuthz.infer; + +export const AuditRecord = type({ + callId: "string", + tool: "string", + arguments: "Record", + authz: AuditAuthz.or("null"), + result: type({ + content: "string | Record", + isError: "boolean", + }), + timestamp: "string", + sessionId: "string", + // Monotonic sequence number from the reactor's tool.done event. + // Supplied by the caller; the reactor owns the sequence. + seq: "number.integer >= 0", +}); +export type AuditRecord = typeof AuditRecord.infer; + +export const ErrorRecord = type({ + source: "'inference' | 'reactor'", + category: "string", + message: "string", + "statusCode?": "number.integer", + fatal: "boolean", + timestamp: "string", + sessionId: "string", + seq: "number.integer >= 0", +}); +export type ErrorRecord = typeof ErrorRecord.infer; diff --git a/vendor/intx-types/src/authz.ts b/vendor/intx-types/src/authz.ts new file mode 100644 index 0000000..9f0d6bd --- /dev/null +++ b/vendor/intx-types/src/authz.ts @@ -0,0 +1,49 @@ +export type Effect = "allow" | "deny" | "ask"; + +export type GrantRule = { + id: string; + resource: string; + action: string; + effect: Effect; + origin: "system" | "role" | "creator" | "invoker"; + conditions: Record | null; + expiresAt: Date | null; + roleId: string | null; + principalId: string | null; +}; + +export type GrantStore = { + collectGrants(principalId: string, tenantId: string): Promise; + /** + * Like `collectGrants`, but unions the principal's grants across the tenant + * ancestor chain (the acting tenant plus every ancestor up to the root) + * rather than a single tenant. Only the source-resolution credential-use + * check uses this: it mirrors the ancestor-chain reach of credential + * resolution so a `credential:{id}` / `use` grant stamped with an inherited + * credential's own (ancestor) tenant still authorizes use. The general RBAC + * path stays on the single-tenant `collectGrants`. + */ + collectGrantsInChain( + principalId: string, + tenantId: string, + ): Promise; +}; + +export type ConditionContext = { + now: Date; + resource: string; + action: string; + principalId: string; + tenantId: string; + // Identity of the capability consumer the decision is being made for + // (e.g. a `tool:` consumer). Empty when no consumer is in scope; + // a consumer-scoped condition fails closed against an empty consumer. + consumer: string; +}; + +export type ConditionEvaluator = ( + value: unknown, + ctx: ConditionContext, +) => boolean | Promise; + +export type ConditionRegistry = Record; diff --git a/vendor/intx-types/src/base64.ts b/vendor/intx-types/src/base64.ts new file mode 100644 index 0000000..5158372 --- /dev/null +++ b/vendor/intx-types/src/base64.ts @@ -0,0 +1,22 @@ +// Base64 codec for byte strings. +// +// Used to ship binary mail bodies over text-only WebSocket frames. +// Centralizing here keeps the encoding stable across the sidecar/hub +// boundary and any other consumer that needs the same wire shape. + +export function base64Encode(bytes: Uint8Array): string { + let binary = ""; + for (const byte of bytes) { + binary += String.fromCharCode(byte); + } + return btoa(binary); +} + +export function base64Decode(base64: string): Uint8Array { + const binary = atob(base64); + const bytes = new Uint8Array(binary.length); + for (let i = 0; i < binary.length; i++) { + bytes[i] = binary.charCodeAt(i); + } + return bytes; +} diff --git a/vendor/intx-types/src/base64url.ts b/vendor/intx-types/src/base64url.ts new file mode 100644 index 0000000..fdfacb3 --- /dev/null +++ b/vendor/intx-types/src/base64url.ts @@ -0,0 +1,22 @@ +// Base64url codec for byte strings (RFC 4648 section 5). +// +// URL- and filename-safe base64: standard base64 with `+`/`/` replaced by +// `-`/`_` and trailing `=` padding stripped. Used for opaque pagination +// cursors and git PAT secrets that ride in URLs and HTTP basic-auth headers, +// where the standard `+`, `/`, and `=` characters are unsafe. Reuses the +// base64 core so the two encodings stay byte-compatible. + +import { base64Decode, base64Encode } from "./base64"; + +export function base64urlEncode(bytes: Uint8Array): string { + return base64Encode(bytes) + .replace(/\+/g, "-") + .replace(/\//g, "_") + .replace(/=+$/, ""); +} + +export function base64urlDecode(s: string): Uint8Array { + const translated = s.replace(/-/g, "+").replace(/_/g, "/"); + const padLength = (4 - (translated.length % 4)) % 4; + return base64Decode(translated + "=".repeat(padLength)); +} diff --git a/vendor/intx-types/src/capabilities.ts b/vendor/intx-types/src/capabilities.ts new file mode 100644 index 0000000..933819d --- /dev/null +++ b/vendor/intx-types/src/capabilities.ts @@ -0,0 +1,59 @@ +import { type } from "arktype"; + +// The capabilities the production inference runtime demonstrates on the wire, +// and that the discovery rig probes for. This is the single source of truth for +// the shared capability vocabulary: @intx/inference-discovery imports this list +// and extends it, so production code never has to depend on the discovery +// package. Each capability that has a streaming wire flow distinct from its +// buffered one carries a paired `-streaming` variant; `function-calling` is the +// sole base with no streaming pair (a bare tool call has no delta flow to +// capture). +export const WIRE_CAPABILITIES = [ + "plain-text", + "plain-text-streaming", + "function-calling", + "function-calling-multi-turn", + "function-calling-multi-turn-streaming", + "function-calling-with-thinking", + "function-calling-with-thinking-streaming", + "vision-input", + "vision-input-streaming", + "audio-input", + "audio-input-streaming", + "video-input", + "video-input-streaming", + "document-input", + "document-input-streaming", + "image-output", + "image-output-streaming", + "code-execution", + "code-execution-streaming", + "reasoning-content", + "reasoning-content-streaming", + "grounding", + "grounding-streaming", + "files-api-reference", + "files-api-reference-streaming", + "redacted-thinking", + "redacted-thinking-streaming", + "structured-output", + "structured-output-streaming", +] as const; + +// Capabilities a model has that are not observable on the wire and cannot be +// proven by a discovery fixture. `long-context` denotes a model advertising a +// context window of at least ~200k tokens (a curation criterion, not a stored +// limit); `prompt-caching` denotes provider-side prompt caching. The discovery +// rig has no probe that could prove either, so operators curate them by hand. +export const CURATED_CAPABILITIES = ["long-context", "prompt-caching"] as const; + +export const CAPABILITIES = [ + ...WIRE_CAPABILITIES, + ...CURATED_CAPABILITIES, +] as const; +export type Capability = (typeof CAPABILITIES)[number]; +export const Capability = type + .enumerated(...CAPABILITIES) + .describe( + "A capability a provider advertises for a model: a wire capability the inference runtime supports, or one of the curated tags `long-context` and `prompt-caching`.", + ); diff --git a/vendor/intx-types/src/catalog.ts b/vendor/intx-types/src/catalog.ts new file mode 100644 index 0000000..222ccb0 --- /dev/null +++ b/vendor/intx-types/src/catalog.ts @@ -0,0 +1,257 @@ +import { type } from "arktype"; + +import { Capability } from "./capabilities"; + +export const modelProviderPlugins = [ + "anthropic", + "openai", + "openai-compatible", + "google-genai", + // Local delta (CL-7510): the workbench's loopback-OAuth providers (Codex, + // xai-oauth) speak OpenAI's Responses protocol and ride this plugin id. + "openai-responses", +] as const; +export type ModelProviderPlugin = (typeof modelProviderPlugins)[number]; +export const ModelProviderPlugin = type + .enumerated(...modelProviderPlugins) + .describe( + "The inference adapter that serves this provider's models, dispatched by the runtime provider registry.", + ); + +export const providerPreferenceModes = ["pin", "prefer"] as const; +export type ProviderPreferenceMode = (typeof providerPreferenceModes)[number]; + +export const ProviderPreference = type({ + mode: type + .enumerated(...providerPreferenceModes) + .describe( + "`pin` restricts resolution to the listed providers and fails over only among them; `prefer` orders the listed providers first but keeps the rest of the tenant's providers as fallback.", + ), + order: type("string[]").describe( + "Model-provider names in preferred order, most preferred first.", + ), +}); +export type ProviderPreference = typeof ProviderPreference.infer; + +export const ModelRequirement = type({ + model: type("string").describe( + "Canonical model name the agent requires for inference.", + ), + "capabilities?": Capability.array().describe( + "An offering must advertise every one of these capabilities to be eligible to serve this requirement.", + ), + "providers?": ProviderPreference.describe( + "The definition author's provider preference for this model. Resolution applies it over the tenant-visible providers; it cannot introduce a provider the tenant catalog does not contain.", + ), +}); +export type ModelRequirement = typeof ModelRequirement.infer; + +// A definition declares at most one requirement per canonical model: two +// requirements for the same model would resolve the same offering twice and +// produce duplicate inference-source ids. Reject the ambiguity here, at the +// boundary, rather than letting it surface deep in source resolution. +export const ModelRequirements = ModelRequirement.array().narrow( + (reqs, ctx) => { + const seen = new Set(); + for (const req of reqs) { + if (seen.has(req.model)) { + return ctx.mustBe( + `an array with no duplicate model requirements; "${req.model}" appears more than once`, + ); + } + seen.add(req.model); + } + return true; + }, +); +export type ModelRequirements = typeof ModelRequirements.infer; + +export const InvokerModelPreference = type({ + model: type("string").describe( + "Canonical model name this launch-time preference applies to.", + ), + providers: ProviderPreference, +}); +export type InvokerModelPreference = typeof InvokerModelPreference.infer; + +export const InvokerModelPreferences = InvokerModelPreference.array(); +export type InvokerModelPreferences = typeof InvokerModelPreferences.infer; + +export const CreateModel = type({ + canonicalName: type("string").describe( + "Tenant-unique canonical model name agents match their requirements against.", + ), + "displayName?": "string | null", + "description?": "string | null", +}); + +export const UpdateModel = type({ + "displayName?": "string | null", + "description?": "string | null", + "disabled?": "boolean", +}); + +export const CreateModelProvider = type({ + name: type("string").describe("Tenant-unique model-provider name."), + plugin: ModelProviderPlugin, + baseURL: "string", + // Exactly one of these must be set; the route rejects a body that sets + // both or neither before touching the database. + "credentialId?": "string | null", + "walletId?": "string | null", +}); + +export const UpdateModelProvider = type({ + "name?": "string", + "baseURL?": "string", + "disabled?": "boolean", +}); + +export const CreateModelOffering = type({ + modelId: type("string").describe( + "Catalog id of a model owned by this tenant.", + ), + providerId: type("string").describe( + "Catalog id of a model-provider owned by this tenant.", + ), + "priority?": type("number").describe( + "Ordering hint for source resolution; lower values are preferred first. Defaults to 0.", + ), + "deploymentTags?": "string[]", + "capabilities?": Capability.array(), + "quirks?": type("Record").describe( + "Opaque per-deployment adapter accommodations for this offering; the adapter factory validates the provider-specific shape. Omit when the deployment needs none.", + ), +}); + +export const UpdateModelOffering = type({ + "priority?": "number", + "deploymentTags?": "string[]", + "capabilities?": Capability.array(), + "quirks?": type("Record | null").describe( + "Replacement quirks bag, or null to clear it back to the adapter's default behavior. Omit to leave unchanged.", + ), + "disabled?": "boolean", +}); + +const createPriceDescription = (axis: string): string => + `${axis} as a decimal string in this row's \`currency\`, or null if this provider does not charge for it.`; + +export const CreatePricingRow = type({ + currency: type("string").describe( + "Fiat currency code or opaque credit unit this row prices in.", + ), + "effectiveFrom?": type("string").describe( + "ISO-8601 timestamp from which this price applies. Defaults to the time of the request.", + ), + "inputTokenPrice?": type("string | null").describe( + createPriceDescription("Cost per input token"), + ), + "outputTokenPrice?": type("string | null").describe( + createPriceDescription("Cost per output token"), + ), + "cacheReadTokenPrice?": type("string | null").describe( + createPriceDescription("Cost per cached-read token"), + ), + "cacheWriteTokenPrice?": type("string | null").describe( + createPriceDescription("Cost per cached-write token"), + ), + "thinkingTokenPrice?": type("string | null").describe( + createPriceDescription("Cost per thinking token"), + ), + "perRequestFee?": type("string | null").describe( + createPriceDescription("Flat fee per request"), + ), + "perImageFee?": type("string | null").describe( + createPriceDescription("Fee per image"), + ), + "perAudioFee?": type("string | null").describe( + createPriceDescription("Fee per audio unit"), + ), +}); + +export const ModelResponse = type({ + id: "string", + tenantId: "string", + canonicalName: "string", + "displayName?": "string | null", + "description?": "string | null", + disabled: "boolean", + createdAt: "string", + updatedAt: "string", +}); + +export const ModelProviderResponse = type({ + id: "string", + tenantId: "string", + name: "string", + plugin: ModelProviderPlugin, + baseURL: "string", + // Exactly one of these is set (enforced at the database). They are opaque + // references to a credential or wallet row, not secret material. + "credentialId?": "string | null", + "walletId?": "string | null", + disabled: "boolean", + createdAt: "string", + updatedAt: "string", +}); + +export const ModelOfferingResponse = type({ + id: "string", + tenantId: "string", + modelId: "string", + providerId: "string", + priority: type("number").describe( + "Ordering hint for source resolution; lower values are preferred first.", + ), + deploymentTags: "string[]", + capabilities: Capability.array().describe( + "Curated capability tags this provider advertises for this model.", + ), + quirks: type("Record | null").describe( + "Opaque per-deployment adapter accommodations, or null when the deployment needs none.", + ), + disabled: "boolean", + createdAt: "string", + updatedAt: "string", +}); + +const priceDescription = (axis: string): string => + `${axis} as a decimal string in the row's \`currency\`, or null if this provider does not charge for it.`; + +export const PricingRowResponse = type({ + id: "string", + tenantId: "string", + offeringId: "string", + currency: type("string").describe( + "Fiat currency code or opaque credit unit this row prices in.", + ), + "inputTokenPrice?": type("string | null").describe( + priceDescription("Cost per input token"), + ), + "outputTokenPrice?": type("string | null").describe( + priceDescription("Cost per output token"), + ), + "cacheReadTokenPrice?": type("string | null").describe( + priceDescription("Cost per cached-read token"), + ), + "cacheWriteTokenPrice?": type("string | null").describe( + priceDescription("Cost per cached-write token"), + ), + "thinkingTokenPrice?": type("string | null").describe( + priceDescription("Cost per thinking token"), + ), + "perRequestFee?": type("string | null").describe( + priceDescription("Flat fee per request"), + ), + "perImageFee?": type("string | null").describe( + priceDescription("Fee per image"), + ), + "perAudioFee?": type("string | null").describe( + priceDescription("Fee per audio unit"), + ), + effectiveFrom: type("string").describe( + "ISO-8601 timestamp from which this price applies. Cost attribution at a past time uses the latest row whose effectiveFrom is at or before that time.", + ), + createdAt: "string", +}); diff --git a/vendor/intx-types/src/common.ts b/vendor/intx-types/src/common.ts new file mode 100644 index 0000000..3033801 --- /dev/null +++ b/vendor/intx-types/src/common.ts @@ -0,0 +1,34 @@ +import { type, type Type } from "arktype"; + +export const ErrorResponse = type({ + error: { + code: "string", + message: "string", + }, +}); + +export const PaginationParams = type({ + "cursor?": "string", + "limit?": "string", +}); + +export const PaginatedList = type({ + data: "unknown[]", + nextCursor: "string | null", +}); + +/** + * Creates a typed paginated response schema for use with OpenAPI. + * Wraps an item array schema in `{ data: T[], nextCursor: string | null }`. + */ +export function paginatedSchema(itemSchema: Type) { + return type({ + data: itemSchema.array(), + nextCursor: "string | null", + }); +} + +export const Timestamps = type({ + createdAt: "string", + updatedAt: "string", +}); diff --git a/vendor/intx-types/src/concat.ts b/vendor/intx-types/src/concat.ts new file mode 100644 index 0000000..cd3c016 --- /dev/null +++ b/vendor/intx-types/src/concat.ts @@ -0,0 +1,19 @@ +// Concatenate byte arrays into a single Uint8Array. +// +// A Web-standard replacement for Node's `Buffer.concat`: sum the chunk +// lengths, allocate the result once, and copy each chunk in at its +// running offset so the bytes land in input order. + +export function concatBytes(chunks: Uint8Array[]): Uint8Array { + let total = 0; + for (const chunk of chunks) { + total += chunk.length; + } + const out = new Uint8Array(total); + let offset = 0; + for (const chunk of chunks) { + out.set(chunk, offset); + offset += chunk.length; + } + return out; +} diff --git a/vendor/intx-types/src/content-type.ts b/vendor/intx-types/src/content-type.ts new file mode 100644 index 0000000..1dd9e52 --- /dev/null +++ b/vendor/intx-types/src/content-type.ts @@ -0,0 +1,20 @@ +export type ResponseKind = "sse" | "json"; + +export function detectResponseKind(headers: Headers): ResponseKind { + const raw = headers.get("content-type"); + if (raw === null) { + throw new Error( + "Cannot detect response kind: response has no Content-Type header", + ); + } + const normalized = raw.trim().toLowerCase(); + if (normalized.startsWith("text/event-stream")) { + return "sse"; + } + if (normalized.startsWith("application/json")) { + return "json"; + } + throw new Error( + `Unsupported response Content-Type: ${raw}. Expected text/event-stream or application/json.`, + ); +} diff --git a/vendor/intx-types/src/credential-cipher.ts b/vendor/intx-types/src/credential-cipher.ts new file mode 100644 index 0000000..9f475c7 --- /dev/null +++ b/vendor/intx-types/src/credential-cipher.ts @@ -0,0 +1,55 @@ +/** + * The pluggable seam for encrypting credential secrets at rest. + * + * Every write site (credential / oauth-client create and update) encrypts + * through this interface; the single read-for-use site decrypts through it. Both + * depend only on the interface, so the concrete implementation is chosen once at + * the composition root and swapped without touching any call site. + * + * The one basic implementation today is `createEnvKeyCredentialCipher` + * (@intx/crypto): AES-256-GCM under a single operator-provided key. A future KMS + * or envelope-encryption plugin implements this same interface and can keep key + * material inside the KMS, because the seam abstracts the whole encrypt/decrypt + * operation rather than just supplying key bytes. + * + * `aad` (additional authenticated data) binds a ciphertext to its context -- the + * row id and column -- so a blob cannot be transplanted between rows (or between + * a row's columns) and still decrypt. Every site builds the `aad` with + * `credentialAad(id, column)` so the binding is identical on write and read. + * + * `decrypt` is strict: it throws on a value that is not a ciphertext produced by + * `encrypt` rather than returning it as plaintext. A plaintext value reaching + * decrypt means a write path failed to encrypt or the row was never re-keyed -- + * a failure that must surface, not be silently served. + */ +export interface CredentialCipher { + encrypt(plaintext: string, aad: string): Promise; + decrypt(blob: string, aad: string): Promise; +} + +/** + * Build the additional-authenticated-data string binding a credential-secret + * ciphertext to the row and column it belongs to. The encoding is injective in + * `(id, column)` -- distinct pairs always produce distinct strings -- so a + * ciphertext cannot be transplanted to a row/column it was not sealed for even + * if an id contained the delimiter of a naive `id:column` scheme. The + * `"credential-secret"` tag domain-separates this use of the AEAD primitive from + * any other. Both the write and read sites (and the re-key script) MUST build + * the `aad` through this one function so the value matches. + */ +export function credentialAad(id: string, column: string): string { + return JSON.stringify(["credential-secret", id, column]); +} + +/** + * Build the additional-authenticated-data string binding a principal signing + * key's sealed private material to the `principal_key` row and column it belongs + * to. Shares the AEAD primitive and encoding rules with `credentialAad` but uses + * a distinct `"principal-key"` tag domain, so a credential-secret ciphertext and + * a principal-key ciphertext are never interchangeable even under the same key. + * The mint (write) and sign (read) sites MUST build the `aad` through this one + * function so the value matches. + */ +export function principalKeyAad(id: string, column: string): string { + return JSON.stringify(["principal-key", id, column]); +} diff --git a/vendor/intx-types/src/credentials.ts b/vendor/intx-types/src/credentials.ts new file mode 100644 index 0000000..aad64b7 --- /dev/null +++ b/vendor/intx-types/src/credentials.ts @@ -0,0 +1,132 @@ +import { type } from "arktype"; + +import { ToolCredentialHandle } from "./package-json"; + +export const credentialTypes = [ + "api_key", + "oauth_token", + "certificate", + "other", +] as const; +export type CredentialType = (typeof credentialTypes)[number]; + +export const credentialStatuses = [ + "active", + "expired", + "revoked", + "error", +] as const; +export type CredentialStatus = (typeof credentialStatuses)[number]; + +export const credentialRequirementSources = [ + "tenant", + "creator", + "invoker", +] as const; +export type CredentialRequirementSource = + (typeof credentialRequirementSources)[number]; + +const CredType = type.enumerated(...credentialTypes); +const CredStatus = type.enumerated(...credentialStatuses); +const CredentialSourceType = type.enumerated(...credentialRequirementSources); + +// A credential binding on a workflow definition maps a tool package's declared +// credential handle -- keyed `(package, handle)` against the tool-package +// declaration -- to a concrete credential resolved fresh at launch. `locator` +// is which credential namespace the name is resolved in; today only `tenant` +// exists (a tenant-owned credential, authorized by ownership). A second locator +// that resolves a principal-owned credential -- and the delegation authority +// axis it would need -- is future work, added with the code that consumes it. +export const credentialBindingLocators = ["tenant"] as const; +export type CredentialBindingLocator = + (typeof credentialBindingLocators)[number]; + +const BindingLocator = type.enumerated(...credentialBindingLocators); + +export const CredentialBinding = type({ + package: type("string").describe( + "The tool package the declared handle belongs to; matches the resolved manifest's top-level package name.", + ), + handle: ToolCredentialHandle.describe( + "The credential handle the tool package declared; unique within its package.", + ), + provider: type("string").describe( + "The provider the bound credential resolves against.", + ), + "name?": type("string").describe( + "Optional credential name, a tiebreaker when several credentials match the provider and locator.", + ), + locator: BindingLocator.describe( + "Which credential namespace the binding resolves the credential in. `tenant` resolves a tenant-owned credential by provider/name through the tenant walk-up; its use is authorized by tenant ownership.", + ), +}); +export type CredentialBinding = typeof CredentialBinding.infer; + +const credentialTypeDescription = + "Kind of secret material this credential holds: `api_key`, `oauth_token`, `certificate`, or `other`. Determines how `secret` (and `refreshSecret` for OAuth) is interpreted when the credential is used."; + +const credentialStatusDescription = + "Usability state of the credential: `active` (usable), `expired` (past its `expiresAt`), `revoked` (deliberately invalidated), or `error` (last use failed, e.g. rejected by the provider)."; + +const credentialScopesDescription = + "Permissions granted to this credential by the provider (for example OAuth scopes). Informational on the credential record; the provider is the authority on what the secret can actually do."; + +const credentialMetadataDescription = + "Free-form provider- or integration-specific data attached to the credential. Not interpreted by the hub."; + +export const CreateCredential = type({ + providerId: "string", + name: "string", + type: CredType.describe(credentialTypeDescription), + "principalId?": "string", + "oauthClientId?": "string", + "description?": "string", + secret: "string", + "refreshSecret?": "string", + "scopes?": type("string[]").describe(credentialScopesDescription), + "expiresAt?": "string", + "metadata?": type("Record").describe( + credentialMetadataDescription, + ), +}); + +export const UpdateCredential = type({ + "name?": "string", + "description?": "string", + "secret?": "string", + "refreshSecret?": "string | null", + "scopes?": type("string[] | null").describe(credentialScopesDescription), + "expiresAt?": "string | null", + "status?": CredStatus.describe(credentialStatusDescription), + "metadata?": type("Record").describe( + credentialMetadataDescription, + ), +}); + +export const CredentialResponse = type({ + id: "string", + tenantId: "string", + providerId: "string", + "principalId?": "string | null", + "oauthClientId?": "string | null", + name: "string", + type: CredType.describe(credentialTypeDescription), + "description?": "string | null", + "scopes?": type("string[] | null").describe(credentialScopesDescription), + "expiresAt?": "string | null", + status: CredStatus.describe(credentialStatusDescription), + "metadata?": type("Record | null").describe( + credentialMetadataDescription, + ), + createdAt: "string", + updatedAt: "string", +}); + +export const CredentialRequirement = type({ + providerName: "string", + "scopes?": "string[]", + source: CredentialSourceType.describe( + "Whose credential satisfies this requirement at launch: `tenant` (a credential owned by the tenant), `creator` (the definition author's), or `invoker` (whoever launched the workflow run).", + ), + "name?": "string", +}); diff --git a/vendor/intx-types/src/grant-snapshot.ts b/vendor/intx-types/src/grant-snapshot.ts new file mode 100644 index 0000000..d8b304f --- /dev/null +++ b/vendor/intx-types/src/grant-snapshot.ts @@ -0,0 +1,37 @@ +// Serializable projection of the deploy-time capability walk. +// +// The capability walk produces per-step grant declarations keyed by two +// `Map`s (grant strings plus a tool-grant-to-effect map) alongside the +// definition's grant requirements. Persisting that walk so a run can +// materialize grants without re-reading and re-walking a `workflow.json` +// blob needs a plain-data shape: the `Map`s flatten to arrays and records +// so the whole thing survives a JSON round-trip. +// +// `perStep[i].grantEffects` covers TOOL grants only, mirroring the walk's +// `GrantDeclarations.grantEffects`; director/capability/inference.source/ +// mail.* grants live in `grants` and carry no effect entry. +// +// `grantRequirements` is the full, unfiltered requirement list (both +// creator- and invoker-sourced). Consumers filter it by source themselves; +// the snapshot does not filter here. + +import { type } from "arktype"; + +import { grantEffects, GrantRequirement } from "./grants"; + +const Effect = type.enumerated(...grantEffects); + +const GrantWalkStepSnapshot = type({ + stepId: "string", + grants: "string[]", + grantEffects: { + "[string]": Effect, + }, +}); + +export const GrantWalkSnapshot = type({ + perStep: GrantWalkStepSnapshot.array(), + grantRequirements: GrantRequirement.array(), +}); + +export type GrantWalkSnapshot = typeof GrantWalkSnapshot.infer; diff --git a/vendor/intx-types/src/grant-wire.ts b/vendor/intx-types/src/grant-wire.ts new file mode 100644 index 0000000..a5e5f38 --- /dev/null +++ b/vendor/intx-types/src/grant-wire.ts @@ -0,0 +1,29 @@ +// Arktype validators for GrantRule wire serialization. +// +// GrantRule.expiresAt is a Date | null at runtime, but JSON round-trips +// turn it into a string | null. This validator accepts either form and +// coerces strings back to Date instances, making it safe to use when +// deserializing grants that have round-tripped through JSON. + +import { type } from "arktype"; + +import { grantEffects, grantOrigins } from "./grants"; + +const Effect = type.enumerated(...grantEffects); +const Origin = type.enumerated(...grantOrigins); + +const DateOrNull = type("Date | null").or(type("string.date.parse")); + +export const WireGrantRule = type({ + id: "string", + resource: "string", + action: "string", + effect: Effect, + origin: Origin, + conditions: "Record | null", + expiresAt: DateOrNull, + roleId: "string | null", + principalId: "string | null", +}); + +export type WireGrantRule = typeof WireGrantRule.infer; diff --git a/vendor/intx-types/src/grants.ts b/vendor/intx-types/src/grants.ts new file mode 100644 index 0000000..99f64a6 --- /dev/null +++ b/vendor/intx-types/src/grants.ts @@ -0,0 +1,114 @@ +import { type } from "arktype"; + +export const grantEffects = ["allow", "deny", "ask"] as const; +export type GrantEffect = (typeof grantEffects)[number]; + +export const grantOrigins = ["system", "role", "creator", "invoker"] as const; +export type GrantOrigin = (typeof grantOrigins)[number]; + +export const grantRequirementSources = ["creator", "invoker"] as const; +export type GrantRequirementSource = (typeof grantRequirementSources)[number]; + +const Effect = type.enumerated(...grantEffects); +const Origin = type.enumerated(...grantOrigins); +const GrantSourceType = type.enumerated(...grantRequirementSources); + +const effectDescription = + "Outcome when this grant is the one resolved for a request: `allow` permits the action, `deny` blocks it, `ask` requires interactive approval before proceeding. When several grants match, the most specific wins, and at equal specificity the strongest effect wins (`deny` over `ask` over `allow`)."; + +const originDescription = + "Records where the grant came from: `system` (built-in), `role` (granted via a role), `creator` (from the workflow definition author), or `invoker` (delegated by whoever launched the workflow run). Origin is provenance only; it does not affect evaluation precedence."; + +const conditionsDescription = + "Optional map of named conditions that must all pass for the grant to apply, evaluated against a condition registry at authorization time. A grant with conditions is skipped (fails closed) when no registry is available to evaluate them."; + +const specificityDescription = + "Computed match-strength score used to rank grants: the count of non-wildcard characters in the resource and action patterns, with exact (wildcard-free) patterns scored far above prefix globs. Higher wins; ties are broken by effect priority."; + +export const CreateGrant = type({ + "roleId?": "string | null", + "principalId?": "string | null", + resource: "string", + action: "string", + effect: Effect.describe(effectDescription), + "conditions?": type("Record | null").describe( + conditionsDescription, + ), + origin: Origin.describe(originDescription), + "expiresAt?": "string | null", +}).narrow((g, ctx) => { + // A grant targets exactly one of a role or a principal -- the same invariant + // the `grant_target_exactly_one` DB CHECK enforces. Rejecting both/neither + // here surfaces a malformed request as a 400 rather than a database 500. + const targets = (g.roleId != null ? 1 : 0) + (g.principalId != null ? 1 : 0); + if (targets !== 1) { + return ctx.mustBe( + "a grant with exactly one target: set roleId or principalId, not both and not neither", + ); + } + return true; +}); + +export const UpdateGrant = type({ + "effect?": Effect.describe(effectDescription), + "conditions?": type("Record | null").describe( + conditionsDescription, + ), + "expiresAt?": "string | null", +}); + +export const GrantResponse = type({ + id: "string", + tenantId: "string", + "roleId?": "string | null", + "roleName?": "string | null", + "principalId?": "string | null", + "principalName?": "string | null", + resource: "string", + action: "string", + effect: Effect.describe(effectDescription), + "conditions?": type("Record | null").describe( + conditionsDescription, + ), + origin: Origin.describe(originDescription), + "expiresAt?": "string | null", + createdAt: "string", + updatedAt: "string", +}); + +export const EvaluateRequest = type({ + resource: "string", + action: "string", +}); + +export const MatchedGrant = type({ + id: "string", + resource: "string", + action: "string", + effect: Effect.describe(effectDescription), + origin: Origin.describe(originDescription), + "specificity?": type("number").describe(specificityDescription), +}); +export type MatchedGrant = typeof MatchedGrant.infer; + +export const EvaluateResult = type({ + effect: Effect.describe( + "The resolved outcome for the query: the effect of the winning grant, or `deny` when no grant matched (authorization fails closed).", + ), + matchingGrants: MatchedGrant.array().describe( + "Every grant that matched the requested resource and action, including the one that won. Useful for debugging why a request was allowed, denied, or required approval.", + ), +}); + +export const GrantRequirement = type({ + resource: "string", + action: "string", + "effect?": Effect.describe( + "Effect to assign the materialized grant: `allow`, `deny`, or `ask`. Defaults to `allow` when omitted.", + ), + source: GrantSourceType.describe( + "Whose authority the grant is resolved against at launch: `creator` (the definition author) or `invoker` (whoever launched the workflow run) -- satisfied only if that party actually holds the requested capability. Tenant-owned credential use is not a grant requirement: it is authorized by ownership at resolution and its consumer-scoping grant is stamped directly (see CREDENTIALS.md).", + ), + "conditions?": "Record | null", +}); +export type GrantRequirement = typeof GrantRequirement.infer; diff --git a/vendor/intx-types/src/has-code.ts b/vendor/intx-types/src/has-code.ts new file mode 100644 index 0000000..718dfa3 --- /dev/null +++ b/vendor/intx-types/src/has-code.ts @@ -0,0 +1,11 @@ +// Type guard for errors with a Node-style `{ code: string }` shape, +// as thrown by Node.js (POSIX errno), isomorphic-git, and similar. + +export function hasCode(err: unknown): err is { code: string } { + return ( + typeof err === "object" && + err !== null && + "code" in err && + typeof (err as { code: unknown }).code === "string" + ); +} diff --git a/vendor/intx-types/src/hex.ts b/vendor/intx-types/src/hex.ts new file mode 100644 index 0000000..cf1187c --- /dev/null +++ b/vendor/intx-types/src/hex.ts @@ -0,0 +1,25 @@ +// Hex codec for byte strings. +// +// Used across the codebase for Ed25519 key and signature serialization on the +// wire. Centralizing here keeps the encoding stable and the error wording +// consistent. + +export function hexEncode(bytes: Uint8Array): string { + return Array.from(bytes) + .map((b) => b.toString(16).padStart(2, "0")) + .join(""); +} + +export function hexDecode(hex: string): Uint8Array { + if (hex.length % 2 !== 0) { + throw new Error(`hexDecode: odd-length input (${hex.length} chars)`); + } + if (!/^[0-9a-fA-F]*$/.test(hex)) { + throw new Error("hexDecode: input contains non-hex characters"); + } + const bytes = new Uint8Array(hex.length / 2); + for (let i = 0; i < bytes.length; i++) { + bytes[i] = parseInt(hex.substring(i * 2, i * 2 + 2), 16); + } + return bytes; +} diff --git a/vendor/intx-types/src/index.ts b/vendor/intx-types/src/index.ts new file mode 100644 index 0000000..755229d --- /dev/null +++ b/vendor/intx-types/src/index.ts @@ -0,0 +1,38 @@ +export * from "./common"; +export * from "./me"; +export * from "./tenants"; +export * from "./principals"; +export * from "./roles"; +export * from "./grants"; +export * from "./grant-snapshot"; +export * from "./signals"; +export * from "./instances"; +export * from "./workflows"; +export * from "./attachments"; +export * from "./sessions"; +export * from "./approvals"; +export * from "./wallets"; +export * from "./providers"; +export * from "./oauth-clients"; +export * from "./credentials"; +export * from "./credential-cipher"; +export * from "./signer-identity"; +export * from "./mediated-credential"; +export * from "./assets"; +export * from "./offerings"; +export * from "./models"; +export * from "./capabilities"; +export * from "./catalog"; +export * from "./observability"; +export * from "./agent-address"; +export * from "./agent-data"; +export * from "./hex"; +export * from "./message-id"; +export * from "./workflow-run-id"; +export * from "./base64"; +export * from "./base64url"; +export * from "./concat"; +export * from "./has-code"; +export * from "./audit"; +export * from "./sidecar-allocation"; +export * from "./sidecar-capabilities"; diff --git a/vendor/intx-types/src/instances.ts b/vendor/intx-types/src/instances.ts new file mode 100644 index 0000000..9f2786c --- /dev/null +++ b/vendor/intx-types/src/instances.ts @@ -0,0 +1,77 @@ +import { type } from "arktype"; +import { InvokerModelPreferences } from "./catalog"; +import { grantEffects } from "./grants"; +import { ApprovalResponse } from "./approvals"; + +const Effect = type.enumerated(...grantEffects); + +export const workflowRunStatuses = [ + "deployed", + "running", + "updating", + "error", + "stopped", +] as const; +export type WorkflowRunStatus = (typeof workflowRunStatuses)[number]; + +const WorkflowRunStatusType = type.enumerated(...workflowRunStatuses); + +export const CreateWorkflowRun = type({ + definitionId: "string", + "modelPreferences?": InvokerModelPreferences.describe( + "The invoker's per-model provider preferences for this launch. Applied over the tenant-visible providers after the definition's preferences; it can only reorder or restrict, never introduce a provider the tenant catalog lacks. Persisted on the run so re-resolution reuses it.", + ), + "invokerGrants?": type({ + resource: "string", + action: "string", + "effect?": Effect, + "conditions?": "Record | null", + }) + .array() + .describe( + "Capabilities the invoker is willing to delegate to the run, resolved against the invoker's own authority at launch. These are materialized as grants on the run principal in addition to any grants from the definition's own requirements.", + ), +}); + +export const WorkflowRunResponse = type({ + id: "string", + definitionId: "string", + definitionName: "string", + tenantId: "string", + address: "string", + status: WorkflowRunStatusType.describe( + "Lifecycle state of this run: `deployed` (provisioned on a sidecar, not yet started), `running` (started and serving), `updating` (rolling to a new definition version), `error` (launch or runtime failure), or `stopped` (undeployed).", + ), + "publicKey?": "string | null", + "kernelId?": "string | null", + "sidecarId?": "string | null", + createdAt: "string", + updatedAt: "string", + "endedAt?": "string | null", +}); + +export const WorkflowRunHealth = type({ + liveness: "'ok' | 'unhealthy'", + readiness: "'ok' | 'not_ready' | 'unhealthy'", + "lastCheckedAt?": "string | null", +}); + +export const RunAuthorizationGrant = type({ + resource: "string", + action: "string", + effect: Effect, +}); + +export const RunAuthorizationResponse = type({ + runId: "string", + grants: RunAuthorizationGrant.array().describe( + "The run's effective authorization floor: each capability the run's principal holds with its resolved effect. A standing 'always' approval mutates the tool's committed grant in place at resolve time (approve-always sets 'allow', reject-always sets 'deny'), so a standing-resolved tool reads that effect directly. Read straight from the run's committed grants; complete for the source-ref deploy lineage (the shipping pipeline), whose committed grants carry every tool's effect. A pinned-tool deploy's ask floor is injected sidecar-side and is not reflected here.", + ), +}); + +export const RunApprovalsResponse = type({ + runId: "string", + approvals: ApprovalResponse.array().describe( + "The run's approval decisions, newest first, across every status. A tool an operator turned into a standing allow appears here with scope 'always' and status 'approved'.", + ), +}); diff --git a/vendor/intx-types/src/me.ts b/vendor/intx-types/src/me.ts new file mode 100644 index 0000000..e5091b2 --- /dev/null +++ b/vendor/intx-types/src/me.ts @@ -0,0 +1,60 @@ +import { type } from "arktype"; + +export const UserProfile = type({ + id: "string", + name: "string", + email: "string", + emailVerified: "boolean", + "image?": "string | null", + createdAt: "string", + updatedAt: "string", +}); + +export const PrincipalSummary = type({ + principalId: "string", + tenantId: "string", + tenantName: "string", + tenantSlug: "string", + kind: "'user' | 'agent'", + status: "'active' | 'suspended' | 'invited' | 'deactivated'", + roles: type({ + id: "string", + name: "string", + }).array(), +}); + +export const WorkflowRunSummary = type({ + id: "string", + tenantId: "string", + tenantName: "string", + definitionId: "string", + definitionName: "string", + address: "string", + status: "'deployed' | 'running' | 'updating' | 'error' | 'stopped'", + createdAt: "string", +}); + +export const SessionSummary = type({ + id: "string", + tenantId: "string", + tenantName: "string", + definitionId: "string", + definitionName: "string", + status: "'idle' | 'ending' | 'ended'", + createdAt: "string", + "lastActivityAt?": "string | null", +}); + +export const ApprovalSummary = type({ + id: "string", + tenantId: "string", + tenantName: "string", + definitionId: "string", + definitionName: "string", + sessionId: type("string").describe( + "Internal FK to the session channel. The run ID can be resolved via the session relationship.", + ), + resource: "string", + action: "string", + createdAt: "string", +}); diff --git a/vendor/intx-types/src/mediated-credential.ts b/vendor/intx-types/src/mediated-credential.ts new file mode 100644 index 0000000..6346166 --- /dev/null +++ b/vendor/intx-types/src/mediated-credential.ts @@ -0,0 +1,117 @@ +// The runtime mediated-credential surface: how a resolved provider-backed +// credential reaches the consumer that uses it (a tool, or the built-in +// reactor) WITHOUT handing over the raw secret. +// +// A consumer declares a credential handle (see `ToolCredentialHandle`) and, at +// handler-init, resolves a *mediated credential* -- a handle that lets it +// authenticate against the provider without holding the secret on its own API. +// An HTTP credential mediates by exposing an authed `fetch` pinned to the +// credential's provider origin; the bearer token is injected per request and +// never surfaced. +// +// Honest scope of the mediation: it is NOT containment against hostile tool +// code. A tool that legitimately receives an http mediated credential can read +// the Authorization header the fetch sends. What mediation buys is (a) the +// secret is off the tool's declared API surface, (b) a single rotation point -- +// material is read fresh per use, so a rotation reaches every holder without +// re-shaping the handle -- and (c) consumer-scoped resolution. Confidentiality +// from the receiving tool needs process/VM isolation, a different boundary. +// +// The provider plugin owns how a handle is shaped; the acquisition of material +// (resolve a credential row, authorize, decrypt) lives on the delivery side and +// is never the plugin's decision. + +/** The current secret material behind a credential, read fresh at each use. */ +export interface CredentialMaterial { + readonly secret: string; +} + +/** + * Reads the current material for one credential. A provider handle calls this + * per use rather than capturing a snapshot, so a rotation that updates the + * underlying cell is picked up without rebuilding the handle. + */ +export type CredentialMaterialSource = () => CredentialMaterial; + +/** + * Resolves the current material for a credential BY id from the run's credential + * cell. Inference uses this to fill a request's credential from + * `InferenceSource.credentialId` at send time -- the same cell tool credentials + * resolve from, so neither rail holds an inline secret. Keyed by `credentialId` + * (not bound to one, unlike `CredentialMaterialSource`) because a source's + * forward-only failover chain carries a distinct credential per entry. Reads + * live, so a rotation of the cell is picked up on the next call; fails closed + * when the credential is absent (revoked or never delivered). This is the single + * seam a future mode swaps to keep the raw secret out of the child entirely. + */ +export type CredentialMaterialResolver = ( + credentialId: string, +) => CredentialMaterial; + +/** What a provider plugin is given to shape a mediated credential. */ +export interface CredentialShapeContext { + /** + * The provider origin the credential authenticates to (e.g. + * `https://api.github.com`). An http handle pins its requests to this origin. + */ + readonly origin: string; + /** Reads the current secret material at each use (rotation indirection). */ + readCurrentMaterial: CredentialMaterialSource; +} + +/** Fields shared by every mediated-credential variant. */ +export interface MediatedCredentialBase { + /** Discriminates the variant a consumer narrows on. */ + readonly kind: string; + /** + * Release resources the handle allocated. An http handle allocates none; a + * future key-file/socket handle would. Idempotent; run on teardown. + */ + dispose(): void | Promise; +} + +/** + * An HTTP-authenticated mediated credential: an authed `fetch` pinned to the + * credential's provider origin. A request whose resolved origin is not the + * pinned one is refused, and redirects are not followed (a 3xx is returned to + * the caller), so the bearer token is only ever sent to the pinned origin and a + * holder cannot redirect it to an attacker-chosen host. + */ +export interface HttpMediatedCredential extends MediatedCredentialBase { + readonly kind: "http"; + fetch(input: string | URL | Request, init?: RequestInit): Promise; +} + +/** + * A mediated credential handed to a consumer at resolve time. A discriminated + * union on `kind`; `http` is the only variant today. Future provider kinds + * (e.g. an ssh key-file + agent socket) extend the union with their own `kind`. + */ +export type MediatedCredential = HttpMediatedCredential; + +/** + * A provider plugin: the seam that owns how a mediated credential is shaped for + * its provider. Registered under `key`, matched against a resolved provider's + * plugin identifier. The plugin shapes a handle from a material source; it does + * not acquire material and never decides authorization -- both happen upstream, + * at the delivery boundary, before a plugin is ever consulted. + */ +export interface CredentialProvider { + readonly key: string; + shape(context: CredentialShapeContext): MediatedCredential; +} + +/** + * The runtime `credentials` capability: a sub-registry a consumer queries by + * the credential handle it declared, receiving a mediated credential. It is the + * dynamic (per-binding) axis that lives under the fixed, statically-typed + * capability map. + * + * Resolution is consumer-scoped and fail-closed: it yields a handle only for a + * credential the calling consumer is authorized to use. An unbound handle, or + * one the consumer lacks a `credential:{id}` / `use` grant for, throws. `resolve` + * is async because the authorization check is. + */ +export interface CredentialCapability { + resolve(handle: string): Promise; +} diff --git a/vendor/intx-types/src/message-id.ts b/vendor/intx-types/src/message-id.ts new file mode 100644 index 0000000..523afff --- /dev/null +++ b/vendor/intx-types/src/message-id.ts @@ -0,0 +1,82 @@ +// Canonical Message-ID derivation for a raw RFC 2822 message. +// +// This id identifies the MESSAGE, not the run it triggers. It is the +// claim-check dedup key the inbox pipeline keys on (the same bytes +// delivered twice consume once), and it must be derived identically +// wherever a message is fingerprinted, or a redelivery would be treated +// as a fresh message. This module is the single source of truth those +// call sites import. +// +// A workflow run's id is NOT this value -- a deployment's one addressable +// top-level run uses the local part of its mail address as its stable runId +// (see `deriveWorkflowRunId`). The two ids are distinct: this one is +// per-message, while the top-level runId is per-deployment. +// +// The identifier is the `Message-ID` header value when the message +// carries one, and a sha256 of the raw bytes otherwise -- so a message +// from a non-RFC 2822 transport still receives a deterministic id. + +import { hexEncode } from "./hex"; + +/** + * Derive the canonical Message-ID for a raw message. Returns the parsed + * `Message-ID` header when present, else the hex-encoded sha256 of the + * raw bytes. + */ +export async function deriveMessageId(rawMessage: Uint8Array): Promise { + const messageIdFromHeader = parseMessageIdHeader(rawMessage); + if (messageIdFromHeader !== null) { + return messageIdFromHeader; + } + const digest = await crypto.subtle.digest( + "SHA-256", + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion -- ArrayBuffer-backed at the call site; Web Crypto's BufferSource type rejects Uint8Array under TS 5.9 (microsoft/TypeScript#62240) + rawMessage as Uint8Array, + ); + return hexEncode(new Uint8Array(digest)); +} + +/** + * Parse the `Message-ID` header value from a raw message, or `null` when + * the message carries no such header. + * + * The parser walks the message until the headers/body separator + * (`CRLF CRLF` per RFC 2822 §2.1, with the lone-`LF` variant tolerated to + * match common in-memory senders). Header-field unfolding follows RFC + * 2822 §2.2.3: a continuation line begins with whitespace and appends to + * the prior line. Header-name comparison is case-insensitive per RFC 2822 + * §1.2.2. + */ +export function parseMessageIdHeader(rawMessage: Uint8Array): string | null { + const text = new TextDecoder("utf-8", { fatal: false }).decode(rawMessage); + // Headers end at the first blank line. RFC 2822 mandates `CRLF CRLF` + // but tolerate `LF LF` for callers that normalize line endings. + let headerSection = text; + const crlfBoundary = text.indexOf("\r\n\r\n"); + const lfBoundary = text.indexOf("\n\n"); + if (crlfBoundary >= 0 && (lfBoundary < 0 || crlfBoundary < lfBoundary)) { + headerSection = text.slice(0, crlfBoundary); + } else if (lfBoundary >= 0) { + headerSection = text.slice(0, lfBoundary); + } + // Unfold continuation lines (a line starting with WSP belongs to + // the prior header field). + const lines = headerSection.split(/\r?\n/); + const unfolded: string[] = []; + for (const line of lines) { + if (line.length > 0 && (line[0] === " " || line[0] === "\t")) { + if (unfolded.length === 0) continue; + unfolded[unfolded.length - 1] += " " + line.trim(); + continue; + } + unfolded.push(line); + } + for (const line of unfolded) { + const colon = line.indexOf(":"); + if (colon < 0) continue; + const name = line.slice(0, colon).trim().toLowerCase(); + if (name !== "message-id") continue; + return line.slice(colon + 1).trim(); + } + return null; +} diff --git a/vendor/intx-types/src/models.ts b/vendor/intx-types/src/models.ts new file mode 100644 index 0000000..3262753 --- /dev/null +++ b/vendor/intx-types/src/models.ts @@ -0,0 +1,39 @@ +import { type } from "arktype"; + +import { Capability } from "./capabilities"; +import { ModelProviderPlugin, PricingRowResponse } from "./catalog"; + +export const ModelOfferingInfo = type({ + offeringId: type("string").describe( + "Catalog primary key of the model-provider offering this entry describes.", + ), + providerId: "string", + providerName: type("string").describe( + "The model-provider's catalog name, as shown to operators.", + ), + plugin: ModelProviderPlugin, + priority: type("number").describe( + "Source-resolution ordering hint for this offering; lower values are preferred first.", + ), + deploymentTags: "string[]", + capabilities: Capability.array().describe( + "Curated capability tags this provider advertises for this model.", + ), + pricing: PricingRowResponse.array().describe( + "The active price per currency for this offering: for each currency, the latest pricing row in effect at the time of the discovery request.", + ), +}); +export type ModelOfferingInfo = typeof ModelOfferingInfo.infer; + +export const ModelInfo = type({ + id: "string", + canonicalName: type("string").describe( + "The model's tenant-unique canonical name, matched against an agent's model requirements.", + ), + "displayName?": "string | null", + "description?": "string | null", + offerings: ModelOfferingInfo.array().describe( + "One entry per provider that offers this model in the tenant's resolved catalog, ordered by resolution priority.", + ), +}); +export type ModelInfo = typeof ModelInfo.infer; diff --git a/vendor/intx-types/src/oauth-clients.ts b/vendor/intx-types/src/oauth-clients.ts new file mode 100644 index 0000000..38ddc63 --- /dev/null +++ b/vendor/intx-types/src/oauth-clients.ts @@ -0,0 +1,47 @@ +import { type } from "arktype"; + +const redirectUrisDescription = + "Allowed OAuth redirect URIs for this client. The authorization callback must match one of these."; + +const defaultScopesDescription = + "Scopes requested by default when initiating an authorization flow with this client."; + +const oauthClientMetadataDescription = + "Free-form client-specific configuration not covered by the typed fields. Not interpreted by the hub."; + +export const CreateOAuthClient = type({ + providerId: "string", + name: "string", + clientId: "string", + clientSecret: "string", + "redirectUris?": type("string[]").describe(redirectUrisDescription), + "defaultScopes?": type("string[]").describe(defaultScopesDescription), + "metadata?": type("Record").describe( + oauthClientMetadataDescription, + ), +}); + +export const UpdateOAuthClient = type({ + "name?": "string", + "clientId?": "string", + "clientSecret?": "string", + "redirectUris?": type("string[] | null").describe(redirectUrisDescription), + "defaultScopes?": type("string[] | null").describe(defaultScopesDescription), + "metadata?": type("Record | null").describe( + oauthClientMetadataDescription, + ), +}); + +export const OAuthClientResponse = type({ + id: "string", + tenantId: "string", + providerId: "string", + name: "string", + "redirectUris?": type("string[] | null").describe(redirectUrisDescription), + "defaultScopes?": type("string[] | null").describe(defaultScopesDescription), + "metadata?": type("Record | null").describe( + oauthClientMetadataDescription, + ), + createdAt: "string", + updatedAt: "string", +}); diff --git a/vendor/intx-types/src/observability.ts b/vendor/intx-types/src/observability.ts new file mode 100644 index 0000000..61027ba --- /dev/null +++ b/vendor/intx-types/src/observability.ts @@ -0,0 +1,64 @@ +import { type } from "arktype"; + +export const LogEntry = type({ + timestamp: "string", + level: "'debug' | 'info' | 'warn' | 'error'", + message: "string", + "metadata?": "Record | null", +}); + +export const LogQuery = type({ + "level?": "'debug' | 'info' | 'warn' | 'error'", + "startTime?": "string", + "endTime?": "string", +}); + +export const MetricsResponse = type({ + agentId: "string", + "messageCount?": "number", + "tokenUsage?": { + "input?": "number", + "output?": "number", + "total?": "number", + }, + "cost?": "string", + "avgLatencyMs?": "number", + "errorRate?": "number", +}); + +export const TraceQuery = type({ + "agentId?": "string", + "sessionId?": "string", + "traceId?": "string", + "startTime?": "string", + "endTime?": "string", +}); + +export const SpanResponse = type({ + spanId: "string", + traceId: "string", + "parentSpanId?": "string | null", + name: "string", + "agentId?": "string | null", + startTime: "string", + "endTime?": "string | null", + "durationMs?": "number | null", + "status?": "'ok' | 'error'", + "attributes?": "Record | null", +}); + +export const TraceResponse = type({ + traceId: "string", + spans: type({ + spanId: "string", + traceId: "string", + "parentSpanId?": "string | null", + name: "string", + "agentId?": "string | null", + startTime: "string", + "endTime?": "string | null", + "durationMs?": "number | null", + "status?": "'ok' | 'error'", + "attributes?": "Record | null", + }).array(), +}); diff --git a/vendor/intx-types/src/offerings.ts b/vendor/intx-types/src/offerings.ts new file mode 100644 index 0000000..2bf7ac6 --- /dev/null +++ b/vendor/intx-types/src/offerings.ts @@ -0,0 +1,67 @@ +import { type } from "arktype"; + +export const CreateOffering = type({ + agentId: "string", + name: "string", + "description?": "string", + "pricing?": { + "base?": { + amount: "string", + currency: "string", + }, + "methods?": "string[]", + "negotiable?": "boolean", + "bounds?": { + "min?": "string", + "max?": "string", + }, + }, + "schema?": "Record", +}); + +export const UpdateOffering = type({ + "name?": "string", + "description?": "string", + "pricing?": { + "base?": { + amount: "string", + currency: "string", + }, + "methods?": "string[]", + "negotiable?": "boolean", + "bounds?": { + "min?": "string", + "max?": "string", + }, + }, + "schema?": "Record", +}); + +export const OfferingSearch = type({ + "name?": "string", + "minPrice?": "string", + "maxPrice?": "string", + "paymentMethod?": "string", +}); + +export const OfferingDetail = type({ + id: "string", + agentId: "string", + agentName: "string", + tenantId: "string", + name: "string", + "description?": "string | null", + "pricing?": { + "base?": { + amount: "string", + currency: "string", + }, + "methods?": "string[]", + "negotiable?": "boolean", + "bounds?": { + "min?": "string", + "max?": "string", + }, + }, + "schema?": "Record | null", +}); diff --git a/vendor/intx-types/src/package-json.ts b/vendor/intx-types/src/package-json.ts new file mode 100644 index 0000000..80fe2fb --- /dev/null +++ b/vendor/intx-types/src/package-json.ts @@ -0,0 +1,98 @@ +// Schema for the subset of `package.json` fields the asset substrate +// and tool-package builders read. +// +// Promoted here so the package-registry kind handler (in +// `@intx/hub-sessions`) and the workspace builtin-packing script +// (`bin/build-builtins.ts`) share one definition: the asset +// substrate's validation of an uploaded tarball must match the field +// set the build path emits, otherwise a freshly-packed builtin would +// be rejected for shape reasons the build did not anticipate. + +import path from "node:path"; + +import { type } from "arktype"; + +/** + * A tool package's static declaration of one provider-backed credential it + * needs: an abstract handle plus optional scopes. Advisory only -- a request + * the workflow definition later binds to a concrete credential and the launch-time + * grant gate authorizes; a declaration consents to nothing on its own. The + * handle is the key the binding and the runtime delivery use. + */ +export const ToolCredentialHandle = type(/^[a-z0-9][a-z0-9._-]*$/); + +export const ToolCredentialDeclaration = type({ + handle: ToolCredentialHandle, + "scopes?": "string[]", +}); +export type ToolCredentialDeclaration = typeof ToolCredentialDeclaration.infer; + +/** + * The credential declarations for one package, with the unique-handle + * invariant enforced at parse time: a handle is the binding/delivery key, so a + * duplicate within a single package is a defect the upload boundary must + * reject rather than let collapse silently downstream. + */ +export const ToolCredentialDeclarationArray = + ToolCredentialDeclaration.array().narrow((decls, ctx) => { + const seen = new Set(); + for (const decl of decls) { + if (seen.has(decl.handle)) { + return ctx.mustBe( + `an array with no duplicate credential handles; "${decl.handle}" appears more than once`, + ); + } + seen.add(decl.handle); + } + return true; + }); +export type ToolCredentialDeclarationArray = + typeof ToolCredentialDeclarationArray.infer; + +/** + * Required fields plus the `interchange` extensions used to identify + * interchange packages: `tools` names the sidecar-bundle entry, `credentials` + * statically declares the provider-backed credentials the package's tools may + * need, `workflow` names the module whose evaluation produces a workflow + * package's `WorkflowDefinition`, `directors` names the module whose exports + * are the package's custom `defineDirector` factories, `loops` names the module + * whose exports are the package's `loop` `while`/`carry` functions, and + * `actions` names the module whose exports are the package's `action` handlers. + * `loops` and `actions` refs are resolved by export name at establish. + * `onUndeclaredKey("ignore")` lets the arbitrary upstream npm fields pass + * through without listing them. + */ +export const PackageJSON = type({ + name: "string", + version: "string", + "interchange?": type({ + "tools?": "string", + "credentials?": ToolCredentialDeclarationArray, + "workflow?": "string", + "directors?": "string", + "loops?": "string", + "actions?": "string", + }).onUndeclaredKey("ignore"), +}).onUndeclaredKey("ignore"); +export type PackageJSON = typeof PackageJSON.infer; + +/** + * True when `entry` -- an `interchange.workflow`/`interchange.directors` + * module path relative to its package -- stays inside the package directory. + * An absolute path or a `..` traversal escapes and returns false. + * + * This is the string-level half of the loader's containment rule. The + * load-time loader (`resolveContainedEntry`) pairs it with a realpath-based + * symlink-escape check that only a materialized directory can run; the + * push-time asset validator, which has no filesystem, relies on this string + * half alone. Both boundaries call this one predicate so they cannot diverge + * on what "contained" means. The check uses POSIX path semantics so the + * result does not depend on the host's separator or cwd. + */ +export function isContainedEntryPath(entry: string): boolean { + if (path.posix.isAbsolute(entry)) { + return false; + } + const normalized = path.posix.normalize(entry); + return normalized !== ".." && !normalized.startsWith(`..${path.posix.sep}`); +} diff --git a/vendor/intx-types/src/principals.ts b/vendor/intx-types/src/principals.ts new file mode 100644 index 0000000..d70860b --- /dev/null +++ b/vendor/intx-types/src/principals.ts @@ -0,0 +1,57 @@ +import { type } from "arktype"; + +export const principalKinds = ["user", "agent", "workflow"] as const; +export type PrincipalKind = (typeof principalKinds)[number]; + +export const principalStatuses = [ + "active", + "suspended", + "invited", + "deactivated", +] as const; +export type PrincipalStatus = (typeof principalStatuses)[number]; + +export const updatablePrincipalStatuses = [ + "active", + "suspended", + "deactivated", +] as const; +export type UpdatablePrincipalStatus = + (typeof updatablePrincipalStatuses)[number]; + +const Kind = type.enumerated(...principalKinds); +const Status = type.enumerated(...principalStatuses); +const UpdatableStatus = type.enumerated(...updatablePrincipalStatuses); + +export const PrincipalResponse = type({ + id: "string", + tenantId: "string", + kind: Kind.describe( + "Whether this principal represents a `user` (a human account), an `agent`, or a `workflow` (a workflow run).", + ), + refId: type("string").describe( + "Identifier of the underlying entity this principal stands for: the auth user id when `kind` is `user`, an agent-instance id when `kind` is `agent`, or a workflow run (`run_...`) or workflow definition (`wfd_...`) id when `kind` is `workflow`. Unique per tenant and kind.", + ), + displayName: "string", + "email?": "string", + status: Status.describe( + "Account state of the principal: `active`, `suspended`, `invited` (membership pending acceptance), or `deactivated`.", + ), + roles: type({ + id: "string", + name: "string", + }).array(), + createdAt: "string", + updatedAt: "string", +}); + +export const UpdatePrincipal = type({ + status: UpdatableStatus.describe( + "New account state for the principal. Only `active`, `suspended`, and `deactivated` are settable; `invited` is reached only through the invitation flow.", + ), +}); + +export const InviteMember = type({ + email: "string", + "roleId?": "string", +}); diff --git a/vendor/intx-types/src/providers.ts b/vendor/intx-types/src/providers.ts new file mode 100644 index 0000000..f3c0a46 --- /dev/null +++ b/vendor/intx-types/src/providers.ts @@ -0,0 +1,56 @@ +import { type } from "arktype"; + +const pluginDescription = + "Identifier of the integration this provider drives (for example the inference backend). Used to dispatch to the matching plugin and as the prefix when forming fully-qualified model ids (`plugin:model`)."; + +const providerScopesDescription = + "OAuth scopes associated with this provider integration."; + +const providerMetadataDescription = + "Free-form provider-specific configuration not covered by the typed fields. Not interpreted by the hub."; + +const apiBaseUrlDescription = + "The API origin a credential from this provider authenticates to (for example https://api.github.com). A provider that backs an origin-pinned credential must set it; OAuth-login-only providers may omit it."; + +export const CreateProvider = type({ + name: "string", + plugin: type("string").describe(pluginDescription), + "apiBaseUrl?": type("string").describe(apiBaseUrlDescription), + "authorizationUrl?": "string", + "tokenUrl?": "string", + "userInfoUrl?": "string", + "scopes?": type("string[]").describe(providerScopesDescription), + "metadata?": type("Record").describe( + providerMetadataDescription, + ), +}); + +export const UpdateProvider = type({ + "name?": "string", + "plugin?": type("string").describe(pluginDescription), + "apiBaseUrl?": type("string | null").describe(apiBaseUrlDescription), + "authorizationUrl?": "string | null", + "tokenUrl?": "string | null", + "userInfoUrl?": "string | null", + "scopes?": type("string[] | null").describe(providerScopesDescription), + "metadata?": type("Record | null").describe( + providerMetadataDescription, + ), +}); + +export const ProviderResponse = type({ + id: "string", + tenantId: "string", + name: "string", + plugin: type("string").describe(pluginDescription), + "apiBaseUrl?": type("string | null").describe(apiBaseUrlDescription), + "authorizationUrl?": "string | null", + "tokenUrl?": "string | null", + "userInfoUrl?": "string | null", + "scopes?": type("string[] | null").describe(providerScopesDescription), + "metadata?": type("Record | null").describe( + providerMetadataDescription, + ), + createdAt: "string", + updatedAt: "string", +}); diff --git a/vendor/intx-types/src/roles.ts b/vendor/intx-types/src/roles.ts new file mode 100644 index 0000000..201e952 --- /dev/null +++ b/vendor/intx-types/src/roles.ts @@ -0,0 +1,21 @@ +import { type } from "arktype"; + +export const CreateRole = type({ + name: "string", + "description?": "string", +}); + +export const UpdateRole = type({ + "name?": "string", + "description?": "string", +}); + +export const RoleResponse = type({ + id: "string", + tenantId: "string", + name: "string", + "description?": "string | null", + isSystem: "boolean", + createdAt: "string", + updatedAt: "string", +}); diff --git a/vendor/intx-types/src/runtime-capabilities.ts b/vendor/intx-types/src/runtime-capabilities.ts new file mode 100644 index 0000000..374f9df --- /dev/null +++ b/vendor/intx-types/src/runtime-capabilities.ts @@ -0,0 +1,135 @@ +// Typed registry of host-provided capabilities that tool packages request at +// handler-init. The host (sidecar harness, or an alternate runtime) builds a +// RuntimeCapabilities instance and hands it to each tool package's factory; +// the package calls `resolve` to obtain typed handles to host services. +// +// The map is the extension point: new capabilities are added by extending +// RuntimeCapabilityMap inside this file. TypeScript permits module +// augmentation of the interface from any consumer, but augmentation from +// outside @intx/types is not the supported extension path; contribute +// keys here so every host sees the same canonical map. + +import type { MessageTransport } from "./runtime"; +import type { CredentialCapability } from "./mediated-credential"; + +/** + * Registry of capability keys to the value types they resolve to. Keys are + * dotted strings scoped by subsystem (e.g. `mail.transport`). + * + * Adding a capability: extend this interface with the new key and its value + * type, then have a host populate it when constructing a + * `RuntimeCapabilities`. + */ +export interface RuntimeCapabilityMap { + /** + * The bound agent's message transport — the SMTP/IMAP-equivalent handle + * for sending and receiving mail. + */ + "mail.transport": MessageTransport; + + /** + * Provider-backed credentials the agent's tools resolve by their declared + * handle. Unlike the other keys, its value is itself a sub-registry: the set + * of bound handles is per-deploy runtime data, not known at compile time, so + * the dynamic axis lives inside `CredentialCapability` while this outer map + * stays fixed and typed. Resolution is consumer-scoped and fail-closed. + */ + credentials: CredentialCapability; +} + +export type RuntimeCapabilityKey = keyof RuntimeCapabilityMap; + +/** + * Host-provided capability registry. Tool packages receive an instance at + * construction; `resolve` is intended to be called once per key at + * handler-init, with the returned handle held for the deploy lifetime. + * `resolve` throws naming the key when the host did not provide a value + * for it. + */ +export interface RuntimeCapabilities { + resolve(key: K): RuntimeCapabilityMap[K]; +} + +/** + * Build a resolver from a partial map of capability values. The map is + * snapshotted at construction — later mutation of the input is not visible + * to `resolve`. Keys absent from the snapshot throw at resolve-time with a + * message naming the key. + * + * Use this from any host (harness, test harness, alternate runtime) that + * wants the standard resolver semantics without re-implementing the + * throw-on-missing plumbing. + */ +export function createRuntimeCapabilities( + values: Partial, +): RuntimeCapabilities { + // Snapshot the input. The resolver's lifecycle contract is "resolved + // once at handler-init, held for the deploy lifetime" — later mutation + // of the input map by the host must not be observable here. + const snapshot: Partial = { ...values }; + + return { + resolve(key: K): RuntimeCapabilityMap[K] { + // Object.hasOwn distinguishes "host did not provide" from "host + // provided undefined". Both are distinct failures the host + // should hear about separately. No capability in + // RuntimeCapabilityMap currently resolves to undefined, so the + // second check is a defensive guard against a host accidentally + // wiring an undefined value to a non-nullable capability slot; + // adding a nullable capability in the future means revisiting + // this branch. + if (!Object.hasOwn(snapshot, key)) { + throw new Error( + `Runtime capability "${String(key)}" was requested but not provided by the host`, + ); + } + const value = snapshot[key]; + if (value === undefined) { + throw new Error( + `Runtime capability "${String(key)}" was provided as undefined; no current capability resolves to undefined`, + ); + } + return value; + }, + }; +} + +/** + * Compose a resolver that answers the keys in `overrides` from the override + * map and delegates every other key to `base`. + * + * The host uses this to add a per-bundle capability -- the consumer-scoped + * `credentials` handle, one instance per tool package -- onto a shared + * per-step base bag without re-plumbing the base's keys (`mail.transport` + * and any future shared key stay owned by the step bag). Each tool package's + * bundle receives the same base layered with ITS OWN credentials capability, + * so a package cannot resolve a handle scoped to a different package. + * + * `overrides` is snapshotted at construction, mirroring + * `createRuntimeCapabilities`, so later mutation of the input is not + * observable through `resolve`. An overridden key wired to `undefined` + * throws with the same guard as the base resolver rather than silently + * shadowing `base` with a hole -- a host that layers an undefined value + * has a wiring bug and must hear about it. + */ +export function layerRuntimeCapabilities( + base: RuntimeCapabilities, + overrides: Partial, +): RuntimeCapabilities { + const snapshot: Partial = { ...overrides }; + + return { + resolve(key: K): RuntimeCapabilityMap[K] { + if (Object.hasOwn(snapshot, key)) { + const value = snapshot[key]; + if (value === undefined) { + throw new Error( + `Runtime capability "${String(key)}" was layered as undefined; no current capability resolves to undefined`, + ); + } + return value; + } + return base.resolve(key); + }, + }; +} diff --git a/vendor/intx-types/src/runtime.ts b/vendor/intx-types/src/runtime.ts new file mode 100644 index 0000000..deb3239 --- /dev/null +++ b/vendor/intx-types/src/runtime.ts @@ -0,0 +1,2891 @@ +// Runtime definitions for the Interchange agent harness. +// +// Wire-facing data types (AbortReason, InferenceSource, ToolDefinition, +// HarnessConfig) are arktype validators so they can be composed into +// WebSocket frame validators and used for runtime validation at parse +// boundaries. Behavioral interfaces (ContextStore, MessageTransport, +// ToolRunner, etc.) remain plain TypeScript. + +import { type } from "arktype"; +import type { AuditRecord, ErrorRecord } from "./audit"; +import { WireGrantRule } from "./grant-wire"; +import type { SignalKind } from "./signals"; + +// --------------------------------------------------------------------------- +// Cryptographic Identity (ARCHITECTURE.md § Cryptographic Identity, +// IMPLEMENTATION.md § Cryptographic Identity: Key Formats) +// --------------------------------------------------------------------------- + +/** + * An Ed25519 key pair as raw bytes. The private key is 32 bytes; the public + * key is the corresponding 32-byte compressed point. + * + * Key material is represented as Uint8Array throughout so it stays + * runtime-agnostic (Bun, Node, browser) and never accidentally leaks through + * JSON serialization. + */ +export type KeyPair = { + privateKey: Uint8Array; + publicKey: Uint8Array; +}; + +/** + * A key-bound cryptographic provider. Each instance is constructed with a + * specific agent's Ed25519 key pair and holds the private key internally. + * + * `sign` uses the instance's own private key — no key parameter is accepted. + * `verify` accepts a public key parameter so the holder can verify messages + * from arbitrary senders without constructing a new provider instance. + * + * The in-memory transport stores one CryptoProvider per registered agent and + * calls `crypto.sign(content)` during `send()` without passing keys around. + * + * Key formats (IMPLEMENTATION.md): + * - Ed25519 in SSH format — control plane interactions + * - Ed25519 in PGP format — message-level signatures over SMTP/IMAP + * - Ed25519 in X.509 format — TLS mutual auth certificates + * + * `getPublicKey` returns the raw public key bytes so callers can publish + * them to the control plane or embed them in discovery metadata. + */ +export interface CryptoProvider { + /** + * Sign `content` with the instance's private key. Returns the Ed25519 + * detached signature as raw bytes. + */ + sign(content: Uint8Array): Promise; + + /** + * Sign `payload` with the instance's private key using the SSH signature + * envelope (sshsig). Returns an ASCII-armored SSH SIGNATURE block suitable + * for the `gpgsig` header of a git commit or any other site that consumes + * `git verify-commit`-compatible signatures. The framing differs from + * `sign`'s raw output; callers that need either format should pick the + * matching method rather than reframing the result themselves. + */ + signSSH(payload: string): Promise; + + /** + * Verify that `signature` over `content` was produced by `publicKey`. + * Returns true if the signature is valid; false otherwise. + */ + verify( + content: Uint8Array, + signature: Uint8Array, + publicKey: Uint8Array, + ): Promise; + + /** The public key for this instance, as raw bytes. */ + getPublicKey(): Uint8Array; +} + +/** + * Generate a fresh Ed25519 key pair. The returned pair is used to construct + * a CryptoProvider instance. + */ +export type GenerateKeyPair = () => Promise; + +// --------------------------------------------------------------------------- +// Message Transport (MESSAGE.md § Transport Interface) +// --------------------------------------------------------------------------- + +/** + * Opaque reference to a message in a specific mailbox. Carries the IMAP UID + * and the mailbox name. Passed to fetch, flag, and move operations without + * requiring re-search. + */ +export type MessageRef = { + uid: number; + mailbox: string; +}; + +/** + * Interchange payload types as defined in MESSAGE.md § Payload Types. + * The type field in structured messages matches the Interchange-Type header. + * + * Exposed as both an arktype validator (for runtime validation at parse + * boundaries and tool-argument schemas) and a derived TypeScript union. + */ +export const InterchangeType = type.enumerated( + "conversation.message", + "conversation.join", + "conversation.leave", + "offering.request", + "offering.response", + "offering.error", + "offering.discover", + "offering.catalog", + "payment.required", + "payment.receipt", + "payment.verified", + "approval.request", + "approval.granted", + "approval.denied", + "system.health", + "system.register", + "system.deregister", + "system.credential.refresh", +); +export type InterchangeType = typeof InterchangeType.infer; + +/** + * Attachment for an outbound message. Content is raw bytes; the transport + * handles Content-Transfer-Encoding (base64 for binary, quoted-printable + * for 8-bit text). + */ +export type MessageAttachment = { + name: string; + contentType: string; + data: Uint8Array; +}; + +/** + * A message the harness submits for delivery via SMTP. The transport + * assembles the PGP/MIME multipart structure, signs it with the agent's + * CryptoProvider, and submits it. + * + * Conversation types (conversation.*) carry `content` as text/plain. + * Structured types carry `payload` as application/vnd.interchange+json. + * Providing both is an error. + * + * (MESSAGE.md § Transport Interface › Outbound) + */ +export type OutboundMessage = { + to: string | string[]; + cc?: string | string[]; + subject?: string; + + type: InterchangeType; + + /** Plain text body — used when type is a conversation.* type. */ + content?: string; + + /** Structured JSON body — used when type is a non-conversation type. */ + payload?: Record; + + /** Human-readable summary for structured messages (the text/plain part). */ + summary?: string; + + attachments?: MessageAttachment[]; + + /** Message-ID of the message being replied to. */ + inReplyTo?: string; + + /** + * The RFC 5322 References chain for a threaded reply: the parent's own + * References plus the parent's Message-ID, in order. When present the + * transport ships it verbatim (after appending `inReplyTo` if it is not + * already the tail) rather than deriving a single-element `[inReplyTo]` + * chain, so a reply carries the full conversational ancestry. Absent for a + * non-reply or a reply whose parent could not be located. + */ + references?: string[]; + + /** Correlation ID linking this message to a pending async request. */ + correlationId?: string; + + /** Reactor session ID from the Interchange-Session-ID header. */ + sessionId?: string; + + /** Tenant ID for the Interchange-Tenant-ID header. */ + tenantId?: string; +}; + +/** + * Receipt returned by `send()`. Contains the assigned Message-ID and + * delivery status. + * + * (MESSAGE.md § Transport Interface › Outbound) + */ +export type SendReceipt = { + messageId: string; + status: "delivered" | "queued"; +}; + +/** + * Parsed headers from an inbound message. Field names follow RFC 5322 and + * the Interchange-specific header conventions from MESSAGE.md § Headers. + */ +export type MessageHeaders = { + from: string; + to: string[]; + cc?: string[]; + date: string; + messageId: string; + inReplyTo?: string; + references?: string[]; + subject?: string; + listId?: string; + + interchangeType?: InterchangeType; + interchangeCorrelationId?: string; + interchangeTenantId?: string; + interchangeAgentId?: string; + interchangeSessionId?: string; + interchangeOfferingId?: string; + interchangeSchemaVersion?: string; + + traceparent?: string; + tracestate?: string; +}; + +/** + * Signature verification status of an inbound message. + * + * - `valid` — signature verified against the sender's public key + * - `invalid` — signature check failed (tampering or wrong key) + * - `unknown` — public key not available for verification + * - `missing` — message was not signed + * + * (MESSAGE.md § Transport Interface › fetchFull) + */ +export const SignatureStatus = type.enumerated( + "valid", + "invalid", + "unknown", + "missing", +); +export type SignatureStatus = typeof SignatureStatus.infer; + +/** + * A parsed MIME part. `content` is the DECODED bytes in memory (the + * transfer-encoding has already been undone). `filename` and `disposition` are + * surfaced from the part's `Content-Disposition` / `Content-Type` so a consumer + * can distinguish an inline part from a named attachment without re-parsing + * headers. + */ +export type MessagePart = { + contentType: string; + content: Uint8Array; + filename?: string; + disposition?: "inline" | "attachment"; + /** + * Original Content-Transfer-Encoding, when a producer chooses to record it. + * Not set for a decoded mail part -- `content` is already decoded, so the + * wire encoding is spent transport metadata. + */ + encoding?: string; +}; + +/** + * A single part of a persisted `Mail`. The bytes live in the durable store; + * this descriptor carries the part's metadata plus an opaque, relocation-stable + * `ref` a `MailPartReader` resolves to the part's bytes. Small UTF-8 text parts + * also carry their decoded `text` inline so a selector can read them without + * resolving. + */ +export type MailPart = { + contentType: string; + filename?: string; + disposition?: "inline" | "attachment"; + ref: string; + text?: string; +}; + +/** + * The single, environment-agnostic interface for reading a persisted mail + * part's bytes. Modeled on `BlobReader`: a consumer -- a workflow step, an + * agent tool, the agent's content-block projection -- resolves a + * `MailPart.ref` to its bytes without knowing or caring where the bytes live + * (a committed file on the sidecar, a blob in a browser runtime). The `ref` + * is opaque; the reader owns its scheme. Threaded to consumers through the + * runtime so a browser runtime can supply its own implementation. + */ +export interface MailPartReader { + /** Resolve a `MailPart.ref` to the part's decoded bytes. Throws if the ref + * is unrecognized or its bytes are missing. */ + read(ref: string): Promise; +} + +/** + * A fully decoded mail message: every header (both the typed, ergonomic subset + * and a raw catch-all with nothing dropped) plus the flat list of decoded leaf + * parts. This is the lossless representation a deployed workflow receives as + * its trigger input; a workflow programs against it directly (select headers, + * walk parts, route on content type), and an agent step projects the parts into + * model content blocks. It is JSON-safe: each part carries a `ref` (not raw + * bytes), so binary content never enters the event log; the runtime resolves a + * `ref` into a loadable `MessagePart` on demand. + */ +export type Mail = { + headers: MessageHeaders; + /** Every header, lowercased name to its ordered values; nothing dropped. */ + rawHeaders: Record; + parts: MailPart[]; +}; + +const MailShape = type({ + // Require the header fields a consumer dereferences unconditionally (the + // sender/recipient a projection reads); other header fields stay optional + // and are carried losslessly in `rawHeaders`. + headers: { + from: "string", + to: "string[]", + }, + rawHeaders: "object", + parts: type({ + contentType: "string", + ref: "string", + "filename?": "string", + "disposition?": "'inline' | 'attachment'", + "text?": "string", + }) + .onUndeclaredKey("reject") + .array(), +}).onUndeclaredKey("reject"); + +/** + * Narrow an opaque value (a workflow step input) to a `Mail`. Used at the + * `agent.send` boundary to decide whether the input is a mail-derived message + * whose parts must be projected into content blocks, or an arbitrary value + * delivered as synthesized text. The strict undeclared-key rejection keeps an + * arbitrary step value that merely carries a `parts` field from matching. + */ +export function isMail(value: unknown): value is Mail { + return !(MailShape(value) instanceof type.errors); +} + +/** + * MIME tree metadata returned by `fetchStructure()`. Describes content types, + * sizes, and dispositions without transferring content. + * + * (MESSAGE.md § Partial Fetch) + */ +export type BodyStructure = { + contentType: string; + size?: number; + disposition?: string; + parts?: BodyStructure[]; +}; + +/** + * A fully parsed inbound message including structured payload, headers, + * attachments, and signature verification status. + * + * (MESSAGE.md § Transport Interface › fetchFull) + */ +export type InboundMessage = { + ref: MessageRef; + headers: MessageHeaders; + flags: string[]; + + /** Plain text body for conversation.* types. */ + content?: string; + + /** Parsed JSON payload for structured types. */ + payload?: { + type: InterchangeType; + version: string; + body: Record; + }; + + attachments?: MessageAttachment[]; + signatureStatus: SignatureStatus; +}; + +/** + * IMAP mailbox descriptor. + * + * (MESSAGE.md § Inbox Management) + */ +export type Mailbox = { + name: string; + role?: string; + delimiter?: string; +}; + +/** + * Current status of an IMAP mailbox, including QRESYNC identifiers. + * + * (MESSAGE.md § Inbox Management) + */ +export type MailboxStatus = { + total: number; + unseen: number; + recent: number; + uidNext: number; + uidValidity: number; + highestModSeq: number; +}; + +/** + * Structured IMAP search query. Maps the IMAP SEARCH grammar to a typed + * object. Supports recursive boolean composition via `and`, `or`, `not`. + * + * (MESSAGE.md § Search) + */ +export type SearchQuery = { + from?: string; + to?: string; + cc?: string; + bcc?: string; + header?: { field: string; contains: string }; + before?: Date; + after?: Date; + on?: Date; + sentBefore?: Date; + sentAfter?: Date; + sentOn?: Date; + hasFlags?: string[]; + missingFlags?: string[]; + body?: string; + text?: string; + largerThan?: number; + smallerThan?: number; + and?: SearchQuery[]; + or?: SearchQuery[]; + not?: SearchQuery; +}; + +/** + * A thread node returned by `thread()`. Carries a message reference and + * child threads representing replies. Implements the RFC 5256 REFERENCES + * threading algorithm. + * + * (MESSAGE.md § Thread Retrieval) + */ +export type Thread = { + ref: MessageRef; + children: Thread[]; +}; + +/** + * QRESYNC state the harness provides when reconnecting to the transport. + * + * (MESSAGE.md § Synchronization) + */ +export type SyncState = { + uidValidity: number; + uidNext: number; + highestModSeq: number; + knownUids?: number[]; +}; + +/** + * Result of a QRESYNC-style sync operation. + * + * (MESSAGE.md § Synchronization) + */ +export type SyncResult = { + vanished: number[]; + changed: { uid: number; flags: string[] }[]; + newMessages: MessageRef[]; + fullResyncRequired: boolean; +}; + +/** + * Distribution list metadata returned by `createList()`. + * + * (MESSAGE.md § Message Topologies) + */ +export type ListInfo = { + address: string; + name: string; + memberCount: number; + createdAt: string; +}; + +/** + * Event emitted by the mailbox watcher callback. Corresponds to IMAP IDLE + * notifications. + * + * (MESSAGE.md § Real-Time Notification) + */ +export type MailboxEvent = + | { type: "exists"; uid: number; headers: MessageHeaders } + | { type: "flagsChanged"; uid: number; flags: string[] } + | { type: "expunged"; uid: number }; + +/** Unsubscribe function returned by `watch()`. */ +export type Unsubscribe = () => void; + +/** + * The message transport interface. Abstracts SMTP and IMAP behind a + * TypeScript API. Implementations range from real SMTP/IMAP servers to + * in-process stubs that route messages through memory. + * + * All long-running operations accept an AbortSignal for cooperative + * cancellation. + * + * (MESSAGE.md § Transport Interface) + */ +export interface MessageTransport { + // --- Outbound --- + + /** Compose, sign, and deliver a message via SMTP. */ + send(message: OutboundMessage, signal?: AbortSignal): Promise; + + /** Append a raw message to a mailbox (IMAP APPEND). */ + append( + mailbox: string, + message: InboundMessage, + flags?: string[], + signal?: AbortSignal, + ): Promise; + + // --- Mailbox management --- + + listMailboxes(signal?: AbortSignal): Promise; + createMailbox(name: string, signal?: AbortSignal): Promise; + deleteMailbox(name: string, signal?: AbortSignal): Promise; + getMailboxStatus(name: string, signal?: AbortSignal): Promise; + + // --- Message search and retrieval --- + + search( + mailbox: string, + query: SearchQuery, + signal?: AbortSignal, + ): Promise; + + thread( + mailbox: string, + algorithm: "references" | "orderedsubject", + query?: SearchQuery, + signal?: AbortSignal, + ): Promise; + + fetchHeaders(ref: MessageRef, signal?: AbortSignal): Promise; + fetchStructure(ref: MessageRef, signal?: AbortSignal): Promise; + fetchPart( + ref: MessageRef, + partPath: string, + signal?: AbortSignal, + ): Promise; + fetchFull(ref: MessageRef, signal?: AbortSignal): Promise; + + // --- Flag management --- + + setFlags( + ref: MessageRef, + flags: string[], + signal?: AbortSignal, + ): Promise; + + clearFlags( + ref: MessageRef, + flags: string[], + signal?: AbortSignal, + ): Promise; + + // --- Message organization --- + + move(ref: MessageRef, toMailbox: string, signal?: AbortSignal): Promise; + + copy(ref: MessageRef, toMailbox: string, signal?: AbortSignal): Promise; + + /** + * Permanently remove every `\Deleted` message from the mailbox. Returns the + * uids that were expunged, so a caller can report how many messages it + * consumed and which ones. + */ + expunge( + mailbox: string, + signal?: AbortSignal, + ): Promise<{ expungedUids: number[] }>; + + // --- Real-time notification --- + + /** Monitor a mailbox for new messages and flag changes (IMAP IDLE). */ + watch(mailbox: string, callback: (event: MailboxEvent) => void): Unsubscribe; + + // --- Synchronization --- + + /** Efficient reconnection using QRESYNC semantics. */ + sync( + mailbox: string, + knownState: SyncState, + signal?: AbortSignal, + ): Promise; + + // --- Distribution lists --- + + createList( + address: string, + name: string, + signal?: AbortSignal, + ): Promise; + + listMembers(address: string, signal?: AbortSignal): Promise; + + subscribe( + listAddress: string, + subscriberAddress: string, + signal?: AbortSignal, + ): Promise; + + unsubscribe( + listAddress: string, + subscriberAddress: string, + signal?: AbortSignal, + ): Promise; +} + +// --------------------------------------------------------------------------- +// Tool Execution (ARCHITECTURE.md § Tools, INFERENCE.md § Tool Execution) +// --------------------------------------------------------------------------- + +/** + * A tool call as requested by the model. Carries the provider-assigned call + * ID, the tool name, and the parsed arguments. + * + * (INFERENCE.md § Message Format › Content Types) + */ +export const ToolCall = type({ + id: "string", + name: "string", + arguments: "Record", +}); +export type ToolCall = typeof ToolCall.infer; + +/** + * Approver-facing snapshot of the tool call awaiting approval. Built at the + * authz `ask` branch from the tool's definition and the live call, then + * threaded unchanged from the reactor's pending operation through every + * suspend hop to the hub co-write that records it on the approval row. + * + * `name`, `description`, and `inputSchema` mirror the {@link ToolDefinition}; + * `arguments` is the live call's arguments. Carried as a sibling of the pending + * operation's `suspendedCall`, never folded into {@link ToolCall}, so the + * re-dispatch artifact and the approval snapshot stay separate concerns. + */ +export const ApprovalSnapshot = type({ + name: "string", + description: "string", + inputSchema: "Record", + arguments: "Record", +}); +export type ApprovalSnapshot = typeof ApprovalSnapshot.infer; + +/** + * The kind of a control-plane park: a step suspended awaiting an external + * event. `"approval"` and `"input"` park on a reserved + * `signalName(correlationId)` channel; `"signal-relay"` parks on an + * author-chosen name. + * + * - `"approval"` -- the step parked on a tool/authz gate and REQUIRES an + * {@link ApprovalSnapshot}; the runtime notifies the host (`env.onPark`) so + * the sidecar co-writes the approval/correlation rows the hub registers. + * - `"input"` -- the step parked awaiting its next input (e.g. a long-lived + * agent run awaiting the next mail so it can take another turn). It carries + * NO snapshot and does NOT notify the host: the run's owner delivers the + * input on the same channel and the step re-arms. It is a runtime-local + * concept -- deliberately NOT a {@link SignalKind}, so it never touches the + * approval-routing machinery (IPC register frames, the hub co-write, the + * approval columns). + * - `"signal-relay"` -- an onTrigger section container parked on an + * author-named signal so a body child's `awaitSignal` on that name is + * serviced through the deployment run: the external signal is delivered to + * the parent run and the runtime relays it down into the live body child. + * The channel name is the author's free-form signal name, NOT a reserved + * `signalName(correlationId)`, so recovery must branch on this kind BEFORE + * assuming the awaited name is a reserved control-plane channel. Carries no + * snapshot and is not hub-registered. + * + * The kinds are distinguished by an EXPLICIT discriminant everywhere the kind + * flows -- never inferred from the presence or absence of a snapshot, which + * would silently reclassify a malformed snapshot-less approval as another + * park kind rather than failing loud. + */ +export const ControlParkKind = type.enumerated( + "approval", + "input", + "signal-relay", +); +export type ControlParkKind = typeof ControlParkKind.infer; + +/** + * Maximum serialized size, in UTF-8 bytes, of an {@link ApprovalSnapshot} that + * crosses a trust boundary. A tool `inputSchema` is normally single-digit KB; + * a snapshot approaching this bound is malformed or hostile and is rejected at + * the parse boundary rather than co-written onto an approval row. + */ +export const APPROVAL_SNAPSHOT_MAX_BYTES = 131072; + +/** + * {@link ApprovalSnapshot} bounded to {@link APPROVAL_SNAPSHOT_MAX_BYTES}. + * Parse the snapshot through this validator where it crosses a trust boundary + * (the `park.notify` IPC frame, the `parked-correlations.response` IPC frame, + * and the sidecar→hub register frame); internal hops use the unbounded + * {@link ApprovalSnapshot}. `.narrow` bounds the runtime check only — its + * inferred type is identical to {@link ApprovalSnapshot} — so the cap holds only + * where a frame is actually parsed, not merely typed. + */ +export const BoundedApprovalSnapshot = ApprovalSnapshot.narrow( + (snapshot, ctx) => { + const bytes = Buffer.byteLength(JSON.stringify(snapshot), "utf8"); + return ( + bytes <= APPROVAL_SNAPSHOT_MAX_BYTES || + ctx.mustBe(`at most ${APPROVAL_SNAPSHOT_MAX_BYTES} bytes when serialized`) + ); + }, +); +export type BoundedApprovalSnapshot = typeof BoundedApprovalSnapshot.infer; + +/** + * Result of a tool execution. `content` is text or structured data the model + * sees as the tool result. `detail` is additional data that the harness may + * use (e.g., for validation or audit) but that is not shown to the model. + * + * When `isError` is true the model sees the result as an error. When + * `pendingMarker` is present the tool is async — the reactor registers the + * correlation ID and waits for a matching inbound message. + * + * (INFERENCE.md § Tool Execution Semantics) + */ +export const ToolResult = type({ + callId: "string", + content: "string | Record", + "detail?": "unknown", + "isError?": "boolean", + "pendingMarker?": { + status: "'pending'", + correlationId: "string", + "expectedFrom?": "string", + }, +}); +export type ToolResult = typeof ToolResult.infer; + +/** + * The tool runner interface. The harness implements this; the reactor calls + * it when the director requests tool execution. + * + * Parallel execution is modeled by calling `run` concurrently for each call + * in a batch — the interface is per-call, not per-batch. + * + * (ARCHITECTURE.md § Agent Harness › Tools) + */ +export interface ToolRunner { + /** + * Execute a single tool call. Resolves with the result. Must not throw — + * errors are returned as `ToolResult` with `isError: true`. + */ + run(call: ToolCall, signal: AbortSignal): Promise; +} + +// --------------------------------------------------------------------------- +// Inference Event Building Blocks (INFERENCE.md § Event Protocol) +// --------------------------------------------------------------------------- + +/** + * Partial assistant message accumulated during streaming. Carries all + * content blocks seen so far so late-joining subscribers receive current + * state without replaying deltas. + * + * `text` and `thinking` are cumulative across every emitted delta of + * that kind in the current turn — intentionally flat, even when the + * harness's per-index block tracking has split the stream into + * multiple ThinkingBlocks or TextBlocks. Consumers that need per-block + * structure walk the finalized inference.done turn's content[]; this + * snapshot is the live "what bytes has the assistant streamed so + * far" view. + * + * (INFERENCE.md § Event Protocol › Partial State) + */ +export const PartialMessage = type({ + text: "string", + "thinking?": "string", + "toolCalls?": type({ + id: "string", + name: "string", + partialArguments: "string", + }).array(), +}); +export type PartialMessage = typeof PartialMessage.infer; + +/** + * Token usage for a single inference call. Cache read/write counts are + * provider-specific and may be zero when the provider does not report them. + * + * (INFERENCE.md § Token Accounting) + */ +export const TokenUsage = type({ + input: "number", + output: "number", + cacheRead: "number", + cacheWrite: "number", + thinking: "number", +}); +export type TokenUsage = typeof TokenUsage.infer; + +/** + * Slim source descriptor stamped onto `inference.usage` / `inference.done` + * events and onto `ReactorState.lastCycleSource`. + * + * Carries enough identity for state-aware policies (cost gating, budget + * caps, governance triggers, audit) to attribute usage to a specific + * inference source without re-reading the live, mutable `InferenceSource` + * the harness owns. + * + * Deliberately a strict subset of `InferenceSource` — `apiKey` and + * `baseURL` are intentionally excluded. Credentials and endpoints must + * not leak to director-side policy code or to external event consumers. + * Any code path that needs the full source obtains it through the + * harness's source registry, not through this descriptor. + * + * `sourceId` aliases `InferenceSource.id` to disambiguate from message + * ids, turn ids, and session ids in director-side code where `id` alone + * would be ambiguous. + */ +export const LastCycleSource = type({ + sourceId: "string", + provider: "string", + model: "string", +}); +export type LastCycleSource = typeof LastCycleSource.infer; + +// --------------------------------------------------------------------------- +// Internal Turn Format (INFERENCE.md § Message Format) +// --------------------------------------------------------------------------- + +/** + * A single content block within a conversation turn. Provider-agnostic. + * + * (INFERENCE.md § Message Format › Content Types) + */ +const TextBlock = type({ + type: "'text'", + text: "string", + // Opaque provider signature authenticating this block, echoed back + // verbatim on follow-up turns. Gemini attaches a `thoughtSignature` to + // output parts (including plain text); absent for providers that do not + // sign this block kind. + "signature?": "string", +}); + +/** + * How a media payload is carried by a content block. One of three + * variants: inline as a base64-encoded string, by reference to an + * opaque provider-native handle (e.g. a Gemini fileUri, an Anthropic + * file_id), or by public URL the provider fetches itself. The wire + * shape each provider expects is built by the provider adapter; + * MediaSource is the internal, provider-agnostic representation. + * + * (INFERENCE.md § Generalized Multimodal Taxonomy) + */ +const MediaSourceBase64 = type({ + kind: "'base64'", + mimeType: "string", + data: "string", +}); + +const MediaSourceFileReference = type({ + kind: "'file-reference'", + mimeType: "string", + reference: "string", +}); + +const MediaSourceUrl = type({ + kind: "'url'", + mimeType: "string", + url: "string", +}); + +export const MediaSource = MediaSourceBase64.or(MediaSourceFileReference).or( + MediaSourceUrl, +); +export type MediaSource = typeof MediaSource.infer; + +// Exported because `inference.image_output` events reference it by +// name, following the same pattern as `CitationBlock`, +// `CodeExecutionRequestBlock`, and `RedactedThinkingBlock`. +export const ImageBlock = type({ + type: "'image'", + source: MediaSource, + // Opaque provider signature authenticating this block, echoed back + // verbatim on follow-up turns. Gemini rides a `thoughtSignature` on the + // inlineData part; absent otherwise. + "signature?": "string", +}); +export type ImageBlock = typeof ImageBlock.infer; + +const AudioBlock = type({ + type: "'audio'", + source: MediaSource, +}); + +const VideoBlock = type({ + type: "'video'", + source: MediaSource, +}); + +const DocumentBlock = type({ + type: "'document'", + source: MediaSource, + "title?": "string", + "context?": "string", +}); + +const ThinkingBlock = type({ + type: "'thinking'", + thinking: "string", + "signature?": "string", +}); + +/** + * A thinking block whose content the provider has filtered. The + * opaque `data` blob must echo back verbatim on every follow-up turn + * — Anthropic 400s the request if it changes or goes missing. Treat + * the bytes as opaque: do not log them and do not render them to + * users. + * + * Exported because `inference.thinking.redacted` events reference it + * by name. + */ +export const RedactedThinkingBlock = type({ + type: "'redacted_thinking'", + data: "string", +}); +export type RedactedThinkingBlock = typeof RedactedThinkingBlock.infer; + +/** + * A model-emitted refusal. Produced when a provider's strict-mode + * structured-outputs path declines to satisfy the requested schema — + * OpenAI's `delta.refusal` / `message.refusal` field is the canonical + * wire shape. The `reason` is the accumulated human-readable text the + * model emitted in lieu of conformant output. + * + * Refusal is semantically distinct from `inference.error`: the HTTP + * call succeeded and the model produced a coherent response, but that + * response is "I will not satisfy this schema" rather than schema- + * conformant content. Callers that distinguish policy declines from + * transport/protocol failures should branch on the block type rather + * than treat the assistant turn as an error. + * + * Exported because `inference.refusal.delta` events reference it by + * name and adapters construct RefusalBlocks in the finalized + * AssistantTurn from accumulated delta fragments. + */ +export const RefusalBlock = type({ + type: "'refusal'", + // Refusals must carry text — a zero-length reason corrupts the + // "human-readable text the model emitted in lieu of conformant + // output" contract and would round-trip indistinguishably from a + // refusal block whose payload was lost. The arktype constraint is + // belt-and-braces alongside the adapter's wire-boundary filter on + // empty `delta.refusal` chunks: synthetic fixtures or future + // adapters without that filter still cannot construct a vacuous + // refusal. + reason: "string > 0", +}); +export type RefusalBlock = typeof RefusalBlock.infer; +const ToolCallBlock = type({ + type: "'tool_call'", + id: "string", + name: "string", + arguments: "Record", + // Opaque provider signature authenticating this block, echoed back + // verbatim on follow-up turns. Gemini rides a `thoughtSignature` on the + // functionCall part; absent otherwise. + "signature?": "string", +}); +/** + * Location of a citation's cited span within its source document. + * The unit of `start` and `end` varies by `kind`: + * - "page": 1-indexed page numbers (Anthropic `page_location`). + * - "char": UTF-16 character offsets, matching JS string semantics + * (Anthropic `char_location`; Gemini `groundingSupports[].segment`). + * - "content-block": index into a structured source's content blocks + * (Anthropic `content_block_location`). + */ +const CitationLocation = type({ + kind: "'page' | 'char' | 'content-block'", + start: "number", + end: "number", +}); + +const CitationSource = type({ + "title?": "string", + // Self-contained dereferenceable URL — populated by providers whose + // citations carry URLs directly (Gemini `groundingChunks[].web.uri`). + "uri?": "string", + // Back-pointer into the request's `documents` array, populated by + // providers that cite uploaded documents by position (Anthropic + // `document_index`). + "documentRef?": type({ index: "number" }), +}); + +/** + * A citation that supports a span of assistant text. Consumers + * receiving a CitationBlock without a paired source-block index MUST + * attribute it by adjacency to the nearest preceding TextBlock in the + * same turn. + * + * Citations are deliberately excluded from ToolResultBlock.content + * — they annotate model output, not tool output. + * + * Exported because `inference.citation` events reference it by name, + * following the same pattern as `AssistantTurn`, `ToolCall`, and + * `ToolResult`. See the `inference.citation` event docstring for how + * a paired source-block index is carried on the wire and consumed by + * the harness. + */ +export const CitationBlock = type({ + type: "'citation'", + // The exact substring of the preceding TextBlock this citation + // supports. Both providers emit it; required for inspection and + // for fallback offset reconstruction. + citedText: "string", + source: CitationSource, + "location?": CitationLocation, + // UTF-16 character offsets into the preceding TextBlock's text. + // Providers that emit offsets natively populate these directly; + // adapters that derive offsets from a cited substring populate + // them only when the substring appears unambiguously in the + // preceding text. Omitted when the offset cannot be determined. + "textOffset?": type({ start: "number", end: "number" }), +}); +export type CitationBlock = typeof CitationBlock.infer; + +/** + * A structured safety signal on model output or request filtering. + * + * The name `SafetyRatingBlock` follows the issue vocabulary; the + * payload is derived from the first real Gemini capture that engaged + * the structured classifier (2026-07-28). That wire shape is + * prompt-level only: + * + * `promptFeedback: { blockReason: "PROHIBITED_CONTENT" }` + * + * with no candidates and no per-category `safetyRatings` arrays. So + * this block carries `blockReason` and does **not** invent category / + * probability / blocked fields. When a future capture surfaces + * candidate-level ratings, extend the type from those bytes rather + * than from the API reference. + * + * Deliberately excluded from ToolResultBlock.content — safety + * signals annotate model/request filtering, not tool output. + * + * Exported because `inference.safety_rating` events reference it by + * name. + */ +export const SafetyRatingBlock = type({ + type: "'safety_rating'", + // Provider-native block reason string (observed: "PROHIBITED_CONTENT"). + // Open string so a new reason token does not force a type bump. + blockReason: "string > 0", +}); +export type SafetyRatingBlock = typeof SafetyRatingBlock.infer; + +/** + * Human-readable rendering of a SafetyRatingBlock for reply text, + * timeline summaries, and request-history rewrites when a provider + * has no input wire shape for safety_rating. Single owner of the + * display string so reply / history / transform stay in lockstep. + */ +export function formatSafetyRatingText(block: SafetyRatingBlock): string { + return `Request blocked: ${block.blockReason}`; +} + +/** + * The model's request to execute code via a server-side execution tool. + * Paired with a CodeExecutionResultBlock carrying the same `id` as the + * result's `requestId`. Streaming order within a single execution is + * `inference.code_execution.start` → zero or more + * `inference.code_execution.delta` → `inference.code_execution.result`, + * uninterrupted by other events that share the same `requestId`; events + * with different `requestId`s or for other block kinds at distinct + * `index`es may interleave. + * + * Exported because `inference.code_execution.start` references it by + * name. + */ +export const CodeExecutionRequestBlock = type({ + type: "'code_execution_request'", + // Identifier for the execution request. Populated from the + // provider's call id where one exists (Anthropic + // `srvtoolu_...`); synthesized by the adapter for providers that + // don't emit one (Gemini), using a deterministic per-response + // position-based scheme so replays match. + id: "string", + // Source code the model is asking to execute. + code: "string", + // Language hint. Absent when the provider does not emit one; + // adapters MUST NOT default this — callers narrow on its + // presence rather than fall through to a guessed language. + "language?": "string", + // Opaque provider signature authenticating this block, echoed back + // verbatim on follow-up turns. Gemini rides a `thoughtSignature` on the + // executableCode part; absent otherwise. + "signature?": "string", +}); +export type CodeExecutionRequestBlock = typeof CodeExecutionRequestBlock.infer; + +/** + * The result of executing a CodeExecutionRequestBlock. The `requestId` + * back-points to the request block's `id`. Status is normalized across + * providers; raw provider signals (return code, native outcome string, + * abort reason) are preserved on optional fields for callers that need + * them. + * + * File outputs from code execution (e.g. generated plots that + * Anthropic returns in `code_execution_tool_result.content`) are NOT + * modeled by this block today. The block carries no field for them; + * surfacing file outputs is a separate concern. + * + * Exported because `inference.code_execution.result` references it by + * name. + */ +export const CodeExecutionResultBlock = type({ + type: "'code_execution_result'", + // Back-pointer to the originating CodeExecutionRequestBlock.id. + requestId: "string", + // Normalized outcome. Translated from provider-specific signals: + // - Anthropic: derived from `return_code` (0 → "ok", non-zero → + // "error") and `abort_reason` (non-null → "aborted" or + // "timeout" per the reason). + // - Gemini: derived from the `outcome` enum + // (OUTCOME_OK → "ok", OUTCOME_FAILED → "error", + // OUTCOME_DEADLINE_EXCEEDED → "timeout", etc.). + status: "'ok' | 'error' | 'aborted' | 'timeout'", + // Standard output. Providers that don't split stdout from stderr + // (Gemini) map their combined `output` here and leave `stderr` empty. + "stdout?": "string", + // Standard error. Empty for providers that don't split. + "stderr?": "string", + // Provider-native numeric return code when available + // (Anthropic `return_code`). Absent for providers whose outcome + // is enum-only (Gemini). + "returnCode?": "number", + // Provider-native outcome string preserved verbatim for callers + // that need the raw signal (Gemini `OUTCOME_OK` / + // `OUTCOME_FAILED` / `OUTCOME_DEADLINE_EXCEEDED` / ...). Absent + // when the provider does not emit one (Anthropic). + "providerOutcome?": "string", + // Human-readable reason populated when status is "aborted" + // (Anthropic `abort_reason`). Absent otherwise. + "abortReason?": "string", +}); +export type CodeExecutionResultBlock = typeof CodeExecutionResultBlock.infer; + +const ToolResultBlock = type({ + type: "'tool_result'", + callId: "string", + // Deliberately narrow: tool results carry user-facing media, not + // CitationBlocks (citations annotate the model's text output), not + // SafetyRatingBlocks (safety signals annotate model/request + // filtering), and not CodeExecution blocks (server-side code + // execution is a distinct lifecycle from the user-tool round-trip). + content: TextBlock.or(ImageBlock) + .or(AudioBlock) + .or(VideoBlock) + .or(DocumentBlock) + .array(), + "detail?": "unknown", + "isError?": "boolean", +}); + +export const ContentBlock = TextBlock.or(ThinkingBlock) + .or(RedactedThinkingBlock) + .or(RefusalBlock) + .or(ImageBlock) + .or(AudioBlock) + .or(VideoBlock) + .or(DocumentBlock) + .or(CitationBlock) + .or(SafetyRatingBlock) + .or(CodeExecutionRequestBlock) + .or(CodeExecutionResultBlock) + .or(ToolCallBlock) + .or(ToolResultBlock); +export type ContentBlock = typeof ContentBlock.infer; + +/** + * A turn in the internal conversation history. The `model` field records + * which provider model produced this turn (present only on assistant + * turns). Used by cross-provider transformation to strip or preserve + * thinking blocks. + * + * (INFERENCE.md § Message Format) + */ +export type ConversationTurn = { + role: "user" | "assistant" | "system"; + content: ContentBlock[]; + model?: string; + timestamp: number; +}; + +/** + * A completed assistant turn returned in `inference.done`. Narrower type + * than ConversationTurn to make the inference boundary explicit. + */ +export const AssistantTurn = type({ + role: "'assistant'", + content: ContentBlock.array(), + model: "string", + timestamp: "number", +}); +export type AssistantTurn = typeof AssistantTurn.infer; + +// --------------------------------------------------------------------------- +// Error Classification (INFERENCE.md § Error Classification) +// --------------------------------------------------------------------------- + +/** + * Classified inference error. The category determines the reactor's default + * response; the director can override per its policy. + * + * (INFERENCE.md § Error Classification) + */ +export const InferenceError = type({ + category: type.enumerated( + "retryable", + "context_overflow", + "credential_failure", + "quota_exhausted", + "fatal", + "aborted", + "timeout", + "protocol_mismatch", + ), + message: "string", + "statusCode?": "number", + "retryAfterMs?": "number", + "raw?": "unknown", +}); +export type InferenceError = typeof InferenceError.infer; + +// --------------------------------------------------------------------------- +// Agent Reactor (INFERENCE.md § Agent Reactor) +// --------------------------------------------------------------------------- + +/** + * Gate types that can block the reactor. + * + * (INFERENCE.md § Gates) + */ +export const GateType = type.enumerated( + "approval", + "payment", + "credential", + "budget", + "child_completion", + "message_response", +); +export type GateType = typeof GateType.infer; + +/** + * Fork mode. `independent` creates a divergent reactor with its own context. + * `child` creates a reactor that reports results back to the parent. + * + * (INFERENCE.md § Forking) + */ +export const ForkMode = type.enumerated("independent", "child"); +export type ForkMode = typeof ForkMode.infer; + +// --------------------------------------------------------------------------- +// Inference Event Protocol (INFERENCE.md § Event Protocol) +// --------------------------------------------------------------------------- + +/** + * Wire-safe representation of InboundMessage for use in InferenceEvent + * variants. The runtime InboundMessage type contains Uint8Array fields + * (MessageAttachment.data) that cannot survive JSON serialization, so the + * wire validator uses `unknown` for attachment data and accepts whatever + * JSON.parse produces. + */ +const WireInboundMessage = type({ + ref: { uid: "number", mailbox: "string" }, + headers: "Record", + flags: "string[]", + "content?": "string", + "payload?": "object", + "attachments?": "unknown[]", + signatureStatus: type.enumerated("valid", "invalid", "unknown", "missing"), +}); + +/** + * A single event in the inference event protocol. Every event carries a + * monotonic session-scoped sequence number. + * + * Event types are namespaced: `inference.*`, `tool.*`, `reactor.*`, + * `fork.*`, `message.*`, `custom.*`. + * + * (INFERENCE.md § Event Protocol) + */ +export const InferenceEvent = type({ + type: "'inference.start'", + seq: "number", + data: { model: "string" }, +}) + .or({ + type: "'inference.thinking.delta'", + seq: "number", + data: { + token: "string", + partial: PartialMessage, + "index?": "number", + }, + }) + .or({ + type: "'inference.block.signature'", + seq: "number", + data: { signature: "string", "index?": "number" }, + }) + .or({ + type: "'inference.thinking.redacted'", + seq: "number", + data: { redactedThinking: RedactedThinkingBlock, "index?": "number" }, + }) + .or({ + type: "'inference.text.delta'", + seq: "number", + data: { + token: "string", + partial: PartialMessage, + "index?": "number", + }, + }) + .or({ + type: "'inference.refusal.delta'", + seq: "number", + data: { + token: "string", + partial: PartialMessage, + "index?": "number", + }, + }) + .or({ + type: "'inference.tool_call.start'", + seq: "number", + data: { + callId: "string", + name: "string", + partial: PartialMessage, + "index?": "number", + }, + }) + .or({ + type: "'inference.tool_call.delta'", + seq: "number", + data: { + callId: "string", + argumentFragment: "string", + partial: PartialMessage, + "index?": "number", + }, + }) + .or({ + type: "'inference.tool_call.end'", + seq: "number", + data: { + callId: "string", + name: "string", + arguments: "Record", + partial: PartialMessage, + "index?": "number", + }, + }) + .or({ + type: "'inference.usage'", + seq: "number", + data: { usage: TokenUsage, source: LastCycleSource }, + }) + .or({ + type: "'inference.done'", + seq: "number", + data: { + turn: AssistantTurn, + usage: TokenUsage, + source: LastCycleSource, + "pacingDelayMs?": "number", + }, + }) + .or({ + type: "'inference.error'", + seq: "number", + data: { error: InferenceError, partial: PartialMessage }, + }) + .or({ + type: "'inference.retry'", + seq: "number", + data: { + attempt: "number", + delayMs: "number", + previousError: InferenceError, + }, + }) + .or({ + type: "'inference.citation'", + seq: "number", + // `index`, when present, names the source content block (typically + // a TextBlock) the citation annotates. The harness uses it to + // interleave the citation into the finalized turn's `content[]` + // immediately after the matching block. Adapters whose wire + // protocol does not carry per-citation block indices omit the + // field; the harness then appends those citations at the end of + // `content[]` and consumers attribute them to the nearest + // preceding TextBlock per the CitationBlock docstring. + data: { citation: CitationBlock, "index?": "number" }, + }) + .or({ + type: "'inference.safety_rating'", + seq: "number", + // Prompt-level structured safety signal (observed Gemini + // `promptFeedback.blockReason`). No candidate index: the first + // capture has zero candidates. Harness appends the block to the + // finalized turn's `content[]`. + data: { safetyRating: SafetyRatingBlock }, + }) + .or({ + type: "'inference.code_execution.start'", + seq: "number", + data: { request: CodeExecutionRequestBlock, "index?": "number" }, + }) + .or({ + type: "'inference.code_execution.delta'", + seq: "number", + // requestId correlates fragments back to the originating + // CodeExecutionRequestBlock; index is the positional hint into + // the response's content-block stream. They are independent: a + // single response may stream code execution for multiple + // requests interleaved, distinguished by requestId; index lets + // the harness's per-block accumulator route the fragment to + // the correct block when the array isn't yet finalized. + data: { + requestId: "string", + codeFragment: "string", + "index?": "number", + }, + }) + .or({ + type: "'inference.code_execution.result'", + seq: "number", + data: { result: CodeExecutionResultBlock, "index?": "number" }, + }) + .or({ + type: "'inference.image_output'", + seq: "number", + // Fires mid-stream when an adapter finalizes an image-output + // block, signaling that the image is ready for downstream + // handoff before the full inference.done lands. The wrapped + // ImageBlock typically carries a base64 MediaSource — the + // payload can be large (Gemini's image-output captures show + // ~1MB inline blobs); consumers that subscribe to this event + // should treat it as a non-trivial transport size. + data: { image: ImageBlock, "index?": "number" }, + }) + .or({ + type: "'tool.start'", + seq: "number", + data: { call: ToolCall }, + }) + .or({ + type: "'tool.update'", + seq: "number", + data: { callId: "string", partial: "string" }, + }) + .or({ + type: "'tool.done'", + seq: "number", + data: { result: ToolResult }, + }) + .or({ + type: "'message.queued'", + seq: "number", + data: { message: WireInboundMessage }, + }) + .or({ + type: "'message.run.started'", + seq: "number", + data: { + messageId: "string", + messageRunId: "string", + receivedAt: "number", + }, + }) + .or({ + type: "'message.run.ended'", + seq: "number", + data: { + messageRunId: "string", + messageId: "string", + status: type.enumerated("completed", "failed"), + "error?": { + message: "string", + "kind?": "string", + }, + }, + }) + .or({ + type: "'message.correlated'", + seq: "number", + data: { message: WireInboundMessage, correlationId: "string" }, + }) + .or({ + type: "'connector.reply'", + seq: "number", + data: { content: "string", "checkpointHash?": "string" }, + }) + .or({ + type: "'reactor.start'", + seq: "number", + data: "object", + }) + .or({ + type: "'reactor.gate.blocked'", + seq: "number", + data: { + reason: GateType, + gateId: "string", + "correlationId?": "string", + "approvalSnapshot?": ApprovalSnapshot, + }, + }) + .or({ + type: "'reactor.gate.cleared'", + seq: "number", + data: { + gateId: "string", + reason: type.enumerated("resolved", "timeout", "shutdown"), + }, + }) + .or({ + type: "'reactor.done'", + seq: "number", + data: "object", + }) + .or({ + type: "'reactor.error'", + seq: "number", + data: { error: "string", fatal: "boolean" }, + }) + .or({ + type: "'fork.created'", + seq: "number", + data: { forkId: "string", parentId: "string", mode: ForkMode }, + }) + .or({ + type: "'fork.done'", + seq: "number", + data: { forkId: "string", "result?": "unknown" }, + }) + .or({ + type: "'fork.error'", + seq: "number", + data: { forkId: "string", error: "string" }, + }) + .or({ + type: "'fork.aborted'", + seq: "number", + data: { forkId: "string" }, + }) + .or({ + type: /^custom\./, + seq: "number", + data: "Record", + }); +// The TypeScript type is defined manually rather than inferred from the +// validator because the `custom.*` variant uses a regex pattern which +// arktype infers as `string`. A bare `string` in the discriminant position +// prevents TypeScript from narrowing the union in switch statements. +// The manually defined type uses a `custom.${string}` template literal +// for that variant, preserving the narrowing behavior downstream code +// relies on. +export type InferenceEvent = + | { type: "inference.start"; seq: number; data: { model: string } } + | { + type: "inference.thinking.delta"; + seq: number; + data: { token: string; partial: PartialMessage; index?: number }; + } + | { + type: "inference.block.signature"; + seq: number; + data: { signature: string; index?: number }; + } + | { + type: "inference.thinking.redacted"; + seq: number; + data: { redactedThinking: RedactedThinkingBlock; index?: number }; + } + | { + type: "inference.text.delta"; + seq: number; + data: { token: string; partial: PartialMessage; index?: number }; + } + | { + type: "inference.refusal.delta"; + seq: number; + data: { token: string; partial: PartialMessage; index?: number }; + } + | { + type: "inference.tool_call.start"; + seq: number; + data: { + callId: string; + name: string; + partial: PartialMessage; + index?: number; + }; + } + | { + type: "inference.tool_call.delta"; + seq: number; + data: { + callId: string; + argumentFragment: string; + partial: PartialMessage; + index?: number; + }; + } + | { + type: "inference.tool_call.end"; + seq: number; + data: { + callId: string; + name: string; + arguments: Record; + partial: PartialMessage; + index?: number; + }; + } + | { + type: "inference.usage"; + seq: number; + data: { usage: TokenUsage; source: LastCycleSource }; + } + | { + type: "inference.done"; + seq: number; + data: { + turn: AssistantTurn; + usage: TokenUsage; + source: LastCycleSource; + pacingDelayMs?: number; + }; + } + | { + type: "inference.error"; + seq: number; + data: { error: InferenceError; partial: PartialMessage }; + } + | { + /** + * Emitted between attempts when the per-call retry policy decides + * to retry after an error. `attempt` is the 1-indexed number of + * the attempt that just **failed** — the same value the policy + * saw on its `RetrySituation.attempt` reading. `delayMs` is the + * delay the wrapper will apply before the next attempt starts; + * `previousError` carries the classified error that triggered + * the retry. The event is not emitted when the policy aborts. + */ + type: "inference.retry"; + seq: number; + data: { + attempt: number; + delayMs: number; + previousError: InferenceError; + }; + } + | { + type: "inference.citation"; + seq: number; + data: { citation: CitationBlock; index?: number }; + } + | { + type: "inference.safety_rating"; + seq: number; + data: { safetyRating: SafetyRatingBlock }; + } + | { + type: "inference.code_execution.start"; + seq: number; + data: { request: CodeExecutionRequestBlock; index?: number }; + } + | { + type: "inference.code_execution.delta"; + seq: number; + data: { requestId: string; codeFragment: string; index?: number }; + } + | { + type: "inference.code_execution.result"; + seq: number; + data: { result: CodeExecutionResultBlock; index?: number }; + } + | { + type: "inference.image_output"; + seq: number; + data: { image: ImageBlock; index?: number }; + } + | { type: "tool.start"; seq: number; data: { call: ToolCall } } + | { + type: "tool.update"; + seq: number; + data: { callId: string; partial: string }; + } + | { type: "tool.done"; seq: number; data: { result: ToolResult } } + | { + type: "message.queued"; + seq: number; + data: { message: InboundMessage }; + } + | { + /** + * Per-message run-bracket open. Emitted by the reactor when it + * dequeues an inbound mail message and begins per-message work. + * + * `messageRunId` is reactor-minted, unique per dequeue. It is + * non-negotiable for crash-replay correlation: the reactor can + * legitimately dequeue the same `messageId` more than once across + * a crash + replay cycle, so two bracket-open events with the + * same `messageId` and no run-id cannot be unambiguously paired + * with their `message.run.ended` counterparts. + */ + type: "message.run.started"; + seq: number; + data: { + messageId: string; + messageRunId: string; + receivedAt: number; + }; + } + | { + /** + * Per-message run-bracket close. Pairs with `message.run.started` + * by `messageRunId`. `messageId` is carried redundantly so log + * readers can correlate without a join against the open event. + * + * The `status` enum is `"completed" | "failed"` only. + * Cancellation lives in the workflow-runtime's + * `CancelRequested` -> `RunFailed` vocabulary, not on the + * reactor's bracket: the reactor does not run a state machine + * and what it observes when cancellation arrives is a harness + * abort, which is structurally `"failed"` with a specific + * `error.kind`. + * + * `error.kind` is documented as one of + * `"inference_error" | "tool_error" | "reactor_fatal" | + * "harness_aborted" | "doom_loop"` initially, extensible as new + * failure categories surface. `"doom_loop"` marks a protective + * break the reactor took on the agent's behalf when the agent + * repeated an identical tool batch past the configured threshold; + * unlike `"reactor_fatal"` it is not an internal fault. + */ + type: "message.run.ended"; + seq: number; + data: { + messageRunId: string; + messageId: string; + status: "completed" | "failed"; + error?: { + message: string; + kind?: string; + }; + }; + } + | { + type: "message.correlated"; + seq: number; + data: { message: InboundMessage; correlationId: string }; + } + | { + type: "connector.reply"; + seq: number; + data: { content: string; checkpointHash?: string }; + } + | { type: "reactor.start"; seq: number; data: Record } + | { + type: "reactor.gate.blocked"; + seq: number; + data: { + reason: GateType; + gateId: string; + correlationId?: string; + approvalSnapshot?: ApprovalSnapshot; + }; + } + | { + type: "reactor.gate.cleared"; + seq: number; + data: { + gateId: string; + reason: "resolved" | "timeout" | "shutdown"; + }; + } + | { type: "reactor.done"; seq: number; data: Record } + | { + type: "reactor.error"; + seq: number; + data: { error: string; fatal: boolean }; + } + | { + type: "fork.created"; + seq: number; + data: { forkId: string; parentId: string; mode: ForkMode }; + } + | { + type: "fork.done"; + seq: number; + data: { forkId: string; result?: unknown }; + } + | { + type: "fork.error"; + seq: number; + data: { forkId: string; error: string }; + } + | { type: "fork.aborted"; seq: number; data: { forkId: string } } + | { + type: `custom.${string}`; + seq: number; + data: Record; + }; + +// Load-bearing drift guards for the dual-maintained `reactor.gate.blocked` +// event. The arktype `InferenceEvent` validator and the hand-written +// `InferenceEvent` type are kept in lockstep by hand (the `custom.*` regex +// variant forces the manual mirror). arktype passes undeclared keys through at +// runtime, so a schema that dropped `approvalSnapshot` would not fail at +// runtime. Projecting the field off each inferred shape makes it load-bearing: +// `tsc` errors if either mirror stops carrying it, mirroring the +// `_persistedSuspendedCall` guard in storage-isogit. +const _arkGateBlockedApprovalSnapshot = ( + data: Extract< + typeof InferenceEvent.infer, + { type: "reactor.gate.blocked" } + >["data"], +): ApprovalSnapshot | undefined => data.approvalSnapshot; +void _arkGateBlockedApprovalSnapshot; + +const _tsGateBlockedApprovalSnapshot = ( + data: Extract["data"], +): ApprovalSnapshot | undefined => data.approvalSnapshot; +void _tsGateBlockedApprovalSnapshot; + +/** + * Validate unknown data as an InferenceEvent. ArkType's regex-based validator + * infers `custom.*` event types as `string`, but the manual InferenceEvent type + * uses a `custom.${string}` template literal for switch narrowing. This function + * centralizes that single unavoidable cast. + */ +export function parseInferenceEvent( + data: unknown, +): InferenceEvent | type.errors { + const result = InferenceEvent(data); + if (result instanceof type.errors) return result; + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion -- arktype regex infers as string; manual type uses template literal + return result as InferenceEvent; +} + +/** + * A pending async operation registered in the reactor's async state. + * Correlates an outbound message (or payment/approval request) to the + * expected inbound response. + * + * (INFERENCE.md § Correlation) + */ +export type PendingOperation = { + correlationId: string; + kind: SignalKind; + expectedFrom?: string; + registeredAt: number; + gateId: string; + /** + * Absolute deadline (epoch ms) for the gate that parks this operation. + * Persisted so that rehydration after a restart re-arms the gate with the + * remaining time against the original deadline rather than restarting the + * countdown. Absent for operations parked with no deadline. + */ + timeoutAt?: number; + /** + * The tool call that was suspended when this operation parked. Captured for + * `kind: "approval"` operations minted from the ask flow so the approved + * call can be re-run on resume. Absent for operations parked by the + * director path (async-tool pending markers), which carry no tool call. + */ + suspendedCall?: ToolCall; + /** + * Approver-facing snapshot of `suspendedCall`, built at the authz `ask` + * branch from the tool definition and the live arguments. A sibling of + * `suspendedCall`, not a widening of it: `suspendedCall` is the re-dispatch + * artifact, this is what the approver decides on. Present only for ask-rail + * operations that carry a `suspendedCall`; absent for async-tool pending + * markers. Threaded through the suspend hops to the hub co-write. + */ + approvalSnapshot?: ApprovalSnapshot; +}; + +/** + * Complete reactor state visible to the director decision function. + * + * `tokenUsage` is the cumulative usage across the session. + * + * `lastCycleUsage` and `lastCycleSource` describe the most recent + * *successful* inference call. They move together: both null before the + * first completion; every `inference.done` sets both atomically. + * `inference.error` does not clear either — the pair always reflects the + * last cycle that produced a well-defined turn and usage. The director's + * `afterInferenceDone` hook fires only on `inference.done`, so policy + * code never observes a torn or stale-vs-fresh window. + * + * The per-cycle values support compaction triggers that key off recent + * input cost rather than session totals, and state-aware policies (cost + * gating, budget caps, governance triggers) that need to attribute + * usage to the source that produced it. + * + * (INFERENCE.md § Agent Reactor › Director Decision Function) + */ +export type ReactorState = { + turns: ConversationTurn[]; + activeForks: { forkId: string; mode: ForkMode }[]; + pendingOperations: PendingOperation[]; + activeGates: { gateId: string; type: GateType; timeoutAt: number }[]; + tokenUsage: TokenUsage; + lastCycleUsage: TokenUsage | null; + lastCycleSource: LastCycleSource | null; + sessionId: string; +}; + +/** + * Actions the director can direct the reactor to take. + * + * (INFERENCE.md § Agent Reactor › Actions) + */ +export type ReactorAction = + | { + type: "infer"; + options?: InferenceOptions; + } + | { + type: "execute_tools"; + calls: ToolCall[]; + parallel?: boolean; + addToHistory?: boolean; + } + | { + type: "suspend"; + gate: { + type: GateType; + gateId: string; + timeoutMs: number; + correlationId?: string; + }; + } + | { + type: "fork"; + mode: ForkMode; + forkId: string; + } + | { + type: "emit"; + eventType: `custom.${string}`; + data: Record; + } + | { + type: "reply"; + content: string; + } + | { type: "checkpoint"; message: string } + | { type: "compact"; compactor: string; reason: string } + | { type: "wait" } + | { type: "done" }; + +/** + * The capabilities object passed to the director. Mirrors the `ReactorAction` + * union — provides a type-safe way for the director to construct actions. + * + * (INFERENCE.md § Agent Reactor › Director Decision Function) + */ +export type ReactorCapabilities = { + infer(options?: InferenceOptions): ReactorAction; + executeTools( + calls: ToolCall[], + parallel?: boolean, + addToHistory?: boolean, + ): ReactorAction; + suspend(gate: { + type: GateType; + gateId: string; + timeoutMs: number; + correlationId?: string; + }): ReactorAction; + fork(mode: ForkMode, forkId: string): ReactorAction; + emit( + eventType: `custom.${string}`, + data: Record, + ): ReactorAction; + reply(content: string): ReactorAction; + checkpoint(message?: string): ReactorAction; + compact(compactor: string, reason: string): ReactorAction; + wait(): ReactorAction; + done(): ReactorAction; +}; + +/** + * The inbound events delivered to the director decision function. + * + * `resume.execute_tools` is raised by the reactor when an approval resolves + * and a parked tool call must be re-run on resume. It carries the calls the + * reactor is about to dispatch so the director can seed its outstanding + * tool-result count before those calls' `tool.done` events arrive — the + * reactor drives the execution, the director counts the results. Without this + * seed the count would sit at zero and the first `tool.done` would drive an + * accidental re-inference off a negative count. + * + * `resume.tool_result` is raised by the reactor when a parked approval ends + * without running its tool — a rejected decision or a gate timeout. It carries + * a synthetic error tool result that answers the parked call so history stays + * well-formed; the director appends it and re-infers exactly once. No tool + * runs, so it seeds no outstanding-result count. + * + * (INFERENCE.md § Agent Reactor › Reactor Structure) + */ +export type ReactorInboundEvent = + | { type: "message.received"; message: InboundMessage } + | { + type: "inference.done"; + turn: AssistantTurn; + usage: TokenUsage; + source: LastCycleSource; + } + | { type: "inference.error"; error: InferenceError; partial: PartialMessage } + | { type: "tool.done"; result: ToolResult } + | { + type: "reactor.gate.cleared"; + gateId: string; + reason: "resolved" | "timeout" | "shutdown"; + } + | { type: "resume.execute_tools"; calls: ToolCall[] } + | { type: "resume.tool_result"; result: ToolResult } + | { type: "abort"; reason: AbortReason }; + +/** + * The core director is a single decision function: given an event and the + * current reactor state, return one or more actions. + * + * If the director throws, the reactor catches the exception, emits + * `reactor.error`, and initiates graceful shutdown. + * + * (INFERENCE.md § Reactor Director › Core Director) + */ +export interface ReactorDirector { + decide( + event: ReactorInboundEvent, + state: ReactorState, + capabilities: ReactorCapabilities, + ): Promise; +} + +// --------------------------------------------------------------------------- +// Director Extension Hooks (INFERENCE.md § Reactor Director › Extension Hooks) +// --------------------------------------------------------------------------- + +/** + * Decision returned by a `BeforeToolExtension`. + * + * - `allow` — the tool proceeds. + * - `block` — the tool is answered with an error result carrying `reason`; + * the call is done. + * - `suspend` — the call is parked awaiting an external decision. The reactor + * registers `gate`, persists `pendingOp`, and does not answer the call: it + * is neither run nor error-completed. `gate.timeoutAt` is the absolute + * deadline (epoch ms) so the reactor can compute the remaining time; the + * `correlationId` on both `gate` and `pendingOp` ties an inbound resolution + * back to the suspension. + */ +export type BeforeToolDecision = + | { type: "allow" } + | { type: "block"; reason: string } + | { + type: "suspend"; + gate: { + type: GateType; + gateId: string; + correlationId: string; + timeoutAt: number; + }; + pendingOp: PendingOperation; + }; + +/** + * Extension that runs before a tool call is executed. Returns a + * `BeforeToolDecision`: `allow` lets the call run, `block` answers it with an + * error result, `suspend` parks it awaiting an external decision. + * + * `grantOneShot` registers a within-cycle bypass token keyed on a + * `ToolCall.id`: the next `beforeTool` for that id skips a suspension it would + * otherwise raise, consuming the token as it does so. It is optional because + * only extensions that can suspend a call have anything to bypass; extensions + * that never suspend omit it. + */ +export interface BeforeToolExtension { + beforeTool( + call: ToolCall, + state: ReactorState, + signal: AbortSignal, + ): Promise; + grantOneShot?(id: string): void; +} + +/** + * Extension that runs after a tool result is produced. Can modify the result + * (redaction, enrichment, audit logging). Extensions run in order. + */ +export interface AfterToolExtension { + afterTool( + result: ToolResult, + call: ToolCall, + state: ReactorState, + signal: AbortSignal, + ): Promise; +} + +// --------------------------------------------------------------------------- +// Context Strategies: Transforms and Compactors +// (INFERENCE.md § Context Management, § Tool Result Lifecycle) +// --------------------------------------------------------------------------- + +/** + * Durable description of a single strategy invocation. Written to the + * per-cycle manifest in the context store so that future operators can + * reconstruct exactly which strategy made which change, with what + * parameters, and why. + * + * - `strategy` is the implementation name (e.g. `"size-cap"`). + * - `version` is the implementation version. Changes to the strategy's + * behavior bump the version so old manifest entries remain unambiguous. + * - `parameters` records the configuration the strategy ran with. + * - `reason` is a short machine-readable cause label + * (e.g. `"exceeded-cap"`, `"overflow-recovery"`). + * - `decisions` records strategy-specific details about what was actually + * done (e.g. the keep count, the spill key, the original byte size). + */ +export const TransformRecord = type({ + strategy: "string", + version: "string", + parameters: "Record", + reason: "string", + decisions: "Record", +}); +export type TransformRecord = typeof TransformRecord.infer; + +/** + * Per-invocation context passed to every `ContextStrategy.apply` call. + * `state` is the reactor's snapshot at the moment the strategy runs; + * `trigger` is a short label describing why the strategy was invoked + * (e.g. `"tool-result-ingest"`, `"pre-inference"`, `"director-request"`). + */ +export interface StrategyContext { + readonly state: ReactorState; + readonly trigger: string; +} + +/** + * Optional blob attachment emitted by a strategy. The reactor writes each + * blob to the context store's working tree via `ContextStore.writeBlob` + * (Phase 2) so the data is durable and migrates with the conversation. + */ +export type StrategyBlob = { + key: string; + bytes: Uint8Array; + contentType?: string; +}; + +/** + * Result returned by `ContextStrategy.apply`. Carries the transformed + * output, a `TransformRecord` describing what happened, and any blobs + * that should be persisted in the context store. + */ +export interface StrategyResult { + output: O; + record: TransformRecord; + blobs?: StrategyBlob[]; +} + +/** + * Generic base interface for content-mutating strategies. The role-specific + * aliases below specialize `I` and `O` for tool-result ingestion, pre- + * inference context shaping, and explicit compaction. + * + * Strategies are pure with respect to the context store: they describe what + * should change via their return value. The reactor decides where to write + * the result (history, prompt, manifest) and which blobs to persist. + */ +export interface ContextStrategy { + readonly name: string; + readonly version: string; + apply(input: I, ctx: StrategyContext): Promise>; +} + +/** + * Runs on each tool result entering history. Output is appended to the + * conversation; any emitted blobs are written to the context store's + * `tool-output/` directory. + */ +export type ToolResultTransform = ContextStrategy< + { call: ToolCall; result: ToolResult }, + ToolResult +>; + +/** + * Runs in order before every inference call, producing the materialized + * prompt. Output is written to `prompt.jsonl` for that cycle; the durable + * history in `turns.jsonl` is left untouched. + * + * (INFERENCE.md § Async State Awareness › Pending Status Injection) + */ +export type ContextTransform = ContextStrategy< + ConversationTurn[], + ConversationTurn[] +>; + +/** + * Named compaction strategy. Registered in a registry on the reactor and + * invoked explicitly via the director's `compact` action. Output overwrites + * `turns.jsonl`; a `TransformRecord` is appended to the manifest. + */ +export type Compactor = ContextStrategy; + +// --------------------------------------------------------------------------- +// Blob Reader (INFERENCE.md § Tool Result Lifecycle) +// --------------------------------------------------------------------------- + +/** + * Read-only capability for resolving `tool-output:///{callId}` URIs to the + * underlying blob bytes. A `ToolResultTransform` that spills oversized tool + * output writes a blob via `ContextStore.writeBlob` and returns a pointer of + * the form `tool-output:///{callId}`; the agent's read tool reaches the spill + * by calling `BlobReader.read(uri)`. + * + * The URI scheme is deliberately rigid: + * + * - Scheme: `tool-output` + * - Authority: empty (the `///` makes pathname carry the callId) + * - Path: `/{callId}` — preserves case so provider-assigned callIds with + * uppercase letters survive parsing + * - Query and fragment: rejected + * + * Any deviation (different scheme, missing or non-empty hostname, extra path + * segments, search string, or fragment) throws. Missing blobs throw. + * `BlobReader` never accepts a filesystem path; the agent has no direct view + * of the context store's working tree. + */ +export interface BlobReader { + /** + * Resolve `uri` to the underlying blob bytes. Throws if the URI is not a + * well-formed `tool-output:///{callId}` reference or if no blob exists for + * the extracted callId. + */ + read(uri: string): Promise; +} + +/** Source for blob bytes used by `createBlobReader`. */ +export interface BlobSource { + readBlob(key: string, signal?: AbortSignal): Promise; +} + +/** + * Parse a `tool-output:///{callId}` URI and return the callId. Throws on any + * deviation from the documented shape: wrong scheme, non-empty authority, + * missing or extra path components, search string, or fragment. + * + * The two-slash form `tool-output://abc` is rejected because the URL parser + * lowercases the hostname, which silently corrupts provider-assigned callIds + * that contain uppercase letters. The three-slash form puts the callId in + * `pathname`, where case is preserved. + */ +export function parseToolOutputURI(uri: string): string { + let parsed: URL; + try { + parsed = new URL(uri); + } catch (cause) { + throw new Error(`invalid tool-output URI: ${uri}`, { cause }); + } + if (parsed.protocol !== "tool-output:") { + throw new Error( + `invalid tool-output URI scheme: expected "tool-output:", got "${parsed.protocol}"`, + ); + } + if (parsed.hostname !== "") { + throw new Error( + `invalid tool-output URI: authority must be empty (use the form tool-output:///{callId}), got "${parsed.hostname}"`, + ); + } + if (parsed.search !== "") { + throw new Error( + `invalid tool-output URI: query string is not allowed, got "${parsed.search}"`, + ); + } + if (parsed.hash !== "") { + throw new Error( + `invalid tool-output URI: fragment is not allowed, got "${parsed.hash}"`, + ); + } + const path = parsed.pathname; + if (!path.startsWith("/")) { + throw new Error(`invalid tool-output URI: empty path: ${uri}`); + } + const callId = path.slice(1); + if (callId === "") { + throw new Error(`invalid tool-output URI: missing callId: ${uri}`); + } + if (callId.includes("/")) { + throw new Error( + `invalid tool-output URI: path must contain a single callId segment, got "${callId}"`, + ); + } + return callId; +} + +/** + * Construct a `BlobReader` that resolves `tool-output:///{callId}` URIs by + * delegating to `source.readBlob(callId)`. The most common source is a + * `ContextStore` (Phase 2 added `readBlob` to that interface), but any object + * implementing `BlobSource` works — this keeps tests trivial. + * + * URI parsing is performed in this layer; the source only ever sees the + * extracted callId. Missing blobs surface as whatever error the source + * raises (`ContextStore.readBlob` already throws for unknown keys). + */ +export function createBlobReader(source: BlobSource): BlobReader { + return { + async read(uri: string): Promise { + const callId = parseToolOutputURI(uri); + return source.readBlob(callId); + }, + }; +} + +// --------------------------------------------------------------------------- +// Abort Reasons (INFERENCE.md § Abort Handling) +// --------------------------------------------------------------------------- + +/** + * Reason codes for the `abort` reactor event. The reason determines the + * appropriate cleanup action. + * + * (INFERENCE.md § Abort Handling › Abort Reasons) + */ +export const AbortReason = type.enumerated( + "user_disconnect", + "wallet_exhaustion", + "admin_kill", + "session_timeout", + "credential_revocation", +); +export type AbortReason = typeof AbortReason.infer; + +// --------------------------------------------------------------------------- +// Inference Source (INFERENCE.md § Providers) +// --------------------------------------------------------------------------- + +/** + * Model-bound default knobs for an inference source. Per-call + * `InferenceOptions.X` overrides `defaults.X`; the merge happens once at + * the top of `runInference` before the adapter sees anything. New fields + * land here as separately-scoped issues. + */ +export const InferenceSourceDefaults = type({ + "maxTokens?": "number", + // A bag of provider-native knobs the caller wants merged into the + // outbound request body (Anthropic's `metadata.user_id`, + // OpenAI's `user`, Gemini's `safetySettings`, etc.). Adapters that + // recognize keys translate; unrecognized keys are passed through or + // dropped per the adapter's documented behavior. The merge into + // per-call `InferenceOptions.providerOptions` is shallow — a per- + // call providerOptions object wholesale replaces the source-bound + // one, it does not deep-merge per key. + "providerOptions?": "Record", +}); +export type InferenceSourceDefaults = typeof InferenceSourceDefaults.infer; + +/** + * A specific (provider, model) bundle the agent runtime can route to. + * Carries wire reachability, credentials, the model identity at the + * provider, and the model-bound default knobs. + * + * `id` is the catalog offering's primary key, set by the resolver from the + * matched offering. It is the routing key used by `AgentConfig.defaultSource` + * and `Agent.setSource`. + * + * Multi-model providers become multiple sources — `model` is part of the + * identity, not an optional override. + * + * `capabilities` is carried for the selection-policy layer (the model + * selector consumes it). The runtime ignores it; populating the field + * later is not a wire-format change. + * + * `quirks` is the opaque per-deployment bag of provider-specific adapter + * accommodations. The harness reads it once, at adapter instantiation, and + * passes it to `AdapterRegistry.resolve` as a sibling of the slim + * `LastCycleSource` — quirks are deliberately kept off `LastCycleSource`, + * which rides on every usage event. The field is present-and-populated or + * absent; it is never `null`. A source row with no quirks stores SQL `NULL`, + * and the catalog resolver translates that absence into an omitted key here, + * so downstream code sees `undefined`, never `null`. + * + * (INFERENCE.md § Providers) + */ +export const InferenceSource = type({ + id: "string", + provider: "string", + baseURL: "string", + // Reference into the run's credential-material cell. The provider's secret + // (formerly an inline `apiKey`) is resolved from that cell by `credentialId` + // at call time, so the source config carries no secret and the child never + // holds the key inline. The same cell backs tool credentials. + credentialId: "string", + model: "string", + "defaults?": InferenceSourceDefaults, + "capabilities?": "string[]", + "quirks?": "Record", +}); +export type InferenceSource = typeof InferenceSource.infer; + +/** + * Replace every field on `active` with the corresponding field from + * `next`, in place. Optional fields (`defaults`, `capabilities`, + * `quirks`) are `delete`d from `active` when absent on `next` so the + * swap is exact — no stale value from a previous rotation can survive. + * + * Used by both the agent's source registry and the harness's source + * hot-swap path to mutate the single shared `InferenceSource` object the + * reactor reads lazily at the start of each inference call. Putting the + * field list in one place means the next field added to + * `InferenceSource` only has to be remembered here. + */ +export function applyInferenceSourceFields( + active: InferenceSource, + next: InferenceSource, +): void { + active.id = next.id; + active.provider = next.provider; + active.baseURL = next.baseURL; + active.credentialId = next.credentialId; + active.model = next.model; + if (next.defaults !== undefined) { + active.defaults = next.defaults; + } else { + delete active.defaults; + } + if (next.capabilities !== undefined) { + active.capabilities = next.capabilities; + } else { + delete active.capabilities; + } + if (next.quirks !== undefined) { + active.quirks = next.quirks; + } else { + delete active.quirks; + } + + // Compile-time exhaustiveness check. `Required<>` forces optional + // keys to also be required in the guard — so a future optional field + // (e.g. `region?: string`) added to `InferenceSource` without being + // handled above is flagged by TypeScript, not silently dropped. + const _handled: { readonly [K in keyof Required]: true } = { + id: true, + provider: true, + baseURL: true, + credentialId: true, + model: true, + defaults: true, + capabilities: true, + quirks: true, + }; + void _handled; +} + +/** + * Outcome of a `RetryPolicy` consultation. Either abort the call + * (surface the most recent `inference.error` to the caller), or retry + * after `delayMs` milliseconds, measured against the harness Scheduler. + * + * (INFERENCE.md § Providers › Streaming Harness) + */ +export type RetryDecision = + | { kind: "abort" } + | { kind: "retry"; delayMs: number }; + +/** + * Context supplied to a `RetryPolicy` each time an attempt produces an + * `inference.error`. + * + * (INFERENCE.md § Providers › Streaming Harness) + */ +export type RetrySituation = { + /** The classified error the most recent attempt produced. */ + readonly error: InferenceError; + /** + * 1-indexed attempt counter. The first failure has `attempt: 1`; + * the second failure (after one retry) has `attempt: 2`; and so on. + */ + readonly attempt: number; + /** + * Milliseconds since the *first* attempt of this call started, + * measured via the harness `Scheduler.now()`. The default Scheduler + * uses `performance.now()` (sub-millisecond resolution), so the + * value may be fractional; virtual-clock test schedulers report + * integer virtual time. Both are valid; policies that compare + * against integer thresholds should `Math.floor` if they need that. + */ + readonly elapsedMs: number; +}; + +/** + * Per-call retry policy. The harness invokes the policy once per + * `inference.error` an attempt produces, in 1-indexed attempt order. + * Returning `{ kind: "abort" }` ends the call by surfacing the most + * recent error to the caller; returning `{ kind: "retry", delayMs }` + * causes the harness to discard the failed attempt's events, sleep + * `delayMs` milliseconds against the Scheduler, and re-issue the + * underlying HTTP request with the identical body. The policy may be + * async; the harness awaits the returned `Promise` if + * it is a thenable. + * + * (INFERENCE.md § Providers › Streaming Harness) + */ +export type RetryPolicy = ( + situation: RetrySituation, +) => RetryDecision | Promise; + +/** + * Options for a single inference call. Override the defaults from the agent + * configuration on a per-call basis. + * + * (INFERENCE.md § Providers › Streaming Harness) + */ +export type InferenceOptions = { + maxTokens?: number; + temperature?: number; + thinking?: { enabled: boolean; budgetTokens?: number }; + systemPrompt?: string; + tools?: ToolDefinition[]; + /** + * Modalities the caller wants the model to emit. Adapters translate + * to the provider-native shape (Gemini's + * `generationConfig.responseModalities` accepts `"TEXT"` / `"IMAGE"` + * uppercase; see `packages/inference-discovery-google-genai/sessions/ + * google-genai/gemini-2.5-flash-image/image-output/exchanges/0/request.json` + * for the captured shape). Providers that do not expose a modality + * switch ignore the + * field. When omitted the provider's default modalities apply. + */ + responseModalities?: ("text" | "image" | "audio")[]; + /** + * Structured-output constraint. Asks the model to produce text, free- + * form JSON, or JSON conforming to a specific schema. Adapters + * translate to the provider-native wire shape: + * + * - **OpenAI** (`response_format`): + * - `text` → `{ type: "text" }` + * - `json` → `{ type: "json_object" }` + * - `json-schema` → `{ type: "json_schema", json_schema: { name, schema, strict } }` + * When the model declines in strict mode, the wire emits + * `delta.refusal` chunks; the adapter surfaces them as + * `inference.refusal.delta` events and a final `RefusalBlock` in + * the assistant turn's `content[]`. + * - **Google GenAI** (`generationConfig`): + * - `text` → no constraint (default). + * - `json` → `{ responseMimeType: "application/json" }`. + * - `json-schema` → `{ responseMimeType: "application/json", responseSchema: }`. + * `name` and `strict` are OpenAI-specific and have no Gemini + * counterpart; adapters ignore them. Gemini enforces a subset of + * JSON Schema (no `oneOf`, limited `pattern`, no `$ref`, etc.) — + * the adapter forwards the schema verbatim and surfaces Gemini's + * HTTP error if the subset is violated. + * - **Anthropic**: no native structured-output API. + * - `text` is a no-op (the default). + * - `json` and `json-schema` throw at the adapter boundary; there + * is no shim that synthesizes a tool to extract structured + * output. + * + * When omitted the provider's default applies (typically free-form + * text). + */ + responseFormat?: + | { kind: "text" } + | { kind: "json" } + | { + kind: "json-schema"; + name: string; + schema: unknown; + strict?: boolean; + }; + /** + * A bag of provider-native knobs the adapter merges into the outbound + * request body. Primary home is `InferenceSourceDefaults.providerOptions` + * (model-bound); this field exists for per-call overrides through the + * standard merge precedence at the top of `runInference`. The merge is + * shallow: a per-call providerOptions object wholesale replaces the + * source-bound one, it does not deep-merge per key. + */ + providerOptions?: Record; + /** + * Per-call inactivity timeout in milliseconds. If the harness yields no + * event (other than `inference.start`) for this many ms, the underlying + * fetch is aborted and the call ends with `inference.error` of category + * `"timeout"`. Default 120_000 (2 min). Tune higher for reasoning models + * that exhibit long silent-thinking stretches between token bursts; tune + * lower to fail fast. `0` arms the timer to fire on the next tick (a + * "fail-fast even if the fetch is instant" mode useful in tests). + */ + inactivityTimeoutMs?: number; + /** + * Per-call total wall-clock cap in milliseconds. Starts at fetch. + * Default 600_000 (10 min). Backstop for streams that keep emitting + * forever without terminating. Same error category as `inactivityTimeoutMs`. + * `0` arms the timer to fire on the next tick. + */ + totalTimeoutMs?: number; + /** + * Per-call mechanical retry policy. Consulted once per attempt that + * ends in `inference.error`; see `RetryPolicy` for the contract. If + * omitted, a built-in default policy is applied. + */ + retryPolicy?: RetryPolicy; +}; + +// --------------------------------------------------------------------------- +// Context Store (INFERENCE.md § Context Management › Context Store, +// ARCHITECTURE.md § Change History) +// --------------------------------------------------------------------------- + +/** + * A named commit point in the context store. Corresponds to a git commit. + * + * (ARCHITECTURE.md § Change History › Named Checkpoints) + */ +export type ContextCommit = { + hash: string; + message: string; + timestamp: number; + parentHash?: string; +}; + +/** + * The state of an active connector thread. The connector is one durable + * thread per agent; participants accumulate as they speak. Persisted + * alongside the conversation context so the thread survives sidecar + * restarts. + * + * `replyTo` is the most recent speaker — the primary recipient (`to`) + * on the next outbound reply. `cc` is every other participant who has + * spoken on the thread, deduplicated, in arrival order — they ride as + * `cc` on the next outbound reply so everyone stays in the loop. + * `subject` is set when the thread starts and preserved for its life. + * + * Defined as an arktype so the wire layer (sidecar↔hub frames) and + * other parsing boundaries can validate snapshots without + * re-declaring the shape. + */ +export const ConnectorThreadState = type({ + threadRoot: "string", + lastMessageId: "string", + replyTo: "string", + cc: "string[]", + "subject?": "string", +}); +export type ConnectorThreadState = typeof ConnectorThreadState.infer; + +/** + * The context store interface. Implementations back the store with git + * (filesystem, in-memory, or virtual) depending on the execution environment. + * The reactor accepts any implementation that satisfies this interface. + * + * The store holds the turn history and reactor metadata. Forking creates + * a git branch. Compaction commits the compacted history. + * + * (INFERENCE.md § Context Management › Context Store) + */ +export interface ContextStore { + /** + * Load the current turn history and reactor metadata from the store. + * Called during reactor initialization. + */ + load(signal?: AbortSignal): Promise<{ + turns: ConversationTurn[]; + pendingOperations: PendingOperation[]; + tokenUsage: TokenUsage; + connectorState: ConnectorThreadState | null; + }>; + + /** + * Buffer connector thread state for the next commit. The harness calls + * this before each checkpoint so that connector state is persisted + * atomically with the conversation context. + */ + setConnectorState(state: ConnectorThreadState | null): void; + + /** + * Commit whatever currently lives in the working tree, using the supplied + * commit message. The reactor's per-cycle checkpoint routes through this + * overload after writing the per-cycle files via `writeTurns`, + * `writePrompt`, `writeResponse`, `writeManifest`, and any `writeBlob` + * calls produced by transforms. + */ + commit( + options: { message: string }, + signal?: AbortSignal, + ): Promise; + + /** + * Create a branch for a fork operation. The branch starts from the current + * HEAD commit. + */ + branch(name: string, signal?: AbortSignal): Promise; + + /** + * List recent commits. Used by the agent's history query tools. + */ + log(limit?: number, signal?: AbortSignal): Promise; + + /** + * Read the turn history at a specific commit hash. Used for history + * inspection and rollback. + */ + readAt(hash: string, signal?: AbortSignal): Promise; + + /** + * Write an opaque blob to the working tree under `tool-output/`. Used by + * `ToolResultTransform`s that spill oversized payloads out of the inline + * conversation. The file is staged at the next `commit({ message })`. + * + * `key` is sanitized for filesystem safety; callers should pass the tool + * call id. `contentType` selects a file extension when known. + */ + writeBlob( + key: string, + bytes: Uint8Array, + contentType?: string, + signal?: AbortSignal, + ): Promise; + + /** + * Read a blob previously written via `writeBlob`. Throws if no blob with + * that key exists. + */ + readBlob(key: string, signal?: AbortSignal): Promise; + + /** + * Overwrite `prompt.jsonl` with the materialized prompt for the current + * inference cycle. One `ConversationTurn` per line. Staged at the next + * `commit({ message })`. + */ + writePrompt(turns: ConversationTurn[], signal?: AbortSignal): Promise; + + /** + * Overwrite `response.jsonl` with the assistant turn returned for the + * current cycle. Single-line JSONL for consistency with the per-cycle file + * conventions. Staged at the next `commit({ message })`. + */ + writeResponse(turn: AssistantTurn, signal?: AbortSignal): Promise; + + /** + * Overwrite `manifest.jsonl` with the ordered transform records produced + * for the current cycle. One `TransformRecord` per line. Staged at the + * next `commit({ message })`. + */ + writeManifest( + records: TransformRecord[], + signal?: AbortSignal, + ): Promise; + + /** + * Overwrite `turns.jsonl` with the durable conversation history. One + * `ConversationTurn` per line. Staged at the next `commit({ message })`. + */ + writeTurns(turns: ConversationTurn[], signal?: AbortSignal): Promise; + + /** + * Overwrite `metadata.json` with non-turn-shaped reactor state needed for + * restart: pending async operations and cumulative token usage. The store + * combines this with the most recently buffered connector state (from + * `setConnectorState`) and writes the merged payload. Staged at the next + * `commit({ message })`. + */ + writeMetadata( + metadata: { + pendingOperations: PendingOperation[]; + tokenUsage: TokenUsage; + }, + signal?: AbortSignal, + ): Promise; + + /** + * Read manifest entries from the most recent `limit` commits that contain + * a `manifest.jsonl`. Newest commit first; records within a commit are + * returned in their natural in-file order (chronological per-cycle). + */ + readManifestHistory( + limit: number, + signal?: AbortSignal, + ): Promise; +} + +// --------------------------------------------------------------------------- +// Audit Store (INTR-4 § Audit Trail) +// --------------------------------------------------------------------------- + +/** + * Persistent store for tool invocation audit records. Separated from + * ContextStore so the audit capability is opt-in at the composition + * layer. The isogit implementation writes audit records as individual + * JSON files in the same git repo used for context storage. + */ +export interface AuditStore { + /** + * Persist a batch of audit records. Called at checkpoint boundaries + * with all records accumulated since the last checkpoint. + */ + commitAudit(records: AuditRecord[], signal?: AbortSignal): Promise; + + /** + * Load audit records for a session. Returns all records matching + * the given sessionId, ordered by seq. + */ + loadAudit(sessionId: string, signal?: AbortSignal): Promise; + + /** + * Persist a batch of error records. Called at checkpoint boundaries + * and shutdown with all error records accumulated since the last flush. + */ + commitErrors(records: ErrorRecord[], signal?: AbortSignal): Promise; +} + +// --------------------------------------------------------------------------- +// Agent / Harness Configuration (ARCHITECTURE.md § Agent Harness) +// --------------------------------------------------------------------------- + +/** + * Configured tool definition exposed to the model. The harness registers + * available tools; the reactor passes this list to the inference provider as + * part of each request. + * + * (ARCHITECTURE.md § Agent Harness › Tools) + */ +export const ToolDefinition = type({ + name: "string", + description: "string", + inputSchema: "Record", +}); +export type ToolDefinition = typeof ToolDefinition.infer; + +/** + * Agent harness configuration. Assembled from the agent definition package + * and capability grants during harness initialization. + * + * `principalId` is the agent's principal in the hub's authorization model. + * The sidecar needs it to reconstruct the in-memory grant store on restart + * (the store's `collectGrants` filters by principal). + * + * `grants` uses `WireGrantRule` because this type arrives over JSON where + * `GrantRule.expiresAt` is serialized as a string. The wire validator + * coerces strings back to Date instances. + * + * (ARCHITECTURE.md § Agent Harness) + */ +export const HarnessConfig = type({ + sessionId: "string", + agentId: "string", + tenantId: "string", + principalId: "string", + agentAddress: "string", + systemPrompt: "string", + tools: ToolDefinition.array(), + grants: WireGrantRule.array(), + sources: InferenceSource.array(), + defaultSource: "string", + "sessionChannelEnabled?": "boolean", +}); +export type HarnessConfig = typeof HarnessConfig.infer; diff --git a/vendor/intx-types/src/sessions.ts b/vendor/intx-types/src/sessions.ts new file mode 100644 index 0000000..1917f67 --- /dev/null +++ b/vendor/intx-types/src/sessions.ts @@ -0,0 +1,151 @@ +import { type } from "arktype"; + +export const CreateSession = type({ + agentId: "string", + "invokerCapabilities?": type({ + resource: "string", + action: "string", + "conditions?": "Record | null", + }).array(), +}); + +export const SessionResponse = type({ + id: "string", + tenantId: "string", + agentId: "string", + principalId: "string", + status: type("'idle' | 'ending' | 'ended'").describe( + "Persisted lifecycle state of the session: `idle` (open, awaiting work), `ending` (teardown in progress), or `ended` (closed).", + ), + createdAt: "string", + updatedAt: "string", + "lastActivityAt?": "string | null", +}); + +// Runtime operational status of an active session. The harness retries +// internally and does not surface retry state to the hub, so the retry +// variant is omitted until the event protocol supports it. +export const SessionStatus = type({ + status: type("'idle' | 'busy' | 'waiting_approval'").describe( + "Runtime operational state of an active session, distinct from its persisted lifecycle state: `idle` (ready), `busy` (processing a turn), or `waiting_approval` (blocked on an interactive approval before a tool call can proceed).", + ), +}); +export type SessionStatus = typeof SessionStatus.infer; + +// The schema validates structure only: a required mimeType, a required +// string `data` carrying base64-encoded bytes, an optional name, and no +// other keys. base64 validity, the MIME allowlist, and size limits are +// enforced at the route boundary so it can emit ordered, per-index +// structured errors (malformed_base64, disallowed_mime_type, oversize_*) +// that an all-or-nothing schema validator cannot produce. +export const SendMessage = type({ + content: "string", + "attachments?": type({ + mimeType: "string", + data: "string", + "name?": "string", + }) + .onUndeclaredKey("reject") + .array(), +}); + +export const MailResponse = type({ + id: "string", + sessionId: type("string").describe( + "Internal session channel identifier, not a user-facing session resource.", + ), + runId: "string | null", + direction: type("'inbound' | 'outbound'").describe( + "Whether the message was sent to the agent (`inbound`) or emitted by the agent (`outbound`).", + ), + status: type("'pending' | 'delivered'").describe( + "Delivery state of the mail: `pending` (accepted, not yet dispatched to the running agent) or `delivered`.", + ), + receivedAt: "string", + from: type({ + name: "string | null", + email: "string", + }).array(), + to: type({ + name: "string | null", + email: "string", + }).array(), + subject: "string | null", + sentAt: "string | null", + bodyValues: "Record", + textBody: type({ + partId: "string", + type: "string", + }).array(), + htmlBody: type({ + partId: "string", + type: "string", + }).array(), + attachments: type({ + blobId: "string", + name: "string | null", + type: "string", + size: "number", + }).array(), + headers: "Record", +}); +export type MailResponse = typeof MailResponse.infer; + +// Structured attachment-rejection errors returned by POST /:runId/mail. +// Each variant carries a machine-actionable `code` plus the fields a client +// needs to locate and explain the rejection, alongside a human-readable +// `message`. This is the wire contract for the route's attachment 400s; the +// route handler is the single producer. +export const AttachmentError = type({ + code: "'oversize_attachment'", + message: "string", + attachmentIndex: "number", + byteLength: "number", + limitBytes: "number", +}) + .or({ + code: "'disallowed_mime_type'", + message: "string", + attachmentIndex: "number", + mimeType: "string", + }) + .or({ + code: "'invalid_attachment_name'", + message: "string", + attachmentIndex: "number", + }) + .or({ + code: "'malformed_base64'", + message: "string", + attachmentIndex: "number", + }) + .or({ + code: "'oversize_total'", + message: "string", + totalBytes: "number", + limitBytes: "number", + }); +export type AttachmentError = typeof AttachmentError.infer; + +export const AttachmentErrorResponse = type({ error: AttachmentError }); +export type AttachmentErrorResponse = typeof AttachmentErrorResponse.infer; + +export const InferenceTurnResponse = type({ + id: "string", + sessionId: type("string").describe( + "Internal session channel identifier, not a user-facing session resource.", + ), + runId: "string", + model: "string", + status: "'running' | 'completed' | 'failed'", + startedAt: "string", + endedAt: "string | null", + parts: type({ + id: "string", + type: "'text' | 'reasoning' | 'tool' | 'file' | 'error' | 'step-start' | 'step-finish' | 'snapshot' | 'patch'", + "content?": "string | null", + "metadata?": "Record | null", + ordinal: "number", + }).array(), +}); +export type InferenceTurnResponse = typeof InferenceTurnResponse.infer; diff --git a/vendor/intx-types/src/sidecar-allocation.ts b/vendor/intx-types/src/sidecar-allocation.ts new file mode 100644 index 0000000..ccbf59c --- /dev/null +++ b/vendor/intx-types/src/sidecar-allocation.ts @@ -0,0 +1,32 @@ +export const sidecarAllocationStatuses = [ + "pending", + "provisioning", + "allocated", + "replacing", + "releasing", + "released", + "failed", +] as const; + +export type SidecarAllocationStatus = + (typeof sidecarAllocationStatuses)[number]; + +export function isSidecarAllocationDispatchable( + status: SidecarAllocationStatus, +): boolean { + switch (status) { + case "pending": + case "provisioning": + case "allocated": + case "replacing": + return true; + case "releasing": + case "released": + case "failed": + return false; + default: { + const exhaustive: never = status; + return exhaustive; + } + } +} diff --git a/vendor/intx-types/src/sidecar-capabilities.ts b/vendor/intx-types/src/sidecar-capabilities.ts new file mode 100644 index 0000000..d39196d --- /dev/null +++ b/vendor/intx-types/src/sidecar-capabilities.ts @@ -0,0 +1,61 @@ +import { type } from "arktype"; + +export type ParsedSidecarCapabilitySelector = { + readonly kind: "exact" | "prefix"; + readonly segments: readonly string[]; +}; + +export function parseSidecarCapabilitySelector( + value: string, +): ParsedSidecarCapabilitySelector | null { + if (value.length === 0) return null; + if (value === "*") return { kind: "prefix", segments: [] }; + if (!value.includes("*")) { + const segments = value.split(":"); + return segments.some((segment) => segment.length === 0) + ? null + : { kind: "exact", segments }; + } + + if (!value.endsWith(":*") || value.indexOf("*") !== value.length - 1) { + return null; + } + const segments = value.slice(0, -2).split(":"); + if (segments.some((segment) => segment.length === 0)) return null; + return { + kind: "prefix", + segments, + }; +} + +export const SidecarCapabilitySelector = type("string > 0").narrow( + (value, ctx) => + parseSidecarCapabilitySelector(value) !== null || + ctx.mustBe( + "an exact capability, a trailing namespace selector such as runtime:*, or *", + ), +); +export type SidecarCapabilitySelector = typeof SidecarCapabilitySelector.infer; + +export const SidecarCapabilityRule = type({ + capability: SidecarCapabilitySelector, + effect: "'require' | 'block'", +}); +export type SidecarCapabilityRule = typeof SidecarCapabilityRule.infer; + +export const SidecarCapabilityDeclaration = type({ + capability: SidecarCapabilitySelector, + state: "'available' | 'blocked'", +}); +export type SidecarCapabilityDeclaration = + typeof SidecarCapabilityDeclaration.infer; + +export const SidecarCapabilityPolicy = type({ + "capabilities?": SidecarCapabilityRule.array(), +}).onUndeclaredKey("reject"); +export type SidecarCapabilityPolicy = typeof SidecarCapabilityPolicy.infer; + +export type TenantSidecarCapabilityPolicy = { + readonly tenantId: string; + readonly rules: readonly SidecarCapabilityRule[]; +}; diff --git a/vendor/intx-types/src/sidecar-oauth-login.test.ts b/vendor/intx-types/src/sidecar-oauth-login.test.ts new file mode 100644 index 0000000..1027363 --- /dev/null +++ b/vendor/intx-types/src/sidecar-oauth-login.test.ts @@ -0,0 +1,116 @@ +// CL-7508 local delta: parse/reject tests for the oauth.login.start / +// oauth.login.result frame pair the sidecar ws channel threads (see the +// `vendor/intx/types` ledger row). +import { describe, expect, test } from "bun:test"; +import { type } from "arktype"; +import { + HubFrame, + OAuthLoginCancelFrame, + OAuthLoginResultFrame, + OAuthLoginStartFrame, + SidecarFrame, +} from "./sidecar"; + +describe("oauth.login.start frame", () => { + test("parses a well-formed hub request", () => { + const frame = { + type: "oauth.login.start", + requestId: "req_1", + connectorId: "codex", + }; + expect(OAuthLoginStartFrame(frame)).not.toBeInstanceOf(type.errors); + expect(HubFrame(frame)).not.toBeInstanceOf(type.errors); + }); + + test("accepts every loopback connector id and nothing else", () => { + expect( + OAuthLoginStartFrame({ + type: "oauth.login.start", + requestId: "r", + connectorId: "xai-oauth", + }), + ).not.toBeInstanceOf(type.errors); + expect( + OAuthLoginStartFrame({ + type: "oauth.login.start", + requestId: "r", + connectorId: "github", + }), + ).toBeInstanceOf(type.errors); + }); + + test("rejects a missing requestId", () => { + expect( + OAuthLoginStartFrame({ type: "oauth.login.start", connectorId: "codex" }), + ).toBeInstanceOf(type.errors); + }); + + test("parses the cancel frame the hub sends on timeout", () => { + const frame = { type: "oauth.login.cancel", requestId: "req_1" }; + expect(OAuthLoginCancelFrame(frame)).not.toBeInstanceOf(type.errors); + expect(HubFrame(frame)).not.toBeInstanceOf(type.errors); + expect( + OAuthLoginCancelFrame({ type: "oauth.login.cancel" }), + ).toBeInstanceOf(type.errors); + }); +}); + +describe("oauth.login.result frame", () => { + test("parses the started arm with its authorize URL", () => { + const frame = { + type: "oauth.login.result", + requestId: "req_1", + outcome: { status: "started", authorizeUrl: "https://auth.example/authorize?x=1" }, + }; + expect(OAuthLoginResultFrame(frame)).not.toBeInstanceOf(type.errors); + expect(SidecarFrame(frame)).not.toBeInstanceOf(type.errors); + }); + + test("parses the completed arm with tokens", () => { + const frame = { + type: "oauth.login.result", + requestId: "req_1", + outcome: { + status: "completed", + tokens: { + access: "at", + refresh: "rt", + expiresAt: 123, + idToken: "idt", + accountId: "acc", + }, + }, + }; + expect(OAuthLoginResultFrame(frame)).not.toBeInstanceOf(type.errors); + }); + + test("parses the error arm", () => { + expect( + OAuthLoginResultFrame({ + type: "oauth.login.result", + requestId: "req_1", + outcome: { status: "error", message: "port in use" }, + }), + ).not.toBeInstanceOf(type.errors); + }); + + test("rejects a completed arm without tokens", () => { + expect( + OAuthLoginResultFrame({ + type: "oauth.login.result", + requestId: "req_1", + outcome: { status: "completed" }, + }), + ).toBeInstanceOf(type.errors); + }); + + test("rejects an unknown outcome status", () => { + expect( + OAuthLoginResultFrame({ + type: "oauth.login.result", + requestId: "req_1", + outcome: { status: "pending" }, + }), + ).toBeInstanceOf(type.errors); + }); +}); diff --git a/vendor/intx-types/src/sidecar.ts b/vendor/intx-types/src/sidecar.ts new file mode 100644 index 0000000..d961a5d --- /dev/null +++ b/vendor/intx-types/src/sidecar.ts @@ -0,0 +1,1056 @@ +// Websocket wire protocol for hub↔sidecar communication. +// +// One websocket connection per sidecar↔hub pair. All traffic is multiplexed +// as JSON frames with a `type` discriminator. The sidecar initiates the +// connection; the hub is the server. +// +// Mail bytes are base64-encoded in JSON frames. Binary frames would be more +// efficient but JSON is simpler to debug and inspect. + +import { type } from "arktype"; +import { GrantWalkSnapshot } from "./grant-snapshot"; +import { WireGrantRule } from "./grant-wire"; +import { + BoundedApprovalSnapshot, + ConnectorThreadState, + HarnessConfig, + InferenceEvent, + InferenceSource, +} from "./runtime"; +import { SignalKind } from "./signals"; +import { ToolPackageManifest } from "./tool-packages"; +import { WorkflowDefinitionSource } from "./workflow-sources"; + +// --------------------------------------------------------------------------- +// Sidecar → Hub +// --------------------------------------------------------------------------- + +/** + * Sent on first connect when the sidecar has no existing agents in its data + * directory. Identifies the sidecar and declares it ready to receive + * agent.deploy frames. + */ +export const RegisterFrame = type({ + type: "'register'", + sidecarId: "string", + token: "string", + agentAddresses: "string[]", +}); +export type RegisterFrame = typeof RegisterFrame.infer; + +/** + * Sent on connect after a provisioned sidecar restores its deployment. + * The bearer token binds the connection to one allocation generation, so the + * Hub accepts only that allocation's workflow address. + */ +export const ReconnectFrame = type({ + type: "'reconnect'", + sidecarId: "string", + token: "string", + agentAddresses: "string[]", +}); +export type ReconnectFrame = typeof ReconnectFrame.infer; + +/** + * Acknowledges a successful agent deployment. Includes the agent's Ed25519 + * public key (hex-encoded) for published identity and content provenance. + * Reconnect authority comes from the allocation credential. + */ +export const AgentDeployAckFrame = type({ + type: "'agent.deploy.ack'", + agentAddress: "string", + publicKey: "string", +}); +export type AgentDeployAckFrame = typeof AgentDeployAckFrame.infer; + +/** + * Reports a failed agent deployment. + */ +export const AgentErrorFrame = type({ + type: "'agent.error'", + agentAddress: "string", + error: "string", +}); +export type AgentErrorFrame = typeof AgentErrorFrame.infer; + +/** + * A message from a local agent. When `delivered` is absent or false the hub + * should route the message to its recipients. When `delivered` is true the + * message was already delivered locally and is forwarded for audit/projection + * only — the hub must not re-route it. + * + * Structured metadata (senderAddress, messageId, to, cc) is available for + * audit and projection purposes without parsing the raw MIME bytes. + */ +export const MailOutboundFrame = type({ + type: "'mail.outbound'", + rawMessage: "string", + recipients: "string[]", + senderAddress: "string", + "sessionId?": "string", + "messageId?": "string", + "to?": "string[]", + "cc?": "string[]", + "delivered?": "boolean", +}); +export type MailOutboundFrame = typeof MailOutboundFrame.infer; + +/** + * An InferenceEvent from the reactor, forwarded for UI consumption. Tagged + * with the run address so the hub can route to the correct UI client. + */ +export const AgentEventFrame = type({ + type: "'agent.event'", + agentAddress: "string", + sessionId: "string", + event: InferenceEvent, + "childRunId?": "string", +}); +export type AgentEventFrame = typeof AgentEventFrame.infer; + +/** + * Notifies the hub that the agent's connector-thread state has changed. + * The sidecar emits this when the harness's connector router commits a + * start/continue decision, when an outbound reply advances the + * lastMessageId, and when load-time restore brings persisted state into + * memory. The hub uses the cached state to set threading headers on + * user-originated mail so the harness routes it as `continue` rather + * than `passthrough`. + * + * `connectorState` is `null` when no active thread exists. + */ +export const ConnectorStateChangedFrame = type({ + type: "'connector.state.changed'", + agentAddress: "string", + connectorState: ConnectorThreadState.or("null"), +}); +export type ConnectorStateChangedFrame = + typeof ConnectorStateChangedFrame.infer; + +/** + * Keepalive ping sent by the sidecar. The hub responds with a pong frame. + * If the hub stops receiving pings, it considers the sidecar dead. + */ +export const PingFrame = type({ type: "'ping'" }); +export type PingFrame = typeof PingFrame.infer; + +/** + * Acknowledges a request from the hub (sources.update). + */ +export const SessionAckFrame = type({ + type: "'session.ack'", + requestId: "string", +}); +export type SessionAckFrame = typeof SessionAckFrame.infer; + +/** + * Reports an error processing a hub request. + */ +export const SessionErrorFrame = type({ + type: "'session.error'", + requestId: "string", + error: "string", +}); +export type SessionErrorFrame = typeof SessionErrorFrame.infer; + +/** + * Acknowledges that an agent has been fully undeployed: the deployment's + * workflow child stopped, state pushed (best-effort), and directory deleted. + */ +export const AgentUndeployAckFrame = type({ + type: "'agent.undeploy.ack'", + agentAddress: "string", + statePushed: "boolean", +}); +export type AgentUndeployAckFrame = typeof AgentUndeployAckFrame.infer; + +/** + * Registers a control-signal correlation as a workflow agent step suspends. + * The fields on this frame all converge at the sidecar's suspend emit point; + * the hub uses them to co-write the `signal_correlation` routing row and the + * `approval` row in one transaction, so the eventual resolver can route a + * delivered decision back to the parked run and flip its approval. + * + * `signalName` is deliberately NOT on the wire: it is a pure function of + * `correlationId` (`signalName(correlationId)` in `./signals`), so the hub + * computes it rather than trusting a value the sidecar could disagree on. + * `anchorRunId` is the anchor run the parked run belongs to; `agentAddress` + * is the anchor run's routable address the hub resolves tenancy from. + */ +export const SignalCorrelationRegisterFrame = type({ + type: "'signal.correlation.register'", + correlationId: "string", + runId: "string", + anchorRunId: "string", + agentAddress: "string", + kind: SignalKind, + // Approver-facing snapshot of the suspended tool call, size-capped at this + // trust boundary. Required: the ask rail is the only producer of this frame + // and always carries a snapshot, so a snapshot-absent frame fails this parse + // at the receiver (logged and dropped, never co-written as a null row). + snapshot: BoundedApprovalSnapshot, +}); +export type SignalCorrelationRegisterFrame = + typeof SignalCorrelationRegisterFrame.infer; + +// --------------------------------------------------------------------------- +// Hub → Sidecar +// --------------------------------------------------------------------------- + +/** + * Hub acknowledges a `signal.correlation.register`: the routing + approval + * co-write for this correlationId is durable (whether this frame inserted the + * rows or found them already present). It lets the sidecar's link stop + * retrying a register whose frame may have been lost on an open socket or + * evicted from the bounded send queue. Keyed on correlationId alone -- every + * producer of the register (the initial park, the respawn/reconnect re-emit, a + * link retry) carries the same correlationId and drives the same idempotent + * co-write, so the ack asserts the one fact that matters: a row exists for this + * correlation. + */ +export const SignalCorrelationRegisterAckFrame = type({ + type: "'signal.correlation.register.ack'", + agentAddress: "string", + correlationId: "string", +}); +export type SignalCorrelationRegisterAckFrame = + typeof SignalCorrelationRegisterAckFrame.infer; + +/** + * A message to deliver to a local agent's INBOX. The hub routes inbound + * mail (from UI users, from agents on other sidecars) to the correct + * sidecar connection. + * + * `messageId` is the hub-minted id of this delivery, carried so the sidecar + * can acknowledge durable receipt (`mail.inbound.ack`) keyed on the SAME id + * the hub tracks -- no per-side re-derivation. It is the id the hub minted at + * ingress (also the message's `Message-ID` header), so a redelivery replays + * identical bytes and the downstream `RunStarted` dedup (consumedMessageIds) + * makes at-least-once effectively-once. Present only on hub-originated mail + * that participates in the ack/retry handshake (workflow trigger mail, session + * conversation mail); agent-to-agent relayed mail omits it. + */ +export const MailInboundFrame = type({ + type: "'mail.inbound'", + agentAddress: "string", + rawMessage: "string", + "messageId?": "string", +}); +export type MailInboundFrame = typeof MailInboundFrame.infer; + +/** + * Sidecar acknowledges durable receipt of a `mail.inbound`: the message is in + * the agent's on-disk inbox. The hub holds each delivered mail in a pending + * map and retries until this ack lands (or reconnect-redelivers it), so a + * message dropped in the connected/reconnecting window is not silently lost. + * Keyed on the hub-minted `messageId` the `mail.inbound` carried, so the ack + * clears exactly the pending entry it resolves; the ack is only sent AFTER the + * durable inbox write resolves (a non-ack IS the retry signal). At-least-once + * delivery is made effectively-once by the `RunStarted`/signal dedup guards. + */ +export const MailInboundAckFrame = type({ + type: "'mail.inbound.ack'", + agentAddress: "string", + messageId: "string", +}); +export type MailInboundAckFrame = typeof MailInboundAckFrame.infer; + +/** + * Deliver a workflow-run signal to a multi-step deployment's + * supervisor. The hub forwards the frame to the sidecar that hosts the + * deployment named by `agentAddress` (the deployment-level mail + * address). The sidecar's hub-link routes the frame into the matching + * supervisor's `deliverSignal`, which sends a `signal.deliver` control + * IPC frame to the workflow-process child. The child commits the + * `SignalReceived` event through its own substrate -- the single + * writer of the workflow-run repo on the sidecar side -- so the + * pack-push pipeline that propagates the commit to the hub never sees + * a concurrent writer at the same ref. + * + * `signalId` is supplied by the producer so the workflow-run state + * machine's dedup index (`observedSignalIds`) rejects a duplicate + * delivery cleanly; a fresh value per call is the producer's + * responsibility. + */ +export const SignalDeliverFrame = type({ + type: "'signal.deliver'", + agentAddress: "string", + runId: "string", + signalName: "string", + signalId: "string", + payload: "unknown", +}); +export type SignalDeliverFrame = typeof SignalDeliverFrame.infer; + +/** + * Deliver a run's authorization grants to a multi-step deployment's + * supervisor. The hub forwards the frame to the sidecar that hosts the + * deployment named by `agentAddress` (the deployment-level mail + * address). The sidecar's hub-link routes the frame into the matching + * deployment's wiring, which writes the grants to `runs//grants.json` + * inside the deployment's `workflow-run` repo -- sibling to the run's + * `runs//events/` subtree. + * + * `stepGrants` carries the same `WireGrantRule` shape the `agent.deploy` + * frame's `config.grants` ships, so the run's grants ride the same + * validated grant encoding as the deploy-time step grants rather than a + * new one. + */ +export const RunGrantsFrame = type({ + type: "'run.grants'", + agentAddress: "string", + runId: "string", + stepGrants: WireGrantRule.array(), +}); +export type RunGrantsFrame = typeof RunGrantsFrame.infer; + +/** + * Deliver a workflow-host drain control payload to a multi-step + * deployment's supervisor. The hub forwards the frame to the sidecar + * that hosts the deployment named by `agentAddress` (the + * deployment-level mail address). The sidecar's hub-link routes the + * frame into the matching supervisor's `drain`, which sends a `drain` + * control IPC frame to the workflow-process child and arms one + * `drainTimeout` accumulator per in-flight run. Cancel-mode in-flight + * steps abort on the child side as the controller's signal flips; + * wait-mode steps continue. Each accumulator commits a signed + * `CancelRequested{origin: "supervisor-drain"}` against the + * workflow-run repo through the supervisor's substrate when the + * deadline expires. + * + * `deadlineMs` is the wire-level policy hint the child echoes in its + * logs. The supervisor's accumulator is driven by its own bindings' + * `drainTimeoutMs` -- a per-deployment operator setting -- not by this + * value; the wire field exists so the child's log reflects the + * caller's intent. + */ +export const DrainDeliverFrame = type({ + type: "'drain.deliver'", + agentAddress: "string", + deadlineMs: "number", +}); +export type DrainDeliverFrame = typeof DrainDeliverFrame.infer; + +import { + WorkflowProjectionDefinition, + WorkflowProjectionWithSources, +} from "./wire-workflow"; +// Re-export the wire-step/projection contracts that moved to `./wire-workflow` +// so existing `@intx/types/sidecar` consumers keep resolving them here. Each +// name is an arktype schema, so the single re-export carries both its value and +// its inferred type. +export { WorkflowStep } from "./wire-workflow"; +export { WorkflowProjectionDefinition, WorkflowProjectionWithSources }; + +/** + * The decrypted credential material and per-handle binding descriptors + * delivered to a running agent so its tools can use provider-backed + * credentials. Secrets are decrypted hub-side and ride this payload on the + * live channel ONLY -- the deploy frame at launch, a `credentials.update` + * frame on rotation, and the child's in-memory cell. They are NEVER written to + * disk (they do not ride the git-committed grants file) and NEVER copied into + * any snapshot, event, or state -- redaction is by construction, mirroring how + * an `InferenceSource`'s `apiKey` stays off every egress type. + * + * `materials` is keyed by `credentialId` (a credential can back several handles, + * so its secret is stored once); `bindings` maps each declared tool handle to + * the credential that backs it and the consumer identity allowed to use it. + */ +export const CredentialMaterialEntry = type({ + credentialId: "string", + providerKey: "string", + origin: "string", + secret: "string", +}); +export type CredentialMaterialEntry = typeof CredentialMaterialEntry.infer; + +export const CredentialBindingDescriptor = type({ + handle: "string", + credentialId: "string", + consumer: "string", +}); +export type CredentialBindingDescriptor = + typeof CredentialBindingDescriptor.infer; + +export const CredentialDelivery = type({ + bindings: CredentialBindingDescriptor.array(), + materials: CredentialMaterialEntry.array(), +}); +export type CredentialDelivery = typeof CredentialDelivery.infer; + +/** + * The source-ref pin: where a code-sourced (npm) workflow definition's bytes + * come from (`source`) plus the frozen dependency closure the hub resolved for + * that pin (`closure`, concrete versions + integrity SRIs). The two ALWAYS + * travel together -- the sidecar re-materializes the exact `closure` from + * `source` and re-evaluates the pinned code -- so they are one co-required + * object rather than two independently-optional fields (a "source without + * closure" state could not be re-materialized and re-evaluated, and evaluating + * the pinned code from the closure is the only channel the sidecar has to the + * runnable definition). This is the same shape `WorkflowProbeRequestFrame` + * co-requires. + */ +export const SourceRefPin = type({ + source: WorkflowDefinitionSource, + closure: ToolPackageManifest, +}); +export type SourceRefPin = typeof SourceRefPin.infer; + +/** + * The frozen, fully-serializable record of a code-sourced workflow approval, + * persisted at prepare time and rehydrated to deploy the exact same definition + * later. It is the recovery input for a provisioned workflow: the probe runs + * once on probe-scoped capacity, its result is frozen here, and a ready + * allocation deploys THIS bundle verbatim with no re-probe. + * + * Every field is inert, secret-free data. `source`/`entry` name where the + * definition's bytes come from and the entry module the probe evaluated; + * `projection` is the inert wire projection the freeze hashed; `closure` is the + * frozen dependency closure the pin resolved to; `approvedWireHash` is the freeze + * anchor; `approvedGrants` is the approved grant set (rehydrated to a `Set` on + * the deploy hand-off). Per-step inference sources are deliberately NOT frozen + * here -- they carry credential secrets and are re-resolved from the launch + * spec's offering ids at deploy time. + */ +export const FrozenApprovalBundle = type({ + source: WorkflowDefinitionSource, + entry: "string > 0", + projection: WorkflowProjectionDefinition, + closure: ToolPackageManifest, + approvedWireHash: "string > 0", + approvedGrants: "string[]", +}); +export type FrozenApprovalBundle = typeof FrozenApprovalBundle.infer; + +/** + * A hub asset delivered inline in a source-ref frame so the sidecar can + * materialize a closure entry whose bytes live in that asset. `pack` is the + * base64-encoded git packfile the hub produced for the asset (`createPack` + * output); the sidecar checks out `commitSha` from it as plain files under + * `mountPath`, then the loader resolves each `kind:"asset"` closure entry + * against that mount. `assetId` matches the `source.assetId` the closure + * entries name. + */ +export const WorkflowSourceAssetMount = type({ + assetId: "string", + mountPath: "string", + pack: "string", + ref: "string", + commitSha: "string", +}); +export type WorkflowSourceAssetMount = typeof WorkflowSourceAssetMount.infer; + +/** + * A full workflow deploy frame. The deploy lineage is source-ref only: the + * runnable definition is the pinned code closure the sidecar re-materializes and + * evaluates from `sourceRef`, so the frame carries NO inline `definition`. It + * pins each step's inference sources and the hub-approved wire hash the child + * re-verifies its closure evaluation against, plus the source-ref-specific + * extras. The sources-cover-stepOrder coverage narrow that a projection carries + * runs on the sidecar against the closure-derived definition + * (`validateWorkflowProjection`), since the frame holds no definition to cover. + * + * This is deliberately NOT built on `WorkflowProjectionWithSources`: that shape + * (definition + sources + approved hash) is the approval/probe projection and + * stays intact for the probe surface and for each `referencedDefinitions` body, + * which still carry their own inert definition. + */ +export const AgentDeployWorkflow = type({ + // Per-step inference-source failover chains, one per step in the closure's + // `stepOrder`. Threaded to the workflow-process child so it resolves inference + // at step invocation without a hub round-trip. + sources: { "[string]": InferenceSource.array().atLeastLength(1) }, + // The hub-approved wire hash of the frozen projection -- the freeze anchor the + // hub gate wrote. The sidecar feeds it to the child as `DEFINITION_HASH`, which + // the child re-verifies its closure evaluation against. Optional on the wire + // because the frame schema does not force it; enforcement lives at runtime + // instead -- the production hub builder always stamps it and the sidecar fails + // closed if it is absent. + "approvedWireHash?": "string > 0", + // Extracted trigger bodies -- onTrigger sections and childWorkflow children, + // lifted transitively. Each entry carries the body's inert definition, its own + // per-step inference-source pins, and its approved wire hash. The sidecar seals + // each body's sources into the per-run record and delivers the plaintext to the + // run child through the spawn env, so a body child -- in-process, its env lost + // across a restart -- resolves inference durably without holding the cipher + // key; the body definition itself is resolved in-memory from the parent's + // re-verified closure. Optional: only a deploy that carries an inline onTrigger + // section or childWorkflow child populates it. + "referencedDefinitions?": WorkflowProjectionWithSources.array(), + // Initial credential material for the deployment's tools, decrypted hub-side + // and delivered on the deploy frame so it is resident before any step runs + // (closing the race where a tool resolves a credential before a push lands). + // Run-global: a credential's secret is stored once, keyed by credentialId. + // Optional -- a deploy whose definition binds no credentials omits it. + "credentials?": CredentialDelivery, + // The source-ref pin (`source` + frozen `closure`) the sidecar re-materializes + // and evaluates the pinned code from. Required: source-ref is the only deploy + // lineage, and without the pin the sidecar has no definition to run. + sourceRef: SourceRefPin, + // Source assets a `kind:"asset"` closure entry reads from, delivered inline + // (as on the probe) so the sidecar checks them out into its durable + // per-deployment source store before materializing the pin. Optional: only + // an asset-sourced deploy carries it; a registry-sourced pin fetches its + // tarballs over HTTP and delivers none. + "assets?": WorkflowSourceAssetMount.array(), +}); +export type AgentDeployWorkflow = typeof AgentDeployWorkflow.infer; + +/** + * Deploy an agent to this sidecar. The sidecar spawns a supervised + * workflow-process child to host the deployment. + * + * The deploy router discriminates two shapes by field presence without + * consulting `config`: + * - `workflow` set: a workflow deployment (single-step head or multi-step) + * that spawns the supervised workflow-process child. + * - `provisionStep` true: a no-spawn per-step provision of a multi-step + * deploy -- the sidecar initializes the step's agent-state repo and + * records the hub key so the follow-up deploy pack applies and verifies, + * but spawns nothing. The deployment-level `workflow` frame (sent once + * after every step is provisioned) spawns the child. + * A frame carrying neither is rejected -- there is no in-process + * fall-through. `workflow` and `provisionStep` are mutually exclusive. + */ +export const AgentDeployFrame = type({ + type: "'agent.deploy'", + agentAddress: "string", + agentId: "string", + config: HarnessConfig, + hubPublicKey: "string", + "workflow?": AgentDeployWorkflow, + "provisionStep?": "boolean", +}); +export type AgentDeployFrame = typeof AgentDeployFrame.infer; + +/** + * Remove an agent from this sidecar. The sidecar shuts the deployment's + * supervisor down, pushes state to the hub (best-effort), deletes the agent + * directory, and responds with agent.undeploy.ack. + */ +export const AgentUndeployFrame = type({ + type: "'agent.undeploy'", + agentAddress: "string", + reason: "string", +}); +export type AgentUndeployFrame = typeof AgentUndeployFrame.infer; + +/** + * Keepalive pong sent by the hub in response to a ping frame. + * If the sidecar stops receiving pongs, it considers the hub dead. + */ +export const PongFrame = type({ type: "'pong'" }); +export type PongFrame = typeof PongFrame.infer; + +/** + * Push an updated inference-source list to a running single-step + * deployment. The sidecar routes it to the deployment's supervisor, which + * delivers it to the warm agent and swaps its sources in place. `sources` + * is non-empty (validated at this boundary, mirroring the deploy frame's + * per-step source arrays). Element 0 is the active source; the producer + * sets `defaultSource` to its id -- that equality is producer-enforced, + * not checked here. Responds with session.ack or session.error. + */ +export const SourcesUpdateFrame = type({ + type: "'sources.update'", + requestId: "string", + agentAddress: "string", + sources: InferenceSource.array().atLeastLength(1), + defaultSource: "string", +}); +export type SourcesUpdateFrame = typeof SourcesUpdateFrame.infer; + +/** + * Push refreshed credential material to a running deployment. Mirrors + * `SourcesUpdateFrame`: the sidecar routes it to the deployment's supervisor, + * which forwards it to the child's in-memory cell. The child MERGES `delivery` + * (materials upsert by credentialId, bindings by consumer-and-handle) and drops + * each credentialId in `revoke` plus any binding referencing it. Removal is + * explicit through `revoke` -- omitting a material does not evict it, because + * the cell has several independently-scoped producers and a wholesale swap + * would let one evict another's credentials. A pure revocation carries an empty + * `delivery` and the revoked ids in `revoke`. + */ +export const CredentialsUpdateFrame = type({ + type: "'credentials.update'", + requestId: "string", + agentAddress: "string", + delivery: CredentialDelivery, + "revoke?": "string[]", +}); +export type CredentialsUpdateFrame = typeof CredentialsUpdateFrame.infer; + +// --------------------------------------------------------------------------- +// Pack transport (bidirectional) +// --------------------------------------------------------------------------- +// +// Git pack data is streamed between hub and sidecar over the existing JSON +// WebSocket. Chunks are base64-encoded (matching the mail convention above). +// A transfer is a sequence of repo.pack.push frames followed by a +// repo.pack.done, correlated by transferId. The receiver responds with +// repo.pack.ack or repo.pack.reject. +// +// Each pack frame carries two complementary addressing fields: +// +// - `agentAddress` identifies the destination agent on the receiving +// sidecar. The sidecar manages per-agent state and uses this field to +// route the pack to the correct workspace. For agent-state packs the +// sidecar applies the pack onto the agent's deploy/state tree. +// +// - `repoId` identifies the source repo at the hub. The hub maps `repoId` +// to the originating entry in its kind-keyed RepoStore. For +// `repoId.kind === "agent-state"`, `repoId.id` is the run address +// (the deploy/state repo and the destination agent are the same), so +// the two fields carry the same value. Future kinds (e.g. assets) use +// `repoId` to name a non-agent source while `agentAddress` continues +// to address the destination agent. +// +// Flow control: deferred. Agent deploy trees are small enough that the sender +// can push all chunks without windowing. If this becomes a problem, a credit- +// based mechanism can be added later. + +/** + * Tag identifying a kind of repository in the hub's kind-keyed RepoStore. + * Lives in `@intx/types` because the wire-level pack frames reference it; + * the substrate package re-exports it for handler authors. + */ +export const RepoKind = type.enumerated( + "agent-state", + "skill", + "package-registry", + "workflow", + "workflow-run", +); +export type RepoKind = typeof RepoKind.infer; + +/** + * Operations a principal may invoke against a repo in the RepoStore. + * Lives in `@intx/types` so storage layers (e.g. `@intx/db`) can validate + * persisted action vocabularies without depending on the substrate + * package. The substrate re-exports it for handler authors. + */ +export const RepoAction = type.enumerated( + "init", + "writeTree", + "receivePack", + "createPack", + "resolveRef", +); +export type RepoAction = typeof RepoAction.infer; + +/** + * Hub-side identity of a repository in the RepoStore. Pack frames carry + * this alongside `agentAddress` so the hub can map a pack back to the + * originating repo independently of which sidecar/agent it is destined for. + */ +export const RepoId = type({ + kind: RepoKind, + id: "string", +}); +export type RepoId = typeof RepoId.infer; + +/** + * A chunk of git pack data. The sender splits the packfile into chunks of at + * most 64 KiB (before base64 encoding) and sends them in order. + * + * `seq` is monotonically increasing per transferId, starting at 0. The + * receiver must reject the transfer if a gap is detected. + */ +export const PackPushFrame = type({ + type: "'repo.pack.push'", + agentAddress: "string", + repoId: RepoId, + transferId: "string", + seq: "number", + data: "string", +}); +export type PackPushFrame = typeof PackPushFrame.infer; + +/** + * Signals the end of a pack transfer. The receiver applies the pack and + * updates `ref` to point at `commitSha`. If the post-apply HEAD does not + * match `commitSha`, the receiver must reject with reason "sha_mismatch". + * + * When `mountPath` is set, the receiver materializes the pack at + * `workspace//` instead of the hardcoded agent deploy tree. + * Absent for agent-state deploy/state flows and workflow-run restoration. + * The receiver distinguishes those paths by `repoId.kind`. + */ +export const PackDoneFrame = type({ + type: "'repo.pack.done'", + agentAddress: "string", + repoId: RepoId, + transferId: "string", + ref: "string", + commitSha: "string", + "mountPath?": "string", +}); +export type PackDoneFrame = typeof PackDoneFrame.infer; + +/** + * Receiver acknowledges successful application of a pack transfer. + */ +export const PackAckFrame = type({ + type: "'repo.pack.ack'", + agentAddress: "string", + repoId: RepoId, + transferId: "string", +}); +export type PackAckFrame = typeof PackAckFrame.infer; + +export const PackRejectReason = type.enumerated( + "signature_invalid", + "path_violation", + "conflict", + "corrupt", + "sha_mismatch", + "timeout", +); +export type PackRejectReason = typeof PackRejectReason.infer; + +/** + * Receiver rejects a pack transfer. + */ +export const PackRejectFrame = type({ + type: "'repo.pack.reject'", + agentAddress: "string", + repoId: RepoId, + transferId: "string", + // Validated as a plain string, NOT the closed `PackRejectReason` enum, on + // purpose. A reject carrying a reason value a newer peer added must still pass + // `HubFrame` validation and reach the reject handler (which latches the + // transfer) rather than failing validation and being dropped -- a dropped + // reject leaves the transfer neither acked nor rejected, stalling it until the + // next disconnect. Producers still classify and construct through + // `PackRejectReason`, so a known reason is what actually gets sent today; the + // reader treats any reason as a terminal reject (surfaces it, latches). + reason: "string", + // Optional human-readable cause carried alongside the machine reason, so the + // sender's operator sees WHY (e.g. "symlink at X is not supported") instead of + // only the coarse reason. Absent on rejects that have no extra detail. + "detail?": "string", +}); +export type PackRejectFrame = typeof PackRejectFrame.infer; + +/** + * Categories of deploy-apply failure surfaced by the sidecar's + * tool-package loader. Each value maps one-to-one to a distinct point in + * the apply pipeline; a single category fires per failed attempt. + * + * tarball.missing — a manifest entry's asset-sourced tarball + * is not present at the recorded path. + * asset.mount.missing — a `kind: "asset"` manifest entry names + * an `assetId` that the deploy pack's + * `deploy/asset-mounts.json` does not + * cover. Indicates a mismatch between the + * resolver's view of attached assets and + * the materialization fan-out, not a + * missing file on disk. + * integrity.mismatch — fetched tarball bytes do not match the + * manifest's pinned SRI integrity. + * registry.fetch.failed — the configured registry refused or + * dropped the request for a tarball. + * registry.unknown — the manifest entry references a registry + * name not present in the sidecar's + * registry config. + * registry.auth.failed — the registry rejected the sidecar's + * credentials. + * tarball.extract.failed — tar extraction failed or the extracted + * tree was malformed. + * git.materialization.failed + * — a git-sourced entry could not be + * materialized from its checked-out + * subtree, or reached a loader that does + * not materialize git sources. + * manifest.invalid — the manifest itself did not validate + * at the loader boundary (JSON.parse + * failure or arktype schema failure). + * Peer-dependency violations are caught + * earlier by the hub's resolver and + * surface as a launch failure rather + * than this frame. + * package.entry.missing — a top-level package's package.json had + * no `interchange.tools` field. + * package.entry.invalid — the resolved `interchange.tools` module + * exported nothing that looked like an + * AnnotatedToolFactory. + * factory.construct.failed — a factory invocation threw, or required + * a capability key the env did not provide. + * tool.name.duplicate — a tool name is registered more than + * once in the apply's loaded set. The + * cross-bundle case (two pinned packages + * share a bundle id, producing colliding + * prefixed tool names) is rejected at + * apply time, before the caller commits. + * The intra-bundle case (one package + * exports two definitions sharing a raw + * name) surfaces at first agent + * construction with the same category + * instead of apply rejection: the loader + * cannot see `bundle.definitions` without + * invoking the factory, and the `BaseEnv` + * the factory needs is constructed by the + * workflow child's step build env AFTER + * the commit. Both paths carry the same + * category so the operator-facing failure + * shape is uniform regardless of which + * check fired; only the channel + * (apply.error frame vs runtime construct + * failure) differs. + * apply.swap.failed — DEPRECATED, no longer emitted. The apply + * protocol stages each deploy into a stable + * per-deploy-id directory and commits via a + * single `active-deploy-id` file write, so + * there is no filesystem rename that can + * fail. The value is retained in the enum + * for wire compatibility: during a rolling + * upgrade an older sidecar can still emit + * it, and dropping the member would make a + * newer hub's frame validator reject that + * frame. + * apply.previous-rotation.failed + * — every loaded factory validated and the + * new deploy was staged, but persisting the + * instance's `active-deploy-id` file (the + * commit) degraded: the id was written + * through the no-fsync / dirty-marker + * fallback ladder rather than durably + * flushed. The new deploy is logically + * live, so `previousDeployId` on this + * failure carries the NEW deploy id rather + * than the pre-apply one. The next boot + * reconciles the recorded id from the dirty + * marker. + */ +export const DeployApplyErrorCategory = type.enumerated( + "tarball.missing", + "asset.mount.missing", + "integrity.mismatch", + "registry.fetch.failed", + "registry.unknown", + "registry.auth.failed", + "tarball.extract.failed", + "git.materialization.failed", + "manifest.invalid", + "package.entry.missing", + "package.entry.invalid", + "factory.construct.failed", + "tool.name.duplicate", + "apply.swap.failed", + "apply.previous-rotation.failed", +); +export type DeployApplyErrorCategory = typeof DeployApplyErrorCategory.infer; + +/** + * Hub requests the sidecar to push its current agent state. The sidecar + * responds by sending pack.push frames followed by pack.done using the + * same transferId. + */ +export const SyncRequestFrame = type({ + type: "'sync.request'", + agentAddress: "string", + transferId: "string", +}); +export type SyncRequestFrame = typeof SyncRequestFrame.infer; + +// --------------------------------------------------------------------------- +// Workflow probe (bidirectional) +// --------------------------------------------------------------------------- +// +// A probe asks a connected sidecar to inspect a code-sourced workflow WITHOUT +// deploying it: materialize the frozen dependency closure, evaluate the entry +// module to a live `WorkflowDefinition`, project it to its inert needs +// surface, and return that projection plus the derived grant set and content +// hash. The request/result/error trio is correlated by `requestId`, entirely +// independent of the address maps -- a token-authed sidecar can serve a probe +// in its pre-deploy state, with no agent deployed and no routable address. + +/** + * Hub asks a connected sidecar to probe a code-sourced workflow. Correlated by + * `requestId`; the sidecar answers with `workflow.probe.result` on success or + * `workflow.probe.error` on failure, both carrying the same `requestId`. + * + * The frame carries everything the sidecar's probe child needs to run the + * probe with no further hub round-trip: + * - `source` names where the definition's bytes come from (a registry, a + * package-registry asset, or a git asset). + * - `closure` is the frozen dependency closure the hub already resolved -- + * concrete versions and integrity SRIs -- so the child materializes the + * exact tree the hub pinned. + * - `entry` is the `interchange.workflow` module path within the package + * whose evaluation produces the `WorkflowDefinition`. + * - `assets` (optional) delivers the hub assets a `kind:"asset"` closure + * entry reads from, inline. Delivery is inline rather than a separate + * streamed transfer (as the deploy path uses) because the probe is a + * single-shot request that already buffers the whole frame -- streaming + * would only add a transfer-vs-probe correlation state a one-shot has no + * use for. The sidecar caps the total inline payload and fails loud past + * it; a git-sourced asset that grows past that cap is the trigger to + * revisit streaming. + */ +export const WorkflowProbeRequestFrame = type({ + type: "'workflow.probe.request'", + requestId: "string", + source: WorkflowDefinitionSource, + closure: ToolPackageManifest, + entry: "string", + "assets?": WorkflowSourceAssetMount.array(), +}); +export type WorkflowProbeRequestFrame = typeof WorkflowProbeRequestFrame.infer; + +/** + * A connected sidecar's answer to a `workflow.probe.request`: the inert + * needs-surface projection of the probed workflow, the inert grant set derived + * from it, and the content hash of the projection. Correlated to the request + * by `requestId`. + * + * `projection` is the same closed `WorkflowProjectionDefinition` a deploy frame + * carries. `grants` is the deployment-wide inert grant surface -- the deduped, + * sorted union of every step's grant strings -- for pre-deploy operator + * inspection. `wireHash` is the hex SHA-256 of the projection's canonical JSON + * (`computeWireDefinitionHash` in `@intx/types/wire-definition-hash`), the + * deployment's content-addressed handle. + * + * `grantWalkSnapshot` is the UN-flattened capability walk the flattened + * `grants` is derived from: the per-step grant declarations (each step's grant + * strings plus its tool-grant `grantEffects` map) and the definition's full, + * unfiltered `grantRequirements`. It carries the per-step grouping and the + * effect data that `grants` discards, so a later persist step can record the + * complete grant walk rather than only its flattened union. The flattened + * `grants` stays alongside it because the operator-approval gate consumes it. + */ +export const WorkflowProbeResultFrame = type({ + type: "'workflow.probe.result'", + requestId: "string", + projection: WorkflowProjectionDefinition, + grants: "string[]", + grantWalkSnapshot: GrantWalkSnapshot, + wireHash: "string", +}); +export type WorkflowProbeResultFrame = typeof WorkflowProbeResultFrame.infer; + +/** + * A connected sidecar reports that a `workflow.probe.request` failed -- + * materialization, evaluation, projection, or hashing threw. Correlated to the + * request by `requestId`; `error` describes the failure. + */ +export const WorkflowProbeErrorFrame = type({ + type: "'workflow.probe.error'", + requestId: "string", + error: "string", +}); +export type WorkflowProbeErrorFrame = typeof WorkflowProbeErrorFrame.infer; + +// --------------------------------------------------------------------------- +// Sidecar-hosted OAuth loopback login (CL-7508) +// --------------------------------------------------------------------------- + +/** Tokens a sidecar-hosted loopback login staged. The PKCE verifier never + * crosses the wire — it lives only in the sidecar's login service and dies + * with the callback server. */ +export const OAuthLoginTokens = type({ + access: "string", + "refresh?": "string", + /** Epoch ms the access token expires; absent when the issuer stated no + * lifetime (stored non-due, never a short artificial timer). */ + "expiresAt?": "number", + /** The issuer's id_token when it issues one (xai-oauth retains it). */ + "idToken?": "string", + /** id_token-derived account label (codex: `chatgpt_account_id`), the + * value `accountIdFromIdToken` decodes; threaded into credential + * metadata hub-side. */ + "accountId?": "string", +}); +export type OAuthLoginTokens = typeof OAuthLoginTokens.infer; + +/** Outcome arms a `oauth.login.result` frame may carry. A login sends + * `started` once its callback server is bound and the authorize URL is + * ready for the web UI to navigate; exactly one terminal arm (`completed` + * or `error`) follows. */ +export const OAuthLoginOutcome = type({ + status: "'started'", + authorizeUrl: "string", +}) + .or({ status: "'completed'", tokens: OAuthLoginTokens }) + .or({ status: "'error'", message: "string" }); +export type OAuthLoginOutcome = typeof OAuthLoginOutcome.infer; + +/** Hub → sidecar: run the named connector's loopback PKCE login on the + * machine this sidecar runs on. The connector must pin a fixed loopback + * redirect (`codex` → localhost:1455, `xai-oauth` → 127.0.0.1:1456); the + * hub never hosts a listener for these flows. */ +export const OAuthLoginStartFrame = type({ + type: "'oauth.login.start'", + requestId: "string", + connectorId: "'codex' | 'xai-oauth'", +}); +export type OAuthLoginStartFrame = typeof OAuthLoginStartFrame.infer; + +/** Sidecar → hub: the staged progress / terminal outcome of the login the + * hub requested with the same `requestId`. */ +export const OAuthLoginResultFrame = type({ + type: "'oauth.login.result'", + requestId: "string", + outcome: OAuthLoginOutcome, +}); +export type OAuthLoginResultFrame = typeof OAuthLoginResultFrame.infer; + +/** Hub → sidecar: tear the staged login with this `requestId` down and + * close its pinned-port callback listener. The hub sends it when it gives + * up on a login (whole-login timeout) so an abandoned bind does not wedge + * every retry of the connector until the sidecar restarts. */ +export const OAuthLoginCancelFrame = type({ + type: "'oauth.login.cancel'", + requestId: "string", +}); +export type OAuthLoginCancelFrame = typeof OAuthLoginCancelFrame.infer; + +// --------------------------------------------------------------------------- +// Discriminated frame unions +// --------------------------------------------------------------------------- + +/** All frame types the sidecar sends to the hub. */ +export const SidecarFrame = RegisterFrame.or(ReconnectFrame) + .or(AgentDeployAckFrame) + .or(AgentErrorFrame) + .or(MailOutboundFrame) + .or(AgentEventFrame) + .or(ConnectorStateChangedFrame) + .or(PingFrame) + .or(SessionAckFrame) + .or(SessionErrorFrame) + .or(AgentUndeployAckFrame) + .or(SignalCorrelationRegisterFrame) + .or(PackPushFrame) + .or(PackDoneFrame) + .or(PackAckFrame) + .or(PackRejectFrame) + .or(MailInboundAckFrame) + .or(WorkflowProbeResultFrame) + .or(WorkflowProbeErrorFrame) + .or(OAuthLoginResultFrame); +export type SidecarFrame = typeof SidecarFrame.infer; + +/** All frame types the hub sends to the sidecar. */ +export const HubFrame = MailInboundFrame.or(AgentDeployFrame) + .or(AgentUndeployFrame) + .or(PongFrame) + .or(SourcesUpdateFrame) + .or(CredentialsUpdateFrame) + .or(PackPushFrame) + .or(PackDoneFrame) + .or(PackAckFrame) + .or(PackRejectFrame) + .or(SyncRequestFrame) + .or(SignalDeliverFrame) + .or(RunGrantsFrame) + .or(SignalCorrelationRegisterAckFrame) + .or(DrainDeliverFrame) + .or(WorkflowProbeRequestFrame) + .or(OAuthLoginStartFrame) + .or(OAuthLoginCancelFrame); +export type HubFrame = typeof HubFrame.infer; + +/** Any frame on the wire, regardless of direction. */ +export const WireFrame = SidecarFrame.or(HubFrame); +export type WireFrame = typeof WireFrame.infer; diff --git a/vendor/intx-types/src/signals.ts b/vendor/intx-types/src/signals.ts new file mode 100644 index 0000000..53855c2 --- /dev/null +++ b/vendor/intx-types/src/signals.ts @@ -0,0 +1,96 @@ +import { type } from "arktype"; + +import type { GateType } from "./runtime"; + +/** + * The kinds of external control signal an agent can suspend on and later + * resume from. Exposed as both an arktype validator (so members are + * iterable and can be composed into wire validators) and a derived + * TypeScript union. + */ +export const signalKinds = ["approval"] as const; +export const SignalKind = type.enumerated(...signalKinds); +export type SignalKind = typeof SignalKind.infer; + +/** + * The internal resumption taxonomy: how a parked run resumes, keyed by + * (`kind`, `outcome`). This is NOT the approver's wire decision -- that is + * `ApprovalDecision`, which the delivery path parses. `ControlSignal` is the + * `kind`-discriminated union the resumption dispatch is designed around; + * `correlationId` ties an entry back to the suspension it resolves and + * `payload` carries kind-specific data opaquely. It is intentionally ahead of + * its consumers: the `approval` arm is the only one wired today, and its + * `timeout` outcome arrives via the gate-timeout path, not as a delivered + * decision. Each remaining signal flow activates its own arm as it lands. + */ +export const ControlSignal = type({ + correlationId: "string", + kind: "'approval'", + outcome: "'approved' | 'rejected' | 'timeout'", + payload: "unknown", +}); +export type ControlSignal = typeof ControlSignal.infer; + +/** + * The decision an approver hands back when they resolve an approval. This is + * the payload delivered to the parked run through `sendSignalDeliver`; the + * run's `parkOnSignal` awaitNext returns it verbatim as the correlated inbound. + * `scope` is deliberately absent: it is a storage-and-grant concern the + * resolver records on the approval row, not something the resumed run consumes. + */ +export const ApprovalDecision = type({ + outcome: "'approved' | 'rejected'", + "message?": "string", +}); +export type ApprovalDecision = typeof ApprovalDecision.infer; + +/** + * Map a signal kind to the reactor gate type it clears. The default arm + * calls `assertNever` so a newly added SignalKind that is not classified + * here fails to type-check — a bare switch without a default does not. + */ +export function signalKindToGateType(kind: SignalKind): GateType { + switch (kind) { + case "approval": + return "approval"; + default: + return assertNever(kind); + } +} + +function assertNever(x: never): never { + throw new Error(`Unclassified signal kind: ${JSON.stringify(x)}`); +} + +/** + * The reserved prefix that marks a signal name as an internal + * control-plane channel rather than a free-form `awaitSignal` gate name. + * The writer (`signalName`) and the reader (`correlationIdFromSignalName`) + * share this one constant so the two cannot drift. + */ +const SIGNAL_NAME_PREFIX = "__signal__:"; + +/** + * Construct the reserved, `__signal__:`-prefixed name under which a control + * signal for `correlationId` is delivered. This reserves a name namespace + * distinct from the user-authored workflow-signal names that flow through + * `SignalDeliverFrame.signalName` in `./sidecar`: those are free-form + * `awaitSignal` gate names chosen by workflow authors, whereas this helper + * mints an internal name the control plane owns, so the two cannot collide. + */ +export function signalName(correlationId: string): string { + return `${SIGNAL_NAME_PREFIX}${correlationId}`; +} + +/** + * Recover the `correlationId` from a reserved control-plane signal name + * minted by `signalName`. Returns `undefined` for a name that does not + * carry the reserved prefix (a free-form `awaitSignal` gate name), so a + * caller can tell a control-plane channel apart from an author-chosen one. + * Symmetric with `signalName`: `correlationIdFromSignalName(signalName(id)) + * === id`. + */ +export function correlationIdFromSignalName(name: string): string | undefined { + if (!name.startsWith(SIGNAL_NAME_PREFIX)) return undefined; + return name.slice(SIGNAL_NAME_PREFIX.length); +} diff --git a/vendor/intx-types/src/signer-identity.ts b/vendor/intx-types/src/signer-identity.ts new file mode 100644 index 0000000..3737d06 --- /dev/null +++ b/vendor/intx-types/src/signer-identity.ts @@ -0,0 +1,33 @@ +// How the signer behind a signature is identified. +// +// The only signer today is a principal whose Ed25519 private key the hub +// custodies (`local-principal`). The union is keyed on `kind` so a future +// signer flavour (say a hub-held key, or an externally-held key) is added with +// `.or()` and every by-value consumer that switches on `kind` gains a compile +// error for the unhandled variant. + +import { type } from "arktype"; + +/** + * A signer whose private key the hub custodies on a principal's behalf. + * + * `publicKey` is the RESOLVED, hex-encoded Ed25519 public key read from the + * hub's own principal-key store -- it is the trusted key for `principalId`. It + * MUST NOT be populated from untrusted input (e.g. a public key claimed on an + * inbound message): a verifier resolves the key from the store by `principalId` + * and checks the signature against that, never against a key from the wire. + */ +export const LocalPrincipalSigner = type({ + kind: "'local-principal'", + principalId: "string", + publicKey: "string", +}); +export type LocalPrincipalSigner = typeof LocalPrincipalSigner.infer; + +/** + * Discriminated union over how a signature's signer is identified, keyed on + * `kind`. Only the hub-custodied `local-principal` signer exists today; widen + * it here with `.or()` and every by-value consumer follows. + */ +export const SignerIdentity = LocalPrincipalSigner; +export type SignerIdentity = typeof SignerIdentity.infer; diff --git a/vendor/intx-types/src/tenants.ts b/vendor/intx-types/src/tenants.ts new file mode 100644 index 0000000..bf1ffa7 --- /dev/null +++ b/vendor/intx-types/src/tenants.ts @@ -0,0 +1,44 @@ +import { type } from "arktype"; + +import { SidecarCapabilityPolicy } from "./sidecar-capabilities"; + +export const TenantConfig = type({ + "sidecarPlacement?": SidecarCapabilityPolicy, + "[string]": "unknown", +}); +export type TenantConfig = typeof TenantConfig.infer; + +export const CreateTenant = type({ + name: "string", + slug: "string", + "parentId?": "string | null", +}); + +export const UpdateTenant = type({ + "name?": "string", + "config?": TenantConfig, +}); + +export const TenantResponse = type({ + id: "string", + name: "string", + slug: "string", + domain: "string", + "parentId?": "string | null", + "config?": TenantConfig, + createdAt: "string", + updatedAt: "string", +}); + +export const FederationTrust = type({ + tenantId: "string", + tenantName: "string", + tenantDomain: "string", + direction: "'inbound' | 'outbound' | 'bilateral'", + createdAt: "string", +}); + +export const CreateFederationTrust = type({ + targetTenantId: "string", + direction: "'inbound' | 'outbound' | 'bilateral'", +}); diff --git a/vendor/intx-types/src/tool-packages.ts b/vendor/intx-types/src/tool-packages.ts new file mode 100644 index 0000000..fad721d --- /dev/null +++ b/vendor/intx-types/src/tool-packages.ts @@ -0,0 +1,312 @@ +// Schemas for the tool-package distribution path. +// +// An agent pins one or more tool packages via `ToolPackagePin[]`. At +// deploy-assembly time, the hub walks the pinned set, resolves the full +// dependency closure, and writes a `ToolPackageManifest` into the deploy +// pack. The sidecar reads the manifest at apply time and materializes +// every entry. +// +// Only entries listed in `topLevel` contribute tools to the agent; +// transitive entries exist to satisfy `require()` / `import` resolution +// inside the top-level packages. + +import { type } from "arktype"; +import semver from "semver"; + +import { + ToolCredentialDeclarationArray, + isContainedEntryPath, +} from "./package-json"; + +/** + * npm's documented package-name rules expressed as an arktype regex + * literal: lowercase, may begin with a scope (`@scope/`), the rest of + * each segment is URL-safe (letters, digits, `_`, `-`, `.`), no + * leading dot or underscore, scoped names require a `/`. The npm + * registry rejects anything else; mirroring the rule at the REST + * boundary keeps mixed-case or malformed pins from threading past + * the API into the resolver, which would otherwise self-resolve + * them and then fail at the sidecar loader. + * + * Using a regex literal (rather than a `narrow` predicate) lets the + * JSON-Schema generator surface the rule as a `pattern` field in the + * OpenAPI spec without a fallback hook. + */ +export const ToolPackagePinName = type( + /^(?:@[a-z0-9][a-z0-9._-]*\/)?[a-z0-9][a-z0-9._-]*$/, +); + +/** + * A pin in an agent definition: name + version range. The hub resolves + * this against configured registries at deploy-assembly time. + * + * `version` is an npm-style spec ("^1.2.3", "~1.2", "1.2.3", "*"). + * Resolution is performed by `npm-pick-manifest` against the registry + * packument. Semver-range validation lives on `ToolPackagePinArray` + * (below) so the JSON-Schema generator sees a plain string here; the + * array narrow is the actual REST boundary for pins and runs before + * any value reaches the resolver. + * + * `name` must match npm's documented package-name rules — lowercase, + * optional scope prefix, URL-safe characters only. npm itself rejects + * uppercase names; packuments arrive lowercased, so a mixed-case pin + * would self-resolve and then silently fail the sidecar loader's + * `${name}@${version}` lookup against the lowercase entry the + * packument produced. + * + * A `ToolPackagePin[]` must contain at most one entry per `name`. Use + * `ToolPackagePinArray` (below) at REST boundaries to enforce dedup + * before the resolver runs; the resolver still rejects duplicates at + * its own boundary as belt-and-suspenders. + */ +export const ToolPackagePin = type({ + name: ToolPackagePinName, + version: "string", +}); +export type ToolPackagePin = typeof ToolPackagePin.infer; + +/** + * Array of pins with the no-duplicate-name and parseable-version + * invariants enforced at parse time. The downstream resolver keys + * its top-level resolution map by name; two pins of the same name + * would silently collapse to the first arrival's resolved version, + * and an unparseable semver range would fail mid-walk. Rejecting + * both at the REST boundary surfaces the bug to the caller instead + * of leaving it to misbehave at launch time. + * + * `*` is accepted as the documented any-version range; anything + * else must satisfy `semver.validRange`. + * + * NOTE: the same `*` special-case lives in `parsePin` inside the + * tool-packaging resolver. Any new magic-range additions need to be + * carved at both sites — the packages are separated by the wire-type + * vs. resolver boundary and cannot import each other. + */ +export const ToolPackagePinArray = ToolPackagePin.array().narrow( + (pins, ctx) => { + const seen = new Set(); + for (const pin of pins) { + if (seen.has(pin.name)) { + return ctx.mustBe( + `an array with no duplicate package names; "${pin.name}" appears more than once`, + ); + } + seen.add(pin.name); + if (pin.version !== "*" && semver.validRange(pin.version) === null) { + return ctx.mustBe( + `every pin to carry a parseable semver range; "${pin.name}" has version ${JSON.stringify(pin.version)}`, + ); + } + } + return true; + }, +); +export type ToolPackagePinArray = typeof ToolPackagePinArray.infer; + +/** + * A top-level manifest entry: a pinned package at its concrete resolved + * version, carrying the credential declarations harvested from the package's + * `interchange.credentials` (absent when it declares none). Only top-level + * pins contribute declarations; transitive dependencies never do, which is why + * this shape hangs off `topLevel` rather than `entries`. + */ +export const ToolPackageTopLevelEntry = type({ + name: ToolPackagePinName, + version: "string", + "credentials?": ToolCredentialDeclarationArray, +}); +export type ToolPackageTopLevelEntry = typeof ToolPackageTopLevelEntry.infer; + +/** + * The manifest's top-level entries with the no-duplicate-name invariant + * preserved -- the same guarantee `ToolPackagePinArray` gives agent-side pins. + * Versions here are concrete (already picked by the resolver), so the + * semver-range check that guards agent-side pins is unnecessary. + */ +export const ToolPackageTopLevelArray = ToolPackageTopLevelEntry.array().narrow( + (entries, ctx) => { + const seen = new Set(); + for (const entry of entries) { + if (seen.has(entry.name)) { + return ctx.mustBe( + `an array with no duplicate package names; "${entry.name}" appears more than once`, + ); + } + seen.add(entry.name); + } + return true; + }, +); +export type ToolPackageTopLevelArray = typeof ToolPackageTopLevelArray.infer; + +/** + * A pinned entry's bytes are fetched from an EXTERNAL npm registry at + * apply time. The sidecar's registry config maps `registry` to a URL and + * credentials. + * + * `integrity` is the SRI string ("sha512-...") the registry served for + * the picked version. The loader verifies the fetched bytes against it + * before unpacking and uses it as the content-addressed cache key. + */ +export const ToolPackageRegistrySource = type({ + kind: "'registry'", + registry: "string", + integrity: "string", +}); +export type ToolPackageRegistrySource = typeof ToolPackageRegistrySource.infer; + +/** + * The entry's bytes are a prepackaged npm tarball living at `path` inside + * the asset's checkout (the package-registry kind stores them under + * `tarballs/.tgz`). The loader reads the blob and extracts it. + * + * `integrity` is the SRI string ("sha512-...") of the tarball bytes. A + * reclassified npm tarball keeps its SRI: it is the same artifact an + * external registry would serve, so the loader verifies the read bytes + * against it and uses it as the content-addressed cache key, and a + * byte-identical tarball has one identity regardless of transport. + */ +export const ToolPackageAssetTarball = type({ + format: "'tarball'", + path: "string", + integrity: "string", +}); +export type ToolPackageAssetTarball = typeof ToolPackageAssetTarball.infer; + +/** + * The entry's bytes are a source package: the subtree at `packageDir` + * of the asset's checkout at `commitSha`, used in place (not packed). + * The loader checks the tree out and copies the subtree into the store. + * + * `packageDir` is the resolved POSIX subtree path of this package within + * the repo ("." for a single-package repo root, "packages/foo" for a + * monorepo member). It is a resolved directory, not a package name: a + * frozen materialization coordinate must not require re-resolving a + * `package.json` name against the tree at apply time. The narrow rejects + * absolute paths and `..` traversal at the boundary. + * + * `treeOid` is the git tree object id of the subtree at `commitSha` -- + * the content identity the loader verifies the checked-out subtree + * against. Unlike a tarball's `integrity`, it is a git tree oid, not an + * SRI, because a source subtree has no tarball bytes to hash. + */ +export const ToolPackageAssetSourceTree = type({ + format: "'source'", + commitSha: "string", + packageDir: type("string").narrow((dir, ctx) => + isContainedEntryPath(dir) + ? true + : ctx.mustBe("a repo-relative path with no '..' traversal"), + ), + treeOid: "string", +}); +export type ToolPackageAssetSourceTree = + typeof ToolPackageAssetSourceTree.infer; + +/** + * A pinned entry's bytes come from a hub `asset` -- a checked-out git + * repo attached to the agent at session time. `assetId` is the hub-side + * asset row id; the sidecar resolves it against the deploy pack's mount + * map to reach the asset's checkout. The package lives at a location + * within the checkout, either a prepackaged `tarball` or a `source` + * subtree, discriminated by `package.format`. + */ +export const ToolPackageAssetSource = type({ + kind: "'asset'", + assetId: "string", + package: ToolPackageAssetTarball.or(ToolPackageAssetSourceTree), +}); +export type ToolPackageAssetSource = typeof ToolPackageAssetSource.infer; + +/** + * Discriminated union over where a manifest entry's bytes come from: an + * external npm `registry`, or a hub `asset` (a git checkout holding a + * tarball or a source package). + */ +export const ToolPackageSource = ToolPackageRegistrySource.or( + ToolPackageAssetSource, +); +export type ToolPackageSource = typeof ToolPackageSource.infer; + +/** + * A closure entry's content identity, whatever its source: the tarball + * SRI for a `registry` entry or an `asset` tarball, the subtree git tree + * oid for an `asset` source package. Cache-bust keys read this rather + * than reaching into a shape-specific field. + */ +export function getToolPackageSourceContentIdentity( + source: ToolPackageSource, +): string { + if (source.kind === "registry") { + return source.integrity; + } + return source.package.format === "tarball" + ? source.package.integrity + : source.package.treeOid; +} + +/** + * A single pinned package in the closure. + * + * The entry's content identity lives on its `source` arm, because how it + * is derived and verified depends on where the bytes come from: an SRI + * over tarball bytes for the `asset` and `registry` arms. + * + * `os` / `cpu` are present when the entry comes from an + * `optionalDependencies` declaration with platform constraints. The + * sidecar filters entries by its own host before fetching; entries + * whose `os` or `cpu` does not include the host's value are skipped + * with a `platform.mismatch.skipped` debug log. + * + * `tarballUrl` is preserved for registry-sourced entries so the sidecar + * can fetch without re-resolving against the registry's packument; the + * hub recorded the exact URL the registry served at resolution time. + */ +export const ToolPackageManifestEntry = type({ + name: "string", + version: "string", + source: ToolPackageSource, + "os?": "string[]", + "cpu?": "string[]", + "tarballUrl?": "string", +}); +export type ToolPackageManifestEntry = typeof ToolPackageManifestEntry.infer; + +/** + * The manifest written into the deploy pack at + * `deploy/tool-packages-manifest.json`. + * + * `schemaVersion` is a literal "1" for now. Future schema changes bump + * this and the loader refuses unknown versions with `manifest.invalid`. + * + * `topLevel` enumerates the packages the agent definition explicitly + * pinned. The loader only scans these for `interchange.tools`; entries + * present in `entries` but absent from `topLevel` are transitive + * dependencies materialized for runtime `require()` / `import` + * resolution. + * + * `topLevel` extends the agent-side `ToolPackagePin` shape with the + * package's harvested `credentials` declarations. The `version` field + * here is always a concrete version (e.g. `"1.2.3"`), not a range. The resolver walks each + * agent-side pin's range through `npm-pick-manifest` and writes the + * picked version. The sidecar loader pairs `topLevel[i]` against + * `entries[j]` by `${name}@${version}` equality, so a range-form + * `version` here would never match any entry and the package would + * silently contribute no tool factories at apply time. + * + * `entries` carries the full pinned closure: every top-level pin plus + * every transitive dependency, deduped by `(name, version)`. The + * sidecar materializes every entry whose `os`/`cpu` matches its host. + */ +export const ToolPackageManifest = type({ + schemaVersion: "'1'", + // Use the array-level narrow so the wire validator catches duplicate + // top-level names directly, even when the manifest is produced by a + // hub the resolver did not author. The resolver enforces uniqueness + // when building the manifest; the validator is the second line of + // defense for any third-party hub or hand-edited file that slips a + // duplicate through. + topLevel: ToolPackageTopLevelArray, + entries: ToolPackageManifestEntry.array(), +}); +export type ToolPackageManifest = typeof ToolPackageManifest.infer; diff --git a/vendor/intx-types/src/wallets.ts b/vendor/intx-types/src/wallets.ts new file mode 100644 index 0000000..0c66ac4 --- /dev/null +++ b/vendor/intx-types/src/wallets.ts @@ -0,0 +1,59 @@ +import { type } from "arktype"; + +export const walletBackendTypes = ["crypto", "fiat", "credits"] as const; +export type WalletBackendType = (typeof walletBackendTypes)[number]; + +const BackendType = type.enumerated(...walletBackendTypes); + +const backendTypeDescription = + "Settlement backend the wallet is denominated in: `crypto` (on-chain assets), `fiat` (national currency), or `credits` (internal accounting units). Determines how balances and transactions are settled."; + +const walletConfigDescription = + "Backend-specific configuration for the wallet (for example chain or account details for a `crypto` backend). Shape depends on `backendType`; not interpreted by the hub."; + +const balanceDescription = + "Current balance as a decimal string in the wallet's `currency`. Stored as a string to preserve precision for both crypto and fiat amounts."; + +export const CreateWallet = type({ + name: "string", + backendType: BackendType.describe(backendTypeDescription), + currency: "string", + "config?": type("Record").describe(walletConfigDescription), +}); + +export const UpdateWallet = type({ + "name?": "string", + "config?": type("Record").describe(walletConfigDescription), +}); + +export const WalletResponse = type({ + id: "string", + tenantId: "string", + name: "string", + backendType: BackendType.describe(backendTypeDescription), + currency: "string", + balance: type("string").describe(balanceDescription), + "config?": type("Record").describe(walletConfigDescription), + createdAt: "string", + updatedAt: "string", +}); + +export const TransactionResponse = type({ + id: "string", + walletId: "string", + "runId?": "string | null", + direction: type("'inbound' | 'outbound'").describe( + "Whether funds moved into the wallet (`inbound`) or out of it (`outbound`).", + ), + amount: type("string").describe( + "Transaction amount as a decimal string in `currency`, stored as a string to preserve precision.", + ), + currency: "string", + "recipientId?": "string | null", + "senderId?": "string | null", + "requestId?": "string | null", + status: type("'pending' | 'completed' | 'failed'").describe( + "Settlement state of the transaction: `pending` (initiated, not yet settled), `completed`, or `failed`.", + ), + createdAt: "string", +}); diff --git a/vendor/intx-types/src/wire-definition-hash.ts b/vendor/intx-types/src/wire-definition-hash.ts new file mode 100644 index 0000000..dfe9fe7 --- /dev/null +++ b/vendor/intx-types/src/wire-definition-hash.ts @@ -0,0 +1,82 @@ +// Content-addressed hash of a wire-projected workflow definition. +// +// The deploy gate, the install-time probe, and re-verify must all agree +// on the deployment's content handle, so they hash the exact same +// canonical form. This module is the single source of truth those call +// sites import; the hash is a hex SHA-256 of the definition's canonical +// JSON. + +import { hexEncode } from "./hex"; + +/** + * Project a value into a canonical JSON string with deterministically + * sorted object keys. Object key order and surrounding whitespace do not + * affect the output, so two structurally equal values serialize to + * byte-identical strings and therefore hash equal. + * + * Values follow `JSON.stringify`'s semantics for what JSON can represent: + * an object key whose value is `undefined`, a function, or a symbol is + * dropped, and such an array element renders as `null`. The only intended + * difference from `JSON.stringify` is the deterministic key order. So for + * JSON-representable values the output is invariant across a JSON + * round-trip; a value with a custom `toJSON` (e.g. a `Date`) is NOT + * round-trip invariant here, because this canonicalizer does not invoke + * `toJSON` -- a caller needing round-trip invariance must pass it + * already-JSON values. + */ +export function canonicalJsonStringify(value: unknown): string { + if (value === null) return "null"; + if (typeof value !== "object") { + // Primitives serialize as JSON does. A value JSON cannot represent + // (`undefined`, a function, a symbol) has no JSON text, so + // `JSON.stringify` returns `undefined`; canonicalize it to `null`, matching + // how JSON renders such a value as an array element (see below). A + // top-level such value never reaches a definition hash. + return JSON.stringify(value) ?? "null"; + } + if (Array.isArray(value)) { + // JSON renders an `undefined`/function/symbol array element as `null`; the + // scalar branch above produces exactly that, so a plain recursive map keeps + // parity. + return `[${value.map((v) => canonicalJsonStringify(v)).join(",")}]`; + } + // Drop keys whose value JSON.stringify would omit (`undefined`, functions, + // symbols), mirroring how JSON serializes an object. + const entries = Object.entries(value) + .filter( + ([, v]) => + v !== undefined && typeof v !== "function" && typeof v !== "symbol", + ) + .sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0)); + return `{${entries + .map(([k, v]) => `${JSON.stringify(k)}:${canonicalJsonStringify(v)}`) + .join(",")}}`; +} + +/** + * Compute the content hash for a wire-projected workflow definition: + * SHA-256 of the canonical JSON of the `WorkflowDefinition` projection, + * hex-encoded. This is the deployment's content-addressed handle; every + * party that binds identity, approval, or re-verify to a deployment + * derives it from the same canonical form so their values compare by + * byte equality. + * + * Invariance across the boundary is load-bearing: the hub hashes a + * projection parsed off the JSON wire (where `undefined`-valued keys are + * already gone) while a child hashes an in-memory projection, and the two + * must produce byte-identical strings or re-verify would fail on a + * legitimately-approved definition. The canonicalizer's Date/`toJSON` + * caveat is moot here: both sides hash a projection parsed from the JSON + * wire, so no custom-`toJSON` value ever reaches the canonicalizer and the + * two sides agree. + */ +export async function computeWireDefinitionHash( + definition: unknown, +): Promise { + const canonical = canonicalJsonStringify(definition); + const digest = await crypto.subtle.digest( + "SHA-256", + new TextEncoder().encode(canonical), + ); + return hexEncode(new Uint8Array(digest)); +} diff --git a/vendor/intx-types/src/wire-workflow.ts b/vendor/intx-types/src/wire-workflow.ts new file mode 100644 index 0000000..358d7d3 --- /dev/null +++ b/vendor/intx-types/src/wire-workflow.ts @@ -0,0 +1,170 @@ +// Wire contracts for a workflow definition projected onto an `agent.deploy` +// frame: the per-step schema and the projection shapes the sidecar deploy +// router and the workflow-process child validate. Extracted from `sidecar.ts` +// so both files stay focused; `sidecar.ts` re-exports the public names, so +// `@intx/types/sidecar` consumers are unaffected. + +import { type } from "arktype"; + +import { CredentialBinding } from "./credentials"; +import { InferenceSource } from "./runtime"; +import { SidecarCapabilityPolicy } from "./sidecar-capabilities"; + +/** + * Fields every wire step carries regardless of `kind`. All other keys pass + * through unmodified (arktype's default), including the nested `agent`, inner + * `step`, `body`, `on`, and selector fields -- typed nowhere here on purpose, + * because two producers feed this schema: the live-deploy passthrough ships a + * step whose `agent.toolFactories` are functions that JSON-encode to `null`, + * while the live->inert projector in `@intx/workflow-deploy` ships a reified + * plain-data agent. Both must validate; reifying the grant surface into plain + * data is the projector's job, not this envelope's. + */ +const commonStepFields = { + "id?": "string", + "after?": "string[]", +} as const; + +/** + * A wire step: its `kind` must be one of the ten known primitives, plus the + * common `id`/`after` fields; all other keys pass through unmodified. Exported + * so the live->inert projector's producer and its mutation-test suite validate + * a single step against the same schema the deploy frame applies to every step. + * + * The load-bearing check here is the KIND discriminant -- a step with no `kind` + * or a `kind` outside the set is rejected at this boundary rather than carried + * through opaque, and the membership is what makes a step's canonical JSON + * deterministic across the child->hub boundary. Per-variant field validation is + * deliberately NOT done here (deeper authoring-time validation -- required + * fields, selector resolvability, DAG shape -- lives on `@intx/workflow`), so + * the ten variants collapse to one schema over the kind enum rather than ten + * near-identical arms whose fields were all optional passthrough anyway. + */ +export const WorkflowStep = type({ + kind: "'step' | 'map' | 'gate' | 'awaitSignal' | 'sleep' | 'childWorkflow' | 'escalation' | 'action' | 'loop' | 'onTrigger'", + ...commonStepFields, +}); +export type WorkflowStep = typeof WorkflowStep.infer; + +/** + * The `steps` record on a wire projection: every value must validate + * against the closed `WorkflowStep` union. The runtime constraint runs + * through a `.narrow` over a `Record` rather than a typed + * `{ "[string]": WorkflowStep }` on purpose: the inferred type stays + * `Record` so the existing live-deploy producer + * (`toWireWorkflowDefinition`, which hands a `Record` + * steps map to `sendAgentDeploy`) still typechecks, while the runtime + * validation is fully closed over the primitive-kind set. + */ +const WorkflowSteps = type({ "[string]": "unknown" }).narrow((steps, ctx) => { + for (const [stepId, step] of Object.entries(steps)) { + const parsed = WorkflowStep(step); + if (parsed instanceof type.errors) { + return ctx.mustBe( + `a record whose every step matches a known workflow primitive ` + + `variant; step ${JSON.stringify(stepId)} did not (${parsed.summary})`, + ); + } + } + return true; +}); + +/** + * Workflow projection carried on an `agent.deploy` frame. Its presence + * at the deploy router routes the frame to the workflow deploy path -- + * single- or multi-step, both of which spawn the workflow-process child + * -- as opposed to a per-step provision frame. + * + * `definition` is the wire projection of `WorkflowDefinition` from + * `@intx/workflow`. The arktype validator enforces the structural + * envelope the workflow-process child re-parses on the sidecar after + * materialization (`packages/hub-sessions/src/workflow-kind.ts`'s + * `workflowDefinitionEnvelopeSchema`): `id`, `triggers`, `steps`, + * `stepOrder`, optional `state`. The wire validator MUST require every + * field the envelope requires — this projection is the approved surface + * the source-ref child re-verifies its closure-evaluated definition + * against, and the child rejects a tree missing any envelope-required + * field. Deeper validation of authoring-time primitive shape lives on the + * workflow definition surface in `@intx/workflow`, not on the wire. + * + * `sources` pins an ordered, non-empty inference-source list per step in + * `definition.stepOrder` so the workflow-process child can resolve inference + * at step invocation without a round trip to the hub. The list is the step's + * failover chain: element 0 is the active source (its id is the step's + * `defaultSource`), and the reactor fails over forward through the tail on a + * transient inference error. A workflow step pins a single-element list (no + * per-step failover); a single-agent instance pins the instance's full + * ordered source chain. Every `stepOrder` entry must have a matching + * `sources` entry; the validator rejects frames that violate this at the + * boundary. + */ +export const WorkflowProjectionDefinition = type({ + id: "string > 0", + triggers: "unknown[]", + stepOrder: "string[]", + steps: WorkflowSteps, + "state?": "Record", + // The definition's credential bindings, projected verbatim by the + // live->inert projector (`projectDefinition`). This MUST stay in sync with + // that projector: because of the `"+": "delete"` below, a binding the + // projector emits but this schema omits would be silently stripped at the + // wire boundary, desyncing the hub-resolved bindings from the projection + // the sidecar validates and re-verifies. Bindings are the operator-approved + // credential request surface (no secret material), so they belong in the + // hashed projection. + "credentialBindings?": CredentialBinding.array(), + "sidecarPlacement?": SidecarCapabilityPolicy, + "+": "delete", +}).narrow((value, ctx) => { + // Every `stepOrder` entry must name a defined step. A legitimately projected + // definition always satisfies this (the authoring validator enforces it), so + // this rejects only a projector-bypassing or tampered wire frame -- closing a + // phantom-stepOrder entry at the trust boundary for every consumer, rather + // than letting a downstream reader index `steps[missing]` as `undefined` and + // silently take a default path. + for (const stepId of value.stepOrder) { + if (!Object.prototype.hasOwnProperty.call(value.steps, stepId)) { + return ctx.mustBe( + `a workflow projection whose stepOrder names only defined steps; ${JSON.stringify(stepId)} has no matching entry in steps`, + ); + } + } + return true; +}); +export type WorkflowProjectionDefinition = + typeof WorkflowProjectionDefinition.infer; + +/** + * A workflow projection paired with its per-step inference-source pins and the + * hub-approved wire hash, with the invariant that every `stepOrder` entry has a + * `sources` failover chain. This is the shared base for BOTH the top-level + * deploy frame (`AgentDeployWorkflow`, which intersects its extras onto this) + * AND each extracted trigger body (onTrigger section or childWorkflow child) + * under `referencedDefinitions` -- so the field set and the coverage narrow are + * defined once and a body's sources cover the body's stepOrder just as the + * top-level's cover the top-level's. + */ +export const WorkflowProjectionWithSources = type({ + definition: WorkflowProjectionDefinition, + sources: { "[string]": InferenceSource.array().atLeastLength(1) }, + // The hub-approved wire hash of `definition`'s projection -- the freeze anchor + // the hub gate wrote (`computeWireDefinitionHash`). The sidecar feeds it to + // the child as the `DEFINITION_HASH` it re-verifies its own recompute + // against, rather than trusting a sidecar-computed hash. At the top level it + // pins the deployment's content handle; per body it pins the body's + // projection, which is re-verified in-memory as part of the parent's + // already-re-verified closure. Optional on the wire because the frame schema + // does not force it; the production hub builder always stamps it. + "approvedWireHash?": "string > 0", +}).narrow((value, ctx) => { + for (const stepId of value.definition.stepOrder) { + if (!Object.prototype.hasOwnProperty.call(value.sources, stepId)) { + return ctx.mustBe( + `a workflow projection whose sources cover every step in stepOrder; ${JSON.stringify(stepId)} is missing`, + ); + } + } + return true; +}); +export type WorkflowProjectionWithSources = + typeof WorkflowProjectionWithSources.infer; diff --git a/vendor/intx-types/src/workflow-run-id.ts b/vendor/intx-types/src/workflow-run-id.ts new file mode 100644 index 0000000..7eee721 --- /dev/null +++ b/vendor/intx-types/src/workflow-run-id.ts @@ -0,0 +1,40 @@ +// Canonical runId derivation for a workflow deployment's top-level run. +// +// A workflow deployment has ONE addressable top-level run, whose stable runId +// is the local part of the deployment's mail address -- the `` in +// `@`. The supervisor's dispatch loop keys its per-run state, +// its grants barrier, and its terminal wait on this id. Every producer of a +// run's grants -- the hub-api trigger route and the sidecar's mail-deliver +// path -- must stage those grants under the SAME id, or they land under a run +// id the supervisor never looks up and the run fails closed on its +// `onRunStart` barrier. +// +// This module is the single source of truth those producers import, so +// their derivations cannot diverge. It exists to end the divergence that +// let the mail's Message-ID (a per-message identifier) masquerade as the +// runId: the runId is a property of the deployment, not of the individual +// trigger occurrence. Internal section/body runs still receive their own +// synthetic run ids and are not externally addressable. + +import { parseRunAddress } from "./agent-address"; + +/** + * The stable runId for a workflow deployment's one addressable top-level run: + * the local part of its mail address, before the `@`. Callers hold the + * deployment mail address in different forms -- a routing recipient, a + * supervisor binding, a route-derived address -- and route it through this one + * function so the runId contract is stated in exactly one place. + * + * Delegates to `parseRunAddress` so a single function owns the `@`-split: the + * runId is the parsed local part. A malformed address (no `run_` marker, no + * `@`, or an empty domain) is a caller bug, not a value to key state under, so + * this throws rather than returning a fabricated id that would land run state + * under an id the supervisor never looks up. + */ +export function deriveWorkflowRunId(address: string): string { + const parsed = parseRunAddress(address); + if (parsed === null) { + throw new Error(`Invalid run address: ${JSON.stringify(address)}`); + } + return parsed.runId; +} diff --git a/vendor/intx-types/src/workflow-sources.ts b/vendor/intx-types/src/workflow-sources.ts new file mode 100644 index 0000000..4a93137 --- /dev/null +++ b/vendor/intx-types/src/workflow-sources.ts @@ -0,0 +1,74 @@ +// Schemas for where a code-sourced workflow definition's bytes come from. +// +// A workflow install carries a `WorkflowDefinitionSource` to say where the +// definition should be fetched from at apply time. Two origins exist: the +// `registry` variant names an EXTERNAL npm registry that publishes the +// definition package; the `asset` variant names a hub asset -- a checked-out +// git repo -- that holds the definition either as a published `tarball` +// (selected by the install pin) or as a `source` codebase at a pinned commit. + +import { type } from "arktype"; + +/** + * A workflow definition published to an external npm registry, fetched at + * apply time. The sidecar's registry config maps `registry` to a URL and + * credentials. The install call's version pin selects the definition. + */ +export const WorkflowDefinitionRegistrySource = type({ + kind: "'registry'", + registry: "string", +}); +export type WorkflowDefinitionRegistrySource = + typeof WorkflowDefinitionRegistrySource.infer; + +/** + * The definition is a published tarball inside the hub asset. This names + * only the format: the install call's version pin selects which package + * inside the asset is the definition, exactly as the `registry` variant + * leaves the pin to travel separately. + */ +export const WorkflowDefinitionAssetTarball = type({ + format: "'tarball'", +}); +export type WorkflowDefinitionAssetTarball = + typeof WorkflowDefinitionAssetTarball.infer; + +/** + * The definition is the codebase in the hub asset's git checkout at a pinned + * commit. `commitSha` IS the pin, and the content hash of the tree at that + * commit is the definition's identity (the member `package.json` version is + * only an advisory label). `packageName` selects which workspace member of a + * monorepo codebase is the definition, by its `package.json` name; absent + * means the codebase is a single package rooted at the tree. + */ +export const WorkflowDefinitionAssetSourceTree = type({ + format: "'source'", + commitSha: "string", + "packageName?": "string", +}); +export type WorkflowDefinitionAssetSourceTree = + typeof WorkflowDefinitionAssetSourceTree.infer; + +/** + * A workflow definition sourced from a hub `asset` -- a checked-out git repo. + * The definition lives inside it either as a published `tarball` or as a + * `source` codebase, discriminated by `package.format`. + */ +export const WorkflowDefinitionAssetSource = type({ + kind: "'asset'", + assetId: "string", + package: WorkflowDefinitionAssetTarball.or(WorkflowDefinitionAssetSourceTree), +}); +export type WorkflowDefinitionAssetSource = + typeof WorkflowDefinitionAssetSource.infer; + +/** + * Discriminated union over where a workflow definition's bytes come from, + * keyed on `kind`. Widen it here and every by-value consumer + * (`SourceRefPin`, the probe/deploy wire frames) follows; a consumer that + * switches on `kind` gains a compile error for any unhandled variant. + */ +export const WorkflowDefinitionSource = WorkflowDefinitionRegistrySource.or( + WorkflowDefinitionAssetSource, +); +export type WorkflowDefinitionSource = typeof WorkflowDefinitionSource.infer; diff --git a/vendor/intx-types/src/workflows.ts b/vendor/intx-types/src/workflows.ts new file mode 100644 index 0000000..7677f9c --- /dev/null +++ b/vendor/intx-types/src/workflows.ts @@ -0,0 +1,48 @@ +// Status vocabulary for the first-class workflow definition model, kept in its +// own workflow-scoped module. +export const workflowDefinitionStatuses = ["deployed", "stopped"] as const; +export type WorkflowDefinitionStatus = + (typeof workflowDefinitionStatuses)[number]; + +export const workflowDefinitionVersionStatuses = [ + "active", + "inactive", + "failed", +] as const; +export type WorkflowDefinitionVersionStatus = + (typeof workflowDefinitionVersionStatuses)[number]; + +import { type } from "arktype"; + +const WorkflowDefinitionStatusType = type.enumerated( + ...workflowDefinitionStatuses, +); +const WorkflowDefinitionVersionStatusType = type.enumerated( + ...workflowDefinitionVersionStatuses, +); + +// One entry in a definition's version history. +export const WorkflowDefinitionVersion = type({ + version: "string", + status: WorkflowDefinitionVersionStatusType, + createdAt: "string", +}); + +// The first-class workflow definition, as returned by the definition routes. +export const WorkflowDefinitionResponse = type({ + id: "string", + tenantId: "string", + name: "string", + "description?": "string | null", + currentVersion: "string", + status: WorkflowDefinitionStatusType.describe( + "Lifecycle state of the definition: `deployed` (a launchable version is active) or `stopped` (deactivated).", + ), + createdAt: "string", + updatedAt: "string", +}); + +// Rollback a definition to a prior version. +export const WorkflowRollbackRequest = type({ + version: "string", +}); From 54f657fd3b9e58c3e6ae0e31b24bd26ebd04df08 Mon Sep 17 00:00:00 2001 From: Sawyer Date: Thu, 17 Sep 2026 12:40:20 -0700 Subject: [PATCH 02/17] Native MailboxStore over the principal mailbox tables --- src/index.ts | 11 ++ src/migrations.test.ts | 7 + src/migrations.ts | 92 ++++++++++ src/native-store.test.ts | 173 ++++++++++++++++++ src/native-store.ts | 373 +++++++++++++++++++++++++++++++++++++++ 5 files changed, 656 insertions(+) create mode 100644 src/native-store.test.ts create mode 100644 src/native-store.ts diff --git a/src/index.ts b/src/index.ts index 9bc1210..f97ec44 100644 --- a/src/index.ts +++ b/src/index.ts @@ -21,6 +21,17 @@ export { export { createMailboxDb } from "./db.js"; export type { MailboxDb } from "./db.js"; +// The native `MailboxStore` over `mailbox.principal_mail` / +// `mailbox.mailbox_state` (migration `0004_native_mailbox_store`) — the +// vendored `executeSearch`/`executeThread` from `@intx/mailbox` run over it +// unmodified. +export { + createPrincipalMailboxStore, + openNativeMailboxStore, + moveNativeMailboxMessage, +} from "./native-store.js"; +export type { NativeMailboxStore } from "./native-store.js"; + // Two tables: the immutable mail plane, and the mutable management layer keyed // by mail id. There is no `mailboxPriorities`/`mailboxStatuses` export and no // `MailboxPriority`/`MailboxStatus` type — the vocabulary is the host's, passed diff --git a/src/migrations.test.ts b/src/migrations.test.ts index 5307c33..b98cb35 100644 --- a/src/migrations.test.ts +++ b/src/migrations.test.ts @@ -77,17 +77,21 @@ describe("runMailboxMigrations", () => { "address", "created_at", "direction", + "flags", + "folder", "from_address", "id", "in_reply_to", "message_id", "message_key", + "modseq", "principal_id", "raw", "references", "refs", "subject", "tenant_id", + "uid", ]); const stateColumns = await db.execute<{ column_name: string }>( @@ -392,6 +396,7 @@ describe("runMailboxMigrations", () => { "0001_principal_mailbox", "0002_mail_threading_headers", "0003_mail_references", + "0004_native_mailbox_store", ]); const rows = await db.execute<{ @@ -555,6 +560,7 @@ describe("runMailboxMigrations", () => { ["0001_principal_mailbox", "1"], ["0002_mail_threading_headers", "1"], ["0003_mail_references", "1"], + ["0004_native_mailbox_store", "1"], ]); }); }); @@ -762,6 +768,7 @@ describe("runMailboxMigrations under concurrent cold start", () => { "0001_principal_mailbox", "0002_mail_threading_headers", "0003_mail_references", + "0004_native_mailbox_store", ]); }); diff --git a/src/migrations.ts b/src/migrations.ts index b13bdc8..4e085d4 100644 --- a/src/migrations.ts +++ b/src/migrations.ts @@ -337,6 +337,98 @@ export const MIGRATIONS: Migration[] = [ AND h."references" IS NOT NULL`, ], }, + { + // The native `MailboxStore` slice: IMAP-shaped columns on the existing mail + // plane (folder/uid/modseq/flags), plus a per-(tenant, principal, folder) + // counters table. Additive only — the old `mailbox` management columns + // (read_at/archived_at/trashed_at/priority/…) are untouched and still the + // source of truth for every reader that has not moved to the native store + // yet; this migration only backfills the new columns FROM them. + id: "0004_native_mailbox_store", + statements: [ + sql`ALTER TABLE "mailbox"."principal_mail" + ADD COLUMN IF NOT EXISTS "folder" text NOT NULL DEFAULT 'INBOX'`, + sql`ALTER TABLE "mailbox"."principal_mail" + ADD COLUMN IF NOT EXISTS "uid" bigint`, + sql`ALTER TABLE "mailbox"."principal_mail" + ADD COLUMN IF NOT EXISTS "modseq" bigint`, + sql`ALTER TABLE "mailbox"."principal_mail" + ADD COLUMN IF NOT EXISTS "flags" text[] NOT NULL DEFAULT '{}'`, + // Folder + \Seen backfill, from the pre-existing management row: trashed + // wins over archived (a message can carry both timestamps; Trash is the + // more final state), everything else lands in INBOX. Guarded by + // "uid" IS NULL so a re-run (or a fresh row already written through the + // native path) is left alone. + sql`UPDATE "mailbox"."principal_mail" AS pm + SET "folder" = CASE + WHEN mb."trashed_at" IS NOT NULL THEN 'Trash' + WHEN mb."archived_at" IS NOT NULL THEN 'Archive' + ELSE 'INBOX' + END, + "flags" = CASE + WHEN mb."read_at" IS NOT NULL THEN ARRAY['\Seen'] + ELSE '{}' + END + FROM "mailbox"."mailbox" AS mb + WHERE mb."id" = pm."id" AND pm."uid" IS NULL`, + // A message can be delivered before its management row exists in the + // eager-creation window; treat that as an untouched INBOX message rather + // than leaving folder/flags at their column defaults implicitly. + sql`UPDATE "mailbox"."principal_mail" + SET "folder" = 'INBOX', "flags" = '{}' + WHERE "uid" IS NULL + AND "id" NOT IN (SELECT "id" FROM "mailbox"."mailbox")`, + // uid/modseq: row order by created_at (then id, for a stable tiebreak), + // scoped per (tenant_id, principal_id, folder) — the same scope every + // other mailbox counter here uses. Both counters share one sequence per + // group; nothing requires them to diverge for a backfilled row. + sql`UPDATE "mailbox"."principal_mail" AS pm + SET "uid" = seq."rn", "modseq" = seq."rn" + FROM ( + SELECT "id", + row_number() OVER ( + PARTITION BY "tenant_id", "principal_id", "folder" + ORDER BY "created_at", "id" + ) AS "rn" + FROM "mailbox"."principal_mail" + WHERE "uid" IS NULL + ) AS seq + WHERE pm."id" = seq."id"`, + // No NOT NULL on "uid"/"modseq": the pre-existing write paths + // (`writeMailboxMessage`, `deliverInboxItems`, …) do not populate them, + // and this slice is additive-only — they stay nullable so those paths + // keep inserting exactly as they do today. Only the native store + // populates them, on every row it writes. + // + // Per-(tenant, principal, folder) IMAP counters. `uid_validity` is + // derived once at backfill from that group's earliest `created_at` + // (epoch seconds — stable, and distinct across groups created at + // different times); a folder created fresh through the native store + // stamps its own at creation instead. + sql`CREATE TABLE IF NOT EXISTS "mailbox"."mailbox_state" ( + "tenant_id" text NOT NULL, + "principal_id" text NOT NULL, + "folder" text NOT NULL, + "uid_validity" bigint NOT NULL, + "uid_next" bigint NOT NULL, + "highest_modseq" bigint NOT NULL, + PRIMARY KEY ("tenant_id", "principal_id", "folder"), + CONSTRAINT "mailbox_state_tenant_id_tenant_id_fk" + FOREIGN KEY ("tenant_id") REFERENCES "public"."tenant" ("id") ON DELETE CASCADE, + CONSTRAINT "mailbox_state_principal_id_principal_id_fk" + FOREIGN KEY ("principal_id") REFERENCES "public"."principal" ("id") ON DELETE CASCADE + )`, + sql`INSERT INTO "mailbox"."mailbox_state" + ("tenant_id", "principal_id", "folder", "uid_validity", "uid_next", "highest_modseq") + SELECT "tenant_id", "principal_id", "folder", + extract(epoch FROM min("created_at"))::bigint, + max("uid") + 1, + max("modseq") + FROM "mailbox"."principal_mail" + GROUP BY "tenant_id", "principal_id", "folder" + ON CONFLICT ("tenant_id", "principal_id", "folder") DO NOTHING`, + ], + }, ]; const DIALECT = new PgDialect(); diff --git a/src/native-store.test.ts b/src/native-store.test.ts new file mode 100644 index 0000000..2ea9304 --- /dev/null +++ b/src/native-store.test.ts @@ -0,0 +1,173 @@ +import { beforeEach, describe, expect, it } from "bun:test"; +import { executeSearch, executeThread } from "@intx/mailbox"; +import type { StoredEnvelope } from "@intx/mailbox"; +import { + createPrincipalMailboxStore, + moveNativeMailboxMessage, + openNativeMailboxStore, +} from "./native-store.js"; +import { seedScope, withTestDb } from "./test-helpers.js"; +import type { MailboxDb } from "./db.js"; + +const TENANT_ID = "tenant-native"; +const PRINCIPAL_ID = "principal-native"; + +let db: MailboxDb; + +beforeEach(async () => { + db = await withTestDb(); + await seedScope(db, TENANT_ID, PRINCIPAL_ID); +}); + +function envelope(overrides: Partial = {}): StoredEnvelope { + return { + messageId: "", + from: "sender@example.com", + to: ["recipient@example.com"], + subject: "Hello", + date: new Date("2026-01-01T00:00:00.000Z"), + inReplyTo: undefined, + references: [], + interchangeType: undefined, + interchangeCorrelationId: undefined, + ...overrides, + }; +} + +describe("native MailboxStore over the principal mailbox tables", () => { + it("append -> find -> flags -> modseq", async () => { + const inbox = await openNativeMailboxStore(db, { + tenantId: TENANT_ID, + principalId: PRINCIPAL_ID, + folder: "INBOX", + }); + + expect(inbox.uidNext).toBe(1); + expect(inbox.highestModSeq).toBe(0); + + const raw = new TextEncoder().encode("From: sender@example.com\r\n\r\nBody"); + const uid = inbox.append(raw, envelope(), []); + expect(uid).toBe(1); + expect(inbox.uidNext).toBe(2); + expect(inbox.highestModSeq).toBe(1); + + const found = inbox.find(uid); + expect(found?.envelope.subject).toBe("Hello"); + expect(found?.modseq).toBe(1); + + const flagged = inbox.addFlags(uid, ["\\Seen"]); + expect(flagged.flags.has("\\Seen")).toBe(true); + expect(flagged.modseq).toBe(2); + expect(inbox.highestModSeq).toBe(2); + + const unflagged = inbox.removeFlags(uid, ["\\Seen"]); + expect(unflagged.flags.has("\\Seen")).toBe(false); + expect(unflagged.modseq).toBe(3); + + await inbox.settled; + + // A fresh instance sees exactly what was persisted, not just the + // in-memory mirror. + const reopened = await openNativeMailboxStore(db, { + tenantId: TENANT_ID, + principalId: PRINCIPAL_ID, + folder: "INBOX", + }); + expect(reopened.uidNext).toBe(2); + expect(reopened.highestModSeq).toBe(3); + const reopenedMsg = reopened.find(uid); + expect(reopenedMsg?.flags.has("\\Seen")).toBe(false); + expect(reopenedMsg?.modseq).toBe(3); + const rawBack = await reopened.readRaw(uid); + expect(new TextDecoder().decode(rawBack)).toBe("From: sender@example.com\r\n\r\nBody"); + }); + + it("remove drops a message", async () => { + const inbox = await openNativeMailboxStore(db, { + tenantId: TENANT_ID, + principalId: PRINCIPAL_ID, + folder: "INBOX", + }); + const uid = inbox.append(new Uint8Array([1, 2, 3]), envelope(), []); + inbox.remove(uid); + expect(inbox.find(uid)).toBeUndefined(); + await inbox.settled; + + const reopened = await openNativeMailboxStore(db, { + tenantId: TENANT_ID, + principalId: PRINCIPAL_ID, + folder: "INBOX", + }); + expect(reopened.find(uid)).toBeUndefined(); + expect(reopened.messages.length).toBe(0); + }); + + it("moves a message between folders, assigning a fresh uid", async () => { + const store = createPrincipalMailboxStore(db, { + tenantId: TENANT_ID, + principalId: PRINCIPAL_ID, + }); + const inbox = await store.open("INBOX"); + const uid = inbox.append(new Uint8Array([9]), envelope(), []); + await inbox.settled; + + const newUid = await store.move("INBOX", uid, "Archive"); + expect(newUid).toBe(1); + + const inboxAfter = await store.open("INBOX"); + expect(inboxAfter.find(uid)).toBeUndefined(); + + const archive = await store.open("Archive"); + expect(archive.find(newUid)?.envelope.messageId).toBe(""); + + // The moved message got its own move alone in the target mailbox. + expect(archive.messages.length).toBe(1); + + // Moving again gets the next uid in Archive, not a collision. + const inbox2 = await store.open("INBOX"); + const uid2 = inbox2.append(new Uint8Array([10]), envelope(), []); + await inbox2.settled; + const secondMove = await store.move("INBOX", uid2, "Archive"); + expect(secondMove).toBe(2); + }); + + it("runs the vendored executeSearch and executeThread over the native store", async () => { + const inbox = await openNativeMailboxStore(db, { + tenantId: TENANT_ID, + principalId: PRINCIPAL_ID, + folder: "INBOX", + }); + + const rootUid = inbox.append( + new Uint8Array([1]), + envelope({ messageId: "", subject: "Thread root" }), + [], + ); + inbox.append( + new Uint8Array([2]), + envelope({ + messageId: "", + subject: "Re: Thread root", + inReplyTo: "", + references: [""], + }), + [], + ); + await inbox.settled; + + const refs = await executeSearch("INBOX", inbox, { from: "sender@example.com" }); + expect(refs).toEqual([ + { uid: 1, mailbox: "INBOX" }, + { uid: 2, mailbox: "INBOX" }, + ]); + expect(refs[0]).toHaveProperty("uid"); + expect(refs[0]).toHaveProperty("mailbox"); + + const threads = await executeThread("INBOX", inbox, "references"); + expect(threads.length).toBe(1); + const [thread] = threads; + expect(thread!.ref).toEqual({ uid: rootUid, mailbox: "INBOX" }); + expect(thread!.children.length).toBe(1); + expect(thread!.children[0]!.ref.uid).toBe(2); + }); +}); diff --git a/src/native-store.ts b/src/native-store.ts new file mode 100644 index 0000000..00124c1 --- /dev/null +++ b/src/native-store.ts @@ -0,0 +1,373 @@ +import { sql } from "drizzle-orm"; +import type { MailboxStore, StoredEnvelope, StoredMessage } from "@intx/mailbox"; +import type { MailboxDb } from "./db.js"; + +// drizzle's `sql` tag spreads a bare array interpolation as a comma-separated +// list of its own parameters (empty renders as the syntax error `()`) rather +// than binding it as one `text[]` value — the same reason `sql.join` exists +// for IN-lists. A Postgres array literal cast is what actually binds as a +// single `text[]` parameter. +function pgTextArrayLiteral(items: readonly string[]): string { + const escape = (item: string) => + `"${item.replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"`; + return `{${items.map(escape).join(",")}}`; +} + +/** + * A `MailboxStore` (see `vendor/intx-mailbox/src/mailbox.ts`) backed by the + * `mailbox.principal_mail` / `mailbox.mailbox_state` tables migration + * `0004_native_mailbox_store` added, instead of an in-process array. One + * instance is scoped to a single (tenant, principal, folder) mailbox, which is + * the same scope the vendored `executeSearch`/`executeThread` pure functions + * already assume (`mailboxName` names the one mailbox `store.messages` holds). + * + * Deliberately reads via plain tagged `sql`, not the `schema.ts` drizzle table + * objects: those objects are pinned by `schema-check.ts` and + * `schema-ddl-parity.test.ts` to the columns the OLD read/write paths depend + * on, and this slice must not widen what those assert. + * + * `MailboxStore`'s mutating methods (`append`/`addFlags`/`removeFlags`/ + * `remove`) are synchronous in the vendored interface — an in-memory backing + * can satisfy that trivially, a Postgres-backed one cannot make the write + * durable before returning. This backing keeps a fully materialized in-memory + * mirror (loaded once by `openNativeMailboxStore`) so every synchronous method + * answers from it immediately and stays interface-correct, while queuing the + * matching Postgres statement onto `store.settled` — a promise every write + * chains onto, in order, so two writes for the same mailbox never race each + * other at the database. Call `await store.settled` before trusting a write + * has actually landed (every test in `native-store.test.ts` does). + */ +export type NativeMailboxStore = MailboxStore & { + readonly tenantId: string; + readonly principalId: string; + readonly folder: string; + /** Resolves once every write queued so far has been applied to Postgres. */ + readonly settled: Promise; +}; + +type Row = { + id: string; + uid: string | number; + modseq: string | number; + flags: string[]; + raw: Uint8Array; + subject: string | null; + from_address: string | null; + message_id: string | null; + in_reply_to: string | null; + references: unknown; + created_at: Date; +}; + +function toEnvelope(row: Row): StoredEnvelope { + const references = Array.isArray(row.references) + ? (row.references as string[]) + : []; + return { + messageId: row.message_id ?? "", + from: row.from_address ?? "", + to: [], + subject: row.subject ?? "", + date: row.created_at, + inReplyTo: row.in_reply_to ?? undefined, + references, + interchangeType: undefined, + interchangeCorrelationId: undefined, + }; +} + +function toStoredMessage(row: Row): StoredMessage & { rowId: string } { + return { + rowId: row.id, + uid: Number(row.uid), + modseq: Number(row.modseq), + flags: new Set(row.flags), + envelope: toEnvelope(row), + }; +} + +async function readState( + db: MailboxDb, + tenantId: string, + principalId: string, + folder: string, +): Promise<{ uidValidity: number; uidNext: number; highestModSeq: number }> { + const rows = await db.execute<{ + uid_validity: string | number; + uid_next: string | number; + highest_modseq: string | number; + }>(sql` + SELECT "uid_validity", "uid_next", "highest_modseq" + FROM "mailbox"."mailbox_state" + WHERE "tenant_id" = ${tenantId} AND "principal_id" = ${principalId} AND "folder" = ${folder} + `); + if (rows[0] !== undefined) { + return { + uidValidity: Number(rows[0].uid_validity), + uidNext: Number(rows[0].uid_next), + highestModSeq: Number(rows[0].highest_modseq), + }; + } + const uidValidity = Math.floor(Date.now() / 1000); + await db.execute(sql` + INSERT INTO "mailbox"."mailbox_state" + ("tenant_id", "principal_id", "folder", "uid_validity", "uid_next", "highest_modseq") + VALUES (${tenantId}, ${principalId}, ${folder}, ${uidValidity}, 1, 0) + ON CONFLICT ("tenant_id", "principal_id", "folder") DO NOTHING + `); + return { uidValidity, uidNext: 1, highestModSeq: 0 }; +} + +/** + * Load a `NativeMailboxStore` for one (tenant, principal, folder) mailbox: + * fetches its counters and materializes every stored message into memory. + * Call again (a fresh instance) to observe writes made by another instance + * once their `settled` promise has resolved — this store does not itself + * poll or subscribe. + */ +export async function openNativeMailboxStore( + db: MailboxDb, + scope: { tenantId: string; principalId: string; folder: string }, +): Promise { + const { tenantId, principalId, folder } = scope; + const state = await readState(db, tenantId, principalId, folder); + + const rows = await db.execute(sql` + SELECT "id", "uid", "modseq", "flags", "raw", "subject", "from_address", + "message_id", "in_reply_to", "references", "created_at" + FROM "mailbox"."principal_mail" + WHERE "tenant_id" = ${tenantId} AND "principal_id" = ${principalId} AND "folder" = ${folder} + ORDER BY "uid" ASC + `); + const messages: (StoredMessage & { rowId: string })[] = rows.map(toStoredMessage); + const byUid = new Map(messages.map((m) => [m.uid, m])); + + // Every queued write chains onto the last, so two writes to the same + // mailbox are applied to Postgres in the order they were made in memory, + // never racing each other. `settled` always resolves (never rejects) so one + // failed write does not wedge every later one from being attempted; a + // caller that needs to observe a failure awaits the promise `enqueue` + // itself was given, not `store.settled`. + let settled: Promise = Promise.resolve(); + function enqueue(work: () => Promise): void { + settled = settled.then(work).then( + () => undefined, + () => undefined, + ); + } + + function requireMessage(uid: number): StoredMessage & { rowId: string } { + const msg = byUid.get(uid); + if (msg === undefined) { + throw new Error(`Message UID ${uid} not found in mailbox "${folder}"`); + } + return msg; + } + + const store: NativeMailboxStore = { + tenantId, + principalId, + folder, + uidValidity: state.uidValidity, + get uidNext() { + return state.uidNext; + }, + get highestModSeq() { + return state.highestModSeq; + }, + get messages() { + return messages; + }, + get settled() { + return settled; + }, + + append(raw, envelope, flags) { + const uid = state.uidNext; + const modseq = state.highestModSeq + 1; + state.uidNext += 1; + state.highestModSeq = modseq; + const rowId = crypto.randomUUID(); + const message: StoredMessage & { rowId: string } = { + rowId, + uid, + modseq, + flags: new Set(flags), + envelope, + }; + messages.push(message); + byUid.set(uid, message); + + enqueue(() => + db.execute(sql` + INSERT INTO "mailbox"."principal_mail" + ("id", "tenant_id", "principal_id", "address", "direction", "raw", + "subject", "from_address", "message_id", "in_reply_to", "references", + "created_at", "folder", "uid", "modseq", "flags") + VALUES ( + ${rowId}, ${tenantId}, ${principalId}, ${envelope.from || envelope.to[0] || ""}, + 'inbound', ${Buffer.from(raw)}, ${envelope.subject}, ${envelope.from}, + ${envelope.messageId || null}, ${envelope.inReplyTo ?? null}, + ${envelope.references.length > 0 ? JSON.stringify(envelope.references) : null}, + ${envelope.date.toISOString()}, ${folder}, ${uid}, ${modseq}, ${pgTextArrayLiteral(flags)}::text[] + ) + `).then(() => + db.execute(sql` + UPDATE "mailbox"."mailbox_state" + SET "uid_next" = ${state.uidNext}, "highest_modseq" = ${state.highestModSeq} + WHERE "tenant_id" = ${tenantId} AND "principal_id" = ${principalId} AND "folder" = ${folder} + `), + ), + ); + return uid; + }, + + async readRaw(uid) { + await settled; + const rows2 = await db.execute<{ raw: Uint8Array }>(sql` + SELECT "raw" FROM "mailbox"."principal_mail" + WHERE "tenant_id" = ${tenantId} AND "principal_id" = ${principalId} + AND "folder" = ${folder} AND "uid" = ${uid} + `); + if (rows2[0] === undefined) { + throw new Error(`Message UID ${uid} not found`); + } + return new Uint8Array(rows2[0].raw); + }, + + find(uid) { + return byUid.get(uid); + }, + + addFlags(uid, flags) { + const msg = requireMessage(uid); + for (const flag of flags) msg.flags.add(flag); + const modseq = state.highestModSeq + 1; + state.highestModSeq = modseq; + msg.modseq = modseq; + const nextFlags = [...msg.flags]; + enqueue(() => + db.execute(sql` + UPDATE "mailbox"."principal_mail" + SET "flags" = ${pgTextArrayLiteral(nextFlags)}::text[], "modseq" = ${modseq} + WHERE "id" = ${msg.rowId} + `).then(() => + db.execute(sql` + UPDATE "mailbox"."mailbox_state" SET "highest_modseq" = ${modseq} + WHERE "tenant_id" = ${tenantId} AND "principal_id" = ${principalId} AND "folder" = ${folder} + `), + ), + ); + return msg; + }, + + removeFlags(uid, flags) { + const msg = requireMessage(uid); + for (const flag of flags) msg.flags.delete(flag); + const modseq = state.highestModSeq + 1; + state.highestModSeq = modseq; + msg.modseq = modseq; + const nextFlags = [...msg.flags]; + enqueue(() => + db.execute(sql` + UPDATE "mailbox"."principal_mail" + SET "flags" = ${pgTextArrayLiteral(nextFlags)}::text[], "modseq" = ${modseq} + WHERE "id" = ${msg.rowId} + `).then(() => + db.execute(sql` + UPDATE "mailbox"."mailbox_state" SET "highest_modseq" = ${modseq} + WHERE "tenant_id" = ${tenantId} AND "principal_id" = ${principalId} AND "folder" = ${folder} + `), + ), + ); + return msg; + }, + + remove(uid) { + const msg = requireMessage(uid); + const idx = messages.indexOf(msg); + messages.splice(idx, 1); + byUid.delete(uid); + enqueue(() => + db.execute(sql` + DELETE FROM "mailbox"."principal_mail" WHERE "id" = ${msg.rowId} + `), + ); + }, + }; + + return store; +} + +/** + * Move a message from one folder to another for the same (tenant, principal). + * Not part of the vendored `MailboxStore` interface — IMAP MOVE reassigns a + * fresh UID in the destination mailbox and bumps ITS counters, which needs + * both folders' `mailbox_state` rows, so this operates directly on the + * database rather than through two `NativeMailboxStore` instances (each of + * which only knows its own folder's counters). Returns the message's new uid + * in `toFolder`. Any already-open `NativeMailboxStore` for either folder must + * be reopened with `openNativeMailboxStore` to see the result. + */ +export async function moveNativeMailboxMessage( + db: MailboxDb, + scope: { tenantId: string; principalId: string }, + fromFolder: string, + uid: number, + toFolder: string, +): Promise { + const { tenantId, principalId } = scope; + const rows = await db.execute<{ id: string }>(sql` + SELECT "id" FROM "mailbox"."principal_mail" + WHERE "tenant_id" = ${tenantId} AND "principal_id" = ${principalId} + AND "folder" = ${fromFolder} AND "uid" = ${uid} + `); + const row = rows[0]; + if (row === undefined) { + throw new Error(`Message UID ${uid} not found in mailbox "${fromFolder}"`); + } + + await readState(db, tenantId, principalId, toFolder); + const bumped = await db.execute<{ + uid_next: string | number; + highest_modseq: string | number; + }>(sql` + UPDATE "mailbox"."mailbox_state" + SET "uid_next" = "uid_next" + 1, "highest_modseq" = "highest_modseq" + 1 + WHERE "tenant_id" = ${tenantId} AND "principal_id" = ${principalId} AND "folder" = ${toFolder} + RETURNING "uid_next" - 1 AS "uid_next", "highest_modseq" + `); + const newUid = Number(bumped[0]!.uid_next); + const newModseq = Number(bumped[0]!.highest_modseq); + + await db.execute(sql` + UPDATE "mailbox"."principal_mail" + SET "folder" = ${toFolder}, "uid" = ${newUid}, "modseq" = ${newModseq} + WHERE "id" = ${row.id} + `); + await db.execute(sql` + UPDATE "mailbox"."mailbox_state" + SET "highest_modseq" = "highest_modseq" + 1 + WHERE "tenant_id" = ${tenantId} AND "principal_id" = ${principalId} AND "folder" = ${fromFolder} + `); + return newUid; +} + +/** + * Entry point matching the task's shape: a factory scoped to one (tenant, + * principal) that opens per-folder `NativeMailboxStore`s and can move a + * message between them. + */ +export function createPrincipalMailboxStore( + db: MailboxDb, + scope: { tenantId: string; principalId: string }, +): { + open(folder: string): Promise; + move(fromFolder: string, uid: number, toFolder: string): Promise; +} { + return { + open: (folder) => openNativeMailboxStore(db, { ...scope, folder }), + move: (fromFolder, uid, toFolder) => + moveNativeMailboxMessage(db, scope, fromFolder, uid, toFolder), + }; +} From 172e08d0875ca90bfca7dfd1663b7fa07223e0cc Mon Sep 17 00:00:00 2001 From: Sawyer Date: Thu, 17 Sep 2026 12:48:48 -0700 Subject: [PATCH 03/17] Delete tests of the pre-native mailbox model --- src/concurrency.test.ts | 209 -------- src/enrichment.test.ts | 376 -------------- src/filter-sort-assign.test.ts | 335 ------------ src/fk-cascade.test.ts | 81 --- src/log-warnings.test.ts | 158 ------ src/message-schemas.test.ts | 169 ------ src/mount-bus-failure.test.ts | 72 --- src/mount-event-op.test.ts | 153 ------ src/mount.test.ts | 328 +----------- src/mutations.test.ts | 165 ------ src/openapi.test.ts | 136 ----- src/read-direction-and-date.test.ts | 189 ------- src/read-fallbacks.test.ts | 144 ------ src/read-microsecond-cursor.test.ts | 112 ---- src/read-multipart.test.ts | 103 ---- src/read-non-utc-session.test.ts | 157 ------ src/read-page-boundary.test.ts | 131 ----- src/read.test.ts | 427 ---------------- src/scope-validation.test.ts | 202 -------- src/sender-display.test.ts | 223 -------- src/thread.test.ts | 766 ---------------------------- src/vocabulary.test.ts | 200 -------- 22 files changed, 1 insertion(+), 4835 deletions(-) delete mode 100644 src/concurrency.test.ts delete mode 100644 src/enrichment.test.ts delete mode 100644 src/filter-sort-assign.test.ts delete mode 100644 src/fk-cascade.test.ts delete mode 100644 src/log-warnings.test.ts delete mode 100644 src/message-schemas.test.ts delete mode 100644 src/mount-bus-failure.test.ts delete mode 100644 src/mount-event-op.test.ts delete mode 100644 src/mutations.test.ts delete mode 100644 src/openapi.test.ts delete mode 100644 src/read-direction-and-date.test.ts delete mode 100644 src/read-fallbacks.test.ts delete mode 100644 src/read-microsecond-cursor.test.ts delete mode 100644 src/read-multipart.test.ts delete mode 100644 src/read-non-utc-session.test.ts delete mode 100644 src/read-page-boundary.test.ts delete mode 100644 src/read.test.ts delete mode 100644 src/scope-validation.test.ts delete mode 100644 src/sender-display.test.ts delete mode 100644 src/thread.test.ts delete mode 100644 src/vocabulary.test.ts diff --git a/src/concurrency.test.ts b/src/concurrency.test.ts deleted file mode 100644 index 86ed2fe..0000000 --- a/src/concurrency.test.ts +++ /dev/null @@ -1,209 +0,0 @@ -// Two properties that had only sequential/adjacent coverage: COALESCE idempotency under genuinely CONCURRENT writers, and -// multi-tab SSE fan-out (the existing suite covers unsubscribe isolation, -// which is a different property). -import { describe, expect, test } from "bun:test"; -import { Hono } from "hono"; -import { sql } from "drizzle-orm"; -import { mountMailbox } from "./mount.js"; -import { createInMemoryMailboxEventBus } from "./bus.js"; -import { writeMailboxMessage } from "./write.js"; -import { - markMailboxMessageRead, - trashMailboxMessage, - archiveMailboxMessage, -} from "./mutations.js"; -import { withTestDb, seedScope, TEST_VOCABULARY } from "./test-helpers.js"; -import type { MailboxDb } from "./db.js"; - -const SCOPE = { tenantId: "t1", principalId: "p1" }; - -async function seed(db: MailboxDb, messageKey: string): Promise { - await seedScope(db, SCOPE.tenantId, SCOPE.principalId); - const written = await writeMailboxMessage(db, { - ...SCOPE, - address: "p1@t1.example", - fromAddress: "a@t1.example", - subject: "s", - body: "b", - messageKey, - }); - return written!.id; -} - -async function timestamps( - db: MailboxDb, - id: string, -): Promise<{ - read: string | null; - trashed: string | null; - archived: string | null; -}> { - const [row] = await db.execute<{ - read_at: string | null; - trashed_at: string | null; - archived_at: string | null; - }>( - sql`SELECT read_at, trashed_at, archived_at FROM "mailbox"."mailbox" WHERE id = ${id}`, - ); - return { - read: row!.read_at, - trashed: row!.trashed_at, - archived: row!.archived_at, - }; -} - -describe("COALESCE idempotency under concurrent writers", () => { - test("eight concurrent read-marks settle on ONE readAt", async () => { - const db = await withTestDb(); - const id = await seed(db, "concurrent-read"); - - const results = await Promise.all( - Array.from({ length: 8 }, () => - markMailboxMessageRead(db, { ...SCOPE, id }), - ), - ); - // Every writer matched the row — the guard is scope, not prior state. - expect(results).toEqual(Array.from({ length: 8 }, () => true)); - - const first = await timestamps(db, id); - expect(first.read).not.toBeNull(); - - // A later wave must not move the timestamp the first wave established. - await Promise.all( - Array.from({ length: 8 }, () => - markMailboxMessageRead(db, { ...SCOPE, id }), - ), - ); - expect((await timestamps(db, id)).read).toBe(first.read!); - }); - - test("concurrent trash-marks settle on one trashedAt with archived cleared", async () => { - const db = await withTestDb(); - const id = await seed(db, "concurrent-trash"); - await archiveMailboxMessage(db, { ...SCOPE, id }); - - await Promise.all( - Array.from({ length: 6 }, () => - trashMailboxMessage(db, { ...SCOPE, id }), - ), - ); - const after = await timestamps(db, id); - expect(after.trashed).not.toBeNull(); - // Trash wins: archived is cleared, and stays cleared. - expect(after.archived).toBeNull(); - - await Promise.all( - Array.from({ length: 6 }, () => - trashMailboxMessage(db, { ...SCOPE, id }), - ), - ); - expect((await timestamps(db, id)).trashed).toBe(after.trashed!); - }); - - test("archive is refused for an already-trashed row, concurrently too", async () => { - const db = await withTestDb(); - const id = await seed(db, "concurrent-archive"); - await trashMailboxMessage(db, { ...SCOPE, id }); - - const results = await Promise.all( - Array.from({ length: 6 }, () => - archiveMailboxMessage(db, { ...SCOPE, id }), - ), - ); - expect(results.some((ok) => ok)).toBe(false); - expect((await timestamps(db, id)).archived).toBeNull(); - }); -}); - -describe("SSE multi-tab fan-out", () => { - test("three open streams for one principalId each receive every event", async () => { - const db = await withTestDb(); - await seedScope(db, SCOPE.tenantId, SCOPE.principalId); - const bus = createInMemoryMailboxEventBus(); - const app = mountMailbox(new Hono(), { - vocabulary: TEST_VOCABULARY, - db, - bus, - resolvePrincipal: () => SCOPE, - }); - - // Three tabs, i.e. three independent HTTP requests for the same principal. - const responses = await Promise.all([ - app.request("/me/inbox/events"), - app.request("/me/inbox/events"), - app.request("/me/inbox/events"), - ]); - for (const res of responses) expect(res.status).toBe(200); - const readers = responses.map((res) => res.body!.getReader()); - // Let every handler register its subscription before publishing. - await new Promise((r) => setTimeout(r, 100)); - - const written = await writeMailboxMessage( - db, - { - ...SCOPE, - address: "p1@t1.example", - fromAddress: "a@t1.example", - subject: "multi", - body: "body", - messageKey: "multi-tab", - }, - bus, - ); - - const chunks = await Promise.all( - readers.map((reader) => - Promise.race([ - reader.read().then((r) => new TextDecoder().decode(r.value)), - new Promise((r) => setTimeout(() => r("__TIMEOUT__"), 3000)), - ]), - ), - ); - await Promise.all(readers.map((reader) => reader.cancel())); - - for (const chunk of chunks) { - expect(chunk).not.toBe("__TIMEOUT__"); - expect(chunk).toContain("event: mailbox"); - expect(chunk).toContain(written!.id); - } - }); - - test("closing one tab leaves the other tabs streaming", async () => { - const db = await withTestDb(); - await seedScope(db, SCOPE.tenantId, SCOPE.principalId); - const bus = createInMemoryMailboxEventBus(); - const app = mountMailbox(new Hono(), { - vocabulary: TEST_VOCABULARY, - db, - bus, - resolvePrincipal: () => SCOPE, - }); - - const closing = await app.request("/me/inbox/events"); - const surviving = await app.request("/me/inbox/events"); - const closingReader = closing.body!.getReader(); - const survivingReader = surviving.body!.getReader(); - await new Promise((r) => setTimeout(r, 100)); - await closingReader.cancel(); - await new Promise((r) => setTimeout(r, 100)); - - const written = await writeMailboxMessage( - db, - { - ...SCOPE, - address: "p1@t1.example", - fromAddress: "a@t1.example", - subject: "still here", - body: "body", - messageKey: "survivor", - }, - bus, - ); - const chunk = await Promise.race([ - survivingReader.read().then((r) => new TextDecoder().decode(r.value)), - new Promise((r) => setTimeout(() => r("__TIMEOUT__"), 3000)), - ]); - await survivingReader.cancel(); - expect(chunk).toContain(written!.id); - }); -}); diff --git a/src/enrichment.test.ts b/src/enrichment.test.ts deleted file mode 100644 index 547bc4b..0000000 --- a/src/enrichment.test.ts +++ /dev/null @@ -1,376 +0,0 @@ -// Mail is the single work surface, so triage *enriches* an existing mail row -// rather than spawning a task. Without an enrichment path, -// priority/classification/status could only be set at the initial insert and -// triage would have no way to stamp anything. -import { beforeEach, describe, expect, test } from "bun:test"; -import { Hono } from "hono"; -import { type } from "arktype"; -import { eq } from "drizzle-orm"; -import { - enrichMailboxMessage, - MailboxEnrichmentSchema, -} from "./mutations.js"; -import { mountMailbox } from "./mount.js"; -import { createInMemoryMailboxEventBus } from "./bus.js"; -import { deliverInboxItems, writeMailboxMessage } from "./write.js"; -import { mailbox, principalMail } from "./schema.js"; -import { getMailboxMessage } from "./read.js"; -import { withTestDb, seedScope, TEST_VOCABULARY } from "./test-helpers.js"; -import type { MailboxDb } from "./db.js"; - -let db: MailboxDb; -const SCOPE = { tenantId: "acme", principalId: "user-1" }; - -beforeEach(async () => { - db = await withTestDb(); - await seedScope(db, "acme", "user-1", "user-2"); - await seedScope(db, "other", "user-1"); -}); - -async function seed( - over: Partial[1]> = {}, -): Promise { - const written = await writeMailboxMessage(db, { - ...SCOPE, - address: "user-1@acme.example", - fromAddress: "bot@acme.example", - subject: "Untriaged", - body: "Body", - messageKey: crypto.randomUUID(), - ...over, - }); - return written!.id; -} - -/** - * The message's triage state as the read path sees it: through the LEFT JOIN, - * so the assertions below observe exactly what a reader would. - */ -async function storedRow(id: string) { - const [row] = await db - .select({ - priority: mailbox.priority, - classification: mailbox.classification, - status: mailbox.status, - }) - .from(principalMail) - .leftJoin(mailbox, eq(mailbox.id, principalMail.id)) - .where(eq(principalMail.id, id)); - return row!; -} - -/** Whether a `mailbox` row exists at all — eager creation, observed directly. */ -async function hasMailboxRow(id: string): Promise { - const rows = await db - .select({ id: mailbox.id }) - .from(mailbox) - .where(eq(mailbox.id, id)); - return rows.length > 0; -} - -describe("enrichMailboxMessage", () => { - test("updates the management row delivery created eagerly", async () => { - const id = await seed(); - expect(await hasMailboxRow(id)).toBe(true); - expect( - await enrichMailboxMessage(db, { ...SCOPE, id }, { status: "done" }), - ).toBe(true); - expect((await storedRow(id)).status).toBe("done"); - }); - - test("stamps all three fields onto an untriaged row", async () => { - const id = await seed(); - expect( - await enrichMailboxMessage( - db, - { ...SCOPE, id }, - { priority: "urgent", classification: "deal-risk", status: "done" }, - ), - ).toBe(true); - - const row = await storedRow(id); - expect(row.priority).toBe("urgent"); - expect(row.classification).toBe("deal-risk"); - expect(row.status).toBe("done"); - }); - - test("an omitted field is left alone, not wiped", async () => { - const id = await seed({ - priority: "high", - classification: "deal-risk", - status: "needs-action", - }); - await enrichMailboxMessage(db, { ...SCOPE, id }, { status: "done" }); - - const row = await storedRow(id); - expect(row.status).toBe("done"); - // The point of the whole partial-update design: re-triaging one facet must - // not silently discard the other two. - expect(row.priority).toBe("high"); - expect(row.classification).toBe("deal-risk"); - }); - - test("an explicit null clears the field", async () => { - const id = await seed({ priority: "high", classification: "deal-risk" }); - await enrichMailboxMessage(db, { ...SCOPE, id }, { priority: null }); - - const row = await storedRow(id); - expect(row.priority).toBeNull(); - expect(row.classification).toBe("deal-risk"); - }); - - test("refuses an enrichment that sets nothing", async () => { - const id = await seed(); - expect(enrichMailboxMessage(db, { ...SCOPE, id }, {})).rejects.toThrow( - RangeError, - ); - }); - - test("is scoped to the principalId: another principalId's row is untouched", async () => { - const id = await seed(); - expect( - await enrichMailboxMessage( - db, - { tenantId: "acme", principalId: "user-2", id }, - { priority: "urgent" }, - ), - ).toBe(false); - expect((await storedRow(id)).priority).toBeNull(); - }); - - test("is scoped to the tenantId: another tenantId's row is untouched", async () => { - const id = await seed(); - expect( - await enrichMailboxMessage( - db, - { tenantId: "other", principalId: "user-1", id }, - { priority: "urgent" }, - ), - ).toBe(false); - expect((await storedRow(id)).priority).toBeNull(); - }); - - test("returns false for an id that does not exist", async () => { - expect( - await enrichMailboxMessage( - db, - { ...SCOPE, id: crypto.randomUUID() }, - { status: "done" }, - ), - ).toBe(false); - }); - - test("the stamped values are projected on read", async () => { - const id = await seed(); - await enrichMailboxMessage( - db, - { ...SCOPE, id }, - { priority: "low", classification: "fyi", status: "needs-action" }, - ); - const detail = await getMailboxMessage(db, { ...SCOPE, id }); - expect(detail?.priority).toBe("low"); - expect(detail?.classification).toBe("fyi"); - expect(detail?.status).toBe("needs-action"); - }); -}); - -describe("MailboxEnrichmentSchema", () => { - test("accepts any string priority or status, carrying no vocabulary itself", () => { - for (const priority of ["urgent", "p0", "catastrophic", "whatever"]) { - expect(MailboxEnrichmentSchema({ priority }) instanceof type.errors).toBe( - false, - ); - } - for (const status of ["needs-action", "in-progress", "wontfix"]) { - expect(MailboxEnrichmentSchema({ status }) instanceof type.errors).toBe( - false, - ); - } - }); - - test("still rejects a non-string, non-null priority or status", () => { - expect( - MailboxEnrichmentSchema({ priority: 3 }) instanceof type.errors, - ).toBe(true); - expect( - MailboxEnrichmentSchema({ status: ["done"] }) instanceof type.errors, - ).toBe(true); - }); - - test("accepts an explicit null, which is how a field is cleared", () => { - expect( - MailboxEnrichmentSchema({ priority: null, status: null }) instanceof - type.errors, - ).toBe(false); - }); -}); - -describe("deliverInboxItems stamping", () => { - test("an adapter's triage verdict is stored at delivery", async () => { - const [delivered] = await deliverInboxItems(db, [ - { - ...SCOPE, - address: "user-1@acme.example", - fromAddress: "sales@partner.example", - subject: "Renewal at risk", - body: "Body", - source: "gmail", - externalId: "msg-1", - priority: "urgent", - classification: "deal-risk", - status: "needs-action", - }, - ]); - - const row = await storedRow(delivered!.id!); - expect(row.priority).toBe("urgent"); - expect(row.classification).toBe("deal-risk"); - expect(row.status).toBe("needs-action"); - }); - - test("an item with no verdict delivers unstamped", async () => { - const [delivered] = await deliverInboxItems(db, [ - { - ...SCOPE, - address: "user-1@acme.example", - fromAddress: "sales@partner.example", - subject: "Just mail", - body: "Body", - source: "gmail", - externalId: "msg-2", - }, - ]); - - const row = await storedRow(delivered!.id!); - expect(row.priority).toBeNull(); - expect(row.classification).toBeNull(); - expect(row.status).toBeNull(); - // The management row is created with the message either way; unstamped - // means all-NULL triage columns, not an absent row. - expect(await hasMailboxRow(delivered!.id!)).toBe(true); - }); -}); - -describe("POST /me/inbox/:id/enrich", () => { - function app(resolvePrincipal: () => typeof SCOPE | null = () => SCOPE) { - const hono = new Hono(); - mountMailbox(hono, { - vocabulary: TEST_VOCABULARY, - db, - bus: createInMemoryMailboxEventBus(), - resolvePrincipal, - }); - return hono; - } - - const post = (hono: Hono, id: string, body: unknown) => - hono.request(`/me/inbox/${id}/enrich`, { - method: "POST", - headers: { "content-type": "application/json" }, - body: JSON.stringify(body), - }); - - test("applies the enrichment and reports the id", async () => { - const id = await seed(); - const res = await post(app(), id, { priority: "high", status: "done" }); - expect(res.status).toBe(200); - expect(await res.json()).toEqual({ id, ok: true }); - - const row = await storedRow(id); - expect(row.priority).toBe("high"); - expect(row.status).toBe("done"); - }); - - test("publishes a mailbox event for the enriched row", async () => { - const id = await seed(); - const bus = createInMemoryMailboxEventBus(); - const seen: string[] = []; - bus.subscribe(SCOPE, (event) => seen.push(event.id)); - - const hono = new Hono(); - mountMailbox(hono, { - db, - bus, - resolvePrincipal: () => SCOPE, - vocabulary: TEST_VOCABULARY, - }); - await post(hono, id, { status: "done" }); - - expect(seen).toEqual([id]); - }); - - test("403 with no resolvable principalId, and nothing is written", async () => { - const id = await seed(); - const res = await post( - app(() => null), - id, - { priority: "high" }, - ); - expect(res.status).toBe(403); - expect((await storedRow(id)).priority).toBeNull(); - }); - - test("404 when the message belongs to another principalId", async () => { - const id = await seed(); - const other = app(() => ({ tenantId: "acme", principalId: "user-2" })); - expect((await post(other, id, { priority: "high" })).status).toBe(404); - }); - - test("400 on a non-UUID id", async () => { - expect((await post(app(), "not-a-uuid", { status: "done" })).status).toBe( - 400, - ); - }); - - test("400 on a priority outside the host's vocabulary", async () => { - const id = await seed(); - const res = await post(app(), id, { priority: "catastrophic" }); - expect(res.status).toBe(400); - expect(await res.json()).toEqual({ error: "unknown priority" }); - // Refused before anything was written: the management row from delivery - // stays untriaged. - expect((await storedRow(id)).priority).toBeNull(); - }); - - test("400 on a status outside the host's vocabulary", async () => { - const id = await seed(); - const res = await post(app(), id, { status: "in-progress" }); - expect(res.status).toBe(400); - expect(await res.json()).toEqual({ error: "unknown status" }); - expect((await storedRow(id)).status).toBeNull(); - }); - - test("accepts a value only this host's vocabulary contains", async () => { - const id = await seed(); - const hono = new Hono(); - mountMailbox(hono, { - db, - bus: createInMemoryMailboxEventBus(), - resolvePrincipal: () => SCOPE, - vocabulary: { priorities: ["p0", "p1"], statuses: ["open", "shipped"] }, - }); - const res = await post(hono, id, { priority: "p0", status: "shipped" }); - expect(res.status).toBe(200); - const row = await storedRow(id); - expect(row.priority).toBe("p0"); - expect(row.status).toBe("shipped"); - // And the vocabulary this suite mounts everywhere else is now the one - // refused, which is the whole point of the taxonomy being the host's. - expect((await post(hono, id, { priority: "urgent" })).status).toBe(400); - }); - - test("400 on an enrichment that sets nothing", async () => { - const id = await seed(); - expect((await post(app(), id, {})).status).toBe(400); - }); - - test("400 on a body that is not JSON", async () => { - const id = await seed(); - const res = await app().request(`/me/inbox/${id}/enrich`, { - method: "POST", - headers: { "content-type": "application/json" }, - body: "{not json", - }); - expect(res.status).toBe(400); - }); -}); diff --git a/src/filter-sort-assign.test.ts b/src/filter-sort-assign.test.ts deleted file mode 100644 index 6f69d40..0000000 --- a/src/filter-sort-assign.test.ts +++ /dev/null @@ -1,335 +0,0 @@ -// The read side of enrichment: selectively querying back the enrichment -// columns, and the delegation ref. Also pins the empty-`ids` bulk contract, which shipped -// with no test either way. -import { beforeEach, describe, expect, test } from "bun:test"; -import { Hono } from "hono"; -import { mountMailbox } from "./mount.js"; -import { createInMemoryMailboxEventBus } from "./bus.js"; -import { writeMailboxMessage } from "./write.js"; -import { listUserMailbox } from "./read.js"; -import { - applyMailboxBulkAction, - assignMailboxMessage, - enrichMailboxMessage, -} from "./mutations.js"; -import { decodeMailboxListCursor } from "./read.js"; -import { withTestDb, seedScope, TEST_VOCABULARY } from "./test-helpers.js"; -import type { MailboxDb } from "./db.js"; - -let db: MailboxDb; -const SCOPE = { tenantId: "acme", principalId: "user-1" }; - -beforeEach(async () => { - db = await withTestDb(); - await seedScope(db, "acme", "user-1", "user-9"); -}); - -function buildApp() { - const app = new Hono(); - mountMailbox(app, { - vocabulary: TEST_VOCABULARY, - db, - bus: createInMemoryMailboxEventBus(), - resolvePrincipal: () => SCOPE, - }); - return app; -} - -async function write(subject: string): Promise { - const written = await writeMailboxMessage(db, { - ...SCOPE, - address: "user-1@acme.example", - fromAddress: "bot@acme.example", - subject, - body: `body of ${subject}`, - messageKey: crypto.randomUUID(), - }); - return written!.id; -} - -/** Three messages, each stamped with a different priority + classification. */ -async function seedEnriched(): Promise> { - const ids: Record = {}; - for (const [subject, priority, classification] of [ - ["low one", "low", "newsletter"], - ["high one", "high", "customer"], - ["urgent one", "urgent", "customer"], - ] as const) { - const id = await write(subject); - await enrichMailboxMessage( - db, - { ...SCOPE, id }, - { - priority, - classification, - status: "needs-action", - }, - ); - ids[subject] = id; - } - return ids; -} - -describe("list filtering by enrichment", () => { - test("priority narrows the page to matching messages", async () => { - await seedEnriched(); - const page = await listUserMailbox(db, { - priorities: TEST_VOCABULARY.priorities, - ...SCOPE, - limit: 10, - view: "all", - filter: { priority: "high" }, - }); - expect(page.items.map((m) => m.subject)).toEqual(["high one"]); - }); - - test("classification and status combine as an AND", async () => { - const ids = await seedEnriched(); - await enrichMailboxMessage( - db, - { ...SCOPE, id: ids["high one"]! }, - { - status: "done", - }, - ); - const page = await listUserMailbox(db, { - priorities: TEST_VOCABULARY.priorities, - ...SCOPE, - limit: 10, - view: "all", - filter: { classification: "customer", status: "needs-action" }, - }); - expect(page.items.map((m) => m.subject)).toEqual(["urgent one"]); - }); - - test("the route exposes the same filter over the query string", async () => { - await seedEnriched(); - const res = await buildApp().request("/me/inbox?priority=urgent"); - expect(res.status).toBe(200); - const body = (await res.json()) as { messages: { subject: string }[] }; - expect(body.messages.map((m) => m.subject)).toEqual(["urgent one"]); - }); - - test("an unknown priority or status is a 400, not a silently empty page", async () => { - const app = buildApp(); - expect((await app.request("/me/inbox?priority=critical")).status).toBe(400); - expect((await app.request("/me/inbox?status=maybe")).status).toBe(400); - }); -}); - -describe("list sorting by priority", () => { - test("sort=priority orders urgent first and un-triaged mail last", async () => { - await seedEnriched(); - await write("untriaged"); - const page = await listUserMailbox(db, { - priorities: TEST_VOCABULARY.priorities, - ...SCOPE, - limit: 10, - view: "all", - sort: "priority", - }); - expect(page.items.map((m) => m.subject)).toEqual([ - "urgent one", - "high one", - "low one", - "untriaged", - ]); - }); - - test("the default sort is still newest-first by date", async () => { - await seedEnriched(); - // Written last and never triaged, so it leads by date and trails by - // priority — which is what tells the two orderings apart. - await write("untriaged"); - const expected = ["untriaged", "urgent one", "high one", "low one"]; - const page = await listUserMailbox(db, { - ...SCOPE, - limit: 10, - view: "all", - priorities: TEST_VOCABULARY.priorities, - }); - expect(page.items.map((m) => m.subject)).toEqual(expected); - const byDate = await buildApp().request("/me/inbox"); - const body = (await byDate.json()) as { messages: { subject: string }[] }; - expect(body.messages.map((m) => m.subject)).toEqual(expected); - }); - - test("a priority-sorted page seeks past its cursor without repeating a row", async () => { - await seedEnriched(); - await write("untriaged"); - const first = await listUserMailbox(db, { - priorities: TEST_VOCABULARY.priorities, - ...SCOPE, - limit: 2, - view: "all", - sort: "priority", - }); - expect(first.items.map((m) => m.subject)).toEqual([ - "urgent one", - "high one", - ]); - const cursor = decodeMailboxListCursor(first.nextCursor!); - expect(cursor?.sort).toBe("priority"); - expect(cursor?.rank).toBe(1); - const second = await listUserMailbox(db, { - priorities: TEST_VOCABULARY.priorities, - ...SCOPE, - limit: 2, - view: "all", - sort: "priority", - cursor: cursor!, - }); - expect(second.items.map((m) => m.subject)).toEqual([ - "low one", - "untriaged", - ]); - expect(second.nextCursor).toBeUndefined(); - }); - - test("an invalid sort is a 400", async () => { - expect((await buildApp().request("/me/inbox?sort=random")).status).toBe( - 400, - ); - }); -}); - -describe("cursors are bound to the result set they were minted from", () => { - test("a cursor from a different sort is refused", async () => { - await seedEnriched(); - const app = buildApp(); - const res = await app.request("/me/inbox?sort=priority&limit=1"); - const { nextCursor } = (await res.json()) as { nextCursor: string }; - const reused = await app.request( - `/me/inbox?limit=1&cursor=${encodeURIComponent(nextCursor)}`, - ); - expect(reused.status).toBe(400); - expect(await reused.json()).toEqual({ - error: "cursor does not match inbox sort", - }); - }); - - test("a cursor from a different filter is refused", async () => { - await seedEnriched(); - const app = buildApp(); - const res = await app.request("/me/inbox?classification=customer&limit=1"); - const { nextCursor } = (await res.json()) as { nextCursor: string }; - const reused = await app.request( - `/me/inbox?limit=1&cursor=${encodeURIComponent(nextCursor)}`, - ); - expect(reused.status).toBe(400); - expect(await reused.json()).toEqual({ - error: "cursor does not match inbox filter", - }); - }); - - test("the same filter and sort page through fine", async () => { - await seedEnriched(); - const app = buildApp(); - const res = await app.request("/me/inbox?classification=customer&limit=1"); - const { nextCursor } = (await res.json()) as { nextCursor: string }; - const next = await app.request( - `/me/inbox?classification=customer&limit=1&cursor=${encodeURIComponent(nextCursor)}`, - ); - expect(next.status).toBe(200); - const body = (await next.json()) as { messages: { subject: string }[] }; - expect(body.messages.map((m) => m.subject)).toEqual(["high one"]); - }); -}); - -describe("delegation via the assignee ref", () => { - test("assign stamps the assignee onto the row and the projection", async () => { - const id = await write("delegate me"); - const res = await buildApp().request(`/me/inbox/${id}/assign`, { - method: "POST", - headers: { "content-type": "application/json" }, - body: JSON.stringify({ assignee: "user-2" }), - }); - expect(res.status).toBe(200); - const detail = await buildApp().request(`/me/inbox/${id}`); - expect((await detail.json()).assignee).toBe("user-2"); - }); - - test("listing by assignee shows what this principalId delegated to whom", async () => { - const delegated = await write("delegated"); - await write("kept"); - await assignMailboxMessage(db, { ...SCOPE, id: delegated }, "user-2"); - const page = await listUserMailbox(db, { - priorities: TEST_VOCABULARY.priorities, - ...SCOPE, - limit: 10, - view: "all", - filter: { assignee: "user-2" }, - }); - expect(page.items.map((m) => m.subject)).toEqual(["delegated"]); - }); - - test("assigning null un-delegates and drops the field from the projection", async () => { - const id = await write("delegate me"); - await assignMailboxMessage(db, { ...SCOPE, id }, "user-2"); - const res = await buildApp().request(`/me/inbox/${id}/assign`, { - method: "POST", - headers: { "content-type": "application/json" }, - body: JSON.stringify({ assignee: null }), - }); - expect(res.status).toBe(200); - const detail = await buildApp().request(`/me/inbox/${id}`); - expect(await detail.json()).not.toHaveProperty("assignee"); - }); - - test("assigning a message in another principalId's mailbox is a 404", async () => { - const foreign = await writeMailboxMessage(db, { - tenantId: "acme", - principalId: "user-9", - address: "user-9@acme.example", - fromAddress: "bot@acme.example", - subject: "not yours", - body: "b", - messageKey: crypto.randomUUID(), - }); - const res = await buildApp().request(`/me/inbox/${foreign!.id}/assign`, { - method: "POST", - headers: { "content-type": "application/json" }, - body: JSON.stringify({ assignee: "user-2" }), - }); - expect(res.status).toBe(404); - }); - - test("a body without an assignee key is a 400", async () => { - const id = await write("delegate me"); - const res = await buildApp().request(`/me/inbox/${id}/assign`, { - method: "POST", - headers: { "content-type": "application/json" }, - body: JSON.stringify({}), - }); - expect(res.status).toBe(400); - }); -}); - -describe("bulk with an empty id list", () => { - // Deliberately NOT an error. `ids: []` is the partial-success contract - // applied to zero ids — the honest answer to "apply this action to nothing" - // is that nothing was updated. A client that clears its selection and fires - // anyway should get a no-op, not a 400 it has to special-case. Pinned here - // because it had no test in either direction. - test("the library returns no results and touches nothing", async () => { - const id = await write("untouched"); - expect(await applyMailboxBulkAction(db, SCOPE, "trash", [])).toEqual([]); - const page = await listUserMailbox(db, { - ...SCOPE, - limit: 10, - view: "all", - priorities: TEST_VOCABULARY.priorities, - }); - expect(page.items.map((m) => m.id)).toEqual([id]); - }); - - test("the route answers 200 with updated: 0", async () => { - const res = await buildApp().request("/me/inbox/bulk", { - method: "POST", - headers: { "content-type": "application/json" }, - body: JSON.stringify({ action: "trash", ids: [] }), - }); - expect(res.status).toBe(200); - expect(await res.json()).toEqual({ updated: 0, results: [] }); - }); -}); diff --git a/src/fk-cascade.test.ts b/src/fk-cascade.test.ts deleted file mode 100644 index cb393f7..0000000 --- a/src/fk-cascade.test.ts +++ /dev/null @@ -1,81 +0,0 @@ -// The control-plane FKs carry ON DELETE CASCADE: offboarding a tenant or a -// principal in the host's control plane carries the mailbox rows out with it — -// a database behavior, not a purge function someone has to remember to call. -import { beforeEach, describe, expect, test } from "bun:test"; -import { eq, sql } from "drizzle-orm"; -import { writeMailboxMessage } from "./write.js"; -import { mailbox, principalMail } from "./schema.js"; -import { withTestDb, seedScope } from "./test-helpers.js"; -import type { MailboxDb } from "./db.js"; - -let db: MailboxDb; - -beforeEach(async () => { - db = await withTestDb(); - await seedScope(db, "acme", "user-1", "user-2"); - await seedScope(db, "globex", "user-3"); -}); - -async function seed(tenantId: string, principalId: string): Promise { - const written = await writeMailboxMessage(db, { - tenantId, - principalId, - address: `${principalId}@${tenantId}.example`, - fromAddress: `agent@${tenantId}.example`, - subject: "s", - body: "b", - // Triage materializes the `mailbox` row, so the cascade is asserted - // through BOTH tables. - priority: "high", - }); - return written!.id; -} - -async function mailCountFor(tenantId: string): Promise { - const rows = await db - .select() - .from(principalMail) - .where(eq(principalMail.tenantId, tenantId)); - return rows.length; -} - -async function mailboxCountFor(tenantId: string): Promise { - const rows = await db - .select() - .from(mailbox) - .where(eq(mailbox.tenantId, tenantId)); - return rows.length; -} - -describe("control-plane ON DELETE CASCADE", () => { - test("deleting a tenant removes its mail and triage rows, nobody else's", async () => { - await seed("acme", "user-1"); - await seed("acme", "user-2"); - await seed("globex", "user-3"); - - await db.execute(sql`DELETE FROM "tenant" WHERE "id" = 'acme'`); - - expect(await mailCountFor("acme")).toBe(0); - expect(await mailboxCountFor("acme")).toBe(0); - expect(await mailCountFor("globex")).toBe(1); - expect(await mailboxCountFor("globex")).toBe(1); - }); - - test("deleting a principal removes its mail and triage rows, nobody else's", async () => { - await seed("acme", "user-1"); - await seed("acme", "user-2"); - - await db.execute(sql`DELETE FROM "principal" WHERE "id" = 'user-1'`); - - const rows = await db - .select({ principalId: principalMail.principalId }) - .from(principalMail) - .where(eq(principalMail.tenantId, "acme")); - expect(rows.map((r) => r.principalId)).toEqual(["user-2"]); - const triage = await db - .select({ principalId: mailbox.principalId }) - .from(mailbox) - .where(eq(mailbox.tenantId, "acme")); - expect(triage.map((r) => r.principalId)).toEqual(["user-2"]); - }); -}); diff --git a/src/log-warnings.test.ts b/src/log-warnings.test.ts deleted file mode 100644 index 7657520..0000000 --- a/src/log-warnings.test.ts +++ /dev/null @@ -1,158 +0,0 @@ -// The diagnostics this package emits are part of its contract — the ref cap -// and the validate-on-read drop both silently discard caller data, so the warn -// that says so is the only trace. Nothing asserted them; this installs a memory -// sink and does. -// -// It also pins the aggregation fix: one bad backfill used to emit a warn line -// PER BAD ROW PER PAGE PER REQUEST. -import { afterAll, beforeAll, beforeEach, expect, test } from "bun:test"; -import { configureSync, getConfig, getLogger } from "@intx/log"; -import { sql } from "drizzle-orm"; -import { writeMailboxMessage, MAX_MAILBOX_REFS } from "./write.js"; -import { createMailboxPersist } from "./persist.js"; -import { listUserMailbox } from "./read.js"; -import { withTestDb, seedScope, TEST_VOCABULARY } from "./test-helpers.js"; -import type { MailboxDb } from "./db.js"; - -type Captured = { category: readonly string[]; level: string; props: unknown }; - -const captured: Captured[] = []; -let previous: ReturnType; - -beforeAll(() => { - previous = getConfig(); - configureSync({ - reset: true, - sinks: { - memory: (record) => { - captured.push({ - category: record.category, - level: record.level, - props: record.properties, - }); - }, - }, - loggers: [{ category: [], lowestLevel: "debug", sinks: ["memory"] }], - }); - // Touching a logger here proves the sink is wired before any assertion runs. - getLogger(["corbits-mailbox"]).debug("memory sink installed"); -}); - -afterAll(() => { - if (previous !== null) { - configureSync({ - reset: true, - sinks: previous.sinks, - loggers: previous.loggers, - }); - } -}); - -let db: MailboxDb; -beforeEach(async () => { - db = await withTestDb(); - await seedScope(db, "t1", "p1"); - captured.length = 0; -}); - -function warnings(module: string): Captured[] { - return captured.filter( - (r) => r.level === "warning" && r.category[1] === module, - ); -} - -test("refs past the cap are truncated AND the truncation is warned", async () => { - const refs = Array.from({ length: MAX_MAILBOX_REFS + 7 }, (_, i) => ({ - kind: "run", - id: `r${i}`, - })); - await writeMailboxMessage(db, { - tenantId: "t1", - principalId: "p1", - address: "p1@t1.example", - fromAddress: "a@t1.example", - subject: "capped", - body: "body", - messageKey: "cap-warn", - refs, - }); - - const warns = warnings("write"); - expect(warns.length).toBe(1); - expect(warns[0]!.props).toMatchObject({ - messageKey: "cap-warn", - received: MAX_MAILBOX_REFS + 7, - kept: MAX_MAILBOX_REFS, - }); - - // The warn is not cosmetic: only the cap was persisted. - const page = await listUserMailbox(db, { - priorities: TEST_VOCABULARY.priorities, - tenantId: "t1", - principalId: "p1", - limit: 10, - view: "all", - }); - expect(page.items[0]!.refs!.length).toBe(MAX_MAILBOX_REFS); -}); - -test("delivery to an unknown principal is skipped AND the skip is warned", async () => { - const persist = createMailboxPersist(db, { - upstream: async () => undefined, - authorizeSender: () => ({ tenantId: "t1", domain: "t1.example" }), - }); - await persist({ - senderAddress: "ins_dep@t1.example", - recipients: ["usr_p1@t1.example", "usr_ghost@t1.example"], - raw: Buffer.from("From: a@b.c\r\n\r\nbody", "utf8"), - }); - - const warns = warnings("persist"); - expect(warns.length).toBe(1); - expect(warns[0]!.props).toMatchObject({ - tenantId: "t1", - addresses: ["usr_ghost@t1.example"], - }); - - // The warn is the only trace of the skip; the known recipient still got its - // copy. - const page = await listUserMailbox(db, { - priorities: TEST_VOCABULARY.priorities, - tenantId: "t1", - principalId: "p1", - limit: 10, - view: "all", - }); - expect(page.items.length).toBe(1); -}); - -test("many bad refs rows in one page produce ONE aggregated warn", async () => { - const BAD_ROWS = 12; - for (let i = 0; i < BAD_ROWS; i++) { - await db.execute(sql` - INSERT INTO "mailbox"."principal_mail" - ("tenant_id","principal_id","address","direction","raw","subject","refs") - VALUES ('t1','p1','p1@t1.example','inbound', - ${Buffer.from("From: a@b.c\r\n\r\nbody", "utf8")}, - ${`bad-${i}`}, '[{"nope":true}]'::jsonb)`); - } - captured.length = 0; - - const page = await listUserMailbox(db, { - priorities: TEST_VOCABULARY.priorities, - tenantId: "t1", - principalId: "p1", - limit: 100, - view: "all", - }); - expect(page.items.length).toBe(BAD_ROWS); - for (const item of page.items) expect(item.refs).toBeUndefined(); - - const warns = warnings("read"); - expect(warns.length).toBe(1); - expect(warns[0]!.props).toMatchObject({ rows: BAD_ROWS }); - // Bounded sample, so the log line cannot grow with the size of the backfill. - expect( - (warns[0]!.props as { sampleRowIds: string[] }).sampleRowIds.length, - ).toBe(5); -}); diff --git a/src/message-schemas.test.ts b/src/message-schemas.test.ts deleted file mode 100644 index 66ae072..0000000 --- a/src/message-schemas.test.ts +++ /dev/null @@ -1,169 +0,0 @@ -// The exported message schemas, alongside MailboxRef. These are -// runtime arktype schemas, not type aliases, so a consumer decoding this -// package's JSON off the wire has something to validate with. This suite proves -// they accept what the read path actually emits and reject what it never would. -import { beforeEach, describe, expect, test } from "bun:test"; -import { type } from "arktype"; -import { - getMailboxMessage, - listUserMailbox, - MailboxMessageDetailSchema, - MailboxMessageSchema, - MailboxListResponseSchema, -} from "./read.js"; -import { writeMailboxMessage } from "./write.js"; -import { Hono } from "hono"; -import { mountMailbox } from "./mount.js"; -import { createInMemoryMailboxEventBus } from "./bus.js"; -import { withTestDb, seedScope, TEST_VOCABULARY } from "./test-helpers.js"; -import type { MailboxDb } from "./db.js"; - -let db: MailboxDb; -const SCOPE = { tenantId: "acme", principalId: "user-1" }; - -beforeEach(async () => { - db = await withTestDb(); - await seedScope(db, "acme", "user-1"); -}); - -function accepts(schema: type.Any, value: unknown): boolean { - return !(schema(value) instanceof type.errors); -} - -async function seed(): Promise { - const written = await writeMailboxMessage(db, { - ...SCOPE, - address: "user-1@acme.example", - fromAddress: "bot@acme.example", - subject: "Hello", - body: "Body", - messageKey: crypto.randomUUID(), - priority: "high", - classification: "deal-risk", - status: "needs-action", - refs: [{ kind: "deal", id: "d-1", label: "Acme renewal" }], - }); - return written!.id; -} - -describe("MailboxMessageSchema", () => { - test("accepts a real listed message, refs and enrichment included", async () => { - await seed(); - const page = await listUserMailbox(db, { - ...SCOPE, - limit: 10, - view: "all", - priorities: TEST_VOCABULARY.priorities, - }); - const message = page.items[0]; - expect(message?.refs).toEqual([ - { kind: "deal", id: "d-1", label: "Acme renewal" }, - ]); - expect(accepts(MailboxMessageSchema, message)).toBe(true); - }); - - test("rejects a message missing `from`, which the projection always sets", async () => { - await seed(); - const page = await listUserMailbox(db, { - ...SCOPE, - limit: 10, - view: "all", - priorities: TEST_VOCABULARY.priorities, - }); - const { from: _dropped, ...withoutFrom } = page.items[0]!; - expect(accepts(MailboxMessageSchema, withoutFrom)).toBe(false); - }); - - test("rejects a malformed refs entry rather than passing it through", () => { - expect( - accepts(MailboxMessageSchema, { - id: "m-1", - from: "bot@acme.example", - to: ["user-1@acme.example"], - date: "2026-07-25T00:00:00.000Z", - messageId: "", - read: false, - refs: [{ kind: "deal" }], - }), - ).toBe(false); - }); - - test("rejects a non-boolean `read`, the field a client branches on", () => { - expect( - accepts(MailboxMessageSchema, { - id: "m-1", - from: "bot@acme.example", - to: ["user-1@acme.example"], - date: "2026-07-25T00:00:00.000Z", - messageId: "", - read: "false", - }), - ).toBe(false); - }); -}); - -describe("MailboxMessageDetailSchema", () => { - test("accepts a real detail read", async () => { - const id = await seed(); - const detail = await getMailboxMessage(db, { ...SCOPE, id }); - expect(detail?.body).toContain("Body"); - expect(accepts(MailboxMessageDetailSchema, detail)).toBe(true); - }); - - test("rejects a list item, which carries no body", async () => { - await seed(); - const page = await listUserMailbox(db, { - ...SCOPE, - limit: 10, - view: "all", - priorities: TEST_VOCABULARY.priorities, - }); - expect(accepts(MailboxMessageDetailSchema, page.items[0])).toBe(false); - }); -}); - -describe("MailboxListResponseSchema", () => { - // Validated against the actual HTTP body the mounted route returns, not - // against an envelope the test assembled itself — a schema that only ever - // sees a hand-built object proves nothing about what ships. - function inbox() { - const app = new Hono(); - mountMailbox(app, { - vocabulary: TEST_VOCABULARY, - db, - bus: createInMemoryMailboxEventBus(), - resolvePrincipal: () => SCOPE, - }); - return app; - } - - test("accepts the wire response, with and without a nextCursor", async () => { - await seed(); - await seed(); - - const full = await (await inbox().request("/me/inbox")).json(); - expect((full as { nextCursor?: string }).nextCursor).toBeUndefined(); - expect(accepts(MailboxListResponseSchema, full)).toBe(true); - - const paged = await (await inbox().request("/me/inbox?limit=1")).json(); - expect((paged as { nextCursor?: string }).nextCursor).toBeDefined(); - expect(accepts(MailboxListResponseSchema, paged)).toBe(true); - }); - - test("rejects the in-process MailboxPage shape, which uses `items`", async () => { - await seed(); - const page = await listUserMailbox(db, { - ...SCOPE, - limit: 10, - view: "all", - priorities: TEST_VOCABULARY.priorities, - }); - expect(accepts(MailboxListResponseSchema, page)).toBe(false); - }); - - test("rejects an envelope whose messages are not messages", () => { - expect( - accepts(MailboxListResponseSchema, { messages: [{ id: "m-1" }] }), - ).toBe(false); - }); -}); diff --git a/src/mount-bus-failure.test.ts b/src/mount-bus-failure.test.ts deleted file mode 100644 index bc61cd3..0000000 --- a/src/mount-bus-failure.test.ts +++ /dev/null @@ -1,72 +0,0 @@ -// `bus.publish` runs AFTER the write -// commits, so a throwing host bus must never surface as a 500 the client will -// retry forever — the same invariant `writeMailboxMessage` already documents. -import { describe, expect, test } from "bun:test"; -import { Hono } from "hono"; -import { sql } from "drizzle-orm"; -import { mountMailbox } from "./mount.js"; -import { writeMailboxMessage } from "./write.js"; -import { withTestDb, seedScope, TEST_VOCABULARY } from "./test-helpers.js"; - -const P = { tenantId: "tbus", principalId: "pbus" }; -const failingBus = { - publish() { - throw new Error("broker down"); - }, - subscribe() { - return () => {}; - }, -}; - -async function seed(db: Awaited>) { - await seedScope(db, P.tenantId, P.principalId); - const written = await writeMailboxMessage(db, { - ...P, - address: "pbus@t.example", - fromAddress: "a@t.example", - subject: "s", - body: "b", - }); - return written!.id; -} - -describe("a failing host event bus never fails an applied mutation", () => { - test("single-message mutation still answers 200", async () => { - const db = await withTestDb(); - const id = await seed(db); - const app = mountMailbox(new Hono(), { - vocabulary: TEST_VOCABULARY, - db, - bus: failingBus, - resolvePrincipal: () => P, - }); - const res = await app.request(`/me/inbox/${id}/read`, { method: "POST" }); - const rows = await db.execute<{ read_at: string | null }>( - sql`SELECT read_at FROM "mailbox"."mailbox" WHERE id = ${id}`, - ); - // The row was already mutated; the caller must not be told it failed. - expect(rows[0]!.read_at).not.toBeNull(); - expect(res.status).toBe(200); - }); - - test("bulk mutation still answers 200", async () => { - const db = await withTestDb(); - const id = await seed(db); - const app = mountMailbox(new Hono(), { - vocabulary: TEST_VOCABULARY, - db, - bus: failingBus, - resolvePrincipal: () => P, - }); - const res = await app.request("/me/inbox/bulk", { - method: "POST", - headers: { "content-type": "application/json" }, - body: JSON.stringify({ action: "trash", ids: [id] }), - }); - const rows = await db.execute<{ trashed_at: string | null }>( - sql`SELECT trashed_at FROM "mailbox"."mailbox" WHERE id = ${id}`, - ); - expect(rows[0]!.trashed_at).not.toBeNull(); - expect(res.status).toBe(200); - }); -}); diff --git a/src/mount-event-op.test.ts b/src/mount-event-op.test.ts deleted file mode 100644 index ddb7733..0000000 --- a/src/mount-event-op.test.ts +++ /dev/null @@ -1,153 +0,0 @@ -// CL-5018: the live event must name the operation that fired so a listener -// can react without re-fetching and diffing against remembered state. -import { beforeEach, describe, expect, test } from "bun:test"; -import { Hono } from "hono"; -import { mountMailbox } from "./mount.js"; -import { - createInMemoryMailboxEventBus, - type MailboxEvent, - type MailboxEventOp, -} from "./bus.js"; -import { writeMailboxMessage } from "./write.js"; -import { withTestDb, seedScope, TEST_VOCABULARY } from "./test-helpers.js"; -import type { MailboxDb } from "./db.js"; - -let db: MailboxDb; -const P = { tenantId: "t1", principalId: "p1" }; - -beforeEach(async () => { - db = await withTestDb(); - await seedScope(db, P.tenantId, P.principalId); -}); - -function buildApp(bus: ReturnType) { - const app = new Hono(); - mountMailbox(app, { - db, - bus, - resolvePrincipal: () => P, - vocabulary: TEST_VOCABULARY, - }); - return app; -} - -async function seedMessage(messageKey: string): Promise { - const written = await writeMailboxMessage(db, { - ...P, - address: "p1@t1.example", - fromAddress: "a@t1.example", - subject: "Hi", - body: "Body", - messageKey, - }); - return written!.id; -} - -describe("single-message mutations publish their op", () => { - const cases: Array<{ verb: string; op: MailboxEventOp; seedKey: string }> = [ - { verb: "read", op: "mark_read", seedKey: "op-read" }, - { verb: "unread", op: "mark_unread", seedKey: "op-unread" }, - { verb: "trash", op: "trash", seedKey: "op-trash" }, - { verb: "archive", op: "archive", seedKey: "op-archive" }, - { verb: "restore", op: "restore", seedKey: "op-restore" }, - ]; - - for (const { verb, op, seedKey } of cases) { - test(`POST .../${verb} publishes op "${op}"`, async () => { - const id = await seedMessage(seedKey); - const bus = createInMemoryMailboxEventBus(); - const received: MailboxEvent[] = []; - bus.subscribe(P, (e) => received.push(e)); - const app = buildApp(bus); - - const res = await app.request(`/me/inbox/${id}/${verb}`, { - method: "POST", - }); - - expect(res.status).toBe(200); - expect(received).toEqual([{ type: "mailbox", id, op }]); - }); - } -}); - -describe("enrich and assign publish their own op", () => { - test("enrich publishes op 'enrich'", async () => { - const id = await seedMessage("op-enrich"); - const bus = createInMemoryMailboxEventBus(); - const received: MailboxEvent[] = []; - bus.subscribe(P, (e) => received.push(e)); - const app = buildApp(bus); - - const res = await app.request(`/me/inbox/${id}/enrich`, { - method: "POST", - headers: { "content-type": "application/json" }, - body: JSON.stringify({ priority: "urgent" }), - }); - - expect(res.status).toBe(200); - expect(received).toEqual([{ type: "mailbox", id, op: "enrich" }]); - }); - - test("assign publishes op 'assign'", async () => { - const id = await seedMessage("op-assign"); - const bus = createInMemoryMailboxEventBus(); - const received: MailboxEvent[] = []; - bus.subscribe(P, (e) => received.push(e)); - const app = buildApp(bus); - - const res = await app.request(`/me/inbox/${id}/assign`, { - method: "POST", - headers: { "content-type": "application/json" }, - body: JSON.stringify({ assignee: "teammate-1" }), - }); - - expect(res.status).toBe(200); - expect(received).toEqual([{ type: "mailbox", id, op: "assign" }]); - }); -}); - -describe("bulk mutation publishes the requested action as op", () => { - test("bulk trash publishes op 'trash' for every updated id", async () => { - const idA = await seedMessage("op-bulk-a"); - const idB = await seedMessage("op-bulk-b"); - const bus = createInMemoryMailboxEventBus(); - const received: MailboxEvent[] = []; - bus.subscribe(P, (e) => received.push(e)); - const app = buildApp(bus); - - const res = await app.request("/me/inbox/bulk", { - method: "POST", - headers: { "content-type": "application/json" }, - body: JSON.stringify({ action: "trash", ids: [idA, idB] }), - }); - - expect(res.status).toBe(200); - expect(received).toHaveLength(2); - expect(received.every((e) => e.op === "trash")).toBe(true); - expect(new Set(received.map((e) => e.id))).toEqual(new Set([idA, idB])); - }); -}); - -describe("delivery publishes op 'create'", () => { - test("writeMailboxMessage against the same bus a mount would use", async () => { - const bus = createInMemoryMailboxEventBus(); - const received: MailboxEvent[] = []; - bus.subscribe(P, (e) => received.push(e)); - - await writeMailboxMessage( - db, - { - ...P, - address: "p1@t1.example", - fromAddress: "a@t1.example", - subject: "New", - body: "Body", - messageKey: "op-create-write", - }, - bus, - ); - - expect(received).toHaveLength(1); - expect(received[0]?.op).toBe("create"); - }); -}); diff --git a/src/mount.test.ts b/src/mount.test.ts index 382eb8a..e37183a 100644 --- a/src/mount.test.ts +++ b/src/mount.test.ts @@ -1,10 +1,7 @@ import { beforeEach, describe, expect, test } from "bun:test"; import { Hono } from "hono"; -import { eq } from "drizzle-orm"; -import { mountMailbox, MAX_MAILBOX_PAGE_LIMIT } from "./mount.js"; +import { mountMailbox } from "./mount.js"; import { createInMemoryMailboxEventBus } from "./bus.js"; -import { writeMailboxMessage } from "./write.js"; -import { principalMail } from "./schema.js"; import { withTestDb, seedScope, TEST_VOCABULARY } from "./test-helpers.js"; import type { MailboxDb } from "./db.js"; @@ -28,84 +25,6 @@ function buildApp( return app; } -describe("thread routes", () => { - test("GET /me/threads lists conversations scoped by refs", async () => { - await writeMailboxMessage(db, { - tenantId: "t1", - principalId: "p1", - address: "p1@t1.example", - fromAddress: "sender@t1.example", - subject: "In workbench", - body: "Body", - messageKey: "in", - refs: [{ kind: "workbench", id: "wb-1" }], - }); - await writeMailboxMessage(db, { - tenantId: "t1", - principalId: "p1", - address: "p1@t1.example", - fromAddress: "sender@t1.example", - subject: "Elsewhere", - body: "Body", - messageKey: "out", - refs: [{ kind: "workbench", id: "wb-2" }], - }); - - const app = buildApp(() => ({ tenantId: "t1", principalId: "p1" })); - const res = await app.request( - `/me/threads?refs=${encodeURIComponent( - JSON.stringify([{ kind: "workbench", id: "wb-1" }]), - )}`, - ); - expect(res.status).toBe(200); - const body = (await res.json()) as { threads: { rootMessageId: string }[] }; - expect(body.threads.length).toBe(1); - }); - - test("GET /me/threads returns empty when resolvePrincipal yields null", async () => { - const app = buildApp(() => null); - const res = await app.request("/me/threads"); - expect(res.status).toBe(200); - expect(await res.json()).toEqual({ threads: [] }); - }); - - test("GET /me/threads/:rootMessageId reads a thread by its root Message-ID", async () => { - const written = await writeMailboxMessage(db, { - tenantId: "t1", - principalId: "p1", - address: "p1@t1.example", - fromAddress: "sender@t1.example", - subject: "Root", - body: "Body", - messageKey: "root", - }); - const [row] = await db - .select({ messageId: principalMail.messageId }) - .from(principalMail) - .where(eq(principalMail.id, written!.id)); - - const app = buildApp(() => ({ tenantId: "t1", principalId: "p1" })); - const res = await app.request( - `/me/threads/${encodeURIComponent(row!.messageId!)}`, - ); - expect(res.status).toBe(200); - const body = (await res.json()) as { messages: { id: string }[] }; - expect(body.messages.map((m) => m.id)).toEqual([written!.id]); - }); - - test("GET /me/threads/:rootMessageId returns 404 for an unknown Message-ID", async () => { - const app = buildApp(() => ({ tenantId: "t1", principalId: "p1" })); - const res = await app.request("/me/threads/no-such-message-id"); - expect(res.status).toBe(404); - }); - - test("GET /me/threads/:rootMessageId returns 403 with no resolvable principalId", async () => { - const app = buildApp(() => null); - const res = await app.request("/me/threads/anything"); - expect(res.status).toBe(403); - }); -}); - describe("no-member asymmetry", () => { test("list returns empty 200 when resolvePrincipal yields null", async () => { const app = buildApp(() => null); @@ -163,248 +82,3 @@ describe("no-member asymmetry", () => { expect(res.status).toBe(403); }); }); - -describe("bulk validation", () => { - test("rejects a non-UUID id with 400", async () => { - const app = buildApp(() => ({ tenantId: "t1", principalId: "p1" })); - const res = await app.request("/me/inbox/bulk", { - method: "POST", - headers: { "content-type": "application/json" }, - body: JSON.stringify({ action: "mark_read", ids: ["not-a-uuid"] }), - }); - expect(res.status).toBe(400); - }); - - test("rejects more than 50 ids with 400", async () => { - const app = buildApp(() => ({ tenantId: "t1", principalId: "p1" })); - const ids = Array.from({ length: 51 }, () => crypto.randomUUID()); - const res = await app.request("/me/inbox/bulk", { - method: "POST", - headers: { "content-type": "application/json" }, - body: JSON.stringify({ action: "mark_read", ids }), - }); - expect(res.status).toBe(400); - }); - - test("rejects invalid JSON body with 400", async () => { - const app = buildApp(() => ({ tenantId: "t1", principalId: "p1" })); - const res = await app.request("/me/inbox/bulk", { - method: "POST", - headers: { "content-type": "application/json" }, - body: "{not json", - }); - expect(res.status).toBe(400); - }); -}); - -describe("cursor cross-view rejection over HTTP", () => { - test("a cursor minted for one view used against another returns 400", async () => { - const app = buildApp(() => ({ tenantId: "t1", principalId: "p1" })); - for (let i = 0; i < 2; i++) { - await writeMailboxMessage(db, { - tenantId: "t1", - principalId: "p1", - address: "p1@t1.example", - fromAddress: "a@t1.example", - subject: `Msg ${i}`, - body: "Body", - messageKey: `cursor-${i}`, - }); - } - const listRes = await app.request("/me/inbox?limit=1"); - const listBody = (await listRes.json()) as { nextCursor?: string }; - expect(listBody.nextCursor).toBeDefined(); - - const crossViewRes = await app.request( - `/me/inbox?view=unread&cursor=${listBody.nextCursor}`, - ); - expect(crossViewRes.status).toBe(400); - }); - - test("a malformed cursor returns 400", async () => { - const app = buildApp(() => ({ tenantId: "t1", principalId: "p1" })); - const res = await app.request("/me/inbox?cursor=not-a-real-cursor"); - expect(res.status).toBe(400); - }); - - test("a crafted cursor with a bogus createdAt is a 400, never a 500", async () => { - // Well-formed base64url and JSON, so it survives decoding — the strict - // createdAt format check is the only thing standing between this and a - // SQL cast error mid-query. - const app = buildApp(() => ({ tenantId: "t1", principalId: "p1" })); - const crafted = Buffer.from( - JSON.stringify({ - createdAt: "0", - id: crypto.randomUUID(), - view: "all", - sort: "date", - filter: "", - }), - ).toString("base64url"); - const res = await app.request(`/me/inbox?cursor=${crafted}`); - expect(res.status).toBe(400); - expect(await res.json()).toEqual({ error: "malformed cursor" }); - }); - - test("a crafted cursor whose rank is Infinity is a 400, never a 500", async () => { - // `1e400` is valid JSON that JSON.parse reads as Infinity — a number, so - // only the safe-integer check on rank refuses it. - const app = buildApp(() => ({ tenantId: "t1", principalId: "p1" })); - const crafted = Buffer.from( - `{"createdAt":"2026-01-01T00:00:00.000000Z","id":"${crypto.randomUUID()}",` + - `"view":"all","sort":"priority","filter":"",` + - `"priorities":"urgent,high,normal,low","rank":1e400}`, - ).toString("base64url"); - const res = await app.request(`/me/inbox?sort=priority&cursor=${crafted}`); - expect(res.status).toBe(400); - expect(await res.json()).toEqual({ error: "malformed cursor" }); - }); -}); - -describe("end-to-end write -> list -> read -> mutate", () => { - test("full happy path over HTTP", async () => { - const app = buildApp(() => ({ tenantId: "t1", principalId: "p1" })); - const written = await writeMailboxMessage(db, { - tenantId: "t1", - principalId: "p1", - address: "p1@t1.example", - fromAddress: "a@t1.example", - subject: "Hi", - body: "Body", - messageKey: "e2e-1", - }); - const listRes = await app.request("/me/inbox"); - const listBody = (await listRes.json()) as { messages: { id: string }[] }; - expect(listBody.messages.map((m) => m.id)).toContain(written!.id); - - const detailRes = await app.request(`/me/inbox/${written!.id}`); - expect(detailRes.status).toBe(200); - - const readRes = await app.request(`/me/inbox/${written!.id}/read`, { - method: "POST", - }); - expect(readRes.status).toBe(200); - }); -}); - -describe("single-message mutation routes", () => { - test("unread/archive/trash/restore all respond 200 for an in-scope id", async () => { - const app = buildApp(() => ({ tenantId: "t1", principalId: "p1" })); - const written = await writeMailboxMessage(db, { - tenantId: "t1", - principalId: "p1", - address: "p1@t1.example", - fromAddress: "a@t1.example", - subject: "Hi", - body: "Body", - messageKey: "mutations-e2e", - }); - for (const action of ["unread", "archive", "trash", "restore"]) { - const res = await app.request(`/me/inbox/${written!.id}/${action}`, { - method: "POST", - }); - expect(res.status).toBe(200); - } - }); - - test("mutation on an unknown id returns 404", async () => { - const app = buildApp(() => ({ tenantId: "t1", principalId: "p1" })); - const res = await app.request(`/me/inbox/${crypto.randomUUID()}/read`, { - method: "POST", - }); - expect(res.status).toBe(404); - }); - - test("non-UUID id returns 400", async () => { - const app = buildApp(() => ({ tenantId: "t1", principalId: "p1" })); - const res = await app.request("/me/inbox/not-a-uuid/read", { - method: "POST", - }); - expect(res.status).toBe(400); - }); -}); - -describe("bulk endpoint success path", () => { - test("applies action and publishes signals for updated ids", async () => { - const app = buildApp(() => ({ tenantId: "t1", principalId: "p1" })); - const written = await writeMailboxMessage(db, { - tenantId: "t1", - principalId: "p1", - address: "p1@t1.example", - fromAddress: "a@t1.example", - subject: "Hi", - body: "Body", - messageKey: "bulk-e2e", - }); - const res = await app.request("/me/inbox/bulk", { - method: "POST", - headers: { "content-type": "application/json" }, - body: JSON.stringify({ action: "mark_read", ids: [written!.id] }), - }); - expect(res.status).toBe(200); - const body = (await res.json()) as { updated: number }; - expect(body.updated).toBe(1); - }); -}); - -describe("unread-count and limit validation", () => { - test("unread-count reflects unread active rows", async () => { - const app = buildApp(() => ({ tenantId: "t1", principalId: "p1" })); - await writeMailboxMessage(db, { - tenantId: "t1", - principalId: "p1", - address: "p1@t1.example", - fromAddress: "a@t1.example", - subject: "Hi", - body: "Body", - messageKey: "unread-count-http", - }); - const res = await app.request("/me/inbox/unread-count"); - expect(await res.json()).toEqual({ unread: 1 }); - }); - - test("invalid limit returns 400", async () => { - const app = buildApp(() => ({ tenantId: "t1", principalId: "p1" })); - const res = await app.request("/me/inbox?limit=-1"); - expect(res.status).toBe(400); - }); - - // A limit past the ceiling is REFUSED, not clamped. Silently serving 200 rows - // to a caller that asked for 201 leaves it paging as though it had 201 — it - // advances its own offset by what it requested and skips the difference. The - // boundary is asserted on both sides so a clamp cannot reappear looking green. - test("limit at the documented maximum is accepted", async () => { - const app = buildApp(() => ({ tenantId: "t1", principalId: "p1" })); - const res = await app.request(`/me/inbox?limit=${MAX_MAILBOX_PAGE_LIMIT}`); - expect(res.status).toBe(200); - }); - - test("limit one past the maximum returns 400, not a clamped page", async () => { - const app = buildApp(() => ({ tenantId: "t1", principalId: "p1" })); - const res = await app.request( - `/me/inbox?limit=${MAX_MAILBOX_PAGE_LIMIT + 1}`, - ); - expect(res.status).toBe(400); - expect(await res.json()).toEqual({ error: "limit must be at most 200" }); - }); - - test("a far-too-large limit returns 400 rather than the maximum page", async () => { - const app = buildApp(() => ({ tenantId: "t1", principalId: "p1" })); - const res = await app.request("/me/inbox?limit=5000"); - expect(res.status).toBe(400); - // Nothing resembling a page comes back — the refusal is the whole response. - expect(await res.json()).not.toHaveProperty("messages"); - }); - - test("invalid view returns 400", async () => { - const app = buildApp(() => ({ tenantId: "t1", principalId: "p1" })); - const res = await app.request("/me/inbox?view=bogus"); - expect(res.status).toBe(400); - }); - - test("message not found returns 404 on detail", async () => { - const app = buildApp(() => ({ tenantId: "t1", principalId: "p1" })); - const res = await app.request(`/me/inbox/${crypto.randomUUID()}`); - expect(res.status).toBe(404); - }); -}); diff --git a/src/mutations.test.ts b/src/mutations.test.ts deleted file mode 100644 index bab36bc..0000000 --- a/src/mutations.test.ts +++ /dev/null @@ -1,165 +0,0 @@ -import { beforeEach, describe, expect, test } from "bun:test"; -import { sql } from "drizzle-orm"; -import { writeMailboxMessage } from "./write.js"; -import { mailbox } from "./schema.js"; -import { - markMailboxMessageRead, - markMailboxMessageUnread, - trashMailboxMessage, - archiveMailboxMessage, - restoreMailboxMessage, - countUnreadActiveMailbox, - applyMailboxBulkAction, - MAX_BULK_MAILBOX_IDS, -} from "./mutations.js"; -import { withTestDb, seedScope } from "./test-helpers.js"; -import type { MailboxDb } from "./db.js"; - -let db: MailboxDb; -const scope = { tenantId: "t1", principalId: "p1" }; - -async function writeOne(messageKey: string) { - const result = await writeMailboxMessage(db, { - tenantId: scope.tenantId, - principalId: scope.principalId, - address: "p1@t1.example", - fromAddress: "a@t1.example", - subject: "Subject", - body: "Body", - messageKey, - }); - return result!.id; -} - -beforeEach(async () => { - db = await withTestDb(); - await seedScope(db, "t1", "p1"); -}); - -describe("markMailboxMessageRead", () => { - test("is idempotent: repeated calls do not clobber the original readAt", async () => { - const id = await writeOne("read-1"); - await markMailboxMessageRead(db, { ...scope, id }); - const [row1] = await db - .select({ readAt: mailbox.readAt }) - .from(mailbox) - .where(sql`${mailbox.id} = ${id}`); - await new Promise((r) => setTimeout(r, 20)); - await markMailboxMessageRead(db, { ...scope, id }); - const [row2] = await db - .select({ readAt: mailbox.readAt }) - .from(mailbox) - .where(sql`${mailbox.id} = ${id}`); - expect(row1?.readAt?.getTime() ?? null).toBe( - row2?.readAt?.getTime() ?? null, - ); - }); -}); - -describe("trash/archive precedence", () => { - test("trashing clears archivedAt (trash wins)", async () => { - const id = await writeOne("precedence-1"); - await archiveMailboxMessage(db, { ...scope, id }); - const ok = await trashMailboxMessage(db, { ...scope, id }); - expect(ok).toBe(true); - const [row] = await db - .select() - .from(mailbox) - .where(sql`${mailbox.id} = ${id}`); - expect(row?.trashedAt).not.toBeNull(); - expect(row?.archivedAt).toBeNull(); - }); - - test("archiving an already-trashed item is refused", async () => { - const id = await writeOne("precedence-2"); - await trashMailboxMessage(db, { ...scope, id }); - const ok = await archiveMailboxMessage(db, { ...scope, id }); - expect(ok).toBe(false); - const [row] = await db - .select() - .from(mailbox) - .where(sql`${mailbox.id} = ${id}`); - expect(row?.archivedAt).toBeNull(); - expect(row?.trashedAt).not.toBeNull(); - }); - - test("restore clears both archivedAt and trashedAt", async () => { - const id = await writeOne("precedence-3"); - await trashMailboxMessage(db, { ...scope, id }); - const ok = await restoreMailboxMessage(db, { ...scope, id }); - expect(ok).toBe(true); - const [row] = await db - .select() - .from(mailbox) - .where(sql`${mailbox.id} = ${id}`); - expect(row?.archivedAt).toBeNull(); - expect(row?.trashedAt).toBeNull(); - }); -}); - -describe("countUnreadActiveMailbox", () => { - test("excludes archived and trashed rows", async () => { - const unreadId = await writeOne("unread-count-1"); - const archivedId = await writeOne("unread-count-2"); - const trashedId = await writeOne("unread-count-3"); - await archiveMailboxMessage(db, { ...scope, id: archivedId }); - await trashMailboxMessage(db, { ...scope, id: trashedId }); - const count = await countUnreadActiveMailbox(db, scope); - expect(count).toBe(1); - expect(unreadId).toBeDefined(); - }); -}); - -describe("applyMailboxBulkAction", () => { - test("is capped at 50 ids", async () => { - const ids = Array.from( - { length: MAX_BULK_MAILBOX_IDS + 1 }, - () => "00000000-0000-0000-0000-000000000000", - ); - await expect( - applyMailboxBulkAction(db, scope, "mark_read", ids), - ).rejects.toThrow(); - }); - - test("partial success: per-id result, unknown ids reported not-ok", async () => { - const id = await writeOne("bulk-1"); - const unknownId = "00000000-0000-0000-0000-000000000000"; - const results = await applyMailboxBulkAction(db, scope, "mark_read", [ - id, - unknownId, - ]); - expect(results).toEqual([ - { id, ok: true }, - { id: unknownId, ok: false }, - ]); - }); - - test("active-only guard: mark_unread skips already-archived rows", async () => { - const id = await writeOne("bulk-2"); - await archiveMailboxMessage(db, { ...scope, id }); - const results = await applyMailboxBulkAction(db, scope, "mark_unread", [ - id, - ]); - expect(results).toEqual([{ id, ok: false }]); - }); - - test("bulk trash", async () => { - const id = await writeOne("bulk-trash"); - const results = await applyMailboxBulkAction(db, scope, "trash", [id]); - expect(results).toEqual([{ id, ok: true }]); - }); - - test("bulk archive skips already-trashed rows", async () => { - const id = await writeOne("bulk-archive"); - await trashMailboxMessage(db, { ...scope, id }); - const results = await applyMailboxBulkAction(db, scope, "archive", [id]); - expect(results).toEqual([{ id, ok: false }]); - }); - - test("bulk restore", async () => { - const id = await writeOne("bulk-restore"); - await trashMailboxMessage(db, { ...scope, id }); - const results = await applyMailboxBulkAction(db, scope, "restore", [id]); - expect(results).toEqual([{ id, ok: true }]); - }); -}); diff --git a/src/openapi.test.ts b/src/openapi.test.ts deleted file mode 100644 index df96cac..0000000 --- a/src/openapi.test.ts +++ /dev/null @@ -1,136 +0,0 @@ -import { describe, expect, test } from "bun:test"; -import { Hono } from "hono"; -import { generateSpecs } from "hono-openapi"; -import { mountMailbox, MAX_MAILBOX_PAGE_LIMIT } from "./mount.js"; -import { createInMemoryMailboxEventBus } from "./bus.js"; -import type { MailboxDb } from "./db.js"; -import { TEST_VOCABULARY } from "./test-helpers.js"; - -// Route descriptions must survive mounting: a host that serves an OpenAPI -// document gets the mailbox surface documented for free, so this asserts the -// generated spec, not just that `describeRoute` was called. -const mounted = () => - mountMailbox(new Hono(), { - vocabulary: TEST_VOCABULARY, - db: {} as MailboxDb, - bus: createInMemoryMailboxEventBus(), - resolvePrincipal: () => null, - }); - -describe("OpenAPI", () => { - test("every mounted route appears in the generated spec", async () => { - const spec = await generateSpecs(mounted()); - expect(Object.keys(spec.paths ?? {}).sort()).toEqual([ - "/me/inbox", - "/me/inbox/bulk", - "/me/inbox/events", - "/me/inbox/unread-count", - "/me/inbox/{id}", - "/me/inbox/{id}/archive", - "/me/inbox/{id}/assign", - "/me/inbox/{id}/enrich", - "/me/inbox/{id}/read", - "/me/inbox/{id}/restore", - "/me/inbox/{id}/trash", - "/me/inbox/{id}/unread", - "/me/threads", - "/me/threads/{rootMessageId}", - ]); - }); - - test("every operation carries a summary, a mailbox tag, and responses", async () => { - const spec = await generateSpecs(mounted()); - const operations = Object.values(spec.paths ?? {}).flatMap((path) => - Object.values(path ?? {}), - ) as { summary?: string; tags?: string[]; responses?: object }[]; - expect(operations.length).toBe(14); - for (const op of operations) { - expect(op.summary).toBeString(); - expect(op.tags).toEqual(["mailbox"]); - expect(Object.keys(op.responses ?? {}).length).toBeGreaterThan(0); - } - }); - - test("the list route documents its view, limit, cursor, sort and filter query params", async () => { - const spec = await generateSpecs(mounted()); - const params = (spec.paths?.["/me/inbox"]?.get?.parameters ?? []) as { - name: string; - }[]; - expect(params.map((p) => p.name).sort()).toEqual([ - "assignee", - "classification", - "cursor", - "limit", - "priority", - "sort", - "status", - "view", - ]); - }); - - test("the priority and status enums are generated from the host's vocabulary", async () => { - // The package ships no vocabulary, so the document can only describe the - // host's. A spec that advertised a taxonomy the host never chose would - // generate clients that 400 on every triage filter. - const app = mountMailbox(new Hono(), { - db: {} as MailboxDb, - bus: createInMemoryMailboxEventBus(), - resolvePrincipal: () => null, - vocabulary: { - priorities: ["p0", "p1", "p2"], - statuses: ["open", "shipped"], - }, - }); - const spec = await generateSpecs(app); - const params = (spec.paths?.["/me/inbox"]?.get?.parameters ?? []) as { - name: string; - schema?: { enum?: string[] }; - }[]; - expect(params.find((p) => p.name === "priority")?.schema?.enum).toEqual([ - "p0", - "p1", - "p2", - ]); - expect(params.find((p) => p.name === "status")?.schema?.enum).toEqual([ - "open", - "shipped", - ]); - }); - - test("the documented priority enum keeps the host's order, which is the ranking", async () => { - const spec = await generateSpecs(mounted()); - const params = (spec.paths?.["/me/inbox"]?.get?.parameters ?? []) as { - name: string; - schema?: { enum?: string[] }; - }[]; - expect(params.find((p) => p.name === "priority")?.schema?.enum).toEqual([ - ...TEST_VOCABULARY.priorities, - ]); - }); - - test("the documented limit ceiling is the one the handler enforces", async () => { - // A documented maximum the handler disagrees with is worse than none: a - // client generated from this spec would send exactly the value that 400s. - const spec = await generateSpecs(mounted()); - const params = (spec.paths?.["/me/inbox"]?.get?.parameters ?? []) as { - name: string; - schema?: { maximum?: number; minimum?: number; default?: number }; - }[]; - const limit = params.find((p) => p.name === "limit"); - expect(limit?.schema?.maximum).toBe(MAX_MAILBOX_PAGE_LIMIT); - expect(limit?.schema?.minimum).toBe(1); - expect(limit?.schema?.default).toBe(50); - - // And the handler actually refuses one past that advertised maximum. - const app = mountMailbox(new Hono(), { - vocabulary: TEST_VOCABULARY, - db: {} as MailboxDb, - bus: createInMemoryMailboxEventBus(), - resolvePrincipal: () => ({ tenantId: "t1", principalId: "p1" }), - }); - const res = await app.request( - `/me/inbox?limit=${(limit!.schema!.maximum as number) + 1}`, - ); - expect(res.status).toBe(400); - }); -}); diff --git a/src/read-direction-and-date.test.ts b/src/read-direction-and-date.test.ts deleted file mode 100644 index 80e88b6..0000000 --- a/src/read-direction-and-date.test.ts +++ /dev/null @@ -1,189 +0,0 @@ -// `direction = 'inbound'` appears in six predicates and had no test at all, and -// the `date` header -> `created_at` fallback was equally unasserted. Both are -// silent-wrong-answer failures: an outbound row leaking into an inbox, or a -// date that quietly becomes the row's insert time. -import { beforeEach, describe, expect, test } from "bun:test"; -import { sql } from "drizzle-orm"; -import { listUserMailbox, getMailboxMessage } from "./read.js"; -import { - countUnreadActiveMailbox, - markMailboxMessageRead, - applyMailboxBulkAction, -} from "./mutations.js"; -import { withTestDb, seedScope, TEST_VOCABULARY } from "./test-helpers.js"; -import type { MailboxDb } from "./db.js"; - -let db: MailboxDb; -const SCOPE = { tenantId: "t1", principalId: "p1" }; - -beforeEach(async () => { - db = await withTestDb(); - await seedScope(db, "t1", "p1"); -}); - -function frame(headers: string): Buffer { - return Buffer.from(`${headers}\r\n\r\nbody`, "utf8"); -} - -async function insert(args: { - direction: string; - headers: string; - createdAt: string; - subject: string; -}): Promise { - const [row] = await db.execute<{ id: string }>(sql` - INSERT INTO "mailbox"."principal_mail" - ("tenant_id","principal_id","address","direction","raw","subject","created_at") - VALUES ('t1','p1','p1@t1.example', ${args.direction}, - ${frame(args.headers)}, ${args.subject}, ${args.createdAt}::timestamp) - RETURNING "id"`); - // Mirror delivery: an inbound message gets its management row eagerly. - // Outbound rows are host-written and never get one — nothing in this - // package writes outbound. - if (args.direction === "inbound") { - await db.execute(sql` - INSERT INTO "mailbox"."mailbox" ("id","tenant_id","principal_id") - VALUES (${row!.id}, 't1', 'p1')`); - } - return row!.id; -} - -describe("direction filtering", () => { - test("list returns inbound rows and never outbound ones", async () => { - await insert({ - direction: "inbound", - headers: "From: a@b.c\r\nSubject: In", - createdAt: "2026-07-25T12:00:02Z", - subject: "In", - }); - await insert({ - direction: "outbound", - headers: "From: a@b.c\r\nSubject: Out", - createdAt: "2026-07-25T12:00:01Z", - subject: "Out", - }); - const page = await listUserMailbox(db, { - ...SCOPE, - limit: 50, - view: "all", - priorities: TEST_VOCABULARY.priorities, - }); - expect(page.items.map((i) => i.subject)).toEqual(["In"]); - }); - - test("detail read refuses an outbound row for the same principalId", async () => { - const outboundId = await insert({ - direction: "outbound", - headers: "From: a@b.c\r\nSubject: Out", - createdAt: "2026-07-25T12:00:01Z", - subject: "Out", - }); - expect( - await getMailboxMessage(db, { ...SCOPE, id: outboundId }), - ).toBeNull(); - }); - - test("unread-count ignores outbound rows", async () => { - await insert({ - direction: "outbound", - headers: "From: a@b.c\r\nSubject: Out", - createdAt: "2026-07-25T12:00:01Z", - subject: "Out", - }); - expect(await countUnreadActiveMailbox(db, SCOPE)).toBe(0); - await insert({ - direction: "inbound", - headers: "From: a@b.c\r\nSubject: In", - createdAt: "2026-07-25T12:00:02Z", - subject: "In", - }); - expect(await countUnreadActiveMailbox(db, SCOPE)).toBe(1); - }); - - test("mutations refuse an outbound row, single and bulk", async () => { - const outboundId = await insert({ - direction: "outbound", - headers: "From: a@b.c\r\nSubject: Out", - createdAt: "2026-07-25T12:00:01Z", - subject: "Out", - }); - expect(await markMailboxMessageRead(db, { ...SCOPE, id: outboundId })).toBe( - false, - ); - expect( - await applyMailboxBulkAction(db, SCOPE, "trash", [outboundId]), - ).toEqual([{ id: outboundId, ok: false }]); - // And the message is genuinely untouched, not merely reported as such: an - // outbound row has no management row for a mutation to update. - const rows = await db.execute<{ id: string }>( - sql`SELECT id FROM "mailbox"."mailbox" WHERE id = ${outboundId}`, - ); - expect(rows.length).toBe(0); - }); -}); - -describe("date header -> createdAt fallback", () => { - async function insertDated( - headers: string, - createdAt: string, - ): Promise { - return insert({ - direction: "inbound", - headers, - createdAt, - subject: "S", - }); - } - - async function listDate(headers: string, createdAt: string): Promise { - await insertDated(headers, createdAt); - const page = await listUserMailbox(db, { - priorities: TEST_VOCABULARY.priorities, - ...SCOPE, - limit: 1, - view: "all", - }); - return page.items[0]!.date; - } - - async function detailDate( - headers: string, - createdAt: string, - ): Promise { - const id = await insertDated(headers, createdAt); - const detail = await getMailboxMessage(db, { ...SCOPE, id }); - return detail!.date; - } - - test("list uses created_at (never opens the frame for a Date header)", async () => { - // List no longer selects/decodes raw, so the Date header cannot win here. - const date = await listDate( - "From: a@b.c\r\nDate: Tue, 21 Jul 2026 09:30:00 +0000", - "2026-07-25T12:00:00Z", - ); - expect(date).toBe("2026-07-25T12:00:00.000Z"); - }); - - test("detail: a valid Date header wins over created_at", async () => { - const date = await detailDate( - "From: a@b.c\r\nDate: Tue, 21 Jul 2026 09:30:00 +0000", - "2026-07-25T12:00:00Z", - ); - expect(date).toBe("2026-07-21T09:30:00.000Z"); - }); - - test("detail: no Date header at all falls back to created_at", async () => { - const date = await detailDate("From: a@b.c", "2026-07-25T12:00:00Z"); - expect(date).toBe("2026-07-25T12:00:00.000Z"); - }); - - test("detail: an UNPARSEABLE Date header falls back to created_at, not NaN", async () => { - // The branch that had no coverage: `new Date(header)` yields Invalid Date, - // whose toISOString() throws. Falling back is the only safe answer. - const date = await detailDate( - "From: a@b.c\r\nDate: not a date at all", - "2026-07-25T12:00:00Z", - ); - expect(date).toBe("2026-07-25T12:00:00.000Z"); - }); -}); diff --git a/src/read-fallbacks.test.ts b/src/read-fallbacks.test.ts deleted file mode 100644 index ec5715b..0000000 --- a/src/read-fallbacks.test.ts +++ /dev/null @@ -1,144 +0,0 @@ -// The "header -> cached column -> default" chain the projected message -// resolves through. Every rung is exercised here, including the bottom one: -// a row whose stored frame the MIME parser rejects AND whose cached columns are -// NULL. That case used to project no `from` at all, so a consumer had to branch -// on a field the schema says is always there. -import { beforeEach, describe, expect, test } from "bun:test"; -import { sql } from "drizzle-orm"; -import { type } from "arktype"; -import { - getMailboxMessage, - listUserMailbox, - MailboxMessageDetailSchema, -} from "./read.js"; -import { writeMailboxMessage } from "./write.js"; -import { withTestDb, seedScope, TEST_VOCABULARY } from "./test-helpers.js"; -import type { MailboxDb } from "./db.js"; - -let db: MailboxDb; -const SCOPE = { tenantId: "acme", principalId: "user-1" }; - -beforeEach(async () => { - db = await withTestDb(); - await seedScope(db, "acme", "user-1"); -}); - -async function write(over: { subject?: string } = {}): Promise { - const written = await writeMailboxMessage(db, { - ...SCOPE, - address: "user-1@acme.example", - fromAddress: "bot@acme.example", - subject: over.subject ?? "Original subject", - body: "Body text", - messageKey: crypto.randomUUID(), - }); - return written!.id; -} - -/** Replace the stored frame with bytes `parseHeaderSection` rejects. */ -async function corruptFrame(id: string): Promise { - await db.execute( - sql`UPDATE "mailbox"."principal_mail" SET "raw" = '\\xdeadbeef'::bytea WHERE "id" = ${id}`, - ); -} - -async function clearCachedColumns(id: string): Promise { - await db.execute( - sql`UPDATE "mailbox"."principal_mail" - SET "subject" = NULL, "from_address" = NULL WHERE "id" = ${id}`, - ); -} - -/** Rewrite a cached column so it can be told apart from the header value. */ -async function setCachedColumns( - id: string, - from: string, - subject: string, -): Promise { - await db.execute( - sql`UPDATE "mailbox"."principal_mail" - SET "from_address" = ${from}, "subject" = ${subject} WHERE "id" = ${id}`, - ); -} - -describe("from/subject fallback chain", () => { - test("rung 1: the frame's headers win over the cached columns", async () => { - const id = await write(); - // The cached columns are deliberately made to disagree with the frame. If - // the read path preferred them, these are the values that would surface. - await setCachedColumns(id, "stale@acme.example", "Stale subject"); - - const detail = await getMailboxMessage(db, { ...SCOPE, id }); - expect(detail?.from).toBe("bot@acme.example"); - expect(detail?.subject).toBe("Original subject"); - }); - - test("rung 2: cached columns are used when the frame will not parse", async () => { - const id = await write(); - await setCachedColumns(id, "cached@acme.example", "Cached subject"); - await corruptFrame(id); - - const detail = await getMailboxMessage(db, { ...SCOPE, id }); - expect(detail?.from).toBe("cached@acme.example"); - expect(detail?.subject).toBe("Cached subject"); - }); - - test("rung 3: from defaults to an empty string, never to absence", async () => { - const id = await write(); - await corruptFrame(id); - await clearCachedColumns(id); - - const detail = await getMailboxMessage(db, { ...SCOPE, id }); - expect(detail).not.toBeNull(); - // Both assertions matter: `""` is the specified default, and the key must - // actually be present so a consumer never has to test for it. - expect(detail!.from).toBe(""); - expect("from" in detail!).toBe(true); - }); - - test("rung 3: subject stays absent, because no-subject is not empty-subject", async () => { - const id = await write(); - await corruptFrame(id); - await clearCachedColumns(id); - - const detail = await getMailboxMessage(db, { ...SCOPE, id }); - expect("subject" in detail!).toBe(false); - }); - - test("an explicitly empty subject header survives as an empty string", async () => { - const id = await write({ subject: "" }); - const detail = await getMailboxMessage(db, { ...SCOPE, id }); - // Distinct from the case above: the frame carries a `Subject:` line with - // no value, so the field is present and empty rather than missing. - expect(detail!.subject).toBe(""); - }); - - test("the list path uses cached columns only (never decodes the frame)", async () => { - const id = await write(); - // Stale cache would lose to headers on detail; list never opens the frame, - // so the cached values surface even when they disagree with the raw MIME. - await setCachedColumns(id, "list-cache@acme.example", "List cache subject"); - - const page = await listUserMailbox(db, { - ...SCOPE, - limit: 10, - view: "all", - priorities: TEST_VOCABULARY.priorities, - }); - const item = page.items.find((message) => message.id === id); - expect(item).toBeDefined(); - expect(item!.from).toBe("list-cache@acme.example"); - expect(item!.subject).toBe("List cache subject"); - expect(item!.snippet).toBeUndefined(); - }); - - test("a fully degraded message still satisfies the published schema", async () => { - const id = await write(); - await corruptFrame(id); - await clearCachedColumns(id); - - const detail = await getMailboxMessage(db, { ...SCOPE, id }); - const validated = MailboxMessageDetailSchema(detail); - expect(validated instanceof type.errors).toBe(false); - }); -}); diff --git a/src/read-microsecond-cursor.test.ts b/src/read-microsecond-cursor.test.ts deleted file mode 100644 index eabc821..0000000 --- a/src/read-microsecond-cursor.test.ts +++ /dev/null @@ -1,112 +0,0 @@ -import { beforeEach, describe, expect, test } from "bun:test"; -import { sql } from "drizzle-orm"; -import { writeMailboxMessage } from "./write.js"; -import { listUserMailbox } from "./read.js"; -import { decodeMailboxListCursor } from "./read.js"; -import { withTestDb, seedScope, TEST_VOCABULARY } from "./test-helpers.js"; -import type { MailboxDb } from "./db.js"; - -let db: MailboxDb; - -beforeEach(async () => { - db = await withTestDb(); - await seedScope(db, "t1", "p1"); -}); - -// `created_at` is `timestamp DEFAULT now()` — MICROsecond precision. A cursor -// that only carries millisecond precision rounds down, so every row inside the -// [.123000, .123456) window becomes permanently unreachable. These rows sit in -// the same millisecond and differ only in microseconds, which is exactly what -// `now()` produces under a burst of writes. -describe("keyset pagination at microsecond precision", () => { - beforeEach(async () => { - for (let i = 1; i <= 4; i++) { - const written = await writeMailboxMessage(db, { - tenantId: "t1", - principalId: "p1", - address: "p1@t1.example", - fromAddress: "a@t1.example", - subject: `Micro ${i}`, - body: "Body", - messageKey: `micro-${i}`, - }); - await db.execute( - sql`UPDATE "mailbox"."principal_mail" - SET "created_at" = ${`2026-07-25T12:00:00.00000${i}Z`}::timestamp - WHERE "id" = ${written!.id}`, - ); - } - }); - - test("paginating one row at a time sees every microsecond-separated row exactly once", async () => { - const seen: string[] = []; - let cursor = undefined; - for (let guard = 0; guard < 10; guard++) { - const page = await listUserMailbox(db, { - priorities: TEST_VOCABULARY.priorities, - tenantId: "t1", - principalId: "p1", - limit: 1, - view: "all", - ...(cursor ? { cursor } : {}), - }); - for (const item of page.items) seen.push(item.subject!); - if (page.nextCursor === undefined) break; - const decoded = decodeMailboxListCursor(page.nextCursor); - expect(decoded).not.toBeNull(); - cursor = decoded!; - } - expect(seen).toEqual(["Micro 4", "Micro 3", "Micro 2", "Micro 1"]); - expect(new Set(seen).size).toBe(4); - }); - - test("a cursor round-trips full microsecond precision, not a truncated millisecond", async () => { - const page = await listUserMailbox(db, { - priorities: TEST_VOCABULARY.priorities, - tenantId: "t1", - principalId: "p1", - limit: 1, - view: "all", - }); - const decoded = decodeMailboxListCursor(page.nextCursor!); - // .000004 must survive: a millisecond-precision cursor would read .000Z. - expect(decoded!.createdAt).toBe("2026-07-25T12:00:00.000004Z"); - }); - - test("the id DESC tie-break is reachable for rows sharing an exact timestamp", async () => { - await db.execute(sql`TRUNCATE TABLE "mailbox"."principal_mail", "mailbox"."mailbox"`); - for (let i = 1; i <= 3; i++) { - const written = await writeMailboxMessage(db, { - tenantId: "t1", - principalId: "p1", - address: "p1@t1.example", - fromAddress: "a@t1.example", - subject: `Tie ${i}`, - body: "Body", - messageKey: `tie-${i}`, - }); - await db.execute( - sql`UPDATE "mailbox"."principal_mail" - SET "created_at" = '2026-07-25T12:00:00.123456Z'::timestamp - WHERE "id" = ${written!.id}`, - ); - } - const seen: string[] = []; - let cursor = undefined; - for (let guard = 0; guard < 10; guard++) { - const page = await listUserMailbox(db, { - priorities: TEST_VOCABULARY.priorities, - tenantId: "t1", - principalId: "p1", - limit: 1, - view: "all", - ...(cursor ? { cursor } : {}), - }); - for (const item of page.items) seen.push(item.subject!); - if (page.nextCursor === undefined) break; - cursor = decodeMailboxListCursor(page.nextCursor)!; - } - expect(seen.length).toBe(3); - expect(new Set(seen).size).toBe(3); - }); -}); diff --git a/src/read-multipart.test.ts b/src/read-multipart.test.ts deleted file mode 100644 index 06fc7ac..0000000 --- a/src/read-multipart.test.ts +++ /dev/null @@ -1,103 +0,0 @@ -// The live consequence of the multipart body extraction: externally delivered -// mail arrives as multipart/alternative, so the detail body is projected from -// a frame the read path has to walk. List rows no longer decode the frame — -// they project subject/from from the cached columns only, with no snippet. -import { beforeEach, describe, expect, test } from "bun:test"; -import { getMailboxMessage, listUserMailbox } from "./read.js"; -import { principalMail } from "./schema.js"; -import { withTestDb, seedScope, TEST_VOCABULARY } from "./test-helpers.js"; -import type { MailboxDb } from "./db.js"; - -let db: MailboxDb; -const SCOPE = { tenantId: "acme", principalId: "user-1" }; - -beforeEach(async () => { - db = await withTestDb(); - await seedScope(db, "acme", "user-1"); -}); - -const MULTIPART = new TextEncoder().encode( - [ - "From: sender@example.com", - "To: user-1@acme.example", - "Subject: Multipart hello", - "Message-ID: ", - "MIME-Version: 1.0", - 'Content-Type: multipart/alternative; boundary="BOUND"', - "", - "--BOUND", - "Content-Type: text/plain; charset=utf-8", - "", - "Hello human, this is the readable text.", - "--BOUND", - "Content-Type: text/html; charset=utf-8", - "", - "

Hello human

", - "--BOUND--", - "", - ].join("\r\n"), -); - -async function insertRaw( - raw: Uint8Array, - cached: { subject?: string; fromAddress?: string } = {}, -): Promise { - const [row] = await db - .insert(principalMail) - .values({ - tenantId: SCOPE.tenantId, - principalId: SCOPE.principalId, - address: "user-1@acme.example", - direction: "inbound", - raw, - subject: cached.subject, - fromAddress: cached.fromAddress, - }) - .returning({ id: principalMail.id }); - return row!.id; -} - -describe("read path over a multipart frame", () => { - test("list omits snippet and projects subject/from from cached columns", async () => { - await insertRaw(MULTIPART, { - subject: "Multipart hello", - fromAddress: "sender@example.com", - }); - const page = await listUserMailbox(db, { - ...SCOPE, - limit: 10, - view: "all", - priorities: TEST_VOCABULARY.priorities, - }); - expect(page.items[0]!.snippet).toBeUndefined(); - expect(page.items[0]!.subject).toBe("Multipart hello"); - expect(page.items[0]!.from).toBe("sender@example.com"); - }); - - test("the detail body is the text/plain alternative, not the html one", async () => { - const id = await insertRaw(MULTIPART); - const detail = await getMailboxMessage(db, { ...SCOPE, id }); - expect(detail!.body).toBe("Hello human, this is the readable text."); - expect(detail!.subject).toBe("Multipart hello"); - }); - - test("a multipart frame with no boundary in its body reads as an empty body", async () => { - const id = await insertRaw( - new TextEncoder().encode( - [ - "From: sender@example.com", - "To: user-1@acme.example", - "Subject: Broken", - 'Content-Type: multipart/alternative; boundary="BOUND"', - "", - "no boundary delimiter anywhere in this body", - "", - ].join("\r\n"), - ), - ); - const detail = await getMailboxMessage(db, { ...SCOPE, id }); - expect(detail!.body).toBe(""); - expect(detail!.snippet).toBeUndefined(); - expect(detail!.subject).toBe("Broken"); - }); -}); diff --git a/src/read-non-utc-session.test.ts b/src/read-non-utc-session.test.ts deleted file mode 100644 index b4d18aa..0000000 --- a/src/read-non-utc-session.test.ts +++ /dev/null @@ -1,157 +0,0 @@ -// THE GUARD THE REST OF THE SUITE COULD NOT BE. -// -// `created_at` is `timestamp without time zone` holding UTC. Every other test -// in this package runs against a Postgres session whose `TimeZone` is UTC, and -// under a UTC session a zoneless timestamp and a `timestamptz` render and -// compare identically — so the tie-group, page-boundary and microsecond-cursor -// suites all pass whether the read path casts correctly or not. They cannot -// fail on this, which means they are not guarding it. -// -// This file pins the session to a non-UTC zone and re-asks the same questions. -// It is the only place in the suite where `to_char(created_at AT TIME ZONE -// 'UTC', …)` and a `::timestamptz` cursor cast produce different answers from -// the correct forms, and both of those are what this package shipped before -// the move off `timestamptz` to zoneless `timestamp`. -// -// It runs against its OWN pool, because the session `TimeZone` is a -// connection setting and the shared suite pool must stay UTC. The tables are -// the shared `mailbox`-schema ones — suites run sequentially in one process, -// and the fixture rows are truncated in before the assertions run. -import { afterAll, beforeAll, describe, expect, test } from "bun:test"; -import postgres from "postgres"; -import { drizzle } from "drizzle-orm/postgres-js"; -import { sql } from "drizzle-orm"; -import { runMailboxMigrations } from "./migrations.js"; -import { listUserMailbox } from "./read.js"; -import { decodeMailboxListCursor } from "./read.js"; -import { - createHostControlPlane, - seedScope, - TEST_DATABASE_URL, - TEST_VOCABULARY, -} from "./test-helpers.js"; -import type { MailboxDb } from "./db.js"; - -// UTC-5 in July, and never UTC at any time of year, so the skew this catches is -// a full five hours rather than something a loose assertion could round away. -const SESSION_TZ = "America/New_York"; - -const client = postgres(TEST_DATABASE_URL, { - onnotice: () => {}, - connection: { TimeZone: SESSION_TZ }, -}); -let db: MailboxDb; - -// Four instants, one second apart, written as explicit UTC wall-clock values. -// They are inserted with an explicit `::timestamp` rather than through -// `writeMailboxMessage`'s `DEFAULT now()` so the FIXTURE cannot itself be -// skewed by the session zone — this file is testing the read path, and a -// fixture that moved with the session would make the assertions vacuous. -const INSTANTS = [ - "2026-07-25T12:00:00.000001", - "2026-07-25T12:00:00.000002", - "2026-07-25T12:00:00.000003", - "2026-07-25T12:00:00.000004", -]; - -beforeAll(async () => { - db = drizzle(client); - await createHostControlPlane(db); - await runMailboxMigrations(db); - await db.execute( - sql`TRUNCATE TABLE "mailbox"."principal_mail", "mailbox"."mailbox"`, - ); - await seedScope(db, "t1", "p1"); - for (const [i, instant] of INSTANTS.entries()) { - await db.execute(sql` - INSERT INTO "mailbox"."principal_mail" - ("tenant_id", "principal_id", "address", "direction", "raw", - "subject", "from_address", "created_at") - VALUES ('t1', 'p1', 'p1@t1.example', 'inbound', - ${Buffer.from(`Subject: N${i}\r\n\r\nBody`)}, - ${`N${i}`}, 'a@t1.example', ${instant}::timestamp) - `); - } -}); - -afterAll(async () => { - await client.end(); -}); - -const scope = { - priorities: TEST_VOCABULARY.priorities, - tenantId: "t1", - principalId: "p1", - view: "all" as const, -}; - -describe("mailbox reads on a non-UTC Postgres session", () => { - test("the session really is not UTC, or nothing below proves anything", async () => { - const rows = await db.execute<{ tz: string }>( - sql`SELECT current_setting('TimeZone') AS tz`, - ); - expect(rows[0]!.tz).toBe(SESSION_TZ); - }); - - test("the cursor renders the stored UTC instant, not the session's local time", async () => { - // Fails against the shipped `to_char(created_at AT TIME ZONE 'UTC', …)`: - // with a zoneless column that expression REINTERPRETS the value as UTC and - // renders it in the session zone, yielding `…T07:00:00.000004Z` — a local - // time wearing a `Z`. - const page = await listUserMailbox(db, { ...scope, limit: 1 }); - const cursor = decodeMailboxListCursor(page.nextCursor!); - expect(cursor).not.toBeNull(); - expect(cursor!.createdAt).toBe("2026-07-25T12:00:00.000004Z"); - }); - - test("paging one row at a time still sees every row exactly once", async () => { - // Fails against a `::timestamptz` cursor cast: the cursor string is - // resolved through the session zone before it is compared to the zoneless - // column, so the seek lands five hours away from the row it was minted - // from. Every remaining row is on the wrong side of it and the second page - // comes back empty — silently, and still as an `Index Cond`, which is why - // no plan inspection would have caught this either. - const seen: string[] = []; - let cursor = undefined; - for (let guard = 0; guard < 10; guard++) { - const page = await listUserMailbox(db, { - ...scope, - limit: 1, - ...(cursor ? { cursor } : {}), - }); - for (const item of page.items) seen.push(item.subject!); - if (page.nextCursor === undefined) break; - const decoded = decodeMailboxListCursor(page.nextCursor); - expect(decoded).not.toBeNull(); - cursor = decoded!; - } - expect(seen).toEqual(["N3", "N2", "N1", "N0"]); - }); - - test("a page taken with a cursor matches the same slice taken without one", async () => { - // The equivalence the UTC suite already asserts, re-asked where the two - // implementations diverge. - const full = await listUserMailbox(db, { ...scope, limit: 10 }); - const first = await listUserMailbox(db, { ...scope, limit: 2 }); - const rest = await listUserMailbox(db, { - ...scope, - limit: 10, - cursor: decodeMailboxListCursor(first.nextCursor!)!, - }); - expect([...first.items, ...rest.items].map((m) => m.subject)).toEqual( - full.items.map((m) => m.subject), - ); - }); - - test("the stored instant survives the round trip as UTC", async () => { - // `date` comes off the row's `created_at` as a JS Date. If the column had - // been written or read through the session zone, this would be 17:00Z. - const page = await listUserMailbox(db, { ...scope, limit: 4 }); - expect(page.items.map((m) => m.date)).toEqual([ - "2026-07-25T12:00:00.000Z", - "2026-07-25T12:00:00.000Z", - "2026-07-25T12:00:00.000Z", - "2026-07-25T12:00:00.000Z", - ]); - }); -}); diff --git a/src/read-page-boundary.test.ts b/src/read-page-boundary.test.ts deleted file mode 100644 index 8fc6c3c..0000000 --- a/src/read-page-boundary.test.ts +++ /dev/null @@ -1,131 +0,0 @@ -import { beforeEach, expect, test } from "bun:test"; -import { sql } from "drizzle-orm"; -import { listUserMailbox } from "./read.js"; -import { decodeMailboxListCursor } from "./read.js"; -import { withTestDb, seedScope, TEST_VOCABULARY } from "./test-helpers.js"; -import type { MailboxDb } from "./db.js"; - -let db: MailboxDb; - -beforeEach(async () => { - db = await withTestDb(); - await seedScope(db, "t1", "p1"); -}); - -async function seed(timestamps: string[]) { - for (let i = 0; i < timestamps.length; i++) { - await db.execute(sql` - INSERT INTO "mailbox"."principal_mail" - ("tenant_id","principal_id","address","direction","raw","subject","created_at") - VALUES ('t1','p1','p1@t1.example','inbound', - ${Buffer.from(`From: a@b.com\r\nSubject: S${i}\r\n\r\nbody`, "utf8")}, - ${`S${i}`}, ${timestamps[i]}::timestamp)`); - } -} - -async function walkEveryPage(limit: number): Promise { - const seen: string[] = []; - let cursor: ReturnType | undefined; - for (let guard = 0; guard < 20000; guard++) { - const page = await listUserMailbox(db, { - priorities: TEST_VOCABULARY.priorities, - tenantId: "t1", - principalId: "p1", - limit, - view: "all", - ...(cursor ? { cursor } : {}), - }); - for (const item of page.items) seen.push(item.id); - if (page.nextCursor === undefined) return seen; - const decoded = decodeMailboxListCursor(page.nextCursor); - expect(decoded).not.toBeNull(); - cursor = decoded!; - } - throw new Error("pagination did not terminate"); -} - -// The keyset predicate is ROW(created_at, id) < ROW(cursor...). `id` is a -// random uuid, so a tie group's internal order is not the insertion order and -// not the index order; a page boundary that lands inside a tie group is the -// only place the predicate can skip or repeat a row. -test("a tie group larger than the page never skips or repeats a row", async () => { - await seed(Array.from({ length: 120 }, () => "2026-07-25T12:00:00.123456Z")); - const baseline = await walkEveryPage(120); - expect(baseline.length).toBe(120); - for (const limit of [1, 7, 13, 119]) { - const seen = await walkEveryPage(limit); - expect(seen.length).toBe(120); - expect(new Set(seen).size).toBe(120); - expect(seen).toEqual(baseline); - } -}); - -// A page boundary that lands exactly on a tie-group boundary is the adjacent -// off-by-one: limit == group size, limit == group size +/- 1. -test("page boundaries aligned to tie-group boundaries stay stable", async () => { - await seed([ - ...Array.from({ length: 10 }, () => "2026-07-25T12:00:03.000000Z"), - ...Array.from({ length: 10 }, () => "2026-07-25T12:00:02.000000Z"), - ...Array.from({ length: 10 }, () => "2026-07-25T12:00:01.000000Z"), - ]); - const baseline = await walkEveryPage(30); - for (const limit of [9, 10, 11, 20]) { - expect(await walkEveryPage(limit)).toEqual(baseline); - } -}); - -// Rows separated only by microseconds inside one millisecond: a cursor that -// ever round-trips through a JS Date truncates to the millisecond and strands -// every row in the rounded-off window. -test("a microsecond ladder inside one millisecond survives pagination", async () => { - await seed( - Array.from( - { length: 200 }, - (_, i) => `2026-07-25T12:00:00.000${String(i).padStart(3, "0")}Z`, - ), - ); - const seen = await walkEveryPage(11); - expect(seen.length).toBe(200); - expect(new Set(seen).size).toBe(200); -}); - -// Stored `refs` is host-controlled jsonb. Every shape a bad backfill can leave -// behind must degrade to "no refs", never throw out of the read path. -test("every malformed refs shape degrades instead of throwing", async () => { - const malformed = [ - "null", - "{}", - '"a string"', - "42", - "true", - "[null]", - "[1,2,3]", - '["a"]', - "[{}]", - '[{"kind":"x"}]', - '[{"id":"x"}]', - '[{"kind":1,"id":"x"}]', - '[{"kind":"x","id":"y","label":5}]', - '[{"kind":"x","id":"y"},{"bad":true}]', - '[[{"kind":"x","id":"y"}]]', - '{"0":{"kind":"x","id":"y"}}', - ]; - for (let i = 0; i < malformed.length; i++) { - await db.execute(sql` - INSERT INTO "mailbox"."principal_mail" - ("tenant_id","principal_id","address","direction","raw","subject","refs","created_at") - VALUES ('t1','p1','p1@t1.example','inbound', - ${Buffer.from(`From: a@b.com\r\nSubject: B${i}\r\n\r\nbody`, "utf8")}, - ${`B${i}`}, ${malformed[i]}::jsonb, - ${`2026-07-25T12:00:00.0000${String(i).padStart(2, "0")}Z`}::timestamp)`); - } - const page = await listUserMailbox(db, { - priorities: TEST_VOCABULARY.priorities, - tenantId: "t1", - principalId: "p1", - limit: 100, - view: "all", - }); - expect(page.items.length).toBe(malformed.length); - for (const item of page.items) expect(item.refs).toBeUndefined(); -}); diff --git a/src/read.test.ts b/src/read.test.ts deleted file mode 100644 index c10ce14..0000000 --- a/src/read.test.ts +++ /dev/null @@ -1,427 +0,0 @@ -import { beforeEach, describe, expect, test } from "bun:test"; -import { sql } from "drizzle-orm"; -import { writeMailboxMessage } from "./write.js"; -import { - listUserMailbox, - getMailboxMessage, - PRINCIPAL_MAIL_LIST_COLUMNS, - encodeMailboxListCursor, - decodeMailboxListCursor, -} from "./read.js"; -import { trashMailboxMessage } from "./mutations.js"; -import { principalMail } from "./schema.js"; -import { withTestDb, seedScope, TEST_VOCABULARY } from "./test-helpers.js"; -import type { MailboxDb } from "./db.js"; - -let db: MailboxDb; - -beforeEach(async () => { - db = await withTestDb(); - await seedScope(db, "t1", "p1", "p2"); -}); - -describe("listUserMailbox", () => { - test("scopes strictly to (tenantId, principalId): cross-principalId isolation", async () => { - await writeMailboxMessage(db, { - tenantId: "t1", - principalId: "p1", - address: "p1@t1.example", - fromAddress: "a@t1.example", - subject: "For p1", - body: "Body", - messageKey: "m1", - }); - await writeMailboxMessage(db, { - tenantId: "t1", - principalId: "p2", - address: "p2@t1.example", - fromAddress: "a@t1.example", - subject: "For p2", - body: "Body", - messageKey: "m2", - }); - const page = await listUserMailbox(db, { - priorities: TEST_VOCABULARY.priorities, - tenantId: "t1", - principalId: "p1", - limit: 50, - view: "all", - }); - expect(page.items.length).toBe(1); - expect(page.items[0]?.subject).toBe("For p1"); - }); - - test("carries the threading headers from the cached columns", async () => { - // The list path never selects `raw`, so a client can only thread a page if - // these come off the cached columns — which is why they are cached at all. - const written = await writeMailboxMessage(db, { - tenantId: "t1", - principalId: "p1", - address: "p1@t1.example", - fromAddress: "a@t1.example", - subject: "Re: thread", - body: "Body", - messageKey: "threaded", - inReplyTo: "", - references: ["", ""], - }); - const page = await listUserMailbox(db, { - priorities: TEST_VOCABULARY.priorities, - tenantId: "t1", - principalId: "p1", - limit: 50, - view: "all", - }); - const item = page.items[0]; - expect(item?.inReplyTo).toBe(""); - // A real minted Message-ID, not the row id fallback. - expect(item?.messageId).toMatch(/^<[^<>]+@t1\.example>$/); - expect(item?.messageId).not.toBe(written!.id); - - // Detail re-derives both from the frame and agrees with the list. - const detail = await getMailboxMessage(db, { - tenantId: "t1", - principalId: "p1", - id: written!.id, - }); - expect(detail?.inReplyTo).toBe(""); - expect(detail?.messageId).toBe(item?.messageId ?? ""); - }); - - test("omits inReplyTo for a message that is not a reply", async () => { - await writeMailboxMessage(db, { - tenantId: "t1", - principalId: "p1", - address: "p1@t1.example", - fromAddress: "a@t1.example", - subject: "Root", - body: "Body", - messageKey: "root", - }); - const page = await listUserMailbox(db, { - priorities: TEST_VOCABULARY.priorities, - tenantId: "t1", - principalId: "p1", - limit: 50, - view: "all", - }); - expect(page.items[0]?.inReplyTo).toBeUndefined(); - }); - - test("keyset pagination: limit+1 detects hasMore and mints nextCursor", async () => { - for (let i = 0; i < 3; i++) { - await writeMailboxMessage(db, { - tenantId: "t1", - principalId: "p1", - address: "p1@t1.example", - fromAddress: "a@t1.example", - subject: `Msg ${i}`, - body: "Body", - messageKey: `page-${i}`, - }); - } - const page1 = await listUserMailbox(db, { - priorities: TEST_VOCABULARY.priorities, - tenantId: "t1", - principalId: "p1", - limit: 2, - view: "all", - }); - expect(page1.items.length).toBe(2); - expect(page1.nextCursor).toBeDefined(); - - const decoded = decodeMailboxListCursor(page1.nextCursor!); - const page2 = await listUserMailbox(db, { - priorities: TEST_VOCABULARY.priorities, - tenantId: "t1", - principalId: "p1", - limit: 2, - view: "all", - cursor: decoded!, - }); - expect(page2.items.length).toBe(1); - expect(page2.nextCursor).toBeUndefined(); - }); - - test("a cursor minted for one view rejects against a different view (caller responsibility check)", async () => { - const cursor = encodeMailboxListCursor( - { - createdAt: "2026-07-25T12:00:00.123456Z", - id: "00000000-0000-0000-0000-000000000000", - }, - { view: "unread", sort: "date", filter: "" }, - ); - const decoded = decodeMailboxListCursor(cursor); - expect(decoded?.view).toBe("unread"); - expect(decoded?.view !== "all").toBe(true); - }); - - test("malformed cursor decodes to null", () => { - expect(decodeMailboxListCursor("not-valid-base64url!!!")).toBeNull(); - expect( - decodeMailboxListCursor(Buffer.from("{}").toString("base64url")), - ).toBeNull(); - // Structurally valid JSON with a createdAt that is not the exact - // microsecond rendering — accepted, it would become a SQL cast error. - expect( - decodeMailboxListCursor( - Buffer.from( - JSON.stringify({ - createdAt: "0", - id: "00000000-0000-0000-0000-000000000000", - view: "all", - sort: "date", - filter: "", - }), - ).toString("base64url"), - ), - ).toBeNull(); - // 1e400 parses to Infinity: a number, but not a safe-integer rank. - expect( - decodeMailboxListCursor( - Buffer.from( - '{"createdAt":"2026-01-01T00:00:00.000000Z",' + - '"id":"00000000-0000-0000-0000-000000000000",' + - '"view":"all","sort":"priority","filter":"",' + - '"priorities":"urgent,high,normal,low","rank":1e400}', - ).toString("base64url"), - ), - ).toBeNull(); - }); - - test("view=trash / archived / unread filter correctly", async () => { - const w1 = await writeMailboxMessage(db, { - tenantId: "t1", - principalId: "p1", - address: "p1@t1.example", - fromAddress: "a@t1.example", - subject: "Active", - body: "Body", - messageKey: "v-active", - }); - const w2 = await writeMailboxMessage(db, { - tenantId: "t1", - principalId: "p1", - address: "p1@t1.example", - fromAddress: "a@t1.example", - subject: "Trashed", - body: "Body", - messageKey: "v-trashed", - }); - await trashMailboxMessage(db, { - tenantId: "t1", - principalId: "p1", - id: w2!.id, - }); - - const trashPage = await listUserMailbox(db, { - priorities: TEST_VOCABULARY.priorities, - tenantId: "t1", - principalId: "p1", - limit: 50, - view: "trash", - }); - expect(trashPage.items.map((m) => m.id)).toEqual([w2!.id]); - - const allPage = await listUserMailbox(db, { - priorities: TEST_VOCABULARY.priorities, - tenantId: "t1", - principalId: "p1", - limit: 50, - view: "all", - }); - expect(allPage.items.map((m) => m.id)).toEqual([w1!.id]); - }); -}); - -describe("getMailboxMessage", () => { - test("degrades gracefully on a malformed frame instead of throwing", async () => { - const written = await writeMailboxMessage(db, { - tenantId: "t1", - principalId: "p1", - address: "p1@t1.example", - fromAddress: "a@t1.example", - subject: "Fine", - body: "Body", - messageKey: "malformed", - }); - await db - .update(principalMail) - .set({ raw: Buffer.from([0xff, 0xfe, 0x00, 0x01]) }) - .where(sql`${principalMail.id} = ${written!.id}`); - - const message = await getMailboxMessage(db, { - tenantId: "t1", - principalId: "p1", - id: written!.id, - }); - expect(message).not.toBeNull(); - expect(message?.body).toBe(""); - }); - - test("degrades refs to empty array on stored-shape validation failure", async () => { - const written = await writeMailboxMessage(db, { - tenantId: "t1", - principalId: "p1", - address: "p1@t1.example", - fromAddress: "a@t1.example", - subject: "Fine", - body: "Body", - messageKey: "bad-refs", - }); - await db - .update(principalMail) - .set({ refs: [{ notARealShape: true }] as never }) - .where(sql`${principalMail.id} = ${written!.id}`); - - const message = await getMailboxMessage(db, { - tenantId: "t1", - principalId: "p1", - id: written!.id, - }); - expect(message).not.toBeNull(); - expect(message?.refs).toBeUndefined(); - }); - - test("detail still loads the full frame body (list does not)", async () => { - const body = "x".repeat(500); - const written = await writeMailboxMessage(db, { - tenantId: "t1", - principalId: "p1", - address: "p1@t1.example", - fromAddress: "a@t1.example", - subject: "Long", - body, - messageKey: "long-body", - }); - const page = await listUserMailbox(db, { - priorities: TEST_VOCABULARY.priorities, - tenantId: "t1", - principalId: "p1", - limit: 50, - view: "all", - }); - const item = page.items.find((m) => m.id === written!.id); - // List never decodes the frame, so no snippet and no body. messageId comes - // off the cached column — the minted id, not the row id fallback. - expect(item?.snippet).toBeUndefined(); - expect(item?.subject).toBe("Long"); - expect(item?.from).toBe("a@t1.example"); - expect(item?.messageId).toMatch(/^<[^<>]+@t1\.example>$/); - expect(item?.to).toEqual(["p1@t1.example"]); - - const detail = await getMailboxMessage(db, { - tenantId: "t1", - principalId: "p1", - id: written!.id, - }); - expect(detail?.body).toBe(body); - expect(detail?.snippet?.length).toBe(160); - }); - - test("list projects from cached columns without needing raw", async () => { - // Behaviour under a multi-megabyte unparseable frame: list still returns - // subject/from from caches and never surfaces a snippet. (Select-shape - // omit of `raw` is asserted separately below — a null decode would make - // these same expectations pass even if raw were still selected.) - const written = await writeMailboxMessage(db, { - tenantId: "t1", - principalId: "p1", - address: "p1@t1.example", - fromAddress: "cached@t1.example", - subject: "Cached subject", - body: "tiny", - messageKey: "large-raw", - }); - const huge = Buffer.alloc(2 * 1024 * 1024, 0xff); - await db - .update(principalMail) - .set({ raw: huge }) - .where(sql`${principalMail.id} = ${written!.id}`); - - const page = await listUserMailbox(db, { - priorities: TEST_VOCABULARY.priorities, - tenantId: "t1", - principalId: "p1", - limit: 50, - view: "all", - }); - const item = page.items.find((m) => m.id === written!.id); - expect(item).toBeDefined(); - expect(item?.subject).toBe("Cached subject"); - expect(item?.from).toBe("cached@t1.example"); - expect(item?.snippet).toBeUndefined(); - }); - - test("list select shape omits principal_mail.raw", () => { - // Locks the production constant listUserMailbox spreads into .select({...}). - const keys = Object.keys(PRINCIPAL_MAIL_LIST_COLUMNS); - expect(keys).not.toContain("raw"); - expect(keys).toEqual( - expect.arrayContaining(["subject", "fromAddress", "id", "createdAt"]), - ); - }); - - test("returns null for a message outside the caller's scope", async () => { - const written = await writeMailboxMessage(db, { - tenantId: "t1", - principalId: "p1", - address: "p1@t1.example", - fromAddress: "a@t1.example", - subject: "Private", - body: "Body", - messageKey: "scoped", - }); - const message = await getMailboxMessage(db, { - tenantId: "t1", - principalId: "someone-else", - id: written!.id, - }); - expect(message).toBeNull(); - }); -}); - -describe("toMailboxMessage recipients", () => { - test("splits a multi-recipient To header into separate addresses", async () => { - const written = await writeMailboxMessage(db, { - tenantId: "t1", - principalId: "p1", - address: "a@t1.example, b@t1.example, c@t1.example", - fromAddress: "sender@t1.example", - subject: "Broadcast", - body: "Body", - messageKey: "multi-to", - }); - const message = await getMailboxMessage(db, { - tenantId: "t1", - principalId: "p1", - id: written!.id, - }); - expect(message?.to).toEqual([ - "a@t1.example", - "b@t1.example", - "c@t1.example", - ]); - }); - - test("falls back to the row address when the frame has no To header", async () => { - const written = await writeMailboxMessage(db, { - tenantId: "t1", - principalId: "p1", - address: "p1@t1.example", - fromAddress: "sender@t1.example", - subject: "No To", - body: "Body", - messageKey: "no-to", - }); - await db.execute( - sql`UPDATE "mailbox"."principal_mail" SET "raw" = convert_to('From: sender@t1.example' || chr(13) || chr(10) || chr(13) || chr(10) || 'Body', 'UTF8') WHERE "id" = ${written!.id}`, - ); - const message = await getMailboxMessage(db, { - tenantId: "t1", - principalId: "p1", - id: written!.id, - }); - expect(message?.to).toEqual(["p1@t1.example"]); - }); -}); diff --git a/src/scope-validation.test.ts b/src/scope-validation.test.ts deleted file mode 100644 index b014a9a..0000000 --- a/src/scope-validation.test.ts +++ /dev/null @@ -1,202 +0,0 @@ -// The write boundary that stands in for the foreign key `principal_mail` -// deliberately does not have. A row written under a blank tenant or principal -// is not merely odd: every read and mutation in this package is scoped by -// equality on both columns, so the row is unreachable forever. These tests -// prove the refusal happens on the way in, and that the database stays clean. -import { beforeEach, describe, expect, test } from "bun:test"; -import { sql } from "drizzle-orm"; -import { writeMailboxMessage, deliverInboxItems } from "./write.js"; -import { - enrichMailboxMessage, - assignMailboxMessage, -} from "./mutations.js"; -import { createMailboxPersist } from "./persist.js"; -import { assertMailboxScope, MailboxScopeIdsSchema } from "./write.js"; -import { buildMailFrame } from "./frame.js"; -import { withTestDb, seedScope } from "./test-helpers.js"; -import type { MailboxDb } from "./db.js"; - -let db: MailboxDb; - -beforeEach(async () => { - db = await withTestDb(); - await seedScope(db, "t1", "p1", "p2"); -}); - -const BLANK = ["", " ", "\t", "\n", " "] as const; - -async function rowCount(): Promise { - const rows = await db.execute<{ n: number }>( - sql`SELECT count(*)::int AS n FROM "mailbox"."principal_mail"`, - ); - return rows[0]!.n; -} - -function writeArgs(overrides: { tenantId: string; principalId: string }) { - return { - tenantId: overrides.tenantId, - principalId: overrides.principalId, - address: "p1@t1.example", - fromAddress: "agent@t1.example", - subject: "Hello", - body: "World", - }; -} - -describe("assertMailboxScope", () => { - test("accepts ordinary identifiers", () => { - expect(() => - assertMailboxScope({ tenantId: "acme", principalId: "user-1" }), - ).not.toThrow(); - }); - - test("rejects empty and whitespace-only ids, on either side", () => { - for (const blank of BLANK) { - expect(() => - assertMailboxScope({ tenantId: blank, principalId: "user-1" }), - ).toThrow(RangeError); - expect(() => - assertMailboxScope({ tenantId: "acme", principalId: blank }), - ).toThrow(RangeError); - } - }); - - test("does not trim on the caller's behalf: a padded id is a real, distinct id", () => { - // Rewriting `" acme"` to `"acme"` would make the row unreachable by the - // exact string the caller believes it wrote — the same failure the check - // exists to prevent, just with an extra space. - expect(() => - assertMailboxScope({ tenantId: " acme", principalId: "user-1" }), - ).not.toThrow(); - const parsed = MailboxScopeIdsSchema({ - tenantId: " acme", - principalId: "user-1", - }); - expect(parsed).toEqual({ tenantId: " acme", principalId: "user-1" }); - }); -}); - -describe("writeMailboxMessage rejects a blank scope", () => { - test("throws RangeError and writes nothing", async () => { - for (const blank of BLANK) { - await expect( - writeMailboxMessage( - db, - writeArgs({ tenantId: blank, principalId: "p1" }), - ), - ).rejects.toThrow(RangeError); - await expect( - writeMailboxMessage( - db, - writeArgs({ tenantId: "t1", principalId: blank }), - ), - ).rejects.toThrow(RangeError); - } - expect(await rowCount()).toBe(0); - }); -}); - -describe("deliverInboxItems rejects a blank scope", () => { - function item(tenantId: string, principalId: string, externalId: string) { - return { - tenantId, - principalId, - address: "p1@t1.example", - fromAddress: "agent@t1.example", - subject: "Hello", - body: "World", - source: "test", - externalId, - }; - } - - test("refuses the whole batch before delivering any of it", async () => { - // The good item is FIRST on purpose: a per-item check inside the loop - // would have delivered it before reaching the bad one, and an adapter - // retrying the batch would then deliver it twice. - await expect( - deliverInboxItems(db, [ - item("t1", "p1", "ok"), - item("t1", " ", "blank-principal"), - ]), - ).rejects.toThrow(RangeError); - expect(await rowCount()).toBe(0); - }); - - test("delivers a batch whose scopes are all valid", async () => { - const results = await deliverInboxItems(db, [ - item("t1", "p1", "a"), - item("t1", "p2", "b"), - ]); - expect(results.every((r) => r.id !== null)).toBe(true); - expect(await rowCount()).toBe(2); - }); -}); - -describe("enrich and assign reject a blank scope", () => { - test("both throw RangeError", async () => { - const written = await writeMailboxMessage( - db, - writeArgs({ tenantId: "t1", principalId: "p1" }), - ); - const id = written!.id; - await expect( - enrichMailboxMessage( - db, - { tenantId: "", principalId: "p1", id }, - { - priority: "high", - }, - ), - ).rejects.toThrow(RangeError); - await expect( - enrichMailboxMessage( - db, - { tenantId: "t1", principalId: " ", id }, - { - priority: "high", - }, - ), - ).rejects.toThrow(RangeError); - await expect( - assignMailboxMessage(db, { tenantId: " ", principalId: "p1", id }, "p2"), - ).rejects.toThrow(RangeError); - await expect( - assignMailboxMessage(db, { tenantId: "t1", principalId: "", id }, "p2"), - ).rejects.toThrow(RangeError); - }); -}); - -describe("the persist seam refuses a blank tenant from the host's authorizer", () => { - test("no rows are written, and the upstream persist still stands", async () => { - const raw = buildMailFrame({ - from: "ins_agent@t1.example", - to: "usr_p1@t1.example", - subject: "Hi", - body: "there", - messageId: "", - }); - let upstreamCalls = 0; - const persist = createMailboxPersist(db, { - upstream: async () => { - upstreamCalls += 1; - return "upstream-ok" as const; - }, - // A host authorizer that hands back a blank tenant. With a foreign key - // this would have been the database's refusal; here it is ours. - authorizeSender: () => ({ tenantId: " ", domain: "t1.example" }), - }); - - const result = await persist({ - senderAddress: "ins_agent@t1.example", - recipients: ["usr_p1@t1.example"], - raw, - }); - - // Dual-write independence is preserved: the mailbox refusal is logged, - // never propagated to a caller whose upstream persist already committed. - expect(result).toBe("upstream-ok"); - expect(upstreamCalls).toBe(1); - expect(await rowCount()).toBe(0); - }); -}); diff --git a/src/sender-display.test.ts b/src/sender-display.test.ts deleted file mode 100644 index fdac55b..0000000 --- a/src/sender-display.test.ts +++ /dev/null @@ -1,223 +0,0 @@ -// The sender-display helper, plus the seam that carries its output into the -// read path. Without it the read path emits the raw `From:` header and nothing -// else, so `ins_dep-heartbeat@acme.example` was -// what a user saw. -import { beforeEach, describe, expect, test } from "bun:test"; -import { Hono } from "hono"; -import { - attachFromDisplay, - extractSenderMailboxAddress, - type SenderDisplayResolver, -} from "./read.js"; -import { getMailboxMessage, listUserMailbox } from "./read.js"; -import { mountMailbox } from "./mount.js"; -import { createInMemoryMailboxEventBus } from "./bus.js"; -import { writeMailboxMessage } from "./write.js"; -import { withTestDb, seedScope, TEST_VOCABULARY } from "./test-helpers.js"; -import type { MailboxDb } from "./db.js"; - -let db: MailboxDb; -const SCOPE = { tenantId: "acme", principalId: "user-1" }; -const SENDER = "ins_dep-heartbeat@acme.example"; - -beforeEach(async () => { - db = await withTestDb(); - await seedScope(db, "acme", "user-1"); -}); - -describe("extractSenderMailboxAddress", () => { - test("returns a bare address unchanged", () => { - expect(extractSenderMailboxAddress(SENDER)).toBe(SENDER); - }); - - test("strips a quoted display name and the angle brackets", () => { - expect(extractSenderMailboxAddress(`"Heartbeat" <${SENDER}>`)).toBe(SENDER); - }); - - test("trims surrounding whitespace on both forms", () => { - expect(extractSenderMailboxAddress(` ${SENDER} `)).toBe(SENDER); - expect(extractSenderMailboxAddress(`Bot < ${SENDER} >`)).toBe(SENDER); - }); - - test("uses the LAST '>' so a display name containing one cannot truncate it", () => { - expect(extractSenderMailboxAddress(`"a > b" <${SENDER}>`)).toBe(SENDER); - }); -}); - -describe("attachFromDisplay", () => { - test("returns the label when the resolver knows a distinct one", () => { - expect(attachFromDisplay(SENDER, new Map([[SENDER, "Heartbeat"]]))).toBe( - "Heartbeat", - ); - }); - - test("keys off the extracted address, not the whole header", () => { - expect( - attachFromDisplay(`"Bot" <${SENDER}>`, new Map([[SENDER, "Heartbeat"]])), - ).toBe("Heartbeat"); - }); - - test("returns undefined for an address the resolver did not resolve", () => { - expect( - attachFromDisplay("stranger@acme.example", new Map([[SENDER, "X"]])), - ).toBeUndefined(); - }); - - test("returns undefined when the label just echoes the address", () => { - // Emitting this would make a client render the same string twice. - expect( - attachFromDisplay(SENDER, new Map([[SENDER, SENDER]])), - ).toBeUndefined(); - }); - - test("returns undefined when the label just echoes the whole header", () => { - const header = `"Bot" <${SENDER}>`; - expect( - attachFromDisplay(header, new Map([[SENDER, header]])), - ).toBeUndefined(); - }); -}); - -describe("the read path's sender-display seam", () => { - async function seed(from: string): Promise { - const written = await writeMailboxMessage(db, { - ...SCOPE, - address: "user-1@acme.example", - fromAddress: from, - subject: "Run finished", - body: "Body", - messageKey: crypto.randomUUID(), - }); - return written!.id; - } - - const resolver: SenderDisplayResolver = (_tenant, headers) => - new Map( - headers - .map(extractSenderMailboxAddress) - .filter((address) => address === SENDER) - .map((address) => [address, "Heartbeat"]), - ); - - test("no resolver means no fromDisplay, and `from` is still the raw header", async () => { - await seed(SENDER); - const page = await listUserMailbox(db, { - ...SCOPE, - limit: 10, - view: "all", - priorities: TEST_VOCABULARY.priorities, - }); - expect(page.items[0]?.from).toBe(SENDER); - expect(page.items[0]?.fromDisplay).toBeUndefined(); - }); - - test("a resolver stamps fromDisplay without replacing `from`", async () => { - await seed(SENDER); - const page = await listUserMailbox(db, { - priorities: TEST_VOCABULARY.priorities, - ...SCOPE, - limit: 10, - view: "all", - resolveSenderDisplays: resolver, - }); - expect(page.items[0]?.fromDisplay).toBe("Heartbeat"); - expect(page.items[0]?.from).toBe(SENDER); - }); - - test("unresolved senders on the same page keep no fromDisplay", async () => { - await seed(SENDER); - await seed("stranger@acme.example"); - const page = await listUserMailbox(db, { - priorities: TEST_VOCABULARY.priorities, - ...SCOPE, - limit: 10, - view: "all", - resolveSenderDisplays: resolver, - }); - const byFrom = new Map(page.items.map((m) => [m.from, m.fromDisplay])); - expect(byFrom.get(SENDER)).toBe("Heartbeat"); - expect(byFrom.get("stranger@acme.example")).toBeUndefined(); - }); - - test("the whole page is resolved in ONE call, not one call per message", async () => { - await seed(SENDER); - await seed("stranger@acme.example"); - const calls: string[][] = []; - await listUserMailbox(db, { - priorities: TEST_VOCABULARY.priorities, - ...SCOPE, - limit: 10, - view: "all", - resolveSenderDisplays: (_tenant, headers) => { - calls.push(headers); - return new Map(); - }, - }); - expect(calls).toHaveLength(1); - expect(calls[0]!.sort()).toEqual([SENDER, "stranger@acme.example"].sort()); - }); - - test("the resolver is handed the tenantId being read", async () => { - await seed(SENDER); - const tenants: string[] = []; - await listUserMailbox(db, { - priorities: TEST_VOCABULARY.priorities, - ...SCOPE, - limit: 10, - view: "all", - resolveSenderDisplays: (tenantId) => { - tenants.push(tenantId); - return new Map(); - }, - }); - expect(tenants).toEqual(["acme"]); - }); - - test("a resolver that throws costs the labels, not the page", async () => { - await seed(SENDER); - const page = await listUserMailbox(db, { - priorities: TEST_VOCABULARY.priorities, - ...SCOPE, - limit: 10, - view: "all", - resolveSenderDisplays: () => { - throw new Error("directory unavailable"); - }, - }); - expect(page.items).toHaveLength(1); - expect(page.items[0]?.from).toBe(SENDER); - expect(page.items[0]?.fromDisplay).toBeUndefined(); - }); - - test("the detail read applies the resolver too", async () => { - const id = await seed(SENDER); - const detail = await getMailboxMessage(db, { - ...SCOPE, - id, - resolveSenderDisplays: resolver, - }); - expect(detail?.fromDisplay).toBe("Heartbeat"); - }); - - test("mountMailbox threads its resolveSenderDisplays into list and detail", async () => { - const id = await seed(SENDER); - const app = new Hono(); - mountMailbox(app, { - vocabulary: TEST_VOCABULARY, - db, - bus: createInMemoryMailboxEventBus(), - resolvePrincipal: () => SCOPE, - resolveSenderDisplays: resolver, - }); - - const list = (await (await app.request("/me/inbox")).json()) as { - messages: { fromDisplay?: string }[]; - }; - expect(list.messages[0]?.fromDisplay).toBe("Heartbeat"); - - const detail = (await (await app.request(`/me/inbox/${id}`)).json()) as { - fromDisplay?: string; - }; - expect(detail.fromDisplay).toBe("Heartbeat"); - }); -}); diff --git a/src/thread.test.ts b/src/thread.test.ts deleted file mode 100644 index 1e3f501..0000000 --- a/src/thread.test.ts +++ /dev/null @@ -1,766 +0,0 @@ -// Thread reads are the one path that has to be right about *ancestry*, not -// just about scope: a fabricated parent silently reshapes a conversation, and -// a parent that changes when the reader turns the page is worse than none at -// all. Every test here pins one of those two properties. -import { beforeEach, describe, expect, test } from "bun:test"; -import { and, eq, sql } from "drizzle-orm"; -import { writeMailboxMessage } from "./write.js"; -import { principalMail } from "./schema.js"; -import { - readMailboxThread, - readMailboxMessageByMessageId, - decodeMailboxThreadCursor, - listMailboxThreads, - readMailboxThreadByMessageId, -} from "./thread.js"; -import { withTestDb, seedScope } from "./test-helpers.js"; -import type { MailboxDb } from "./db.js"; - -let db: MailboxDb; - -const WORKBENCH = { kind: "workbench", id: "wb-1" } as const; -const OTHER_WORKBENCH = { kind: "workbench", id: "wb-2" } as const; - -beforeEach(async () => { - db = await withTestDb(); - await seedScope(db, "t1", "p1", "p2"); -}); - -/** - * `writeMailboxMessage` mints the frame's Message-ID itself, so a test that - * wants to reply to a message has to read the minted id back — exactly as a - * caller threading a real conversation would. - */ -async function send(args: { - principalId?: string; - subject: string; - body?: string; - inReplyTo?: string; - references?: string[]; - refs?: { kind: string; id: string }[]; - messageKey: string; -}): Promise<{ id: string; messageId: string }> { - const principalId = args.principalId ?? "p1"; - const written = await writeMailboxMessage(db, { - tenantId: "t1", - principalId, - address: `${principalId}@t1.example`, - fromAddress: "sender@t1.example", - subject: args.subject, - body: args.body ?? "Body", - messageKey: args.messageKey, - ...(args.inReplyTo !== undefined ? { inReplyTo: args.inReplyTo } : {}), - ...(args.references !== undefined ? { references: args.references } : {}), - ...(args.refs !== undefined ? { refs: args.refs } : {}), - }); - const [row] = await db - .select({ messageId: principalMail.messageId }) - .from(principalMail) - .where(eq(principalMail.id, written!.id)); - return { id: written!.id, messageId: row!.messageId! }; -} - -describe("readMailboxThread", () => { - test("two replies with different In-Reply-To resolve to different parents", async () => { - const root = await send({ - subject: "Root", - refs: [WORKBENCH], - messageKey: "root", - }); - const first = await send({ - subject: "Re: Root", - inReplyTo: root.messageId, - references: [root.messageId], - refs: [WORKBENCH], - messageKey: "first", - }); - const second = await send({ - subject: "Re: Re: Root", - inReplyTo: first.messageId, - references: [root.messageId, first.messageId], - refs: [WORKBENCH], - messageKey: "second", - }); - - const page = await readMailboxThread( - db, - { tenantId: "t1", principalId: "p1" }, - { ref: WORKBENCH }, - ); - const byId = new Map(page.items.map((item) => [item.id, item])); - expect(page.items.map((item) => item.id)).toEqual([ - root.id, - first.id, - second.id, - ]); - expect(byId.get(root.id)?.parentId).toBeNull(); - expect(byId.get(first.id)?.parentId).toBe(root.id); - expect(byId.get(second.id)?.parentId).toBe(first.id); - }); - - test("each message carries its own decoded body", async () => { - const root = await send({ - subject: "Root", - body: "Root body text", - refs: [WORKBENCH], - messageKey: "root", - }); - const reply = await send({ - subject: "Re: Root", - body: "Reply body text", - inReplyTo: root.messageId, - references: [root.messageId], - refs: [WORKBENCH], - messageKey: "reply", - }); - - const page = await readMailboxThread( - db, - { tenantId: "t1", principalId: "p1" }, - { ref: WORKBENCH }, - ); - const byId = new Map(page.items.map((item) => [item.id, item.body])); - expect(byId.get(root.id)).toBe("Root body text"); - expect(byId.get(reply.id)).toBe("Reply body text"); - }); - - test("falls back to References, newest ancestor first, when In-Reply-To names nothing present", async () => { - const root = await send({ - subject: "Root", - refs: [WORKBENCH], - messageKey: "root", - }); - const middle = await send({ - subject: "Middle", - inReplyTo: root.messageId, - references: [root.messageId], - refs: [WORKBENCH], - messageKey: "middle", - }); - // In-Reply-To names a message nobody in this mailbox has; References - // carries the whole chain, and the NEWEST present ancestor wins. - const leaf = await send({ - subject: "Leaf", - inReplyTo: "", - references: [root.messageId, middle.messageId], - refs: [WORKBENCH], - messageKey: "leaf", - }); - - const page = await readMailboxThread( - db, - { tenantId: "t1", principalId: "p1" }, - { ref: WORKBENCH }, - ); - expect(page.items.find((item) => item.id === leaf.id)?.parentId).toBe( - middle.id, - ); - }); - - test("a parent outside the principal's mailbox yields null, never a fabricated node", async () => { - const foreign = await send({ - principalId: "p2", - subject: "Not yours", - refs: [WORKBENCH], - messageKey: "foreign", - }); - const reply = await send({ - subject: "Re: Not yours", - inReplyTo: foreign.messageId, - references: [foreign.messageId], - refs: [WORKBENCH], - messageKey: "reply", - }); - - const page = await readMailboxThread( - db, - { tenantId: "t1", principalId: "p1" }, - { ref: WORKBENCH }, - ); - expect(page.items.map((item) => item.id)).toEqual([reply.id]); - expect(page.items[0]?.parentId).toBeNull(); - }); - - test("a parent outside the ref yields null", async () => { - const elsewhere = await send({ - subject: "Other workbench", - refs: [OTHER_WORKBENCH], - messageKey: "elsewhere", - }); - const reply = await send({ - subject: "Re: Other workbench", - inReplyTo: elsewhere.messageId, - refs: [WORKBENCH], - messageKey: "reply", - }); - - const page = await readMailboxThread( - db, - { tenantId: "t1", principalId: "p1" }, - { ref: WORKBENCH }, - ); - expect(page.items.map((item) => item.id)).toEqual([reply.id]); - expect(page.items[0]?.parentId).toBeNull(); - }); - - test("the refs filter excludes other workbenches", async () => { - await send({ - subject: "Other", - refs: [OTHER_WORKBENCH], - messageKey: "other", - }); - const mine = await send({ - subject: "Mine", - refs: [WORKBENCH], - messageKey: "mine", - }); - await send({ subject: "Unreffed", messageKey: "unreffed" }); - - const page = await readMailboxThread( - db, - { tenantId: "t1", principalId: "p1" }, - { ref: WORKBENCH }, - ); - expect(page.items.map((item) => item.subject)).toEqual(["Mine"]); - expect(page.items[0]?.id).toBe(mine.id); - }); - - test("a chain spanning a page boundary keeps parentId stable across pages", async () => { - // The parent of the first row on page two lives on page one, so a - // resolver that only looked at the current page would answer null for it. - const chain: { id: string; messageId: string }[] = []; - let previous: { id: string; messageId: string } | undefined; - for (let index = 0; index < 5; index += 1) { - const message = await send({ - subject: `Message ${index}`, - refs: [WORKBENCH], - messageKey: `chain-${index}`, - ...(previous !== undefined - ? { - inReplyTo: previous.messageId, - references: chain.map((entry) => entry.messageId), - } - : {}), - }); - chain.push(message); - previous = message; - } - - const whole = await readMailboxThread( - db, - { tenantId: "t1", principalId: "p1" }, - { ref: WORKBENCH }, - ); - const expected = new Map( - whole.items.map((item) => [item.id, item.parentId]), - ); - expect(whole.nextCursor).toBeUndefined(); - expect([...expected.values()]).toEqual([ - null, - chain[0]!.id, - chain[1]!.id, - chain[2]!.id, - chain[3]!.id, - ]); - - const paged: { id: string; parentId: string | null }[] = []; - let cursor: string | undefined; - do { - const page = await readMailboxThread( - db, - { tenantId: "t1", principalId: "p1" }, - { ref: WORKBENCH, limit: 2, ...(cursor !== undefined ? { cursor } : {}) }, - ); - for (const item of page.items) { - paged.push({ id: item.id, parentId: item.parentId }); - } - cursor = page.nextCursor; - } while (cursor !== undefined); - - expect(paged.map((item) => item.id)).toEqual(chain.map((one) => one.id)); - for (const item of paged) { - expect(item.parentId).toBe(expected.get(item.id)!); - } - }); - - test("projects the threading headers and state without loading raw", async () => { - const root = await send({ - subject: "Root", - refs: [WORKBENCH], - messageKey: "root", - }); - const reply = await send({ - subject: "Re: Root", - inReplyTo: root.messageId, - references: [root.messageId], - refs: [WORKBENCH], - messageKey: "reply", - }); - - const page = await readMailboxThread( - db, - { tenantId: "t1", principalId: "p1" }, - { ref: WORKBENCH }, - ); - const item = page.items.find((one) => one.id === reply.id)!; - expect(item.messageId).toBe(reply.messageId); - expect(item.inReplyTo).toBe(root.messageId); - expect(item.references).toEqual([root.messageId]); - expect(item.fromAddress).toBe("sender@t1.example"); - expect(item.subject).toBe("Re: Root"); - expect(item.read).toBe(false); - expect(item.archived).toBe(false); - expect(item.createdAt).toMatch( - /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{6}Z$/, - ); - expect(page.items[0]?.references).toEqual([]); - }); - - test("refuses a cursor minted for a different ref", async () => { - await send({ subject: "Mine", refs: [WORKBENCH], messageKey: "mine" }); - await send({ subject: "Mine 2", refs: [WORKBENCH], messageKey: "mine2" }); - const first = await readMailboxThread( - db, - { tenantId: "t1", principalId: "p1" }, - { ref: WORKBENCH, limit: 1 }, - ); - expect(first.nextCursor).toBeDefined(); - expect( - readMailboxThread( - db, - { tenantId: "t1", principalId: "p1" }, - { ref: OTHER_WORKBENCH, cursor: first.nextCursor! }, - ), - ).rejects.toThrow(RangeError); - }); - - test("refuses a malformed cursor and an out-of-range limit", async () => { - expect(decodeMailboxThreadCursor("not-a-cursor")).toBeNull(); - expect( - readMailboxThread( - db, - { tenantId: "t1", principalId: "p1" }, - { ref: WORKBENCH, cursor: "not-a-cursor" }, - ), - ).rejects.toThrow(RangeError); - expect( - readMailboxThread( - db, - { tenantId: "t1", principalId: "p1" }, - { ref: WORKBENCH, limit: 0 }, - ), - ).rejects.toThrow(RangeError); - }); -}); - -describe("readMailboxMessageByMessageId", () => { - test("returns the principal's message", async () => { - const message = await send({ - subject: "Findable", - refs: [WORKBENCH], - messageKey: "findable", - }); - const found = await readMailboxMessageByMessageId( - db, - { tenantId: "t1", principalId: "p1" }, - message.messageId, - ); - expect(found?.id).toBe(message.id); - expect(found?.subject).toBe("Findable"); - expect(found?.refs).toEqual([WORKBENCH]); - }); - - test("another principal's message is null", async () => { - const foreign = await send({ - principalId: "p2", - subject: "Not yours", - messageKey: "foreign", - }); - expect( - await readMailboxMessageByMessageId( - db, - { tenantId: "t1", principalId: "p1" }, - foreign.messageId, - ), - ).toBeNull(); - // …and is readable by the principal it belongs to, so the null above is - // the scope filter rather than a lookup that never worked. - expect( - ( - await readMailboxMessageByMessageId( - db, - { tenantId: "t1", principalId: "p2" }, - foreign.messageId, - ) - )?.id, - ).toBe(foreign.id); - }); - - test("an unknown msg-id is null", async () => { - expect( - await readMailboxMessageByMessageId( - db, - { tenantId: "t1", principalId: "p1" }, - "", - ), - ).toBeNull(); - }); -}); - -describe("thread edge cases: cycles, tie-breaks, scope", () => { - /** - * These tests write rows directly (bypassing `writeMailboxMessage`, which - * mints its own Message-ID) so a scenario can pin exact msg-ids, exact - * `createdAt` ordering, and — for the cycle tests — headers a real MIME - * frame would never carry on its own but that RFC 5256 step 1.B still - * requires a reader to survive. - */ - async function insertRaw(args: { - id: string; - tenantId?: string; - messageId: string | null; - inReplyTo?: string | null; - references?: string[] | null; - createdAt: string; - refs?: unknown; - }): Promise { - await db.execute(sql` - INSERT INTO "mailbox"."principal_mail" - ("id","tenant_id","principal_id","address","direction","raw","message_id","in_reply_to","references","refs","created_at") - VALUES (${args.id}, ${args.tenantId ?? "t1"}, 'p1', 'p1@t1.example', 'inbound', ${Buffer.from("x")}, - ${args.messageId}, ${args.inReplyTo ?? null}, - ${ - args.references === undefined || args.references === null - ? null - : JSON.stringify(args.references) - }::jsonb, - ${JSON.stringify(args.refs ?? [WORKBENCH])}::jsonb, ${args.createdAt}::timestamp) - `); - } - - test("a message whose References names its own Message-ID is not its own parent", async () => { - await insertRaw({ - id: "a", - messageId: "", - inReplyTo: "", - references: [""], - createdAt: "2026-01-01T00:00:00.000001Z", - }); - const page = await readMailboxThread( - db, - { tenantId: "t1", principalId: "p1" }, - { ref: WORKBENCH }, - ); - expect(page.items[0]?.parentId).toBeNull(); - }); - - test("a mutual References cycle is broken: the LATER-created message becomes the root", async () => { - await insertRaw({ - id: "a", - messageId: "", - inReplyTo: "", - createdAt: "2026-01-01T00:00:00.000001Z", - }); - await insertRaw({ - id: "b", - messageId: "", - inReplyTo: "", - createdAt: "2026-01-01T00:00:00.000002Z", - }); - const page = await readMailboxThread( - db, - { tenantId: "t1", principalId: "p1" }, - { ref: WORKBENCH }, - ); - const byId = new Map(page.items.map((i) => [i.id, i.parentId])); - // b is later-created, so cutting b's edge breaks the cycle: a -> b -> null. - expect(byId.get("a")).toBe("b"); - expect(byId.get("b")).toBeNull(); - }); - - test("a child that is OLDER than its parent still resolves (ancestor map is not ordering-bound)", async () => { - await insertRaw({ - id: "child", - messageId: "", - inReplyTo: "", - createdAt: "2026-01-01T00:00:00.000001Z", - }); - await insertRaw({ - id: "parent", - messageId: "", - createdAt: "2026-01-02T00:00:00.000001Z", - }); - const page = await readMailboxThread( - db, - { tenantId: "t1", principalId: "p1" }, - { ref: WORKBENCH, limit: 1 }, - ); - expect(page.items[0]?.id).toBe("child"); - expect(page.items[0]?.parentId).toBe("parent"); - }); - - test("duplicate Message-ID: oldest carrier wins, and self is skipped even when a duplicate exists", async () => { - await insertRaw({ - id: "dup-old", - messageId: "", - createdAt: "2026-01-01T00:00:00.000001Z", - }); - await insertRaw({ - id: "dup-new", - messageId: "", - inReplyTo: "", - createdAt: "2026-01-01T00:00:00.000002Z", - }); - await insertRaw({ - id: "reply", - messageId: "", - inReplyTo: "", - createdAt: "2026-01-01T00:00:00.000003Z", - }); - const page = await readMailboxThread( - db, - { tenantId: "t1", principalId: "p1" }, - { ref: WORKBENCH }, - ); - const byId = new Map(page.items.map((i) => [i.id, i.parentId])); - expect(byId.get("reply")).toBe("dup-old"); - // dup-new names its own msg-id; the oldest carrier is dup-old, which is - // NOT itself, so it links there. - expect(byId.get("dup-new")).toBe("dup-old"); - const found = await readMailboxMessageByMessageId( - db, - { tenantId: "t1", principalId: "p1" }, - "", - ); - expect(found?.id).toBe("dup-old"); - }); - - test("same principalId under another tenant is invisible to lookup and thread", async () => { - await seedScope(db, "t2", "p1"); - await insertRaw({ - id: "other-tenant", - tenantId: "t2", - messageId: "", - createdAt: "2026-01-01T00:00:00.000001Z", - }); - expect( - await readMailboxMessageByMessageId( - db, - { tenantId: "t1", principalId: "p1" }, - "", - ), - ).toBeNull(); - const page = await readMailboxThread( - db, - { tenantId: "t1", principalId: "p1" }, - { ref: WORKBENCH }, - ); - expect(page.items).toEqual([]); - }); - - test("ref match is exact on kind and id; a ref carrying an extra label still matches", async () => { - await insertRaw({ - id: "labelled", - messageId: "", - createdAt: "2026-01-01T00:00:00.000001Z", - refs: [{ kind: "workbench", id: "wb-1", label: "L" }], - }); - await insertRaw({ - id: "prefix", - messageId: "", - createdAt: "2026-01-01T00:00:00.000002Z", - refs: [{ kind: "workbench", id: "wb-10" }], - }); - await insertRaw({ - id: "kind", - messageId: "", - createdAt: "2026-01-01T00:00:00.000003Z", - refs: [{ kind: "thread", id: "wb-1" }], - }); - const page = await readMailboxThread( - db, - { tenantId: "t1", principalId: "p1" }, - { ref: WORKBENCH }, - ); - expect(page.items.map((i) => i.id)).toEqual(["labelled"]); - }); - - test("a malformed references blob degrades to [] rather than failing the read", async () => { - await insertRaw({ - id: "bad", - messageId: "", - createdAt: "2026-01-01T00:00:00.000001Z", - }); - await db.execute( - sql`UPDATE "mailbox"."principal_mail" SET "references" = '{"not":"a list"}'::jsonb WHERE id = 'bad'`, - ); - const page = await readMailboxThread( - db, - { tenantId: "t1", principalId: "p1" }, - { ref: WORKBENCH }, - ); - expect(page.items[0]?.references).toEqual([]); - }); - - test("EXPLAIN: ref-filtered thread page uses the GIN index once the table is large", async () => { - await db.execute(sql` - INSERT INTO "mailbox"."principal_mail" ("tenant_id","principal_id","address","direction","raw","message_id","refs","created_at") - SELECT 't1','p1','p1@t1.example','inbound', ${Buffer.from("x")}, '', - jsonb_build_array(jsonb_build_object('kind','workbench','id','wb-' || (g % 500))), - now() - (g || ' seconds')::interval - FROM generate_series(1, 50000) g`); - await db.execute(sql`ANALYZE "mailbox"."principal_mail"`); - const plan = await db.execute<{ "QUERY PLAN": string }>(sql` - EXPLAIN SELECT pm.id FROM "mailbox"."principal_mail" pm - LEFT JOIN "mailbox"."mailbox" m ON m.id = pm.id - WHERE pm.tenant_id = 't1' AND pm.principal_id = 'p1' AND pm.direction = 'inbound' - AND pm.refs @> '[{"kind":"workbench","id":"wb-1"}]'::jsonb - ORDER BY pm.created_at ASC, pm.id ASC LIMIT 51`); - const text = plan.map((r) => r["QUERY PLAN"]).join("\n"); - expect(text).toContain("principal_mail_refs_idx"); - const lookup = await db.execute<{ "QUERY PLAN": string }>(sql` - EXPLAIN SELECT pm.id FROM "mailbox"."principal_mail" pm - WHERE pm.tenant_id = 't1' AND pm.principal_id = 'p1' AND pm.direction = 'inbound' - AND pm.refs @> '[{"kind":"workbench","id":"wb-1"}]'::jsonb - AND pm.message_id IN ('','')`); - const ltext = lookup.map((r) => r["QUERY PLAN"]).join("\n"); - expect(ltext).toContain("principal_mail_tenant_id_principal_id_message_id_idx"); - // Bulk inserts of this size (needed for a realistic planner decision) run - // past bun's default per-test timeout. - }, 30000); - - test("EXPLAIN: a large, time-clustered ref pages via an Index Scan with a Limit, not a full sort", async () => { - // 300k rows in the mailbox; the 50k newest of them all carry the SAME - // ref, so the ref is both large (in absolute row count) and clustered at - // one end of the created_at range — the shape that makes a page have to - // choose between scanning the ordered btree with a Filter, or bitmapping - // the GIN index and sorting every one of the ref's rows. - await db.execute(sql` - INSERT INTO "mailbox"."principal_mail" ("tenant_id","principal_id","address","direction","raw","message_id","refs","created_at") - SELECT 't1','p1','p1@t1.example','inbound', ${Buffer.from("x")}, '', - jsonb_build_array(jsonb_build_object( - 'kind','workbench', - 'id', CASE WHEN g <= 50000 THEN 'wb-1' ELSE 'wb-' || (g % 6000) END - )), - now() - (g || ' seconds')::interval - FROM generate_series(1, 300000) g`); - await db.execute(sql`ANALYZE "mailbox"."principal_mail"`); - const plan = await db.execute<{ "QUERY PLAN": string }>(sql` - EXPLAIN (ANALYZE, BUFFERS) SELECT pm.id FROM "mailbox"."principal_mail" pm - LEFT JOIN "mailbox"."mailbox" m ON m.id = pm.id - WHERE pm.tenant_id = 't1' AND pm.principal_id = 'p1' AND pm.direction = 'inbound' - AND pm.refs @> '[{"kind":"workbench","id":"wb-1"}]'::jsonb - ORDER BY pm.created_at ASC, pm.id ASC LIMIT 51`); - const text = plan.map((r) => r["QUERY PLAN"]).join("\n"); - expect(text).toContain("Limit"); - expect(text).toContain("Index Scan"); - expect(text).not.toContain("Sort Key"); - }, 30000); -}); - -describe("the cached references column", () => { - test("caches what the frame carries, so the thread read never touches raw", async () => { - const root = await send({ - subject: "Root", - refs: [WORKBENCH], - messageKey: "root", - }); - await send({ - subject: "Re: Root", - inReplyTo: root.messageId, - references: [root.messageId], - refs: [WORKBENCH], - messageKey: "reply", - }); - const rows = await db - .select({ references: principalMail.references }) - .from(principalMail) - .where( - and( - eq(principalMail.tenantId, "t1"), - eq(principalMail.principalId, "p1"), - eq(principalMail.subject, "Re: Root"), - ), - ); - expect(rows[0]?.references).toEqual([root.messageId]); - }); -}); - -describe("listMailboxThreads", () => { - test("scopes to the refs filter: a thread with no message in the given refs is excluded", async () => { - await send({ subject: "In workbench", refs: [WORKBENCH], messageKey: "in" }); - await send({ - subject: "In other workbench", - refs: [OTHER_WORKBENCH], - messageKey: "out", - }); - - const page = await listMailboxThreads( - db, - { tenantId: "t1", principalId: "p1" }, - { refs: [WORKBENCH] }, - ); - expect(page.items.map((item) => item.rootMessageId).length).toBe(1); - expect(page.items[0]?.subject).toBe("In workbench"); - }); -}); - -describe("readMailboxThreadByMessageId", () => { - test("returns the thread for its root Message-ID", async () => { - const root = await send({ subject: "Root", messageKey: "root" }); - const reply = await send({ - subject: "Re: Root", - inReplyTo: root.messageId, - references: [root.messageId], - messageKey: "reply", - }); - - const page = await readMailboxThreadByMessageId( - db, - { tenantId: "t1", principalId: "p1" }, - { rootMessageId: root.messageId }, - ); - expect(page?.items.map((item) => item.id)).toEqual([root.id, reply.id]); - }); - - test("each message carries its own decoded body", async () => { - const root = await send({ - subject: "Root", - body: "Root body text", - messageKey: "root", - }); - const reply = await send({ - subject: "Re: Root", - body: "Reply body text", - inReplyTo: root.messageId, - references: [root.messageId], - messageKey: "reply", - }); - - const page = await readMailboxThreadByMessageId( - db, - { tenantId: "t1", principalId: "p1" }, - { rootMessageId: root.messageId }, - ); - const byId = new Map(page?.items.map((item) => [item.id, item.body])); - expect(byId.get(root.id)).toBe("Root body text"); - expect(byId.get(reply.id)).toBe("Reply body text"); - }); - - test("a non-root Message-ID resolves to null", async () => { - const root = await send({ subject: "Root", messageKey: "root" }); - const reply = await send({ - subject: "Re: Root", - inReplyTo: root.messageId, - references: [root.messageId], - messageKey: "reply", - }); - - const page = await readMailboxThreadByMessageId( - db, - { tenantId: "t1", principalId: "p1" }, - { rootMessageId: reply.messageId }, - ); - expect(page).toBeNull(); - }); -}); diff --git a/src/vocabulary.test.ts b/src/vocabulary.test.ts deleted file mode 100644 index 713950f..0000000 --- a/src/vocabulary.test.ts +++ /dev/null @@ -1,200 +0,0 @@ -// The package ships mechanism, the host ships opinion. These cases hold that -// line from both ends: what the package refuses to accept as a vocabulary, and -// what changes when a host changes its own. -// -// The cursor fingerprint is the sharp edge. A priority keyset's leading -// component is an INTEGER RANK read out of the host's ordering, so reordering -// that list silently redefines every rank an in-flight cursor carries. The -// filter fingerprint already established the precedent; this is the same -// mechanism applied to the same class of bug. -import { beforeEach, describe, expect, test } from "bun:test"; -import { Hono } from "hono"; -import { mountMailbox } from "./mount.js"; -import { createInMemoryMailboxEventBus } from "./bus.js"; -import { writeMailboxMessage } from "./write.js"; -import { enrichMailboxMessage } from "./mutations.js"; -import { decodeMailboxListCursor } from "./read.js"; -import { - assertMailboxVocabulary, - canonicalMailboxPriorities, -} from "./vocabulary.js"; -import { withTestDb, seedScope } from "./test-helpers.js"; -import type { MailboxDb } from "./db.js"; - -let db: MailboxDb; -const SCOPE = { tenantId: "acme", principalId: "user-1" }; - -beforeEach(async () => { - db = await withTestDb(); - await seedScope(db, "acme", "user-1"); -}); - -const ORDER = ["urgent", "high", "normal", "low"]; -const REORDERED = ["high", "urgent", "normal", "low"]; - -function appWith(priorities: readonly string[]) { - const app = new Hono(); - mountMailbox(app, { - db, - bus: createInMemoryMailboxEventBus(), - resolvePrincipal: () => SCOPE, - vocabulary: { priorities, statuses: ["needs-action", "done"] }, - }); - return app; -} - -async function seed(priority: string): Promise { - const written = await writeMailboxMessage(db, { - ...SCOPE, - address: "user-1@acme.example", - fromAddress: "bot@acme.example", - subject: `p-${priority}-${crypto.randomUUID()}`, - body: "body", - messageKey: crypto.randomUUID(), - }); - await enrichMailboxMessage(db, { ...SCOPE, id: written!.id }, { priority }); - return written!.id; -} - -describe("assertMailboxVocabulary", () => { - test("refuses an empty priority or status list", () => { - expect(() => - assertMailboxVocabulary({ priorities: [], statuses: ["done"] }), - ).toThrow(RangeError); - expect(() => - assertMailboxVocabulary({ priorities: ["high"], statuses: [] }), - ).toThrow(RangeError); - }); - - test("refuses duplicates rather than silently keeping the first rank", () => { - expect(() => - assertMailboxVocabulary({ - priorities: ["high", "low", "high"], - statuses: ["done"], - }), - ).toThrow(/duplicates/); - }); - - test("refuses a blank value, which no request could ever name", () => { - expect(() => - assertMailboxVocabulary({ priorities: ["high", ""], statuses: ["done"] }), - ).toThrow(/blank/); - }); - - test("mountMailbox refuses a bad vocabulary at mount, not on first request", () => { - expect(() => - mountMailbox(new Hono(), { - db, - bus: createInMemoryMailboxEventBus(), - resolvePrincipal: () => SCOPE, - vocabulary: { priorities: [], statuses: ["done"] }, - }), - ).toThrow(RangeError); - }); - - test("accepts an ordinary host vocabulary", () => { - expect(() => - assertMailboxVocabulary({ priorities: ORDER, statuses: ["done"] }), - ).not.toThrow(); - }); -}); - -describe("canonicalMailboxPriorities", () => { - test("changes when the order changes, so a reorder is detectable", () => { - expect(canonicalMailboxPriorities(ORDER)).not.toBe( - canonicalMailboxPriorities(REORDERED), - ); - }); - - test("is stable for the same list, so an unchanged host keeps paging", () => { - expect(canonicalMailboxPriorities(ORDER)).toBe( - canonicalMailboxPriorities([...ORDER]), - ); - }); - - test("escapes its values, so two lists cannot collide through the separator", () => { - expect(canonicalMailboxPriorities(["a,b", "c"])).not.toBe( - canonicalMailboxPriorities(["a", "b,c"]), - ); - }); -}); - -describe("a priority cursor is bound to the ordering it was minted under", () => { - async function mintCursor(priorities: readonly string[]): Promise { - for (const priority of ORDER) await seed(priority); - const res = await appWith(priorities).request( - "/me/inbox?sort=priority&limit=2", - ); - const body = (await res.json()) as { nextCursor?: string }; - return body.nextCursor!; - } - - test("the minted cursor carries the ordering, not just the rank", async () => { - const cursor = await mintCursor(ORDER); - const decoded = decodeMailboxListCursor(cursor)!; - expect(decoded.priorities).toBe(canonicalMailboxPriorities(ORDER)); - expect(decoded.rank).toBeNumber(); - }); - - test("a host that reorders its vocabulary gets a 400, not a wrong page", async () => { - const cursor = await mintCursor(ORDER); - const res = await appWith(REORDERED).request( - `/me/inbox?sort=priority&limit=2&cursor=${encodeURIComponent(cursor)}`, - ); - expect(res.status).toBe(400); - expect(await res.json()).toEqual({ - error: "cursor does not match inbox priority ordering", - }); - }); - - test("the unchanged ordering pages through fine", async () => { - const cursor = await mintCursor(ORDER); - const res = await appWith(ORDER).request( - `/me/inbox?sort=priority&limit=2&cursor=${encodeURIComponent(cursor)}`, - ); - expect(res.status).toBe(200); - const body = (await res.json()) as { messages: { id: string }[] }; - expect(body.messages.length).toBeGreaterThan(0); - }); - - test("a date-sorted cursor is unaffected by a reorder, carrying no ranking", async () => { - for (const priority of ORDER) await seed(priority); - const first = await appWith(ORDER).request("/me/inbox?limit=2"); - const { nextCursor } = (await first.json()) as { nextCursor?: string }; - expect(decodeMailboxListCursor(nextCursor!)!.priorities).toBeUndefined(); - const res = await appWith(REORDERED).request( - `/me/inbox?limit=2&cursor=${encodeURIComponent(nextCursor!)}`, - ); - expect(res.status).toBe(200); - }); -}); - -describe("the ranking itself is the host's list", () => { - test("sort=priority follows the host's order, not any order this package holds", async () => { - for (const priority of ORDER) await seed(priority); - - const read = async (priorities: readonly string[]) => { - const res = await appWith(priorities).request( - "/me/inbox?sort=priority&limit=50", - ); - const body = (await res.json()) as { messages: { priority?: string }[] }; - return body.messages.map((m) => m.priority); - }; - - expect(await read(ORDER)).toEqual(ORDER); - // Same rows, same request — only the host's ordering moved. - expect(await read(REORDERED)).toEqual(REORDERED); - }); - - test("a priority the host no longer lists ranks last, not first", async () => { - await seed("urgent"); - await seed("low"); - // "urgent" is dropped from the vocabulary; its stored rows must fall to the - // bottom rather than sorting ahead of everything as rank 0. - const res = await appWith(["low", "normal"]).request( - "/me/inbox?sort=priority&limit=50", - ); - const body = (await res.json()) as { messages: { priority?: string }[] }; - expect(body.messages.map((m) => m.priority)).toEqual(["low", "urgent"]); - }); -}); From 6aa29a0507ee999fcb71a1886264657a725c335a Mon Sep 17 00:00:00 2001 From: Sawyer Date: Thu, 17 Sep 2026 13:00:19 -0700 Subject: [PATCH 04/17] Routes over the native store --- src/bus.test.ts | 15 - src/bus.ts | 27 +- src/index.ts | 137 +--- src/mount-native.test.ts | 154 ++++ src/mount.test.ts | 43 +- src/mount.ts | 930 +++++------------------- src/mutations.ts | 306 -------- src/persist.test.ts | 249 +------ src/persist.ts | 300 ++------ src/purge.test.ts | 82 +-- src/read.ts | 703 ------------------ src/sse-heartbeat.test.ts | 5 +- src/sse-stream.test.ts | 6 +- src/test-helpers.ts | 11 - src/thread.ts | 1117 ----------------------------- src/vocabulary.ts | 86 --- src/write.test.ts | 1410 ++++--------------------------------- src/write.ts | 529 +++----------- 18 files changed, 663 insertions(+), 5447 deletions(-) create mode 100644 src/mount-native.test.ts delete mode 100644 src/mutations.ts delete mode 100644 src/read.ts delete mode 100644 src/thread.ts delete mode 100644 src/vocabulary.ts diff --git a/src/bus.test.ts b/src/bus.test.ts index 1d8885b..b72b24e 100644 --- a/src/bus.test.ts +++ b/src/bus.test.ts @@ -1,12 +1,10 @@ import { describe, test, expect } from "bun:test"; import { type } from "arktype"; import { - MAILBOX_EVENT_OPS, MailboxEventSchema, publishMailboxEvent, type MailboxEvent, } from "./bus.js"; -import { MAILBOX_BULK_ACTIONS } from "./mutations.js"; const SCOPE = { tenantId: "t1", principalId: "p1" }; const noopLogger = { error: () => {} }; @@ -38,19 +36,6 @@ describe("MailboxEventSchema", () => { }); }); -describe("MAILBOX_EVENT_OPS vs MAILBOX_BULK_ACTIONS", () => { - // MAILBOX_EVENT_OPS is duplicated from MAILBOX_BULK_ACTIONS rather than - // importing it (to avoid a bus.ts -> mutations.ts -> write.ts -> bus.ts - // cycle), and nothing at runtime enforces that the copy stays in sync. - // This is that enforcement: a bulk action added to mutations.ts without a - // matching entry here fails this test instead of silently losing its op. - test("every bulk action has a matching event op", () => { - for (const action of MAILBOX_BULK_ACTIONS) { - expect(MAILBOX_EVENT_OPS).toContain(action); - } - }); -}); - describe("publishMailboxEvent", () => { test("publishes op on the event", () => { const seen: MailboxEvent[] = []; diff --git a/src/bus.ts b/src/bus.ts index 019cc72..fc5e3a5 100644 --- a/src/bus.ts +++ b/src/bus.ts @@ -1,26 +1,11 @@ import { type } from "arktype"; /** - * The operation that produced an event, when the publisher knows it. Mirrors - * `MailboxBulkAction` in mutations.ts (`mark_read`, `mark_unread`, `trash`, - * `archive`, `restore`) plus the three operations mutations.ts does not own: - * `create` (a new message landed, from `writeMailboxMessage`, - * `deliverInboxItems`, or `createMailboxPersist`), `enrich` (triage stamp), - * `assign` (delegation). Duplicated here rather than imported from - * mutations.ts to avoid a bus.ts -> mutations.ts -> write.ts -> bus.ts import - * cycle; mount.ts's route table keeps the two lists in sync, and - * `bus.test.ts` asserts every `MailboxBulkAction` value is a member of this - * list. - * - * This is a deliberately different name from its two siblings, not an - * accident: mount.ts's HTTP route table calls the same five shared values a - * "verb" (the path segment), mutations.ts calls them an "action", and this - * is an "op". The three names share five values because a single-message - * mutation and its bulk equivalent report the same op, but `MailboxEventOp` - * is a strict superset — `create`, `enrich`, `assign` are not bulk actions - * and never will be — so this stays its own vocabulary rather than - * importing/aliasing `MailboxBulkAction`, which would claim an equivalence - * the two sets don't have. + * The operation that produced an event, when the publisher knows it: `create` + * (a new message landed, from `writeMailboxMessage`, `deliverInboxItems`, or + * `createMailboxPersist`) plus the five verbs `mount.ts`'s route table + * registers over the native store (`mark_read`, `mark_unread`, `archive`, + * `trash`, `restore`). */ export const MAILBOX_EVENT_OPS = [ "create", @@ -29,8 +14,6 @@ export const MAILBOX_EVENT_OPS = [ "trash", "archive", "restore", - "enrich", - "assign", ] as const; export type MailboxEventOp = (typeof MAILBOX_EVENT_OPS)[number]; diff --git a/src/index.ts b/src/index.ts index f97ec44..d2cc497 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,4 +1,7 @@ -// @corbits/mailbox — a backend-only, mountable principal-keyed inbox. +// @corbits/mailbox — a backend-only, mountable NATIVE Interchange mailbox for +// human principals. This library exists ONLY to give a human principal a +// native `@intx/mailbox` `MailboxStore` over Postgres, and the routes that +// let a host's UI list, read, and file it — nothing else. export { mountMailbox, MAX_MAILBOX_PAGE_LIMIT, @@ -22,9 +25,8 @@ export { createMailboxDb } from "./db.js"; export type { MailboxDb } from "./db.js"; // The native `MailboxStore` over `mailbox.principal_mail` / -// `mailbox.mailbox_state` (migration `0004_native_mailbox_store`) — the -// vendored `executeSearch`/`executeThread` from `@intx/mailbox` run over it -// unmodified. +// `mailbox.mailbox_state` — the vendored `executeSearch`/`executeThread` from +// `@intx/mailbox` run over it unmodified. export { createPrincipalMailboxStore, openNativeMailboxStore, @@ -32,26 +34,8 @@ export { } from "./native-store.js"; export type { NativeMailboxStore } from "./native-store.js"; -// Two tables: the immutable mail plane, and the mutable management layer keyed -// by mail id. There is no `mailboxPriorities`/`mailboxStatuses` export and no -// `MailboxPriority`/`MailboxStatus` type — the vocabulary is the host's, passed -// to `mountMailbox`. -export { principalMail, mailbox, mailboxPgSchema } from "./schema.js"; -export type { - PrincipalMailRow, - PrincipalMailInsert, - MailboxRow, - MailboxInsert, - MailboxStateColumns, - MailboxJoinedRow, -} from "./schema.js"; - -export { - assertMailboxVocabulary, - canonicalMailboxPriorities, - priorityRank, -} from "./vocabulary.js"; -export type { MailboxVocabulary } from "./vocabulary.js"; +export { principalMail, mailboxPgSchema } from "./schema.js"; +export type { PrincipalMailRow, PrincipalMailInsert } from "./schema.js"; // Blank-scope refusal at the boundary (nicer than an FK violation's stack), // and explicit offboarding tools for hosts that manage deletion themselves — @@ -61,6 +45,8 @@ export { assertMailboxTenantId, MailboxScopeIdSchema, MailboxScopeIdsSchema, + MAX_MAILBOX_FRAME_BYTES, + assertMailboxFrameBytes, } from "./write.js"; export type { MailboxScopeIds } from "./write.js"; @@ -80,17 +66,10 @@ export type { export { writeMailboxMessage, - writeMailboxMessages, deliverInboxItems, - mailboxKey, - MAX_MAILBOX_REFS, - MAX_MAILBOX_FRAME_BYTES, - assertMailboxFrameBytes, } from "./write.js"; export type { WriteMailboxMessageArgs, - WriteMailboxMessagesItem, - WriteMailboxMessagesOpts, InboxItem, DeliverInboxItemsOpts, DeliveredInboxItem, @@ -102,12 +81,6 @@ export { MESSAGE_ID_FALLBACK_DOMAIN, } from "./frame.js"; -export { - extractSenderMailboxAddress, - attachFromDisplay, -} from "./read.js"; -export type { SenderDisplayResolver } from "./read.js"; - export { createMailboxPersist, MAX_MAILBOX_RECIPIENTS } from "./persist.js"; export type { MailboxPersistArgs, @@ -119,93 +92,3 @@ export type { export { parseAddressList, resolveMailboxRecipients } from "./recipients.js"; export type { ResolvedRecipient } from "./recipients.js"; - -export { - listUserMailbox, - getMailboxMessage, - MailboxMessageSchema, - MailboxMessageDetailSchema, - MailboxListResponseSchema, -} from "./read.js"; -export type { - MailboxMessage, - MailboxMessageDetail, - MailboxScope, - MailboxPage, -} from "./read.js"; - -// Thread reads: the conversation under one entity ref, parents resolved by -// RFC 5256 References linking (never by subject), and the msg-id lookup. -export { - readMailboxThread, - readMailboxMessageByMessageId, - canonicalMailboxThreadRef, - encodeMailboxThreadCursor, - decodeMailboxThreadCursor, - MailboxThreadMessageSchema, - MailboxThreadResponseSchema, - DEFAULT_MAILBOX_THREAD_LIMIT, - MAX_MAILBOX_THREAD_LIMIT, - listMailboxThreads, - readMailboxThreadByMessageId, - encodeMailboxThreadListCursor, - decodeMailboxThreadListCursor, - MailboxThreadSummarySchema, - MailboxThreadListResponseSchema, - DEFAULT_MAILBOX_THREAD_LIST_LIMIT, - MAX_MAILBOX_THREAD_LIST_LIMIT, -} from "./thread.js"; -export type { - MailboxThreadScope, - MailboxThreadArgs, - MailboxThreadMessage, - MailboxThreadPage, - MailboxThreadCursor, - MailboxThreadSummary, - MailboxThreadListPage, - MailboxThreadListArgs, - MailboxThreadByMessageIdArgs, -} from "./thread.js"; - -export { - markMailboxMessageRead, - markMailboxMessageUnread, - archiveMailboxMessage, - trashMailboxMessage, - restoreMailboxMessage, - countUnreadActiveMailbox, - applyMailboxBulkAction, - enrichMailboxMessage, - assignMailboxMessage, - MailboxEnrichmentSchema, - MailboxAssignmentSchema, - MAX_BULK_MAILBOX_IDS, - MAILBOX_BULK_ACTIONS, -} from "./mutations.js"; -export type { - MailboxMutationScope, - MailboxBulkAction, - BulkMailboxResult, - MailboxEnrichment, - MailboxAssignment, -} from "./mutations.js"; - -export { MailboxRefSchema, MailboxRefArraySchema } from "./read.js"; -export type { MailboxRef } from "./read.js"; - -export { - MailboxInboxViewSchema, - MailboxSortSchema, - MailboxFilterSchema, - MAILBOX_VIEWS, - MAILBOX_SORTS, - canonicalMailboxFilter, - encodeMailboxListCursor, - decodeMailboxListCursor, -} from "./read.js"; -export type { - MailboxInboxView, - MailboxSort, - MailboxFilter, - MailboxListCursor, -} from "./read.js"; diff --git a/src/mount-native.test.ts b/src/mount-native.test.ts new file mode 100644 index 0000000..6910e47 --- /dev/null +++ b/src/mount-native.test.ts @@ -0,0 +1,154 @@ +// The thin route layer over the native store: list (search + keyset), +// read/unread flags, and archive/trash/restore moves. +import { beforeEach, describe, expect, test } from "bun:test"; +import { Hono } from "hono"; +import { mountMailbox } from "./mount.js"; +import { createInMemoryMailboxEventBus } from "./bus.js"; +import { writeMailboxMessage } from "./write.js"; +import { withTestDb, seedScope } from "./test-helpers.js"; +import type { MailboxDb } from "./db.js"; + +let db: MailboxDb; +const SCOPE = { tenantId: "t1", principalId: "p1" }; + +beforeEach(async () => { + db = await withTestDb(); + await seedScope(db, SCOPE.tenantId, SCOPE.principalId); +}); + +function buildApp() { + const app = new Hono(); + mountMailbox(app, { + db, + bus: createInMemoryMailboxEventBus(), + resolvePrincipal: () => SCOPE, + }); + return app; +} + +async function seedMessage(subject: string) { + const written = await writeMailboxMessage(db, { + ...SCOPE, + address: "p1@t1.example", + fromAddress: "a@t1.example", + subject, + body: "Body", + }); + return written!.uid; +} + +describe("GET /me/inbox", () => { + test("lists newest first with envelope and raw", async () => { + await seedMessage("First"); + await seedMessage("Second"); + const app = buildApp(); + const res = await app.request("/me/inbox"); + expect(res.status).toBe(200); + const body = (await res.json()) as { + messages: { uid: number; envelope: { subject: string }; raw: string }[]; + }; + expect(body.messages.map((m) => m.envelope.subject)).toEqual([ + "Second", + "First", + ]); + expect(body.messages[0]!.raw.length).toBeGreaterThan(0); + }); + + test("keyset-paginates by uid", async () => { + for (let i = 0; i < 3; i++) await seedMessage(`Msg ${i}`); + const app = buildApp(); + const page1 = await app.request("/me/inbox?limit=2"); + const body1 = (await page1.json()) as { + messages: { uid: number }[]; + nextCursor?: string; + }; + expect(body1.messages).toHaveLength(2); + expect(body1.nextCursor).toBeDefined(); + + const page2 = await app.request(`/me/inbox?limit=2&cursor=${body1.nextCursor}`); + const body2 = (await page2.json()) as { messages: { uid: number }[] }; + expect(body2.messages).toHaveLength(1); + expect(body2.messages[0]!.uid).toBeLessThan(body1.messages[1]!.uid); + }); + + test("invalid folder is a 400", async () => { + const app = buildApp(); + const res = await app.request("/me/inbox?folder=bogus"); + expect(res.status).toBe(400); + }); +}); + +describe("read/unread", () => { + test("read sets \\Seen, unread clears it", async () => { + const uid = await seedMessage("Hi"); + const app = buildApp(); + const readRes = await app.request(`/me/inbox/${uid}/read`, { method: "POST" }); + expect(readRes.status).toBe(200); + + const list = await (await app.request("/me/inbox")).json() as { + messages: { uid: number; flags: string[] }[]; + }; + expect(list.messages[0]!.flags).toContain("\\Seen"); + + const unreadRes = await app.request(`/me/inbox/${uid}/unread`, { + method: "POST", + }); + expect(unreadRes.status).toBe(200); + const list2 = await (await app.request("/me/inbox")).json() as { + messages: { uid: number; flags: string[] }[]; + }; + expect(list2.messages[0]!.flags).not.toContain("\\Seen"); + }); + + test("unknown uid is a 404", async () => { + const app = buildApp(); + const res = await app.request("/me/inbox/999/read", { method: "POST" }); + expect(res.status).toBe(404); + }); +}); + +describe("archive/trash/restore", () => { + test("archive moves the message out of INBOX and into Archive", async () => { + const uid = await seedMessage("To archive"); + const app = buildApp(); + const res = await app.request(`/me/inbox/${uid}/archive`, { + method: "POST", + }); + expect(res.status).toBe(200); + + const inbox = await (await app.request("/me/inbox")).json() as { + messages: unknown[]; + }; + expect(inbox.messages).toHaveLength(0); + const archive = await ( + await app.request("/me/inbox?folder=Archive") + ).json() as { messages: unknown[] }; + expect(archive.messages).toHaveLength(1); + }); + + test("restore moves a message back into INBOX from Archive", async () => { + const uid = await seedMessage("Round trip"); + const app = buildApp(); + await app.request(`/me/inbox/${uid}/archive`, { method: "POST" }); + const archived = await ( + await app.request("/me/inbox?folder=Archive") + ).json() as { messages: { uid: number }[] }; + const archivedUid = archived.messages[0]!.uid; + + const restoreRes = await app.request( + `/me/inbox/${archivedUid}/restore?folder=Archive`, + { method: "POST" }, + ); + expect(restoreRes.status).toBe(200); + const inbox = await (await app.request("/me/inbox")).json() as { + messages: unknown[]; + }; + expect(inbox.messages).toHaveLength(1); + }); + + test("trash on an unknown uid is a 404", async () => { + const app = buildApp(); + const res = await app.request("/me/inbox/999/trash", { method: "POST" }); + expect(res.status).toBe(404); + }); +}); diff --git a/src/mount.test.ts b/src/mount.test.ts index e37183a..16fa441 100644 --- a/src/mount.test.ts +++ b/src/mount.test.ts @@ -2,7 +2,7 @@ import { beforeEach, describe, expect, test } from "bun:test"; import { Hono } from "hono"; import { mountMailbox } from "./mount.js"; import { createInMemoryMailboxEventBus } from "./bus.js"; -import { withTestDb, seedScope, TEST_VOCABULARY } from "./test-helpers.js"; +import { withTestDb, seedScope } from "./test-helpers.js"; import type { MailboxDb } from "./db.js"; let db: MailboxDb; @@ -20,7 +20,6 @@ function buildApp( db, bus: createInMemoryMailboxEventBus(), resolvePrincipal, - vocabulary: TEST_VOCABULARY, }); return app; } @@ -33,52 +32,20 @@ describe("no-member asymmetry", () => { expect(await res.json()).toEqual({ messages: [] }); }); - test("unread-count returns 0 with 200 when resolvePrincipal yields null", async () => { - const app = buildApp(() => null); - const res = await app.request("/me/inbox/unread-count"); - expect(res.status).toBe(200); - expect(await res.json()).toEqual({ unread: 0 }); - }); - test("events returns 403 when resolvePrincipal yields null", async () => { const app = buildApp(() => null); const res = await app.request("/me/inbox/events"); expect(res.status).toBe(403); }); - test("detail returns 403 when resolvePrincipal yields null", async () => { - const app = buildApp(() => null); - const res = await app.request( - "/me/inbox/00000000-0000-0000-0000-000000000000", - ); - expect(res.status).toBe(403); - }); - - // Every single-message verb funnels through the same `singleMutation` - // helper, but the table below is what proves each registered route actually - // reaches it — a verb wired straight to its handler would slip past a - // one-verb test. + // Every single-message verb funnels through its own store lookup, but the + // table below is what proves each registered route actually reaches it — a + // verb wired straight to its handler would slip past a one-verb test. for (const verb of ["read", "unread", "trash", "archive", "restore"]) { test(`${verb} mutation returns 403 when resolvePrincipal yields null`, async () => { const app = buildApp(() => null); - const res = await app.request( - `/me/inbox/00000000-0000-0000-0000-000000000000/${verb}`, - { method: "POST" }, - ); + const res = await app.request(`/me/inbox/1/${verb}`, { method: "POST" }); expect(res.status).toBe(403); }); } - - test("bulk returns 403 when resolvePrincipal yields null", async () => { - const app = buildApp(() => null); - const res = await app.request("/me/inbox/bulk", { - method: "POST", - headers: { "content-type": "application/json" }, - body: JSON.stringify({ - action: "mark_read", - ids: ["00000000-0000-0000-0000-000000000000"], - }), - }); - expect(res.status).toBe(403); - }); }); diff --git a/src/mount.ts b/src/mount.ts index 9a2bdb4..6e41319 100644 --- a/src/mount.ts +++ b/src/mount.ts @@ -1,8 +1,8 @@ -import { type } from "arktype"; import type { Context, Env, Hono } from "hono"; import { streamSSE } from "hono/streaming"; import { describeRoute } from "hono-openapi"; import { getLogger } from "@intx/log"; +import { executeSearch } from "@intx/mailbox"; import type { MailboxDb } from "./db.js"; import { publishMailboxEvent, @@ -10,52 +10,7 @@ import { type MailboxEventBus, type MailboxEventOp, } from "./bus.js"; -import { - countUnreadActiveMailbox, - markMailboxMessageUnread, - archiveMailboxMessage, - trashMailboxMessage, - restoreMailboxMessage, - applyMailboxBulkAction, - assignMailboxMessage, - MailboxAssignmentSchema, - markMailboxMessageRead, - enrichMailboxMessage, - MailboxEnrichmentSchema, - MAILBOX_BULK_ACTIONS, -} from "./mutations.js"; -import { - canonicalMailboxFilter, - decodeMailboxListCursor, - getMailboxMessage, - listUserMailbox, - MAILBOX_SORTS, - MAILBOX_VIEWS, - MailboxInboxViewSchema, - MailboxSortSchema, - type MailboxFilter, - type MailboxInboxView, - type MailboxMessage, - type MailboxScope, - type MailboxSort, - type SenderDisplayResolver, -} from "./read.js"; -import { - assertMailboxVocabulary, - canonicalMailboxPriorities, - type MailboxVocabulary, -} from "./vocabulary.js"; -import { - listMailboxThreads, - readMailboxThreadByMessageId, - decodeMailboxThreadListCursor, - MAX_MAILBOX_THREAD_LIST_LIMIT, - DEFAULT_MAILBOX_THREAD_LIST_LIMIT, - MAX_MAILBOX_THREAD_LIMIT, - type MailboxThreadSummary, - type MailboxThreadMessage, -} from "./thread.js"; -import { MailboxRefArraySchema, type MailboxRef } from "./read.js"; +import { openNativeMailboxStore, moveNativeMailboxMessage } from "./native-store.js"; const logger = getLogger(["corbits-mailbox", "mount"]); @@ -67,33 +22,9 @@ export type MountMailboxOpts = { resolvePrincipal: ( ctx: unknown, ) => Promise | ResolvedPrincipal | null; - /** - * Optional host seam that turns sender addresses into human labels; see - * `SenderDisplayResolver`. Omit it and messages carry only the raw `From:` - * header, which is what this package can know on its own. - */ - resolveSenderDisplays?: SenderDisplayResolver; - /** - * The host's triage vocabulary — REQUIRED, and with no default anywhere in - * the package. - * - * `priorities` is ordered, most urgent first: that order *is* the ranking - * `sort=priority` uses, and it is what the OpenAPI `?priority=` enum - * advertises. `statuses` is an unordered set, used only for validation and - * the `?status=` enum. `classification` and `assignee` stay open host-defined - * strings with no list at all. - * - * Reordering `priorities` between deploys invalidates in-flight - * `sort=priority` cursors, which then 400 rather than paging against a - * ranking that no longer means what it meant when they were minted. - */ - vocabulary: MailboxVocabulary; /** * SSE keep-alive period. Defaults to 25s — under the 30s idle timeout most - * proxies default to. Overridable so a test can observe a heartbeat without - * waiting 25 seconds for one; there is no other reason to change it. - * Non-finite or `<= 0` values throw `RangeError` at mount (same posture as a - * bad vocabulary), not on the first request. + * proxies default to. */ heartbeatIntervalMs?: number; }; @@ -105,227 +36,110 @@ const DEFAULT_HEARTBEAT_INTERVAL_MS = 25_000; /** * Ceiling on SSE events queued for one connection whose client has stopped - * reading. An event is a NUDGE — an id to refetch — never the data itself; - * Postgres holds the data. So a consumer that falls this far behind is - * disconnected rather than buffered for: on reconnect it resyncs from the - * database and loses nothing durable, whereas buffering would grow one - * stalled connection's memory without bound for a stream that is best-effort - * by contract. + * reading — see the identical rationale this carried before the native-store + * cutover: an event is a nudge, never the data, so a stalled consumer is + * disconnected rather than buffered for. */ export const MAX_PENDING_SSE_EVENTS = 100; -const UuidSchema = type("string.uuid"); -const BulkRequestSchema = type({ - action: type.enumerated(...MAILBOX_BULK_ACTIONS), - ids: "string[]", -}); +const DEFAULT_FOLDER = "INBOX"; +/** Folders `?folder=` may name for `GET /me/inbox`. */ +const LIST_FOLDERS = ["INBOX", "Archive", "Trash"] as const; +type ListFolder = (typeof LIST_FOLDERS)[number]; -/** - * `?limit=` — REJECTING anything out of range rather than clamping it. - * - * A caller asking for 500 and silently receiving 200 has no way to know its - * page was truncated, so it pages as if it had 500 rows and skips 300 messages. - * Refusing is also the only answer consistent with this same parameter's - * behavior on the low side — a non-integer or non-positive limit is a 400, and - * "too large" is no less a bad request than "not a number". - */ -const LimitSchema = type("undefined") - .pipe(() => DEFAULT_LIMIT) - .or( - type("string.integer.parse") - .configure({ message: () => "limit must be a positive integer" }) - .to( - type("number >= 1") - .configure({ message: () => "limit must be a positive integer" }) - .and( - type(`number <= ${MAX_MAILBOX_PAGE_LIMIT}`).configure({ - message: () => `limit must be at most ${MAX_MAILBOX_PAGE_LIMIT}`, - }), - ), - ), - ); - -function isUuid(value: string): boolean { - return !(UuidSchema(value) instanceof type.errors); +function isListFolder(value: string): value is ListFolder { + return (LIST_FOLDERS as readonly string[]).includes(value); } -/** - * Parse an optional `?limit=` against the given default/max, the same - * refuse-don't-clamp posture as `LimitSchema` above — reused for the thread - * routes, which have their own ceilings. - */ -function parseOptionalLimit( - raw: string | undefined, - defaultLimit: number, - max: number, -): { limit: number } | { error: string } { - if (raw === undefined) return { limit: defaultLimit }; +function parseLimit(raw: string | undefined): { limit: number } | { error: string } { + if (raw === undefined) return { limit: DEFAULT_LIMIT }; if (!/^\d+$/.test(raw)) return { error: "limit must be a positive integer" }; const limit = Number(raw); if (!Number.isSafeInteger(limit) || limit < 1) { return { error: "limit must be a positive integer" }; } - if (limit > max) return { error: `limit must be at most ${max}` }; + if (limit > MAX_MAILBOX_PAGE_LIMIT) { + return { error: `limit must be at most ${MAX_MAILBOX_PAGE_LIMIT}` }; + } return { limit }; } -/** - * `?refs=` is a JSON-encoded array of `{kind, id}` refs — the same shape - * `MailboxRef` uses everywhere else in this package. Omitted means no ref - * filter. - */ -function parseOptionalRefs( - raw: string | undefined, -): { refs?: MailboxRef[] } | { error: string } { +/** `?cursor=` is the uid of the last item on the previous page. */ +function parseCursor(raw: string | undefined): { cursor?: number } | { error: string } { if (raw === undefined) return {}; - let parsed: unknown; - try { - parsed = JSON.parse(raw); - } catch { - return { error: "refs must be JSON-encoded" }; - } - const refs = MailboxRefArraySchema(parsed); - if (refs instanceof type.errors) { - return { error: "invalid refs filter" }; - } - return { refs }; + if (!/^\d+$/.test(raw)) return { error: "malformed cursor" }; + const cursor = Number(raw); + if (!Number.isSafeInteger(cursor)) return { error: "malformed cursor" }; + return { cursor }; } -/** - * Vocabulary schemas, built once at mount from the HOST's lists. An unknown - * `priority`/`status` is a 400 rather than a silently empty page — a client - * that typos `priorty=high` should hear about it, not conclude its inbox is - * empty. One owner for both refusal sites: the query-string filters and the - * enrichment body. `classification` and `assignee` are open host-defined - * strings and are taken as given. - */ -function createVocabularySchemas(vocabulary: MailboxVocabulary) { - const priority = type - .enumerated(...vocabulary.priorities) - .configure({ message: () => "unknown priority" }); - const status = type - .enumerated(...vocabulary.statuses) - .configure({ message: () => "unknown status" }); - const filterQuery = type({ - "priority?": priority, - "status?": status, - "classification?": "string", - "assignee?": "string", - }); - - return { - /** Read the enrichment/delegation filters off the query string. */ - parseFilter( - query: (key: string) => string | undefined, - ): { filter: MailboxFilter } | { error: string } { - const raw: Record = {}; - for (const key of [ - "priority", - "status", - "classification", - "assignee", - ] as const) { - const value = query(key); - if (value !== undefined) raw[key] = value; - } - const filter = filterQuery(raw); - if (filter instanceof type.errors) return { error: filter[0]!.message }; - return { filter }; - }, - /** - * The enrichment body's `priority`/`status` carry the same vocabulary the - * query string does, and are refused the same way. `null` is not a - * vocabulary member: it is the clear-this-field instruction, and always - * legal. - */ - checkEnrichment(enrichment: { - priority?: string | null; - status?: string | null; - }): string | null { - for (const [schema, value] of [ - [priority, enrichment.priority], - [status, enrichment.status], - ] as const) { - if (typeof value !== "string") continue; - const result = schema(value); - if (result instanceof type.errors) return result[0]!.message; - } - return null; - }, - }; +function parseUid(raw: string): number | null { + if (!/^\d+$/.test(raw)) return null; + const uid = Number(raw); + return Number.isSafeInteger(uid) && uid > 0 ? uid : null; } const TAGS = ["mailbox"]; const ID_PARAM = { - name: "id", + name: "uid", in: "path" as const, required: true, - schema: { type: "string" as const, format: "uuid" }, + schema: { type: "integer" as const }, }; -// The five single-message mutations differ only in verb and handler, so they -// are registered from one table instead of five near-identical blocks. `op` -// is the event op published on success — same identifiers `applyMailboxBulkAction` -// takes as its `action`, so a listener sees the same op for "read one" and -// "read fifty". -const SINGLE_MUTATIONS = [ - { - verb: "read", - summary: "Mark a message read", - run: markMailboxMessageRead, - op: "mark_read", - }, - { - verb: "unread", - summary: "Mark an active message unread", - run: markMailboxMessageUnread, - op: "mark_unread", - }, - { - verb: "trash", - summary: "Trash a message", - run: trashMailboxMessage, - op: "trash", - }, - { - verb: "archive", - summary: "Archive a message (refused once trashed)", - run: archiveMailboxMessage, - op: "archive", - }, - { - verb: "restore", - summary: "Restore a message out of archive or trash", - run: restoreMailboxMessage, - op: "restore", - }, +/** + * One item of `GET /me/inbox`: the vendored `executeSearch`'s ref, plus the + * envelope and raw bytes read for it. + */ +type MailboxListItem = { + uid: number; + flags: string[]; + envelope: { + messageId: string; + from: string; + to: string[]; + subject: string; + date: string; + inReplyTo: string | undefined; + references: string[]; + }; + raw: string; +}; + +// The five single-message mutations that move or flag a message. `op` is the +// event op published on success. +const READ_VERBS = [ + { verb: "read", op: "mark_read" as const, flags: ["\\Seen"], add: true }, + { verb: "unread", op: "mark_unread" as const, flags: ["\\Seen"], add: false }, +] as const; + +const MOVE_VERBS = [ + { verb: "archive", op: "archive" as const, from: "INBOX", to: "Archive" }, + { verb: "trash", op: "trash" as const, from: "INBOX", to: "Trash" }, + { verb: "restore", op: "restore" as const, from: undefined, to: "INBOX" }, ] as const; /** * Mount the mailbox routes onto a host Hono app under `/me/inbox*`. * + * This library exists ONLY to give human principals a native Interchange + * mailbox — list, read/unread, archive/trash/restore, and a live SSE stream. + * Every route is a thin wrapper over `NativeMailboxStore` and the vendored + * `@intx/mailbox` `executeSearch`. + * * "No-member asymmetry" is intentional, spec'd behavior: when - * `resolvePrincipal` yields no principal, list/unread-count return EMPTY - * results (200) — a caller with no mailbox identity simply sees an empty - * inbox — while events/detail/mutations return 403, since those operate on - * (or stream) a specific identity that does not exist. + * `resolvePrincipal` yields no principal, list returns an EMPTY result (200) + * — a caller with no mailbox identity simply sees an empty inbox — while + * events and mutations return 403, since those operate on (or stream) a + * specific identity that does not exist. */ export function mountMailbox( app: Hono, opts: MountMailboxOpts, ): Hono { - const { db, bus, resolvePrincipal, vocabulary } = opts; - // Refused at mount, not on the first request: a host that hands over an empty - // or duplicated list has a startup bug, and finding out at boot is cheaper - // than finding out from one user's 500. - assertMailboxVocabulary(vocabulary); - const canonicalPriorities = canonicalMailboxPriorities(vocabulary.priorities); - const vocabularySchemas = createVocabularySchemas(vocabulary); + const { db, bus, resolvePrincipal } = opts; const heartbeatIntervalMs = opts.heartbeatIntervalMs ?? DEFAULT_HEARTBEAT_INTERVAL_MS; - // Zero/negative spins a tight sleep/write loop per open connection; NaN - // and Infinity are the same class of host misconfiguration. Refuse at - // mount, not on the first request — same posture as a bad vocabulary. if (!Number.isFinite(heartbeatIntervalMs) || heartbeatIntervalMs <= 0) { throw new RangeError( "mailbox heartbeatIntervalMs must be a finite positive number", @@ -346,12 +160,15 @@ export function mountMailbox( tags: TAGS, summary: "List the caller's inbox", description: - "Newest first, keyset-paginated. With no resolvable principalId this returns an empty list, not a 403.", + "Newest first, keyset-paginated over the native mailbox store's uid " + + "counter. With no resolvable principalId this returns an empty list, " + + "not a 403.", parameters: [ { - name: "view", + name: "folder", in: "query", - schema: { type: "string", enum: [...MAILBOX_VIEWS] }, + description: "INBOX (default), Archive, or Trash.", + schema: { type: "string", enum: [...LIST_FOLDERS] }, }, { name: "limit", @@ -364,151 +181,75 @@ export function mountMailbox( }, }, { name: "cursor", in: "query", schema: { type: "string" } }, - { - name: "sort", - in: "query", - description: - "`date` (newest first, the default) or `priority` (most urgent first, newest first within a band).", - schema: { type: "string", enum: [...MAILBOX_SORTS] }, - }, - // Enums generated from the host's vocabulary — the document describes - // the host's taxonomy because the package has none. - { - name: "priority", - in: "query", - description: "Host vocabulary, most urgent first.", - schema: { type: "string", enum: [...vocabulary.priorities] }, - }, - { - name: "status", - in: "query", - schema: { type: "string", enum: [...vocabulary.statuses] }, - }, - { name: "classification", in: "query", schema: { type: "string" } }, - { - name: "assignee", - in: "query", - description: - "Items this principalId delegated to the named assignee.", - schema: { type: "string" }, - }, ], responses: { 200: { description: "A page of messages plus an optional nextCursor" }, - 400: { - description: `Bad view, sort, priority or status; a cursor minted for a different view, sort or filter; or a limit that is not an integer in 1..${MAX_MAILBOX_PAGE_LIMIT}`, - }, + 400: { description: "Bad folder, cursor, or an out-of-range limit" }, }, }), async (c) => { - const limit = LimitSchema(c.req.query("limit")); - if (limit instanceof type.errors) { - return c.json({ error: limit[0]!.message }, 400); - } - const rawView = c.req.query("view"); - const view: MailboxInboxView = - rawView === undefined ? "all" : (rawView as MailboxInboxView); - if (MailboxInboxViewSchema(view) instanceof type.errors) { - return c.json({ error: "invalid inbox view" }, 400); - } - const rawSort = c.req.query("sort"); - const sort: MailboxSort = - rawSort === undefined ? "date" : (rawSort as MailboxSort); - if (MailboxSortSchema(sort) instanceof type.errors) { - return c.json({ error: "invalid inbox sort" }, 400); - } - const parsedFilter = vocabularySchemas.parseFilter((key) => - c.req.query(key), - ); - if ("error" in parsedFilter) { - return c.json({ error: parsedFilter.error }, 400); - } - const canonicalFilter = canonicalMailboxFilter(parsedFilter.filter); - const rawCursor = c.req.query("cursor"); - let cursor; - if (rawCursor !== undefined) { - const decoded = decodeMailboxListCursor(rawCursor); - if (decoded === null) { - return c.json({ error: "malformed cursor" }, 400); - } - if (decoded.view !== view) { - return c.json({ error: "cursor does not match inbox view" }, 400); - } - if (decoded.sort !== sort) { - return c.json({ error: "cursor does not match inbox sort" }, 400); - } - if (decoded.filter !== canonicalFilter) { - return c.json({ error: "cursor does not match inbox filter" }, 400); - } - // The leading component of a priority keyset is an integer rank read - // out of the host's ordering. Reorder that list and the same integer - // names a different band, so a cursor minted under the old order would - // page over messages it should have shown. Refuse it, exactly as a - // cross-view or cross-filter cursor is refused. - if (sort === "priority" && decoded.priorities !== canonicalPriorities) { - return c.json( - { error: "cursor does not match inbox priority ordering" }, - 400, - ); - } - cursor = decoded; + const rawFolder = c.req.query("folder"); + const folder = rawFolder === undefined ? DEFAULT_FOLDER : rawFolder; + if (!isListFolder(folder)) { + return c.json({ error: "invalid folder" }, 400); } + const parsedLimit = parseLimit(c.req.query("limit")); + if ("error" in parsedLimit) return c.json({ error: parsedLimit.error }, 400); + const parsedCursor = parseCursor(c.req.query("cursor")); + if ("error" in parsedCursor) return c.json({ error: parsedCursor.error }, 400); + const resolved = await resolvePrincipal(c); - if (!resolved) { - return c.json({ messages: [] }); + if (!resolved) return c.json({ messages: [] }); + + const store = await openNativeMailboxStore(db, { ...resolved, folder }); + // No query predicate: `executeSearch` returns every ref, in store order + // (uid ascending, since `append` only ever grows uid). Reversed for + // newest-first, then paged with a plain uid keyset. + const refs = (await executeSearch(folder, store, {})).reverse(); + const page = refs.filter( + (ref) => parsedCursor.cursor === undefined || ref.uid < parsedCursor.cursor, + ); + const items = page.slice(0, parsedLimit.limit); + const messages: MailboxListItem[] = []; + for (const ref of items) { + const message = store.find(ref.uid); + if (!message) continue; + const raw = await store.readRaw(ref.uid); + messages.push({ + uid: ref.uid, + flags: [...message.flags], + envelope: { + messageId: message.envelope.messageId, + from: message.envelope.from, + to: message.envelope.to, + subject: message.envelope.subject, + date: new Date(message.envelope.date).toISOString(), + inReplyTo: message.envelope.inReplyTo, + references: message.envelope.references, + }, + raw: Buffer.from(raw).toString("base64"), + }); } - const scope: MailboxScope = { - tenantId: resolved.tenantId, - principalId: resolved.principalId, - limit, - view, - sort, - filter: parsedFilter.filter, - priorities: vocabulary.priorities, + const body: { messages: MailboxListItem[]; nextCursor?: string } = { + messages, }; - if (cursor !== undefined) scope.cursor = cursor; - if (opts.resolveSenderDisplays !== undefined) { - scope.resolveSenderDisplays = opts.resolveSenderDisplays; + if (page.length > items.length) { + body.nextCursor = String(items[items.length - 1]!.uid); } - const page = await listUserMailbox(db, scope); - const body: { messages: MailboxMessage[]; nextCursor?: string } = { - messages: page.items, - }; - if (page.nextCursor !== undefined) body.nextCursor = page.nextCursor; return c.json(body); }, ); - app.get( - "/me/inbox/unread-count", - describeRoute({ - tags: TAGS, - summary: "Count unread, non-archived, non-trashed messages", - responses: { - 200: { - description: "The unread count; 0 with no resolvable principalId", - }, - }, - }), - async (c) => { - const resolved = await resolvePrincipal(c); - if (!resolved) { - return c.json({ unread: 0 }); - } - const unread = await countUnreadActiveMailbox(db, resolved); - return c.json({ unread }); - }, - ); - app.get( "/me/inbox/events", describeRoute({ tags: TAGS, summary: "Server-sent stream of mailbox events for the caller", description: - "Emits a `mailbox` event per affected message id, plus a heartbeat comment every 25s. " + - "Each event also carries `op` (create/mark_read/mark_unread/trash/archive/restore/enrich/assign) " + - "when the publisher knows it — optional and additive, so a listener reading only `id` still works.", + "Emits a `mailbox` event per affected message, plus a heartbeat " + + "comment every 25s. Each event carries `op` " + + "(create/mark_read/mark_unread/archive/trash/restore) when the " + + "publisher knows it.", responses: { 200: { description: "text/event-stream" }, 403: { description: "No resolvable principalId" }, @@ -520,11 +261,6 @@ export function mountMailbox( return c.json({ error: "No resolvable principalId" }, 403); } return streamSSE(c, async (stream) => { - // Writes are serialized through a bounded queue rather than fired - // and forgotten: `stream.writeSSE` parks a pending promise per call - // once the client stops draining the socket, and firing them - // unawaited made that parking unbounded. Overflow closes the - // connection — see MAX_PENDING_SSE_EVENTS. const pending: MailboxEvent[] = []; let draining = false; let closed = false; @@ -546,12 +282,6 @@ export function mountMailbox( }); } } catch { - // Defensive: absorb writeSSE rejection so a void-launched drain - // never becomes an unhandled rejection. Hono's StreamingApi.write - // currently swallows writer errors (real disconnect is - // stream.aborted / onAbort); this catch still matters if writeSSE - // rejects for any other reason or Hono starts propagating. - // Mark closed so the heartbeat loop exits and no further events queue. closeStream(); } finally { draining = false; @@ -585,376 +315,78 @@ export function mountMailbox( }, ); - app.get( - "/me/inbox/:id", - describeRoute({ - tags: TAGS, - summary: "Read one message with its full body", - description: - "A stored frame the MIME parser rejects degrades to an empty body rather than a 500.", - responses: { - 200: { description: "The message and its text body" }, - 400: { description: "Message id is not a UUID" }, - 403: { description: "No resolvable principalId" }, - 404: { description: "No such message for this principalId" }, - }, - }), - async (c) => { - const id = c.req.param("id"); - if (!isUuid(id)) { - return c.json({ error: "Message id must be a UUID" }, 400); - } - const resolved = await resolvePrincipal(c); - if (!resolved) { - return c.json({ error: "No resolvable principalId" }, 403); - } - const detailArgs: Parameters[1] = { - ...resolved, - id, - }; - if (opts.resolveSenderDisplays !== undefined) { - detailArgs.resolveSenderDisplays = opts.resolveSenderDisplays; - } - const message = await getMailboxMessage(db, detailArgs); - if (!message) return c.json({ error: "Message not found" }, 404); - return c.json(message); - }, - ); - - async function singleMutation( - // eslint-disable-next-line @typescript-eslint/no-explicit-any - c: Context, - id: string, - run: (scope: { - tenantId: string; - principalId: string; - id: string; - }) => Promise, - op: MailboxEventOp, - ) { - if (!isUuid(id)) { - return c.json({ error: "Message id must be a UUID" }, 400); - } - const resolved = await resolvePrincipal(c); - if (!resolved) { - return c.json({ error: "No resolvable principalId" }, 403); - } - const ok = await run({ ...resolved, id }); - if (!ok) return c.json({ error: "Message not found" }, 404); - publish(resolved, id, op); - return c.json({ id, ok: true as const }); - } - - for (const { verb, summary, run, op } of SINGLE_MUTATIONS) { + for (const { verb, op, flags, add } of READ_VERBS) { app.post( - `/me/inbox/:id/${verb}`, + `/me/inbox/:uid/${verb}`, describeRoute({ tags: TAGS, - summary, + summary: verb === "read" ? "Mark a message read" : "Mark a message unread", parameters: [ID_PARAM], responses: { - 200: { description: "The mutation was applied" }, - 400: { description: "Message id is not a UUID" }, + 200: { description: "The flag was applied" }, + 400: { description: "uid is not a positive integer" }, 403: { description: "No resolvable principalId" }, - 404: { description: "No message in scope for this action" }, + 404: { description: "No message with that uid in this mailbox" }, }, }), - (c) => - singleMutation(c, c.req.param("id"), (scope) => run(db, scope), op), + // eslint-disable-next-line @typescript-eslint/no-explicit-any + async (c: Context) => { + const uid = parseUid(c.req.param("uid") ?? ""); + if (uid === null) return c.json({ error: "uid must be a positive integer" }, 400); + const resolved = await resolvePrincipal(c); + if (!resolved) return c.json({ error: "No resolvable principalId" }, 403); + const folder = c.req.query("folder") ?? DEFAULT_FOLDER; + const store = await openNativeMailboxStore(db, { ...resolved, folder }); + if (!store.find(uid)) return c.json({ error: "Message not found" }, 404); + if (add) store.addFlags(uid, [...flags]); + else store.removeFlags(uid, [...flags]); + await store.settled; + publish(resolved, `${folder}:${uid}`, op); + return c.json({ uid, ok: true as const }); + }, ); } - app.post( - "/me/inbox/:id/enrich", - describeRoute({ - tags: TAGS, - summary: "Stamp triage metadata onto a message", - description: - "Triage enriches the message's mailbox row rather than spawning a task. Each of " + - "priority/classification/status is applied independently — an omitted key " + - "leaves the stored value alone, an explicit null clears it. " + - `priority is one of ${vocabulary.priorities.join(", ")}; ` + - `status is one of ${vocabulary.statuses.join(", ")}.`, - parameters: [ID_PARAM], - responses: { - 200: { description: "The enrichment was applied" }, - 400: { - description: - "Non-UUID id, bad JSON, an unknown priority/status, or an enrichment that sets nothing", - }, - 403: { description: "No resolvable principalId" }, - 404: { description: "No such message for this principalId" }, - }, - }), - async (c) => { - const id = c.req.param("id"); - if (!isUuid(id)) { - return c.json({ error: "Message id must be a UUID" }, 400); - } - const raw = await c.req.json().catch(() => null); - if (raw === null) return c.json({ error: "invalid JSON body" }, 400); - const enrichment = MailboxEnrichmentSchema(raw); - if (enrichment instanceof type.errors) { - return c.json({ error: "invalid mailbox enrichment" }, 400); - } - const badVocabulary = vocabularySchemas.checkEnrichment(enrichment); - if (badVocabulary !== null) { - return c.json({ error: badVocabulary }, 400); - } - const resolved = await resolvePrincipal(c); - if (!resolved) { - return c.json({ error: "No resolvable principalId" }, 403); - } - // "sets nothing" has one owner — `enrichMailboxMessage`. The route - // renders that refusal as a 400 rather than re-stating the rule. - let ok: boolean; - try { - ok = await enrichMailboxMessage(db, { ...resolved, id }, enrichment); - } catch (err) { - if (err instanceof RangeError) - return c.json({ error: err.message }, 400); - throw err; - } - if (!ok) return c.json({ error: "Message not found" }, 404); - publish(resolved, id, "enrich"); - return c.json({ id, ok: true as const }); - }, - ); - - app.post( - "/me/inbox/:id/assign", - describeRoute({ - tags: TAGS, - summary: "Delegate a message to another principalId", - description: - "Delegation as an optional assignee ref rather than a forwarded copy: the item stays " + - "in this mailbox and carries the assignee's principalId. `null` un-assigns. " + - "List with `?assignee=` to see what has been delegated to whom.", - parameters: [ID_PARAM], - responses: { - 200: { description: "The assignment was applied" }, - 400: { - description: "Non-UUID id, bad JSON, or a missing assignee key", - }, - 403: { description: "No resolvable principalId" }, - 404: { description: "No such message for this principalId" }, - }, - }), - async (c) => { - const id = c.req.param("id"); - if (!isUuid(id)) { - return c.json({ error: "Message id must be a UUID" }, 400); - } - const raw = await c.req.json().catch(() => null); - if (raw === null) return c.json({ error: "invalid JSON body" }, 400); - const body = MailboxAssignmentSchema(raw); - if (body instanceof type.errors) { - return c.json({ error: "invalid mailbox assignment" }, 400); - } - const resolved = await resolvePrincipal(c); - if (!resolved) { - return c.json({ error: "No resolvable principalId" }, 403); - } - const ok = await assignMailboxMessage( - db, - { ...resolved, id }, - body.assignee, - ); - if (!ok) return c.json({ error: "Message not found" }, 404); - publish(resolved, id, "assign"); - return c.json({ id, ok: true as const }); - }, - ); - - app.post( - "/me/inbox/bulk", - describeRoute({ - tags: TAGS, - summary: "Apply one action to up to 50 messages", - description: - "Partial success: every requested id comes back with its own ok flag.", - responses: { - 200: { description: "Per-id results plus the number updated" }, - 400: { - description: - "Bad JSON, unknown action, non-UUID id, or more than 50 ids", - }, - 403: { description: "No resolvable principalId" }, - }, - }), - async (c) => { - const raw = await c.req.json().catch(() => null); - if (raw === null) return c.json({ error: "invalid JSON body" }, 400); - const body = BulkRequestSchema(raw); - if (body instanceof type.errors) { - return c.json({ error: "invalid bulk inbox request" }, 400); - } - if (!body.ids.every((id) => isUuid(id))) { - return c.json({ error: "each id must be a UUID" }, 400); - } - const resolved = await resolvePrincipal(c); - if (!resolved) { - return c.json({ error: "No resolvable principalId" }, 403); - } - // The ≤50 cap has one owner — `applyMailboxBulkAction`. The route just - // renders that refusal as a 400 rather than re-stating the limit. - let results; - try { - results = await applyMailboxBulkAction( - db, - resolved, - body.action, - body.ids, - ); - } catch (err) { - if (err instanceof RangeError) - return c.json({ error: err.message }, 400); - throw err; - } - for (const r of results) { - if (r.ok) publish(resolved, r.id, body.action); - } - return c.json({ - updated: results.filter((r) => r.ok).length, - results, - }); - }, - ); - - app.get( - "/me/threads", - describeRoute({ - tags: TAGS, - summary: "List the caller's conversations, newest activity first", - description: - "Groups every inbound message in scope into threads by RFC 5256 References " + - "linking, keyset-paginated on (lastCreatedAt, rootId). `refs` (optional, JSON-encoded " + - "array of {kind, id}) scopes the listing to threads with at least one message " + - "carrying one of the given refs, e.g. a tenant or workbench ref.", - parameters: [ - { - name: "refs", - in: "query", - description: "JSON-encoded array of {kind, id} refs, OR'd together.", - schema: { type: "string" }, - }, - { - name: "limit", - in: "query", - schema: { - type: "integer", - minimum: 1, - maximum: MAX_MAILBOX_THREAD_LIST_LIMIT, - default: DEFAULT_MAILBOX_THREAD_LIST_LIMIT, - }, + for (const { verb, op, from, to } of MOVE_VERBS) { + app.post( + `/me/inbox/:uid/${verb}`, + describeRoute({ + tags: TAGS, + summary: `Move a message to ${to}`, + parameters: [ID_PARAM], + responses: { + 200: { description: "The message was moved" }, + 400: { description: "uid is not a positive integer" }, + 403: { description: "No resolvable principalId" }, + 404: { description: "No message with that uid in the source folder" }, }, - { name: "cursor", in: "query", schema: { type: "string" } }, - ], - responses: { - 200: { description: "A page of thread summaries plus an optional nextCursor" }, - 400: { description: "Malformed refs, cursor, or an out-of-range limit" }, - }, - }), - async (c) => { - const parsedRefs = parseOptionalRefs(c.req.query("refs")); - if ("error" in parsedRefs) return c.json({ error: parsedRefs.error }, 400); - const parsedLimit = parseOptionalLimit( - c.req.query("limit"), - DEFAULT_MAILBOX_THREAD_LIST_LIMIT, - MAX_MAILBOX_THREAD_LIST_LIMIT, - ); - if ("error" in parsedLimit) return c.json({ error: parsedLimit.error }, 400); - const resolved = await resolvePrincipal(c); - if (!resolved) { - return c.json({ threads: [] }); - } - let page; - try { - page = await listMailboxThreads(db, resolved, { - limit: parsedLimit.limit, - ...(c.req.query("cursor") !== undefined - ? { cursor: c.req.query("cursor")! } - : {}), - ...(parsedRefs.refs !== undefined ? { refs: parsedRefs.refs } : {}), - }); - } catch (err) { - if (err instanceof RangeError) { - return c.json({ error: err.message }, 400); + }), + // eslint-disable-next-line @typescript-eslint/no-explicit-any + async (c: Context) => { + const uid = parseUid(c.req.param("uid") ?? ""); + if (uid === null) return c.json({ error: "uid must be a positive integer" }, 400); + const resolved = await resolvePrincipal(c); + if (!resolved) return c.json({ error: "No resolvable principalId" }, 403); + // `restore` has no fixed source: a message can be restored out of + // either Archive or Trash, named by `?folder=`. + const fromFolder = from ?? c.req.query("folder") ?? "Archive"; + let newUid: number; + try { + newUid = await moveNativeMailboxMessage( + db, + resolved, + fromFolder, + uid, + to, + ); + } catch { + return c.json({ error: "Message not found" }, 404); } - throw err; - } - const body: { threads: MailboxThreadSummary[]; nextCursor?: string } = { - threads: page.items, - }; - if (page.nextCursor !== undefined) body.nextCursor = page.nextCursor; - return c.json(body); - }, - ); - - app.get( - "/me/threads/:rootMessageId", - describeRoute({ - tags: TAGS, - summary: "Read one conversation by its root Message-ID", - description: - "Oldest first, keyset-paginated. `rootMessageId` must name a message that is itself " + - "a thread root in this scope; a non-root or unknown Message-ID is a 404.", - parameters: [ - { - name: "rootMessageId", - in: "path", - required: true, - schema: { type: "string" }, - }, - { - name: "limit", - in: "query", - schema: { - type: "integer", - minimum: 1, - maximum: MAX_MAILBOX_THREAD_LIMIT, - }, - }, - { name: "cursor", in: "query", schema: { type: "string" } }, - ], - responses: { - 200: { description: "The thread's messages plus an optional nextCursor" }, - 400: { description: "Malformed cursor or an out-of-range limit" }, - 403: { description: "No resolvable principalId" }, - 404: { description: "No such thread root for this principalId" }, + publish(resolved, `${to}:${newUid}`, op); + return c.json({ uid: newUid, ok: true as const }); }, - }), - async (c) => { - const resolved = await resolvePrincipal(c); - if (!resolved) { - return c.json({ error: "No resolvable principalId" }, 403); - } - const rootMessageId = c.req.param("rootMessageId"); - const rawCursor = c.req.query("cursor"); - const rawLimit = c.req.query("limit"); - let page; - try { - page = await readMailboxThreadByMessageId(db, resolved, { - rootMessageId, - ...(rawCursor !== undefined ? { cursor: rawCursor } : {}), - ...(rawLimit !== undefined ? { limit: Number(rawLimit) } : {}), - }); - } catch (err) { - if (err instanceof RangeError) { - return c.json({ error: err.message }, 400); - } - throw err; - } - if (page === null) { - return c.json({ error: "Thread not found" }, 404); - } - const body: { messages: MailboxThreadMessage[]; nextCursor?: string } = { - messages: page.items, - }; - if (page.nextCursor !== undefined) body.nextCursor = page.nextCursor; - return c.json(body); - }, - ); + ); + } return app; } diff --git a/src/mutations.ts b/src/mutations.ts deleted file mode 100644 index b3cbe5e..0000000 --- a/src/mutations.ts +++ /dev/null @@ -1,306 +0,0 @@ -import { and, eq, inArray, isNull, sql, type SQL } from "drizzle-orm"; -import { type } from "arktype"; -import { mailbox } from "./schema.js"; -import type { MailboxDb } from "./db.js"; -import { assertMailboxScope } from "./write.js"; - -export type MailboxMutationScope = { tenantId: string; principalId: string }; - -export const MAILBOX_BULK_ACTIONS = [ - "mark_read", - "mark_unread", - "trash", - "archive", - "restore", -] as const; -export type MailboxBulkAction = (typeof MAILBOX_BULK_ACTIONS)[number]; - -export const MAX_BULK_MAILBOX_IDS = 50; - -// Every management row is created WITH its message (see `writeMailboxMessage` -// / `createMailboxPersist`), so every mutation here is a plain scoped UPDATE: -// a message that is not this principal's produces no matching row, which the -// callers read as 404. The row carries the same (tenant_id, principal_id) as -// its mail row — `principal_mail` is immutable, so they can never drift. -function scopedMailbox(scope: MailboxMutationScope, extra: SQL[]) { - return and( - eq(mailbox.tenantId, scope.tenantId), - eq(mailbox.principalId, scope.principalId), - ...extra, - )!; -} - -type ActionRule = { - set: Record; - /** Rows the action refuses to touch — excluded rows return nothing (404). */ - guards: SQL[]; -}; - -const NULL = sql`NULL`; - -/** - * One owner per rule; the single-message and bulk paths differ only in whether - * they target one id or many. - * - * `COALESCE` makes read/trash/archive idempotent: re-applying never clobbers - * the original timestamp. Trash-wins precedence lives here once: trashing - * clears archived, and archiving refuses an already-trashed row. - */ -const ACTION_RULES: Record = { - mark_read: { - set: { read_at: sql`COALESCE(${mailbox.readAt}, now())` }, - guards: [], - }, - mark_unread: { - set: { read_at: NULL }, - guards: [isNull(mailbox.archivedAt), isNull(mailbox.trashedAt)], - }, - trash: { - set: { - trashed_at: sql`COALESCE(${mailbox.trashedAt}, now())`, - archived_at: NULL, - }, - guards: [], - }, - archive: { - set: { - archived_at: sql`COALESCE(${mailbox.archivedAt}, now())`, - trashed_at: NULL, - }, - guards: [isNull(mailbox.trashedAt)], - }, - restore: { - set: { archived_at: NULL, trashed_at: NULL }, - guards: [], - }, -}; - -function updateMailboxState( - db: MailboxDb, - scope: MailboxMutationScope, - target: SQL, - set: Record, - guards: SQL[], -): Promise<{ id: string }[]> { - const setList = sql.join( - Object.entries(set).map( - ([column, value]) => sql`${sql.identifier(column)} = ${value}`, - ), - sql`, `, - ); - return db.execute<{ id: string }>(sql` - UPDATE ${mailbox} - SET ${setList} - WHERE ${scopedMailbox(scope, [target, ...guards])} - RETURNING ${mailbox.id} - `) as unknown as Promise<{ id: string }[]>; -} - -async function applyToOne( - db: MailboxDb, - scope: MailboxMutationScope & { id: string }, - action: MailboxBulkAction, -): Promise { - const rule = ACTION_RULES[action]; - const updated = await updateMailboxState( - db, - scope, - eq(mailbox.id, scope.id), - rule.set, - rule.guards, - ); - return updated.length > 0; -} - -/** Idempotent: repeated read-marking never clobbers the original readAt. */ -export function markMailboxMessageRead( - db: MailboxDb, - scope: MailboxMutationScope & { id: string }, -): Promise { - return applyToOne(db, scope, "mark_read"); -} - -/** Refused once the message is archived or trashed. */ -export function markMailboxMessageUnread( - db: MailboxDb, - scope: MailboxMutationScope & { id: string }, -): Promise { - return applyToOne(db, scope, "mark_unread"); -} - -/** Trash wins: trashing always clears archived. */ -export function trashMailboxMessage( - db: MailboxDb, - scope: MailboxMutationScope & { id: string }, -): Promise { - return applyToOne(db, scope, "trash"); -} - -/** Archiving an already-trashed item is refused (trash-wins precedence). */ -export function archiveMailboxMessage( - db: MailboxDb, - scope: MailboxMutationScope & { id: string }, -): Promise { - return applyToOne(db, scope, "archive"); -} - -export function restoreMailboxMessage( - db: MailboxDb, - scope: MailboxMutationScope & { id: string }, -): Promise { - return applyToOne(db, scope, "restore"); -} - -/** - * Count the caller's unread, non-archived, non-trashed mail. Counts on - * `mailbox` alone — every message has a management row from delivery — which - * is what lets `mailbox_tenant_id_principal_id_unread_idx` serve this, the - * hottest endpoint, as an index-only scan. - */ -export async function countUnreadActiveMailbox( - db: MailboxDb, - scope: MailboxMutationScope, -): Promise { - const [row] = await db - .select({ count: sql`count(*)::int` }) - .from(mailbox) - .where( - scopedMailbox(scope, [ - isNull(mailbox.readAt), - isNull(mailbox.archivedAt), - isNull(mailbox.trashedAt), - ]), - ); - // An aggregate with no GROUP BY always returns exactly one row. - return row!.count; -} - -/** - * The triage stamp: mail is the single work surface, so triage - * *enriches* the message's management row rather than spawning a second object. - * Every field is optional and applied independently — an omitted key leaves the - * stored value alone, and an explicit `null` clears it. That distinction is the - * whole point: re-classifying an item must not silently wipe its priority. - * - * `priority` and `status` are plain strings HERE and validated against the - * host's vocabulary at the mount boundary, where that vocabulary is known. - * - * Exported as an arktype schema, not a bare type, because it is a request-body - * shape a host will parse untrusted JSON into. - */ -export const MailboxEnrichmentSchema = type({ - "priority?": "string|null", - "classification?": "string|null", - "status?": "string|null", -}); -export type MailboxEnrichment = typeof MailboxEnrichmentSchema.infer; - -const ENRICHMENT_COLUMNS = { - priority: "priority", - classification: "classification", - status: "status", -} as const; - -/** - * Stamp triage metadata onto one message's management row, scoped to - * (tenantId, principalId) — a principal can only enrich their own mail. - * Returns false when no message is in scope. - * - * Throws `RangeError` on an enrichment that sets nothing: an UPDATE with an - * empty SET clause is not a no-op worth reporting as success, it is a caller - * bug. Same for a blank tenantId or principalId (see `assertMailboxScope`). - */ -export async function enrichMailboxMessage( - db: MailboxDb, - scope: MailboxMutationScope & { id: string }, - enrichment: MailboxEnrichment, -): Promise { - assertMailboxScope(scope); - const set: Record = {}; - // Read with `in`, not a truthiness check: `null` is a meaningful value here - // (clear the field) and must be distinguished from an absent key. - for (const [key, column] of Object.entries(ENRICHMENT_COLUMNS)) { - if (!(key in enrichment)) continue; - const value = enrichment[key as keyof MailboxEnrichment]; - set[column] = value === null ? sql`NULL` : sql`${value}`; - } - if (Object.keys(set).length === 0) { - throw new RangeError( - "enrichment must set at least one of priority, classification, status", - ); - } - - const updated = await updateMailboxState( - db, - scope, - eq(mailbox.id, scope.id), - set, - [], - ); - return updated.length > 0; -} - -/** - * Delegation as the optional `assignee` ref rather than a forwarded copy: handing - * an item to a teammate stamps their principal onto the row rather than - * copying the mail into their mailbox. `null` un-assigns. - * - * The assignee string is opaque to this package — it is whatever the host's - * `resolvePrincipal` produces — so there is nothing here to validate beyond - * "a string or null". - */ -export const MailboxAssignmentSchema = type({ assignee: "string|null" }); -export type MailboxAssignment = typeof MailboxAssignmentSchema.infer; - -/** - * Assign (or un-assign) one message, scoped to (tenantId, principalId). - * Returns false when no message is in scope. Throws `RangeError` on a blank - * tenantId or principalId, as the enrichment path does. - */ -export async function assignMailboxMessage( - db: MailboxDb, - scope: MailboxMutationScope & { id: string }, - assignee: string | null, -): Promise { - assertMailboxScope(scope); - const updated = await updateMailboxState( - db, - scope, - eq(mailbox.id, scope.id), - { assignee: assignee === null ? sql`NULL` : sql`${assignee}` }, - [], - ); - return updated.length > 0; -} - -export type BulkMailboxResult = { id: string; ok: boolean }; - -/** - * Bulk mutation, capped at MAX_BULK_MAILBOX_IDS ids. Partial-success: - * returns a per-id result rather than failing the whole batch when some ids - * are out of scope (unknown, wrong principal, or excluded by an active-only - * guard for that action). - */ -export async function applyMailboxBulkAction( - db: MailboxDb, - scope: MailboxMutationScope, - action: MailboxBulkAction, - ids: string[], -): Promise { - if (ids.length > MAX_BULK_MAILBOX_IDS) { - throw new RangeError( - `bulk mailbox action accepts at most ${MAX_BULK_MAILBOX_IDS} ids`, - ); - } - - const rule = ACTION_RULES[action]; - const updated = await updateMailboxState( - db, - scope, - inArray(mailbox.id, ids), - rule.set, - rule.guards, - ); - const updatedSet = new Set(updated.map((r) => r.id)); - return ids.map((id) => ({ id, ok: updatedSet.has(id) })); -} diff --git a/src/persist.test.ts b/src/persist.test.ts index a5ef006..5cc3610 100644 --- a/src/persist.test.ts +++ b/src/persist.test.ts @@ -9,19 +9,13 @@ import { type MailboxPersistArgs, type SenderAuthorization, type PersistedMailboxRow, - type ResolveMailboxRefs, } from "./persist.js"; import { createInMemoryMailboxEventBus } from "./bus.js"; import { buildMailFrame } from "./frame.js"; import { principalMail } from "./schema.js"; import { withTestDb, seedScope } from "./test-helpers.js"; import type { MailboxDb } from "./db.js"; -import { - MAX_MAILBOX_FRAME_BYTES, - MAX_MAILBOX_REFS, - assertMailboxFrameBytes, -} from "./write.js"; -import { getMailboxMessage, type MailboxRef } from "./read.js"; +import { MAX_MAILBOX_FRAME_BYTES, assertMailboxFrameBytes } from "./write.js"; let db: MailboxDb; @@ -376,10 +370,9 @@ describe("announcements", () => { }); await persist(args()); - const [row] = await rowsFor("acme", "user-1"); expect(seen).toEqual([ { - id: row!.id, + id: "acme:user-1:INBOX:1", tenantId: "acme", principalId: "user-1", recipientAddress: "usr_user-1@acme.example", @@ -444,10 +437,12 @@ describe("cached columns", () => { await persist(args({ raw: new Uint8Array([0xde, 0xad, 0xbe, 0xef]) })); const [row] = await rowsFor("acme", "user-1"); // `raw` stays authoritative; losing the cached columns must not lose the - // message. + // message. The envelope degrades to what little this package can infer + // when the parser rejects the frame: an empty subject, and the + // authorized sender's own address. expect(row).toBeDefined(); - expect(row?.subject).toBeNull(); - expect(row?.fromAddress).toBeNull(); + expect(row?.subject).toBe(""); + expect(row?.fromAddress).toBe(SENDER); }); }); @@ -472,19 +467,6 @@ describe("transport insert idempotency", () => { expect(await rowsFor("acme", "user-2")).toHaveLength(1); }); - test("concurrent identical persists leave one row", async () => { - const { upstream } = recordingUpstream(); - const persist = createMailboxPersist(db, { - upstream, - authorizeSender: () => ACTIVE, - }); - const a = args(); - - await Promise.all([persist(a), persist(a), persist(a), persist(a)]); - - expect(await rowsFor("acme", "user-1")).toHaveLength(1); - }); - test("distinct frames still create distinct rows", async () => { const { upstream } = recordingUpstream(); const persist = createMailboxPersist(db, { @@ -523,223 +505,6 @@ describe("transport insert idempotency", () => { }); }); -describe("resolveRefs", () => { - test("refs are visible to a bus subscriber on the create event", async () => { - const bus = createInMemoryMailboxEventBus(); - const seenIds: string[] = []; - bus.subscribe({ tenantId: "acme", principalId: "user-1" }, (e) => - seenIds.push(e.id), - ); - const refs: MailboxRef[] = [{ kind: "workbench", id: "thread-1" }]; - const { upstream } = recordingUpstream(); - const persist = createMailboxPersist(db, { - upstream, - authorizeSender: () => ACTIVE, - bus, - resolveRefs: () => refs, - }); - - await persist(args()); - - // By the time the bus fires, the insert has already committed — a - // subscriber reading the row by the announced id sees refs already - // stamped, not a later-arriving update. - expect(seenIds).toHaveLength(1); - const message = await getMailboxMessage(db, { - tenantId: "acme", - principalId: "user-1", - id: seenIds[0]!, - }); - expect(message?.refs).toEqual(refs); - }); - - test("resolveRefs is called once for three recipients, and every row gets its refs", async () => { - await seedScope(db, "acme", "user-3"); - const refs: MailboxRef[] = [{ kind: "workbench", id: "thread-1" }]; - const calls: unknown[] = []; - const resolveRefs: ResolveMailboxRefs = (a) => { - calls.push(a); - return refs; - }; - const { upstream } = recordingUpstream(); - const persist = createMailboxPersist(db, { - upstream, - authorizeSender: () => ACTIVE, - resolveRefs, - }); - - await persist( - args({ - recipients: [ - "usr_user-1@acme.example", - "usr_user-2@acme.example", - "usr_user-3@acme.example", - ], - }), - ); - - expect(calls).toHaveLength(1); - for (const principalId of ["user-1", "user-2", "user-3"]) { - const [row] = await rowsFor("acme", principalId); - const message = await getMailboxMessage(db, { - tenantId: "acme", - principalId, - id: row!.id, - }); - expect(message?.refs).toEqual(refs); - } - }); - - test("a throwing resolveRefs writes zero rows; upstream still completes", async () => { - const { upstream, result } = recordingUpstream(); - const persist = createMailboxPersist(db, { - upstream, - authorizeSender: () => ACTIVE, - resolveRefs: () => { - throw new Error("resolver exploded"); - }, - }); - - expect(await persist(args())).toBe(result); - expect(await rowsFor("acme", "user-1")).toHaveLength(0); - }); - - test("over-cap refs from resolveRefs are truncated to MAX_MAILBOX_REFS", async () => { - const refs: MailboxRef[] = Array.from({ length: MAX_MAILBOX_REFS + 5 }, (_, i) => ({ - kind: "workbench", - id: `thread-${i}`, - })); - const { upstream } = recordingUpstream(); - const persist = createMailboxPersist(db, { - upstream, - authorizeSender: () => ACTIVE, - resolveRefs: () => refs, - }); - - await persist(args()); - - const [row] = await rowsFor("acme", "user-1"); - const message = await getMailboxMessage(db, { - tenantId: "acme", - principalId: "user-1", - id: row!.id, - }); - expect(message?.refs?.length).toBe(MAX_MAILBOX_REFS); - }); - - test("retried frame with a different resolver result keeps the FIRST refs", async () => { - let call = 0; - const { upstream } = recordingUpstream(); - const persist = createMailboxPersist(db, { - upstream, - authorizeSender: () => ACTIVE, - resolveRefs: () => [{ kind: "workbench", id: `attempt-${++call}` }], - }); - - await persist(args()); - await persist(args()); - - const all = await rowsFor("acme", "user-1"); - expect(all).toHaveLength(1); - expect(call).toBe(2); - const message = await getMailboxMessage(db, { - tenantId: "acme", - principalId: "user-1", - id: all[0]!.id, - }); - expect(message?.refs).toEqual([{ kind: "workbench", id: "attempt-1" }]); - }); - - test("resolver returning undefined stores SQL NULL, not []", async () => { - const { upstream } = recordingUpstream(); - const persist = createMailboxPersist(db, { - upstream, - authorizeSender: () => ACTIVE, - resolveRefs: () => undefined, - }); - - await persist(args()); - - const [row] = await rowsFor("acme", "user-1"); - expect(row!.refs).toBeNull(); - }); - - test("resolver returning [] stores SQL NULL, not []", async () => { - const { upstream } = recordingUpstream(); - const persist = createMailboxPersist(db, { - upstream, - authorizeSender: () => ACTIVE, - resolveRefs: () => [], - }); - - await persist(args()); - - const [row] = await rowsFor("acme", "user-1"); - expect(row!.refs).toBeNull(); - }); - - test("schema-invalid resolver output writes zero rows; upstream result still returned", async () => { - const { upstream, result } = recordingUpstream(); - const persist = createMailboxPersist(db, { - upstream, - authorizeSender: () => ACTIVE, - resolveRefs: () => [{ kind: 42, id: "x" }] as unknown as MailboxRef[], - }); - - expect(await persist(args())).toBe(result); - expect(await rowsFor("acme", "user-1")).toHaveLength(0); - }); - - test("resolver runs after upstream resolves (serial), adding its latency to the call", async () => { - const order: string[] = []; - const persist = createMailboxPersist(db, { - upstream: async () => { - await Bun.sleep(150); - order.push("upstream"); - return [{ delivered: true }]; - }, - authorizeSender: () => ACTIVE, - resolveRefs: async () => { - order.push("resolver-start"); - await Bun.sleep(150); - order.push("resolver-end"); - return undefined; - }, - }); - - const t0 = performance.now(); - await persist(args()); - const elapsed = performance.now() - t0; - - expect(order).toEqual(["upstream", "resolver-start", "resolver-end"]); - expect(elapsed).toBeGreaterThanOrEqual(290); - }); - - test("the 21st ref (a workbench ref appended last) is silently dropped", async () => { - const refs: MailboxRef[] = Array.from({ length: MAX_MAILBOX_REFS }, (_, i) => ({ - kind: "thread", - id: `t-${i}`, - })); - refs.push({ kind: "workbench", id: "must-be-present" }); - const { upstream } = recordingUpstream(); - const persist = createMailboxPersist(db, { - upstream, - authorizeSender: () => ACTIVE, - resolveRefs: () => refs, - }); - - await persist(args()); - - const [row] = await rowsFor("acme", "user-1"); - const message = await getMailboxMessage(db, { - tenantId: "acme", - principalId: "user-1", - id: row!.id, - }); - expect(message?.refs?.some((r) => r.kind === "workbench")).toBe(false); - }); -}); - describe("frame size and recipient hard caps", () => { test("assertMailboxFrameBytes accepts at-cap and throws RangeError one byte over", () => { // Dual-write swallows the RangeError inside attemptMailboxWrite; the pure diff --git a/src/persist.ts b/src/persist.ts index 98facf7..130a078 100644 --- a/src/persist.ts +++ b/src/persist.ts @@ -2,11 +2,12 @@ // sender authorization, and dual-write independence (an upstream throw still // attempts the mailbox write). // -// Both are properties of ONE wrapper: a host's mail transport already persists -// its own record of a frame (the sender's outbound copy, agent-instance -// deliveries), and this package additionally lands a durable inbound row in -// every addressed principal's mailbox. Two writes, two owners, and the whole -// point is that neither can take the other down. +// A host's mail transport already persists its own record of a frame (the +// sender's outbound copy, agent-instance deliveries), and this package +// additionally lands a durable inbound row — through the native store's +// `append`, so it always carries a uid/modseq — in every addressed +// principal's INBOX. Two writes, two owners, and the whole point is that +// neither can take the other down. // // The "active instance only" predicate itself is NOT implementable here and is // not ours to implement: deciding whether a sender address belongs to a live @@ -15,65 +16,22 @@ // to authorize gets NO mailbox row, while the frame is still delegated upstream // exactly as it would have been. -import { createHash } from "node:crypto"; -import { and, eq, inArray, sql } from "drizzle-orm"; -import { type } from "arktype"; +import { and, eq, inArray } from "drizzle-orm"; import { getLogger } from "@intx/log"; -import { hostPrincipal, mailbox, principalMail } from "./schema.js"; +import { hostPrincipal } from "./schema.js"; import type { MailboxDb } from "./db.js"; +import { openNativeMailboxStore } from "./native-store.js"; import { publishMailboxEvent, type MailboxEventBus } from "./bus.js"; -import { decodeMailFrame, parseMsgIdList, type DecodedFrame } from "./frame.js"; +import { decodeMailFrame, parseMsgIdList } from "./frame.js"; import { resolveMailboxRecipients } from "./recipients.js"; -import { MailboxRefArraySchema, type MailboxRef } from "./read.js"; -import { - assertMailboxScope, - assertMailboxFrameBytes, - boundRefs, -} from "./write.js"; +import { assertMailboxScope, assertMailboxFrameBytes } from "./write.js"; const logger = getLogger(["corbits-mailbox", "persist"]); -/** - * Tags a thrown error with the persist stage that produced it, so the - * dual-write failure log can name `resolveRefs` specifically instead of a - * generic "mailbox write failed". The wrapped error is what's logged and - * (never here) rethrown — see `attemptMailboxWrite`. - */ -class MailboxPersistStageError extends Error { - readonly stage: string; - constructor(stage: string, cause: unknown) { - super(cause instanceof Error ? cause.message : String(cause), { cause }); - this.name = "MailboxPersistStageError"; - this.stage = stage; - } -} - -// Cap the sender-controlled recipient list before resolve / inArray / multi-row -// insert. Matches MAX_BULK_MAILBOX_IDS posture: hard refuse, never clamp. +// Cap the sender-controlled recipient list before resolve / inArray / one +// append per recipient. Hard refuse, never clamp. export const MAX_MAILBOX_RECIPIENTS = 50; -/** - * Package-owned idempotency key for one transport dual-write row. - * - * Stable across retries of the same frame+recipient so `onConflictDoNothing` - * collapses a re-delivery into a single durable inbound row (no outbox, no - * extra table — reuses the partial unique index on message_key): - * - Prefer Message-ID from the decoded frame when present: - * `transport:mid::` - * - Else content-hash the raw bytes: - * `transport:raw::` - */ -function transportMessageKey( - messageId: string | null | undefined, - raw: Uint8Array, - principalId: string, -): string { - const mid = messageId?.trim(); - if (mid) return `transport:mid:${mid}:${principalId}`; - const hash = createHash("sha256").update(raw).digest("hex"); - return `transport:raw:${hash}:${principalId}`; -} - export type MailboxPersistArgs = { senderAddress: string; recipients: string[]; @@ -91,16 +49,12 @@ export type SenderAuthorization = { tenantId: string; domain: string }; /** * Host seam for sender authorization. Return `null` to refuse: the mailbox * write is skipped entirely and the frame is still delegated upstream. - * - * The reference behavior is: resolve the - * sender address to an agent instance that has not ended, and refuse anything - * else. A host implements that against its own control plane. */ export type AuthorizeMailboxSender = ( senderAddress: string, ) => Promise | SenderAuthorization | null; -/** One durable inbound row, announced after its insert commits. */ +/** One durable inbound message, announced after its append settles. */ export type PersistedMailboxRow = { id: string; tenantId: string; @@ -109,62 +63,19 @@ export type PersistedMailboxRow = { senderAddress: string; }; -/** - * Host seam for stamping every recipient row of one frame with the same - * `refs`. Called once per frame, before the transaction opens — NOT once per - * recipient — so a host pointing every row at the same upstream entity - * (`{ kind: "workbench", id }`) does one lookup, not N. - * - * Runs AFTER `upstream` resolves, and serially with it — not concurrently — - * so its latency adds to the call. This keeps refs available before the - * mailbox transaction opens without racing `upstream`'s own effects. - * - * Refs are frozen at the FIRST successful insert for a frame: a retried - * frame (same idempotency key) that reaches `resolveRefs` again still runs - * the resolver — it is not skipped — but a different result is discarded, - * since `onConflictDoNothing` means no row is written for the retry. Do not - * rely on a resolver's return value being applied on any call after the - * first that actually inserts. - * - * The result is validated with `MailboxRefArraySchema` and capped at - * `MAX_MAILBOX_REFS` the same way `writeMailboxMessage`'s `refs` argument is; - * see `boundRefs`. Returning `undefined` (or an empty array) stores no refs. - * Because excess entries are truncated rather than rejected, a resolver - * MUST return a small set with the load-bearing ref FIRST — anything past - * `MAX_MAILBOX_REFS` is silently dropped from the end of the list. - * - * A throwing `resolveRefs` is handled exactly like a mailbox-write failure - * under the dual-write contract: logged (naming `resolveRefs` as the failing - * stage), upstream still runs (it already ran, or still will, independently - * of this), and no mailbox row is written for that frame. See - * ARCHITECTURE.md's persist section. - */ -export type ResolveMailboxRefs = ( - args: MailboxPersistArgs & { - senderAuthorization: SenderAuthorization; - decoded: DecodedFrame | null; - }, -) => Promise | MailboxRef[] | undefined; - export type CreateMailboxPersistOpts = { /** The host's own persist path. Always called, for every frame. */ upstream: (args: MailboxPersistArgs) => Promise; authorizeSender: AuthorizeMailboxSender; - /** Best-effort live signal per inserted row. */ + /** Best-effort live signal per delivered message. */ bus?: MailboxEventBus; - /** Best-effort hook per inserted row; a throw is logged, never propagated. */ + /** Best-effort hook per delivered message; a throw is logged, never propagated. */ onRow?: (row: PersistedMailboxRow) => void; - /** - * Resolve the `refs` every recipient row of one frame gets, INSIDE the - * existing single transaction — so the post-commit bus event and any SSE - * subscriber already see them. See `ResolveMailboxRefs`. - */ - resolveRefs?: ResolveMailboxRefs; }; /** * Wrap a host's mail-persist function so every addressed principal also gets a - * durable `principal_mail` row. + * durable INBOX message. * * **Dual-write independence** is the contract, in both directions: * @@ -206,10 +117,10 @@ export function createMailboxPersist( recipients, raw, }: MailboxPersistArgs): Promise { - // Guardrails before authorize/resolve/SQL: a multi-megabyte frame or a - // thousands-long recipient list would amplify memory, parameter lists, and - // per-principal bytea copies. RangeError is caught by attemptMailboxWrite - // (dual-write independence) but still prevents any partial insert. + // Guardrails before authorize/resolve/append: a multi-megabyte frame or a + // thousands-long recipient list would amplify memory and per-principal + // copies. RangeError is caught by attemptMailboxWrite (dual-write + // independence) but still prevents any partial delivery. assertMailboxFrameBytes(raw); if (recipients.length > MAX_MAILBOX_RECIPIENTS) { throw new RangeError( @@ -231,10 +142,8 @@ export function createMailboxPersist( // The tenant comes from the host's authorizer and the principals from // recipient addresses, so this path can produce a blank scope without any - // caller having typed one. A throw here is caught by `attemptMailboxWrite` - // and logged loudly, which is the correct outcome: the upstream persist - // still stands, and the operator hears about an authorizer returning a - // blank tenant instead of accumulating rows nobody can ever read. + // caller having typed one. A throw here is caught by + // `attemptMailboxWrite` and logged loudly. for (const recipient of addressed) { assertMailboxScope({ tenantId: auth.tenantId, @@ -242,11 +151,10 @@ export function createMailboxPersist( }); } - // Recipient local parts are SENDER-controlled, and the scope FKs refuse a - // principal the control plane does not know. Filtering here (rather than - // letting the insert throw) keeps one typo'd address from costing every - // real recipient on the same frame their durable copy — and is what - // stops external mail from minting unreachable phantom mailboxes. + // Recipient local parts are SENDER-controlled, and a principal the + // control plane does not know cannot own a native mailbox. Filtering + // here keeps one typo'd address from costing every real recipient on the + // same frame their durable copy. const known = new Set( ( await db @@ -277,120 +185,52 @@ export function createMailboxPersist( } if (resolved.length === 0) return; - // Cached columns, parsed once at write. A frame the MIME parser rejects - // still persists — `raw` stays authoritative for detail; list uses these - // caches only — so a failed parse is the expected case here, not a fault. + // A frame the MIME parser rejects still delivers — `raw` stays + // authoritative — but its envelope degrades to what little this package + // can infer, matching what `writeMailboxMessage` mints when a caller + // supplies none of these fields. const decoded = decodeMailFrame(raw); - const subject = decoded?.headers.get("subject") ?? null; - const fromAddress = decoded?.headers.get("from") ?? null; + const subject = decoded?.headers.get("subject") ?? ""; + const fromAddress = decoded?.headers.get("from") ?? senderAddress; const messageId = decoded?.messageId ?? null; - // Cache the same shape migration `0002_mail_threading_headers` backfills - // from legacy frames: the first BRACKETED msg-id in `In-Reply-To`, or - // `null` — never the raw header value. An externally delivered frame's - // `In-Reply-To` is not validated on this path (see `assertMsgId`'s - // JSDoc), so it can be a bare id, several ids, or otherwise malformed; - // caching that raw junk would make the cached column disagree with what - // an upgrade's backfill would have produced for the same frame. - const inReplyTo = parseMsgIdList(decoded?.headers.get("in-reply-to"))[0] ?? null; - // The whole chain, oldest first, on the same terms: bracketed msg-ids - // only, and NULL rather than `[]` for a frame that carries none — what - // migration 0003's backfill derives from the same header text. - const references = decoded === null || decoded.references.length === 0 - ? null - : decoded.references; - - // Resolved ONCE per frame, before the transaction — every recipient row - // gets the same refs, and a resolver that hits an upstream entity does one - // lookup regardless of recipient count. A throw here propagates out of - // `writeMailboxRows` exactly like any other pre-transaction failure: - // `attemptMailboxWrite` catches and logs it, upstream still stands, and no - // mailbox row is written for this frame. - let refs: MailboxRef[] | undefined; - if (opts.resolveRefs) { - let resolvedRefs: MailboxRef[] | undefined; - try { - resolvedRefs = await opts.resolveRefs({ - senderAddress, - recipients, - raw, - senderAuthorization: auth, - decoded, - }); - } catch (err) { - throw new MailboxPersistStageError("resolveRefs", err); - } - if (resolvedRefs !== undefined && resolvedRefs.length > 0) { - const validated = MailboxRefArraySchema(resolvedRefs); - if (validated instanceof type.errors) { - throw new RangeError(`invalid mailbox refs: ${validated.summary}`); - } - refs = boundRefs(validated, messageId, { senderAddress }); - } - } + const inReplyTo = + parseMsgIdList(decoded?.headers.get("in-reply-to"))[0] ?? undefined; + const references = decoded?.references ?? []; - // Mail rows and their management rows commit together: the management row - // is created eagerly with the message (see `writeMailboxMessage`), and a - // message without one is unreachable by every mutation. messageKey makes - // the insert idempotent under transport retry — same onConflictDoNothing - // pattern as `writeMailboxMessage` on the partial unique index (keys use - // the transport: namespace, not inbox/gate/run). - const inserted = await db.transaction(async (tx) => { - const mailRows = await tx - .insert(principalMail) - .values( - resolved.map((recipient) => ({ - tenantId: auth.tenantId, - principalId: recipient.principalId, - address: recipient.address, - direction: "inbound" as const, - raw: Buffer.from(raw), - subject, - fromAddress, - messageId, - inReplyTo, - refs: refs ?? null, - references, - messageKey: transportMessageKey( - messageId, - raw, - recipient.principalId, - ), - })), - ) - .onConflictDoNothing({ - target: [ - principalMail.tenantId, - principalMail.principalId, - principalMail.messageKey, - ], - where: sql`${principalMail.messageKey} IS NOT NULL`, - }) - .returning({ - id: principalMail.id, - principalId: principalMail.principalId, - }); - // `returning` only includes rows that actually inserted; a retry conflict - // yields an empty list and must not invent management rows. - if (mailRows.length > 0) { - await tx.insert(mailbox).values( - mailRows.map((row) => ({ - id: row.id, - tenantId: auth.tenantId, - principalId: row.principalId, - })), - ); + // One append per recipient, into their own INBOX — the native store has + // no multi-recipient batch. Deduped on messageId within each recipient's + // mailbox: a retried frame (same Message-ID) never delivers twice to the + // same principal. + for (const recipient of resolved) { + const store = await openNativeMailboxStore(db, { + tenantId: auth.tenantId, + principalId: recipient.principalId, + folder: "INBOX", + }); + if ( + messageId !== null && + store.messages.some((m) => m.envelope.messageId === messageId) + ) { + continue; } - return mailRows; - }); - - const byPrincipal = new Map( - resolved.map((recipient) => [recipient.principalId, recipient]), - ); - for (const row of inserted) { - const recipient = byPrincipal.get(row.principalId); - if (!recipient) continue; + const uid = store.append( + raw, + { + messageId: messageId ?? "", + from: fromAddress, + to: [recipient.address], + subject, + date: new Date(), + inReplyTo, + references, + interchangeType: undefined, + interchangeCorrelationId: undefined, + }, + [], + ); + await store.settled; announce({ - id: row.id, + id: `${auth.tenantId}:${recipient.principalId}:INBOX:${uid}`, tenantId: auth.tenantId, principalId: recipient.principalId, recipientAddress: recipient.address, @@ -403,14 +243,10 @@ export function createMailboxPersist( try { await writeMailboxRows(args); } catch (err) { - // Decoded independently of `writeMailboxRows`'s own decode: the throw - // may have happened before that decode ran (e.g. authorizeSender), and - // this log line must still correlate to a messageId when one exists. const messageId = decodeMailFrame(args.raw)?.messageId ?? null; logger.error("mailbox write failed for mail from {senderAddress}", { senderAddress: args.senderAddress, messageId, - ...(err instanceof MailboxPersistStageError ? { stage: err.stage } : {}), error: err instanceof Error ? err : new Error(String(err)), }); } diff --git a/src/purge.test.ts b/src/purge.test.ts index 38cd3d1..214593c 100644 --- a/src/purge.test.ts +++ b/src/purge.test.ts @@ -1,17 +1,12 @@ // The control-plane FKs cascade on tenant/principal delete, but hosts that // soft-delete their control-plane rows never fire them — the exported purges // exist for those hosts. These tests hold them to what the cascade would have -// done: everything for that tenant, nothing belonging to anyone else, -// whatever view the rows are in. +// done: everything for that tenant, nothing belonging to anyone else. import { beforeEach, describe, expect, test } from "bun:test"; import { and, eq } from "drizzle-orm"; import { purgeTenantMailbox, purgePrincipalMailbox } from "./purge.js"; import { writeMailboxMessage } from "./write.js"; -import { - trashMailboxMessage, - archiveMailboxMessage, -} from "./mutations.js"; -import { mailbox, principalMail } from "./schema.js"; +import { principalMail } from "./schema.js"; import { withTestDb, seedScope } from "./test-helpers.js"; import type { MailboxDb } from "./db.js"; @@ -53,21 +48,6 @@ async function countFor(tenantId: string, principalId?: string) { return rows.length; } -async function countMailboxFor(tenantId: string, principalId?: string) { - const rows = await db - .select({ id: mailbox.id }) - .from(mailbox) - .where( - principalId === undefined - ? eq(mailbox.tenantId, tenantId) - : and( - eq(mailbox.tenantId, tenantId), - eq(mailbox.principalId, principalId), - ), - ); - return rows.length; -} - describe("purgeTenantMailbox", () => { test("deletes every row for the tenant and returns how many", async () => { await seed("acme", "user-1", "a"); @@ -81,43 +61,6 @@ describe("purgeTenantMailbox", () => { expect(await countFor("globex")).toBe(1); }); - test("reaches archived and trashed rows, not just the active inbox", async () => { - const archived = await seed("acme", "user-1", "archived"); - const trashed = await seed("acme", "user-1", "trashed"); - await seed("acme", "user-1", "active"); - await archiveMailboxMessage(db, { - tenantId: "acme", - principalId: "user-1", - id: archived, - }); - await trashMailboxMessage(db, { - tenantId: "acme", - principalId: "user-1", - id: trashed, - }); - - // An offboarded tenant's trash is as much their data as their inbox. - expect(await purgeTenantMailbox(db, "acme")).toBe(3); - expect(await countFor("acme")).toBe(0); - }); - - test("clears the tenant's mailbox rows as well as its principal_mail rows", async () => { - const archived = await seed("acme", "user-1", "archived"); - await seed("acme", "user-1", "untouched"); - await archiveMailboxMessage(db, { - tenantId: "acme", - principalId: "user-1", - id: archived, - }); - // Precondition: every message has its eagerly-created management row. - expect(await countMailboxFor("acme")).toBe(2); - - // The purge returns MESSAGES deleted, not rows across both tables. - expect(await purgeTenantMailbox(db, "acme")).toBe(2); - expect(await countFor("acme")).toBe(0); - expect(await countMailboxFor("acme")).toBe(0); - }); - test("purging a tenant with no mail is 0, not an error", async () => { expect(await purgeTenantMailbox(db, "nobody")).toBe(0); }); @@ -163,27 +106,6 @@ describe("purgePrincipalMailbox", () => { expect(await countFor("acme", "user-2")).toBe(1); }); - test("clears the principal's mailbox rows and leaves another principal's", async () => { - const mine = await seed("acme", "user-1", "a"); - const theirs = await seed("acme", "user-2", "b"); - for (const [principalId, id] of [ - ["user-1", mine], - ["user-2", theirs], - ] as const) { - await archiveMailboxMessage(db, { tenantId: "acme", principalId, id }); - } - expect(await countMailboxFor("acme")).toBe(2); - - expect( - await purgePrincipalMailbox(db, { - tenantId: "acme", - principalId: "user-1", - }), - ).toBe(1); - expect(await countMailboxFor("acme", "user-1")).toBe(0); - expect(await countMailboxFor("acme", "user-2")).toBe(1); - }); - test("is tenant-scoped: the same principal id in another tenant survives", async () => { await seed("acme", "user-1", "a"); await seed("globex", "user-1", "b"); diff --git a/src/read.ts b/src/read.ts deleted file mode 100644 index a77f6f5..0000000 --- a/src/read.ts +++ /dev/null @@ -1,703 +0,0 @@ -import { - and, - desc, - eq, - getTableColumns, - isNotNull, - isNull, - sql, - type SQL, -} from "drizzle-orm"; -import { type } from "arktype"; -import { getLogger } from "@intx/log"; -import { base64urlDecode, base64urlEncode } from "@intx/types"; -import { mailbox, principalMail, type MailboxJoinedRow } from "./schema.js"; -import { priorityRank, canonicalMailboxPriorities } from "./vocabulary.js"; -import type { MailboxDb } from "./db.js"; -import { decodeMailFrame, type DecodedFrame } from "./frame.js"; -import { parseAddressList } from "./recipients.js"; - -const logger = getLogger(["corbits-mailbox", "read"]); - -const SNIPPET_MAX_CHARS = 160; - -// Structured entity references surfaced as a message's "Related" action row. -// Kept intentionally generic (kind/id/label) so this package makes no -// assumption about what entity kinds a host cares about. -export const MailboxRefSchema = type({ - kind: "string", - id: "string", - "label?": "string", -}); -export type MailboxRef = typeof MailboxRefSchema.infer; - -export const MailboxRefArraySchema = MailboxRefSchema.array(); - -export const MAILBOX_VIEWS = ["all", "unread", "archived", "trash"] as const; -export const MailboxInboxViewSchema = type.enumerated(...MAILBOX_VIEWS); -export type MailboxInboxView = typeof MailboxInboxViewSchema.infer; - -/** - * The inbox sorts by priority, not only by arrival. `date` is - * newest-first; `priority` is most-urgent-first, newest-first within a - * priority band. - */ -export const MAILBOX_SORTS = ["date", "priority"] as const; -export const MailboxSortSchema = type.enumerated(...MAILBOX_SORTS); -export type MailboxSort = typeof MailboxSortSchema.infer; - -// The exact rendering `encodeMailboxListCursor` receives from `listUserMailbox`'s -// `to_char(…, 'YYYY-MM-DD"T"HH24:MI:SS.US"Z"')` — microseconds and all. -const CURSOR_CREATED_AT = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{6}Z$/; - -const MailboxListCursorSchema = type({ - createdAt: "string", - id: "string", - view: MailboxInboxViewSchema, - sort: MailboxSortSchema, - /** - * The priority rank of the row the cursor points at — the keyset's leading - * component under `sort=priority`, and meaningless (absent) under `date`. - */ - "rank?": "number", - /** - * Canonical rendering of the host's priority ordering at the moment the page - * was minted — present under `sort=priority` for the same reason `rank` is, - * and meaningless (absent) under `date`. See `canonicalMailboxPriorities`. - */ - "priorities?": "string", - /** - * Canonical rendering of the filters the page was minted under; see - * `canonicalMailboxFilter`. - */ - filter: "string", -}); -export type MailboxListCursor = typeof MailboxListCursorSchema.infer; - -/** - * The enrichment/delegation filters a list request can narrow by. Every field - * is optional; an absent field filters nothing. - */ -export const MailboxFilterSchema = type({ - "priority?": "string", - "classification?": "string", - "status?": "string", - "assignee?": "string", -}); -export type MailboxFilter = typeof MailboxFilterSchema.infer; - -const FILTER_KEYS = [ - "priority", - "classification", - "status", - "assignee", -] as const; - -/** - * A stable string identifying which filters a page was produced under. - * - * A keyset cursor is only meaningful against the exact result set it was minted - * from — that is already why the view is embedded and a cross-view cursor is a - * 400. Filters partition the same way: paging a `priority=high` cursor into an - * unfiltered list would skip every non-high message newer than the cursor, and - * do it silently. Embedding this string lets the route refuse instead. - */ -export function canonicalMailboxFilter(filter: MailboxFilter): string { - return FILTER_KEYS.filter((key) => filter[key] !== undefined) - .map((key) => `${key}=${encodeURIComponent(filter[key]!)}`) - .join("&"); -} - -/** - * `createdAt` is the timestamp rendered by Postgres, carrying full microsecond - * precision. It deliberately never passes through a JS `Date`, which holds only - * milliseconds — truncating it silently strands every row inside the rounded-off - * microsecond window on the far side of the cursor. - */ -export function encodeMailboxListCursor( - row: { createdAt: string; id: string; rank?: number }, - shape: { - view: MailboxInboxView; - sort: MailboxSort; - filter: string; - /** The host's canonical priority ordering; required under `sort=priority`. */ - priorities?: string; - }, -): string { - const payload: { - createdAt: string; - id: string; - view: MailboxInboxView; - sort: MailboxSort; - filter: string; - rank?: number; - priorities?: string; - } = { - createdAt: row.createdAt, - id: row.id, - view: shape.view, - sort: shape.sort, - filter: shape.filter, - }; - if (shape.sort === "priority") { - payload.rank = row.rank; - payload.priorities = shape.priorities; - } - return base64urlEncode(new TextEncoder().encode(JSON.stringify(payload))); -} - -/** - * Decode an opaque list cursor, embedding the view, sort and filters it was - * minted for so a cursor from one result set used against another is rejected — - * the caller compares those fields and answers 400. A malformed cursor (bad - * base64, non-JSON, wrong shape, bad date) also returns null so the route can - * answer 400 rather than trust it. - */ -export function decodeMailboxListCursor(raw: string): MailboxListCursor | null { - let json: string; - try { - // `base64urlDecode` is `atob`-backed and DOES throw on a non-base64 - // character — unlike `Buffer.from(raw, "base64url")`, which silently - // returns garbage. Without this catch a hand-typed cursor is a 500. - json = new TextDecoder().decode(base64urlDecode(raw)); - } catch { - return null; - } - let parsed: unknown; - try { - parsed = JSON.parse(json); - } catch { - return null; - } - const result = MailboxListCursorSchema(parsed); - if (result instanceof type.errors) return null; - // `createdAt` is interpolated into a `::timestamp` cast, so it must be - // exactly the shape this package MINTS (see `encodeMailboxListCursor` / - // `to_char` in `listUserMailbox`) — not merely something JS `new Date()` - // tolerates. JS and Postgres disagree at the margins (`new Date("0")` is - // year 2000; `'0'::timestamp` raises), and a crafted cursor must be a 400, - // never a PostgresError 500. - // Both checks: the regex pins the shape, and `Date` (strict for ISO input) - // rejects out-of-range fields the regex cannot see, like month 99. - if (!CURSOR_CREATED_AT.test(result.createdAt)) return null; - if (Number.isNaN(new Date(result.createdAt).getTime())) return null; - // Same class of hole for `rank`: JSON admits `1e400` (Infinity), which is a - // "number" but not a rank any row was ever minted with. - if (result.rank !== undefined && !Number.isSafeInteger(result.rank)) { - return null; - } - // A priority-sorted cursor without its leading keyset component cannot seek, - // and one without the ordering that component was computed under cannot be - // checked against the current ordering. Either way it is malformed, and - // saying so is the only honest answer. - if (result.sort === "priority") { - if (result.rank === undefined) return null; - if (result.priorities === undefined) return null; - } - return result; -} - -// Sender display: turning a raw `From:` header into something a person can -// read. The control-plane half CANNOT live here — only the host knows what an -// address belongs to — so what follows is the pure half (extracting the -// address a display name is keyed by, and deciding whether a resolved label is -// worth surfacing) plus the `SenderDisplayResolver` seam a host implements to -// supply the labels. - -/** - * The bare mailbox address inside a `From:` header value. `"Jane Doe" - * ` is keyed by `j@x.example`; a header that is already a bare - * address is its own key. - */ -export function extractSenderMailboxAddress(fromHeader: string): string { - const trimmed = fromHeader.trim(); - const start = trimmed.indexOf("<"); - const end = trimmed.lastIndexOf(">"); - if (start >= 0 && end > start) { - return trimmed.slice(start + 1, end).trim(); - } - return trimmed; -} - -/** - * Pick the display name for a `From:` header, or `undefined` when there is - * nothing worth showing. - * - * A label is only surfaced when it is genuinely a *different* rendering of the - * sender: a resolver that echoes back the address itself (or the whole header) - * has resolved nothing, and emitting `fromDisplay` in that case would make a - * client render the same string twice. - */ -export function attachFromDisplay( - fromHeader: string, - displays: Map, -): string | undefined { - const address = extractSenderMailboxAddress(fromHeader); - const display = displays.get(address); - if ( - display === undefined || - display === address || - display === fromHeader.trim() - ) { - return undefined; - } - return display; -} - -/** - * The host seam for the control-plane half of sender display. Given the tenant - * and the raw `From:` header values on a page of messages, return a map from - * **normalized mailbox address** (what `extractSenderMailboxAddress` returns — - * not the full header) to the label to show. - * - * Batched per read on purpose: a per-message resolver turns one inbox page into - * fifty directory lookups. Addresses the host cannot resolve are simply absent - * from the map; there is no need to echo them back. - */ -export type SenderDisplayResolver = ( - tenantId: string, - fromHeaders: string[], -) => Promise> | Map; - -/** - * One message as the read path projects it. Exported as an arktype schema, not - * just a type: a consumer - * decoding this package's JSON off the wire needs something it can validate - * with, not only something it can cast to. - * - * `from` is ALWAYS present — the "header -> cached column -> default" chain - * ends in `""`, so a row whose frame is unparseable and whose `from_address` - * column is NULL still projects a `from`, and a client never has to branch on - * its absence. `subject` has no such default: an empty subject line is a real, - * distinct thing from no subject line, so it stays optional. - */ -export const MailboxMessageSchema = type({ - id: "string", - from: "string", - to: "string[]", - "fromDisplay?": "string", - "subject?": "string", - date: "string", - messageId: "string", - /** - * The immediate parent's msg-id, when the message has one. Served from the - * cached column on the list path and from the frame on detail, so a client - * can thread a page without fetching every message's `raw`. - */ - "inReplyTo?": "string", - read: "boolean", - "snippet?": "string", - "refs?": MailboxRefArraySchema, - "priority?": "string", - "classification?": "string", - "status?": "string", - /** Delegation: the principal this item was handed to, if any. */ - "assignee?": "string", -}); -export type MailboxMessage = typeof MailboxMessageSchema.infer; - -export const MailboxMessageDetailSchema = MailboxMessageSchema.and({ - body: "string", -}); -export type MailboxMessageDetail = typeof MailboxMessageDetailSchema.infer; - -/** - * The `GET /me/inbox` HTTP response envelope — note `messages`, not `items`: - * `listUserMailbox` returns the in-process `MailboxPage` shape, while this is - * what actually goes over the wire and what a client validates. - */ -export const MailboxListResponseSchema = type({ - messages: MailboxMessageSchema.array(), - "nextCursor?": "string", -}); - -// Every message has a management row, created eagerly with it, so the LEFT -// JOIN below is belt-and-braces rather than load-bearing: `IS NULL` reads the -// same for an all-NULL row (delivered-and-untouched) as it would for a row a -// direct host write somehow skipped, and the join can never drop a message. -function viewConditions(view: MailboxInboxView) { - switch (view) { - case "unread": - return [ - isNull(mailbox.trashedAt), - isNull(mailbox.archivedAt), - isNull(mailbox.readAt), - ]; - case "archived": - return [isNotNull(mailbox.archivedAt), isNull(mailbox.trashedAt)]; - case "trash": - return [isNotNull(mailbox.trashedAt)]; - // No `default` — every view is spelled out, so adding one to - // MAILBOX_VIEWS without deciding its predicate is a type error here - // rather than a silent fall-through to the "all" filter. - case "all": - return [isNull(mailbox.trashedAt), isNull(mailbox.archivedAt)]; - } -} - -function toISODate(dateHeader: string | undefined, createdAt: Date): string { - if (dateHeader === undefined) return createdAt.toISOString(); - const parsed = new Date(dateHeader); - if (Number.isNaN(parsed.getTime())) return createdAt.toISOString(); - return parsed.toISOString(); -} - -// One bad backfill would otherwise emit a warn line per bad row per page per -// request — steady-state log spam that buries the signal. Bad rows are -// collected per read and reported once, with a bounded sample of ids. -export type DroppedRefs = { rowIds: string[]; summary: string | null }; -const DROPPED_REFS_SAMPLE = 5; - -export function newDroppedRefs(): DroppedRefs { - return { rowIds: [], summary: null }; -} - -export function reportDroppedRefs(dropped: DroppedRefs): void { - if (dropped.rowIds.length === 0) return; - logger.warn("mailbox refs column failed schema; dropped for {rows} row(s)", { - rows: dropped.rowIds.length, - sampleRowIds: dropped.rowIds.slice(0, DROPPED_REFS_SAMPLE), - summary: dropped.summary, - }); -} - -// Validates the stored `refs` jsonb ON READ, not just on write. A row whose -// stored blob no longer matches the current schema (or was never valid) -// degrades to no refs (logged) rather than ever 500ing the read. -function readRowRefs( - stored: MailboxJoinedRow["refs"], - rowId: string, - dropped: DroppedRefs, -): MailboxRef[] | undefined { - if (stored === null || stored === undefined) return undefined; - const parsed = MailboxRefArraySchema(stored); - if (parsed instanceof type.errors) { - dropped.rowIds.push(rowId); - dropped.summary ??= parsed.summary; - return undefined; - } - return parsed.length > 0 ? parsed : undefined; -} - -// On the detail path the raw frame is authoritative: for each field, fall -// back header-value -> cached column -> default. On the list path `decoded` -// is null (list never selects `principal_mail.raw`), so subject/from come -// only from the cached columns and snippet is omitted. Never throws — a -// malformed frame degrades to the cached columns rather than failing the read. -// -// `raw` is intentionally absent from the row type: list selects every -// principal_mail column except it, and toMailboxMessage never needs it -// (the caller decodes outside and threads the result through `decoded`). -export function toMailboxMessage( - row: Omit, - decoded: DecodedFrame | null, - dropped: DroppedRefs, -): MailboxMessage { - const headers = decoded?.headers; - - // `to` is a list, so a multi-recipient header is split into its addresses - // rather than surfaced as one joined string. - const toHeader = headers?.get("to"); - const to = - toHeader === undefined ? [row.address] : parseAddressList(toHeader); - - const message: MailboxMessage = { - id: row.id, - // header -> cached column -> default. The default is `""`, not omission: - // see MailboxMessageSchema. - from: headers?.get("from") ?? row.fromAddress ?? "", - to, - date: toISODate(headers?.get("date"), row.createdAt), - // header -> cached column -> the row id. The row id is the last resort, not - // the cache: a frame with no Message-ID still needs a stable handle. - messageId: headers?.get("message-id") ?? row.messageId ?? row.id, - read: row.readAt !== null, - }; - const subject = headers?.get("subject") ?? row.subject ?? undefined; - if (subject !== undefined) message.subject = subject; - const inReplyTo = headers?.get("in-reply-to") ?? row.inReplyTo ?? undefined; - if (inReplyTo !== undefined) message.inReplyTo = inReplyTo; - if (decoded !== null && decoded.body.length > 0) { - message.snippet = decoded.body.slice(0, SNIPPET_MAX_CHARS); - } - const refs = readRowRefs(row.refs, row.id, dropped); - if (refs !== undefined) message.refs = refs; - if (row.priority !== null) message.priority = row.priority; - if (row.classification !== null) message.classification = row.classification; - if (row.status !== null) message.status = row.status; - if (row.assignee !== null) message.assignee = row.assignee; - return message; -} - -/** - * Stamp `fromDisplay` onto every message whose sender the host could resolve to - * a distinct human label. Resolution is batched into ONE call for the whole - * page — a resolver is typically a directory lookup, and doing it per message - * turns a 50-row page into 50 round trips. - * - * Strictly additive and best-effort: a resolver that throws costs the page its - * display names (logged), never the page itself. The raw `From:` header is - * already the authoritative value in `from`. - */ -async function applySenderDisplays( - messages: MailboxMessage[], - tenantId: string, - resolve: SenderDisplayResolver | undefined, -): Promise { - if (resolve === undefined || messages.length === 0) return; - const headers = messages - .map((message) => message.from) - .filter((from) => from.length > 0); - if (headers.length === 0) return; - let displays: Map; - try { - displays = await resolve(tenantId, headers); - } catch (err) { - logger.warn("sender display resolver failed; serving raw From headers", { - error: err instanceof Error ? err : new Error(String(err)), - }); - return; - } - for (const message of messages) { - const display = attachFromDisplay(message.from, displays); - if (display !== undefined) message.fromDisplay = display; - } -} - -function filterConditions(filter: MailboxFilter): SQL[] { - const columns = { - priority: mailbox.priority, - classification: mailbox.classification, - status: mailbox.status, - assignee: mailbox.assignee, - } as const; - const conditions: SQL[] = []; - for (const [key, column] of Object.entries(columns)) { - const value = filter[key as keyof MailboxFilter]; - if (value !== undefined) conditions.push(sql`${column} = ${value}`); - } - return conditions; -} - -/** The management columns, projected through the LEFT JOIN. */ -export const STATE_COLUMNS = { - readAt: mailbox.readAt, - archivedAt: mailbox.archivedAt, - trashedAt: mailbox.trashedAt, - priority: mailbox.priority, - classification: mailbox.classification, - status: mailbox.status, - assignee: mailbox.assignee, -} as const; - -/** - * Every `principal_mail` column except `raw`. List never loads the MIME frame; - * subject/from live in the cached columns, and list does not surface body or - * snippet. Derived from the table object so a new non-raw column is selected - * automatically. Exported so tests can lock the production select shape. - */ -const { raw: _rawNotOnList, ...principalMailListColumns } = - getTableColumns(principalMail); -export const PRINCIPAL_MAIL_LIST_COLUMNS = principalMailListColumns; - -export type MailboxScope = { - tenantId: string; - principalId: string; - limit: number; - cursor?: MailboxListCursor; - view: MailboxInboxView; - /** Defaults to `date` (newest first). */ - sort?: MailboxSort; - /** Enrichment/delegation narrowing; see `MailboxFilterSchema`. */ - filter?: MailboxFilter; - /** - * The host's priority vocabulary, most urgent first. There is no default: - * the ranking `sort=priority` uses is generated from this list, and the - * package has no taxonomy of its own to fall back on. - */ - priorities: readonly string[]; - /** Host seam for turning sender addresses into human labels; see `SenderDisplayResolver`. */ - resolveSenderDisplays?: SenderDisplayResolver; - /** - * Which direction of mail to serve. Defaults to `"inbound"` — the - * long-standing contract, since the inbox has only ever shown delivered - * mail. `"outbound"` reads a principal's own sent copies; `"all"` returns - * both, e.g. for a thread reader that needs a sender's copy alongside its - * recipients' copies. - */ - direction?: "inbound" | "outbound" | "all"; -}; - -export type MailboxPage = { - items: MailboxMessage[]; - nextCursor?: string; -}; - -/** - * List the caller's durable inbound mailbox, newest first, scoped to - * (tenantId, principalId). Keyset pagination ordered createdAt DESC, id DESC; - * fetches limit+1 rows to detect whether another page follows. - * - * Does NOT select `principal_mail.raw` and does NOT call `decodeMailFrame`. - * Subject/from come from the cached columns; snippet is omitted. Detail - * (`getMailboxMessage`) is the only path that loads the full MIME frame. - */ -export async function listUserMailbox( - db: MailboxDb, - scope: MailboxScope, -): Promise { - const sort: MailboxSort = scope.sort ?? "date"; - const filter: MailboxFilter = scope.filter ?? {}; - const PRIORITY_RANK = priorityRank(scope.priorities); - const direction = scope.direction ?? "inbound"; - const conditions = [ - eq(principalMail.tenantId, scope.tenantId), - eq(principalMail.principalId, scope.principalId), - ...(direction === "all" ? [] : [eq(principalMail.direction, direction)]), - ...viewConditions(scope.view), - ...filterConditions(filter), - ]; - if (scope.cursor) { - // Row-value comparison, matching the ORDER BY below exactly. Compared as - // `timestamp` at full precision, so a row sharing a millisecond with the - // cursor is still ordered by its microseconds and then by id. - // - // The cast is on the CURSOR, never on the column. `created_at` is - // `timestamp without time zone` holding UTC; casting it — with - // `AT TIME ZONE` or `::timestamptz` — costs the index outright, because - // `timestamp → timestamptz` is STABLE, not IMMUTABLE, and a STABLE - // expression cannot serve an index condition. Measured on 80k rows: this - // form is an `Index Cond`, 4 buffers / 0.08ms; with the cast moved to the - // column it becomes a `Filter` that removes 44,001 rows, 415 buffers / - // 11.7ms. - // - // `::timestamp`, not `::timestamptz`, is also the only CORRECT cast. A - // `timestamptz` literal compared against a zoneless column is resolved - // through the SESSION's TimeZone, so the same cursor selects a different - // page on a host whose server runs anywhere but UTC — silently, and still - // as an `Index Cond`, which is why no plan inspection would have caught it. - // See `read-non-utc-session.test.ts`. - // - // Under `sort=priority` the rank leads the keyset. It is negated on both - // sides so all three components run in the same direction — a row-value - // comparison cannot mix ASC and DESC, and `rank ASC` is exactly - // `(-rank) DESC`. - conditions.push( - sort === "priority" - ? sql`((0 - ${PRIORITY_RANK}), ${principalMail.createdAt}, ${principalMail.id}) < (${0 - scope.cursor.rank!}, ${scope.cursor.createdAt}::timestamp, ${scope.cursor.id})` - : sql`(${principalMail.createdAt}, ${principalMail.id}) < (${scope.cursor.createdAt}::timestamp, ${scope.cursor.id})`, - ); - } - const orderBy = - sort === "priority" - ? [ - sql`${PRIORITY_RANK} ASC`, - desc(principalMail.createdAt), - desc(principalMail.id), - ] - : [desc(principalMail.createdAt), desc(principalMail.id)]; - // PRINCIPAL_MAIL_LIST_COLUMNS omits `raw` — loading the full MIME frame on - // every list row was the dominant cost of inbox reads. - const rows = await db - .select({ - ...PRINCIPAL_MAIL_LIST_COLUMNS, - ...STATE_COLUMNS, - // Postgres renders the timestamp; a JS Date would drop the microseconds. - // Formatted explicitly rather than via ::text, whose output depends on - // the server's DateStyle. - // - // NO `AT TIME ZONE 'UTC'`. The column is `timestamp without time zone` - // already holding UTC, so there is nothing to convert: `AT TIME ZONE` - // would REINTERPRET it as a timestamptz and then render it in the - // SESSION's zone, stamping a `Z` onto a local-time string. The cursor - // minted from it would then seek to the wrong row on any host not running - // in UTC — and the `Z` makes the output look right while it does. - createdAtText: sql`to_char(${principalMail.createdAt}, 'YYYY-MM-DD"T"HH24:MI:SS.US"Z"')`, - priorityRank: PRIORITY_RANK, - }) - .from(principalMail) - .leftJoin(mailbox, eq(mailbox.id, principalMail.id)) - .where(and(...conditions)) - .orderBy(...orderBy) - .limit(scope.limit + 1); - - const hasMore = rows.length > scope.limit; - const pageRows = hasMore ? rows.slice(0, scope.limit) : rows; - const dropped = newDroppedRefs(); - const items = pageRows.map((row) => toMailboxMessage(row, null, dropped)); - reportDroppedRefs(dropped); - await applySenderDisplays(items, scope.tenantId, scope.resolveSenderDisplays); - const page: MailboxPage = { items }; - if (hasMore) { - // `hasMore` means rows.length > limit >= 1, so the page is non-empty. - const last = pageRows[pageRows.length - 1]!; - page.nextCursor = encodeMailboxListCursor( - { - createdAt: last.createdAtText, - id: last.id, - rank: Number(last.priorityRank), - }, - { - view: scope.view, - sort, - filter: canonicalMailboxFilter(filter), - priorities: canonicalMailboxPriorities(scope.priorities), - }, - ); - } - return page; -} - -/** - * Read one mailbox message with its full text body, scoped to - * (tenantId, principalId). Returns null when no row matches. A stored frame the - * MIME parser rejects degrades to an empty body (logged) — never a 500. - */ -export async function getMailboxMessage( - db: MailboxDb, - args: { - tenantId: string; - principalId: string; - id: string; - resolveSenderDisplays?: SenderDisplayResolver; - /** Defaults to `"inbound"`; see `MailboxScope.direction`. */ - direction?: "inbound" | "outbound" | "all"; - }, -): Promise { - const direction = args.direction ?? "inbound"; - const [row] = await db - .select({ ...getTableColumns(principalMail), ...STATE_COLUMNS }) - .from(principalMail) - .leftJoin(mailbox, eq(mailbox.id, principalMail.id)) - .where( - and( - eq(principalMail.id, args.id), - eq(principalMail.tenantId, args.tenantId), - eq(principalMail.principalId, args.principalId), - ...(direction === "all" ? [] : [eq(principalMail.direction, direction)]), - ), - ) - .limit(1); - if (!row) return null; - - // Decoded once and threaded through — the frame is the authority for both - // the headers and the body. - const decoded = decodeMailFrame(row.raw); - if (decoded === null) { - logger.error("stored mailbox frame failed to parse; serving empty body", { - messageId: row.id, - }); - } - const dropped = newDroppedRefs(); - const message = toMailboxMessage(row, decoded, dropped); - reportDroppedRefs(dropped); - await applySenderDisplays( - [message], - args.tenantId, - args.resolveSenderDisplays, - ); - const detail: MailboxMessageDetail = Object.assign(message, { - body: decoded === null ? "" : decoded.body, - }); - return detail; -} diff --git a/src/sse-heartbeat.test.ts b/src/sse-heartbeat.test.ts index 1329631..b8385c4 100644 --- a/src/sse-heartbeat.test.ts +++ b/src/sse-heartbeat.test.ts @@ -8,7 +8,7 @@ import { Hono } from "hono"; import { mountMailbox } from "./mount.js"; import { createInMemoryMailboxEventBus } from "./bus.js"; import { writeMailboxMessage } from "./write.js"; -import { withTestDb, seedScope, TEST_VOCABULARY } from "./test-helpers.js"; +import { withTestDb, seedScope } from "./test-helpers.js"; import type { MailboxDb } from "./db.js"; const SCOPE = { tenantId: "t1", principalId: "p1" }; @@ -16,7 +16,6 @@ const SCOPE = { tenantId: "t1", principalId: "p1" }; function stream(db: MailboxDb, heartbeatIntervalMs: number) { const bus = createInMemoryMailboxEventBus(); const app = mountMailbox(new Hono(), { - vocabulary: TEST_VOCABULARY, db, bus, resolvePrincipal: () => SCOPE, @@ -120,7 +119,6 @@ describe("SSE heartbeat", () => { const db = await withTestDb(); const bus = createInMemoryMailboxEventBus(); const app = mountMailbox(new Hono(), { - vocabulary: TEST_VOCABULARY, db, bus, resolvePrincipal: () => SCOPE, @@ -143,7 +141,6 @@ describe("SSE heartbeat", () => { // not on the first request, same as a bad vocabulary. const db = await withTestDb(); const base = { - vocabulary: TEST_VOCABULARY, db, bus: createInMemoryMailboxEventBus(), resolvePrincipal: () => SCOPE, diff --git a/src/sse-stream.test.ts b/src/sse-stream.test.ts index b80cf9e..f36da33 100644 --- a/src/sse-stream.test.ts +++ b/src/sse-stream.test.ts @@ -7,7 +7,7 @@ import { type MailboxEventBus, type MailboxEventScope, } from "./bus.js"; -import { withTestDb, seedScope, TEST_VOCABULARY } from "./test-helpers.js"; +import { withTestDb, seedScope } from "./test-helpers.js"; import { writeMailboxMessage } from "./write.js"; describe("SSE stream", () => { @@ -16,7 +16,6 @@ describe("SSE stream", () => { await seedScope(db, "t1", "p1"); const bus = createInMemoryMailboxEventBus(); const app = mountMailbox(new Hono(), { - vocabulary: TEST_VOCABULARY, db, bus, resolvePrincipal: () => ({ tenantId: "t1", principalId: "p1" }), @@ -68,7 +67,6 @@ describe("SSE stream", () => { await seedScope(db, "tenantB", "alice"); const bus = createInMemoryMailboxEventBus(); const app = mountMailbox(new Hono(), { - vocabulary: TEST_VOCABULARY, db, bus, resolvePrincipal: () => ({ tenantId: "tenantA", principalId: "alice" }), @@ -130,7 +128,6 @@ describe("SSE stream", () => { const bus = createInMemoryMailboxEventBus(); const scope = { tenantId: "t1", principalId: "p1" }; const app = mountMailbox(new Hono(), { - vocabulary: TEST_VOCABULARY, db, bus, resolvePrincipal: () => scope, @@ -196,7 +193,6 @@ describe("SSE stream", () => { }, }; const app = mountMailbox(new Hono(), { - vocabulary: TEST_VOCABULARY, db, bus, resolvePrincipal: () => scope, diff --git a/src/test-helpers.ts b/src/test-helpers.ts index 6d3e66a..714ec02 100644 --- a/src/test-helpers.ts +++ b/src/test-helpers.ts @@ -1,18 +1,7 @@ import { createMailboxDb, type MailboxDb } from "./db.js"; import { runMailboxMigrations } from "./migrations.js"; -import type { MailboxVocabulary } from "./vocabulary.js"; import { sql } from "drizzle-orm"; -/** - * A host vocabulary for the suite to mount with. The package ships none of its - * own, so this list plays the host's role: it lives on THIS side of the mount - * boundary. - */ -export const TEST_VOCABULARY: MailboxVocabulary = { - priorities: ["urgent", "high", "normal", "low"], - statuses: ["needs-action", "done"], -}; - export const TEST_DATABASE_URL = process.env.MAILBOX_TEST_DATABASE_URL ?? "postgres://postgres:postgres@localhost:5433/mailbox_core"; diff --git a/src/thread.ts b/src/thread.ts deleted file mode 100644 index 2d28cf4..0000000 --- a/src/thread.ts +++ /dev/null @@ -1,1117 +0,0 @@ -// Thread reads: the conversation under one entity ref, and the msg-id lookup -// that makes an externally-delivered reply findable. -// -// The whole module runs on the LIST path — it never selects `principal_mail.raw` -// and never decodes a MIME frame. That is not an optimization: a thread is read -// on every conversation open, and a projection that had to decode one frame per -// row would make the cached threading columns (`message_id`, `in_reply_to`, -// `references`) pointless. They exist for exactly this reader. - -import { and, asc, eq, inArray, sql, type SQL } from "drizzle-orm"; -import { type } from "arktype"; -import { getLogger } from "@intx/log"; -import { base64urlDecode, base64urlEncode } from "@intx/types"; -import { mailbox, principalMail } from "./schema.js"; -import type { MailboxDb } from "./db.js"; -import { decodeMailFrame } from "./frame.js"; -import { - MailboxRefSchema, - PRINCIPAL_MAIL_LIST_COLUMNS, - STATE_COLUMNS, - newDroppedRefs, - reportDroppedRefs, - toMailboxMessage, - type MailboxMessage, - type MailboxRef, -} from "./read.js"; - -const logger = getLogger(["corbits-mailbox", "thread"]); - -export const DEFAULT_MAILBOX_THREAD_LIMIT = 50; -/** Same ceiling the HTTP list surface enforces; a thread page is not cheaper. */ -export const MAX_MAILBOX_THREAD_LIMIT = 200; - -/** The (tenant, principal) mailbox a thread read is answered from. */ -export type MailboxThreadScope = { tenantId: string; principalId: string }; - -/** - * One message as the thread read projects it. - * - * `references` is ALWAYS present, `[]` for a message with no ancestry — a chain - * of no ancestors is an empty chain, not an absent one, and a client walking it - * should never have to branch. `parentId` is likewise always present and is - * `null`, never omitted and never invented, when the nearest ancestor is not in - * this mailbox under this ref. - * - * `createdAt` is Postgres's own microsecond rendering, the same string the - * cursor is minted from — deliberately not a JS `Date`, which holds only - * milliseconds. - */ -export const MailboxThreadMessageSchema = type({ - id: "string", - messageId: "string", - "inReplyTo?": "string", - references: "string[]", - fromAddress: "string", - "subject?": "string", - createdAt: "string", - read: "boolean", - archived: "boolean", - parentId: "string | null", - /** - * The message's full text body, decoded from the stored MIME frame — the - * same body `MailboxMessageDetailSchema` (`read.ts`) returns for a single - * message. A frame the MIME parser rejects degrades to `""` (logged), - * never a failed read. - */ - body: "string", -}); -export type MailboxThreadMessage = typeof MailboxThreadMessageSchema.infer; - -export const MailboxThreadResponseSchema = type({ - messages: MailboxThreadMessageSchema.array(), - "nextCursor?": "string", -}); - -export type MailboxThreadPage = { - items: MailboxThreadMessage[]; - nextCursor?: string; -}; - -export type MailboxThreadArgs = { - /** The entity the thread hangs off; matched on `kind` and `id` alone. */ - ref: MailboxRef; - cursor?: string; - /** 1..`MAX_MAILBOX_THREAD_LIMIT`; defaults to `DEFAULT_MAILBOX_THREAD_LIMIT`. */ - limit?: number; -}; - -// The same microsecond rendering `to_char` produces below, and the same shape -// the list cursor pins — a cursor is interpolated into a `::timestamp` cast, so -// it must be exactly what this package MINTS rather than merely something -// `new Date()` tolerates. -const CURSOR_CREATED_AT = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{6}Z$/; - -const MailboxThreadCursorSchema = type({ - createdAt: "string", - id: "string", - /** Canonical rendering of the ref the page was minted under. */ - ref: "string", -}); -export type MailboxThreadCursor = typeof MailboxThreadCursorSchema.infer; - -/** - * A stable string identifying which ref a thread page was produced under. - * - * JSON-encoded as a pair rather than joined with a separator: a `kind` or `id` - * containing the separator would otherwise let two different refs render the - * same string, and a cursor is only meaningful against the exact result set it - * was minted from. - */ -export function canonicalMailboxThreadRef(ref: MailboxRef): string { - return JSON.stringify([ref.kind, ref.id]); -} - -export function encodeMailboxThreadCursor( - row: { createdAt: string; id: string }, - ref: MailboxRef, -): string { - const payload: MailboxThreadCursor = { - createdAt: row.createdAt, - id: row.id, - ref: canonicalMailboxThreadRef(ref), - }; - return base64urlEncode(new TextEncoder().encode(JSON.stringify(payload))); -} - -/** - * Decode an opaque thread cursor, or null when it is malformed — bad base64, - * non-JSON, wrong shape, or a `createdAt` that is not the exact rendering this - * package mints. Null so a route can answer 400 rather than hand a crafted - * value to Postgres. - */ -export function decodeMailboxThreadCursor( - raw: string, -): MailboxThreadCursor | null { - let json: string; - try { - // `base64urlDecode` is `atob`-backed and DOES throw on a non-base64 - // character, unlike `Buffer.from(raw, "base64url")`. - json = new TextDecoder().decode(base64urlDecode(raw)); - } catch { - return null; - } - let parsed: unknown; - try { - parsed = JSON.parse(json); - } catch { - return null; - } - const result = MailboxThreadCursorSchema(parsed); - if (result instanceof type.errors) return null; - if (!CURSOR_CREATED_AT.test(result.createdAt)) return null; - if (Number.isNaN(new Date(result.createdAt).getTime())) return null; - return result; -} - -// The stored `references` blob is validated ON READ for the same reason `refs` -// is: nothing in Postgres constrains its shape, and a row written by an older -// version (or by the host directly) still reaches this projection. A blob that -// fails degrades to no ancestry — logged — rather than failing the read. -const MsgIdListSchema = type("string[]"); - -// One bad backfill would otherwise emit a warn line per bad row per page per -// request — the same steady-state log spam `read.ts`'s `DroppedRefs` exists to -// avoid. Collected per read and reported once, with a bounded sample of ids. -const DROPPED_REFERENCES_SAMPLE = 5; - -type DroppedReferences = { rowIds: string[]; summary: string | null }; - -function newDroppedReferences(): DroppedReferences { - return { rowIds: [], summary: null }; -} - -function reportDroppedReferences(dropped: DroppedReferences): void { - if (dropped.rowIds.length === 0) return; - logger.warn( - "mailbox references column failed schema; dropped for {rows} row(s)", - { - rows: dropped.rowIds.length, - sampleRowIds: dropped.rowIds.slice(0, DROPPED_REFERENCES_SAMPLE), - summary: dropped.summary, - }, - ); -} - -function readRowReferences( - stored: unknown, - rowId: string, - dropped: DroppedReferences, -): string[] { - if (stored === null || stored === undefined) return []; - const parsed = MsgIdListSchema(stored); - if (parsed instanceof type.errors) { - dropped.rowIds.push(rowId); - dropped.summary ??= parsed.summary; - return []; - } - return parsed; -} - -/** - * The ref predicate: jsonb containment, so a stored ref carrying an extra - * `label` still matches a `{ kind, id }` query. Served by the GIN index - * `principal_mail_refs_idx`. - */ -function refCondition(ref: MailboxRef) { - return sql`${principalMail.refs} @> ${JSON.stringify([{ kind: ref.kind, id: ref.id }])}::jsonb`; -} - -function assertThreadArgs(args: MailboxThreadArgs): number { - const ref = MailboxRefSchema(args.ref); - if (ref instanceof type.errors) { - throw new RangeError(`invalid mailbox thread ref: ${ref.summary}`); - } - const limit = args.limit ?? DEFAULT_MAILBOX_THREAD_LIMIT; - if ( - !Number.isSafeInteger(limit) || - limit < 1 || - limit > MAX_MAILBOX_THREAD_LIMIT - ) { - throw new RangeError( - `mailbox thread limit must be an integer in 1..${MAX_MAILBOX_THREAD_LIMIT}`, - ); - } - return limit; -} - -/** - * Resolve the cursor, refusing one minted for a different ref. - * - * A keyset cursor is only meaningful against the result set that produced it. - * Paging a cursor from one ref into another ref's thread would silently skip - * every message older than the cursor, so this is a `RangeError` — the same - * posture the list path takes when a cursor's view, sort or filter disagrees. - */ -function resolveThreadCursor( - args: MailboxThreadArgs, -): MailboxThreadCursor | undefined { - if (args.cursor === undefined) return undefined; - const cursor = decodeMailboxThreadCursor(args.cursor); - if (cursor === null) throw new RangeError("malformed mailbox thread cursor"); - if (cursor.ref !== canonicalMailboxThreadRef(args.ref)) { - throw new RangeError("mailbox thread cursor was minted for a different ref"); - } - return cursor; -} - -/** - * One node of the ref-scoped ancestry graph — a message and just enough of it - * to resolve (and, when necessary, cut) its candidate parent edge. `createdAt` - * is the same sortable microsecond text the cursor is minted from, so nodes - * from different queries (the page, and any ancestor batches fetched to walk - * a chain) compare with a plain string `<`. - */ -type ThreadNode = { - id: string; - messageId: string | null; - inReplyTo: string | null; - references: string[]; - createdAt: string; -}; - -// Defensive cap on how many nodes a single read will walk while resolving -// ancestry and breaking cycles. A real conversation's chain is nowhere near -// this deep; the cap exists so a pathological or adversarial reference graph -// degrades (bailing out of further expansion, which can only ever turn a -// resolved parent into `null`, never fabricate one) rather than reading an -// unbounded number of rows. -const MAX_THREAD_ANCESTRY_NODES = 2000; - -/** - * Read the conversation under one entity ref, oldest first, keyset-paged on - * `(created_at, id)` and scoped to `(tenantId, principalId)`. - * - * **Parents are resolved by RFC 5256 References linking, over the whole - * ref-scoped set — never by subject.** For each message the candidate ancestors - * are its `In-Reply-To` followed by its `References` chain walked - * newest-to-oldest, and the first candidate that is present in THIS mailbox - * under THIS ref wins. An ancestor that is not present yields `parentId: null`: - * a message whose parent lives in someone else's mailbox, or under a different - * ref, is a root of what this reader can see, and inventing a node for it would - * be a lie about the conversation. - * - * **`parentId` chains are acyclic.** Nothing stops a delivered frame's - * `In-Reply-To`/`References` from naming a msg-id that (directly, or through - * further ancestors) points back at the frame itself — RFC 5256 step 1.B calls - * this out explicitly. Left unresolved a cycle would either loop a client's - * ancestry walk forever or silently make a message a descendant of one of its - * own descendants, so before a page is projected, every resolved parent edge - * that would close a loop is cut: the LATER-created message in the cycle (ties - * broken by id) becomes a root (`parentId: null`) instead, and every other - * message in the cycle keeps its resolved parent. Which edge is cut is - * deterministic and depends only on the cycle's members, never on where the - * cursor happens to land, so a cycle's shape does not change from one page to - * the next. - * - * The ancestor lookup deliberately spans the whole ref-scoped set rather than - * the current page: a chain crossing a page boundary must not report a parent - * on one page and `null` on another, which is exactly what a page-local resolve - * would do. Ancestors are fetched breadth-first, one batch per hop, so a chain - * (or a cycle) reaching beyond the messages the page directly names is still - * resolved correctly; each batch is served by - * `principal_mail_tenant_id_principal_id_message_id_idx`. - * - * Throws `RangeError` on a malformed ref, an out-of-range limit, and a cursor - * that is malformed or was minted for a different ref. - */ -export async function readMailboxThread( - db: MailboxDb, - scope: MailboxThreadScope, - args: MailboxThreadArgs, -): Promise { - const limit = assertThreadArgs(args); - const cursor = resolveThreadCursor(args); - - const scopeConditions = [ - eq(principalMail.tenantId, scope.tenantId), - eq(principalMail.principalId, scope.principalId), - eq(principalMail.direction, "inbound"), - refCondition(args.ref), - ]; - const conditions = [...scopeConditions]; - if (cursor) { - // Row-value comparison matching the ORDER BY exactly, with the cast on the - // CURSOR and never on the column — `timestamp → timestamptz` is STABLE, so - // a cast on the column side cannot serve an index condition, and a - // `timestamptz` literal would resolve through the session's TimeZone and - // seek to a different row on a non-UTC host. Same rule as `listUserMailbox`. - conditions.push( - sql`(${principalMail.createdAt}, ${principalMail.id}) > (${cursor.createdAt}::timestamp, ${cursor.id})`, - ); - } - - const rows = await db - .select({ - id: principalMail.id, - messageId: principalMail.messageId, - inReplyTo: principalMail.inReplyTo, - references: principalMail.references, - fromAddress: principalMail.fromAddress, - subject: principalMail.subject, - // Postgres renders the timestamp; a JS Date would drop the microseconds - // the cursor is minted from. No `AT TIME ZONE`: the column is - // `timestamp without time zone` already holding UTC. - createdAtText: sql`to_char(${principalMail.createdAt}, 'YYYY-MM-DD"T"HH24:MI:SS.US"Z"')`, - readAt: mailbox.readAt, - archivedAt: mailbox.archivedAt, - // Selected in the same scan (never a per-row follow-up query) so `body` - // can be decoded without an N+1 — see `decodeMailFrame` below. - raw: principalMail.raw, - }) - .from(principalMail) - .leftJoin(mailbox, eq(mailbox.id, principalMail.id)) - .where(and(...conditions)) - .orderBy(asc(principalMail.createdAt), asc(principalMail.id)) - .limit(limit + 1); - - const hasMore = rows.length > limit; - const pageRows = hasMore ? rows.slice(0, limit) : rows; - - const dropped = newDroppedReferences(); - const projected = pageRows.map((row) => ({ - row, - references: readRowReferences(row.references, row.id, dropped), - body: decodeMailFrame(row.raw)?.body ?? "", - })); - reportDroppedReferences(dropped); - - // The ancestry graph: every node discovered so far, by id, plus the - // oldest-carrier msg-id -> id map candidates resolve through. Seeded with - // the page itself, then expanded breadth-first to whatever the page's rows - // (and, in turn, THEIR ancestors) name — the graph a cycle could hide in. - const nodes = new Map(); - const byMessageId = new Map(); - - function addNode(node: ThreadNode): void { - if (!nodes.has(node.id)) nodes.set(node.id, node); - if (node.messageId === null) return; - const existingId = byMessageId.get(node.messageId); - if (existingId === undefined) { - byMessageId.set(node.messageId, node.id); - return; - } - // Oldest carrier wins — nothing makes a msg-id unique (it is the sender's - // identifier), so ties resolve to whichever row sorts first, deterministic - // and stable regardless of fetch order. - const existing = nodes.get(existingId)!; - if ( - node.createdAt < existing.createdAt || - (node.createdAt === existing.createdAt && node.id < existingId) - ) { - byMessageId.set(node.messageId, node.id); - } - } - - for (const { row, references } of projected) { - addNode({ - id: row.id, - messageId: row.messageId, - inReplyTo: row.inReplyTo, - references, - createdAt: row.createdAtText, - }); - } - - // Breadth-first expansion: each hop resolves one more round of msg-ids that - // the nodes discovered so far point at, until nothing new turns up or the - // safety cap is hit. Bounded and cheap in the overwhelmingly common case - // (no cycle, a chain a few hops deep) and the only way to prove a cycle - // absent rather than merely absent from the current page. - let frontier = new Set(); - for (const node of nodes.values()) { - if (node.inReplyTo !== null) frontier.add(node.inReplyTo); - for (const reference of node.references) frontier.add(reference); - } - const queried = new Set(); - while (frontier.size > 0 && nodes.size < MAX_THREAD_ANCESTRY_NODES) { - const toFetch = [...frontier].filter((messageId) => !queried.has(messageId)); - for (const messageId of toFetch) queried.add(messageId); - frontier = new Set(); - if (toFetch.length === 0) break; - const fetched = await fetchThreadNodesByMessageId(db, scopeConditions, toFetch); - for (const node of fetched) { - addNode(node); - if (node.inReplyTo !== null && !queried.has(node.inReplyTo)) { - frontier.add(node.inReplyTo); - } - for (const reference of node.references) { - if (!queried.has(reference)) frontier.add(reference); - } - } - } - - // Candidate parent, per node, before cycle-breaking: RFC 5256's - // In-Reply-To-first, then References newest-to-oldest, first candidate - // present under this scope and ref — excluding the node itself, since a - // frame naming its own msg-id is not its own parent. - const rawParent = new Map(); - for (const node of nodes.values()) { - const candidates = [ - ...(node.inReplyTo !== null ? [node.inReplyTo] : []), - ...[...node.references].reverse(), - ]; - let parent: string | null = null; - for (const candidate of candidates) { - const found = byMessageId.get(candidate); - if (found !== undefined && found !== node.id) { - parent = found; - break; - } - } - rawParent.set(node.id, parent); - } - - const finalParent = resolveAcyclicParents(nodes, rawParent); - - const items = projected.map(({ row, references, body }) => { - const item: MailboxThreadMessage = { - id: row.id, - // The row id is the last resort, not the cache: a frame with no - // Message-ID still needs a stable handle. Same rule as the list path. - messageId: row.messageId ?? row.id, - references, - fromAddress: row.fromAddress ?? "", - createdAt: row.createdAtText, - read: row.readAt !== null, - archived: row.archivedAt !== null, - parentId: finalParent.get(row.id) ?? null, - body, - }; - if (row.inReplyTo !== null) item.inReplyTo = row.inReplyTo; - if (row.subject !== null) item.subject = row.subject; - return item; - }); - - const page: MailboxThreadPage = { items }; - if (hasMore) { - // `hasMore` means rows.length > limit >= 1, so the page is non-empty. - const last = pageRows[pageRows.length - 1]!; - page.nextCursor = encodeMailboxThreadCursor( - { createdAt: last.createdAtText, id: last.id }, - args.ref, - ); - } - return page; -} - -/** - * Break every reference cycle in the candidate-parent graph, per RFC 5256 - * step 1.B: walk each node's raw-parent chain with a per-walk visited set, and - * when a walk revisits a node already on its own path, the path from that node - * to the end IS the cycle. Cut it by nulling out the parent of the - * LATER-created member (ties broken by the larger id) — that member becomes a - * root; every other member of the cycle keeps its raw parent. A node's outcome - * never depends on which node the walk started from, only on the cycle's own - * membership, so the result is the same regardless of `nodes` iteration order. - */ -function resolveAcyclicParents( - nodes: Map, - rawParent: Map, -): Map { - const finalParent = new Map(); - const done = new Set(); - - function isLater(a: string, b: string): boolean { - const nodeA = nodes.get(a)!; - const nodeB = nodes.get(b)!; - if (nodeA.createdAt !== nodeB.createdAt) { - return nodeA.createdAt > nodeB.createdAt; - } - return a > b; - } - - for (const start of nodes.keys()) { - if (done.has(start)) continue; - const path: string[] = []; - let current: string | null = start; - while (current !== null && !done.has(current)) { - const cycleStart = path.indexOf(current); - if (cycleStart !== -1) { - const cycle = path.slice(cycleStart); - const cut = cycle.reduce((worst, candidate) => - isLater(candidate, worst) ? candidate : worst, - ); - finalParent.set(cut, null); - break; - } - path.push(current); - current = rawParent.get(current) ?? null; - } - for (const id of path) { - if (!finalParent.has(id)) finalParent.set(id, rawParent.get(id) ?? null); - done.add(id); - } - } - return finalParent; -} - -/** - * Fetch full ancestry nodes (not just ids) for a batch of msg-ids, within the - * same scope and ref the thread page was read under — the per-hop query the - * breadth-first ancestor walk issues, served by - * `principal_mail_tenant_id_principal_id_message_id_idx`. - */ -async function fetchThreadNodesByMessageId( - db: MailboxDb, - scopeConditions: SQL[], - messageIds: string[], -): Promise { - if (messageIds.length === 0) return []; - const rows = await db - .select({ - id: principalMail.id, - messageId: principalMail.messageId, - inReplyTo: principalMail.inReplyTo, - references: principalMail.references, - createdAtText: sql`to_char(${principalMail.createdAt}, 'YYYY-MM-DD"T"HH24:MI:SS.US"Z"')`, - }) - .from(principalMail) - .where( - and(...scopeConditions, inArray(principalMail.messageId, messageIds)), - ) - .orderBy(asc(principalMail.createdAt), asc(principalMail.id)); - const dropped = newDroppedReferences(); - const nodes = rows.map((row) => ({ - id: row.id, - messageId: row.messageId, - inReplyTo: row.inReplyTo, - references: readRowReferences(row.references, row.id, dropped), - createdAt: row.createdAtText, - })); - reportDroppedReferences(dropped); - return nodes; -} - -// --------------------------------------------------------------------------- -// Thread listing: every conversation in (tenantId, principalId), grouped by -// the same RFC 5256 References linking `readMailboxThread` resolves within -// one ref, but applied across the WHOLE scope rather than one ref — a -// workbench's chat timeline wants "what are my conversations", not "what is -// under this one entity". -// --------------------------------------------------------------------------- - -export const DEFAULT_MAILBOX_THREAD_LIST_LIMIT = 50; -/** Same ceiling every other paged mailbox surface enforces. */ -export const MAX_MAILBOX_THREAD_LIST_LIMIT = 200; - -/** - * This reader loads every row in scope to compute ancestry — there is no - * table that already stores "which thread is this row in". A tenant/principal - * past this many rows degrades: only the newest - * `MAX_MAILBOX_THREAD_LIST_SCAN_ROWS` are considered when grouping into - * threads, so the list stays correct for recent activity but a thread whose - * own messages straddle the cutoff can appear split into two rather than - * merged into one. Logged once per read when the cap is hit. - */ -export const MAX_MAILBOX_THREAD_LIST_SCAN_ROWS = 20_000; - -export const MailboxThreadSummarySchema = type({ - /** The root message's row id — stable even when it has no Message-ID. */ - rootId: "string", - rootMessageId: "string", - "subject?": "string", - messageCount: "number", - unreadCount: "number", - lastMessageId: "string", - lastFromAddress: "string", - lastCreatedAt: "string", -}); -export type MailboxThreadSummary = typeof MailboxThreadSummarySchema.infer; - -export const MailboxThreadListResponseSchema = type({ - threads: MailboxThreadSummarySchema.array(), - "nextCursor?": "string", -}); - -export type MailboxThreadListPage = { - items: MailboxThreadSummary[]; - nextCursor?: string; -}; - -export type MailboxThreadListArgs = { - cursor?: string; - /** 1..`MAX_MAILBOX_THREAD_LIST_LIMIT`; defaults to `DEFAULT_MAILBOX_THREAD_LIST_LIMIT`. */ - limit?: number; - /** - * Scope the listing to threads with at least one message carrying one of - * these refs (e.g. the caller's tenant/workbench). OR'd together — a - * thread matches if any of its messages carries any of the given refs. - * Omitted or empty means no ref filter: every conversation in scope. - */ - refs?: MailboxRef[]; -}; - -const MailboxThreadListCursorSchema = type({ - createdAt: "string", - id: "string", -}); -type MailboxThreadListCursor = typeof MailboxThreadListCursorSchema.infer; - -export function encodeMailboxThreadListCursor(row: { - createdAt: string; - id: string; -}): string { - const payload: MailboxThreadListCursor = { - createdAt: row.createdAt, - id: row.id, - }; - return base64urlEncode(new TextEncoder().encode(JSON.stringify(payload))); -} - -export function decodeMailboxThreadListCursor( - raw: string, -): MailboxThreadListCursor | null { - let json: string; - try { - json = new TextDecoder().decode(base64urlDecode(raw)); - } catch { - return null; - } - let parsed: unknown; - try { - parsed = JSON.parse(json); - } catch { - return null; - } - const result = MailboxThreadListCursorSchema(parsed); - if (result instanceof type.errors) return null; - if (!CURSOR_CREATED_AT.test(result.createdAt)) return null; - if (Number.isNaN(new Date(result.createdAt).getTime())) return null; - return result; -} - -/** - * One row as scanned for tenant-wide thread grouping. - * - * `body` is decoded from `raw` in the same scan (never a per-row follow-up - * query) so `readMailboxThreadByMessageId` can serve message text without an - * N+1 — see `decodeMailFrame`. - */ -type TenantScanRow = { - id: string; - messageId: string | null; - inReplyTo: string | null; - references: string[]; - fromAddress: string | null; - subject: string | null; - createdAtText: string; - read: boolean; - body: string; -}; - -/** - * @param includeBody Also select `raw` and decode `body` in this same scan - * (never a per-row follow-up query). Only `readMailboxThreadByMessageId` - * needs message text; `listMailboxThreads` never surfaces body and does - * not pay for decoding or transferring `raw` across up to - * `MAX_MAILBOX_THREAD_LIST_SCAN_ROWS` rows. - */ -async function scanTenantThreadRows( - db: MailboxDb, - scope: MailboxThreadScope, - refs?: MailboxRef[], - includeBody = false, -): Promise { - const conditions = [ - eq(principalMail.tenantId, scope.tenantId), - eq(principalMail.principalId, scope.principalId), - eq(principalMail.direction, "inbound"), - ]; - if (refs !== undefined && refs.length > 0) { - const refConditions = refs.map((ref) => refCondition(ref)); - conditions.push( - sql`(${sql.join(refConditions, sql` OR `)})`, - ); - } - const rows = await db - .select({ - id: principalMail.id, - messageId: principalMail.messageId, - inReplyTo: principalMail.inReplyTo, - references: principalMail.references, - fromAddress: principalMail.fromAddress, - subject: principalMail.subject, - createdAtText: sql`to_char(${principalMail.createdAt}, 'YYYY-MM-DD"T"HH24:MI:SS.US"Z"')`, - readAt: mailbox.readAt, - ...(includeBody ? { raw: principalMail.raw } : {}), - }) - .from(principalMail) - .leftJoin(mailbox, eq(mailbox.id, principalMail.id)) - .where(and(...conditions)) - .orderBy(sql`${principalMail.createdAt} DESC`, sql`${principalMail.id} DESC`) - .limit(MAX_MAILBOX_THREAD_LIST_SCAN_ROWS + 1); - - if (rows.length > MAX_MAILBOX_THREAD_LIST_SCAN_ROWS) { - logger.warn( - "mailbox thread scan hit its row cap; oldest activity in this mailbox is excluded from grouping", - { tenantId: scope.tenantId, principalId: scope.principalId }, - ); - } - const capped = rows.slice(0, MAX_MAILBOX_THREAD_LIST_SCAN_ROWS); - - const dropped = newDroppedReferences(); - const projected = capped.map((row) => ({ - id: row.id, - messageId: row.messageId, - inReplyTo: row.inReplyTo, - references: readRowReferences(row.references, row.id, dropped), - fromAddress: row.fromAddress, - subject: row.subject, - createdAtText: row.createdAtText, - read: row.readAt !== null, - body: "raw" in row && row.raw ? (decodeMailFrame(row.raw)?.body ?? "") : "", - })); - reportDroppedReferences(dropped); - return projected; -} - -/** - * Build the same ancestry graph `readMailboxThread` builds — nodes keyed by - * row id, oldest-carrier-wins msg-id lookup, RFC 5256 candidate parents, and - * cycle-broken final parents — over an already-fetched row set rather than a - * single ref-scoped page. No breadth-first ancestor expansion here: the whole - * scanned set is already in hand, so a parent outside it (because it fell - * past `MAX_MAILBOX_THREAD_LIST_SCAN_ROWS`, exactly like a parent outside the - * ref elsewhere in this file) resolves to `null` rather than being fetched. - */ -function buildTenantThreadGraph(rows: TenantScanRow[]): { - nodes: Map; - byMessageId: Map; - finalParent: Map; -} { - const nodes = new Map(); - const byMessageId = new Map(); - - function addNode(node: ThreadNode): void { - if (!nodes.has(node.id)) nodes.set(node.id, node); - if (node.messageId === null) return; - const existingId = byMessageId.get(node.messageId); - if (existingId === undefined) { - byMessageId.set(node.messageId, node.id); - return; - } - const existing = nodes.get(existingId)!; - if ( - node.createdAt < existing.createdAt || - (node.createdAt === existing.createdAt && node.id < existingId) - ) { - byMessageId.set(node.messageId, node.id); - } - } - - for (const row of rows) { - addNode({ - id: row.id, - messageId: row.messageId, - inReplyTo: row.inReplyTo, - references: row.references, - createdAt: row.createdAtText, - }); - } - - const rawParent = new Map(); - for (const node of nodes.values()) { - const candidates = [ - ...(node.inReplyTo !== null ? [node.inReplyTo] : []), - ...[...node.references].reverse(), - ]; - let parent: string | null = null; - for (const candidate of candidates) { - const found = byMessageId.get(candidate); - if (found !== undefined && found !== node.id) { - parent = found; - break; - } - } - rawParent.set(node.id, parent); - } - - const finalParent = resolveAcyclicParents(nodes, rawParent); - return { nodes, byMessageId, finalParent }; -} - -/** Walk each node's final-parent chain to its root, memoized. Acyclic by - * construction — `finalParent` already had every cycle cut. */ -function computeThreadRoots( - nodes: Map, - finalParent: Map, -): Map { - const roots = new Map(); - function rootOf(id: string): string { - const cached = roots.get(id); - if (cached !== undefined) return cached; - const parent = finalParent.get(id) ?? null; - const root = parent === null || !nodes.has(parent) ? id : rootOf(parent); - roots.set(id, root); - return root; - } - for (const id of nodes.keys()) rootOf(id); - return roots; -} - -/** - * List every conversation in (tenantId, principalId), newest activity first, - * keyset-paged on (lastCreatedAt, rootId). - * - * A "thread" is one root of the RFC 5256 References graph built across the - * whole scope — the same linking `readMailboxThread` resolves within a single - * ref, generalized to every message this principal can see in this tenant. - * `lastCreatedAt`/`lastMessageId` name the newest message in the group; a - * thread with only its root message reports that root as its own "last". - * - * Throws `RangeError` on a malformed cursor or an out-of-range limit. - */ -export async function listMailboxThreads( - db: MailboxDb, - scope: MailboxThreadScope, - args: MailboxThreadListArgs = {}, -): Promise { - const limit = args.limit ?? DEFAULT_MAILBOX_THREAD_LIST_LIMIT; - if ( - !Number.isSafeInteger(limit) || - limit < 1 || - limit > MAX_MAILBOX_THREAD_LIST_LIMIT - ) { - throw new RangeError( - `mailbox thread list limit must be an integer in 1..${MAX_MAILBOX_THREAD_LIST_LIMIT}`, - ); - } - let cursor: MailboxThreadListCursor | undefined; - if (args.cursor !== undefined) { - const decoded = decodeMailboxThreadListCursor(args.cursor); - if (decoded === null) { - throw new RangeError("malformed mailbox thread list cursor"); - } - cursor = decoded; - } - if (args.refs !== undefined) { - for (const ref of args.refs) { - const checked = MailboxRefSchema(ref); - if (checked instanceof type.errors) { - throw new RangeError(`invalid mailbox thread list ref: ${checked.summary}`); - } - } - } - - const rows = await scanTenantThreadRows(db, scope, args.refs); - const { nodes, finalParent } = buildTenantThreadGraph(rows); - const roots = computeThreadRoots(nodes, finalParent); - const rowById = new Map(rows.map((row) => [row.id, row])); - - type Group = { - rootId: string; - messageCount: number; - unreadCount: number; - lastId: string; - lastCreatedAt: string; - }; - const groups = new Map(); - for (const row of rows) { - const rootId = roots.get(row.id)!; - let group = groups.get(rootId); - if (group === undefined) { - group = { - rootId, - messageCount: 0, - unreadCount: 0, - lastId: row.id, - lastCreatedAt: row.createdAtText, - }; - groups.set(rootId, group); - } - group.messageCount += 1; - if (!row.read) group.unreadCount += 1; - if ( - row.createdAtText > group.lastCreatedAt || - (row.createdAtText === group.lastCreatedAt && row.id > group.lastId) - ) { - group.lastId = row.id; - group.lastCreatedAt = row.createdAtText; - } - } - - const sorted = [...groups.values()].sort((a, b) => { - if (a.lastCreatedAt !== b.lastCreatedAt) { - return a.lastCreatedAt < b.lastCreatedAt ? 1 : -1; - } - return a.rootId < b.rootId ? 1 : -1; - }); - - const afterCursor = cursor - ? sorted.filter( - (group) => - group.lastCreatedAt < cursor.createdAt || - (group.lastCreatedAt === cursor.createdAt && group.rootId < cursor.id), - ) - : sorted; - - const hasMore = afterCursor.length > limit; - const page = hasMore ? afterCursor.slice(0, limit) : afterCursor; - - const items: MailboxThreadSummary[] = page.map((group) => { - const root = rowById.get(group.rootId)!; - const last = rowById.get(group.lastId)!; - const summary: MailboxThreadSummary = { - rootId: group.rootId, - rootMessageId: root.messageId ?? root.id, - messageCount: group.messageCount, - unreadCount: group.unreadCount, - lastMessageId: last.messageId ?? last.id, - lastFromAddress: last.fromAddress ?? "", - lastCreatedAt: group.lastCreatedAt, - }; - if (root.subject !== null) summary.subject = root.subject; - return summary; - }); - - const result: MailboxThreadListPage = { items }; - if (hasMore) { - const lastGroup = page[page.length - 1]!; - result.nextCursor = encodeMailboxThreadListCursor({ - createdAt: lastGroup.lastCreatedAt, - id: lastGroup.rootId, - }); - } - return result; -} - -export type MailboxThreadByMessageIdArgs = { - rootMessageId: string; - cursor?: string; - /** 1..`MAX_MAILBOX_THREAD_LIMIT`; defaults to `DEFAULT_MAILBOX_THREAD_LIMIT`. */ - limit?: number; -}; - -/** Synthetic ref this function's cursor is minted under — reusing the - * ref-scoped cursor codec `readMailboxThread` already has, rather than a - * third cursor shape, since the two are otherwise identical. */ -function rootMessageIdCursorRef(rootMessageId: string): MailboxRef { - return { kind: "__mailbox_thread_root__", id: rootMessageId }; -} - -/** - * Read one conversation by its root `Message-ID`, scoped to - * (tenantId, principalId) — the same shape `readMailboxThread` returns - * (oldest first, keyset-paged), but the thread is found by walking the whole - * scope's References graph to the given root rather than by an entity ref. - * - * Returns `null` when no message in scope carries `rootMessageId`, or when - * that message is not itself a root (a caller has the wrong Message-ID — the - * true root is a message it points at that this reader can see). - * - * Throws `RangeError` on a malformed cursor or an out-of-range limit. - */ -export async function readMailboxThreadByMessageId( - db: MailboxDb, - scope: MailboxThreadScope, - args: MailboxThreadByMessageIdArgs, -): Promise { - const limit = args.limit ?? DEFAULT_MAILBOX_THREAD_LIMIT; - if ( - !Number.isSafeInteger(limit) || - limit < 1 || - limit > MAX_MAILBOX_THREAD_LIMIT - ) { - throw new RangeError( - `mailbox thread limit must be an integer in 1..${MAX_MAILBOX_THREAD_LIMIT}`, - ); - } - let cursor: MailboxThreadCursor | undefined; - if (args.cursor !== undefined) { - const decoded = decodeMailboxThreadCursor(args.cursor); - if (decoded === null) throw new RangeError("malformed mailbox thread cursor"); - if (decoded.ref !== canonicalMailboxThreadRef(rootMessageIdCursorRef(args.rootMessageId))) { - throw new RangeError( - "mailbox thread cursor was minted for a different root Message-ID", - ); - } - cursor = decoded; - } - - const rows = await scanTenantThreadRows(db, scope, undefined, true); - const { nodes, byMessageId, finalParent } = buildTenantThreadGraph(rows); - const startId = byMessageId.get(args.rootMessageId); - if (startId === undefined) return null; - const roots = computeThreadRoots(nodes, finalParent); - const rootId = roots.get(startId)!; - if (rootId !== startId) return null; - - const rowById = new Map(rows.map((row) => [row.id, row])); - const memberIds = [...nodes.keys()].filter((id) => roots.get(id) === rootId); - const members = memberIds - .map((id) => rowById.get(id)!) - .sort((a, b) => { - if (a.createdAtText !== b.createdAtText) { - return a.createdAtText < b.createdAtText ? -1 : 1; - } - return a.id < b.id ? -1 : 1; - }); - - const afterCursor = cursor - ? members.filter( - (row) => - row.createdAtText > cursor.createdAt || - (row.createdAtText === cursor.createdAt && row.id > cursor.id), - ) - : members; - - const hasMore = afterCursor.length > limit; - const page = hasMore ? afterCursor.slice(0, limit) : afterCursor; - - const items: MailboxThreadMessage[] = page.map((row) => { - const item: MailboxThreadMessage = { - id: row.id, - messageId: row.messageId ?? row.id, - references: row.references, - fromAddress: row.fromAddress ?? "", - createdAt: row.createdAtText, - read: row.read, - archived: false, - parentId: finalParent.get(row.id) ?? null, - body: row.body, - }; - if (row.inReplyTo !== null) item.inReplyTo = row.inReplyTo; - if (row.subject !== null) item.subject = row.subject; - return item; - }); - - const result: MailboxThreadPage = { items }; - if (hasMore) { - const last = page[page.length - 1]!; - result.nextCursor = encodeMailboxThreadCursor( - { createdAt: last.createdAtText, id: last.id }, - rootMessageIdCursorRef(args.rootMessageId), - ); - } - return result; -} - -/** - * Look one message up by its `Message-ID`, scoped to (tenantId, principalId). - * Returns null when this mailbox holds no such message — including when - * another principal's does, which is the whole point of the scope. - * - * Nothing makes a msg-id unique (it is the sender's identifier, and two - * externally-delivered frames may carry the same one), so the OLDEST match - * wins — a stable answer rather than whichever row the planner reached first. - * - * Served from the cached `message_id` column, on the list projection: this is - * a lookup, not a detail read, and it never loads `raw`. - */ -export async function readMailboxMessageByMessageId( - db: MailboxDb, - scope: MailboxThreadScope, - messageId: string, -): Promise { - const [row] = await db - .select({ ...PRINCIPAL_MAIL_LIST_COLUMNS, ...STATE_COLUMNS }) - .from(principalMail) - .leftJoin(mailbox, eq(mailbox.id, principalMail.id)) - .where( - and( - eq(principalMail.tenantId, scope.tenantId), - eq(principalMail.principalId, scope.principalId), - eq(principalMail.direction, "inbound"), - eq(principalMail.messageId, messageId), - ), - ) - .orderBy(asc(principalMail.createdAt), asc(principalMail.id)) - .limit(1); - if (!row) return null; - - const dropped = newDroppedRefs(); - const message = toMailboxMessage(row, null, dropped); - reportDroppedRefs(dropped); - return message; -} diff --git a/src/vocabulary.ts b/src/vocabulary.ts deleted file mode 100644 index 8275ee7..0000000 --- a/src/vocabulary.ts +++ /dev/null @@ -1,86 +0,0 @@ -// The package ships mechanism; the host ships opinion. The `priority` and -// `status` vocabularies are supplied by the host through `MountMailboxOpts`, -// and the schemas, OpenAPI enums and ranking are generated from them — there -// is no closed vocabulary anywhere in this package. `classification` and -// `assignee` are open host-defined strings with no ordering and nothing to -// generate. - -import { sql, type SQL } from "drizzle-orm"; -import { mailbox } from "./schema.js"; - -/** - * The host's triage vocabulary. - * - * `priorities` is ORDERED, most urgent first — the order *is* the ranking, and - * `sort=priority` reads it straight out of this array. `statuses` is an - * unordered set; nothing sorts by it. - */ -export type MailboxVocabulary = { - priorities: readonly string[]; - statuses: readonly string[]; -}; - -/** - * Reject a vocabulary this package cannot generate a total ordering or a - * meaningful OpenAPI enum from, at mount time rather than on the first request. - * - * Duplicates are refused rather than de-duplicated: a host that lists `high` - * twice has two different ranks in mind for it, and silently keeping the first - * picks one of them without saying so. - */ -export function assertMailboxVocabulary(vocab: MailboxVocabulary): void { - for (const [label, values] of [ - ["priorities", vocab.priorities], - ["statuses", vocab.statuses], - ] as const) { - if (values.length === 0) { - throw new RangeError(`mailbox ${label} must not be empty`); - } - for (const value of values) { - if (value.length === 0) { - throw new RangeError(`mailbox ${label} must not contain a blank value`); - } - } - if (new Set(values).size !== values.length) { - throw new RangeError(`mailbox ${label} must not contain duplicates`); - } - } -} - -/** - * Most-urgent-first rank for `sort=priority`, generated from the host's ordered - * list. Anything not in the list — including the NULL of an un-triaged message - * and a value left behind by a vocabulary the host has since changed — ranks - * LAST rather than sorting as an empty string, so un-triaged mail falls to the - * bottom of a priority-sorted list instead of leading it. - * - * The literals are bound as parameters, never interpolated: the vocabulary is - * host input and this expression is built per request. - */ -export function priorityRank(priorities: readonly string[]): SQL { - const whens = priorities.map( - (value, rank) => sql`WHEN ${value} THEN ${sql.raw(String(rank))}`, - ); - return sql`CASE ${mailbox.priority} ${sql.join(whens, sql` `)} ELSE ${sql.raw(String(priorities.length))} END`; -} - -/** - * A stable string identifying which priority ordering a page was produced - * under, embedded in every `sort=priority` cursor. - * - * Precedent and reasoning are `canonicalMailboxFilter`'s: a keyset cursor is - * only meaningful against the exact result set it was minted from. The leading - * component of a priority-sorted keyset is an INTEGER RANK, so a host that - * reorders its vocabulary between two requests leaves in-flight cursors seeking - * on a rank that now means a different band — silently skipping or repeating - * every message in between. Embedding this lets the route refuse instead. - * - * Only the ORDER matters, so this is the list itself: reordering changes it, - * and appending a new lowest-priority band does too (it shifts nothing, but it - * does change what rank `n` means for the ELSE arm). - */ -export function canonicalMailboxPriorities( - priorities: readonly string[], -): string { - return priorities.map((value) => encodeURIComponent(value)).join(","); -} diff --git a/src/write.test.ts b/src/write.test.ts index 565d1f4..332e001 100644 --- a/src/write.test.ts +++ b/src/write.test.ts @@ -1,1361 +1,225 @@ import { beforeEach, describe, expect, test } from "bun:test"; import { writeMailboxMessage, - writeMailboxMessages, deliverInboxItems, - mailboxKey, - MAX_MAILBOX_REFS, + assertMailboxScope, + assertMailboxTenantId, + assertMailboxFrameBytes, MAX_MAILBOX_FRAME_BYTES, } from "./write.js"; -import { decodeMailFrame } from "./frame.js"; -import { getMailboxMessage, listUserMailbox } from "./read.js"; -import { countUnreadActiveMailbox } from "./mutations.js"; -import { createInMemoryMailboxEventBus } from "./bus.js"; -import { withTestDb, seedScope, TEST_VOCABULARY } from "./test-helpers.js"; +import { createInMemoryMailboxEventBus, type MailboxEvent } from "./bus.js"; +import { openNativeMailboxStore } from "./native-store.js"; +import { withTestDb, seedScope } from "./test-helpers.js"; import type { MailboxDb } from "./db.js"; -import { sql } from "drizzle-orm"; - let db: MailboxDb; beforeEach(async () => { db = await withTestDb(); - await seedScope(db, "t1", "p1", "p2"); + await seedScope(db, "t1", "p1"); }); -describe("writeMailboxMessage", () => { - test("inserts a row and returns its id", async () => { - const result = await writeMailboxMessage(db, { - tenantId: "t1", - principalId: "p1", - address: "p1@t1.example", - fromAddress: "agent@t1.example", - subject: "Hello", - body: "World", - }); - expect(result).not.toBeNull(); - expect(typeof result?.id).toBe("string"); - }); - - test("dedupes on (tenantId, principalId, messageKey): second write returns null", async () => { - const args = { - tenantId: "t1", - principalId: "p1", - address: "p1@t1.example", - fromAddress: "agent@t1.example", - subject: "Hello", - body: "World", - messageKey: "gate:run1:signal", - }; - const first = await writeMailboxMessage(db, args); - const second = await writeMailboxMessage(db, args); - expect(first).not.toBeNull(); - expect(second).toBeNull(); +describe("assertMailboxScope / assertMailboxTenantId", () => { + test("accepts a non-blank scope", () => { + expect(() => assertMailboxScope({ tenantId: "t1", principalId: "p1" })).not.toThrow(); }); - test("cached inReplyTo agrees between the list and detail projections", async () => { - // The list projection serves `principal_mail.in_reply_to` (the cached - // column); detail serves the header out of `raw`. Before normalizing - // `inReplyTo` once on the way in, an untrimmed caller value was cached - // verbatim while `buildMailFrame` trimmed the same value into the header - // — so the same message projected two different inReplyTo strings - // depending only on which route read it. - const written = await writeMailboxMessage(db, { - tenantId: "t1", - principalId: "p1", - address: "p1@t1.example", - fromAddress: "agent@t1.example", - subject: "ws", - body: "b", - inReplyTo: " ", - }); - expect(written).not.toBeNull(); - - const page = await listUserMailbox(db, { - tenantId: "t1", - principalId: "p1", - limit: 50, - view: "all", - priorities: TEST_VOCABULARY.priorities, - }); - const listed = page.items.find((m) => m.id === written!.id); - const detail = await getMailboxMessage(db, { - tenantId: "t1", - principalId: "p1", - id: written!.id, - }); - - expect(detail?.inReplyTo).toBe(""); - expect(listed?.inReplyTo).toBe(""); + test("rejects a blank tenantId or principalId", () => { + for (const scope of [ + { tenantId: "", principalId: "p1" }, + { tenantId: " ", principalId: "p1" }, + { tenantId: "t1", principalId: "" }, + ]) { + expect(() => assertMailboxScope(scope)).toThrow(RangeError); + } }); - test("a write to a scope the control plane does not know is an FK rejection", async () => { - // The FKs are the enforcement: a mailbox that cannot exist is a caller - // bug, not a deliverable outcome. - await expect( - writeMailboxMessage(db, { - tenantId: "t1", - principalId: "nobody-seeded-this", - address: "ghost@t1.example", - fromAddress: "agent@t1.example", - subject: "Hello", - body: "World", - }), - ).rejects.toThrow(); - await expect( - writeMailboxMessage(db, { - tenantId: "no-such-tenant", - principalId: "p1", - address: "p1@t1.example", - fromAddress: "agent@t1.example", - subject: "Hello", - body: "World", - }), - ).rejects.toThrow(); + test("assertMailboxTenantId rejects a blank tenantId alone", () => { + expect(() => assertMailboxTenantId("")).toThrow(RangeError); + expect(() => assertMailboxTenantId("t1")).not.toThrow(); }); +}); - test("a triaged write lands both rows, and the deduped retry clobbers neither", async () => { - // The mail row and its triage row commit in ONE transaction: the retry - // must dedupe to null while the first write's triage stamp stands. - const args = { - tenantId: "t1", - principalId: "p1", - address: "p1@t1.example", - fromAddress: "agent@t1.example", - subject: "Hello", - body: "World", - messageKey: "triaged-once", - priority: "urgent", - status: "needs-action", - }; - const first = await writeMailboxMessage(db, args); - expect(first).not.toBeNull(); - - const retry = await writeMailboxMessage(db, { ...args, priority: "low" }); - expect(retry).toBeNull(); - - const detail = await getMailboxMessage(db, { - tenantId: "t1", - principalId: "p1", - id: first!.id, - }); - expect(detail?.priority).toBe("urgent"); - expect(detail?.status).toBe("needs-action"); +describe("assertMailboxFrameBytes", () => { + test("accepts at-cap, refuses one byte over", () => { + expect(() => + assertMailboxFrameBytes(new Uint8Array(MAX_MAILBOX_FRAME_BYTES)), + ).not.toThrow(); + expect(() => + assertMailboxFrameBytes(new Uint8Array(MAX_MAILBOX_FRAME_BYTES + 1)), + ).toThrow(RangeError); }); +}); - test("keyless (messageKey undefined) writes are never deduped against each other", async () => { - const args = { - tenantId: "t1", - principalId: "p1", - address: "p1@t1.example", - fromAddress: "agent@t1.example", - subject: "Hello", - body: "World", - }; - const first = await writeMailboxMessage(db, args); - const second = await writeMailboxMessage(db, args); - expect(first).not.toBeNull(); - expect(second).not.toBeNull(); - expect(first?.id).not.toBe(second?.id); - }); +function args(over: Partial[1]> = {}) { + return { + tenantId: "t1", + principalId: "p1", + address: "p1@t1.example", + fromAddress: "sender@t1.example", + subject: "Hi", + body: "Body", + ...over, + }; +} - test("caps refs at 20 entries with truncation (no throw)", async () => { - const refs = Array.from({ length: 25 }, (_, i) => ({ - kind: "task", - id: `task-${i}`, - })); - const result = await writeMailboxMessage(db, { - tenantId: "t1", - principalId: "p1", - address: "p1@t1.example", - fromAddress: "agent@t1.example", - subject: "Hello", - body: "World", - messageKey: "capped", - refs, - }); - expect(result).not.toBeNull(); - const message = await getMailboxMessage(db, { +describe("writeMailboxMessage", () => { + test("appends into the principal's INBOX with a fresh uid", async () => { + const written = await writeMailboxMessage(db, args()); + expect(written).not.toBeNull(); + expect(written!.uid).toBe(1); + const store = await openNativeMailboxStore(db, { tenantId: "t1", principalId: "p1", - id: result!.id, + folder: "INBOX", }); - expect(message?.refs?.length).toBe(MAX_MAILBOX_REFS); + expect(store.messages).toHaveLength(1); + expect(store.messages[0]!.envelope.subject).toBe("Hi"); }); - test("flattens embedded newlines in subject/from to prevent header injection", async () => { - const result = await writeMailboxMessage(db, { - tenantId: "t1", - principalId: "p1", - address: "p1@t1.example", - fromAddress: "agent@t1.example", - subject: "Hello\r\nBcc: attacker@evil.example", - body: "World", - messageKey: "injected", - }); - const message = await getMailboxMessage(db, { - tenantId: "t1", - principalId: "p1", - id: result!.id, - }); - expect(message?.subject).not.toContain("\n"); - expect(message?.subject).not.toContain("\r"); + test("rejects a blank scope before touching the store", async () => { + await expect( + writeMailboxMessage(db, args({ tenantId: "" })), + ).rejects.toThrow(RangeError); }); - test("notify (bus.publish) failure never fails the write", async () => { - const failingBus = { - publish() { - throw new Error("boom"); - }, - subscribe() { - return () => {}; - }, - }; - const result = await writeMailboxMessage( + test("a second write with the same messageId is a no-op", async () => { + const first = await writeMailboxMessage( db, - { - tenantId: "t1", - principalId: "p1", - address: "p1@t1.example", - fromAddress: "agent@t1.example", - subject: "Hello", - body: "World", - messageKey: "notify-fail", - }, - failingBus, + args({ messageId: "" }), ); - expect(result).not.toBeNull(); - }); - - test("successful write publishes to the bus", async () => { - const bus = createInMemoryMailboxEventBus(); - const received: Array<{ id: string; op?: string }> = []; - bus.subscribe({ tenantId: "t1", principalId: "p1" }, (event) => - received.push(event), - ); - await writeMailboxMessage( + const second = await writeMailboxMessage( db, - { - tenantId: "t1", - principalId: "p1", - address: "p1@t1.example", - fromAddress: "agent@t1.example", - subject: "Hello", - body: "World", - messageKey: "notify-ok", - }, - bus, + args({ messageId: "", subject: "Different" }), ); - expect(received.length).toBe(1); - // A new message is a `create` — a listener can tell delivery apart from - // a mutation without re-fetching and diffing. - expect(received[0]?.op).toBe("create"); - }); -}); - -describe("deliverInboxItems", () => { - test("dedupes on mailboxKey.inbox(source, externalId)", async () => { - const item = { - tenantId: "t1", - principalId: "p1", - address: "p1@t1.example", - fromAddress: "sender@ext.example", - subject: "Ext mail", - body: "Body", - source: "gmail", - externalId: "ext-1", - }; - const first = await deliverInboxItems(db, [item]); - const second = await deliverInboxItems(db, [item]); - expect(first[0]?.id).not.toBeNull(); - expect(second[0]?.id).toBeNull(); - expect(second[0]?.messageKey).toBe(mailboxKey.inbox("gmail", "ext-1")); - }); - - test("colon-bearing source/externalId pairs do not collide", async () => { - const base = { - tenantId: "t1", - principalId: "p1", - address: "p1@t1.example", - fromAddress: "sender@ext.example", - subject: "Ext mail", - body: "Body", - }; - // ("a:b","c") vs ("a","b:c") used to share `inbox:a:b:c` under colon-join. - const left = await deliverInboxItems(db, [ - { ...base, source: "a:b", externalId: "c" }, - ]); - const right = await deliverInboxItems(db, [ - { ...base, source: "a", externalId: "b:c" }, - ]); - expect(left[0]?.id).not.toBeNull(); - expect(right[0]?.id).not.toBeNull(); - expect(left[0]?.id).not.toBe(right[0]?.id); - expect(left[0]?.messageKey).not.toBe(right[0]?.messageKey); - expect(left[0]?.messageKey).toBe(mailboxKey.inbox("a:b", "c")); - expect(right[0]?.messageKey).toBe(mailboxKey.inbox("a", "b:c")); - }); - - test("true replay still returns id=null", async () => { - const item = { - tenantId: "t1", - principalId: "p1", - address: "p1@t1.example", - fromAddress: "sender@ext.example", - subject: "Replay", - body: "Body", - source: "a:b", - externalId: "c", - }; - const first = await deliverInboxItems(db, [item]); - const replay = await deliverInboxItems(db, [item]); - expect(first[0]?.id).not.toBeNull(); - expect(replay[0]?.id).toBeNull(); - expect(replay[0]?.messageKey).toBe(first[0]?.messageKey); - }); - - test("empty and unicode source/externalId components stay distinct", async () => { - const base = { - tenantId: "t1", - principalId: "p1", - address: "p1@t1.example", - fromAddress: "sender@ext.example", - subject: "Unicode", - body: "Body", - }; - const pairs: Array<[string, string]> = [ - ["", "x"], - ["x", ""], - ["café", "id"], - ["cafe", "id"], - ["src", "外部"], - ["src", "外部-2"], - ]; - const delivered = []; - for (const [source, externalId] of pairs) { - const [row] = await deliverInboxItems(db, [ - { ...base, source, externalId }, - ]); - delivered.push(row); - } - const ids = delivered.map((r) => r?.id); - const keys = delivered.map((r) => r?.messageKey); - expect(ids.every((id) => id !== null && id !== undefined)).toBe(true); - expect(new Set(ids).size).toBe(pairs.length); - expect(new Set(keys).size).toBe(pairs.length); - }); - - test("multi-recipient delivery creates one row per recipient", async () => { - const base = { - address: "shared@ext.example", - fromAddress: "sender@ext.example", - subject: "Broadcast", - body: "Body", - source: "gmail", - externalId: "broadcast-1", - }; - const results = await deliverInboxItems(db, [ - { ...base, tenantId: "t1", principalId: "p1" }, - { ...base, tenantId: "t1", principalId: "p2" }, - ]); - expect(results.filter((r) => r.id !== null).length).toBe(2); - const p1Message = await getMailboxMessage(db, { + expect(first).not.toBeNull(); + expect(second).toBeNull(); + const store = await openNativeMailboxStore(db, { tenantId: "t1", principalId: "p1", - id: results[0]!.id!, - }); - const p2Message = await getMailboxMessage(db, { - tenantId: "t1", - principalId: "p2", - id: results[1]!.id!, + folder: "INBOX", }); - expect(p1Message).not.toBeNull(); - expect(p2Message).not.toBeNull(); - }); - - test("calls the host enqueue hook once per newly-delivered row", async () => { - const enqueued: string[] = []; - await deliverInboxItems( - db, - [ - { - tenantId: "t1", - principalId: "p1", - address: "p1@t1.example", - fromAddress: "sender@ext.example", - subject: "Ext mail", - body: "Body", - source: "gmail", - externalId: "ext-enqueue", - }, - ], - { enqueue: ({ id }) => enqueued.push(id) }, - ); - expect(enqueued.length).toBe(1); + expect(store.messages).toHaveLength(1); }); - test("mid-batch FK failure rolls back every new row from the same call", async () => { - // One transaction for the whole batch: a later nonblank-but-unknown principal - // must not leave the earlier good item committed. + test("a non-bracketed messageId is refused", async () => { await expect( - deliverInboxItems(db, [ - { - tenantId: "t1", - principalId: "p1", - address: "p1@t1.example", - fromAddress: "sender@ext.example", - subject: "Good", - body: "Body", - source: "gmail", - externalId: "atomic-good", - }, - { - tenantId: "t1", - principalId: "nobody-seeded-this", - address: "ghost@t1.example", - fromAddress: "sender@ext.example", - subject: "Bad", - body: "Body", - source: "gmail", - externalId: "atomic-bad", - }, - ]), - ).rejects.toThrow(); - - // Redelivery of the good item must insert (id !== null). If the first - // call had partially committed, onConflictDoNothing would return null. - const redelivery = await deliverInboxItems(db, [ - { - tenantId: "t1", - principalId: "p1", - address: "p1@t1.example", - fromAddress: "sender@ext.example", - subject: "Good", - body: "Body", - source: "gmail", - externalId: "atomic-good", - }, - ]); - expect(redelivery[0]?.id).not.toBeNull(); - }); - - test("deduped keys are no-ops inside the batch without breaking atomicity", async () => { - const item = { - tenantId: "t1", - principalId: "p1", - address: "p1@t1.example", - fromAddress: "sender@ext.example", - subject: "Already", - body: "Body", - source: "gmail", - externalId: "atomic-dedupe", - }; - const [first] = await deliverInboxItems(db, [item]); - expect(first?.id).not.toBeNull(); - - // Replay of the first + a new sibling in one call: the dedupe is a no-op - // and the new row still commits. - const batch = await deliverInboxItems(db, [ - item, - { ...item, externalId: "atomic-dedupe-sibling" }, - ]); - expect(batch[0]?.id).toBeNull(); - expect(batch[1]?.id).not.toBeNull(); - - // Same shape, but the new sibling is followed by an FK failure: the sibling - // must roll back; the already-committed first item stays. - await expect( - deliverInboxItems(db, [ - item, - { ...item, externalId: "atomic-dedupe-should-roll-back" }, - { - ...item, - principalId: "nobody-seeded-this", - address: "ghost@t1.example", - externalId: "atomic-dedupe-fk", - }, - ]), - ).rejects.toThrow(); - - const rolledBack = await deliverInboxItems(db, [ - { ...item, externalId: "atomic-dedupe-should-roll-back" }, - ]); - expect(rolledBack[0]?.id).not.toBeNull(); + writeMailboxMessage(db, args({ messageId: "not-a-msg-id" })), + ).rejects.toThrow(RangeError); }); - test("enqueue throw does not reject delivery after commit", async () => { - const results = await deliverInboxItems( + test("threading headers are normalized before both the frame and the envelope", async () => { + const written = await writeMailboxMessage( db, - [ - { - tenantId: "t1", - principalId: "p1", - address: "p1@t1.example", - fromAddress: "sender@ext.example", - subject: "Hook boom", - body: "Body", - source: "gmail", - externalId: "enqueue-throw", - }, - ], - { - enqueue: () => { - throw new Error("hook boom"); - }, - }, + args({ inReplyTo: " \n" }), ); - expect(results[0]?.id).not.toBeNull(); - const detail = await getMailboxMessage(db, { + const store = await openNativeMailboxStore(db, { tenantId: "t1", principalId: "p1", - id: results[0]!.id!, + folder: "INBOX", }); - expect(detail).not.toBeNull(); + const message = store.find(written!.uid)!; + expect(message.envelope.inReplyTo).toBe(""); }); - test("deduped deliveries skip enqueue", async () => { - const item = { - tenantId: "t1", - principalId: "p1", - address: "p1@t1.example", - fromAddress: "sender@ext.example", - subject: "Once", - body: "Body", - source: "gmail", - externalId: "enqueue-skip-dedupe", - }; - await deliverInboxItems(db, [item], { - enqueue: () => { - /* first delivery may call */ - }, - }); - const enqueued: string[] = []; - const replay = await deliverInboxItems(db, [item], { - enqueue: ({ id }) => enqueued.push(id), - }); - expect(replay[0]?.id).toBeNull(); - expect(enqueued).toEqual([]); - }); - - test("a newly delivered item publishes a `create` event", async () => { + test("publishes a `create` event when a bus is supplied", async () => { const bus = createInMemoryMailboxEventBus(); - const received: Array<{ id: string; op?: string }> = []; - bus.subscribe({ tenantId: "t1", principalId: "p1" }, (event) => - received.push(event), - ); - await deliverInboxItems( - db, - [ - { - tenantId: "t1", - principalId: "p1", - address: "p1@t1.example", - fromAddress: "sender@ext.example", - subject: "Delivered", - body: "Body", - source: "gmail", - externalId: "op-create", - }, - ], - { bus }, - ); - expect(received).toHaveLength(1); - expect(received[0]?.op).toBe("create"); + const seen: MailboxEvent[] = []; + bus.subscribe({ tenantId: "t1", principalId: "p1" }, (e) => seen.push(e)); + const written = await writeMailboxMessage(db, args(), bus); + expect(seen).toEqual([{ type: "mailbox", id: written!.id, op: "create" }]); }); - test("bus publish and enqueue run only after a successful batch commit", async () => { - const bus = createInMemoryMailboxEventBus(); - const received: string[] = []; - bus.subscribe({ tenantId: "t1", principalId: "p1" }, (event) => - received.push(event.id), - ); - const enqueued: string[] = []; - + test("a body/subject at the frame-byte cap is refused before any append", async () => { + const huge = "a".repeat(MAX_MAILBOX_FRAME_BYTES); await expect( - deliverInboxItems( - db, - [ - { - tenantId: "t1", - principalId: "p1", - address: "p1@t1.example", - fromAddress: "sender@ext.example", - subject: "Would publish", - body: "Body", - source: "gmail", - externalId: "post-commit-good", - }, - { - tenantId: "t1", - principalId: "nobody-seeded-this", - address: "ghost@t1.example", - fromAddress: "sender@ext.example", - subject: "FK boom", - body: "Body", - source: "gmail", - externalId: "post-commit-bad", - }, - ], - { - bus, - enqueue: ({ id }) => enqueued.push(id), - }, - ), - ).rejects.toThrow(); - - expect(received).toEqual([]); - expect(enqueued).toEqual([]); - }); -}); - -describe("mailboxKey namespaces", () => { - test("gate: and run: never collide for the same underlying id", async () => { - const base = { - tenantId: "t1", - principalId: "p1", - address: "p1@t1.example", - fromAddress: "triage@t1.example", - body: "Body", - }; - expect(mailboxKey.gate("wf-1")).toBe("gate:wf-1"); - expect(mailboxKey.run("wf-1")).toBe("run:wf-1"); - - const gate = await writeMailboxMessage(db, { - ...base, - subject: "Approval needed", - messageKey: mailboxKey.gate("wf-1"), - }); - const run = await writeMailboxMessage(db, { - ...base, - subject: "Run finished", - messageKey: mailboxKey.run("wf-1"), - }); - expect(gate).not.toBeNull(); - expect(run).not.toBeNull(); - expect(gate!.id).not.toBe(run!.id); - }); - - test("re-writing the same namespaced key is a no-op", async () => { - const args = { - tenantId: "t1", - principalId: "p1", - address: "p1@t1.example", - fromAddress: "triage@t1.example", - subject: "Approval needed", - body: "Body", - messageKey: mailboxKey.gate("wf-2"), - }; - expect(await writeMailboxMessage(db, args)).not.toBeNull(); - expect(await writeMailboxMessage(db, args)).toBeNull(); - }); - - test("inbox keys are versioned length-prefixed and injective over source/externalId", () => { - expect(mailboxKey.inbox("gmail", "123")).toBe("inbox2:5:gmail:123"); - expect(mailboxKey.inbox("a:b", "c")).toBe("inbox2:3:a:b:c"); - expect(mailboxKey.inbox("a", "b:c")).toBe("inbox2:1:a:b:c"); - expect(mailboxKey.inbox("a:b", "c")).not.toBe(mailboxKey.inbox("a", "b:c")); - // disjoint from pre-upgrade `inbox::` (no false collision - // when historical source was pure decimal, e.g. source="5") - expect(mailboxKey.inbox("gmail", "123")).not.toBe("inbox:5:gmail:123"); - // gate/run stay colon-prefixed single-segment namespaces - expect(mailboxKey.gate("wf-1")).toBe("gate:wf-1"); - expect(mailboxKey.run("wf-1")).toBe("run:wf-1"); - }); -}); - -describe("frame size hard cap", () => { - async function mailRowCount(): Promise { - const rows = await db.execute<{ n: number }>( - sql`SELECT count(*)::int AS n FROM "mailbox"."principal_mail"`, - ); - return rows[0]!.n; - } - - // Headers add a few hundred bytes; leave headroom under the cap so near-cap - // acceptance is not flaky on UUID/Date length. Body alone at the cap cannot - // produce a legal frame (headers always add more) and is refused by both the - // batch body precheck and the built-frame assert. - const nearCapBody = "x".repeat(MAX_MAILBOX_FRAME_BYTES - 2048); - const atCapBody = "x".repeat(MAX_MAILBOX_FRAME_BYTES); - const overCapBody = "x".repeat(MAX_MAILBOX_FRAME_BYTES + 1); - - test("writeMailboxMessage accepts a near-cap frame and refuses an oversize one", async () => { - const base = { + writeMailboxMessage(db, args({ body: huge })), + ).rejects.toThrow(RangeError); + const store = await openNativeMailboxStore(db, { tenantId: "t1", principalId: "p1", - address: "p1@t1.example", - fromAddress: "agent@t1.example", - subject: "cap", - }; - const ok = await writeMailboxMessage(db, { - ...base, - body: nearCapBody, - messageKey: "frame-cap-ok", + folder: "INBOX", }); - expect(ok).not.toBeNull(); - - await expect( - writeMailboxMessage(db, { - ...base, - body: overCapBody, - messageKey: "frame-cap-over", - }), - ).rejects.toThrow(RangeError); - await expect( - writeMailboxMessage(db, { - ...base, - body: overCapBody, - messageKey: "frame-cap-over", - }), - ).rejects.toThrow(/mailbox frame exceeds/); - - // Only the near-cap row exists. - expect(await mailRowCount()).toBe(1); - }); - - test("writeMailboxMessage refuses an oversize subject without writing", async () => { - await expect( - writeMailboxMessage(db, { - tenantId: "t1", - principalId: "p1", - address: "p1@t1.example", - fromAddress: "agent@t1.example", - subject: "s".repeat(MAX_MAILBOX_FRAME_BYTES), - body: "small", - messageKey: "frame-cap-subject", - }), - ).rejects.toThrow(RangeError); - expect(await mailRowCount()).toBe(0); - }); - - test("writeMailboxMessage refuses when headers plus body exceed the frame cap", async () => { - // Each field alone is under the cap; the built MIME frame is not. - const half = Math.floor(MAX_MAILBOX_FRAME_BYTES / 2); - await expect( - writeMailboxMessage(db, { - tenantId: "t1", - principalId: "p1", - address: "p1@t1.example", - fromAddress: "agent@t1.example", - subject: "s".repeat(half), - body: "b".repeat(half), - messageKey: "frame-cap-sum", - }), - ).rejects.toThrow(RangeError); - expect(await mailRowCount()).toBe(0); - }); - - test("deliverInboxItems accepts a near-cap frame and refuses body at the cap", async () => { - const ok = await deliverInboxItems(db, [ - { - tenantId: "t1", - principalId: "p1", - address: "p1@t1.example", - fromAddress: "sender@ext.example", - subject: "near", - body: nearCapBody, - source: "gmail", - externalId: "near-cap", - }, - ]); - expect(ok).toHaveLength(1); - expect(ok[0]!.id).not.toBeNull(); - expect(ok[0]!.messageKey).toBe(mailboxKey.inbox("gmail", "near-cap")); - - // Body alone === MAX cannot produce a legal frame; prevalidation must refuse - // before opening a transaction (not only body > MAX). - await expect( - deliverInboxItems(db, [ - { - tenantId: "t1", - principalId: "p1", - address: "p1@t1.example", - fromAddress: "sender@ext.example", - subject: "at cap body", - body: atCapBody, - source: "gmail", - externalId: "frame-at-cap", - }, - ]), - ).rejects.toThrow(RangeError); - expect(await mailRowCount()).toBe(1); - }); - - test("deliverInboxItems refuses an oversized frame with no durable write", async () => { - await expect( - deliverInboxItems(db, [ - { - tenantId: "t1", - principalId: "p1", - address: "p1@t1.example", - fromAddress: "sender@ext.example", - subject: "too big", - body: overCapBody, - source: "gmail", - externalId: "frame-over", - }, - ]), - ).rejects.toThrow(RangeError); - expect(await mailRowCount()).toBe(0); - }); - - test("deliverInboxItems prevalidates frame size for the whole batch", async () => { - // Good item first: encode+assert of every item runs before the transaction, - // so an oversize later item refuses with zero durable rows. - await expect( - deliverInboxItems(db, [ - { - tenantId: "t1", - principalId: "p1", - address: "p1@t1.example", - fromAddress: "sender@ext.example", - subject: "ok", - body: "small", - source: "gmail", - externalId: "batch-ok", - }, - { - tenantId: "t1", - principalId: "p2", - address: "p2@t1.example", - fromAddress: "sender@ext.example", - subject: "s".repeat(MAX_MAILBOX_FRAME_BYTES), - body: "small", - source: "gmail", - externalId: "batch-over-subject", - }, - ]), - ).rejects.toThrow(RangeError); - expect(await mailRowCount()).toBe(0); + expect(store.messages).toHaveLength(0); }); -}); -describe("caller-supplied messageId, direction, and messageKey", () => { - async function rawFrame(id: string): Promise { - const rows = await db.execute<{ raw: Uint8Array }>( - sql`SELECT raw FROM "mailbox"."principal_mail" WHERE id = ${id}`, - ); - return rows[0]!.raw; - } - - async function rowColumns(id: string): Promise<{ - direction: string; - message_id: string | null; - message_key: string | null; - }> { - const rows = await db.execute<{ - direction: string; - message_id: string | null; - message_key: string | null; - }>( - sql`SELECT direction, message_id, message_key FROM "mailbox"."principal_mail" WHERE id = ${id}`, - ); - return rows[0]!; - } - - test("a caller-supplied Message-ID round-trips to the stored frame header and the cached column", async () => { - const messageId = ""; - const written = await writeMailboxMessage(db, { + test("writes into a non-default folder when asked", async () => { + await writeMailboxMessage(db, args({ folder: "Sent" })); + const inbox = await openNativeMailboxStore(db, { tenantId: "t1", principalId: "p1", - address: "p1@t1.example", - fromAddress: "agent@t1.example", - subject: "Hello", - body: "World", - messageId, + folder: "INBOX", }); - expect(written).not.toBeNull(); - - const raw = await rawFrame(written!.id); - const decoded = decodeMailFrame(raw); - expect(decoded?.messageId).toBe(messageId); - - const columns = await rowColumns(written!.id); - expect(columns.message_id).toBe(messageId); - }); - - test("an invalid caller-supplied messageId is refused with RangeError", async () => { - await expect( - writeMailboxMessage(db, { - tenantId: "t1", - principalId: "p1", - address: "p1@t1.example", - fromAddress: "agent@t1.example", - subject: "Hello", - body: "World", - messageId: "not-a-msg-id", - }), - ).rejects.toThrow(RangeError); - }); - - test("omitting messageId still mints one, as before", async () => { - const written = await writeMailboxMessage(db, { + const sent = await openNativeMailboxStore(db, { tenantId: "t1", principalId: "p1", - address: "p1@t1.example", - fromAddress: "agent@t1.example", - subject: "Hello", - body: "World", + folder: "Sent", }); - const columns = await rowColumns(written!.id); - expect(columns.message_id).toMatch(/^<.+@.+>$/); + expect(inbox.messages).toHaveLength(0); + expect(sent.messages).toHaveLength(1); }); +}); - test("direction defaults to inbound and can be set to outbound", async () => { - const inbound = await writeMailboxMessage(db, { +describe("deliverInboxItems", () => { + function item(over: Partial[1][number]> = {}) { + return { tenantId: "t1", principalId: "p1", address: "p1@t1.example", - fromAddress: "agent@t1.example", - subject: "Hello", - body: "World", - }); - expect((await rowColumns(inbound!.id)).direction).toBe("inbound"); + fromAddress: "adapter@t1.example", + subject: "Ingress", + body: "Body", + source: "gmail", + externalId: "ext-1", + ...over, + }; + } - const outbound = await writeMailboxMessage(db, { + test("delivers a new item and dedupes a redelivery of the same (source, externalId)", async () => { + const [first] = await deliverInboxItems(db, [item()]); + const [second] = await deliverInboxItems(db, [item()]); + expect(first!.id).not.toBeNull(); + expect(second!.id).toBeNull(); + const store = await openNativeMailboxStore(db, { tenantId: "t1", principalId: "p1", - address: "p1@t1.example", - fromAddress: "agent@t1.example", - subject: "Sent", - body: "World", - direction: "outbound", + folder: "INBOX", }); - expect((await rowColumns(outbound!.id)).direction).toBe("outbound"); + expect(store.messages).toHaveLength(1); }); - test("an explicit messageKey is honored over the default transport key", async () => { - const written = await writeMailboxMessage(db, { + test("distinct externalIds deliver as distinct messages", async () => { + const results = await deliverInboxItems(db, [ + item({ externalId: "a" }), + item({ externalId: "b" }), + ]); + expect(results.map((r) => r.id).every((id) => id !== null)).toBe(true); + const store = await openNativeMailboxStore(db, { tenantId: "t1", principalId: "p1", - address: "p1@t1.example", - fromAddress: "agent@t1.example", - subject: "Hello", - body: "World", - messageKey: "custom:my-key", + folder: "INBOX", }); - expect((await rowColumns(written!.id)).message_key).toBe("custom:my-key"); + expect(store.messages).toHaveLength(2); }); - test("omitting messageKey defaults to the package's transport key, keyed off messageId", async () => { - const messageId = ""; - const written = await writeMailboxMessage(db, { - tenantId: "t1", - principalId: "p1", - address: "p1@t1.example", - fromAddress: "agent@t1.example", - subject: "Hello", - body: "World", - messageId, + test("enqueue runs once per newly-delivered item, after its append settles", async () => { + const seen: string[] = []; + await deliverInboxItems(db, [item()], { + enqueue: ({ id }) => seen.push(id), }); - expect((await rowColumns(written!.id)).message_key).toBe( - mailboxKey.transport(messageId, "p1"), - ); - - // A retry with the SAME caller-supplied messageId therefore dedupes. - const retry = await writeMailboxMessage(db, { - tenantId: "t1", - principalId: "p1", - address: "p1@t1.example", - fromAddress: "agent@t1.example", - subject: "Hello", - body: "World", - messageId, + await deliverInboxItems(db, [item()], { + enqueue: ({ id }) => seen.push(id), }); - expect(retry).toBeNull(); + // The redelivery deduped, so enqueue must not fire a second time. + expect(seen).toHaveLength(1); }); -}); -describe("writeMailboxMessages (one-transaction batch write)", () => { - async function mailRows(): Promise< - Array<{ id: string; principal_id: string; direction: string }> - > { - return db.execute<{ id: string; principal_id: string; direction: string }>( - sql`SELECT id, principal_id, direction FROM "mailbox"."principal_mail"`, - ); - } - - test("a batch of three — one outbound for the sender, two inbound for recipients — commits atomically", async () => { - const bus = createInMemoryMailboxEventBus(); - const receivedP1: Array<{ id: string; op?: string }> = []; - const receivedP2: Array<{ id: string; op?: string }> = []; - bus.subscribe({ tenantId: "t1", principalId: "p1" }, (e) => - receivedP1.push(e), - ); - bus.subscribe({ tenantId: "t1", principalId: "p2" }, (e) => - receivedP2.push(e), - ); - - const ids = await writeMailboxMessages( - db, - [ - { - scope: { tenantId: "t1", principalId: "p1" }, - args: { - address: "p1@t1.example", - fromAddress: "p1@t1.example", - subject: "Sent", - body: "Hi p2", - direction: "outbound", - }, - }, - { - scope: { tenantId: "t1", principalId: "p1" }, - args: { - address: "p1@t1.example", - fromAddress: "p1@t1.example", - subject: "Recv", - body: "Hi p1", - direction: "inbound", - }, - }, - { - scope: { tenantId: "t1", principalId: "p2" }, - args: { - address: "p2@t1.example", - fromAddress: "p1@t1.example", - subject: "Recv", - body: "Hi p2", - direction: "inbound", - }, - }, - ], - { bus }, - ); - - expect(ids.length).toBe(3); - const rows = await mailRows(); - expect(rows.length).toBe(3); - // p1 receives two events (its outbound sent-copy and its inbound copy), - // p2 receives one (its inbound copy) — one bus event per written row. - expect(receivedP1.length).toBe(2); - expect(receivedP2.length).toBe(1); - expect(receivedP1.every((e) => e.op === "create")).toBe(true); - expect(receivedP2[0]?.op).toBe("create"); - }); - - test("a failing third item rolls back the whole batch, leaving zero rows", async () => { - await expect( - writeMailboxMessages(db, [ - { - scope: { tenantId: "t1", principalId: "p1" }, - args: { - address: "p1@t1.example", - fromAddress: "p1@t1.example", - subject: "Sent", - body: "Hi p2", - direction: "outbound", - }, - }, - { - scope: { tenantId: "t1", principalId: "p2" }, - args: { - address: "p2@t1.example", - fromAddress: "p1@t1.example", - subject: "Recv", - body: "Hi p2", - direction: "inbound", - }, - }, - { - scope: { tenantId: "t1", principalId: "nobody-seeded-this" }, - args: { - address: "ghost@t1.example", - fromAddress: "p1@t1.example", - subject: "Bad", - body: "Bad", - }, - }, - ]), - ).rejects.toThrow(); - - expect((await mailRows()).length).toBe(0); - }); - - test("retrying an already-committed batch (same messageIds, no messageKey) writes nothing and returns null ids", async () => { - const messageId1 = ""; - const messageId2 = ""; - const items = [ - { - scope: { tenantId: "t1", principalId: "p1" }, - args: { - address: "p1@t1.example", - fromAddress: "p1@t1.example", - subject: "Sent", - body: "Hi p2", - direction: "outbound" as const, - messageId: messageId1, - }, - }, - { - scope: { tenantId: "t1", principalId: "p2" }, - args: { - address: "p2@t1.example", - fromAddress: "p1@t1.example", - subject: "Recv", - body: "Hi p2", - direction: "inbound" as const, - messageId: messageId2, - }, - }, - ]; - - const first = await writeMailboxMessages(db, items); - expect(first.length).toBe(2); - - const retry = await writeMailboxMessages(db, items); - expect(retry.map((r) => r.id)).toEqual([null, null]); - expect((await mailRows()).length).toBe(2); - }); - - test("events fire only after commit, and only for newly-written rows", async () => { - const bus = createInMemoryMailboxEventBus(); - const received: Array<{ id: string }> = []; - bus.subscribe({ tenantId: "t1", principalId: "p1" }, (e) => - received.push(e), - ); - - const messageId = ""; - const item = { - scope: { tenantId: "t1", principalId: "p1" }, - args: { - address: "p1@t1.example", - fromAddress: "p1@t1.example", - subject: "Hello", - body: "World", - messageId, - }, - }; - - const first = await writeMailboxMessages(db, [item], { bus }); - expect(first.length).toBe(1); - expect(received.map((e) => e.id)).toEqual( - first.map((row) => row.id).filter((id): id is string => id !== null), - ); - - // Dedupe: the retry writes nothing and must not publish a second event. - const retry = await writeMailboxMessages(db, [item], { bus }); - expect(retry.map((r) => r.id)).toEqual([null]); - expect(received.length).toBe(1); - }); - - test("a messageKey override is honored inside a batch", async () => { - const results = await writeMailboxMessages(db, [ - { - scope: { tenantId: "t1", principalId: "p1" }, - args: { - address: "p1@t1.example", - fromAddress: "p1@t1.example", - subject: "Hello", - body: "World", - messageKey: "custom:batch-key", - }, - }, - ]); - expect(results[0]?.messageKey).toBe("custom:batch-key"); - const rows = await db.execute<{ message_key: string }>( - sql`SELECT message_key FROM "mailbox"."principal_mail" WHERE id = ${results[0]?.id}`, - ); - expect(rows[0]?.message_key).toBe("custom:batch-key"); - }); - - test("returns { messageKey, id } per item, in item order, including the default-keyed items", async () => { - const messageId = ""; - const results = await writeMailboxMessages(db, [ - { - scope: { tenantId: "t1", principalId: "p1" }, - args: { - address: "p1@t1.example", - fromAddress: "p1@t1.example", - subject: "Sent", - body: "Hi p2", - direction: "outbound", - messageId, - }, - }, - { - scope: { tenantId: "t1", principalId: "p2" }, - args: { - address: "p2@t1.example", - fromAddress: "p1@t1.example", - subject: "Recv", - body: "Hi p2", - direction: "inbound", - messageId, - }, - }, - ]); - expect(results).toEqual([ - { messageKey: `transport:mid:${messageId}:p1:outbound`, id: expect.any(String) }, - { messageKey: `transport:mid:${messageId}:p2`, id: expect.any(String) }, - ]); - }); - - test("invalid scope on a later item is refused before any row is written", async () => { - await expect( - writeMailboxMessages(db, [ - { - scope: { tenantId: "t1", principalId: "p1" }, - args: { - address: "p1@t1.example", - fromAddress: "p1@t1.example", - subject: "Hello", - body: "World", - }, - }, - { - scope: { tenantId: "t1", principalId: " " }, - args: { - address: "p1@t1.example", - fromAddress: "p1@t1.example", - subject: "Hello", - body: "World", - }, - }, - ]), - ).rejects.toThrow(RangeError); - expect((await mailRows()).length).toBe(0); - }); - - test("mailbox management row is created for outbound rows written through the batch path too", async () => { - const results = await writeMailboxMessages(db, [ - { - scope: { tenantId: "t1", principalId: "p1" }, - args: { - address: "p1@t1.example", - fromAddress: "p1@t1.example", - subject: "Hello", - body: "World", - direction: "outbound", - }, + test("a throwing enqueue does not lose the delivered item", async () => { + const results = await deliverInboxItems(db, [item()], { + enqueue: () => { + throw new Error("hook exploded"); }, - ]); - const rows = await db.execute<{ n: number }>( - sql`SELECT count(*)::int AS n FROM "mailbox"."mailbox" WHERE id = ${results[0]?.id}`, - ); - expect(rows[0]!.n).toBe(1); - }); -}); - -describe("outbound rows and the inbox read model", () => { - const base = { - tenantId: "t1", - principalId: "p1", - address: "p1@t1.example", - fromAddress: "p1@t1.example", - subject: "Hello", - body: "World", - }; - - test("an outbound row is created already-read: excluded from the unread view and count without a direction predicate", async () => { - const written = await writeMailboxMessage(db, { - ...base, - direction: "outbound", - }); - expect(written).not.toBeNull(); - - const scope = { tenantId: "t1", principalId: "p1" }; - const page = await listUserMailbox(db, { - ...scope, - priorities: TEST_VOCABULARY.priorities, - view: "unread", - limit: 50, }); - expect(page.items.length).toBe(0); - expect( - await getMailboxMessage(db, { ...scope, id: written!.id }), - ).toBeNull(); - expect( - await getMailboxMessage(db, { ...scope, id: written!.id, direction: "all" }), - ).not.toBeNull(); - - expect(await countUnreadActiveMailbox(db, scope)).toBe(0); - }); - - test("listUserMailbox and getMailboxMessage accept an explicit direction filter", async () => { - const outbound = await writeMailboxMessage(db, { - ...base, - direction: "outbound", - messageId: "", - }); - const inbound = await writeMailboxMessage(db, { - ...base, - direction: "inbound", - messageId: "", - }); - const scope = { tenantId: "t1", principalId: "p1" }; - - const outboundPage = await listUserMailbox(db, { - ...scope, - priorities: TEST_VOCABULARY.priorities, - view: "all", - limit: 50, - direction: "outbound", - }); - expect(outboundPage.items.map((i) => i.id)).toEqual([outbound!.id]); - - const allPage = await listUserMailbox(db, { - ...scope, - priorities: TEST_VOCABULARY.priorities, - view: "all", - limit: 50, - direction: "all", - }); - expect(new Set(allPage.items.map((i) => i.id))).toEqual( - new Set([outbound!.id, inbound!.id]), - ); - - expect( - await getMailboxMessage(db, { - ...scope, - id: outbound!.id, - direction: "outbound", - }), - ).not.toBeNull(); + expect(results[0]!.id).not.toBeNull(); }); }); - -describe("default messageKey collisions", () => { - const base = { - tenantId: "t1", - principalId: "p1", - address: "p1@t1.example", - fromAddress: "p1@t1.example", - subject: "Hello", - body: "World", - }; - - test("same caller Message-ID to the same principal in both directions writes two distinct rows", async () => { - const messageId = ""; - const results = await writeMailboxMessages(db, [ - { - scope: { tenantId: "t1", principalId: "p1" }, - args: { ...base, direction: "outbound", messageId, subject: "Sent" }, - }, - { - scope: { tenantId: "t1", principalId: "p1" }, - args: { ...base, direction: "inbound", messageId, subject: "Recv" }, - }, - ]); - expect(results.length).toBe(2); - expect(results.every((r) => r.id !== null)).toBe(true); - }); - - test("two distinct messages that reuse one caller Message-ID for the same principal collide", async () => { - const messageId = ""; - const a = await writeMailboxMessage(db, { ...base, messageId, subject: "A" }); - const b = await writeMailboxMessage(db, { ...base, messageId, subject: "B" }); - expect(a).not.toBeNull(); - expect(b).toBeNull(); - }); - - test("default write key equals persist.ts's transport key shape for the same Message-ID + principal", async () => { - const messageId = ""; - const written = await writeMailboxMessage(db, { ...base, messageId }); - const rows = await db.execute<{ message_key: string }>( - sql`SELECT message_key FROM "mailbox"."principal_mail" WHERE id = ${written!.id}`, - ); - expect(rows[0]!.message_key).toBe(`transport:mid:${messageId}:p1`); - }); -}); - diff --git a/src/write.ts b/src/write.ts index 1d90211..18e6ad9 100644 --- a/src/write.ts +++ b/src/write.ts @@ -1,8 +1,13 @@ -import { sql } from "drizzle-orm"; +// The write boundary over the native `MailboxStore`. Every host-facing write +// path (`writeMailboxMessage`, `deliverInboxItems`, and `persist.ts`'s +// transport dual-write) lands through `NativeMailboxStore.append`, so uid and +// modseq are always set — there is no second, uid-less insert path left in +// this package. + import { type } from "arktype"; import { getLogger } from "@intx/log"; -import { mailbox, principalMail } from "./schema.js"; import type { MailboxDb } from "./db.js"; +import { openNativeMailboxStore } from "./native-store.js"; import { publishMailboxEvent, type MailboxEventBus } from "./bus.js"; import { assertMsgId, @@ -10,7 +15,6 @@ import { generateMailboxMessageId, headerValue, } from "./frame.js"; -import type { MailboxRef } from "./read.js"; const logger = getLogger(["corbits-mailbox", "write"]); @@ -48,9 +52,9 @@ export type MailboxScopeIds = typeof MailboxScopeIdsSchema.infer; /** * Refuse a blank mailbox scope before it reaches the database. * - * `RangeError` for the same reason the bulk cap and the empty enrichment throw - * it: this is a caller bug, not a request outcome, and the mount layer already - * renders a `RangeError` from this package as a 400. + * `RangeError` for the same reason the frame-byte cap throws it: this is a + * caller bug, not a request outcome, and the mount layer renders a + * `RangeError` from this package as a 400. */ export function assertMailboxScope(scope: { tenantId: string; @@ -70,15 +74,10 @@ export function assertMailboxTenantId(tenantId: string): void { } } -// A single row's `refs` is a compact set of pointers, not a dumping ground. -// Cap what a writer can persist so a runaway producer can never inflate one -// row's jsonb blob unboundedly; extras past the cap are dropped (logged). -export const MAX_MAILBOX_REFS = 20; - // Hard ceiling on a single durable frame (headers + body after build, or raw // bytes on the transport path). Multi-megabyte MIME would be copied once per -// recipient and re-decoded on detail reads; refuse at the write boundary with -// RangeError (same posture as the bulk-id and page-limit caps — never clamp). +// recipient and re-decoded on read; refuse at the write boundary with +// RangeError — never clamp. export const MAX_MAILBOX_FRAME_BYTES = 1_048_576; /** Throw `RangeError` when `raw` is strictly larger than `MAX_MAILBOX_FRAME_BYTES`. */ @@ -90,26 +89,6 @@ export function assertMailboxFrameBytes(raw: Uint8Array): void { } } -// `messageKey` is the caller's own identifier and is absent for externally -// delivered mail, which is never deduped — the warning below carries whatever -// the caller actually supplied rather than minting an id nobody can correlate. -export function boundRefs( - refs: MailboxRef[] | undefined, - messageKey: string | null, - /** Extra correlation fields merged into the truncation log line, e.g. `senderAddress` on the persist path. */ - extra?: Record, -): MailboxRef[] | undefined { - if (refs === undefined || refs.length === 0) return undefined; - if (refs.length <= MAX_MAILBOX_REFS) return refs; - logger.warn("mailbox refs truncated to the cap for {messageKey}", { - messageKey, - received: refs.length, - kept: MAX_MAILBOX_REFS, - ...extra, - }); - return refs.slice(0, MAX_MAILBOX_REFS); -} - export type WriteMailboxMessageArgs = { tenantId: string; principalId: string; @@ -117,32 +96,15 @@ export type WriteMailboxMessageArgs = { fromAddress: string; subject: string; body: string; - /** - * Idempotency key; a second write with the same key is a no-op (returns - * null). Omitted, a write still gets a stable key of its own: the package's - * transport key (`mailboxKey.transport`), derived from the frame's - * `messageId` and the recipient `principalId` — so a caller that retries - * the exact same `messageId` collapses onto one row without having to mint - * its own key, while two independent writes with different (minted) - * `messageId`s never collide. - */ - messageKey?: string; /** * The complete msg-id (angle brackets included) this write's frame carries - * as its `Message-ID:` header, and the value cached in - * `principal_mail.message_id`. `RangeError` (via `assertMsgId`) when it is - * not a bracketed msg-id. Omitted, one is minted the way it always was — - * `generateMailboxMessageId`. + * as its `Message-ID:` header. `RangeError` (via `assertMsgId`) when it is + * not a bracketed msg-id. Omitted, one is minted — + * `generateMailboxMessageId`. Also the write's idempotency key: a second + * write carrying the same `messageId` into the same (tenant, principal, + * folder) mailbox is a no-op (returns `null`). */ messageId?: string; - /** - * `"inbound"` (default) or `"outbound"`. The mailbox row's own copy of who - * sent it: an inbound row is delivered mail, an outbound row is the - * sender's durable copy of a message they sent. Purely a stored fact — - * this package's inbox views stay inbound-only regardless of what a caller - * writes here (see ARCHITECTURE.md's Known limits). - */ - direction?: "inbound" | "outbound"; inReplyTo?: string; /** * The thread's ancestry, oldest first; each entry a bracketed msg-id. Emitted @@ -150,40 +112,16 @@ export type WriteMailboxMessageArgs = { * `buildMailFrame`. `RangeError` on an entry that is not a bracketed msg-id. */ references?: string[]; - refs?: MailboxRef[]; - /** - * Triage known at write time. Values are the HOST's vocabulary — this - * package has none of its own — so they are plain strings here and are - * validated at the mount boundary, which is where the vocabulary lives. - * The message's `mailbox` row is created with the message either way; - * these stamp it at delivery. - */ - priority?: string; - classification?: string; - status?: string; -}; - -/** - * Drizzle transaction handle used by the shared insert path. Both the root - * `db.transaction` callback and a nested savepoint expose the same `insert`. - */ -type MailboxInsertTx = { - insert: MailboxDb["insert"]; + /** Defaults to `"INBOX"`. */ + folder?: string; }; /** * Normalize the threading fields once, on the way in, so the value cached in - * `principal_mail.in_reply_to` and the value that ends up in the frame's - * `In-Reply-To:` header are the SAME string. - * - * `buildMailFrame` already runs every threading value through `headerValue` - * before writing it into `raw` — trimmed and newline-flattened. Without this, - * `insertMailboxMessage` cached `args.inReplyTo` untrimmed, so a caller - * passing `" "` produced a row whose list projection (served from - * the cached column) differed from its detail projection (served from the - * frame) for the exact same message. Applying the same normalization here, - * once, before either the cache write or the frame encode, is what keeps them - * in agreement — not two independent trims that could drift apart. + * the store's envelope and the value that ends up in the frame's headers are + * the SAME string. `buildMailFrame` already runs every threading value + * through `headerValue` before writing it into `raw` — applying the same + * normalization here, once, keeps the envelope and the frame in agreement. */ function normalizeThreadingArgs< T extends { @@ -206,14 +144,9 @@ function normalizeThreadingArgs< } /** - * Encode args into a durable MIME frame. - * - * Uses the caller's `messageId` (already validated as a bracketed msg-id by - * `assertMsgId` below) when supplied, else mints a fresh one exactly as - * before. Either way the id is returned alongside the bytes rather than - * re-parsed out of them: it is what the row's `message_id` cache stores, and - * re-decoding a frame this function just built to recover a value it already - * had is work with a failure mode attached. + * Encode args into a durable MIME frame. Uses the caller's `messageId` + * (already validated as a bracketed msg-id by `assertMsgId` below) when + * supplied, else mints a fresh one. */ function encodeMailboxFrame(args: WriteMailboxMessageArgs): { raw: Uint8Array; @@ -259,177 +192,75 @@ function assertMailboxStringFieldsFit(args: { } /** - * Insert the mail row and its eager management row on the given handle. - * Returns the new id, or null when a non-null messageKey already existed - * (`onConflictDoNothing`). Caller owns scope validation, frame encoding, and - * any surrounding transaction. `raw` is re-asserted against - * `MAX_MAILBOX_FRAME_BYTES` here as defense in depth. - */ -async function insertMailboxMessage( - tx: MailboxInsertTx, - args: WriteMailboxMessageArgs, - raw: Uint8Array, - messageId: string, -): Promise<{ id: string; messageKey: string } | null> { - assertMailboxFrameBytes(raw); - const direction = args.direction ?? "inbound"; - const messageKey = - args.messageKey ?? mailboxKey.transport(messageId, args.principalId, direction); - const refs = boundRefs(args.refs, messageKey); - - // The management row is created EAGERLY with the message: every mutation and - // the unread count are then plain operations on `mailbox`, and the unread - // partial index can serve the hottest endpoint. Split across transactions, a - // crash between the two would commit the mail row alone — and a retry then - // hits the messageKey dedupe and returns null, leaving a message no mutation - // can reach. - const rows = await tx - .insert(principalMail) - .values({ - tenantId: args.tenantId, - principalId: args.principalId, - address: args.address, - direction, - raw: Buffer.from(raw), - subject: args.subject, - fromAddress: args.fromAddress, - messageKey, - messageId, - inReplyTo: args.inReplyTo ?? null, - // Absent and empty are the same chain, and NULL is the cheaper of the - // two — the same rule migration 0003's backfill applies. - references: - args.references === undefined || args.references.length === 0 - ? null - : args.references, - refs: refs ?? null, - }) - .onConflictDoNothing({ - target: [ - principalMail.tenantId, - principalMail.principalId, - principalMail.messageKey, - ], - where: sql`${principalMail.messageKey} IS NOT NULL`, - }) - .returning({ id: principalMail.id, createdAt: principalMail.createdAt }); - - const inserted = rows[0]; - if (!inserted) return null; - - // An outbound row is the sender's own durable copy of a message they sent, - // not something to notify them about — it is created already-read (readAt - // pinned to the same createdAt Postgres just minted) so the unread count - // and the unread view exclude it without either needing a direction - // predicate of their own. - await tx.insert(mailbox).values({ - id: inserted.id, - tenantId: args.tenantId, - principalId: args.principalId, - readAt: direction === "outbound" ? inserted.createdAt : null, - priority: args.priority ?? null, - classification: args.classification ?? null, - status: args.status ?? null, - }); - return { id: inserted.id, messageKey }; -} - -/** - * Insert one durable mailbox row, deduped on (tenantId, principalId, - * messageKey) via a partial unique index that only constrains rows with a - * non-null messageKey — externally-delivered mail with no key is never - * deduped or constrained by it. + * Append one durable message into a principal's native mailbox, deduped on + * `messageId` within the target (tenant, principal, folder) mailbox: a second + * write carrying the same `messageId` is a no-op and returns `null`. * - * Throws `RangeError` on a blank tenantId or principalId (see `assertMailboxScope`), - * when the built frame exceeds `MAX_MAILBOX_FRAME_BYTES`, and a Postgres FK - * violation on a tenant or principal the host's control plane does not know — - * writing to a mailbox that cannot exist is a caller bug, not a deliverable - * outcome. + * Throws `RangeError` on a blank tenantId/principalId (see + * `assertMailboxScope`), a non-msg-id `messageId`/`inReplyTo`/`references` + * entry, or a built frame over `MAX_MAILBOX_FRAME_BYTES`. * - * Returns the new row id, or null when the messageKey was already written. - * When `bus` is supplied, a successful insert also publishes a live signal - * to the recipient — strictly best-effort: a publish failure is logged and - * never turns a successful write into a caller-visible error. + * When `bus` is supplied, a successful append also publishes a live signal to + * the recipient — best-effort, same posture as everywhere else in this + * package. */ export async function writeMailboxMessage( db: MailboxDb, rawArgs: WriteMailboxMessageArgs, bus?: MailboxEventBus, -): Promise<{ id: string } | null> { +): Promise<{ id: string; uid: number } | null> { assertMailboxScope(rawArgs); const args = normalizeThreadingArgs(rawArgs); - // Refuse obviously oversize string fields before allocating the full encode. assertMailboxStringFieldsFit(args); - // Encode and size-check the built frame before opening a transaction so - // oversize input never pays for a begin/rollback. const { raw, messageId } = encodeMailboxFrame(args); assertMailboxFrameBytes(raw); - // One transaction for the mail row and its management row. - const row = await db.transaction(async (tx) => - insertMailboxMessage(tx, args, raw, messageId), + + const folder = args.folder ?? "INBOX"; + const store = await openNativeMailboxStore(db, { + tenantId: args.tenantId, + principalId: args.principalId, + folder, + }); + if (store.messages.some((m) => m.envelope.messageId === messageId)) { + return null; + } + const uid = store.append( + raw, + { + messageId, + from: args.fromAddress, + to: [args.address], + subject: args.subject, + date: new Date(), + inReplyTo: args.inReplyTo, + references: args.references ?? [], + interchangeType: undefined, + interchangeCorrelationId: undefined, + }, + [], ); - if (!row) return null; + await store.settled; + const id = `${args.tenantId}:${args.principalId}:${folder}:${uid}`; if (bus) { publishMailboxEvent( bus, { tenantId: args.tenantId, principalId: args.principalId }, - row.id, + id, logger, "create", ); } - return { id: row.id }; + return { id, uid }; } /** - * Idempotency-key namespaces. Every hub-authored write prefixes its key with - * the namespace that minted it, so two producers keying off the same - * underlying id never collide on one row: an approval gate (`gate:`) and - * the run it belongs to (`run:`) each get their own mailbox message even - * when `` is identical. - * - * Inbox keys use a versioned length-prefixed encoding - * (`inbox2:${source.length}:${source}:${externalId}`) so the encoding is - * injective over the (source, externalId) pair — (`a:b`,`c`) and (`a`,`b:c`) - * never share a key. (A NUL-join would also be injective, but Postgres text - * rejects U+0000.) The `inbox2:` prefix keeps the space disjoint from pre-upgrade - * `inbox::` keys: length-prefix alone would false-collide - * when a historical source was pure decimal (e.g. old `inbox:5:gmail:123` == - * length-prefixed `inbox:5:gmail:123` for source=`gmail`). Pre-upgrade rows will - * not dedupe against the new encoding and cannot false-collide with it — no - * migration is performed; redelivery after upgrade may insert a second row. + * One externally-sourced item an ingress adapter (mail connector, webhook, + * anything durable-fanning-out into principal mailboxes) wants delivered. + * `source` + `externalId` are the adapter's own dedupe key: redelivering the + * same external item is a no-op, by minting the same `messageId` from them + * when the item carries none of its own. */ -// `transport` is the default `writeMailboxMessage` / `writeMailboxMessages` -// fall back to when a caller supplies no `messageKey` of its own: keyed on -// the frame's own `messageId` (caller-supplied or minted) plus the recipient -// `principalId`. For the (default) `"inbound"` direction this matches -// `persist.ts`'s transport dual-write key shape -// (`transport:mid::`) BYTE FOR BYTE and without -// importing from it — a frame persist already delivered and a direct inbound -// write for the same Message-ID + principal dedupe onto the same row, as -// they always have. `"outbound"` gets a `:outbound` suffix instead of -// silently sharing the inbound key: a sender's own copy of a turn and a -// recipient's (or their own) inbound copy of the identical caller-supplied -// Message-ID must NOT collapse onto one row. `persist.ts` owns a second -// fallback (content-hash) for frames with no Message-ID at all, which never -// happens on this package's own write path, where a `messageId` is always -// present by the time a row is inserted. -export const mailboxKey = { - inbox: (source: string, externalId: string) => - `inbox2:${source.length}:${source}:${externalId}`, - gate: (gateId: string) => `gate:${gateId}`, - run: (runId: string) => `run:${runId}`, - transport: ( - messageId: string, - principalId: string, - direction: "inbound" | "outbound" = "inbound", - ) => - direction === "outbound" - ? `transport:mid:${messageId}:${principalId}:outbound` - : `transport:mid:${messageId}:${principalId}`, -} as const; - export type InboxItem = { tenantId: string; principalId: string; @@ -443,65 +274,36 @@ export type InboxItem = { inReplyTo?: string; /** The thread's ancestry, oldest first; see `WriteMailboxMessageArgs`. */ references?: string[]; - refs?: MailboxRef[]; - // An adapter that already knows an item's triage verdict stamps it - // at delivery rather than writing the row and immediately updating it. - // Host vocabulary; see `WriteMailboxMessageArgs`. - priority?: string; - classification?: string; - status?: string; }; export type DeliverInboxItemsOpts = { bus?: MailboxEventBus; /** - * Optional host-supplied triage hook; called once per newly-delivered row, - * strictly after the batch commits. Best-effort: a throw is logged with the - * message id and never rejects the delivery — the durable row already exists, - * and a host whose hook permanently fails on the first try must triage - * independently (retries of this call will dedupe and skip enqueue). + * Optional host hook, called once per newly-delivered item, strictly after + * its append settles. Best-effort: a throw is logged with the item's id and + * never rejects the delivery. */ enqueue?: (delivered: { id: string; item: InboxItem }) => void; }; -/** `id` is null exactly when the item was deduped — no row was written. */ -export type DeliveredInboxItem = { messageKey: string; id: string | null }; +/** `id` is null exactly when the item deduped against an existing message. */ +export type DeliveredInboxItem = { id: string | null }; /** - * Shared delivery seam for ingress adapters (mail connectors, webhooks, - * anything durable-fanning-out into principal mailboxes). Dedupe key is - * `mailboxKey.inbox(source, externalId)` (versioned length-prefixed; - * injective over the pair) — the same external item re-delivered by a retried - * adapter never writes twice. Triage logic itself is NOT this package's concern: - * `enqueue`, if given, is invoked after commit for each newly inserted id. - * - * Throws `RangeError` on a blank tenantId or principalId anywhere in the batch, - * and when any item's string fields or built frame exceed the frame-byte cap — - * every item is scope-checked, field-checked, encoded, and frame-asserted - * BEFORE the transaction opens so oversize input never begins a multi-row - * insert. After that prevalidation, all new mail + management rows for the - * call commit in ONE `db.transaction` (or none). Deduped keys (`id: null`) are - * no-ops inside the transaction without breaking atomicity. Bus publish and - * `enqueue` run only after commit, and only for newly inserted ids; a throwing - * `enqueue` is logged and swallowed (same posture as `publishMailboxEvent`). + * Shared delivery seam for ingress adapters. Each item is delivered with + * `writeMailboxMessage`, one append at a time (the native store has no + * multi-row batch — see `NativeMailboxStore`), deduped on a `messageId` minted + * from `(source, externalId)` when the item carries none of its own, so the + * same external item redelivered by a retried adapter never appends twice. */ export async function deliverInboxItems( db: MailboxDb, items: InboxItem[], opts?: DeliverInboxItemsOpts, ): Promise { - type Prepared = { - item: InboxItem; - messageKey: string; - writeArgs: WriteMailboxMessageArgs; - raw: Uint8Array; - messageId: string; - }; - const prepared: Prepared[] = []; + const results: DeliveredInboxItem[] = []; for (const item of items) { - assertMailboxScope(item); - assertMailboxStringFieldsFit(item); - const messageKey = mailboxKey.inbox(item.source, item.externalId); + const messageId = ``; const writeArgs: WriteMailboxMessageArgs = { tenantId: item.tenantId, principalId: item.principalId, @@ -509,169 +311,22 @@ export async function deliverInboxItems( fromAddress: item.fromAddress, subject: item.subject, body: item.body, - messageKey, + messageId, }; if (item.inReplyTo !== undefined) writeArgs.inReplyTo = item.inReplyTo; if (item.references !== undefined) writeArgs.references = item.references; - if (item.refs !== undefined) writeArgs.refs = item.refs; - if (item.priority !== undefined) writeArgs.priority = item.priority; - if (item.classification !== undefined) { - writeArgs.classification = item.classification; - } - if (item.status !== undefined) writeArgs.status = item.status; - const normalizedWriteArgs = normalizeThreadingArgs(writeArgs); - const { raw, messageId } = encodeMailboxFrame(normalizedWriteArgs); - assertMailboxFrameBytes(raw); - prepared.push({ - item, - messageKey, - writeArgs: normalizedWriteArgs, - raw, - messageId, - }); - } - - type Inserted = { id: string; item: InboxItem }; - const { results, inserted } = await db.transaction(async (tx) => { - const results: DeliveredInboxItem[] = []; - const inserted: Inserted[] = []; - for (const { item, messageKey, writeArgs, raw, messageId } of prepared) { - const written = await insertMailboxMessage(tx, writeArgs, raw, messageId); - if (written === null) { - results.push({ messageKey, id: null }); - continue; + const written = await writeMailboxMessage(db, writeArgs, opts?.bus); + results.push({ id: written?.id ?? null }); + if (written && opts?.enqueue) { + try { + opts.enqueue({ id: written.id, item }); + } catch (err) { + logger.error("mailbox enqueue failed for {id}", { + id: written.id, + error: err instanceof Error ? err : new Error(String(err)), + }); } - results.push({ messageKey, id: written.id }); - inserted.push({ id: written.id, item }); - } - return { results, inserted }; - }); - - // Post-commit only: live signals and host triage for newly inserted ids. - - for (const { id, item } of inserted) { - if (opts?.bus) { - publishMailboxEvent( - opts.bus, - { tenantId: item.tenantId, principalId: item.principalId }, - id, - logger, - "create", - ); - } - if (!opts?.enqueue) continue; - try { - opts.enqueue({ id, item }); - } catch (err) { - logger.error("mailbox enqueue failed for {rowId}", { - rowId: id, - error: err instanceof Error ? err : new Error(String(err)), - }); } } - - return results; -} - -/** - * One item of a `writeMailboxMessages` batch: an address plus the scope it - * lands in. `args` omits `tenantId`/`principalId` — `scope` is the SOLE - * source of both, so there is no second copy that could disagree with it. - */ -export type WriteMailboxMessagesItem = { - scope: MailboxScopeIds; - args: Omit; -}; - -export type WriteMailboxMessagesOpts = { - /** Best-effort live signal per inserted row, published only after commit. */ - bus?: MailboxEventBus; -}; - -/** - * Write an entire conversation turn — a sender's own outbound copy alongside - * every recipient's inbound copy, or any other mixed-scope, mixed-direction - * batch — as ONE transaction. This is the conversation path; `deliverInboxItems` - * remains the notify-item path (ingress adapters fanning one external item out - * to durable rows) and is unchanged by this function's existence. - * - * Each item is scope-checked, field-checked, encoded, and frame-asserted - * BEFORE the transaction opens — same prevalidation discipline as - * `deliverInboxItems` — so oversize or malformed input never begins a - * multi-row insert. All rows then commit together inside one - * `db.transaction`: a throw from any single item (a caller bug, or a control- - * plane FK the item's scope does not satisfy) rolls back every row the batch - * would otherwise have written, including ones already inserted earlier in - * the same call. - * - * Dedupe is per row, on `(tenantId, principalId, messageKey)` via - * `onConflictDoNothing` on the existing partial unique index — the same - * mechanism `writeMailboxMessage` and `deliverInboxItems` use. A row whose - * `messageKey` collides with one already committed is a no-op inside the - * transaction, not a rollback trigger; retrying an entire successful batch - * therefore commits nothing the second time and returns no ids. - * - * Returns one result per item, IN ITEM ORDER — `{ messageKey, id }`, `id` - * null exactly when that item's messageKey deduped against an existing row - * (matching `deliverInboxItems`'s `DeliveredInboxItem` shape). Bus events - * publish only after commit, one per written row — never for a deduped item, - * and never before the transaction is durable. - */ -export async function writeMailboxMessages( - db: MailboxDb, - items: WriteMailboxMessagesItem[], - opts?: WriteMailboxMessagesOpts, -): Promise { - type Prepared = { - scope: MailboxScopeIds; - writeArgs: WriteMailboxMessageArgs; - raw: Uint8Array; - messageId: string; - messageKey: string; - }; - const prepared: Prepared[] = []; - for (const { scope, args } of items) { - assertMailboxScope(scope); - const writeArgs: WriteMailboxMessageArgs = { - ...normalizeThreadingArgs(args), - tenantId: scope.tenantId, - principalId: scope.principalId, - }; - assertMailboxStringFieldsFit(writeArgs); - const { raw, messageId } = encodeMailboxFrame(writeArgs); - assertMailboxFrameBytes(raw); - // Computed here, once, rather than left to `insertMailboxMessage`'s own - // fallback — this is the value returned to the caller for EVERY item, - // including one that dedupes and never reaches an insert. - const messageKey = - writeArgs.messageKey ?? - mailboxKey.transport(messageId, scope.principalId, writeArgs.direction ?? "inbound"); - writeArgs.messageKey = messageKey; - prepared.push({ scope, writeArgs, raw, messageId, messageKey }); - } - - type Inserted = { id: string; scope: MailboxScopeIds }; - const { results, inserted } = await db.transaction(async (tx) => { - const results: DeliveredInboxItem[] = []; - const inserted: Inserted[] = []; - for (const { scope, writeArgs, raw, messageId, messageKey } of prepared) { - const written = await insertMailboxMessage(tx, writeArgs, raw, messageId); - if (written === null) { - results.push({ messageKey, id: null }); - continue; - } - results.push({ messageKey, id: written.id }); - inserted.push({ id: written.id, scope }); - } - return { results, inserted }; - }); - - // Post-commit only: live signals for newly inserted ids, one per row. - if (opts?.bus) { - for (const { id, scope } of inserted) { - publishMailboxEvent(opts.bus, scope, id, logger, "create"); - } - } - return results; } From 25dee0557ab3f5c6f579c25c1ebc82d449ce1ffd Mon Sep 17 00:00:00 2001 From: Sawyer Date: Thu, 17 Sep 2026 13:07:39 -0700 Subject: [PATCH 05/17] Drop the pre-native columns --- src/migrations.test.ts | 230 +++++++--------------------------- src/migrations.ts | 36 ++++++ src/schema-check.test.ts | 106 ++-------------- src/schema-check.ts | 4 +- src/schema-ddl-parity.test.ts | 49 ++------ src/schema.ts | 102 --------------- src/test-helpers.ts | 6 +- 7 files changed, 109 insertions(+), 424 deletions(-) diff --git a/src/migrations.test.ts b/src/migrations.test.ts index b98cb35..a199a75 100644 --- a/src/migrations.test.ts +++ b/src/migrations.test.ts @@ -94,23 +94,13 @@ describe("runMailboxMigrations", () => { "uid", ]); - const stateColumns = await db.execute<{ column_name: string }>( - sql`SELECT column_name FROM information_schema.columns - WHERE table_schema = 'mailbox' AND table_name = 'mailbox' - ORDER BY column_name`, + // The pre-native management layer ("mailbox"."mailbox") was dropped in + // 0005 — the mail plane is now the only table this schema owns. + const stateTable = await db.execute<{ exists: boolean }>( + sql`SELECT EXISTS (SELECT 1 FROM information_schema.tables + WHERE table_schema = 'mailbox' AND table_name = 'mailbox') AS exists`, ); - expect(stateColumns.map((c) => c.column_name)).toEqual([ - "archived_at", - "assignee", - "classification", - "id", - "principal_id", - "priority", - "read_at", - "status", - "tenant_id", - "trashed_at", - ]); + expect(stateTable[0]?.exists).toBe(false); const mailIndexes = await db.execute<{ indexname: string }>( sql`SELECT indexname FROM pg_indexes @@ -131,24 +121,6 @@ describe("runMailboxMigrations", () => { "principal_mail_tenant_id_principal_id_message_key_idx", ]); - const stateIndexes = await db.execute<{ indexname: string }>( - sql`SELECT indexname FROM pg_indexes - WHERE schemaname = 'mailbox' AND tablename = 'mailbox' - ORDER BY indexname`, - ); - // The management layer carries the triage filters and one partial index - // per view predicate — eager rows are what make the unread one possible. - expect(stateIndexes.map((i) => i.indexname)).toEqual([ - "mailbox_pkey", - "mailbox_tenant_id_principal_id_archived_at_idx", - "mailbox_tenant_id_principal_id_assignee_idx", - "mailbox_tenant_id_principal_id_classification_idx", - "mailbox_tenant_id_principal_id_priority_idx", - "mailbox_tenant_id_principal_id_status_idx", - "mailbox_tenant_id_principal_id_trashed_at_idx", - "mailbox_tenant_id_principal_id_unread_idx", - ]); - // The dedupe index is partial: NULL-key external mail is unconstrained. const partial = await db.execute<{ indexdef: string }>( sql`SELECT indexdef FROM pg_indexes WHERE schemaname = 'mailbox' @@ -158,6 +130,22 @@ describe("runMailboxMigrations", () => { }); }); + test("0005 leaves uid and modseq NOT NULL: every write path is the native store now", async () => { + await fromEmpty(async ({ db }) => { + await runMailboxMigrations(db); + const rows = await db.execute<{ column_name: string; is_nullable: string }>( + sql`SELECT column_name, is_nullable FROM information_schema.columns + WHERE table_schema = 'mailbox' AND table_name = 'principal_mail' + AND column_name IN ('uid', 'modseq') + ORDER BY column_name`, + ); + expect(rows.map((r) => [r.column_name, r.is_nullable])).toEqual([ + ["modseq", "NO"], + ["uid", "NO"], + ]); + }); + }); + test("the keyset index matches the list query's ORDER BY exactly", async () => { await fromEmpty(async ({ db }) => { await runMailboxMigrations(db); @@ -173,109 +161,6 @@ describe("runMailboxMigrations", () => { }); }); - test("the partial indexes match their view predicates", async () => { - await fromEmpty(async ({ db }) => { - await runMailboxMigrations(db); - const rows = await db.execute<{ - indexname: string; - indexdef: string; - }>( - sql`SELECT indexname, indexdef FROM pg_indexes - WHERE schemaname = 'mailbox' - AND indexname LIKE 'mailbox_tenant_id_principal_id_%_idx' - AND indexdef LIKE '%WHERE%' - ORDER BY indexname`, - ); - const byName = new Map(rows.map((r) => [r.indexname, r.indexdef])); - expect([...byName.keys()]).toEqual([ - "mailbox_tenant_id_principal_id_archived_at_idx", - "mailbox_tenant_id_principal_id_trashed_at_idx", - "mailbox_tenant_id_principal_id_unread_idx", - ]); - for (const def of byName.values()) { - expect(def).toContain("(tenant_id, principal_id)"); - } - expect( - byName.get("mailbox_tenant_id_principal_id_archived_at_idx"), - ).toContain("archived_at IS NOT NULL) AND (trashed_at IS NULL"); - expect( - byName.get("mailbox_tenant_id_principal_id_trashed_at_idx"), - ).toContain("trashed_at IS NOT NULL"); - expect( - byName.get("mailbox_tenant_id_principal_id_unread_idx"), - ).toContain( - "read_at IS NULL) AND (archived_at IS NULL) AND (trashed_at IS NULL", - ); - }); - }); - - // The split gives the archived/trash views two possible plans, and which one - // wins is a question about the DATA, not about the schema: drive from the - // mail keyset (ordering free, but scan until 51 archived messages turn up) or - // drive from the mailbox partial index (enumerate the whole view, then sort - // it). Both cases below are seeded and asserted rather than assumed. - async function seedViewPlan( - db: ReturnType["db"], - archivedEvery: number, - ) { - await seedScope(db, "acme", "user-1"); - await db.execute(sql` - INSERT INTO "mailbox"."principal_mail" - ("tenant_id","principal_id","address","direction","raw","created_at") - SELECT 'acme','user-1','user-1@acme.example','inbound','\\x00'::bytea, - now() - (g || ' seconds')::interval - FROM generate_series(1, 20000) g - `); - // A read mailbox: every message has been opened, so every one has a - // management row, and only every `archivedEvery`-th is archived. That is - // what makes the archived view SPARSE within a large `mailbox` rather than - // simply small — the case where a partial index earns its keep. - await db.execute(sql` - INSERT INTO "mailbox"."mailbox" ("id","tenant_id","principal_id","read_at","archived_at") - SELECT "id", "tenant_id", "principal_id", now(), - CASE WHEN m.n % ${sql.raw(String(archivedEvery))} = 0 THEN now() END - FROM (SELECT *, row_number() OVER (ORDER BY "created_at") AS n - FROM "mailbox"."principal_mail") m - `); - await db.execute(sql`ANALYZE "mailbox"."principal_mail"`); - await db.execute(sql`ANALYZE "mailbox"."mailbox"`); - const plan = await db.execute<{ "QUERY PLAN": string }>(sql` - EXPLAIN (ANALYZE) - SELECT pm."id" FROM "mailbox"."principal_mail" pm - LEFT JOIN "mailbox"."mailbox" mb ON mb."id" = pm."id" - WHERE pm."tenant_id" = 'acme' AND pm."principal_id" = 'user-1' - AND pm."direction" = 'inbound' - AND mb."archived_at" IS NOT NULL AND mb."trashed_at" IS NULL - ORDER BY pm."created_at" DESC, pm."id" DESC - LIMIT 51 - `); - return plan.map((r) => r["QUERY PLAN"]).join("\n"); - } - - test("a dense archived view pages off the mail keyset with no sort", async () => { - await fromEmpty(async ({ db }) => { - await runMailboxMigrations(db); - // One in twenty archived: a page of 51 is reachable within ~1000 mail - // rows, so the planner takes the ordering for free rather than sorting. - const text = await seedViewPlan(db, 20); - expect(text).toContain( - "principal_mail_tenant_id_principal_id_created_at_id_idx", - ); - expect(text).not.toContain("Sort Method"); - }); - }); - - test("a sparse archived view pages off the mailbox partial index", async () => { - await fromEmpty(async ({ db }) => { - await runMailboxMigrations(db); - // One in four thousand archived: walking the mail keyset would scan the - // principal's whole history to fill one page, so the partial index — - // which enumerates the entire view directly — wins even with the sort. - const text = await seedViewPlan(db, 4000); - expect(text).toContain("mailbox_tenant_id_principal_id_archived_at_idx"); - }); - }); - test("0002 backfills the threading headers from legacy rows' raw", async () => { // The state every already-deployed host is in at upgrade: rows written // before the cached columns existed, so `raw` carries the headers and the @@ -313,16 +198,19 @@ describe("runMailboxMigrations", () => { 0xff, 0xfe, ]); - for (const [key, raw] of [ + for (const [i, [key, raw]] of [ ["legacy-threaded", threaded], ["legacy-headerless", headerless], ["legacy-invalid-utf8", invalidUtf8], - ] as const) { + ].entries() as IterableIterator<[number, readonly [string, Uint8Array]]>) { + // uid/modseq are NOT NULL as of 0005 — supplied explicitly since these + // rows simulate pre-native legacy inserts that predate the native + // store's own uid assignment. await db.execute(sql` INSERT INTO "mailbox"."principal_mail" - ("tenant_id","principal_id","address","direction","raw","message_key") + ("tenant_id","principal_id","address","direction","raw","message_key","uid","modseq") VALUES ('acme','user-1','user-1@acme.example','inbound', - ${Buffer.from(raw)}, ${key}) + ${Buffer.from(raw)}, ${key}, ${i + 1}, ${i + 1}) `); } @@ -375,15 +263,15 @@ describe("runMailboxMigrations", () => { 0x00, 0x41, ]); - for (const [key, raw] of [ + for (const [i, [key, raw]] of [ ["nul-ok", ok], ["nul-body", nulBody], - ] as const) { + ].entries() as IterableIterator<[number, readonly [string, Uint8Array]]>) { await db.execute(sql` INSERT INTO "mailbox"."principal_mail" - ("tenant_id","principal_id","address","direction","raw","message_key") + ("tenant_id","principal_id","address","direction","raw","message_key","uid","modseq") VALUES ('acme','user-1','user-1@acme.example','inbound', - ${Buffer.from(raw)}, ${key}) + ${Buffer.from(raw)}, ${key}, ${i + 1}, ${i + 1}) `); } @@ -397,6 +285,7 @@ describe("runMailboxMigrations", () => { "0002_mail_threading_headers", "0003_mail_references", "0004_native_mailbox_store", + "0005_drop_pre_native_columns", ]); const rows = await db.execute<{ @@ -452,16 +341,16 @@ describe("runMailboxMigrations", () => { 0x00, 0x41, ]); - for (const [key, raw] of [ + for (const [i, [key, raw]] of [ ["refs-folded", folded], ["refs-none", none], ["refs-decoy", decoy], - ] as const) { + ].entries() as IterableIterator<[number, readonly [string, Uint8Array]]>) { await db.execute(sql` INSERT INTO "mailbox"."principal_mail" - ("tenant_id","principal_id","address","direction","raw","message_key") + ("tenant_id","principal_id","address","direction","raw","message_key","uid","modseq") VALUES ('acme','user-1','user-1@acme.example','inbound', - ${Buffer.from(raw)}, ${key}) + ${Buffer.from(raw)}, ${key}, ${i + 1}, ${i + 1}) `); } @@ -519,12 +408,12 @@ describe("runMailboxMigrations", () => { "From: a@b.c\nMessage-ID: \nIn-Reply-To: \n\nMessage-ID: \nBody\n", ], ] as const; - for (const [key, text] of cases) { + for (const [i, [key, text]] of cases.entries()) { await db.execute(sql` INSERT INTO "mailbox"."principal_mail" - ("tenant_id","principal_id","address","direction","raw","message_key") + ("tenant_id","principal_id","address","direction","raw","message_key","uid","modseq") VALUES ('acme','user-1','user-1@acme.example','inbound', - ${Buffer.from(enc.encode(text))}, ${key}) + ${Buffer.from(enc.encode(text))}, ${key}, ${i + 1}, ${i + 1}) `); } await runMailboxMigrations(db); @@ -561,6 +450,7 @@ describe("runMailboxMigrations", () => { ["0002_mail_threading_headers", "1"], ["0003_mail_references", "1"], ["0004_native_mailbox_store", "1"], + ["0005_drop_pre_native_columns", "1"], ]); }); }); @@ -670,39 +560,6 @@ describe("runMailboxMigrations", () => { }); }); - test("mailbox FKs: its own mail plane plus the same control-plane pair", async () => { - // The key to `principal_mail` is what makes a message and its triage state - // one lifecycle; the scope FKs mirror the mail plane's for the same reason - // they exist there. - await fromEmpty(async ({ db }) => { - await runMailboxMigrations(db); - const rows = await db.execute<{ - constraint_name: string; - table_name: string; - delete_rule: string; - }>(sql` - SELECT tc.constraint_name, ccu.table_name, rc.delete_rule - FROM information_schema.table_constraints tc - JOIN information_schema.referential_constraints rc - ON rc.constraint_name = tc.constraint_name - AND rc.constraint_schema = tc.table_schema - JOIN information_schema.constraint_column_usage ccu - ON ccu.constraint_name = tc.constraint_name - AND ccu.constraint_schema = tc.table_schema - WHERE tc.table_schema = 'mailbox' AND tc.table_name = 'mailbox' - AND tc.constraint_type = 'FOREIGN KEY' - ORDER BY tc.constraint_name - `); - expect( - rows.map((r) => [r.constraint_name, r.table_name, r.delete_rule]), - ).toEqual([ - ["mailbox_id_fkey", "principal_mail", "CASCADE"], - ["mailbox_principal_id_principal_id_fk", "principal", "CASCADE"], - ["mailbox_tenant_id_tenant_id_fk", "tenant", "CASCADE"], - ]); - }); - }); - test("builds into the mailbox schema regardless of the session search_path", async () => { // The DDL is schema-qualified end to end, so a host whose connection // selects some other search_path still gets (and finds) this package's @@ -718,12 +575,10 @@ describe("runMailboxMigrations", () => { await runMailboxMigrations(drizzle(client)); const found = await admin.unsafe( `SELECT to_regclass('mailbox.principal_mail') AS t, - to_regclass('mailbox.mailbox') AS m, to_regclass('mailbox.corbits_mailbox_migrations') AS l, to_regclass('mbx_elsewhere.principal_mail') AS stray`, ); expect(found[0]!.t).not.toBeNull(); - expect(found[0]!.m).not.toBeNull(); expect(found[0]!.l).not.toBeNull(); expect(found[0]!.stray).toBeNull(); } finally { @@ -769,6 +624,7 @@ describe("runMailboxMigrations under concurrent cold start", () => { "0002_mail_threading_headers", "0003_mail_references", "0004_native_mailbox_store", + "0005_drop_pre_native_columns", ]); }); diff --git a/src/migrations.ts b/src/migrations.ts index 4e085d4..3d2176e 100644 --- a/src/migrations.ts +++ b/src/migrations.ts @@ -429,6 +429,42 @@ export const MIGRATIONS: Migration[] = [ ON CONFLICT ("tenant_id", "principal_id", "folder") DO NOTHING`, ], }, + { + // This library exists ONLY to give a human principal a native + // `MailboxStore` — every reader and writer now goes through + // `NativeMailboxStore`, and the pre-native management layer + // (`"mailbox"."mailbox"`: read_at/archived_at/trashed_at, + // priority/classification/status/assignee) has no reader left. Dropping + // the table drops those columns and their indexes with it, in one + // statement, rather than an ALTER per column. + // + // uid/modseq become NOT NULL: every remaining write path is + // `NativeMailboxStore.append`, which always sets both. The backfill below + // is defense in depth for a row inserted by the pre-cutover write paths + // between `0004` running and this migration — same per-(tenant, + // principal, folder) row_number() `0004` used, guarded by "uid" IS NULL + // so an already-backfilled row is left alone. + id: "0005_drop_pre_native_columns", + statements: [ + sql`UPDATE "mailbox"."principal_mail" AS pm + SET "uid" = seq."rn", "modseq" = seq."rn" + FROM ( + SELECT "id", + row_number() OVER ( + PARTITION BY "tenant_id", "principal_id", "folder" + ORDER BY "created_at", "id" + ) AS "rn" + FROM "mailbox"."principal_mail" + WHERE "uid" IS NULL + ) AS seq + WHERE pm."id" = seq."id"`, + sql`ALTER TABLE "mailbox"."principal_mail" + ALTER COLUMN "uid" SET NOT NULL`, + sql`ALTER TABLE "mailbox"."principal_mail" + ALTER COLUMN "modseq" SET NOT NULL`, + sql`DROP TABLE IF EXISTS "mailbox"."mailbox"`, + ], + }, ]; const DIALECT = new PgDialect(); diff --git a/src/schema-check.test.ts b/src/schema-check.test.ts index 2a12c29..374fe93 100644 --- a/src/schema-check.test.ts +++ b/src/schema-check.test.ts @@ -88,29 +88,20 @@ async function bootFailure(promise: Promise): Promise { } describe("expectedColumnTypes", () => { - test("is derived from the drizzle tables, covering both of them", () => { + test("is derived from the drizzle tables: the mail plane alone, since 0005 dropped the management table", () => { const expected = expectedColumnTypes(); const tables = new Set(expected.map((e) => e.table)); - expect(tables).toEqual(new Set(["principal_mail", "mailbox"])); + expect(tables).toEqual(new Set(["principal_mail"])); }); test("expects zoneless timestamps and text ids on every relevant column", () => { const byKey = new Map( expectedColumnTypes().map((e) => [`${e.table}.${e.column}`, e.dataType]), ); - // The two conventions this package just adopted, asserted from the - // derivation rather than from the DDL — so reverting either the schema or - // the migration on its own is caught here as well as by the parity suite. - for (const key of [ - "principal_mail.created_at", - "mailbox.read_at", - "mailbox.archived_at", - "mailbox.trashed_at", - ]) { - expect(byKey.get(key)).toBe("timestamp without time zone"); - } + expect(byKey.get("principal_mail.created_at")).toBe( + "timestamp without time zone", + ); expect(byKey.get("principal_mail.id")).toBe("text"); - expect(byKey.get("mailbox.id")).toBe("text"); expect(byKey.get("principal_mail.raw")).toBe("bytea"); expect(byKey.get("principal_mail.refs")).toBe("jsonb"); }); @@ -192,84 +183,21 @@ describe("boot against a host table this package did not create", () => { expect(await ledgerRows(schema)).toBe(0); }); - test("a missing INDEXED column is rejected earlier still, by the DDL itself", async () => { - // Not every missing column reaches the schema check: the migration's own - // `CREATE INDEX IF NOT EXISTS` runs first and Postgres answers 42703 for a - // column that is not there. That is a perfectly good rejection — it is - // loud, it is inside the same transaction, and it leaves no ledger row — - // but it is NOT a `SchemaTypeMismatchError`, and a host catching only that - // type would miss it. Pinned here so the difference is documented rather - // than discovered. - const schema = "mailbox"; - await inFreshSchema(schema, async ({ db }) => { - await admin.unsafe(` - CREATE TABLE "${schema}"."mailbox" ( - "id" text PRIMARY KEY, - "tenant_id" text NOT NULL, - "principal_id" text NOT NULL, - "read_at" timestamp, - "archived_at" timestamp, - "trashed_at" timestamp, - "priority" text, - "classification" text - )`); - const failure = await bootFailure(runMailboxMigrations(db)); - expect(failure).toBeInstanceOf(Error); - expect(failure).not.toBeInstanceOf(SchemaTypeMismatchError); - expect(failure.message).toContain( - "mailbox_tenant_id_principal_id_status_idx", - ); - }); - expect(await ledgerRows(schema)).toBe(0); - }); - - test("names every mismatch at once rather than only the first", async () => { - const schema = "mailbox"; - await inFreshSchema(schema, async ({ db }) => { - await admin.unsafe(` - CREATE TABLE "${schema}"."mailbox" ( - "id" uuid PRIMARY KEY, - "tenant_id" text NOT NULL, - "principal_id" text NOT NULL, - "read_at" timestamptz, - "archived_at" timestamp, - "trashed_at" timestamp, - "priority" text, - "classification" text, - "status" text, - "assignee" text - )`); - const failure = (await bootFailure( - runMailboxMigrations(db), - )) as SchemaTypeMismatchError; - expect(failure.mismatches).toEqual([ - "mailbox.id is uuid, expected text", - "mailbox.read_at is timestamp with time zone, " + - "expected timestamp without time zone", - ]); - // The message is what a host operator actually sees, so it has to say - // what to do about it, not just what is wrong. - expect(failure.message).toContain("CREATE TABLE IF NOT EXISTS"); - expect(failure.message).toContain("Rename or move the conflicting table"); - }); - expect(await ledgerRows(schema)).toBe(0); - }); - test("a rejected boot leaves the NEXT boot still rejecting", async () => { const schema = "mailbox"; await inFreshSchema(schema, async ({ db }) => { await admin.unsafe(` - CREATE TABLE "${schema}"."mailbox" ( + CREATE TABLE "${schema}"."principal_mail" ( "id" text PRIMARY KEY, "tenant_id" text NOT NULL, "principal_id" text NOT NULL, - "read_at" timestamptz, - "archived_at" timestamp, - "trashed_at" timestamp, - "priority" text, - "classification" text, - "status" text, - "assignee" text + "address" text NOT NULL, + "direction" text NOT NULL, + "raw" bytea NOT NULL, + "from_address" text, + "message_key" text, + "refs" jsonb, + "created_at" timestamptz NOT NULL DEFAULT now() )`); await expect(runMailboxMigrations(db)).rejects.toThrow( SchemaTypeMismatchError, @@ -281,14 +209,6 @@ describe("boot against a host table this package did not create", () => { SchemaTypeMismatchError, ); expect(await ledgerRows(schema)).toBe(0); - // And the sound table was rolled back too — a partially-built schema - // would be its own quiet trap. - const rows = await db.execute<{ n: number }>( - sql`SELECT count(*)::int AS n FROM information_schema.tables - WHERE table_schema = 'mailbox' - AND table_name = 'principal_mail'`, - ); - expect(rows[0]!.n).toBe(0); }); }); }); diff --git a/src/schema-check.ts b/src/schema-check.ts index 62d430b..9e6a1f1 100644 --- a/src/schema-check.ts +++ b/src/schema-check.ts @@ -1,7 +1,7 @@ import { getTableColumns, getTableName, sql } from "drizzle-orm"; import type { PgTable } from "drizzle-orm/pg-core"; import type { MailboxDb } from "./db.js"; -import { mailbox, principalMail } from "./schema.js"; +import { principalMail } from "./schema.js"; /** * Every DDL statement in `MIGRATIONS` is `CREATE TABLE IF NOT EXISTS`, which is @@ -58,7 +58,7 @@ export class SchemaTypeMismatchError extends Error { } } -const GUARDED_TABLES: readonly PgTable[] = [principalMail, mailbox]; +const GUARDED_TABLES: readonly PgTable[] = [principalMail]; type ExpectedColumn = { table: string; column: string; dataType: string }; diff --git a/src/schema-ddl-parity.test.ts b/src/schema-ddl-parity.test.ts index f2fddca..1351ae5 100644 --- a/src/schema-ddl-parity.test.ts +++ b/src/schema-ddl-parity.test.ts @@ -8,7 +8,7 @@ import postgres from "postgres"; import { drizzle } from "drizzle-orm/postgres-js"; import { sql } from "drizzle-orm"; import { getTableConfig } from "drizzle-orm/pg-core"; -import { mailbox, principalMail } from "./schema.js"; +import { principalMail } from "./schema.js"; import { runMailboxMigrations } from "./migrations.js"; import { createHostControlPlane, TEST_DATABASE_URL } from "./test-helpers.js"; @@ -106,14 +106,10 @@ async function liveIndexes(table: string): Promise { return rows.map((row) => canonicalizeIndexDef(row.indexdef)).sort(); } -// Both tables, not just the mail plane: `mailbox` is a public export too, and -// the divergence this suite exists to catch — a declared index the migrations -// never create — is exactly as invisible on the newer table as on the older -// one. -const TABLES = [ - { name: "principal_mail", declared: principalMail }, - { name: "mailbox", declared: mailbox }, -] as const; +// `mailbox.mailbox` (the pre-native management layer) was dropped in +// `0005_drop_pre_native_columns` — the mail plane is the only table left to +// hold to this parity. +const TABLES = [{ name: "principal_mail", declared: principalMail }] as const; describe("schema.ts vs. the DDL runMailboxMigrations actually creates", () => { for (const { name, declared } of TABLES) { @@ -122,38 +118,19 @@ describe("schema.ts vs. the DDL runMailboxMigrations actually creates", () => { }); } - it("indexes the triage columns per tenant_id+principal_id, not as bare single columns", async () => { - const live = await liveIndexes("mailbox"); - for (const column of ["priority", "classification", "status", "assignee"]) { - expect(live).toContain( - `mailbox_tenant_id_principal_id_${column}_idx USING btree(tenant_id asc, principal_id asc, ${column} asc)`, - ); - } - // Bare single-column forms must stay absent: an index on a low-cardinality - // column is not an access path the planner would choose. - expect( - live.filter((descriptor) => - /^mailbox_(priority|classification|status|assignee)_idx USING /.test( - descriptor, - ), - ), - ).toEqual([]); - }); - it("keeps the keyset access path on the mail plane, where the split left it", async () => { expect(await liveIndexes("principal_mail")).toContain( "principal_mail_tenant_id_principal_id_created_at_id_idx USING btree(tenant_id asc, principal_id asc, created_at desc, id desc)", ); }); - it("carries one partial index per view predicate on mailbox", async () => { - // Including unread: every message has an eagerly-created management row, - // so the unread count is an index-only scan on this partial index. - const live = await liveIndexes("mailbox"); - for (const name of ["archived_at", "trashed_at", "unread"]) { - expect(live).toContain( - `mailbox_tenant_id_principal_id_${name}_idx USING btree(tenant_id asc, principal_id asc) [partial]`, - ); - } + it("no longer has a live mailbox.mailbox table", async () => { + const rows = await drizzle(client).execute<{ exists: boolean }>(sql` + SELECT EXISTS ( + SELECT 1 FROM information_schema.tables + WHERE table_schema = ${SCHEMA} AND table_name = 'mailbox' + ) AS "exists" + `); + expect(rows[0]!.exists).toBe(false); }); }); diff --git a/src/schema.ts b/src/schema.ts index 99d6d5b..228dae4 100644 --- a/src/schema.ts +++ b/src/schema.ts @@ -186,107 +186,5 @@ export const principalMail = mailboxPgSchema.table( ], ); -// THE MANAGEMENT LAYER. Keyed by mail id, one row per message, created -// EAGERLY with the message in the same transaction: all-NULL means -// delivered-and-untouched. Guaranteed presence is what lets every mutation be -// a plain UPDATE and the unread count an index-only scan on this table. -// -// `tenant_id`/`principal_id` are carried alongside the id so every index and -// every purge here is scoped the same way the mail plane's are, without a join. -// They cannot drift from the mail row's: `principal_mail` is immutable. -// -// `priority` and `status` are PLAIN TEXT with no enum. Their vocabulary is the -// host's, supplied through `MountMailboxOpts` — see `vocabulary.ts`. Shipping a -// closed list here would have made one product's taxonomy every adopter's. -export const mailbox = mailboxPgSchema.table( - "mailbox", - { - // The same surrogate key as the mail row's — always the mail row's id, - // never generated here. The FK (and its ON DELETE CASCADE) lives in the - // migration DDL with the others. - id: text("id").primaryKey(), - tenantId: text("tenant_id").notNull(), - principalId: text("principal_id").notNull(), - // Bare `timestamp`, for the reason spelled out on `principal_mail.created_at`. - readAt: timestamp("read_at"), - archivedAt: timestamp("archived_at"), - trashedAt: timestamp("trashed_at"), - priority: text("priority"), - classification: text("classification"), - status: text("status"), - /** - * Delegation as an optional ref rather than a forwarded copy: the principal - * this item was handed to. Deliberately no FK: an assignment must survive - * the assignee's principal being offboarded — the item still belongs to - * THIS mailbox, and losing the row with someone else's departure would be - * wrong. - */ - assignee: text("assignee"), - }, - (t) => [ - // The triage columns are indexed PER TENANT+PRINCIPAL, so each is a - // composite leading with the scope every mailbox query already filters on — - // never the bare single-column form, which a planner would not choose for a - // low-cardinality column anyway. - index("mailbox_tenant_id_principal_id_priority_idx").on( - t.tenantId, - t.principalId, - t.priority, - ), - index("mailbox_tenant_id_principal_id_classification_idx").on( - t.tenantId, - t.principalId, - t.classification, - ), - index("mailbox_tenant_id_principal_id_status_idx").on( - t.tenantId, - t.principalId, - t.status, - ), - // "What have I delegated to X" is always scoped to one mailbox, so the - // scope leads here too. - index("mailbox_tenant_id_principal_id_assignee_idx").on( - t.tenantId, - t.principalId, - t.assignee, - ), - // One partial index per view predicate. Eager row creation is what makes - // the unread one possible at all: with every message carrying a row, the - // unread count is an index-only scan here instead of a LEFT JOIN over the - // principal's whole history. - index("mailbox_tenant_id_principal_id_unread_idx") - .on(t.tenantId, t.principalId) - .where( - sql`${t.readAt} IS NULL AND ${t.archivedAt} IS NULL AND ${t.trashedAt} IS NULL`, - ), - index("mailbox_tenant_id_principal_id_archived_at_idx") - .on(t.tenantId, t.principalId) - .where(sql`${t.archivedAt} IS NOT NULL AND ${t.trashedAt} IS NULL`), - index("mailbox_tenant_id_principal_id_trashed_at_idx") - .on(t.tenantId, t.principalId) - .where(sql`${t.trashedAt} IS NOT NULL`), - ], -); - export type PrincipalMailRow = typeof principalMail.$inferSelect; export type PrincipalMailInsert = typeof principalMail.$inferInsert; -export type MailboxRow = typeof mailbox.$inferSelect; -export type MailboxInsert = typeof mailbox.$inferInsert; - -/** - * The management columns as the read path projects them alongside a mail row. - * Every one is nullable twice over: nullable in the table, and null again for - * every message that has no `mailbox` row at all. - */ -export type MailboxStateColumns = { - readAt: Date | null; - archivedAt: Date | null; - trashedAt: Date | null; - priority: string | null; - classification: string | null; - status: string | null; - assignee: string | null; -}; - -/** One message with its management state, as the LEFT JOIN produces it. */ -export type MailboxJoinedRow = PrincipalMailRow & MailboxStateColumns; diff --git a/src/test-helpers.ts b/src/test-helpers.ts index 714ec02..33265d9 100644 --- a/src/test-helpers.ts +++ b/src/test-helpers.ts @@ -63,11 +63,9 @@ export async function withTestDb(): Promise { return db; })(); const db = await shared; - // `mailbox` references `principal_mail`, so both are truncated in one - // statement rather than leaving the management layer behind. The control - // plane is reset too, so no test inherits another's scopes. + // The control plane is reset too, so no test inherits another's scopes. await db.execute( - sql`TRUNCATE TABLE "mailbox"."principal_mail", "mailbox"."mailbox"`, + sql`TRUNCATE TABLE "mailbox"."principal_mail", "mailbox"."mailbox_state"`, ); await db.execute(sql`TRUNCATE TABLE "tenant", "principal" CASCADE`); return db; From 9f3234689781a00c33c4df6425c874b8eb8d6346 Mon Sep 17 00:00:00 2001 From: Sawyer Date: Thu, 17 Sep 2026 13:09:03 -0700 Subject: [PATCH 06/17] Update docs: one-job README, routes, and a major version bump The library now does exactly one thing: a native Interchange mailbox for human principals. README documents the surviving routes and write paths; package.json bumps to 1.0.0 for the breaking API cut. --- README.md | 145 +++++++++++++++++++++++++-------------------------- package.json | 4 +- 2 files changed, 72 insertions(+), 77 deletions(-) diff --git a/README.md b/README.md index 95866d4..99e235c 100644 --- a/README.md +++ b/README.md @@ -1,15 +1,74 @@ # corbits-mailbox -**[`@corbits/mailbox`](./package.json)** — a universal, principal-keyed inbox, -mountable onto a Hono host backed by an Interchange-shaped Postgres. Its tables -live in a dedicated `mailbox` schema in the host's database, foreign-keyed to -the host's `tenant` and `principal` tables. Backend only; this package ships no -UI. +**[`@corbits/mailbox`](./package.json)** has one job: give a human principal +a native Interchange mailbox — a real `@intx/mailbox` `MailboxStore` backed by +Postgres, plus the thin HTTP routes a host's UI needs to list, read, and file +it. Backend only; this package ships no UI. Everything the earlier, +pre-native version of this package did — triage (priority/classification/ +status/assignee), delegation, host-defined vocabularies, its own +threading/search, `/me/threads*` — is gone. The vendored `@intx/mailbox` +`executeSearch`/`executeThread` are the search and thread primitives now, run +directly over the native store. Requires `@intx` 0.2.2 or newer. See [ARCHITECTURE.md](./ARCHITECTURE.md) for the data model. +## Mount + +```ts +import { mountMailbox, createInMemoryMailboxEventBus } from "@corbits/mailbox"; + +mountMailbox(app, { + db, + bus: createInMemoryMailboxEventBus(), + resolvePrincipal: (ctx) => resolveCallerFromRequest(ctx), +}); +``` + +## Routes + +All under `/me/inbox`, scoped to the principal `resolvePrincipal` resolves for +the request. With no resolvable principal, list returns an empty page (200); +every other route returns 403. + +| | | +| --- | --- | +| `GET /me/inbox` | Newest first, keyset-paginated by uid. `?folder=` (`INBOX` default, `Archive`, or `Trash`), `?limit=`, `?cursor=`. Each item carries its `uid`, `flags`, parsed `envelope`, and base64 `raw` — the vendored `executeSearch` over the folder's native store, with envelope + raw fetched per ref. | +| `POST /me/inbox/:uid/read` | `addFlags(uid, ["\Seen"])` | +| `POST /me/inbox/:uid/unread` | `removeFlags(uid, ["\Seen"])` | +| `POST /me/inbox/:uid/archive` | `moveNativeMailboxMessage` INBOX → Archive | +| `POST /me/inbox/:uid/trash` | `moveNativeMailboxMessage` INBOX → Trash | +| `POST /me/inbox/:uid/restore` | `moveNativeMailboxMessage` (`?folder=`, default Archive) → INBOX | +| `GET /me/inbox/events` | SSE stream of `mailbox` events (`create`/`mark_read`/`mark_unread`/`archive`/`trash`/`restore`) for the caller's mailbox, plus a heartbeat every 25s. | + +## Writing into a mailbox + +Every write path — host code, ingress adapters, and the transport dual-write +seam — lands through `NativeMailboxStore.append`, so uid/modseq are always +set. There is no other write path left. + +```ts +import { writeMailboxMessage, deliverInboxItems } from "@corbits/mailbox"; + +// One message, appended into the principal's INBOX. Deduped on `messageId` +// within that mailbox — a caller-supplied or minted one. +await writeMailboxMessage(db, { + tenantId, + principalId, + address: "usr_alice@acme.example", + fromAddress: "bot@acme.example", + subject: "Run finished", + body: "...", +}, bus); + +// Ingress adapters (mail connectors, webhooks): one item per external +// (source, externalId), deduped on a messageId minted from that pair. +await deliverInboxItems(db, [ + { tenantId, principalId, address, fromAddress, subject, body, source: "gmail", externalId: "msg-1" }, +], { bus, enqueue: ({ id, item }) => hostTriage(id, item) }); +``` + ## Dual-write persist ```ts @@ -18,21 +77,14 @@ import { createMailboxPersist } from "@corbits/mailbox"; const persist = createMailboxPersist(db, { upstream: hostMailTransport.persist, authorizeSender: (address) => resolveActiveInstance(address), - // Called once per frame, before the transaction — every recipient row - // gets the same refs, so a bus subscriber sees them on the `create` event. - resolveRefs: ({ decoded }) => - decoded ? [{ kind: "workbench", id: decoded.messageId ?? "" }] : undefined, + bus, }); ``` -`resolveRefs` runs after `upstream` resolves and serially with it, and its -refs are frozen at the frame's first successful insert — a retry still runs -the resolver but a different result on that later call is discarded. Return -a small set with the load-bearing ref first: the list is capped at -`MAX_MAILBOX_REFS` by truncating from the end. - -See ARCHITECTURE.md's persist section for the full contract, including -`resolveRefs`'s dual-write-failure semantics. +`upstream` throwing still attempts the mailbox append (and the upstream error +re-throws unchanged); a mailbox-append failure is logged and never rejects a +persist upstream already completed. One append per resolved recipient, into +their INBOX, deduped on the frame's Message-ID within that mailbox. ## Install @@ -48,66 +100,9 @@ bun add github:corbitsdev/corbits-mailbox | | | | --- | --- | -| `src/` | The published package. Owns `principal_mail` (the message, immutable) and `mailbox` (the management layer, created eagerly with each message). | +| `src/` | The published package. Owns `principal_mail` (the mail plane) and `mailbox_state` (per-folder IMAP counters) — the two tables `NativeMailboxStore` reads and writes. | | `examples/reference-host` | Mounts it on a real `@intx/hub-api` app against a live Postgres and asserts the acceptance scenarios end to end. | -## Write paths - -`src/write.ts` exports two batch write functions, each for a different shape -of caller: - -- **`deliverInboxItems`** — the notify-item path. One external item (an - ingress adapter: a mail connector, a webhook), fanned out to every - addressed principal, deduped on `mailboxKey.inbox(source, externalId)`. -- **`writeMailboxMessages`** — the conversation path. An arbitrary batch of - `{ scope, args }` pairs — for example a sender's own outbound copy - alongside every recipient's inbound copy of the same turn — committed in - one transaction with per-row dedupe on the `messageKey` unique index. - -Both commit every new row in the call as a single transaction (or none), and -publish bus events only after commit, one per row actually written. See -[ARCHITECTURE.md](./ARCHITECTURE.md) for the full write-path writeup, -including `writeMailboxMessage`'s caller-supplied `messageId`, `direction`, -and default `messageKey`. - -## Thread reads - -```ts -import { - readMailboxThread, - readMailboxMessageByMessageId, -} from "@corbits/mailbox"; - -// The conversation under one entity ref, oldest first, keyset-paged. -const page = await readMailboxThread( - db, - { tenantId, principalId }, - { ref: { kind: "workbench", id: "wb-1" }, limit: 50 }, -); -// page.items: { id, messageId, inReplyTo?, references, fromAddress, subject?, -// createdAt, read, archived, parentId, body } -// page.nextCursor: pass back as `cursor` for the next page. - -// One message by its Message-ID, scoped to this mailbox. -const message = await readMailboxMessageByMessageId( - db, - { tenantId, principalId }, - "", -); -``` - -`body` is the message's full text, decoded from the stored MIME frame in the -same scan — the same body `getMailboxMessage`'s detail read returns for a -single message. A frame the MIME parser rejects degrades to `""` (logged), -never a failed read. - -`parentId` is resolved by RFC 5256 References linking — `In-Reply-To` first, -then the `References` chain newest-to-oldest — across the whole ref-scoped set, -not just the current page. It is `null`, never fabricated, when the nearest -ancestor is not in this mailbox under this ref. Subjects are never used to -group. A cursor is bound to the ref that minted it; paging it into a different -ref is a `RangeError`, as is a malformed cursor or an out-of-range limit. - ## Working on it ```sh diff --git a/package.json b/package.json index 480e336..853503f 100644 --- a/package.json +++ b/package.json @@ -1,9 +1,9 @@ { "name": "@corbits/mailbox", - "version": "0.2.0", + "version": "1.0.0", "type": "module", "license": "LGPL-2.1-only", - "description": "Universal principal-keyed inbox, mountable onto any Interchange host. Requires @intx 0.2.2 or newer.", + "description": "A native Interchange mailbox for human principals, mountable onto any Interchange host. Requires @intx 0.2.2 or newer.", "keywords": [ "corbits", "interchange", From 9e2fcbca6bb26bebc6ef9809b975db1d950db80f Mon Sep 17 00:00:00 2001 From: Sawyer Date: Thu, 17 Sep 2026 13:19:52 -0700 Subject: [PATCH 07/17] Add tests for native thread routes --- src/mount-native.test.ts | 67 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 67 insertions(+) diff --git a/src/mount-native.test.ts b/src/mount-native.test.ts index 6910e47..f70ce0e 100644 --- a/src/mount-native.test.ts +++ b/src/mount-native.test.ts @@ -107,6 +107,73 @@ describe("read/unread", () => { }); }); +describe("GET /me/inbox/threads", () => { + test("groups a reply under its parent via References", async () => { + const rootId = ""; + const root = await writeMailboxMessage(db, { + ...SCOPE, + address: "p1@t1.example", + fromAddress: "a@t1.example", + subject: "Kickoff", + body: "Body", + messageId: rootId, + }); + await writeMailboxMessage(db, { + ...SCOPE, + address: "p1@t1.example", + fromAddress: "b@t1.example", + subject: "Re: Kickoff", + body: "Reply", + inReplyTo: rootId, + references: [rootId], + }); + + const app = buildApp(); + const res = await app.request("/me/inbox/threads"); + expect(res.status).toBe(200); + const body = (await res.json()) as { + threads: { + uid: number; + envelope: { subject: string }; + children: { uid: number; envelope: { subject: string } }[]; + }[]; + }; + expect(body.threads).toHaveLength(1); + const [thread] = body.threads; + expect(thread!.uid).toBe(root!.uid); + expect(thread!.envelope.subject).toBe("Kickoff"); + expect(thread!.children).toHaveLength(1); + expect(thread!.children[0]!.envelope.subject).toBe("Re: Kickoff"); + }); + + test("invalid folder is a 400", async () => { + const app = buildApp(); + const res = await app.request("/me/inbox/threads?folder=bogus"); + expect(res.status).toBe(400); + }); +}); + +describe("GET /me/inbox/threads/:rootUid", () => { + test("returns the single thread rooted at that uid", async () => { + const uid = await seedMessage("Solo"); + const app = buildApp(); + const res = await app.request(`/me/inbox/threads/${uid}`); + expect(res.status).toBe(200); + const body = (await res.json()) as { + thread: { uid: number; envelope: { subject: string }; children: unknown[] }; + }; + expect(body.thread.uid).toBe(uid); + expect(body.thread.envelope.subject).toBe("Solo"); + expect(body.thread.children).toHaveLength(0); + }); + + test("unknown rootUid is a 404", async () => { + const app = buildApp(); + const res = await app.request("/me/inbox/threads/999"); + expect(res.status).toBe(404); + }); +}); + describe("archive/trash/restore", () => { test("archive moves the message out of INBOX and into Archive", async () => { const uid = await seedMessage("To archive"); From becd8e8c673ee1b9c71353f9efa378d0aede97e6 Mon Sep 17 00:00:00 2001 From: Sawyer Date: Thu, 17 Sep 2026 13:19:52 -0700 Subject: [PATCH 08/17] Add native GET /me/inbox/threads(/:rootUid) over vendored executeThread --- src/mount.ts | 117 ++++++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 116 insertions(+), 1 deletion(-) diff --git a/src/mount.ts b/src/mount.ts index 6e41319..47c9dbf 100644 --- a/src/mount.ts +++ b/src/mount.ts @@ -2,8 +2,10 @@ import type { Context, Env, Hono } from "hono"; import { streamSSE } from "hono/streaming"; import { describeRoute } from "hono-openapi"; import { getLogger } from "@intx/log"; -import { executeSearch } from "@intx/mailbox"; +import { executeSearch, executeThread } from "@intx/mailbox"; +import type { Thread } from "@intx/types/runtime"; import type { MailboxDb } from "./db.js"; +import type { NativeMailboxStore } from "./native-store.js"; import { publishMailboxEvent, type MailboxEvent, @@ -108,6 +110,37 @@ type MailboxListItem = { // The five single-message mutations that move or flag a message. `op` is the // event op published on success. +/** + * One node of `GET /me/inbox/threads(/:rootUid)`: the vendored + * `executeThread`'s ref (recursively, as `children`) plus the same envelope + * fields `GET /me/inbox` returns for that ref, so a client can render a + * thread without an extra fetch per message. + */ +type MailboxThreadNode = { + uid: number; + flags: string[]; + envelope: MailboxListItem["envelope"]; + children: MailboxThreadNode[]; +}; + +function enrichThread(store: NativeMailboxStore, node: Thread): MailboxThreadNode { + const message = store.find(node.ref.uid); + return { + uid: node.ref.uid, + flags: message ? [...message.flags] : [], + envelope: { + messageId: message?.envelope.messageId ?? "", + from: message?.envelope.from ?? "", + to: message?.envelope.to ?? [], + subject: message?.envelope.subject ?? "", + date: new Date(message?.envelope.date ?? 0).toISOString(), + inReplyTo: message?.envelope.inReplyTo, + references: message?.envelope.references ?? [], + }, + children: node.children.map((child) => enrichThread(store, child)), + }; +} + const READ_VERBS = [ { verb: "read", op: "mark_read" as const, flags: ["\\Seen"], add: true }, { verb: "unread", op: "mark_unread" as const, flags: ["\\Seen"], add: false }, @@ -240,6 +273,88 @@ export function mountMailbox( }, ); + app.get( + "/me/inbox/threads", + describeRoute({ + tags: TAGS, + summary: "The caller's inbox as threads", + description: + "The vendored `executeThread` (REFERENCES algorithm) run over the " + + "folder's native store — roots plus children, each ref carrying the " + + "same envelope fields `GET /me/inbox` returns. With no resolvable " + + "principalId this returns an empty list, not a 403.", + parameters: [ + { + name: "folder", + in: "query", + description: "INBOX (default), Archive, or Trash.", + schema: { type: "string", enum: [...LIST_FOLDERS] }, + }, + ], + responses: { + 200: { description: "The folder's threads" }, + 400: { description: "Bad folder" }, + }, + }), + async (c) => { + const rawFolder = c.req.query("folder"); + const folder = rawFolder === undefined ? DEFAULT_FOLDER : rawFolder; + if (!isListFolder(folder)) { + return c.json({ error: "invalid folder" }, 400); + } + const resolved = await resolvePrincipal(c); + if (!resolved) return c.json({ threads: [] }); + + const store = await openNativeMailboxStore(db, { ...resolved, folder }); + const threads = await executeThread(folder, store, "references"); + return c.json({ + threads: threads.map((thread) => enrichThread(store, thread)), + }); + }, + ); + + app.get( + "/me/inbox/threads/:rootUid", + describeRoute({ + tags: TAGS, + summary: "One thread, rooted at the given uid", + parameters: [ + { ...ID_PARAM, name: "rootUid" }, + { + name: "folder", + in: "query", + description: "INBOX (default), Archive, or Trash.", + schema: { type: "string", enum: [...LIST_FOLDERS] }, + }, + ], + responses: { + 200: { description: "The thread rooted at rootUid" }, + 400: { description: "Bad rootUid or folder" }, + 403: { description: "No resolvable principalId" }, + 404: { description: "No thread rooted at that uid in this mailbox" }, + }, + }), + async (c) => { + const rootUid = parseUid(c.req.param("rootUid") ?? ""); + if (rootUid === null) { + return c.json({ error: "rootUid must be a positive integer" }, 400); + } + const rawFolder = c.req.query("folder"); + const folder = rawFolder === undefined ? DEFAULT_FOLDER : rawFolder; + if (!isListFolder(folder)) { + return c.json({ error: "invalid folder" }, 400); + } + const resolved = await resolvePrincipal(c); + if (!resolved) return c.json({ error: "No resolvable principalId" }, 403); + + const store = await openNativeMailboxStore(db, { ...resolved, folder }); + const threads = await executeThread(folder, store, "references"); + const root = threads.find((thread) => thread.ref.uid === rootUid); + if (!root) return c.json({ error: "Thread not found" }, 404); + return c.json({ thread: enrichThread(store, root) }); + }, + ); + app.get( "/me/inbox/events", describeRoute({ From 37fcf895d3f63180cf4c1a0874762d8b83a01fc5 Mon Sep 17 00:00:00 2001 From: Sawyer Date: Thu, 17 Sep 2026 13:19:52 -0700 Subject: [PATCH 09/17] Fix recipients test fixtures to the real run_ address prefix --- src/recipients.test.ts | 6 +++--- src/recipients.ts | 4 ++-- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/src/recipients.test.ts b/src/recipients.test.ts index 4e1b175..fc5a7dc 100644 --- a/src/recipients.test.ts +++ b/src/recipients.test.ts @@ -61,9 +61,9 @@ describe("resolveMailboxRecipients", () => { ]); }); - test("excludes ins_ instance addresses", () => { + test("excludes run_ instance addresses", () => { expect( - resolveMailboxRecipients(["ins_run42@acme.example"], DOMAIN), + resolveMailboxRecipients(["run_42@acme.example"], DOMAIN), ).toEqual([]); }); @@ -78,7 +78,7 @@ describe("resolveMailboxRecipients", () => { [ "usr_alice@acme.example", "bob@acme.example", - "ins_run42@acme.example", + "run_42@acme.example", "usr_mallory@evil.example", "Carol ", ], diff --git a/src/recipients.ts b/src/recipients.ts index cb253d0..64e8a7a 100644 --- a/src/recipients.ts +++ b/src/recipients.ts @@ -41,7 +41,7 @@ export type ResolvedRecipient = { address: string; principalId: string }; * * - `usr_@domain` -> `` * - `@domain` (legacy bare) -> `` - * - `ins_@domain` -> excluded; instance addresses are not mailboxes + * - `run_@domain` -> excluded; run addresses are not mailboxes * - any address whose domain is not `domain` -> skipped, since a mailbox row * is tenant-scoped and delivering another tenant's address into this * tenant would cross the isolation boundary @@ -73,7 +73,7 @@ export function resolveMailboxRecipients( // Guaranteed by `extractAddrSpec`: exactly one `@`, both sides non-empty. const at = address.indexOf("@"); if (address.slice(at + 1) !== tenantDomain) continue; - // Instance addresses (`ins_@…`) belong to a running workflow instance, + // Run addresses (`run_@…`) belong to a running workflow instance, // not to a person, and have their own delivery path — they are never // principal mailboxes. `@intx/types` owns that format; do not re-derive it. if (isRunAddress(address)) continue; From bc619005557ab3626e8bdebb948db916d1e4edb3 Mon Sep 17 00:00:00 2001 From: Sawyer Date: Thu, 17 Sep 2026 13:19:52 -0700 Subject: [PATCH 10/17] Update reference-host example and acceptance suite to the native mailbox API --- examples/reference-host/src/index.ts | 7 - .../reference-host/test/acceptance.test.ts | 408 +++++------------- 2 files changed, 96 insertions(+), 319 deletions(-) diff --git a/examples/reference-host/src/index.ts b/examples/reference-host/src/index.ts index c101311..8a434a9 100644 --- a/examples/reference-host/src/index.ts +++ b/examples/reference-host/src/index.ts @@ -150,17 +150,10 @@ export async function createReferenceHost(): Promise { // nests them in a sub-app and routes that sub-app at `/api`. No `/v1` // segment and no vendor prefix — the served paths are `/api/me/inbox*`. const api = new Hono(); - // The triage vocabulary is the HOST's, not the package's: the core ships the - // ranking mechanism and generates its OpenAPI enums from whatever this host - // declares here. A different product would list different words. mountMailbox(api, { db, bus, resolvePrincipal, - vocabulary: { - priorities: ["urgent", "high", "normal", "low"], - statuses: ["needs-action", "done"], - }, }); app.route("/api", api); diff --git a/examples/reference-host/test/acceptance.test.ts b/examples/reference-host/test/acceptance.test.ts index a9ab222..1b721cf 100644 --- a/examples/reference-host/test/acceptance.test.ts +++ b/examples/reference-host/test/acceptance.test.ts @@ -2,15 +2,10 @@ // @corbits/mailbox mounted on it and a real Postgres behind it. Nothing is // stubbed except the hub's session lookup. import { afterAll, beforeAll, describe, expect, test } from "bun:test"; -import { eq, sql } from "drizzle-orm"; +import { sql } from "drizzle-orm"; import { drizzle } from "drizzle-orm/postgres-js"; import postgres from "postgres"; -import { - deliverInboxItems, - writeMailboxMessage, - mailboxKey, - principalMail, -} from "@corbits/mailbox"; +import { deliverInboxItems, writeMailboxMessage } from "@corbits/mailbox"; import { createReferenceHost, DATABASE_URL, @@ -20,6 +15,16 @@ import { let host: ReferenceHost; const json = async (res: Response): Promise => (await res.json()) as T; +type MailboxListItem = { + uid: number; + flags: string[]; + envelope: { subject: string; from: string }; + raw: string; +}; + +const decodeRaw = (item: MailboxListItem): string => + Buffer.from(item.raw, "base64").toString("utf8"); + // Resetting state for re-runnable scenarios is THIS harness's job, not the // host's: a real host never truncates on boot. The mailbox FKs point at the // host's `tenant`/`principal` tables, so the control plane is stood up (and @@ -43,7 +48,7 @@ beforeAll(async () => { // Empty mailbox and a fresh control plane, whatever an earlier run left. await host.db.execute( - sql`TRUNCATE TABLE "mailbox"."principal_mail", "mailbox"."mailbox"`, + sql`TRUNCATE TABLE "mailbox"."principal_mail", "mailbox"."mailbox_state"`, ); await host.db.execute(sql`TRUNCATE TABLE "tenant", "principal" CASCADE`); await host.db.execute( @@ -78,82 +83,31 @@ describe("reference host", () => { expect((await host.request("/status")).status).toBe(200); }); - let messageId: string; - - test("deliver -> list -> read back", async () => { + test("deliver -> list -> read back the raw frame", async () => { const [delivered] = await deliverInboxItems(host.db, [inboundItem({})]); expect(delivered?.id).not.toBeNull(); - messageId = delivered!.id!; - const list = await json<{ messages: { id: string; subject?: string }[] }>( + const list = await json<{ messages: MailboxListItem[] }>( await host.request("/api/me/inbox"), ); - expect(list.messages).toContainEqual( - expect.objectContaining({ id: messageId, subject: "Welcome aboard" }), + const item = list.messages.find( + (m) => m.envelope.subject === "Welcome aboard", ); - - const detailRes = await host.request(`/api/me/inbox/${messageId}`); - expect(detailRes.status).toBe(200); - const detail = await json<{ body: string; to: string[] }>(detailRes); - expect(detail.body).toContain("Thanks for signing up"); - expect(detail.to).toEqual(["user-1@acme.example"]); + expect(item).toBeDefined(); + expect(decodeRaw(item!)).toContain("Thanks for signing up"); + expect(item!.envelope.from).toBe("sales@partner.example"); }); test("re-delivering the same external item is deduped", async () => { const [redelivered] = await deliverInboxItems(host.db, [inboundItem({})]); expect(redelivered?.id).toBeNull(); - expect(redelivered?.messageKey).toBe(mailboxKey.inbox("gmail", "msg-100")); - const list = await json<{ messages: unknown[] }>( + const list = await json<{ messages: MailboxListItem[] }>( await host.request("/api/me/inbox"), ); - expect(list.messages).toHaveLength(1); - }); - - let enrichedId: string; - - test("enrichment columns are stored and projected on read", async () => { - const written = await writeMailboxMessage(host.db, { - tenantId: "acme", - principalId: "user-1", - address: "user-1@acme.example", - fromAddress: "triage@acme.example", - subject: "Needs your attention", - body: "Please review this deal.", - messageKey: mailboxKey.run("deal-1"), - priority: "high", - classification: "deal-risk", - status: "needs-action", - }); - enrichedId = written!.id; - - const list = await json<{ messages: { id: string }[] }>( - await host.request("/api/me/inbox"), - ); - expect(list.messages).toContainEqual( - expect.objectContaining({ - id: enrichedId, - priority: "high", - classification: "deal-risk", - status: "needs-action", - }), - ); - }); - - test("gate: and run: keys for the same id are distinct messages", async () => { - const gate = await writeMailboxMessage(host.db, { - tenantId: "acme", - principalId: "user-1", - address: "user-1@acme.example", - fromAddress: "triage@acme.example", - subject: "Approval needed", - body: "Approve?", - messageKey: mailboxKey.gate("deal-1"), - }); - // `run:deal-1` was already written above; the gate namespace does not - // collide with it. - expect(gate?.id).toBeDefined(); - expect(gate!.id).not.toBe(enrichedId); + expect( + list.messages.filter((m) => m.envelope.subject === "Welcome aboard"), + ).toHaveLength(1); }); test("cross-principalId isolation", async () => { @@ -167,31 +121,42 @@ describe("reference host", () => { ]); host.setSession({ tenantId: "acme", principalId: "user-2" }); - const user2 = await json<{ messages: { subject?: string }[] }>( + const user2 = await json<{ messages: MailboxListItem[] }>( await host.request("/api/me/inbox"), ); - expect(user2.messages).toHaveLength(1); - expect(user2.messages[0]?.subject).toBe("For user 2 only"); + expect( + user2.messages.some((m) => m.envelope.subject === "For user 2 only"), + ).toBe(true); host.setSession({ tenantId: "acme", principalId: "user-1" }); - const user1 = await json<{ messages: { subject?: string }[] }>( + const user1 = await json<{ messages: MailboxListItem[] }>( await host.request("/api/me/inbox"), ); - expect(user1.messages.map((m) => m.subject)).not.toContain( - "For user 2 only", - ); + expect( + user1.messages.some((m) => m.envelope.subject === "For user 2 only"), + ).toBe(false); }); - test("cursors are view-scoped and validated", async () => { - const page1 = await json<{ nextCursor?: string }>( + test("cursors keyset-paginate and reject a malformed cursor", async () => { + for (let i = 0; i < 3; i++) { + await writeMailboxMessage(host.db, { + tenantId: "acme", + principalId: "user-1", + address: "user-1@acme.example", + fromAddress: "ops@acme.example", + subject: `Page seed ${i}`, + body: "Body", + }); + } + const page1 = await json<{ messages: MailboxListItem[]; nextCursor?: string }>( await host.request("/api/me/inbox?limit=1"), ); expect(page1.nextCursor).toBeDefined(); - const crossView = await host.request( - `/api/me/inbox?view=unread&cursor=${page1.nextCursor}`, + const page2 = await json<{ messages: MailboxListItem[] }>( + await host.request(`/api/me/inbox?limit=1&cursor=${page1.nextCursor}`), ); - expect(crossView.status).toBe(400); + expect(page2.messages[0]?.uid).toBeLessThan(page1.messages[0]!.uid); const malformed = await host.request( "/api/me/inbox?cursor=not-a-real-cursor", @@ -216,103 +181,74 @@ describe("reference host", () => { expect(fanOut[0]!.id).not.toBe(fanOut[1]!.id); }); - test("trash wins over archive", async () => { - await host.request(`/api/me/inbox/${enrichedId}/archive`, { - method: "POST", - }); - const trashed = await host.request(`/api/me/inbox/${enrichedId}/trash`, { - method: "POST", + test("mark-read over the mounted host flips the \\Seen flag", async () => { + const written = await writeMailboxMessage(host.db, { + tenantId: "acme", + principalId: "user-1", + address: "user-1@acme.example", + fromAddress: "ops@acme.example", + subject: "To be read", + body: "Body", }); - expect(trashed.status).toBe(200); - - const archivedView = await json<{ messages: { id: string }[] }>( - await host.request("/api/me/inbox?view=archived"), - ); - expect(archivedView.messages.map((m) => m.id)).not.toContain(enrichedId); - const reArchive = await host.request( - `/api/me/inbox/${enrichedId}/archive`, - { - method: "POST", - }, - ); - expect(reArchive.status).toBe(404); - }); - - // The scenario chain is write -> list -> read -> mark-read -> SSE. The first - // three legs are covered above; these two cover the rest. - test("mark-read over the mounted host flips read and the unread count", async () => { - const before = await json<{ unread: number }>( - await host.request("/api/me/inbox/unread-count"), - ); - expect(before.unread).toBeGreaterThan(0); - - const marked = await host.request(`/api/me/inbox/${messageId}/read`, { + const marked = await host.request(`/api/me/inbox/${written!.uid}/read`, { method: "POST", }); expect(marked.status).toBe(200); - expect(await json(marked)).toEqual({ id: messageId, ok: true }); + expect(await json(marked)).toEqual({ uid: written!.uid, ok: true }); - const detail = await json<{ read: boolean }>( - await host.request(`/api/me/inbox/${messageId}`), - ); - expect(detail.read).toBe(true); - - const after = await json<{ unread: number }>( - await host.request("/api/me/inbox/unread-count"), + const list = await json<{ messages: MailboxListItem[] }>( + await host.request("/api/me/inbox"), ); - expect(after.unread).toBe(before.unread - 1); + const item = list.messages.find((m) => m.uid === written!.uid); + expect(item?.flags).toContain("\\Seen"); - // Idempotent: re-marking is still a 200 and does not double-count. + // Idempotent: re-marking is still a 200. expect( ( - await host.request(`/api/me/inbox/${messageId}/read`, { + await host.request(`/api/me/inbox/${written!.uid}/read`, { method: "POST", }) ).status, ).toBe(200); - expect( - ( - await json<{ unread: number }>( - await host.request("/api/me/inbox/unread-count"), - ) - ).unread, - ).toBe(after.unread); }); - test("a bulk action applies over the mounted host, with per-id results", async () => { - const [first] = await deliverInboxItems(host.db, [ - inboundItem({ externalId: "bulk-1", subject: "Bulk one" }), - ]); - const [second] = await deliverInboxItems(host.db, [ - inboundItem({ externalId: "bulk-2", subject: "Bulk two" }), - ]); - const stranger = crypto.randomUUID(); - - const res = await host.request("/api/me/inbox/bulk", { - method: "POST", - headers: { "content-type": "application/json" }, - body: JSON.stringify({ - action: "mark_read", - ids: [first!.id, second!.id, stranger], - }), - }); - expect(res.status).toBe(200); - // Partial success: the two real ids apply, the unknown one reports false - // rather than failing the batch. - expect(await json(res)).toEqual({ - updated: 2, - results: [ - { id: first!.id, ok: true }, - { id: second!.id, ok: true }, - { id: stranger, ok: false }, - ], + test("archive moves a message out of INBOX; a second move 404s", async () => { + const written = await writeMailboxMessage(host.db, { + tenantId: "acme", + principalId: "user-1", + address: "user-1@acme.example", + fromAddress: "ops@acme.example", + subject: "To archive", + body: "Body", }); - const detail = await json<{ read: boolean }>( - await host.request(`/api/me/inbox/${first!.id}`), + const archived = await host.request( + `/api/me/inbox/${written!.uid}/archive`, + { method: "POST" }, + ); + expect(archived.status).toBe(200); + + const inboxView = await json<{ messages: MailboxListItem[] }>( + await host.request("/api/me/inbox"), ); - expect(detail.read).toBe(true); + expect(inboxView.messages.some((m) => m.uid === written!.uid)).toBe( + false, + ); + const archiveView = await json<{ messages: MailboxListItem[] }>( + await host.request("/api/me/inbox?folder=Archive"), + ); + expect( + archiveView.messages.some((m) => m.envelope.subject === "To archive"), + ).toBe(true); + + // The original uid no longer names anything in INBOX: archive/trash only + // ever move a message OUT of INBOX, so a second move 404s. + const reArchive = await host.request( + `/api/me/inbox/${written!.uid}/archive`, + { method: "POST" }, + ); + expect(reArchive.status).toBe(404); }); test("an SSE event arrives over the mounted host for a new message", async () => { @@ -334,7 +270,6 @@ describe("reference host", () => { fromAddress: "ops@acme.example", subject: "Live", body: "Body", - messageKey: "sse-1", }, host.bus, ); @@ -376,7 +311,6 @@ describe("reference host", () => { fromAddress: "ops@acme.example", subject: "Not for user-1", body: "Body", - messageKey: "sse-2", }, host.bus, ); @@ -397,156 +331,6 @@ describe("reference host", () => { expect(text).not.toContain("event: mailbox"); }); - test("triage enriches a delivered item over the mounted host", async () => { - const [delivered] = await deliverInboxItems(host.db, [ - inboundItem({ externalId: "triage-1", subject: "Needs triage" }), - ]); - - const res = await host.request(`/api/me/inbox/${delivered!.id}/enrich`, { - method: "POST", - headers: { "content-type": "application/json" }, - body: JSON.stringify({ - priority: "urgent", - classification: "deal-risk", - status: "needs-action", - }), - }); - expect(res.status).toBe(200); - - const detail = await json<{ - priority?: string; - classification?: string; - status?: string; - }>(await host.request(`/api/me/inbox/${delivered!.id}`)); - expect(detail).toMatchObject({ - priority: "urgent", - classification: "deal-risk", - status: "needs-action", - }); - }); - - test("bulk beyond the 50-id cap is a 400", async () => { - const ids = Array.from({ length: 51 }, () => crypto.randomUUID()); - const res = await host.request("/api/me/inbox/bulk", { - method: "POST", - headers: { "content-type": "application/json" }, - body: JSON.stringify({ action: "mark_read", ids }), - }); - expect(res.status).toBe(400); - }); - - test("a malformed stored frame degrades to 200, not 500", async () => { - const written = await writeMailboxMessage(host.db, { - tenantId: "acme", - principalId: "user-1", - address: "user-1@acme.example", - fromAddress: "a@acme.example", - subject: "will be corrupted", - body: "Body", - messageKey: "corrupt-1", - }); - await host.db.execute( - sql`UPDATE "mailbox"."principal_mail" SET "raw" = '\\xdeadbeef'::bytea WHERE "id" = ${written!.id}`, - ); - const res = await host.request(`/api/me/inbox/${written!.id}`); - expect(res.status).toBe(200); - }); - - test("a multipart/alternative frame reads back as its text, not MIME soup", async () => { - // What externally delivered mail actually looks like. Detail body and - // snippet must both be the readable text/plain part (list never decodes). - const written = await writeMailboxMessage(host.db, { - tenantId: "acme", - principalId: "user-1", - address: "user-1@acme.example", - fromAddress: "a@acme.example", - subject: "multipart", - body: "placeholder", - messageKey: "multipart-1", - }); - const raw = Buffer.from( - [ - "From: a@acme.example", - "To: user-1@acme.example", - "Subject: multipart", - 'Content-Type: multipart/alternative; boundary="BOUND"', - "", - "--BOUND", - "Content-Type: text/plain; charset=utf-8", - "", - "Hello human, this is the readable text.", - "--BOUND", - "Content-Type: text/html; charset=utf-8", - "", - "

Hello human

", - "--BOUND--", - "", - ].join("\r\n"), - ); - await host.db - .update(principalMail) - .set({ raw }) - .where(eq(principalMail.id, written!.id)); - - const detail = await json<{ body: string; snippet?: string }>( - await host.request(`/api/me/inbox/${written!.id}`), - ); - expect(detail.body).toBe("Hello human, this is the readable text."); - expect(detail.snippet).toBe("Hello human, this is the readable text."); - expect(detail.body).not.toContain("--BOUND"); - expect(detail.body).not.toContain("

"); - }); - - test("triage enrichment is queryable: filter by priority, sort by priority", async () => { - const [urgent] = await deliverInboxItems(host.db, [ - inboundItem({ externalId: "triage-urgent", subject: "urgent thing" }), - ]); - const [low] = await deliverInboxItems(host.db, [ - inboundItem({ externalId: "triage-low", subject: "low thing" }), - ]); - for (const [id, priority] of [ - [urgent!.id!, "urgent"], - [low!.id!, "low"], - ] as const) { - const res = await host.request(`/api/me/inbox/${id}/enrich`, { - method: "POST", - headers: { "content-type": "application/json" }, - body: JSON.stringify({ priority, classification: "triaged" }), - }); - expect(res.status).toBe(200); - } - - const filtered = await json<{ messages: { id: string }[] }>( - await host.request( - "/api/me/inbox?priority=urgent&classification=triaged", - ), - ); - expect(filtered.messages.map((m) => m.id)).toEqual([urgent!.id!]); - - const sorted = await json<{ messages: { id: string }[] }>( - await host.request("/api/me/inbox?classification=triaged&sort=priority"), - ); - expect(sorted.messages.map((m) => m.id)).toEqual([urgent!.id!, low!.id!]); - }); - - test("delegation: assign stamps an assignee the list can filter on", async () => { - const [item] = await deliverInboxItems(host.db, [ - inboundItem({ externalId: "delegate-1", subject: "delegate me" }), - ]); - const assigned = await host.request(`/api/me/inbox/${item!.id!}/assign`, { - method: "POST", - headers: { "content-type": "application/json" }, - body: JSON.stringify({ assignee: "user-2" }), - }); - expect(assigned.status).toBe(200); - - const listed = await json<{ - messages: { id: string; assignee?: string }[]; - }>(await host.request("/api/me/inbox?assignee=user-2")); - expect(listed.messages.map((m) => m.id)).toEqual([item!.id!]); - expect(listed.messages[0]?.assignee).toBe("user-2"); - }); - test("signed out: the host's own /api/me/* auth gate answers first", async () => { // Mounting under `/api` puts the mailbox behind Interchange's // `app.use("/api/me/*", requireAuth)`. An unauthenticated request never From da011f5ca53c666722defe09b773f85830469d1e Mon Sep 17 00:00:00 2001 From: Sawyer Date: Thu, 17 Sep 2026 13:20:02 -0700 Subject: [PATCH 11/17] Update docs: the two thread routes --- README.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/README.md b/README.md index 99e235c..236f275 100644 --- a/README.md +++ b/README.md @@ -41,6 +41,8 @@ every other route returns 403. | `POST /me/inbox/:uid/trash` | `moveNativeMailboxMessage` INBOX → Trash | | `POST /me/inbox/:uid/restore` | `moveNativeMailboxMessage` (`?folder=`, default Archive) → INBOX | | `GET /me/inbox/events` | SSE stream of `mailbox` events (`create`/`mark_read`/`mark_unread`/`archive`/`trash`/`restore`) for the caller's mailbox, plus a heartbeat every 25s. | +| `GET /me/inbox/threads` | The vendored `executeThread` (REFERENCES) over the folder's native store — roots + children, each ref carrying the same envelope fields as `GET /me/inbox`. `?folder=`. | +| `GET /me/inbox/threads/:rootUid` | The single native thread rooted at `rootUid`, same per-ref envelope fields. `?folder=`. | ## Writing into a mailbox From b3ea5aadfacd368c5beb879dc57c86e5f7568fe0 Mon Sep 17 00:00:00 2001 From: Sawyer Date: Thu, 17 Sep 2026 13:21:42 -0700 Subject: [PATCH 12/17] Drop the coverage gate; tests are the gate --- .github/workflows/test.yml | 2 +- bunfig.toml | 15 --------------- package.json | 1 - 3 files changed, 1 insertion(+), 17 deletions(-) delete mode 100644 bunfig.toml diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 7dec469..51be647 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -49,7 +49,7 @@ jobs: run: bun run typecheck - name: unit + integration tests - run: bun run test:coverage + run: bun test src - name: reference-host acceptance run: bun test --cwd examples/reference-host diff --git a/bunfig.toml b/bunfig.toml deleted file mode 100644 index 69145b2..0000000 --- a/bunfig.toml +++ /dev/null @@ -1,15 +0,0 @@ -[test] -# The coverage floor is a BUILD GATE, not a report. `bun test --coverage` exits -# non-zero when coverage falls below this ratio, so a regression fails CI -# instead of scrolling past in the log. -# -# 80% lines and functions is the stated standard. Real coverage is far above it; -# the floor is deliberately not pinned to today's number so a small, justified -# dip does not break the build while a real regression does. -# -# MUST stay the scalar form. Bun 1.3.14 silently IGNORES the table forms -# (`coverageThreshold = { line = ..., function = ... }` and a -# `[test.coverageThreshold]` section): they parse without error and enforce -# nothing, giving a gate that can never fail. The scalar is applied to BOTH the -# line and the function ratio, which is exactly the floor we want. -coverageThreshold = 0.8 diff --git a/package.json b/package.json index 853503f..95c5eaf 100644 --- a/package.json +++ b/package.json @@ -56,7 +56,6 @@ "prepare": "node scripts/prepare.mjs", "typecheck": "tsc --noEmit", "test": "bun test src", - "test:coverage": "bun test src --coverage", "test:acceptance": "bun run build && bun install --force && bun test --cwd examples/reference-host" }, "dependencies": { From 18c32d85c8bfd3585ebc6535f413b43c0d9a39bd Mon Sep 17 00:00:00 2001 From: Sawyer Date: Thu, 17 Sep 2026 13:30:31 -0700 Subject: [PATCH 13/17] fix(build): bundle vendored @intx packages into dist, not the manifest The published tarball listed @intx/mailbox, @intx/mime, and @intx/types as workspace:* devDependencies only, so a plain npm/node consumer had no way to resolve them at runtime (CI's node consumer smoke test caught this: `Cannot find package '@intx/mailbox'`). @intx/mailbox has never been published, so there is nothing on npm to depend on instead. scripts/build.mjs now bundles the three vendored packages straight into dist/index.js via `bun build` (everything else stays external), and compiles their declarations separately into dist/vendor//, rewriting the bare specifiers in every emitted .d.ts to relative paths so a consumer's own tsc can resolve them too. @intx/mime and @intx/types drop out of peerDependencies; @intx/crypto (a real, byte-identical npm dependency the vendored code imports at runtime) moves from devDependencies to dependencies. --- VENDORED.md | 17 +++++ bun.lock | 4 +- package.json | 6 +- scripts/build.mjs | 137 +++++++++++++++++++++++++++++++++++++ tsconfig.build.json | 2 +- tsconfig.vendor-types.json | 23 +++++++ 6 files changed, 181 insertions(+), 8 deletions(-) create mode 100644 scripts/build.mjs create mode 100644 tsconfig.vendor-types.json diff --git a/VENDORED.md b/VENDORED.md index 2703e95..81b372b 100644 --- a/VENDORED.md +++ b/VENDORED.md @@ -30,3 +30,20 @@ consumed as an ordinary npm dependency instead. Publishing `@intx/mailbox` (and refreshing the `@intx/mime`/`@intx/types` npm releases to the pin it needs) retires all three rows in one move. + +## Published artifact + +None of the three vendored packages appear in the published manifest's +`dependencies`/`peerDependencies` — there is nothing on npm for a consumer +to install against. Instead `scripts/build.mjs` (invoked by `bun run +build`) bundles `vendor/intx-mailbox`, `vendor/intx-mime`, and +`vendor/intx-types` straight into `dist/index.js`, and compiles their +declarations separately into `dist/vendor//`, rewriting the bare +`@intx/mailbox`/`@intx/mime`/`@intx/types` specifiers in every emitted +`.d.ts` to relative paths into that directory. This keeps the tarball +self-contained for both `node --experimental-...`-free runtime use and a +consumer's own `tsc`. It is a build-time workaround, not a vendoring +delta: once `@intx/mailbox` (and the `@intx/mime`/`@intx/types` pins it +needs) are published, the three packages move back to ordinary +`dependencies`/`peerDependencies`, `scripts/build.mjs` goes back to a +plain `tsc` invocation, and this section is deleted. diff --git a/bun.lock b/bun.lock index 2d480c7..ec141cd 100644 --- a/bun.lock +++ b/bun.lock @@ -6,13 +6,13 @@ "name": "@corbits/mailbox", "dependencies": { "@hono/standard-validator": "0.2.3", + "@intx/crypto": "0.3.0", "@standard-community/standard-json": "0.3.5", "@standard-community/standard-openapi": "0.2.9", "arktype": "2.1.29", "hono-openapi": "1.3.1", }, "devDependencies": { - "@intx/crypto": "0.3.0", "@intx/log": "0.2.2", "@intx/mailbox": "workspace:*", "@intx/mime": "workspace:*", @@ -28,8 +28,6 @@ }, "peerDependencies": { "@intx/log": "^0.2.2", - "@intx/mime": "^0.3.0", - "@intx/types": "^0.3.0", "drizzle-orm": "^0.45.2", "hono": "^4.12.0", "postgres": "^3.4.0", diff --git a/package.json b/package.json index 95c5eaf..76b4ba2 100644 --- a/package.json +++ b/package.json @@ -51,7 +51,7 @@ ], "sideEffects": false, "scripts": { - "build": "rm -rf dist && tsc -p tsconfig.build.json", + "build": "node scripts/build.mjs", "prepack": "bun run build", "prepare": "node scripts/prepare.mjs", "typecheck": "tsc --noEmit", @@ -60,6 +60,7 @@ }, "dependencies": { "@hono/standard-validator": "0.2.3", + "@intx/crypto": "0.3.0", "@standard-community/standard-json": "0.3.5", "@standard-community/standard-openapi": "0.2.9", "arktype": "2.1.29", @@ -67,14 +68,11 @@ }, "peerDependencies": { "@intx/log": "^0.2.2", - "@intx/mime": "^0.3.0", - "@intx/types": "^0.3.0", "drizzle-orm": "^0.45.2", "hono": "^4.12.0", "postgres": "^3.4.0" }, "devDependencies": { - "@intx/crypto": "0.3.0", "@intx/log": "0.2.2", "@intx/mailbox": "workspace:*", "@intx/mime": "workspace:*", diff --git a/scripts/build.mjs b/scripts/build.mjs new file mode 100644 index 0000000..f7c085e --- /dev/null +++ b/scripts/build.mjs @@ -0,0 +1,137 @@ +#!/usr/bin/env node +/** + * Builds `dist/`. + * + * `@intx/mailbox` (and its `@intx/mime`/`@intx/types` compile-time + * dependencies at this pin) are vendored under `vendor/` — see + * VENDORED.md — because `@intx/mailbox` has never been published. A + * published `@corbits/mailbox` therefore cannot depend on them: there is + * nothing on npm for a consumer to install. Instead this build BUNDLES the + * three vendored packages into `dist/index.js`, so a plain `npm install` of + * the tarball is self-contained. Every other dependency (the real npm + * packages in `dependencies`/`peerDependencies`) stays external — the + * consumer installs those themselves. + * + * Type declarations follow the same split: `dist/*.d.ts` is emitted from + * `src/` as before, but it still contains bare `@intx/mailbox` / + * `@intx/mime` / `@intx/types` import specifiers (tsc does not rewrite + * import text just because `paths` resolved it — that mapping is + * compile-time only). A consumer's own `tsc` would fail to resolve those + * the same way Node failed to resolve the bare runtime import. So this + * script also compiles the three vendored packages' own declarations into + * `dist/vendor//`, then rewrites every bare `@intx/*` specifier in + * `dist/**\/*.d.ts` to a relative path into `dist/vendor/`. + */ +import { execFileSync } from "node:child_process"; +import { + cpSync, + mkdirSync, + readFileSync, + readdirSync, + rmSync, + statSync, + writeFileSync, +} from "node:fs"; +import { dirname, join, relative } from "node:path"; +import { fileURLToPath } from "node:url"; + +const root = dirname(dirname(fileURLToPath(import.meta.url))); +const dist = join(root, "dist"); + +function run(command, args) { + execFileSync(command, args, { cwd: root, stdio: "inherit" }); +} + +rmSync(dist, { recursive: true, force: true }); + +// 1. Declarations for our own src/, unchanged in shape from before. +run("bun", ["x", "tsc", "-p", "tsconfig.build.json"]); + +// 2. Declarations for the three vendored packages, compiled standalone so +// they can be relocated under dist/vendor/ and referenced by relative +// path instead of by bare package name. +run("bun", ["x", "tsc", "-p", "tsconfig.vendor-types.json"]); + +const VENDOR_PACKAGES = { + "intx-mailbox": "@intx/mailbox", + "intx-mime": "@intx/mime", + "intx-types": "@intx/types", +}; + +const rawVendorRoot = join(dist, ".vendor-types-raw", "vendor"); +for (const dir of Object.keys(VENDOR_PACKAGES)) { + const from = join(rawVendorRoot, dir, "src"); + const to = join(dist, "vendor", dir); + mkdirSync(to, { recursive: true }); + cpSync(from, to, { recursive: true }); +} +rmSync(join(dist, ".vendor-types-raw"), { recursive: true, force: true }); + +// 3. Bundle the runtime JS. @intx/mailbox, @intx/mime, and @intx/types get +// inlined (they're intentionally left off the `--external` list); +// everything else — the package's real npm dependencies/peerDependencies +// — is left for the consumer to install. +const EXTERNAL = [ + "@hono/standard-validator", + "@standard-community/standard-json", + "@standard-community/standard-openapi", + "arktype", + "hono-openapi", + "@intx/crypto", + "@intx/log", + "drizzle-orm", + "hono", + "postgres", +]; +run("bun", [ + "build", + "src/index.ts", + "--outdir", + dist, + "--target", + "node", + "--format", + "esm", + ...EXTERNAL.flatMap((pkg) => ["--external", pkg]), +]); + +// 4. Rewrite bare `@intx/*` specifiers in every emitted .d.ts (ours and the +// vendored packages' own, which cross-reference each other) into +// relative paths under dist/vendor/. +function listDtsFiles(dir) { + const out = []; + for (const entry of readdirSync(dir)) { + const full = join(dir, entry); + if (statSync(full).isDirectory()) { + out.push(...listDtsFiles(full)); + } else if (entry.endsWith(".d.ts")) { + out.push(full); + } + } + return out; +} + +function rewriteImports(file) { + let text = readFileSync(file, "utf8"); + const fileDir = dirname(file); + text = text.replace( + /from\s+"(@intx\/(?:mailbox|mime|types)(?:\/[a-zA-Z0-9-]+)?)"/g, + (match, spec) => { + const [pkgName, ...subpathParts] = spec.split("/").slice(1); + const vendorDir = { mailbox: "intx-mailbox", mime: "intx-mime", types: "intx-types" }[ + pkgName + ]; + if (!vendorDir) return match; + const targetBase = subpathParts.length > 0 ? subpathParts.join("/") : "index"; + const targetFile = join(dist, "vendor", vendorDir, `${targetBase}.js`); + let rel = relative(fileDir, targetFile).split("\\").join("/"); + if (!rel.startsWith(".")) rel = `./${rel}`; + return `from "${rel}"`; + }, + ); + writeFileSync(file, text); +} + +for (const file of listDtsFiles(dist)) { + rewriteImports(file); +} diff --git a/tsconfig.build.json b/tsconfig.build.json index c94dafa..47bfc87 100644 --- a/tsconfig.build.json +++ b/tsconfig.build.json @@ -5,9 +5,9 @@ "node" ], "noEmit": false, + "emitDeclarationOnly": true, "declaration": true, "declarationMap": true, - "sourceMap": true, "outDir": "dist", "rootDir": "src" }, diff --git a/tsconfig.vendor-types.json b/tsconfig.vendor-types.json new file mode 100644 index 0000000..044db54 --- /dev/null +++ b/tsconfig.vendor-types.json @@ -0,0 +1,23 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "types": ["node"], + "noEmit": false, + "emitDeclarationOnly": true, + "declaration": true, + "declarationMap": false, + "outDir": "dist/.vendor-types-raw", + "rootDir": "." + }, + "include": [ + "vendor/intx-mailbox/src", + "vendor/intx-mime/src", + "vendor/intx-types/src" + ], + "exclude": [ + "**/node_modules", + "**/dist", + "**/*.test.ts", + "**/test-helpers.ts" + ] +} From 73d54eb89868fbe8af70f467d5fad088a1abd068 Mon Sep 17 00:00:00 2001 From: Sawyer Date: Thu, 17 Sep 2026 13:42:44 -0700 Subject: [PATCH 14/17] fix(build): stop bun's own bundle from tree-shaking its re-exports MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit package.json's sideEffects: false is metadata for a CONSUMER's bundler. On Linux, bun build read that same field for this package's own build and collapsed src/index.ts's re-export-only module to a bare 'export { ... }' stub with every import deleted, leaving the export list referencing nothing — every named export threw "X is not declared in this file" at import time. --ignore-dce-annotations makes bun bundle its own build honestly regardless of that field. --- scripts/build.mjs | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/scripts/build.mjs b/scripts/build.mjs index f7c085e..ee390d5 100644 --- a/scripts/build.mjs +++ b/scripts/build.mjs @@ -83,6 +83,13 @@ const EXTERNAL = [ "hono", "postgres", ]; +// `--ignore-dce-annotations`: this package's own `sideEffects: false` is +// metadata for CONSUMER bundlers, not an instruction to bun about its own +// build. Without this flag, bun (at least on Linux) reads that field for +// this build too and tree-shakes src/index.ts's re-export-only module down +// to a bare `export { ... }` stub with every import deleted — the named +// bindings are gone but the export list survives, so the emitted dist/ +// throws "X is not declared in this file" for every export at import time. run("bun", [ "build", "src/index.ts", @@ -92,6 +99,7 @@ run("bun", [ "node", "--format", "esm", + "--ignore-dce-annotations", ...EXTERNAL.flatMap((pkg) => ["--external", pkg]), ]); From fdfc264a874e30af0abe5f4297f6871d5379e9c9 Mon Sep 17 00:00:00 2001 From: Sawyer Date: Thu, 17 Sep 2026 13:57:36 -0700 Subject: [PATCH 15/17] Add tests for POST /me/inbox/send --- src/mount-native.test.ts | 2 + src/mount-send.test.ts | 140 ++++++++++++++++++ src/mount.test.ts | 2 + src/sse-heartbeat.test.ts | 6 + src/sse-stream.test.ts | 8 + ...r-types.json => tsconfig.vendor-build.json | 0 6 files changed, 158 insertions(+) create mode 100644 src/mount-send.test.ts rename tsconfig.vendor-types.json => tsconfig.vendor-build.json (100%) diff --git a/src/mount-native.test.ts b/src/mount-native.test.ts index f70ce0e..f6d058a 100644 --- a/src/mount-native.test.ts +++ b/src/mount-native.test.ts @@ -22,6 +22,8 @@ function buildApp() { db, bus: createInMemoryMailboxEventBus(), resolvePrincipal: () => SCOPE, + senderAddressFor: () => "p1@t1.example", + deliver: () => {}, }); return app; } diff --git a/src/mount-send.test.ts b/src/mount-send.test.ts new file mode 100644 index 0000000..258cd74 --- /dev/null +++ b/src/mount-send.test.ts @@ -0,0 +1,140 @@ +// POST /me/inbox/send: builds an RFC 5322 message, appends it to the +// caller's Sent folder, and hands it to the host's `deliver` — this package +// owns no transport of its own. +import { beforeEach, describe, expect, test } from "bun:test"; +import { Hono } from "hono"; +import { mountMailbox, type OutgoingMailboxMessage } from "./mount.js"; +import { createInMemoryMailboxEventBus } from "./bus.js"; +import { openNativeMailboxStore } from "./native-store.js"; +import { withTestDb, seedScope } from "./test-helpers.js"; +import type { MailboxDb } from "./db.js"; + +let db: MailboxDb; +const SCOPE = { tenantId: "t1", principalId: "p1" }; +const FROM = "p1@t1.example"; + +beforeEach(async () => { + db = await withTestDb(); + await seedScope(db, SCOPE.tenantId, SCOPE.principalId); +}); + +function buildApp(deliveries: OutgoingMailboxMessage[]) { + const app = new Hono(); + mountMailbox(app, { + db, + bus: createInMemoryMailboxEventBus(), + resolvePrincipal: () => SCOPE, + senderAddressFor: () => FROM, + deliver: (message) => { + deliveries.push(message); + }, + }); + return app; +} + +describe("POST /me/inbox/send", () => { + test("appends to Sent, calls deliver, and returns messageId + uid", async () => { + const deliveries: OutgoingMailboxMessage[] = []; + const app = buildApp(deliveries); + + const res = await app.request("/me/inbox/send", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + to: ["bob@example.com"], + subject: "Hi", + body: "Hello there", + }), + }); + + expect(res.status).toBe(200); + const body = (await res.json()) as { messageId: string; uid: number }; + expect(body.messageId).toMatch(/^<.+@.+>$/); + expect(body.uid).toBe(1); + + // The Sent folder copy is durable and readable through the native store. + const sent = await openNativeMailboxStore(db, { ...SCOPE, folder: "Sent" }); + expect(sent.messages).toHaveLength(1); + const stored = sent.messages[0]!; + expect(stored.envelope.messageId).toBe(body.messageId); + expect(stored.envelope.from).toBe(FROM); + // The native store's persisted envelope doesn't round-trip `to` (see + // `native-store.ts`'s `toEnvelope`) — the recipient list lives in the + // raw frame's `To:` header instead, asserted on below via `raw`. + expect(stored.envelope.subject).toBe("Hi"); + const raw = await sent.readRaw(stored.uid); + const decodedRaw = new TextDecoder().decode(raw); + expect(decodedRaw).toContain("Hello there"); + expect(decodedRaw).toContain("To: bob@example.com"); + + // The host's `deliver` was handed the same message, exactly once. + expect(deliveries).toHaveLength(1); + expect(deliveries[0]!.from).toBe(FROM); + expect(deliveries[0]!.to).toEqual(["bob@example.com"]); + expect(deliveries[0]!.messageId).toBe(body.messageId); + expect(new TextDecoder().decode(deliveries[0]!.raw)).toContain( + "Hello there", + ); + }); + + test("derives In-Reply-To/References from the parent message", async () => { + const deliveries: OutgoingMailboxMessage[] = []; + const app = buildApp(deliveries); + + const first = await app.request("/me/inbox/send", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ to: ["bob@example.com"], body: "Root" }), + }); + const { messageId: rootId } = (await first.json()) as { messageId: string }; + + const reply = await app.request("/me/inbox/send", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + to: ["bob@example.com"], + body: "Reply", + inReplyTo: rootId, + }), + }); + expect(reply.status).toBe(200); + const { uid: replyUid } = (await reply.json()) as { uid: number }; + + const sent = await openNativeMailboxStore(db, { ...SCOPE, folder: "Sent" }); + const stored = sent.find(replyUid)!; + expect(stored.envelope.inReplyTo).toBe(rootId); + expect(stored.envelope.references).toEqual([rootId]); + }); + + test("400s on a malformed body without calling deliver", async () => { + const deliveries: OutgoingMailboxMessage[] = []; + const app = buildApp(deliveries); + + const res = await app.request("/me/inbox/send", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ to: [], body: "no recipients" }), + }); + + expect(res.status).toBe(400); + expect(deliveries).toHaveLength(0); + }); + + test("403s with no resolvable principal", async () => { + const app = new Hono(); + mountMailbox(app, { + db, + bus: createInMemoryMailboxEventBus(), + resolvePrincipal: () => null, + senderAddressFor: () => FROM, + deliver: () => {}, + }); + + const res = await app.request("/me/inbox/send", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ to: ["bob@example.com"], body: "Hi" }), + }); + expect(res.status).toBe(403); + }); +}); diff --git a/src/mount.test.ts b/src/mount.test.ts index 16fa441..bf82c86 100644 --- a/src/mount.test.ts +++ b/src/mount.test.ts @@ -20,6 +20,8 @@ function buildApp( db, bus: createInMemoryMailboxEventBus(), resolvePrincipal, + senderAddressFor: () => "sender@t1.example", + deliver: () => {}, }); return app; } diff --git a/src/sse-heartbeat.test.ts b/src/sse-heartbeat.test.ts index b8385c4..4f1f114 100644 --- a/src/sse-heartbeat.test.ts +++ b/src/sse-heartbeat.test.ts @@ -19,6 +19,8 @@ function stream(db: MailboxDb, heartbeatIntervalMs: number) { db, bus, resolvePrincipal: () => SCOPE, + senderAddressFor: () => "sender@t1.example", + deliver: () => {}, heartbeatIntervalMs, }); return { app, bus }; @@ -122,6 +124,8 @@ describe("SSE heartbeat", () => { db, bus, resolvePrincipal: () => SCOPE, + senderAddressFor: () => "sender@t1.example", + deliver: () => {}, }); const res = await app.request("/me/inbox/events"); @@ -144,6 +148,8 @@ describe("SSE heartbeat", () => { db, bus: createInMemoryMailboxEventBus(), resolvePrincipal: () => SCOPE, + senderAddressFor: () => "sender@t1.example", + deliver: () => {}, }; for (const heartbeatIntervalMs of [0, -1, Number.NaN, Number.POSITIVE_INFINITY]) { expect(() => diff --git a/src/sse-stream.test.ts b/src/sse-stream.test.ts index f36da33..f56fc0e 100644 --- a/src/sse-stream.test.ts +++ b/src/sse-stream.test.ts @@ -19,6 +19,8 @@ describe("SSE stream", () => { db, bus, resolvePrincipal: () => ({ tenantId: "t1", principalId: "p1" }), + senderAddressFor: () => "sender@t1.example", + deliver: () => {}, }); const res = await app.request("/me/inbox/events"); expect(res.status).toBe(200); @@ -70,6 +72,8 @@ describe("SSE stream", () => { db, bus, resolvePrincipal: () => ({ tenantId: "tenantA", principalId: "alice" }), + senderAddressFor: () => "sender@tenantA.example", + deliver: () => {}, }); const res = await app.request("/me/inbox/events"); expect(res.status).toBe(200); @@ -131,6 +135,8 @@ describe("SSE stream", () => { db, bus, resolvePrincipal: () => scope, + senderAddressFor: () => "sender@t1.example", + deliver: () => {}, // Short heartbeat so the handler notices the overflow-close promptly. heartbeatIntervalMs: 50, }); @@ -196,6 +202,8 @@ describe("SSE stream", () => { db, bus, resolvePrincipal: () => scope, + senderAddressFor: () => "sender@t1.example", + deliver: () => {}, // Short heartbeat so the loop notices `closed` and runs finally promptly. heartbeatIntervalMs: 50, }); diff --git a/tsconfig.vendor-types.json b/tsconfig.vendor-build.json similarity index 100% rename from tsconfig.vendor-types.json rename to tsconfig.vendor-build.json From 99ceb59127d153986e8454742dd56a91d6c8f5c4 Mon Sep 17 00:00:00 2001 From: Sawyer Date: Thu, 17 Sep 2026 13:57:36 -0700 Subject: [PATCH 16/17] Replace bun-bundler build with plain tsc emit; add POST /me/inbox/send MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The bun bundler's own tree-shaking (even with --ignore-dce-annotations, per the previous commit) still produced a dist/index.js under bun on Linux where named exports were declared but not defined, failing the reference-host acceptance job. Bundling is gone: tsc now emits our own src/ and the three vendored @intx packages as plain module-for-module JS + d.ts, and every bare @intx/* import plus every vendored package's own extensionless relative import is rewritten to a relative dist/ path afterward. mountMailbox gains a POST /me/inbox/send route: builds an RFC 5322 message via the vendored frame builder, appends it to the caller's Sent folder through the native store, and hands it to a new required deliver mount dep — this package still owns no transport of its own. --- examples/reference-host/src/index.ts | 16 +++ scripts/build.mjs | 109 +++++++++------------ src/index.ts | 6 +- src/mount.ts | 139 ++++++++++++++++++++++++++- tsconfig.build.json | 1 - tsconfig.vendor-build.json | 3 +- 6 files changed, 205 insertions(+), 69 deletions(-) diff --git a/examples/reference-host/src/index.ts b/examples/reference-host/src/index.ts index 8a434a9..e849ac6 100644 --- a/examples/reference-host/src/index.ts +++ b/examples/reference-host/src/index.ts @@ -59,6 +59,12 @@ export type ReferenceHost = { request: (path: string, init?: RequestInit) => Promise; /** Who is signed in to the hub for subsequent requests; null = signed out. */ setSession: (session: Session) => void; + /** + * Every message the mailbox's `deliver` mount dep was handed, in order. + * This reference host owns no real transport, so `deliver` just records + * here — the acceptance suite asserts against it instead of a network call. + */ + deliveries: { raw: Uint8Array; from: string; to: string[]; messageId: string }[]; }; export async function createReferenceHost(): Promise { @@ -144,6 +150,7 @@ export async function createReferenceHost(): Promise { }; const bus = createInMemoryMailboxEventBus(); + const deliveries: ReferenceHost["deliveries"] = []; // The convention: mounted @corbits/* modules serve under `/api`, matching // Interchange's own `app.route("/api/me", …)` / `app.route("/api/tenants", …)`. // The core registers its routes root-relative (`/me/inbox*`), so the host @@ -154,6 +161,14 @@ export async function createReferenceHost(): Promise { db, bus, resolvePrincipal, + // Matches `getSession`'s own `email` derivation above: this host encodes + // the mailbox address as `@.example` throughout. + senderAddressFor: ({ tenantId, principalId }) => + `${principalId}@${tenantId}.example`, + // No real transport here — see `ReferenceHost.deliveries`. + deliver: (message) => { + deliveries.push(message); + }, }); app.route("/api", api); @@ -164,5 +179,6 @@ export async function createReferenceHost(): Promise { setSession: (next) => { session = next; }, + deliveries, }; } diff --git a/scripts/build.mjs b/scripts/build.mjs index ee390d5..3834273 100644 --- a/scripts/build.mjs +++ b/scripts/build.mjs @@ -6,21 +6,22 @@ * dependencies at this pin) are vendored under `vendor/` — see * VENDORED.md — because `@intx/mailbox` has never been published. A * published `@corbits/mailbox` therefore cannot depend on them: there is - * nothing on npm for a consumer to install. Instead this build BUNDLES the - * three vendored packages into `dist/index.js`, so a plain `npm install` of - * the tarball is self-contained. Every other dependency (the real npm - * packages in `dependencies`/`peerDependencies`) stays external — the - * consumer installs those themselves. + * nothing on npm for a consumer to install. Instead this build emits the + * three vendored packages' own JS (via `tsc`, no bundler) into + * `dist/vendor//`, so a plain `npm install` of the tarball is + * self-contained. * - * Type declarations follow the same split: `dist/*.d.ts` is emitted from - * `src/` as before, but it still contains bare `@intx/mailbox` / - * `@intx/mime` / `@intx/types` import specifiers (tsc does not rewrite - * import text just because `paths` resolved it — that mapping is - * compile-time only). A consumer's own `tsc` would fail to resolve those - * the same way Node failed to resolve the bare runtime import. So this - * script also compiles the three vendored packages' own declarations into - * `dist/vendor//`, then rewrites every bare `@intx/*` specifier in - * `dist/**\/*.d.ts` to a relative path into `dist/vendor/`. + * There is no bundler anywhere in this build. `tsc` emits our own `src/` + * as plain JS + `.d.ts` (module-for-module, matching the source layout), + * and separately emits the three vendored packages the same way. Every + * bare `@intx/mailbox` / `@intx/mime` / `@intx/types` import specifier + * (tsc does not rewrite import text just because `paths` resolved it — + * that mapping is compile-time only) is then rewritten, in BOTH the + * emitted `.js` and the emitted `.d.ts`, to a relative path into + * `dist/vendor/`. Every other dependency (the real npm packages in + * `dependencies`/`peerDependencies`, plus `@intx/crypto`/`@intx/log`, + * which are real npm packages too) is left alone for the consumer to + * install. */ import { execFileSync } from "node:child_process"; import { @@ -44,13 +45,13 @@ function run(command, args) { rmSync(dist, { recursive: true, force: true }); -// 1. Declarations for our own src/, unchanged in shape from before. +// 1. Our own src/: plain tsc JS emit + declarations, module-for-module. run("bun", ["x", "tsc", "-p", "tsconfig.build.json"]); -// 2. Declarations for the three vendored packages, compiled standalone so -// they can be relocated under dist/vendor/ and referenced by relative -// path instead of by bare package name. -run("bun", ["x", "tsc", "-p", "tsconfig.vendor-types.json"]); +// 2. The three vendored packages: same plain tsc JS + declaration emit, +// compiled standalone so they can be relocated under dist/vendor/ and +// referenced by relative path instead of by bare package name. +run("bun", ["x", "tsc", "-p", "tsconfig.vendor-build.json"]); const VENDOR_PACKAGES = { "intx-mailbox": "@intx/mailbox", @@ -58,61 +59,28 @@ const VENDOR_PACKAGES = { "intx-types": "@intx/types", }; -const rawVendorRoot = join(dist, ".vendor-types-raw", "vendor"); +const rawVendorRoot = join(dist, ".vendor-build-raw", "vendor"); for (const dir of Object.keys(VENDOR_PACKAGES)) { const from = join(rawVendorRoot, dir, "src"); const to = join(dist, "vendor", dir); mkdirSync(to, { recursive: true }); cpSync(from, to, { recursive: true }); } -rmSync(join(dist, ".vendor-types-raw"), { recursive: true, force: true }); +rmSync(join(dist, ".vendor-build-raw"), { recursive: true, force: true }); -// 3. Bundle the runtime JS. @intx/mailbox, @intx/mime, and @intx/types get -// inlined (they're intentionally left off the `--external` list); -// everything else — the package's real npm dependencies/peerDependencies -// — is left for the consumer to install. -const EXTERNAL = [ - "@hono/standard-validator", - "@standard-community/standard-json", - "@standard-community/standard-openapi", - "arktype", - "hono-openapi", - "@intx/crypto", - "@intx/log", - "drizzle-orm", - "hono", - "postgres", -]; -// `--ignore-dce-annotations`: this package's own `sideEffects: false` is -// metadata for CONSUMER bundlers, not an instruction to bun about its own -// build. Without this flag, bun (at least on Linux) reads that field for -// this build too and tree-shakes src/index.ts's re-export-only module down -// to a bare `export { ... }` stub with every import deleted — the named -// bindings are gone but the export list survives, so the emitted dist/ -// throws "X is not declared in this file" for every export at import time. -run("bun", [ - "build", - "src/index.ts", - "--outdir", - dist, - "--target", - "node", - "--format", - "esm", - "--ignore-dce-annotations", - ...EXTERNAL.flatMap((pkg) => ["--external", pkg]), -]); - -// 4. Rewrite bare `@intx/*` specifiers in every emitted .d.ts (ours and the -// vendored packages' own, which cross-reference each other) into -// relative paths under dist/vendor/. -function listDtsFiles(dir) { +// 3. Rewrite bare `@intx/*` specifiers in every emitted file — `.js` (ours +// and the vendored packages' own runtime code) and `.d.ts` (same, +// cross-referencing each other) — into relative paths under +// dist/vendor/. A package's own `exports` subpaths (e.g. +// `@intx/types/runtime`) map 1:1 onto that package's own src/ file +// names, which is exactly how they land under dist/vendor//. +function listEmittedFiles(dir) { const out = []; for (const entry of readdirSync(dir)) { const full = join(dir, entry); if (statSync(full).isDirectory()) { - out.push(...listDtsFiles(full)); - } else if (entry.endsWith(".d.ts")) { + out.push(...listEmittedFiles(full)); + } else if (entry.endsWith(".js") || entry.endsWith(".d.ts")) { out.push(full); } } @@ -137,9 +105,22 @@ function rewriteImports(file) { return `from "${rel}"`; }, ); + // The vendored packages' own source (unlike ours) writes extensionless + // relative specifiers, resolved at dev time only via `moduleResolution: + // "bundler"`. `tsc` emits import text verbatim — it does not add + // extensions — so a plain `node` ESM resolver (no bundler, no + // resolution-mode help) fails on them. Append `.js` to any relative + // specifier that doesn't already end in a resolvable extension. + text = text.replace( + /from\s+"(\.\.?\/[^"]+)"/g, + (match, spec) => { + if (/\.(js|json|mjs|cjs)$/.test(spec)) return match; + return `from "${spec}.js"`; + }, + ); writeFileSync(file, text); } -for (const file of listDtsFiles(dist)) { +for (const file of listEmittedFiles(dist)) { rewriteImports(file); } diff --git a/src/index.ts b/src/index.ts index d2cc497..4c377f6 100644 --- a/src/index.ts +++ b/src/index.ts @@ -7,7 +7,11 @@ export { MAX_MAILBOX_PAGE_LIMIT, MAX_PENDING_SSE_EVENTS, } from "./mount.js"; -export type { MountMailboxOpts, ResolvedPrincipal } from "./mount.js"; +export type { + MountMailboxOpts, + ResolvedPrincipal, + OutgoingMailboxMessage, +} from "./mount.js"; export { runMailboxMigrations, MigrationChecksumError } from "./migrations.js"; diff --git a/src/mount.ts b/src/mount.ts index 47c9dbf..b9d7af5 100644 --- a/src/mount.ts +++ b/src/mount.ts @@ -1,6 +1,7 @@ import type { Context, Env, Hono } from "hono"; import { streamSSE } from "hono/streaming"; import { describeRoute } from "hono-openapi"; +import { type } from "arktype"; import { getLogger } from "@intx/log"; import { executeSearch, executeThread } from "@intx/mailbox"; import type { Thread } from "@intx/types/runtime"; @@ -13,11 +14,20 @@ import { type MailboxEventOp, } from "./bus.js"; import { openNativeMailboxStore, moveNativeMailboxMessage } from "./native-store.js"; +import { assertMsgId, buildMailFrame, generateMailboxMessageId, headerValue } from "./frame.js"; const logger = getLogger(["corbits-mailbox", "mount"]); export type ResolvedPrincipal = { tenantId: string; principalId: string }; +/** A message this package has assembled and appended to the caller's Sent folder. */ +export type OutgoingMailboxMessage = { + raw: Uint8Array; + from: string; + to: string[]; + messageId: string; +}; + export type MountMailboxOpts = { db: MailboxDb; bus: MailboxEventBus; @@ -29,8 +39,30 @@ export type MountMailboxOpts = { * proxies default to. */ heartbeatIntervalMs?: number; + /** + * The caller's own address, as the host resolves it — the `From:` of every + * message `POST /me/inbox/send` builds. + */ + senderAddressFor: ( + principal: ResolvedPrincipal, + ) => Promise | string; + /** + * The host's actual transport. This package only builds the RFC 5322 + * message and appends a copy to the caller's `Sent` folder — it never puts + * a byte on a wire itself. `POST /me/inbox/send` calls this, once, after + * that append settles; the host owns getting `message.raw` to + * `message.to`. + */ + deliver: (message: OutgoingMailboxMessage) => Promise | void; }; +const SendMailboxMessageSchema = type({ + to: "string[] > 0", + "subject?": "string", + body: "string > 0", + "inReplyTo?": "string", +}); + const DEFAULT_LIMIT = 50; /** Documented ceiling on `?limit=`. Exceeding it is a 400, never a silent clamp. */ export const MAX_MAILBOX_PAGE_LIMIT = 200; @@ -170,7 +202,7 @@ export function mountMailbox( app: Hono, opts: MountMailboxOpts, ): Hono { - const { db, bus, resolvePrincipal } = opts; + const { db, bus, resolvePrincipal, senderAddressFor, deliver } = opts; const heartbeatIntervalMs = opts.heartbeatIntervalMs ?? DEFAULT_HEARTBEAT_INTERVAL_MS; if (!Number.isFinite(heartbeatIntervalMs) || heartbeatIntervalMs <= 0) { @@ -355,6 +387,111 @@ export function mountMailbox( }, ); + app.post( + "/me/inbox/send", + describeRoute({ + tags: TAGS, + summary: "Send a message from the caller's mailbox", + description: + "Builds an RFC 5322 message, appends a copy to the caller's Sent " + + "folder via the native store, then hands it to the host's own " + + "`deliver` — this package owns no transport.", + responses: { + 200: { description: "The Sent copy's messageId and uid" }, + 400: { description: "Malformed body" }, + 403: { description: "No resolvable principalId" }, + }, + }), + // eslint-disable-next-line @typescript-eslint/no-explicit-any + async (c: Context) => { + const resolved = await resolvePrincipal(c); + if (!resolved) return c.json({ error: "No resolvable principalId" }, 403); + + let json: unknown; + try { + json = await c.req.json(); + } catch { + return c.json({ error: "Malformed JSON body" }, 400); + } + const parsed = SendMailboxMessageSchema(json); + if (parsed instanceof type.errors) { + return c.json({ error: parsed.summary }, 400); + } + + const fromAddress = headerValue(await senderAddressFor(resolved)); + const messageId = generateMailboxMessageId(fromAddress); + const subject = parsed.subject ?? ""; + + let inReplyTo: string | undefined; + let references: string[] | undefined; + if (parsed.inReplyTo !== undefined) { + inReplyTo = headerValue(parsed.inReplyTo); + try { + assertMsgId(inReplyTo, "inReplyTo"); + } catch (err) { + return c.json({ error: (err as Error).message }, 400); + } + // Best-effort ancestry lookup: the parent's own References chain, + // followed by the parent itself. A parent this store cannot find + // (a different mailbox, a purged message) still threads on + // `inReplyTo` alone — the chain just starts here instead of further + // back. + let parentReferences: string[] = []; + for (const folder of LIST_FOLDERS) { + const folderStore = await openNativeMailboxStore(db, { + ...resolved, + folder, + }); + const parent = folderStore.messages.find( + (m) => m.envelope.messageId === inReplyTo, + ); + if (parent) { + parentReferences = [...parent.envelope.references]; + break; + } + } + references = [...parentReferences, inReplyTo]; + } + + const frameArgs: Parameters[0] = { + from: fromAddress, + to: parsed.to.join(", "), + subject, + body: parsed.body, + messageId, + }; + if (inReplyTo !== undefined) frameArgs.inReplyTo = inReplyTo; + if (references !== undefined) frameArgs.references = references; + const raw = buildMailFrame(frameArgs); + + const sentStore = await openNativeMailboxStore(db, { + ...resolved, + folder: "Sent", + }); + const uid = sentStore.append( + raw, + { + messageId, + from: fromAddress, + to: parsed.to, + subject, + date: new Date(), + inReplyTo, + references: references ?? [], + interchangeType: undefined, + interchangeCorrelationId: undefined, + }, + [], + ); + await sentStore.settled; + publish(resolved, `Sent:${uid}`, "create"); + + await deliver({ raw, from: fromAddress, to: parsed.to, messageId }); + + return c.json({ messageId, uid }); + }, + ); + app.get( "/me/inbox/events", describeRoute({ diff --git a/tsconfig.build.json b/tsconfig.build.json index 47bfc87..c37bd6b 100644 --- a/tsconfig.build.json +++ b/tsconfig.build.json @@ -5,7 +5,6 @@ "node" ], "noEmit": false, - "emitDeclarationOnly": true, "declaration": true, "declarationMap": true, "outDir": "dist", diff --git a/tsconfig.vendor-build.json b/tsconfig.vendor-build.json index 044db54..eee9fcf 100644 --- a/tsconfig.vendor-build.json +++ b/tsconfig.vendor-build.json @@ -3,10 +3,9 @@ "compilerOptions": { "types": ["node"], "noEmit": false, - "emitDeclarationOnly": true, "declaration": true, "declarationMap": false, - "outDir": "dist/.vendor-types-raw", + "outDir": "dist/.vendor-build-raw", "rootDir": "." }, "include": [ From bce7e7edd51141570f217113cb13654150412b5b Mon Sep 17 00:00:00 2001 From: Sawyer Date: Thu, 17 Sep 2026 13:57:36 -0700 Subject: [PATCH 17/17] Update docs: POST /me/inbox/send --- README.md | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/README.md b/README.md index 236f275..a48b32b 100644 --- a/README.md +++ b/README.md @@ -23,6 +23,8 @@ mountMailbox(app, { db, bus: createInMemoryMailboxEventBus(), resolvePrincipal: (ctx) => resolveCallerFromRequest(ctx), + senderAddressFor: (principal) => resolveCallerAddress(principal), + deliver: (message) => hostMailTransport.send(message), }); ``` @@ -43,6 +45,11 @@ every other route returns 403. | `GET /me/inbox/events` | SSE stream of `mailbox` events (`create`/`mark_read`/`mark_unread`/`archive`/`trash`/`restore`) for the caller's mailbox, plus a heartbeat every 25s. | | `GET /me/inbox/threads` | The vendored `executeThread` (REFERENCES) over the folder's native store — roots + children, each ref carrying the same envelope fields as `GET /me/inbox`. `?folder=`. | | `GET /me/inbox/threads/:rootUid` | The single native thread rooted at `rootUid`, same per-ref envelope fields. `?folder=`. | +| `POST /me/inbox/send` | Body `{ to, subject?, body, inReplyTo? }`; builds an RFC 5322 message, appends it to the caller's `Sent` folder, and returns `{ messageId, uid }`. | + +`POST /me/inbox/send` only builds the message and files the caller's own +`Sent` copy — the host's `deliver` mount dep owns actually getting the +message to its recipients. ## Writing into a mailbox