diff --git a/.changeset/22870-service-mailbox.md b/.changeset/22870-service-mailbox.md new file mode 100644 index 00000000000..3fb8199f702 --- /dev/null +++ b/.changeset/22870-service-mailbox.md @@ -0,0 +1,27 @@ +--- +'@objectstack/spec': minor +'@objectstack/service-mailbox': minor +'@objectstack/core': minor +'@objectstack/plugin-security': minor +'@objectstack/plugin-audit': patch +'@objectstack/service-storage': patch +'@objectstack/cli': patch +'@objectstack/verify': patch +--- + +Inbound mail lands as a capability: `requires: ['mailbox']` registers the mirrored message `sys_mail_message`, its record links `sys_mail_association` and the mailbox directory `sys_mailbox`, with an unassociated pool restricted to mailbox owners and the new `manage_mailboxes` capability + +Clause-②: yes (widening) + +- **`@objectstack/spec`.** Two closed vocabularies each gain one entry; every stack and permission set that parsed before parses unchanged. + - **`mailbox`** is a platform capability token (`PLATFORM_CAPABILITY_TOKENS`, provider `@objectstack/service-mailbox`, open edition). It is not on the always-on slate: an app that wants mailboxes declares `requires: ['mailbox']`. + - **`manage_mailboxes`** is a curated organization capability (`PLATFORM_CAPABILITIES`), held by default by platform administrators (`ADMIN_FULL_ACCESS_CAPABILITIES`). Its holder reads the mailbox directory, and every mirrored message and association of its organization past the mail read gates, the unassociated pool included. Object grants and field-level security still apply to a holder. +- **`@objectstack/service-mailbox`** (new). `MailboxServicePlugin` owns the three objects (ADR-0029 D1/D2: registered only where the capability is required) and installs two read gates on the data middleware, so a list's `total` agrees with its rows: + - `sys_mail_message`, the RFC 5322 mirror of one inbound message (participants, Message-ID and threading headers, subject, text and HTML bodies, attachments as `sys_attachment` rows). A row is served to a caller who can read a record one of its associations names, to the owner of the mailbox it arrived in, or to a `manage_mailboxes` holder. A message no record matched (`association_state: 'unassociated'`) is therefore refused to an ordinary member as not found (`404 RECORD_NOT_FOUND`), never shown organization-wide. + - `sys_mail_association`, one row per (message, record) link with the participant address and email field that matched. A row is served to a caller who can read the record it names, or to a `manage_mailboxes` holder. + - `sys_mailbox`, the mailbox's address and owner, served only to a `manage_mailboxes` holder (`requiredPermissions`). + - All three are `engine-owned` and the data API serves `get` and `list` only. The HTML body is plain text (`textarea`), never rendered markup. Nothing writes these rows yet: the IMAP connector and the ingest engine, which stamp associations from the email fields an app flags with `mailAssociation`, ship next in this package. +- **`@objectstack/core`.** `resolveReadableParentIds` ("which of these parent records can the caller read", one caller-scoped engine read per parent object) is exported, so every parent-derived read gate asks the one evaluator. `CAPABILITY_PROVIDERS` gains the `mailbox` row. +- **`@objectstack/plugin-security`.** `organization_admin` (and its derived no-bypass variant) holds `manage_mailboxes`: an organization administrator reads its own organization's mailboxes and pool. +- **`@objectstack/plugin-audit`, `@objectstack/service-storage`.** The comment, activity, ledger and attachment gates import `resolveReadableParentIds` from `@objectstack/core` instead of carrying their own copies. The two copies were byte-identical, so no gate's answer changes. +- **`@objectstack/cli`, `@objectstack/verify`.** Both depend on `@objectstack/service-mailbox`, so `objectstack serve` and `bootStack` mount the provider an app's `requires: ['mailbox']` names. The ADR-0131 organization-ownership inventory carries a row for each new object. diff --git a/.changeset/config.json b/.changeset/config.json index 563d0dd6c3a..2828cc9e446 100644 --- a/.changeset/config.json +++ b/.changeset/config.json @@ -65,6 +65,7 @@ "@objectstack/service-i18n", "@objectstack/service-job", "@objectstack/service-knowledge", + "@objectstack/service-mailbox", "@objectstack/service-messaging", "@objectstack/service-package", "@objectstack/service-queue", diff --git a/content/docs/permissions/system-context.mdx b/content/docs/permissions/system-context.mdx index db116ec513d..089dc9e25da 100644 --- a/content/docs/permissions/system-context.mdx +++ b/content/docs/permissions/system-context.mdx @@ -169,27 +169,28 @@ One of the largest consumers of the flag; | 44 | Attachment access hooks return early (insert + update + delete, and the read AST) | service-storage | Lose: attachment visibility scoping | `packages/services/service-storage/src/attachment-access-hooks.ts#installAttachmentAccessHooks`, `#installAttachmentReadVisibility` | | 45 | Comment access hooks return early (insert + update + delete, and the read AST), and so do the activity and audit-log read gates (their read AST), the activity field redaction and the audit-log field redaction (the rows a read serves), and the query guard over both objects' value-bearing columns (the read AST) | plugin-audit | Get: the whole activity row and the whole `sys_audit_log` row, its before/after snapshots included, on `find` / `findOne` — the audit writer, each read gate's own pre-scan and the activity redaction's — and a filter, sort or grouping by those columns. Lose: comment visibility scoping, the narrowing of `sys_activity` and of `sys_audit_log` to rows whose parent record the caller can read, the redaction of a parent field's value from an activity row's text and recorded change and from a ledger row's before/after snapshots, the withholding of an activity row recording an update whose every changed field the reader is withheld (on `count` and `aggregate` too, so a total agrees with the rows), and the refusal of a query over those columns for a reader withheld a field of the objects it can reach | `packages/plugins/plugin-audit/src/comment-access-hooks.ts#installCommentAccessHooks`, `#installCommentReadVisibility`, `packages/plugins/plugin-audit/src/activity-read-visibility.ts#installActivityReadVisibility`, `packages/plugins/plugin-audit/src/audit-log-read-visibility.ts#installAuditLogReadVisibility`, `packages/plugins/plugin-audit/src/activity-field-redaction.ts#installActivityFieldRedaction`, `packages/plugins/plugin-audit/src/audit-log-field-redaction.ts#installAuditLogFieldRedaction`, `packages/plugins/plugin-audit/src/parent-field-query-guard.ts#installParentFieldQueryGuard` | | 46 | Knowledge search returns hits unfiltered | service-knowledge | Lose: the permission filter over search results | `packages/services/service-knowledge/src/knowledge-service.ts#applyPermissionFilter` | +| 47 | The inbound-mail read gates return early (the read AST) | service-mailbox | Get: every `sys_mail_message` and `sys_mail_association` row on `find` / `findOne` — the ingest writer and each gate's own pre-scan, which read the association and mailbox rows a decision needs. Lose: the narrowing of a message to callers who can read a record it is associated with or who own its mailbox (so the unassociated pool is restricted), and of an association to callers who can read the record it names (on `count` and `aggregate` too, so a total agrees with the rows) | `packages/services/service-mailbox/src/mail-read-visibility.ts#gateMiddleware` | ### 5. Actions, metadata plane, provenance, the organization wall | # | Behaviour when `isSystem` | Package | What you get / what you lose | Anchor | |:--|:---|:---|:---|:---| -| 47 | Object API-exposure gate bypassed (`apiEnabled` / `apiMethods`) | runtime | Get: internal self-writes ignore exposure declarations — these govern **external** exposure, not engine self-writes | `packages/runtime/src/action-execution.ts#callData` | -| 48 | Action `requiredPermissions` bypassed | runtime | Get: engine self-invocation runs any action | `packages/runtime/src/action-execution.ts#actionPermissionError` | -| 49 | `manage_metadata` bypassed on metadata writes | runtime, rest | Get: schema writes without the capability (the permission-set clone door's save included) — and, because REST's save door and the stored-version read doors ask one save verdict, an app's full stored version on `/layers`, `?layers=true` and `/diff`, whatever its entry gates withhold | `packages/runtime/src/domains/meta.ts#handleMetadataRequest`, `packages/rest/src/rest-server.ts#registerMetadataEndpointsInner`, `packages/rest/src/rest-server.ts#metaSaveVerdict`, `packages/runtime/src/domains/permission-set-clone.ts#handlePermissionSetCloneWrite` | -| 50 | The shared metadata-write verdict itself returns `allowed` | metadata-core | Get: the one function all of row 49's doors consult answers yes before any capability is examined | `packages/metadata-core/src/meta-write-capability.ts#metaWriteCapabilityVerdict` | -| 51 | Anonymous-deny seam satisfied on the domain dispatchers and the package/federation routes | runtime, rest | Get: passes with no `userId` | `packages/runtime/src/domains/actions.ts#handleActionsRequest`, `packages/runtime/src/domains/ai.ts#handleAIRequest`, `packages/runtime/src/domains/automation.ts#handleAutomationRequest`, `packages/runtime/src/domains/meta.ts#handleMetadataRequest`, `packages/runtime/src/domains/security.ts#handleSecurityRequest`, `packages/runtime/src/domains/packages.ts#handlePackagesRequest`, `packages/runtime/src/domains/analytics.ts#handleAnalyticsRequest`, `packages/runtime/src/domains/i18n.ts#handleI18nRequest`, `packages/rest/src/external-datasource-routes.ts#registerExternalDatasourceRoutes`, `packages/rest/src/package-routes.ts#refusePackageRequest` | -| 52 | MCP principal check satisfied | runtime | Get: MCP surface reachable with no user | `packages/runtime/src/domains/mcp.ts#handleMcpRequest` | -| 53 | Package REST route capability gate bypassed | rest | Get: a marketplace publish over REST (`POST /packages/publish`, the one route the REST registrar mounts since #14503) without `manage_metadata`; the package read cohort (`studio.access` / `setup.access`) is enforced by the dispatcher `/packages` domain's own read gate, where the reads are served | `packages/rest/src/package-routes.ts#refusePackageRequest` | -| 54 | Package domain capability gates bypassed | runtime | Get: package management and package-inventory reads without the capability | `packages/runtime/src/domains/packages.ts#requireManageMetadata`, `#requireReadCapability` | -| 55 | Activation write / authoring refusals do not fire | runtime | Get: activation artifacts writable and authorable without the activation-authoring capability | `packages/runtime/src/domains/activation-gate.ts#refuseUngrantedActivationWrite`, `#refuseUngrantedActivationAuthoring` | +| 48 | Object API-exposure gate bypassed (`apiEnabled` / `apiMethods`) | runtime | Get: internal self-writes ignore exposure declarations — these govern **external** exposure, not engine self-writes | `packages/runtime/src/action-execution.ts#callData` | +| 49 | Action `requiredPermissions` bypassed | runtime | Get: engine self-invocation runs any action | `packages/runtime/src/action-execution.ts#actionPermissionError` | +| 50 | `manage_metadata` bypassed on metadata writes | runtime, rest | Get: schema writes without the capability (the permission-set clone door's save included) — and, because REST's save door and the stored-version read doors ask one save verdict, an app's full stored version on `/layers`, `?layers=true` and `/diff`, whatever its entry gates withhold | `packages/runtime/src/domains/meta.ts#handleMetadataRequest`, `packages/rest/src/rest-server.ts#registerMetadataEndpointsInner`, `packages/rest/src/rest-server.ts#metaSaveVerdict`, `packages/runtime/src/domains/permission-set-clone.ts#handlePermissionSetCloneWrite` | +| 51 | The shared metadata-write verdict itself returns `allowed` | metadata-core | Get: the one function all of row 50's doors consult answers yes before any capability is examined | `packages/metadata-core/src/meta-write-capability.ts#metaWriteCapabilityVerdict` | +| 52 | Anonymous-deny seam satisfied on the domain dispatchers and the package/federation routes | runtime, rest | Get: passes with no `userId` | `packages/runtime/src/domains/actions.ts#handleActionsRequest`, `packages/runtime/src/domains/ai.ts#handleAIRequest`, `packages/runtime/src/domains/automation.ts#handleAutomationRequest`, `packages/runtime/src/domains/meta.ts#handleMetadataRequest`, `packages/runtime/src/domains/security.ts#handleSecurityRequest`, `packages/runtime/src/domains/packages.ts#handlePackagesRequest`, `packages/runtime/src/domains/analytics.ts#handleAnalyticsRequest`, `packages/runtime/src/domains/i18n.ts#handleI18nRequest`, `packages/rest/src/external-datasource-routes.ts#registerExternalDatasourceRoutes`, `packages/rest/src/package-routes.ts#refusePackageRequest` | +| 53 | MCP principal check satisfied | runtime | Get: MCP surface reachable with no user | `packages/runtime/src/domains/mcp.ts#handleMcpRequest` | +| 54 | Package REST route capability gate bypassed | rest | Get: a marketplace publish over REST (`POST /packages/publish`, the one route the REST registrar mounts since #14503) without `manage_metadata`; the package read cohort (`studio.access` / `setup.access`) is enforced by the dispatcher `/packages` domain's own read gate, where the reads are served | `packages/rest/src/package-routes.ts#refusePackageRequest` | +| 55 | Package domain capability gates bypassed | runtime | Get: package management and package-inventory reads without the capability | `packages/runtime/src/domains/packages.ts#requireManageMetadata`, `#requireReadCapability` | +| 56 | Activation write / authoring refusals do not fire | runtime | Get: activation artifacts writable and authorable without the activation-authoring capability | `packages/runtime/src/domains/activation-gate.ts#refuseUngrantedActivationWrite`, `#refuseUngrantedActivationAuthoring` | | 55b | Walled catalog refusal does not fire | metadata-core, rest, plugin-security, runtime | Get: under `isolated` / `group`, a create, change or delete of a position, a permission set or a capability, at the `/meta` write doors (save, reset, publish, rollback), at the permission-set clone door, at the data door on `sys_position`, `sys_permission_set` and `sys_capability`, and at the `/packages` write doors (delete, `publish-drafts`, `discard-drafts`, `revert`, `rollback`, `commits/:commitId/revert`, `duplicate`, `adopt-orphans`) when the items they act on include a position, a permission set or a capability. Every other caller below the platform-operator rung is refused `403` there (ADR-0131 D3), because one organization's write would change what every organization resolves by name. The seeders, the package door and migrations write the catalog this way. Under `single` the refusal is inert for every caller | `packages/metadata-core/src/meta-write-capability.ts#walledCatalogWriteVerdict`, `packages/rest/src/rest-server.ts#walledCatalogRefusal`, `packages/plugins/plugin-security/src/walled-catalog-data-door.ts#createWalledCatalogDataDoorRefusal`, `packages/runtime/src/domains/packages.ts#refuseWalledCatalogPackageWrite` | -| 56 | Automation run-state read, flow-authoring write, the paused-run caller gate (screen read and resume), the two operator run-lifecycle writes and the elevated self-triggered start at every door that starts a flow by name all pass | runtime | Get: run state, flow writes, a paused run's screen and its resume with no grant and without being the run's starter — and cancelling or restoring a suspension without the platform-operator rung. The screen read and the resume ask one predicate, so this is one bypass for both. The lifecycle bypass is the in-process owner's door: plugin-approvals' revise-window recall (ADR-0044) cancels on behalf of a decision it already authorized and recorded. And starting a flow declared `runAs: 'system'` whose type is self-triggered (`autolaunched`, `record_change`, `schedule`) through any door that starts a flow by name — the trigger route, an action of `type: 'flow'` (REST `/actions` and the MCP `run_action` bridge) and a declared endpoint of `type: 'flow'` — which every other caller, the guest principal included, is refused `403 PERMISSION_DENIED` (the maintainer's rulings, letters B and A). The three doors ask one predicate, so this is one bypass for all of them, and it is the in-process caller's, such as a job: inbound HTTP never carries the flag | `packages/runtime/src/domains/automation.ts#mayReadRunState`, `#refuseUngrantedFlowWrite`, `#isRunStarterOrRunStateReader`, `#refuseUngrantedRunLifecycleWrite`, `packages/runtime/src/flow-start-admission.ts#refusesElevatedSelfTriggeredStart` | -| 57 | Audience-binding suggestion authority gate passes | plugin-security | Get: a system caller lists, accepts and dismisses suggestions without `manage_metadata` and, under a wall, without the platform-operator rung | `packages/plugins/plugin-security/src/suggested-audience-bindings.ts#assertDeploymentAuthority` | -| 58 | Email-template organization door passes; webhook provenance stamp skipped | plugin-email, plugin-webhooks | Get (`sys_email_template`): a create or update of a template row. The door refuses every other write that names a caller, `403 PERMISSION_DENIED` (ADR-0131 D6); the plugin itself no longer writes the table (templates resolve from the registry), so the v18 migration ceremony's promotion is the one remaining writer. Lose (`sys_webhook`): the row is not marked as an admin customization | `packages/plugins/plugin-email/src/email-template-door.ts#isOrganizationWrite`, `packages/plugins/plugin-webhooks/src/webhook-provenance.ts#bindWebhookProvenanceStamp` | -| 59 | **Automation flow data nodes re-add the `owner_id` stamp** (the one place row 2's gap is compensated inline) | service-automation | Get: a flow-authored INSERT under system elevation still lands owned, when the run resolved a user. Fill-only — flow-authored values win | `packages/services/service-automation/src/runtime-identity.ts#stampSystemInsertOwner`, called from `packages/services/service-automation/src/builtin/crud-nodes.ts#registerCrudNodes` | -| 60 | Inbox caller refusal names `isSystem` as what was carried | service-messaging | Get: nothing — the refusal still fires. The flag only shapes the diagnostic, because privilege is not an authorization subject | `packages/services/service-messaging/src/inbox-caller.ts#resolveInboxRecipient` | -| 61 | **`organization_id` is not auto-stamped on INSERT** — the organization-axis twin of the `owner_id` gap above | organizations | Get: an elevated write may name another organization deliberately, which is what the per-organization seed replay, the orphan-row claim, imports and migrations all rely on. Lose: the fill-only stamp, so an elevated insert that names no organization lands `organization_id = NULL` and the wall hides it. ⛔ Neither path rewrites a supplied `organization_id`: the non-elevated path fills only an absent one, and a supplied value meets the Layer 0 write wall, which refuses a forged one `403 PERMISSION_DENIED` exactly as it refuses an update re-pointing a row. Elevation skips the stamp and the wall alike, which is why it is the seam the legitimate cross-organization writers use | `packages/plugins/organizations/src/organizations-plugin.ts#start` | +| 57 | Automation run-state read, flow-authoring write, the paused-run caller gate (screen read and resume), the two operator run-lifecycle writes and the elevated self-triggered start at every door that starts a flow by name all pass | runtime | Get: run state, flow writes, a paused run's screen and its resume with no grant and without being the run's starter — and cancelling or restoring a suspension without the platform-operator rung. The screen read and the resume ask one predicate, so this is one bypass for both. The lifecycle bypass is the in-process owner's door: plugin-approvals' revise-window recall (ADR-0044) cancels on behalf of a decision it already authorized and recorded. And starting a flow declared `runAs: 'system'` whose type is self-triggered (`autolaunched`, `record_change`, `schedule`) through any door that starts a flow by name — the trigger route, an action of `type: 'flow'` (REST `/actions` and the MCP `run_action` bridge) and a declared endpoint of `type: 'flow'` — which every other caller, the guest principal included, is refused `403 PERMISSION_DENIED` (the maintainer's rulings, letters B and A). The three doors ask one predicate, so this is one bypass for all of them, and it is the in-process caller's, such as a job: inbound HTTP never carries the flag | `packages/runtime/src/domains/automation.ts#mayReadRunState`, `#refuseUngrantedFlowWrite`, `#isRunStarterOrRunStateReader`, `#refuseUngrantedRunLifecycleWrite`, `packages/runtime/src/flow-start-admission.ts#refusesElevatedSelfTriggeredStart` | +| 58 | Audience-binding suggestion authority gate passes | plugin-security | Get: a system caller lists, accepts and dismisses suggestions without `manage_metadata` and, under a wall, without the platform-operator rung | `packages/plugins/plugin-security/src/suggested-audience-bindings.ts#assertDeploymentAuthority` | +| 59 | Email-template organization door passes; webhook provenance stamp skipped | plugin-email, plugin-webhooks | Get (`sys_email_template`): a create or update of a template row. The door refuses every other write that names a caller, `403 PERMISSION_DENIED` (ADR-0131 D6); the plugin itself no longer writes the table (templates resolve from the registry), so the v18 migration ceremony's promotion is the one remaining writer. Lose (`sys_webhook`): the row is not marked as an admin customization | `packages/plugins/plugin-email/src/email-template-door.ts#isOrganizationWrite`, `packages/plugins/plugin-webhooks/src/webhook-provenance.ts#bindWebhookProvenanceStamp` | +| 60 | **Automation flow data nodes re-add the `owner_id` stamp** (the one place row 2's gap is compensated inline) | service-automation | Get: a flow-authored INSERT under system elevation still lands owned, when the run resolved a user. Fill-only — flow-authored values win | `packages/services/service-automation/src/runtime-identity.ts#stampSystemInsertOwner`, called from `packages/services/service-automation/src/builtin/crud-nodes.ts#registerCrudNodes` | +| 61 | Inbox caller refusal names `isSystem` as what was carried | service-messaging | Get: nothing — the refusal still fires. The flag only shapes the diagnostic, because privilege is not an authorization subject | `packages/services/service-messaging/src/inbox-caller.ts#resolveInboxRecipient` | +| 62 | **`organization_id` is not auto-stamped on INSERT** — the organization-axis twin of the `owner_id` gap above | organizations | Get: an elevated write may name another organization deliberately, which is what the per-organization seed replay, the orphan-row claim, imports and migrations all rely on. Lose: the fill-only stamp, so an elevated insert that names no organization lands `organization_id = NULL` and the wall hides it. ⛔ Neither path rewrites a supplied `organization_id`: the non-elevated path fills only an absent one, and a supplied value meets the Layer 0 write wall, which refuses a forged one `403 PERMISSION_DENIED` exactly as it refuses an update re-pointing a row. Elevation skips the stamp and the wall alike, which is why it is the seam the legitimate cross-organization writers use | `packages/plugins/organizations/src/organizations-plugin.ts#start` | ### 6. Reads that only carry the flag onward @@ -199,9 +200,9 @@ a reader tracing where elevation travels needs them. | # | Site | Package | What it does | |:--|:---|:---|:---| -| 62 | `packages/objectql/src/engine.ts#buildSession` | objectql | Propagates `isSystem` into the hook session so hooks can tell engine self-writes from user writes | -| 63 | `packages/objectql/src/engine.ts#isSystem` | objectql | `ScopedContext.isSystem` getter — re-exposes the underlying execution context's flag | -| 64 | `packages/runtime/src/sandbox/body-runner.ts#executionContextFromHook` | runtime | Rebuilds an `ExecutionContext` from a hook session, carrying the flag across | +| 63 | `packages/objectql/src/engine.ts#buildSession` | objectql | Propagates `isSystem` into the hook session so hooks can tell engine self-writes from user writes | +| 64 | `packages/objectql/src/engine.ts#isSystem` | objectql | `ScopedContext.isSystem` getter — re-exposes the underlying execution context's flag | +| 65 | `packages/runtime/src/sandbox/body-runner.ts#executionContextFromHook` | runtime | Rebuilds an `ExecutionContext` from a hook session, carrying the flag across | --- @@ -369,8 +370,8 @@ restates none of its figures: | Appearances parsed in some other syntactic position | a local, a cast, a conditional | | Of those reads: reads of one of the unrelated metadata fields | subtracted — the collision this page opens with | | Of those reads: reads of `ExecutionContext.isSystem` | **the census answer**: every one sits in a symbol a row above anchors | -| — behaviour-bearing (rows 1–61 above) | sections 1 to 5 | -| — carry the flag onward only (rows 62–64 above) | section 6 | +| — behaviour-bearing (rows 1–62 above) | sections 1 to 5 | +| — carry the flag onward only (rows 63–65 above) | section 6 | `node scripts/isystem-census.mjs` prints how many appearances fell in each syntactic slot, the subtraction, the answer and the packages and files that diff --git a/content/docs/protocol/backward-compatibility.mdx b/content/docs/protocol/backward-compatibility.mdx index a22c5853f86..c6051285b02 100644 --- a/content/docs/protocol/backward-compatibility.mdx +++ b/content/docs/protocol/backward-compatibility.mdx @@ -114,7 +114,7 @@ Read the **Breaking?** column, not the version number: in the 17.x minors every 1. **RFC (Request for Comments)** — Breaking changes are proposed as GitHub issues with the `protocol:breaking` label. 2. **Deprecation** — The old behavior is deprecated in a MINOR release (see timeline above). 3. **Migration Guide** — A detailed migration guide is published before the removal lands, in the release notes for the version that carries it. -4. **Release** — In the 17.x minors the breaking change shipped in the next **MINOR** version, carrying a changeset entry marked `**BREAKING**`, and `scripts/check-changeset-no-major.mjs` failed a pull request that declared a `major` bump, because under lockstep one `major` would promote all 70 published packages. On the v18 line it ships in the next `18.0.0-next.N`: the guard stands aside in pre mode, so a pull request may declare a major bump. See [how a breaking stage lands on the v18 line](/docs/releases/v18#how-a-breaking-stage-lands-on-this-line). +4. **Release** — In the 17.x minors the breaking change shipped in the next **MINOR** version, carrying a changeset entry marked `**BREAKING**`, and `scripts/check-changeset-no-major.mjs` failed a pull request that declared a `major` bump, because under lockstep one `major` would promote all 71 published packages. On the v18 line it ships in the next `18.0.0-next.N`: the guard stands aside in pre mode, so a pull request may declare a major bump. See [how a breaking stage lands on the v18 line](/docs/releases/v18#how-a-breaking-stage-lands-on-this-line). --- @@ -192,7 +192,7 @@ The `@objectstack/spec` package provides additional stability guarantees: ### Which surfaces this covers -**All of them.** It was not scoped to an experimental corner or a pre-release channel: all **70** packages published from this repository belong to a single Changesets `fixed` group, so they share one version number and one policy. No published surface was exempt. +**All of them.** It was not scoped to an experimental corner or a pre-release channel: all **71** packages published from this repository belong to a single Changesets `fixed` group, so they share one version number and one policy. No published surface was exempt. The convention was enforced rather than informal — `scripts/check-changeset-no-major.mjs` failed a pull request that introduced a `major` bump, because under lockstep a single `major` on one package would promote the entire stack. Changesets is now in pre mode with the tag `next`, and the guard stands aside in pre mode, so a pull request on the v18 line may declare a major bump. diff --git a/docs/adr/0139-engine-internal-semi-join-leaf-readable-parent-ids.md b/docs/adr/0139-engine-internal-semi-join-leaf-readable-parent-ids.md index 85bb69572e2..e3b436c0379 100644 --- a/docs/adr/0139-engine-internal-semi-join-leaf-readable-parent-ids.md +++ b/docs/adr/0139-engine-internal-semi-join-leaf-readable-parent-ids.md @@ -60,7 +60,7 @@ classes stay on today's bounded fail-closed probe, by name. ⛔ Not a general su One module decides which rows are judged and turns the answer into a WHERE (`packages/plugins/plugin-audit/src/parent-record-read-gate.ts#computeParentRecordFilter`); readability itself is one -caller-scoped engine read per parent object (`packages/plugins/plugin-audit/src/comment-access-hooks.ts#resolveReadableParentIds`), +caller-scoped engine read per parent object (`packages/core/src/security/readable-parent-ids.ts#resolveReadableParentIds`), so the parent's own CRUD grant, OWD/sharing and RLS decide and no second derivation exists. The bound is `packages/plugins/plugin-audit/src/parent-record-read-gate.ts#PARENT_GATE_SCAN_LIMIT` (2,000), the comment gate's `packages/plugins/plugin-audit/src/comment-access-hooks.ts#READ_SCAN_LIMIT` reused. The consumers, as read on diff --git a/examples/app-showcase/objectstack.config.ts b/examples/app-showcase/objectstack.config.ts index f1777eba834..d728363357a 100644 --- a/examples/app-showcase/objectstack.config.ts +++ b/examples/app-showcase/objectstack.config.ts @@ -103,7 +103,10 @@ export default defineStack({ // blueprint flow to auto-create a writable "app package" home // (ADR-0033 zero-package app building) and the Studio package // selector to list DB packages. - requires: ['ui', 'automation', 'approvals', 'messaging', 'triggers', 'job', 'marketplace', 'webhooks', 'realtime'], + // • mailbox — MailboxServicePlugin: the inbound-mail objects + // (`sys_mailbox`, `sys_mail_message`, `sys_mail_association`) + // and their read gates, served through the data API. + requires: ['ui', 'automation', 'approvals', 'messaging', 'triggers', 'job', 'marketplace', 'webhooks', 'realtime', 'mailbox'], // Concrete connectors for the `connector_action` node. The baseline engine // ships the dispatch node + an empty registry; these plugins populate it. diff --git a/packages/cli/package.json b/packages/cli/package.json index 6c3190acd5a..65856d111bd 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -90,6 +90,7 @@ "@objectstack/service-cache": "workspace:*", "@objectstack/service-datasource": "workspace:*", "@objectstack/service-job": "workspace:*", + "@objectstack/service-mailbox": "workspace:*", "@objectstack/service-messaging": "workspace:*", "@objectstack/service-package": "workspace:*", "@objectstack/service-queue": "workspace:*", diff --git a/packages/cli/src/utils/organization-ownership-inventory.ts b/packages/cli/src/utils/organization-ownership-inventory.ts index 857e3a577e4..e3dc4afa7bd 100644 --- a/packages/cli/src/utils/organization-ownership-inventory.ts +++ b/packages/cli/src/utils/organization-ownership-inventory.ts @@ -521,6 +521,12 @@ export const ORGANIZATION_OWNERSHIP_INVENTORY: readonly InventoryEntry[] = [ { object: 'sys_secret', census: P1, fate: 'attribution', citation: `re-verification 6018712820: sys_secret is tenant-attributed by one producer (moved here from C6); ${D10_SINGLE}`, anchors: [] }, { object: 'sys_scim_connection_credential', census: P1, fate: 'attribution', citation: `${D3_ORG_ROWS}; ${D10_SINGLE}`, anchors: [] }, { object: 'sys_flow_credential', census: PMAIN, fate: 'attribution', citation: `${CENSUS_MAIN} (in reach, added since the first census); ${D10_SINGLE}`, anchors: [] }, + // Inbound mail (`@objectstack/service-mailbox`): every row is written by the + // system with the mailbox's organization stamped (ADR-0131 D9), so a NULL + // row has no producer today; the parents below are where one would take it. + { object: 'sys_mailbox', census: PMAIN, fate: 'attribution', citation: `${CENSUS_MAIN} (in reach, added since the first census); ${D10_SINGLE}`, anchors: [] }, + { object: 'sys_mail_message', census: PMAIN, fate: 'attribution', citation: `${CENSUS_MAIN} (in reach); the message's mailbox carries the organization`, anchors: [parent('mailbox_id', 'sys_mailbox')] }, + { object: 'sys_mail_association', census: PMAIN, fate: 'attribution', citation: `${CENSUS_MAIN} (in reach); the association's message carries the organization`, anchors: [parent('mail_message_id', 'sys_mail_message')] }, { object: 'sys_import_job', census: P1, fate: 'attribution', citation: `${D3_ORG_ROWS}; ${D10_SINGLE}`, anchors: [] }, { object: 'sys_user_preference', census: P1, fate: 'attribution', citation: `${D3_ORG_ROWS}; ${D10_SINGLE}`, anchors: [] }, diff --git a/packages/cli/test/serve-capability-identity.test.ts b/packages/cli/test/serve-capability-identity.test.ts index 2ec76b9b428..4ac01cf72a1 100644 --- a/packages/cli/test/serve-capability-identity.test.ts +++ b/packages/cli/test/serve-capability-identity.test.ts @@ -123,6 +123,7 @@ const EXPECTED_PROVIDER_NAME: Record = { // PackageServicePlugin registers, which the drift block below re-derives. 'package-registry': 'package-service', email: 'com.objectstack.service.email', + mailbox: 'com.objectstack.service.mailbox', sms: 'com.objectstack.service.sms', sharing: 'com.objectstack.service.sharing', 'pinyin-search': 'com.objectstack.plugin.pinyin-search', diff --git a/packages/core/src/capability-providers.ts b/packages/core/src/capability-providers.ts index c436c197007..20c613376ce 100644 --- a/packages/core/src/capability-providers.ts +++ b/packages/core/src/capability-providers.ts @@ -181,6 +181,13 @@ export const CAPABILITY_PROVIDERS: Readonly> = { export: 'EmailServicePlugin', identities: ['com.objectstack.service.email', 'EmailServicePlugin'], }, + // Inbound mail: `sys_mailbox`, `sys_mail_message`, `sys_mail_association` + // and their read gates. Requested, never always-on (ADR-0029 D2). + mailbox: { + pkg: '@objectstack/service-mailbox', + export: 'MailboxServicePlugin', + identities: ['com.objectstack.service.mailbox', 'MailboxServicePlugin'], + }, sms: { // #2780 — backs phone-number OTP sign-in/reset (plugin-auth) and // the messaging `sms` channel. Provider config lives in the `sms` diff --git a/packages/core/src/security/index.ts b/packages/core/src/security/index.ts index 397a792ba11..f7963f7216f 100644 --- a/packages/core/src/security/index.ts +++ b/packages/core/src/security/index.ts @@ -343,6 +343,14 @@ export { withoutOperationPrivateKeys, } from './operation-private-keys.js'; +// The caller-scoped "which of these parent records can the caller read?" probe +// every parent-derived read gate asks (comments, activity, ledger, attachments, +// mail). One owner for the body two packages had hand-copied. +export { + resolveReadableParentIds, + type ReadableParentIdsEngine, +} from './readable-parent-ids.js'; + // [#11968] Authorization caching SUBSTRATE, TTL-bounded and invalidated over a // best-effort cross-node channel — the channel contract and the boot-time // posture statement (#11633 §3, Fork 2 → B, ruled 2026-08-25). No cache lives diff --git a/packages/core/src/security/readable-parent-ids.ts b/packages/core/src/security/readable-parent-ids.ts new file mode 100644 index 00000000000..307ee429ebd --- /dev/null +++ b/packages/core/src/security/readable-parent-ids.ts @@ -0,0 +1,64 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import { withoutOperationPrivateKeys } from './operation-private-keys.js'; + +/** + * The one engine read {@link resolveReadableParentIds} needs: a caller-scoped + * `find`. Structural, so every package's engine facade satisfies it without a + * dependency on the engine package. + */ +export interface ReadableParentIdsEngine { + find(object: string, options: Record): Promise>>; +} + +/** + * Which of these parent records can the CALLER read? Answered per parent + * object by ONE caller-scoped engine read of the candidate ids, so the parent + * object's own OWD/sharing, RLS and object-level CRUD decide. + * + * This is the one answer every parent-derived read gate asks: the comment + * thread and activity / ledger gates (`@objectstack/plugin-audit`), the + * attachment gate and its write door's not-visible check + * (`@objectstack/service-storage`), and the mail association and message gates + * (`@objectstack/service-mailbox`). One evaluator, so a record can never be + * readable under one gate and unreadable under another — ⛔ do not hand-copy + * this probe into a gate, import it. + * + * Batched: one read per parent OBJECT, never one per row. An object whose read + * throws (unknown to the engine, refused, driver fault) contributes no visible + * id — fail closed — and a record that no longer exists is simply not found. + * + * The caller's envelope, minus the operation-private keys + * ({@link withoutOperationPrivateKeys}): this probe reads a DIFFERENT object + * than the one the middleware resolved its depth for, and `__readScope` / + * `__expandRead` are widening inputs that would arrive attached to the wrong + * question (the security middleware re-stamps the depth for THIS object when + * it resolves any set, so the only thing dropping them can do is leave the + * owner-match at its narrowest — the safe direction). + */ +export async function resolveReadableParentIds( + engine: ReadableParentIdsEngine, + callerContext: Record | undefined, + idsByObject: ReadonlyMap>, +): Promise>> { + const callerEnvelope = withoutOperationPrivateKeys((callerContext ?? {}) as Record); + const visibleByObject = new Map>(); + for (const [parentObject, idSet] of idsByObject) { + const ids = [...idSet]; + let visible: string[] = []; + try { + const rows = await engine.find(parentObject, { + where: { id: { $in: ids } }, + fields: ['id'], + limit: ids.length, + context: { ...callerEnvelope }, + }); + visible = rows.map((r) => String(r.id)).filter(Boolean); + } catch { + // Unknown/failing parent object → none visible (fail closed). + visible = []; + } + visibleByObject.set(parentObject, new Set(visible)); + } + return visibleByObject; +} diff --git a/packages/plugins/plugin-audit/src/comment-access-hooks.ts b/packages/plugins/plugin-audit/src/comment-access-hooks.ts index 0caf131192f..08767b94821 100644 --- a/packages/plugins/plugin-audit/src/comment-access-hooks.ts +++ b/packages/plugins/plugin-audit/src/comment-access-hooks.ts @@ -107,7 +107,7 @@ * objects, so the two can never disagree about who is exempt. */ -import { withoutOperationPrivateKeys } from '@objectstack/core'; +import { resolveReadableParentIds, withoutOperationPrivateKeys } from '@objectstack/core'; import type { StandardErrorCode } from '@objectstack/spec/api'; import type { ISecurityService, ISharingService } from '@objectstack/spec/contracts'; import type { ExecutionContext } from '@objectstack/spec/kernel'; @@ -939,52 +939,3 @@ async function computeThreadVisibilityFilter( return { thread_id: { $in: visibleThreads } }; } -/** - * Which of these parent records can the CALLER read? Answered per parent - * object by ONE caller-scoped engine read of the candidate ids, so the parent - * object's own OWD/sharing, RLS and object-level CRUD decide. - * - * This is the one answer every parent-derived read gate in this package asks: - * `sys_comment`'s thread visibility above and `sys_activity`'s parent - * visibility (`activity-read-visibility.ts`). Shared as a function rather than - * restated, so a record can never be readable under one gate and unreadable - * under the other — ⛔ do not hand-copy this probe into a third gate, import it. - * - * Batched: one read per parent OBJECT, never one per row. An object whose read - * throws (unknown to the engine, refused, driver fault) contributes no visible - * id — fail closed — and a record that no longer exists is simply not found. - * - * [#7141] The caller's envelope, minus the operation-private keys: this probe - * reads a DIFFERENT object than the one the middleware resolved its depth for, - * and `__readScope` / `__expandRead` are widening inputs that would arrive - * attached to the wrong question (the security middleware re-stamps the depth - * for THIS object when it resolves any set, so the only thing dropping them can - * do is leave the owner-match at its narrowest — the safe direction). Same rule - * as `callerContext` above. - */ -export async function resolveReadableParentIds( - engine: Pick, - callerContext: Record | undefined, - idsByObject: ReadonlyMap>, -): Promise>> { - const callerEnvelope = withoutOperationPrivateKeys((callerContext ?? {}) as Record); - const visibleByObject = new Map>(); - for (const [parentObject, idSet] of idsByObject) { - const ids = [...idSet]; - let visible: string[] = []; - try { - const rows = await engine.find(parentObject, { - where: { id: { $in: ids } }, - fields: ['id'], - limit: ids.length, - context: { ...callerEnvelope }, - }); - visible = rows.map((r) => String(r.id)).filter(Boolean); - } catch { - // Unknown/failing parent object → none visible (fail closed). - visible = []; - } - visibleByObject.set(parentObject, new Set(visible)); - } - return visibleByObject; -} diff --git a/packages/plugins/plugin-audit/src/parent-record-read-gate.ts b/packages/plugins/plugin-audit/src/parent-record-read-gate.ts index 34200b290c2..e9795fc0f4b 100644 --- a/packages/plugins/plugin-audit/src/parent-record-read-gate.ts +++ b/packages/plugins/plugin-audit/src/parent-record-read-gate.ts @@ -14,7 +14,7 @@ * row, so neither OWD/sharing nor RLS can narrow them. * * ⛔ Nothing here derives readability. "Can this caller read that record" is - * {@link resolveReadableParentIds} (`comment-access-hooks.ts`): one + * {@link resolveReadableParentIds} (`@objectstack/core`): one * caller-scoped engine read per parent object, so the parent's own OWD/sharing, * RLS and object-level CRUD decide. This module only chooses WHICH rows are * judged that way and turns the answer into a WHERE. @@ -53,8 +53,8 @@ * (the activity stream) exempts no caller. */ +import { resolveReadableParentIds } from '@objectstack/core'; import { - resolveReadableParentIds, type CommentAccessEngine, type CommentAccessLogger, type CommentReadMiddlewareCtx, diff --git a/packages/plugins/plugin-security/src/builtin-positions.boot.test.ts b/packages/plugins/plugin-security/src/builtin-positions.boot.test.ts index 11adcea9334..043a2e62f71 100644 --- a/packages/plugins/plugin-security/src/builtin-positions.boot.test.ts +++ b/packages/plugins/plugin-security/src/builtin-positions.boot.test.ts @@ -630,7 +630,7 @@ const GRANT_GOLDEN: Record<'single' | 'isolated', unknown> = { permissions: ['mcp_agent_data_read'], positions: [], principalKind: 'agent', - systemPermissions: ['manage_org_users', 'setup.access', 'setup.write'], + systemPermissions: ['manage_mailboxes', 'manage_org_users', 'setup.access', 'setup.write'], }, member: { accessible_org_ids: ['org_eq'], @@ -648,7 +648,7 @@ const GRANT_GOLDEN: Record<'single' | 'isolated', unknown> = { permissions: ['field_default', 'member_default', 'organization_admin_no_bypass'], positions: ['everyone', 'org_owner'], posture: 'TENANT_ADMIN', - systemPermissions: ['manage_org_users', 'setup.access', 'setup.write'], + systemPermissions: ['manage_mailboxes', 'manage_org_users', 'setup.access', 'setup.write'], }, platformAdmin: { accessible_org_ids: [], @@ -658,6 +658,7 @@ const GRANT_GOLDEN: Record<'single' | 'isolated', unknown> = { positions: ['everyone', 'platform_admin'], posture: 'PLATFORM_ADMIN', systemPermissions: [ + 'manage_mailboxes', 'manage_metadata', 'manage_platform_settings', 'manage_sharing', @@ -678,7 +679,7 @@ const GRANT_GOLDEN: Record<'single' | 'isolated', unknown> = { permissions: ['mcp_agent_data_read'], positions: [], principalKind: 'agent', - systemPermissions: ['manage_org_users', 'setup.access', 'setup.write'], + systemPermissions: ['manage_mailboxes', 'manage_org_users', 'setup.access', 'setup.write'], }, member: { accessible_org_ids: ['org_eq'], @@ -696,7 +697,7 @@ const GRANT_GOLDEN: Record<'single' | 'isolated', unknown> = { permissions: ['field_default', 'member_default', 'organization_admin'], positions: ['everyone', 'org_owner'], posture: 'TENANT_ADMIN', - systemPermissions: ['manage_org_users', 'setup.access', 'setup.write'], + systemPermissions: ['manage_mailboxes', 'manage_org_users', 'setup.access', 'setup.write'], }, platformAdmin: { accessible_org_ids: [], @@ -706,6 +707,7 @@ const GRANT_GOLDEN: Record<'single' | 'isolated', unknown> = { positions: ['everyone', 'platform_admin'], posture: 'PLATFORM_ADMIN', systemPermissions: [ + 'manage_mailboxes', 'manage_metadata', 'manage_platform_settings', 'manage_sharing', diff --git a/packages/plugins/plugin-security/src/grant-permission-set-name-backfill.test.ts b/packages/plugins/plugin-security/src/grant-permission-set-name-backfill.test.ts index 337f63645a1..71459df0090 100644 --- a/packages/plugins/plugin-security/src/grant-permission-set-name-backfill.test.ts +++ b/packages/plugins/plugin-security/src/grant-permission-set-name-backfill.test.ts @@ -701,7 +701,7 @@ const GOLDEN: Record = { permissions: ['mcp_agent_data_read'], positions: [], principalKind: 'agent', - systemPermissions: ['manage_org_users', 'setup.access', 'setup.write'], + systemPermissions: ['manage_mailboxes', 'manage_org_users', 'setup.access', 'setup.write'], }, member: { accessible_org_ids: ['org_eq'], @@ -719,7 +719,7 @@ const GOLDEN: Record = { permissions: ['organization_admin_no_bypass'], positions: ['everyone', 'org_owner'], posture: 'TENANT_ADMIN', - systemPermissions: ['manage_org_users', 'setup.access', 'setup.write'], + systemPermissions: ['manage_mailboxes', 'manage_org_users', 'setup.access', 'setup.write'], }, platformAdmin: { accessible_org_ids: [], @@ -729,7 +729,7 @@ const GOLDEN: Record = { positions: ['everyone', 'platform_admin'], posture: 'PLATFORM_ADMIN', systemPermissions: [ - 'manage_metadata', 'manage_platform_settings', 'manage_sharing', 'manage_users', + 'manage_mailboxes', 'manage_metadata', 'manage_platform_settings', 'manage_sharing', 'manage_users', 'setup.access', 'setup.write', 'studio.access', 'view_all_audit_log', ], }, @@ -740,7 +740,7 @@ const GOLDEN: Record = { permissions: ['mcp_agent_data_read'], positions: [], principalKind: 'agent', - systemPermissions: ['manage_org_users', 'setup.access', 'setup.write'], + systemPermissions: ['manage_mailboxes', 'manage_org_users', 'setup.access', 'setup.write'], }, member: { accessible_org_ids: ['org_eq'], @@ -758,7 +758,7 @@ const GOLDEN: Record = { permissions: ['organization_admin'], positions: ['everyone', 'org_owner'], posture: 'TENANT_ADMIN', - systemPermissions: ['manage_org_users', 'setup.access', 'setup.write'], + systemPermissions: ['manage_mailboxes', 'manage_org_users', 'setup.access', 'setup.write'], }, platformAdmin: { accessible_org_ids: [], @@ -768,7 +768,7 @@ const GOLDEN: Record = { positions: ['everyone', 'platform_admin'], posture: 'PLATFORM_ADMIN', systemPermissions: [ - 'manage_metadata', 'manage_platform_settings', 'manage_sharing', 'manage_users', + 'manage_mailboxes', 'manage_metadata', 'manage_platform_settings', 'manage_sharing', 'manage_users', 'setup.access', 'setup.write', 'studio.access', 'view_all_audit_log', ], }, diff --git a/packages/plugins/plugin-security/src/grant-permission-set-name.equivalence.test.ts b/packages/plugins/plugin-security/src/grant-permission-set-name.equivalence.test.ts index 659cc5371da..abd70167fc2 100644 --- a/packages/plugins/plugin-security/src/grant-permission-set-name.equivalence.test.ts +++ b/packages/plugins/plugin-security/src/grant-permission-set-name.equivalence.test.ts @@ -240,7 +240,7 @@ const GOLDEN: Record<'single' | 'isolated', unknown> = { permissions: ['mcp_agent_data_read'], positions: [], principalKind: 'agent', - systemPermissions: ['manage_org_users', 'setup.access', 'setup.write'], + systemPermissions: ['manage_mailboxes', 'manage_org_users', 'setup.access', 'setup.write'], }, member: { accessible_org_ids: ['org_eq'], @@ -258,7 +258,7 @@ const GOLDEN: Record<'single' | 'isolated', unknown> = { permissions: ['organization_admin_no_bypass'], positions: ['everyone', 'org_owner'], posture: 'TENANT_ADMIN', - systemPermissions: ['manage_org_users', 'setup.access', 'setup.write'], + systemPermissions: ['manage_mailboxes', 'manage_org_users', 'setup.access', 'setup.write'], }, platformAdmin: { accessible_org_ids: [], @@ -268,7 +268,7 @@ const GOLDEN: Record<'single' | 'isolated', unknown> = { positions: ['everyone', 'platform_admin'], posture: 'PLATFORM_ADMIN', systemPermissions: [ - 'manage_metadata', 'manage_platform_settings', 'manage_sharing', 'manage_users', + 'manage_mailboxes', 'manage_metadata', 'manage_platform_settings', 'manage_sharing', 'manage_users', 'setup.access', 'setup.write', 'studio.access', 'view_all_audit_log', ], }, @@ -279,7 +279,7 @@ const GOLDEN: Record<'single' | 'isolated', unknown> = { permissions: ['mcp_agent_data_read'], positions: [], principalKind: 'agent', - systemPermissions: ['manage_org_users', 'setup.access', 'setup.write'], + systemPermissions: ['manage_mailboxes', 'manage_org_users', 'setup.access', 'setup.write'], }, member: { accessible_org_ids: ['org_eq'], @@ -297,7 +297,7 @@ const GOLDEN: Record<'single' | 'isolated', unknown> = { permissions: ['organization_admin'], positions: ['everyone', 'org_owner'], posture: 'TENANT_ADMIN', - systemPermissions: ['manage_org_users', 'setup.access', 'setup.write'], + systemPermissions: ['manage_mailboxes', 'manage_org_users', 'setup.access', 'setup.write'], }, platformAdmin: { accessible_org_ids: [], @@ -307,7 +307,7 @@ const GOLDEN: Record<'single' | 'isolated', unknown> = { positions: ['everyone', 'platform_admin'], posture: 'PLATFORM_ADMIN', systemPermissions: [ - 'manage_metadata', 'manage_platform_settings', 'manage_sharing', 'manage_users', + 'manage_mailboxes', 'manage_metadata', 'manage_platform_settings', 'manage_sharing', 'manage_users', 'setup.access', 'setup.write', 'studio.access', 'view_all_audit_log', ], }, diff --git a/packages/plugins/plugin-security/src/grant-readers-by-name.golden.test.ts b/packages/plugins/plugin-security/src/grant-readers-by-name.golden.test.ts index 972db17ea4c..4d535158d7a 100644 --- a/packages/plugins/plugin-security/src/grant-readers-by-name.golden.test.ts +++ b/packages/plugins/plugin-security/src/grant-readers-by-name.golden.test.ts @@ -357,7 +357,7 @@ const SINGLE: unknown = { permissions: ['organization_admin_no_bypass'], positions: ['everyone', 'org_owner'], posture: 'TENANT_ADMIN', - systemPermissions: ['manage_org_users', 'setup.access', 'setup.write'], + systemPermissions: ['manage_mailboxes', 'manage_org_users', 'setup.access', 'setup.write'], tabPermissions: null, }, reconcile: { action: 'noop' }, @@ -371,6 +371,7 @@ const SINGLE: unknown = { positions: ['everyone', 'platform_admin'], posture: 'PLATFORM_ADMIN', systemPermissions: [ + 'manage_mailboxes', 'manage_metadata', 'manage_platform_settings', 'manage_sharing', @@ -448,7 +449,7 @@ const WALLED: unknown = { permissions: ['organization_admin'], positions: ['everyone', 'org_owner'], posture: 'TENANT_ADMIN', - systemPermissions: ['manage_org_users', 'setup.access', 'setup.write'], + systemPermissions: ['manage_mailboxes', 'manage_org_users', 'setup.access', 'setup.write'], tabPermissions: null, }, reconcile: { action: 'noop' }, @@ -462,6 +463,7 @@ const WALLED: unknown = { positions: ['everyone', 'platform_admin'], posture: 'PLATFORM_ADMIN', systemPermissions: [ + 'manage_mailboxes', 'manage_metadata', 'manage_platform_settings', 'manage_sharing', diff --git a/packages/plugins/plugin-security/src/objects/default-permission-sets.test.ts b/packages/plugins/plugin-security/src/objects/default-permission-sets.test.ts index 9088a876919..0b326974e78 100644 --- a/packages/plugins/plugin-security/src/objects/default-permission-sets.test.ts +++ b/packages/plugins/plugin-security/src/objects/default-permission-sets.test.ts @@ -391,6 +391,9 @@ describe('sys_comment_reaction delete is ownership-shaped (#22566)', () => { * #21175 — platform administrators hold the compliance ledger's audit * capability by default). And one more, on #21237: the `fields` block keeping the * identity object's `Admin` field group, which the `everyone` baseline withholds. + * And `manage_mailboxes` (ruling 6116489638, Q4 → A on the inbound-mail record — + * platform and organization administrators hold the mailbox administrator + * capability by default). */ describe('admin_full_access imports the kernel capability declaration unchanged (#11965)', () => { it('parsed declaration deep-equals the pre-#11965 inline literal', () => { @@ -418,6 +421,8 @@ describe('admin_full_access imports the kernel capability declaration unchanged 'studio.access', // [#21260] added on purpose — see the docblock above. 'view_all_audit_log', + // added on purpose — see the docblock above. + 'manage_mailboxes', ], // [#21237] added on purpose — see the docblock above. The group is read off // the identity object's declaration, never listed here. diff --git a/packages/plugins/plugin-security/src/objects/default-permission-sets.ts b/packages/plugins/plugin-security/src/objects/default-permission-sets.ts index 43611a67ec5..a84f32fdc46 100644 --- a/packages/plugins/plugin-security/src/objects/default-permission-sets.ts +++ b/packages/plugins/plugin-security/src/objects/default-permission-sets.ts @@ -484,7 +484,10 @@ const baseDefaultPermissionSets: PermissionSet[] = [ // the ledger is append-only and written under system context. sys_audit_log: { allowRead: true, allowCreate: false, allowEdit: false, allowDelete: false }, }, - systemPermissions: ['manage_org_users', 'setup.access', 'setup.write'], + // `manage_mailboxes` (ruling 6116489638, Q4 → A): an organization + // administrator reads its organization's mailboxes and their unassociated + // pool; the organization wall bounds it to its own organization's rows. + systemPermissions: ['manage_org_users', 'setup.access', 'setup.write', 'manage_mailboxes'], // [#21237] Keeps the identity object's `Admin` group for an org admin, who // resolves `member_default` too; the derived no-bypass variant carries it // over. See `identityAdminFieldSecurity`. diff --git a/packages/plugins/plugin-security/src/resolve-authz-grant-set-by-name.golden.test.ts b/packages/plugins/plugin-security/src/resolve-authz-grant-set-by-name.golden.test.ts index e731cf7d8ed..ef3852ecb81 100644 --- a/packages/plugins/plugin-security/src/resolve-authz-grant-set-by-name.golden.test.ts +++ b/packages/plugins/plugin-security/src/resolve-authz-grant-set-by-name.golden.test.ts @@ -350,6 +350,7 @@ const SINGLE: unknown = { ], posture: 'TENANT_ADMIN', systemPermissions: [ + 'manage_mailboxes', 'manage_org_users', 'setup.access', 'setup.write' @@ -392,6 +393,7 @@ const SINGLE: unknown = { ], posture: 'PLATFORM_ADMIN', systemPermissions: [ + 'manage_mailboxes', 'manage_metadata', 'manage_platform_settings', 'manage_sharing', @@ -417,6 +419,7 @@ const SINGLE: unknown = { ], posture: 'PLATFORM_ADMIN', systemPermissions: [ + 'manage_mailboxes', 'manage_metadata', 'manage_platform_settings', 'manage_sharing', @@ -530,6 +533,7 @@ const WALLED: unknown = { ], posture: 'TENANT_ADMIN', systemPermissions: [ + 'manage_mailboxes', 'manage_org_users', 'setup.access', 'setup.write' @@ -572,6 +576,7 @@ const WALLED: unknown = { ], posture: 'PLATFORM_ADMIN', systemPermissions: [ + 'manage_mailboxes', 'manage_metadata', 'manage_platform_settings', 'manage_sharing', @@ -597,6 +602,7 @@ const WALLED: unknown = { ], posture: 'PLATFORM_ADMIN', systemPermissions: [ + 'manage_mailboxes', 'manage_metadata', 'manage_platform_settings', 'manage_sharing', diff --git a/packages/qa/dogfood/test/mail-message-pool-read-gate.dogfood.test.ts b/packages/qa/dogfood/test/mail-message-pool-read-gate.dogfood.test.ts new file mode 100644 index 00000000000..0c9642cea00 --- /dev/null +++ b/packages/qa/dogfood/test/mail-message-pool-read-gate.dogfood.test.ts @@ -0,0 +1,250 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. +// +// [#22870] The inbound-mail objects on a showcase boot, through the data API: +// `sys_mail_message` is registered and served, and its unassociated pool is +// RESTRICTED by default — mailbox owners and mailbox administrators only, never +// every member who holds the object's read grant. +// +// ## The composition +// +// The showcase as `objectstack serve` composes it (`bootShowcase`), whose +// `requires` names `mailbox`, so `MailboxServicePlugin` registers the three +// objects and installs both read gates. A fixture security plugin adds three +// permission sets: object read on the mail objects (and on the showcase +// contact the fixture associates mail with), and the explicit grant of the +// mailbox administrator capability. Principals: +// +// - READER: holds the mail read set and owns one contact (its own record); +// - OWNER: holds the mail read set and owns the fixture mailbox; +// - HOLDER: holds the mail read set and the capability; +// - OUTSIDER: holds no grant on the mail objects (the 403 control); +// - the seeded platform administrator (a default holder of the capability). +// +// The rows, written as the system as the ingest service writes them: +// - one mailbox, owned by OWNER; +// - message ASSOCIATED: linked to the reader's own contact; +// - message FOREIGN: linked to a contact the reader cannot read; +// - message POOL: no association at all (the unassociated pool). +// +// `@objectstack/service-mailbox` resolves through its BUILT output here (it is +// reached through `@objectstack/verify`'s provider table), so a verdict on a +// change to the gates is a verdict on their last build. Fixtures are synthetic. +// ⚠️ No test title states a value. + +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import { type VerifyStack } from '@objectstack/verify'; +import { SecurityPlugin, securityDefaultPermissionSets } from '@objectstack/plugin-security'; +import { ADMIN_FULL_ACCESS_CAPABILITIES } from '@objectstack/spec'; +import { PermissionSetSchema, PLATFORM_CAPABILITIES } from '@objectstack/spec/security'; +import { bootShowcase } from './showcase-boot.js'; +import { assertArmed, armedWhen } from './armed.js'; + +/** The capability under test, as `PLATFORM_CAPABILITIES` declares it. */ +const CAP = 'manage_mailboxes'; +const MESSAGE = 'sys_mail_message'; +const ASSOCIATION = 'sys_mail_association'; +const MAILBOX = 'sys_mailbox'; +const CONTACT = 'showcase_contact'; +const SYS = { isSystem: true } as const; +const DOMAIN = 'mail-pool-22870.verify.test'; +const PASSWORD = 'Mail-Pool-Pass-22870'; + +const read = { allowRead: true, allowCreate: false, allowEdit: false, allowDelete: false }; +/** + * Object read on the three mail objects and the contact the fixture associates + * mail with. The mailbox directory's grant is here too, so a refusal of it + * below comes from the directory's capability requirement, not from a missing + * grant. + */ +const mailReadSet = PermissionSetSchema.parse({ + name: 'mail_pool_22870_read', + label: 'Mail read (fixture)', + objects: { [MESSAGE]: read, [ASSOCIATION]: read, [MAILBOX]: read, [CONTACT]: read }, +}); +/** The explicit grant of the mailbox administrator capability. */ +const mailAdminSet = PermissionSetSchema.parse({ + name: 'mail_pool_22870_admin', + label: 'Mailbox administrator (fixture)', + objects: {}, + systemPermissions: [CAP], +}); + +type Who = 'admin' | 'reader' | 'owner' | 'holder' | 'outsider'; +type Row = Record; +const rowsOf = (body: any): Row[] => body?.records ?? body?.data ?? (Array.isArray(body) ? body : []); +const filterOf = (where: Record) => encodeURIComponent(JSON.stringify(where)); + +describe('[#22870] the inbound-mail pool is restricted to mailbox owners and administrators, through the data API on a showcase boot', () => { + let stack: VerifyStack; + let ql: any; + const token = {} as Record; + const uid = {} as Record; + const id = { mailbox: '', ownContact: '', foreignContact: '', associated: '', foreign: '', pool: '' }; + + const atRest = async (object: string, where: Record): Promise => + (await ql.find(object, { where, limit: 50, context: { ...SYS } })) ?? []; + const byId = async (who: Who, object: string, rowId: string) => { + const res = await stack.apiAs(token[who], 'GET', `/data/${object}/${rowId}`); + const body: any = await res.json().catch(() => ({})); + return { status: res.status, code: body?.code ?? body?.error?.code, body }; + }; + const list = async (who: Who, object: string, where: Record) => { + const res = await stack.apiAs(token[who], 'GET', `/data/${object}?$filter=${filterOf(where)}&limit=50`); + expect(res.status, `${who} lists ${object}`).toBe(200); + const body: any = await res.json(); + return { ids: rowsOf(body).map((r) => String(r.id)).sort(), total: Number(body.total) }; + }; + const inMailbox = () => ({ mailbox_id: id.mailbox }); + + beforeAll(async () => { + stack = await bootShowcase({ + security: new SecurityPlugin({ + defaultPermissionSets: [...securityDefaultPermissionSets, mailReadSet, mailAdminSet], + }), + }); + ql = await stack.kernel.getServiceAsync('objectql'); + token.admin = await stack.signIn(); // the seeded platform administrator + const userIdOf = async (email: string) => String((await atRest('sys_user', { email }))[0]?.id ?? ''); + uid.admin = await userIdOf('admin@objectos.ai'); + const grant = async (who: Who, setName: string) => { + const [set] = await atRest('sys_permission_set', { name: setName }); + expect(set?.id, `fixture permission set ${setName} seeded`).toBeTruthy(); + await ql.insert('sys_user_permission_set', { user_id: uid[who], permission_set: set.name }, { context: { ...SYS } }); + }; + for (const who of ['reader', 'owner', 'holder', 'outsider'] as const) { + const email = `${who}@${DOMAIN}`; + token[who] = await stack.signUp(email, PASSWORD, `Mail Pool ${who}`); + uid[who] = await userIdOf(email); + } + for (const who of ['reader', 'owner', 'holder'] as const) await grant(who, mailReadSet.name); + await grant('holder', mailAdminSet.name); + + // The organization every fixture row is stamped with: the reader's own, + // as the ingest service stamps a message with its mailbox's organization. + const [membership] = await atRest('sys_member', { user_id: uid.reader }); + const organization = membership?.organization_id ? { organization_id: membership.organization_id } : {}; + const insert = async (object: string, data: Record) => + String((await ql.insert(object, { ...organization, ...data }, { context: { ...SYS } })).id); + + id.ownContact = await insert(CONTACT, { name: 'Mail Pool Own', email: `own@${DOMAIN}`, owner_id: uid.reader }); + id.foreignContact = await insert(CONTACT, { name: 'Mail Pool Foreign', email: `foreign@${DOMAIN}`, owner_id: uid.admin }); + id.mailbox = await insert(MAILBOX, { name: 'Mail Pool Inbox', address: `inbox@${DOMAIN}`, owner_user_id: uid.owner }); + const message = (key: string, state: 'associated' | 'unassociated') => + insert(MESSAGE, { + mailbox_id: id.mailbox, + subject: `Mail pool ${key}`, + from_address: `${key}@${DOMAIN}`, + received_at: new Date().toISOString(), + association_state: state, + dedupe_key: `mail-pool-22870-${key}`, + }); + id.associated = await message('associated', 'associated'); + id.foreign = await message('foreign', 'associated'); + id.pool = await message('pool', 'unassociated'); + const link = (messageId: string, recordId: string, address: string) => + insert(ASSOCIATION, { + mail_message_id: messageId, + object_name: CONTACT, + record_id: recordId, + matched_address: address, + matched_field: 'email', + matched_by: 'ingest', + }); + await link(id.associated, id.ownContact, `own@${DOMAIN}`); + await link(id.foreign, id.foreignContact, `foreign@${DOMAIN}`); + + await assertArmed([ + armedWhen({ + control: 'the declaration: the capability is a curated organization capability, and the platform administrator grant carries it', + disarmedBy: 'a capability nobody declares or grants would make the exemption cases a statement about a string', + observe: async () => ({ + declared: PLATFORM_CAPABILITIES.find((c) => c.name === CAP)?.scope ?? 'undeclared', + adminGrant: (ADMIN_FULL_ACCESS_CAPABILITIES.systemPermissions ?? []).includes(CAP), + }), + armed: (o) => o.declared === 'org' && o.adminGrant, + describe: (o) => JSON.stringify(o), + }), + armedWhen({ + control: 'at rest: three messages in the fixture mailbox, two associations, and the pool message has none', + disarmedBy: 'a fixture write that never landed would let every refusal below pass on a row that does not exist', + observe: async () => ({ + messages: (await atRest(MESSAGE, inMailbox())).length, + associations: (await atRest(ASSOCIATION, { object_name: CONTACT, record_id: { $in: [id.ownContact, id.foreignContact] } })).length, + poolLinks: (await atRest(ASSOCIATION, { mail_message_id: id.pool })).length, + }), + armed: (o) => o.messages === 3 && o.associations === 2 && o.poolLinks === 0, + describe: (o) => JSON.stringify(o), + }), + armedWhen({ + control: 'the reader reads its own contact and not the foreign one (the record half of the gate has something to decide)', + disarmedBy: 'a reader who could read both contacts, or neither, would make the associated and foreign cases indistinguishable', + observe: async () => ({ + own: (await byId('reader', CONTACT, id.ownContact)).status, + foreign: (await byId('reader', CONTACT, id.foreignContact)).status, + }), + armed: (o) => o.own === 200 && o.foreign === 404, + describe: (o) => JSON.stringify(o), + }), + ]); + }, 240_000); + + afterAll(async () => { + await stack?.stop?.(); + }); + + it('the object is registered and served: the reader reads a message associated with its own record (positive control)', async () => { + const a = await byId('reader', MESSAGE, id.associated); + expect(a.status).toBe(200); + expect(String((a.body?.record ?? a.body?.data ?? a.body)?.id)).toBe(id.associated); + }); + + it('the pin: an ordinary member reading an unassociated message is refused as not found', async () => { + const a = await byId('reader', MESSAGE, id.pool); + expect(a.status).toBe(404); + expect(a.code).toBe('RECORD_NOT_FOUND'); + }); + + it('a message associated only with a record the reader cannot read is refused as not found', async () => { + const a = await byId('reader', MESSAGE, id.foreign); + expect(a.status).toBe(404); + expect(a.code).toBe('RECORD_NOT_FOUND'); + }); + + it('list: the reader is served only the associated message, and the total agrees with the rows', async () => { + expect(await list('reader', MESSAGE, inMailbox())).toEqual({ ids: [id.associated], total: 1 }); + }); + + it('list: the reader is served only the association naming its own record', async () => { + const served = await list('reader', ASSOCIATION, { mail_message_id: { $in: [id.associated, id.foreign] } }); + expect(served.total).toBe(1); + const [row] = await atRest(ASSOCIATION, { mail_message_id: id.associated }); + expect(served.ids).toEqual([String(row.id)]); + }); + + it('the mailbox owner is served every message of its mailbox, the pool included', async () => { + expect((await byId('owner', MESSAGE, id.pool)).status).toBe(200); + expect(await list('owner', MESSAGE, inMailbox())).toEqual({ + ids: [id.associated, id.foreign, id.pool].sort(), + total: 3, + }); + }); + + it('a holder of the mailbox administrator capability is served the pool (the exemption control)', async () => { + expect((await byId('holder', MESSAGE, id.pool)).status).toBe(200); + expect((await list('holder', MESSAGE, inMailbox())).total).toBe(3); + expect((await byId('admin', MESSAGE, id.pool)).status).toBe(200); + }); + + it('a member with no grant on the object is refused at the object, not at the gate (the 403 control)', async () => { + const a = await byId('outsider', MESSAGE, id.pool); + expect(a.status).toBe(403); + expect(a.code).toBe('PERMISSION_DENIED'); + }); + + it('the mailbox directory is served only to a holder of the capability, its owner included in the refusal', async () => { + expect((await byId('holder', MAILBOX, id.mailbox)).status).toBe(200); + const a = await byId('owner', MAILBOX, id.mailbox); + expect(a.status).toBe(403); + expect(a.code).toBe('PERMISSION_DENIED'); + }); +}); diff --git a/packages/services/service-mailbox/LICENSE b/packages/services/service-mailbox/LICENSE new file mode 100644 index 00000000000..16bc23f4043 --- /dev/null +++ b/packages/services/service-mailbox/LICENSE @@ -0,0 +1,202 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute + must include a readable copy of the attribution notices + contained within such NOTICE file, excluding those notices + that do not pertain to any part of the Derivative Works, + in at least one of the following places: within a NOTICE + text file distributed as part of the Derivative Works; within + the Source form or documentation, if provided along with + the Derivative Works; or, within a display generated by the + Derivative Works, if and wherever such third-party notices + normally appear. The contents of the NOTICE file are for + informational purposes only and do not modify the License. + You may add Your own attribution notices within Derivative + Works that You distribute, alongside or as an addendum to + the NOTICE text from the Work, provided that such additional + attribution notices cannot be construed as modifying the + License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright 2026 ObjectStack + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/packages/services/service-mailbox/README.md b/packages/services/service-mailbox/README.md new file mode 100644 index 00000000000..510621b67b6 --- /dev/null +++ b/packages/services/service-mailbox/README.md @@ -0,0 +1,29 @@ +# @objectstack/service-mailbox + +Inbound mail for ObjectStack: the mirrored message, its links to an app's records, and the read gates that keep a mailbox's unmatched mail restricted. + +> ⚠️ **Nothing ingests mail yet.** This release ships the objects and their read gates only. The IMAP connector and the ingest engine (deduplication, association stamping from the email fields an app flags with `mailAssociation`, and re-match) are not yet shipped, so no row is written by this package today. + +## What this package provides + +- **`MailboxServicePlugin`** — the provider of the `mailbox` capability. An app opts in with `requires: ['mailbox']`; it is not mounted by default. It registers three system objects, each `engine-owned` and served by the data API through `get` and `list` only: + - **`sys_mailbox`** — a mailbox's identity: `name`, `address` and `owner_user_id`. Served only to holders of `manage_mailboxes` (`requiredPermissions`). + - **`sys_mail_message`** — a faithful RFC 5322 mirror of one inbound message: participants, Message-ID and threading headers, subject, text and HTML bodies (the HTML body is stored as plain text, never rendered unsanitized), and attachments as `sys_attachment` rows. A message no record matched has `association_state: 'unassociated'` and waits in the mailbox's pool. + - **`sys_mail_association`** — one row per link between a message and a record of the app's own objects (`object_name`, `record_id`), with the participant address and email field that matched. +- **Two read gates**, installed as data middleware on `find`, `findOne`, `count` and `aggregate`, so a list's `total` agrees with its rows: + - a **message** is served to a caller who can read a record it is associated with, to the owner of its mailbox, or to a holder of `manage_mailboxes`. An unassociated message has no record to read, so an ordinary member is refused it as not found (`404 RECORD_NOT_FOUND`): the pool is restricted, never organization-wide; + - an **association** is served to a caller who can read the record it names, or to a holder of `manage_mailboxes`. +- **`manage_mailboxes`** — an organization capability declared in `@objectstack/spec`. Platform administrators and organization administrators hold it by default; every other position only by explicit grant. Object grants and field-level security still apply to a holder. + +## Usage + +```typescript +import { defineStack } from '@objectstack/spec'; + +export default defineStack({ + // … + requires: ['mailbox'], +}); +``` + +Grant object read on `sys_mail_message` and `sys_mail_association` to the positions that should see mail on the records they can read; grant `manage_mailboxes` to the people who administer mailboxes and their unassociated pool. diff --git a/packages/services/service-mailbox/package.json b/packages/services/service-mailbox/package.json new file mode 100644 index 00000000000..683d0eaf92d --- /dev/null +++ b/packages/services/service-mailbox/package.json @@ -0,0 +1,63 @@ +{ + "name": "@objectstack/service-mailbox", + "version": "17.7.0", + "license": "Apache-2.0", + "description": "Mailbox Service for ObjectStack \u2014 inbound mail: the mirrored message (sys_mail_message), its record associations and their read gates.", + "type": "module", + "main": "dist/index.js", + "types": "dist/index.d.ts", + "exports": { + ".": { + "import": { + "types": "./dist/index.d.ts", + "default": "./dist/index.js" + }, + "require": { + "types": "./dist/index.d.cts", + "default": "./dist/index.cjs" + } + } + }, + "scripts": { + "build": "tsup --config ../../../tsup.config.ts && node ../../../scripts/check-dts-emitted.mjs", + "typecheck": "tsc --noEmit && tsc --noEmit -p tsconfig.scripts.json", + "test": "vitest run" + }, + "dependencies": { + "@objectstack/core": "workspace:*", + "@objectstack/platform-objects": "workspace:*", + "@objectstack/spec": "workspace:*" + }, + "devDependencies": { + "@objectstack/lint": "workspace:*", + "@types/node": "^26.6.3", + "typescript": "^6.0.3", + "vitest": "^4.1.11" + }, + "keywords": [ + "objectstack", + "service", + "mailbox", + "inbound-mail", + "email" + ], + "author": "ObjectStack", + "repository": { + "type": "git", + "url": "https://github.com/objectstack-ai/objectstack.git", + "directory": "packages/services/service-mailbox" + }, + "homepage": "https://objectstack.ai/docs", + "bugs": "https://github.com/objectstack-ai/objectstack/issues", + "publishConfig": { + "access": "public" + }, + "files": [ + "dist", + "README.md", + "CHANGELOG.md" + ], + "engines": { + "node": ">=22.0.0" + } +} diff --git a/packages/services/service-mailbox/scripts/i18n-extract.config.ts b/packages/services/service-mailbox/scripts/i18n-extract.config.ts new file mode 100644 index 00000000000..08692178301 --- /dev/null +++ b/packages/services/service-mailbox/scripts/i18n-extract.config.ts @@ -0,0 +1,69 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * Build-time only config for `os i18n extract` (ADR-0029 D8). Not deployed. + * The plugin owns the i18n extraction for the objects it owns; the + * `translations` baseline is this plugin's OWN generated bundles so re-running + * `--merge` preserves every hand-translated string. + * + * os i18n extract packages/services/service-mailbox/scripts/i18n-extract.config.ts \ + * --locales=zh-CN,ja-JP,es-ES --fill=default --objects-only --no-metadata-forms \ + * --source-hashes \ + * --out=packages/services/service-mailbox/src/translations + * + * The `source-hashes` flag on the command above also emits + * `.source-hashes.generated.ts` — the provenance companion from + * maintainer ruling #12069 Option A (#11671), rolled out to every bundle set by + * #12559. Without it every generated leaf here is LEGACY-TRUSTED: a leaf filled + * from the source and then left behind when the source was revised is + * indistinguishable BY VALUE from a real translation, so it publishes a + * superseded draft under a green `check:i18n` forever, and + * `check:i18n-stale-fill` cannot see it either unless two locales happen to + * hold the same stale bytes. + * + * A record is written only for a leaf that IS currently a byte copy of the + * CURRENT source, so the companion arrives 0-stale by construction and only + * ever reports drift accruing afterwards. Records count the leaves currently + * RECORDABLE, never the leaves covered — a table with few entries, or with none + * at all where a locale is fully translated, is the instrument armed, and an + * entry appears by itself on the first extract after a leaf becomes a fill. + * + * The flag is named in prose WITHOUT its leading dashes on purpose: + * `flagsFromDocstring` (scripts/i18n-bundle-surface.mjs) harvests every + * recognised flag spelling out of this whole comment, so a second spelling + * would keep the opt-in switched on after someone deleted it from the command + * block — the one place that decides. + */ + +import { defineStack, type ObjectStackDefinition } from '@objectstack/spec'; +import { SysMailbox, SysMailMessage, SysMailAssociation } from '../src/objects/index.js'; +import { enObjects } from '../src/translations/en.objects.generated.js'; +import { zhCNObjects } from '../src/translations/zh-CN.objects.generated.js'; +import { jaJPObjects } from '../src/translations/ja-JP.objects.generated.js'; +import { esESObjects } from '../src/translations/es-ES.objects.generated.js'; + +/** + * The annotation is load-bearing, not decoration (#10868). `defineStack` + * already RETURNS `ObjectStackDefinition`, but that type is + * `z.input` — a generic instantiation the + * declaration emitter does not preserve as an alias, so an un-annotated + * `export default` is emitted as the STRUCTURAL expansion instead. That + * expansion mentions `FormFieldInput` / `NavigationItemInput` / + * `StateNodeConfig`, which the root `@objectstack/spec` entry does not + * re-export, so tsc falls back to naming them through the file that physically + * declares them — a hash-named internal dist chunk it cannot address through + * the package's `exports` map (TS2883, "likely not portable"). Naming the + * public root-entry type here is what the diagnostic asks for and costs no + * precision: the annotated type is the function's own return type. + */ +const config: ObjectStackDefinition = defineStack({ + objects: [SysMailbox, SysMailMessage, SysMailAssociation] as any, + translations: [ + { en: { objects: enObjects } }, + { 'zh-CN': { objects: zhCNObjects } }, + { 'ja-JP': { objects: jaJPObjects } }, + { 'es-ES': { objects: esESObjects } }, + ], +}); + +export default config; diff --git a/packages/services/service-mailbox/src/index.ts b/packages/services/service-mailbox/src/index.ts new file mode 100644 index 00000000000..d7bc61044d9 --- /dev/null +++ b/packages/services/service-mailbox/src/index.ts @@ -0,0 +1,17 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +export { MailboxServicePlugin, MAILBOX_SERVICE_MANIFEST_ID } from './mailbox-service-plugin.js'; +export { SysMailbox, SysMailMessage, SysMailAssociation } from './objects/index.js'; +export { + MAILBOX_ADMIN_CAPABILITY, + MAIL_MESSAGE_OBJECT, + MAIL_ASSOCIATION_OBJECT, + MAILBOX_OBJECT, + MAIL_GATE_SCAN_LIMIT, + computeMailMessageFilter, + computeMailAssociationFilter, + installMailReadVisibility, + type MailGateEngine, + type MailGateLogger, + type MailReadMiddlewareCtx, +} from './mail-read-visibility.js'; diff --git a/packages/services/service-mailbox/src/mail-read-visibility.ts b/packages/services/service-mailbox/src/mail-read-visibility.ts new file mode 100644 index 00000000000..551782582a4 --- /dev/null +++ b/packages/services/service-mailbox/src/mail-read-visibility.ts @@ -0,0 +1,325 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * The two inbound-mail read gates. + * + * Both objects are written once, as the system, by the ingest service, and + * carry no owner column; a message is about records of OTHER objects, and the + * set of those records is a different set on every row. So neither OWD/sharing + * nor RLS can say who may read one, and a data middleware does — the + * parent-record read gate shape (`@objectstack/plugin-audit`, + * `parent-record-read-gate.ts`), whose one readability answer is + * {@link resolveReadableParentIds} (`@objectstack/core`): one caller-scoped + * read per record object, so each record's own OWD/sharing, RLS and + * object-level CRUD decide. + * + * - `sys_mail_association`: a row is served when the caller can read the + * record its (`object_name`, `record_id`) pair names. + * - `sys_mail_message`: a row is served when the caller can read a record one + * of its association rows names, OR owns the mailbox the message arrived in + * (`sys_mailbox.owner_user_id`). + * + * A message no record matched — the unassociated POOL — has no association, so + * only its mailbox's owner (and the exemption below) is served it: the pool is + * restricted by default, never organization-wide. + * + * The one declared exemption on both gates is the capability + * {@link MAILBOX_ADMIN_CAPABILITY}: its holder is not narrowed by them at all. + * Only these gates are skipped; the objects' grants, field-level security and + * every other read seam still apply. Held means the caller's resolved + * `systemPermissions`, the capability set the request's authorization resolver + * stamps on the execution context. + * + * Mechanism, for one read: pre-scan the rows the query would touch under + * SYSTEM context (bounded at {@link MAIL_GATE_SCAN_LIMIT}, in the caller's own + * order), decide which of them the caller may read, and AND an `id`/pair `$in` + * into the read's WHERE. The middleware covers `count` and `aggregate` too, so + * a list's `total` excludes what its rows exclude. + * + * Fails CLOSED: a row this module cannot authorize is excluded (a malformed or + * self-referential pair, a record that no longer exists, a record object the + * caller cannot read or the engine does not know); rows past a scan bound are + * excluded and said so; a filter-compute failure denies the whole read. System + * and context-less reads are internal and are not narrowed. + */ + +import { resolveReadableParentIds } from '@objectstack/core'; + +/** The gated message object. */ +export const MAIL_MESSAGE_OBJECT = 'sys_mail_message'; +/** The gated association object. */ +export const MAIL_ASSOCIATION_OBJECT = 'sys_mail_association'; +/** The mailbox directory the owner limb reads. */ +export const MAILBOX_OBJECT = 'sys_mailbox'; + +/** + * The mailbox administrator capability: its holder is exempt from both gates. + * Declared in `PLATFORM_CAPABILITIES` (`@objectstack/spec/security`) and + * granted by `ADMIN_FULL_ACCESS_CAPABILITIES` and `organization_admin`; this + * spelling is pinned against the declaration in this package's tests. + */ +export const MAILBOX_ADMIN_CAPABILITY = 'manage_mailboxes'; + +/** The engine reads a mail read gate answers for. */ +export const MAIL_GATE_READ_OPS: ReadonlySet = new Set(['find', 'findOne', 'count', 'aggregate']); + +/** Bound on each per-read pre-scan — the parent-record gate's bound. Beyond it + * the filter fails CLOSED (the un-scanned rows are excluded, never leaked). */ +export const MAIL_GATE_SCAN_LIMIT = 2_000; + +/** No real message row matches it — the fail-closed answer. */ +export const MAIL_MESSAGE_DENY_ALL: Readonly> = Object.freeze({ id: '__mail_message_denied__' }); +/** No real association row matches it — the fail-closed answer. */ +export const MAIL_ASSOCIATION_DENY_ALL: Readonly> = Object.freeze({ id: '__mail_association_denied__' }); + +const SYSTEM_CTX = { isSystem: true } as const; + +/** Object machine-name shape (`ObjectSchema.name` in packages/spec). */ +const OBJECT_NAME_RE = /^[a-z_][a-z0-9_]*$/; + +/** The mail objects themselves: never a record a message is about, and probing + * one would re-enter these gates. */ +const MAIL_OBJECTS: ReadonlySet = new Set([MAIL_MESSAGE_OBJECT, MAIL_ASSOCIATION_OBJECT, MAILBOX_OBJECT]); + +/** The read seam's context, as the engine's middleware hands it. */ +export interface MailReadMiddlewareCtx { + object: string; + operation: string; + ast?: { where?: unknown; orderBy?: unknown } & Record; + context?: { userId?: unknown; isSystem?: boolean; systemPermissions?: unknown } & Record; +} + +/** The engine surface the gates use. */ +export interface MailGateEngine { + find(object: string, options: Record): Promise>>; + registerMiddleware?( + fn: (ctx: MailReadMiddlewareCtx, next: () => Promise) => Promise, + options?: { object?: string }, + ): void; +} + +/** The logger surface the gates use. */ +export interface MailGateLogger { + warn(message: string): void; +} + +/** Whether the caller holds `capability` in its resolved `systemPermissions`. */ +function callerHolds(context: MailReadMiddlewareCtx['context'], capability: string): boolean { + const held = context?.systemPermissions; + return Array.isArray(held) && held.includes(capability); +} + +/** A stored id as the gates compare it, or `null` when the row carries none. */ +function idOf(value: unknown): string | null { + if (typeof value !== 'string' && typeof value !== 'number') return null; + const s = String(value); + return s === '' ? null : s; +} + +/** The record an association row names, or `null` when it names none a gate can authorize. */ +export function parseAssociatedRecord( + row: Record | null | undefined, +): { object: string; recordId: string } | null { + const object = row?.object_name; + if (typeof object !== 'string' || !OBJECT_NAME_RE.test(object) || MAIL_OBJECTS.has(object)) return null; + const recordId = idOf(row?.record_id); + return recordId === null ? null : { object, recordId }; +} + +/** AND `filter` into the read's WHERE, never replacing the caller's own. */ +function andIntoWhere(ctx: MailReadMiddlewareCtx, filter: unknown): void { + if (!ctx.ast) return; + ctx.ast.where = ctx.ast.where ? { $and: [ctx.ast.where, filter] } : filter; +} + +/** The rows a read would touch, pre-scanned as the system in the caller's order. */ +async function preScan( + engine: MailGateEngine, + ctx: MailReadMiddlewareCtx, + fields: string[], + logger: MailGateLogger, + seam: string, +): Promise>> { + const orderBy = ctx.ast?.orderBy; + const rows = await engine.find(ctx.object, { + where: (ctx.ast?.where as Record) ?? {}, + fields, + ...(Array.isArray(orderBy) && orderBy.length > 0 ? { orderBy } : {}), + limit: MAIL_GATE_SCAN_LIMIT, + context: { ...SYSTEM_CTX }, + }); + if (rows.length >= MAIL_GATE_SCAN_LIMIT) { + logger.warn( + `[mailbox] ${seam}: candidate pre-scan hit the ${MAIL_GATE_SCAN_LIMIT}-row cap; the visibility filter for ` + + 'this broad read is fail-closed and may omit visible rows — scope the query (by mailbox, or by record)', + ); + } + return rows; +} + +/** + * Of these association rows, the ones naming a record the caller can read — + * one caller-scoped read per record object. + */ +async function readableAssociations( + engine: MailGateEngine, + callerContext: Record | undefined, + rows: ReadonlyArray>, +): Promise>> { + const idsByObject = new Map>(); + for (const row of rows) { + const parent = parseAssociatedRecord(row); + if (!parent) continue; + let ids = idsByObject.get(parent.object); + if (!ids) idsByObject.set(parent.object, (ids = new Set())); + ids.add(parent.recordId); + } + if (idsByObject.size === 0) return []; + const readable = await resolveReadableParentIds(engine, callerContext, idsByObject); + return rows.filter((row) => { + const parent = parseAssociatedRecord(row); + return parent !== null && readable.get(parent.object)?.has(parent.recordId) === true; + }); +} + +/** + * The visibility WHERE for one read of `sys_mail_association`: `null` when the + * caller holds the exemption or the query matches no rows, one `$in` branch per + * record object holding the readable record ids, or the deny-all sentinel. + */ +export async function computeMailAssociationFilter( + engine: MailGateEngine, + ctx: MailReadMiddlewareCtx, + logger: MailGateLogger, +): Promise { + if (callerHolds(ctx.context, MAILBOX_ADMIN_CAPABILITY)) return null; + const candidates = await preScan(engine, ctx, ['object_name', 'record_id'], logger, 'mail association read visibility'); + if (!candidates.length) return null; + const kept = await readableAssociations(engine, ctx.context, candidates); + // record object → the stored record ids kept, so the emitted filter can only + // match rows the pre-scan saw spelled that way. + const byObject = new Map(); + for (const row of kept) { + const object = row.object_name as string; + let ids = byObject.get(object); + if (!ids) byObject.set(object, (ids = [])); + if (!ids.includes(row.record_id)) ids.push(row.record_id); + } + if (byObject.size === 0) return MAIL_ASSOCIATION_DENY_ALL; + const branches = [...byObject].map(([object, ids]) => ({ object_name: object, record_id: { $in: ids } })); + return branches.length === 1 ? branches[0] : { $or: branches }; +} + +/** + * The visibility WHERE for one read of `sys_mail_message`: `null` when the + * caller holds the exemption or the query matches no rows, an `id` `$in` of + * the messages the caller may read (its own mailboxes' messages and those + * associated with a record it can read), or the deny-all sentinel. + */ +export async function computeMailMessageFilter( + engine: MailGateEngine, + ctx: MailReadMiddlewareCtx, + logger: MailGateLogger, +): Promise { + if (callerHolds(ctx.context, MAILBOX_ADMIN_CAPABILITY)) return null; + const candidates = await preScan(engine, ctx, ['id', 'mailbox_id'], logger, 'mail message read visibility'); + if (!candidates.length) return null; + + /** message id as compared → the id as STORED, for every candidate. */ + const stored = new Map(); + for (const row of candidates) { + const id = idOf(row.id); + if (id !== null && !stored.has(id)) stored.set(id, row.id); + } + const visible = new Set(); + + // 1. The owner limb: the caller's own mailboxes, read as the system (the + // directory is served only to administrators). + const userId = idOf(ctx.context?.userId); + if (userId !== null) { + const mailboxIds = [...new Set(candidates.map((r) => idOf(r.mailbox_id)).filter((v): v is string => v !== null))]; + if (mailboxIds.length) { + const owned = await engine.find(MAILBOX_OBJECT, { + where: { id: { $in: mailboxIds }, owner_user_id: userId }, + fields: ['id'], + limit: mailboxIds.length, + context: { ...SYSTEM_CTX }, + }); + const ownedIds = new Set(owned.map((r) => idOf(r.id)).filter((v): v is string => v !== null)); + for (const row of candidates) { + const id = idOf(row.id); + const mailbox = idOf(row.mailbox_id); + if (id !== null && mailbox !== null && ownedIds.has(mailbox)) visible.add(id); + } + } + } + + // 2. The association limb, for the rest: a message is readable when one of + // its associations names a record the caller can read. A pool message has + // no association, so nothing here can serve it. + const pending = [...stored.keys()].filter((id) => !visible.has(id)); + if (pending.length) { + const associations = await engine.find(MAIL_ASSOCIATION_OBJECT, { + where: { mail_message_id: { $in: pending } }, + fields: ['mail_message_id', 'object_name', 'record_id'], + limit: MAIL_GATE_SCAN_LIMIT, + context: { ...SYSTEM_CTX }, + }); + if (associations.length >= MAIL_GATE_SCAN_LIMIT) { + logger.warn( + `[mailbox] mail message read visibility: association pre-scan hit the ${MAIL_GATE_SCAN_LIMIT}-row cap; ` + + 'the visibility filter for this broad read is fail-closed and may omit visible messages — scope the query', + ); + } + for (const row of await readableAssociations(engine, ctx.context, associations)) { + const id = idOf(row.mail_message_id); + if (id !== null && stored.has(id)) visible.add(id); + } + } + + if (visible.size === 0) return MAIL_MESSAGE_DENY_ALL; + return { id: { $in: [...visible].map((id) => stored.get(id)) } }; +} + +/** One gate's middleware: reads only, callers only, fail closed. */ +function gateMiddleware( + engine: MailGateEngine, + logger: MailGateLogger, + seam: string, + compute: typeof computeMailMessageFilter, + denyAll: Readonly>, +) { + return async (ctx: MailReadMiddlewareCtx, next: () => Promise): Promise => { + // Only reads carry an `ast` to constrain; both objects are engine-owned and + // `apiMethods: ['get', 'list']`. System / context-less reads are internal. + if (!MAIL_GATE_READ_OPS.has(ctx.operation) || !ctx.ast || !ctx.context || ctx.context.isSystem) { + return next(); + } + try { + const filter = await compute(engine, ctx, logger); + if (filter) andIntoWhere(ctx, filter); + } catch (err) { + // A filter-compute failure must never fall open into a leak. + logger.warn(`[mailbox] ${seam}: filter failed, denying all (${(err as Error)?.message ?? err})`); + andIntoWhere(ctx, denyAll); + } + return next(); + }; +} + +/** + * Install both read gates. Returns `false` (and installs nothing) on an engine + * without the middleware seam, so the caller can say so out loud. + */ +export function installMailReadVisibility(engine: MailGateEngine, logger: MailGateLogger): boolean { + if (typeof engine.registerMiddleware !== 'function') return false; + engine.registerMiddleware( + gateMiddleware(engine, logger, 'mail message read visibility', computeMailMessageFilter, MAIL_MESSAGE_DENY_ALL), + { object: MAIL_MESSAGE_OBJECT }, + ); + engine.registerMiddleware( + gateMiddleware(engine, logger, 'mail association read visibility', computeMailAssociationFilter, MAIL_ASSOCIATION_DENY_ALL), + { object: MAIL_ASSOCIATION_OBJECT }, + ); + return true; +} diff --git a/packages/services/service-mailbox/src/mailbox-service-plugin.test.ts b/packages/services/service-mailbox/src/mailbox-service-plugin.test.ts new file mode 100644 index 00000000000..e965e345eb8 --- /dev/null +++ b/packages/services/service-mailbox/src/mailbox-service-plugin.test.ts @@ -0,0 +1,103 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import { describe, it, expect } from 'vitest'; +import { ADMIN_FULL_ACCESS_CAPABILITIES } from '@objectstack/spec'; +import { PLATFORM_CAPABILITIES } from '@objectstack/spec/security'; +import { CAPABILITY_PROVIDERS } from '@objectstack/core'; +import { + MailboxServicePlugin, + MAILBOX_ADMIN_CAPABILITY, + MAILBOX_SERVICE_MANIFEST_ID, + SysMailAssociation, + SysMailbox, + SysMailMessage, +} from './index.js'; + +/** A plugin context that records the manifest it is handed and the hooks it arms. */ +function fakeContext() { + const registered: Array> = []; + const hooks: Record Promise>> = {}; + const ctx: any = { + getService: (name: string) => { + if (name === 'manifest') return { register: (m: Record) => registered.push(m) }; + throw new Error(`no service ${name}`); + }, + hook: (event: string, fn: () => Promise) => (hooks[event] ??= []).push(fn), + logger: { info: () => {}, warn: () => {}, error: () => {} }, + }; + return { ctx, registered, hooks }; +} + +describe('MailboxServicePlugin', () => { + it('registers the three inbound-mail objects under its own manifest, and nothing else', async () => { + const { ctx, registered } = fakeContext(); + await new MailboxServicePlugin().init(ctx); + expect(registered).toHaveLength(1); + expect(registered[0].id).toBe(MAILBOX_SERVICE_MANIFEST_ID); + expect(registered[0].objects.map((o: { name: string }) => o.name).sort()).toEqual([ + 'sys_mail_association', + 'sys_mail_message', + 'sys_mailbox', + ]); + }); + + it('is the provider the `mailbox` capability token names, under its registered identity', () => { + const row = CAPABILITY_PROVIDERS.mailbox; + expect(row?.pkg).toBe('@objectstack/service-mailbox'); + expect(row?.export).toBe('MailboxServicePlugin'); + expect(row?.identities).toContain(new MailboxServicePlugin().name); + }); + + it('says out loud, at error, when the engine offers no middleware seam for the read gates', async () => { + const { ctx, hooks } = fakeContext(); + const errors: string[] = []; + ctx.logger.error = (m: string) => errors.push(m); + ctx.getService = (name: string) => { + if (name === 'objectql') return { find: async () => [] }; + throw new Error(`no service ${name}`); + }; + await new MailboxServicePlugin().start(ctx); + for (const fn of hooks['kernel:ready'] ?? []) await fn(); + expect(errors).toHaveLength(1); + expect(errors[0]).toMatch(/read gates are NOT installed/); + }); +}); + +describe('the mailbox administrator capability', () => { + it('is the curated organization capability the platform declares', () => { + expect(PLATFORM_CAPABILITIES.find((c) => c.name === MAILBOX_ADMIN_CAPABILITY)?.scope).toBe('org'); + }); + + it('is held by default by platform administrators', () => { + expect(ADMIN_FULL_ACCESS_CAPABILITIES.systemPermissions).toContain(MAILBOX_ADMIN_CAPABILITY); + }); + + it('gates the mailbox directory', () => { + expect(SysMailbox.requiredPermissions).toEqual([MAILBOX_ADMIN_CAPABILITY]); + }); +}); + +describe('the inbound-mail objects', () => { + it('store the sender-controlled HTML part as plain text, never as rendered markup', () => { + expect(SysMailMessage.fields.body_html.type).toBe('textarea'); + }); + + it('are written only by the service: the data API serves get and list', () => { + for (const object of [SysMailbox, SysMailMessage, SysMailAssociation]) { + expect(object.managedBy, object.name).toBe('engine-owned'); + expect(object.enable?.apiMethods, object.name).toEqual(['get', 'list']); + } + }); + + it('let a message carry attachments by file reference', () => { + expect(SysMailMessage.enable?.files).toBe(true); + }); + + it('dedupe a message per mailbox, and keep re-match idempotent per record', () => { + expect(SysMailMessage.indexes).toContainEqual({ fields: ['mailbox_id', 'dedupe_key'], unique: 'organization' }); + expect(SysMailAssociation.indexes).toContainEqual({ + fields: ['mail_message_id', 'object_name', 'record_id'], + unique: 'organization', + }); + }); +}); diff --git a/packages/services/service-mailbox/src/mailbox-service-plugin.ts b/packages/services/service-mailbox/src/mailbox-service-plugin.ts new file mode 100644 index 00000000000..68241d361fd --- /dev/null +++ b/packages/services/service-mailbox/src/mailbox-service-plugin.ts @@ -0,0 +1,83 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import type { Plugin, PluginContext } from '@objectstack/core'; +import type { II18nService } from '@objectstack/spec/contracts'; +import { SysMailAssociation, SysMailbox, SysMailMessage } from './objects/index.js'; +import { installMailReadVisibility, type MailGateEngine } from './mail-read-visibility.js'; + +/** The manifest id this plugin registers its objects under. */ +export const MAILBOX_SERVICE_MANIFEST_ID = 'com.objectstack.service.mailbox'; + +/** + * MailboxServicePlugin — the inbound-mail capability (`requires: ['mailbox']`). + * + * Owns the inbound-mail objects (ADR-0029 D1/D2): `sys_mailbox` (a mailbox's + * identity: address and owner), `sys_mail_message` (the RFC 5322 mirror of one + * inbound message) and `sys_mail_association` (a message's links to the app's + * records). They are registered only where an app requires the capability, so + * a deployment without mailboxes carries none of them. + * + * Installs the two read gates (`mail-read-visibility.ts`): a message is served + * to a caller who can read a record it is associated with, to its mailbox's + * owner, and to holders of `manage_mailboxes` — so the unassociated pool is + * restricted by default, never organization-wide. + * + * The ingest engine that writes these rows (the IMAP connector, dedupe, + * association stamping and re-match) is the next half of the inbound-mail + * programme and lands in this package. + */ +export class MailboxServicePlugin implements Plugin { + name = MAILBOX_SERVICE_MANIFEST_ID; + version = '1.0.0'; + type = 'standard' as const; + dependencies = ['com.objectstack.engine.objectql']; + + async init(ctx: PluginContext): Promise { + ctx.getService<{ register(m: any): void }>('manifest').register({ + id: MAILBOX_SERVICE_MANIFEST_ID, + name: 'Mailbox Service', + version: '1.0.0', + type: 'plugin', + scope: 'system', + namespace: 'sys', + objects: [SysMailbox, SysMailMessage, SysMailAssociation], + }); + + // ADR-0029 D8 — contribute the objects' translations on kernel:ready. + ctx.hook('kernel:ready', async () => { + try { + const i18n = ctx.getService('i18n'); + if (i18n && typeof i18n.loadTranslations === 'function') { + const { MailboxTranslations } = await import('./translations/index.js'); + for (const [locale, data] of Object.entries(MailboxTranslations)) { + i18n.loadTranslations(locale, data as Record); + } + } + } catch { /* i18n optional */ } + }); + } + + async start(ctx: PluginContext): Promise { + // The engine is resolvable once the kernel is ready; the gates are + // installed before any request is served. + ctx.hook('kernel:ready', async () => { + let engine: MailGateEngine | undefined; + try { + engine = ctx.getService('objectql'); + } catch { + engine = undefined; + } + if (!engine || !installMailReadVisibility(engine, ctx.logger)) { + // Not a quiet degradation: without the gates every caller with object + // read on sys_mail_message would be served the unassociated pool. + ctx.logger.error( + 'MailboxServicePlugin: the ObjectQL engine or its middleware seam is not available — the sys_mail_message ' + + 'and sys_mail_association read gates are NOT installed, so the unassociated pool is not restricted. ' + + 'Compose the mailbox capability with the ObjectQL engine (com.objectstack.engine.objectql).', + ); + return; + } + ctx.logger.info('MailboxServicePlugin: sys_mail_message and sys_mail_association read gates installed'); + }); + } +} diff --git a/packages/services/service-mailbox/src/objects/index.ts b/packages/services/service-mailbox/src/objects/index.ts new file mode 100644 index 00000000000..5f0668d01f5 --- /dev/null +++ b/packages/services/service-mailbox/src/objects/index.ts @@ -0,0 +1,10 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * Inbound-mail objects owned by `@objectstack/service-mailbox` (ADR-0029 D1/D2): + * registered only where an app requires the `mailbox` capability. + */ + +export { SysMailbox } from './sys-mailbox.object.js'; +export { SysMailMessage } from './sys-mail-message.object.js'; +export { SysMailAssociation } from './sys-mail-association.object.js'; diff --git a/packages/services/service-mailbox/src/objects/objects-authoring-door.test.ts b/packages/services/service-mailbox/src/objects/objects-authoring-door.test.ts new file mode 100644 index 00000000000..0283488ad55 --- /dev/null +++ b/packages/services/service-mailbox/src/objects/objects-authoring-door.test.ts @@ -0,0 +1,31 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import { describe, expect, it } from 'vitest'; +import { runRuntimeAuthoringRules } from '@objectstack/lint'; +import { SysMailAssociation, SysMailbox, SysMailMessage } from './index.js'; + +/** + * The three inbound-mail objects publish clean through the object write door: + * every field reference they carry (`highlightFields`, `nameField`, + * `titleFormat`, index columns) resolves. Driven through the real authoring + * gate rather than by comparing keys, because the gate resolves injected system + * columns and semantic aliases a key comparison does not. The control proves the + * door ran: one dangling entry is refused at its path. + */ +describe('the inbound-mail objects — field references resolve at the object write door', () => { + const objects = [SysMailbox, SysMailMessage, SysMailAssociation]; + const gate = (item: unknown) => + runRuntimeAuthoringRules({ type: 'object', item, context: { objects } }); + + it.each(objects.map((o) => [o.name, o] as const))('%s publishes clean', (_name, object) => { + const result = gate(object); + expect(result.errors, JSON.stringify(result.errors, null, 2)).toEqual([]); + }); + + it('control: a dangling highlight field is refused at its path, so the cases above are not vacuous', () => { + const result = gate({ ...SysMailMessage, highlightFields: ['subject', 'from'] }); + expect(result.errors.map((e) => `${e.rule} ${e.path}`)).toEqual([ + 'object-field-ref-unknown objects.sys_mail_message.highlightFields[1]', + ]); + }); +}); diff --git a/packages/services/service-mailbox/src/objects/sys-mail-association.object.ts b/packages/services/service-mailbox/src/objects/sys-mail-association.object.ts new file mode 100644 index 00000000000..bf55c2afaf3 --- /dev/null +++ b/packages/services/service-mailbox/src/objects/sys-mail-association.object.ts @@ -0,0 +1,113 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import { ObjectSchema, Field } from '@objectstack/spec/data'; + +/** + * sys_mail_association — Mail Association + * + * One link between a mirrored inbound message and a record of the app's own + * objects: "this message belongs to that record". A message routinely belongs + * to several records (its To and Cc name several known correspondents), so the + * link is its own object, as `sys_attachment` links one file to many parents. + * + * Stamped at ingest, and added by an explicit re-match; never removed by one + * (re-match is additive). A row records which participant address matched + * which flagged email field (`FieldSchema.mailAssociation`), so an association + * is an auditable fact, not a read-time guess. + * + * The parent pair is the ActivityPointer pair (`object_name`, `record_id`, + * ADR-0052 §5), so the parent-record read gate applies unchanged: a row is + * served to a caller who can read the record it names, or who holds + * `manage_mailboxes`. + * + * @namespace sys + */ +export const SysMailAssociation = ObjectSchema.create({ + name: 'sys_mail_association', + label: 'Mail Association', + pluralLabel: 'Mail Associations', + icon: 'link', + isSystem: true, + managedBy: 'engine-owned', + description: 'Links a mirrored inbound message to a record it belongs to', + displayNameField: 'matched_address', + nameField: 'matched_address', // [ADR-0079] canonical primary-title pointer (mirrors deprecated displayNameField) + titleFormat: '{matched_address}', + highlightFields: ['mail_message_id', 'object_name', 'record_id', 'matched_address'], + + fields: { + id: Field.text({ + label: 'Association ID', + required: true, + readonly: true, + group: 'System', + }), + + mail_message_id: Field.lookup('sys_mail_message', { + label: 'Message', + required: true, + readonly: true, + }), + + object_name: Field.text({ + label: 'Object', + required: true, + readonly: true, + maxLength: 255, + description: 'Machine name of the object the associated record belongs to', + group: 'Record', + }), + + // The id half of the ActivityPointer pair; bounded at 255, the width of + // every table's primary key, so the `(object_name, record_id)` index below + // is expressible on MySQL (the `sys_activity.record_id` reasoning). + record_id: Field.text({ + label: 'Record ID', + required: true, + readonly: true, + maxLength: 255, + referenceVia: 'object_name', + description: 'Id of the associated record, in the object named by Object', + group: 'Record', + }), + + matched_address: Field.text({ + label: 'Matched Address', + required: true, + readonly: true, + maxLength: 320, + description: 'The participant address that matched the record', + group: 'Match', + }), + + matched_field: Field.text({ + label: 'Matched Field', + required: true, + readonly: true, + maxLength: 255, + description: 'The email field of the record whose value equalled the matched address', + group: 'Match', + }), + + matched_by: Field.select({ + label: 'Matched By', + required: true, + readonly: true, + options: [ + { value: 'ingest', label: 'Ingest' }, + { value: 'rematch', label: 'Re-match' }, + ], + description: 'Whether the link was stamped when the message arrived or added by a later re-match', + group: 'Match', + }), + }, + + indexes: [ + { fields: ['mail_message_id', 'object_name', 'record_id'], unique: 'organization' }, + { fields: ['object_name', 'record_id'], unique: false }, + ], + + enable: { + apiMethods: ['get', 'list'], + }, +}); diff --git a/packages/services/service-mailbox/src/objects/sys-mail-message.object.ts b/packages/services/service-mailbox/src/objects/sys-mail-message.object.ts new file mode 100644 index 00000000000..98f75078630 --- /dev/null +++ b/packages/services/service-mailbox/src/objects/sys-mail-message.object.ts @@ -0,0 +1,228 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import { ObjectSchema, Field } from '@objectstack/spec/data'; + +/** + * sys_mail_message — Inbound Mail Message + * + * A faithful mirror of one inbound message as it arrived in a mailbox + * (`sys_mailbox`). Its schema is dictated by the external standard, RFC 5322: + * participants, message id and threading references, subject, body parts, and + * attachments by file reference (`sys_attachment` rows with this object as + * their parent). It is the inbound counterpart of the outbound log `sys_email`. + * + * Mirror, not adopt: apps keep their own business objects and reach a message + * through a declared association (`sys_mail_association`), stamped at ingest + * from the email fields an app flags with `mailAssociation`. ⛔ Apps never + * extend this object with business keys. + * + * A message no record matched stays in the UNASSOCIATED POOL + * (`association_state: 'unassociated'`), never dropped, and is associated later + * when a matching record appears. The pool is RESTRICTED: the read gate serves + * a message to a caller who can read a record it is associated with, to its + * mailbox's owner, and to holders of `manage_mailboxes` — so a pool message, + * which has no associated record, only to the latter two. + * + * The ingest service is the only writer; the data API serves `get` and `list`. + * + * @namespace sys + */ +export const SysMailMessage = ObjectSchema.create({ + name: 'sys_mail_message', + label: 'Mail Message', + pluralLabel: 'Mail Messages', + icon: 'mail-open', + isSystem: true, + managedBy: 'engine-owned', + description: 'Inbound mail message mirrored from a mailbox', + displayNameField: 'subject', + nameField: 'subject', // [ADR-0079] canonical primary-title pointer (mirrors deprecated displayNameField) + titleFormat: '{subject}', + highlightFields: ['subject', 'from_address', 'received_at', 'association_state'], + + fields: { + id: Field.text({ + label: 'Message ID', + required: true, + readonly: true, + group: 'System', + }), + + mailbox_id: Field.lookup('sys_mailbox', { + label: 'Mailbox', + required: true, + readonly: true, + description: 'The mailbox the message arrived in', + group: 'Envelope', + }), + + // ── Identity and threading (RFC 5322 §3.6.4) ──────────────── + message_id: Field.text({ + label: 'Message-ID', + required: false, + readonly: true, + maxLength: 998, + description: 'The Message-ID header, angle brackets stripped', + group: 'Threading', + }), + + in_reply_to: Field.textarea({ + label: 'In-Reply-To', + required: false, + readonly: true, + description: 'The In-Reply-To header: the message ids this message replies to, space-separated', + group: 'Threading', + }), + + reference_ids: Field.textarea({ + label: 'References', + required: false, + readonly: true, + description: 'The References header: the message ids of the thread, space-separated', + group: 'Threading', + }), + + // ── Participants (RFC 5322 §3.6.2, §3.6.3) ────────────────── + from_address: Field.text({ + label: 'From', + required: false, + readonly: true, + maxLength: 320, + searchable: true, + description: 'The first From address. A message without one is still mirrored, never dropped.', + group: 'Envelope', + }), + + from_name: Field.text({ + label: 'From Name', + required: false, + readonly: true, + maxLength: 255, + description: 'The display name of the first From address', + group: 'Envelope', + }), + + sender_address: Field.text({ + label: 'Sender', + required: false, + readonly: true, + maxLength: 320, + description: 'The Sender header, when the message carries one', + group: 'Envelope', + }), + + reply_to: Field.text({ + label: 'Reply-To', + required: false, + readonly: true, + maxLength: 4000, + description: 'Comma-separated Reply-To addresses', + group: 'Envelope', + }), + + to_addresses: Field.text({ + label: 'To', + required: false, + readonly: true, + maxLength: 4000, + searchable: true, + description: 'Comma-separated To addresses', + group: 'Envelope', + }), + + cc_addresses: Field.text({ + label: 'Cc', + required: false, + readonly: true, + maxLength: 4000, + description: 'Comma-separated Cc addresses', + group: 'Envelope', + }), + + sent_at: Field.datetime({ + label: 'Sent At', + required: false, + readonly: true, + description: 'The Date header: when the sender says the message was written', + group: 'Envelope', + }), + + received_at: Field.datetime({ + label: 'Received At', + required: true, + readonly: true, + description: 'When the mailbox received the message', + group: 'Envelope', + }), + + // ── Content ────────────────────────────────────────────────── + subject: Field.text({ + label: 'Subject', + required: false, + readonly: true, + maxLength: 998, + searchable: true, + group: 'Content', + }), + + body_text: Field.textarea({ + label: 'Body (text)', + required: false, + readonly: true, + searchable: true, + group: 'Content', + }), + + // Plain text, never the `html` type: the sender controls this markup, so it + // is stored verbatim and shown only through a sanitizing renderer. + body_html: Field.textarea({ + label: 'Body (HTML)', + required: false, + readonly: true, + description: 'The HTML part as the sender wrote it, stored verbatim and never rendered unsanitized', + group: 'Content', + }), + + // ── Association ────────────────────────────────────────────── + match_addresses: Field.textarea({ + label: 'Match Addresses', + required: false, + readonly: true, + description: 'The participant addresses records were matched against, minus the mailbox’s own address; a re-match replays exactly these', + group: 'Association', + }), + + association_state: Field.select({ + label: 'Association', + required: true, + readonly: true, + defaultValue: 'unassociated', + options: [ + { value: 'associated', label: 'Associated' }, + { value: 'unassociated', label: 'Unassociated' }, + ], + description: 'Whether any record matched the message. An unassociated message waits in the restricted pool.', + group: 'Association', + }), + + dedupe_key: Field.text({ + label: 'Dedupe Key', + required: true, + readonly: true, + maxLength: 128, + description: 'Hash of the normalized Message-ID (or of the header block when there is none); one row per message per mailbox', + group: 'System', + }), + }, + + indexes: [ + { fields: ['mailbox_id', 'dedupe_key'], unique: 'organization' }, + { fields: ['association_state', 'received_at'], unique: false }, + { fields: ['mailbox_id', 'received_at'], unique: false }, + ], + + enable: { + apiMethods: ['get', 'list'], + files: true, + }, +}); diff --git a/packages/services/service-mailbox/src/objects/sys-mailbox.object.ts b/packages/services/service-mailbox/src/objects/sys-mailbox.object.ts new file mode 100644 index 00000000000..4bc8712244e --- /dev/null +++ b/packages/services/service-mailbox/src/objects/sys-mailbox.object.ts @@ -0,0 +1,76 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import { ObjectSchema, Field } from '@objectstack/spec/data'; + +/** + * sys_mailbox — Inbound Mailbox + * + * One mailbox the platform mirrors inbound mail from: a shared address that + * people forward or BCC to. This object carries the mailbox's IDENTITY only — + * which address it is and who owns it — because that is what the mail read + * gates need: the owner is served the mailbox's unassociated messages, and the + * address is the one participant the association seam never matches. + * + * The connector account, credential and folder cursor that make a mailbox + * ingest are added by the ingest half of the inbound-mail programme, together + * with their writer. + * + * Readers: the `sys_mail_message` read gate (the owner limb, read under system + * context). The directory itself is served only to holders of + * `manage_mailboxes`. + * + * @namespace sys + */ +export const SysMailbox = ObjectSchema.create({ + name: 'sys_mailbox', + label: 'Mailbox', + pluralLabel: 'Mailboxes', + icon: 'inbox', + isSystem: true, + managedBy: 'engine-owned', + requiredPermissions: ['manage_mailboxes'], + description: 'An inbound mailbox the platform mirrors mail from: its address and its owner', + displayNameField: 'name', + nameField: 'name', // [ADR-0079] canonical primary-title pointer (mirrors deprecated displayNameField) + titleFormat: '{name}', + highlightFields: ['name', 'address', 'owner_user_id'], + + fields: { + id: Field.text({ + label: 'Mailbox ID', + required: true, + readonly: true, + group: 'System', + }), + + name: Field.text({ + label: 'Name', + required: true, + maxLength: 255, + searchable: true, + description: 'Display name of the mailbox (for example "Sales inbox")', + }), + + address: Field.email({ + label: 'Address', + required: true, + maxLength: 320, + searchable: true, + description: 'The mailbox address. Never matched as a participant: a message to this mailbox is not about the mailbox itself.', + }), + + owner_user_id: Field.lookup('sys_user', { + label: 'Owner', + required: false, + description: 'The user who owns this mailbox. The owner is served every message that arrived in it, unassociated ones included.', + }), + }, + + indexes: [ + { fields: ['owner_user_id'], unique: false }, + ], + + enable: { + apiMethods: ['get', 'list'], + }, +}); diff --git a/packages/services/service-mailbox/src/translations/en.objects.generated.ts b/packages/services/service-mailbox/src/translations/en.objects.generated.ts new file mode 100644 index 00000000000..c785bbee491 --- /dev/null +++ b/packages/services/service-mailbox/src/translations/en.objects.generated.ts @@ -0,0 +1,161 @@ +// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * Auto-generated by 'os i18n extract' for locale 'en'. + * Edit translations in place; re-run extract (with --merge) to fill new gaps. + * Do not hand-edit the structure — only the leaf string values. + * Merge only fills gaps: correcting a source label/description does not + * push the correction into a leaf here that already holds a translation — + * a present-but-stale string is not a gap, so it is left as-is. Re-translate + * it by hand when its source changes; nothing here or in `os i18n check` + * tells a leaf a translator updated on purpose from one nobody has looked + * at since the source moved. + */ + +import type { TranslationData } from '@objectstack/spec/system'; + +export const enObjects: NonNullable = { + sys_mailbox: { + label: "Mailbox", + pluralLabel: "Mailboxes", + description: "An inbound mailbox the platform mirrors mail from: its address and its owner", + fields: { + id: { + label: "Mailbox ID" + }, + name: { + label: "Name", + help: "Display name of the mailbox (for example \"Sales inbox\")" + }, + address: { + label: "Address", + help: "The mailbox address. Never matched as a participant: a message to this mailbox is not about the mailbox itself." + }, + owner_user_id: { + label: "Owner", + help: "The user who owns this mailbox. The owner is served every message that arrived in it, unassociated ones included." + } + } + }, + sys_mail_message: { + label: "Mail Message", + pluralLabel: "Mail Messages", + description: "Inbound mail message mirrored from a mailbox", + fields: { + id: { + label: "Message ID" + }, + mailbox_id: { + label: "Mailbox", + help: "The mailbox the message arrived in" + }, + message_id: { + label: "Message-ID", + help: "The Message-ID header, angle brackets stripped" + }, + in_reply_to: { + label: "In-Reply-To", + help: "The In-Reply-To header: the message ids this message replies to, space-separated" + }, + reference_ids: { + label: "References", + help: "The References header: the message ids of the thread, space-separated" + }, + from_address: { + label: "From", + help: "The first From address. A message without one is still mirrored, never dropped." + }, + from_name: { + label: "From Name", + help: "The display name of the first From address" + }, + sender_address: { + label: "Sender", + help: "The Sender header, when the message carries one" + }, + reply_to: { + label: "Reply-To", + help: "Comma-separated Reply-To addresses" + }, + to_addresses: { + label: "To", + help: "Comma-separated To addresses" + }, + cc_addresses: { + label: "Cc", + help: "Comma-separated Cc addresses" + }, + sent_at: { + label: "Sent At", + help: "The Date header: when the sender says the message was written" + }, + received_at: { + label: "Received At", + help: "When the mailbox received the message" + }, + subject: { + label: "Subject" + }, + body_text: { + label: "Body (text)" + }, + body_html: { + label: "Body (HTML)", + help: "The HTML part as the sender wrote it, stored verbatim and never rendered unsanitized" + }, + match_addresses: { + label: "Match Addresses", + help: "The participant addresses records were matched against, minus the mailbox’s own address; a re-match replays exactly these" + }, + association_state: { + label: "Association", + help: "Whether any record matched the message. An unassociated message waits in the restricted pool.", + options: { + associated: "Associated", + unassociated: "Unassociated" + } + }, + dedupe_key: { + label: "Dedupe Key", + help: "Hash of the normalized Message-ID (or of the header block when there is none); one row per message per mailbox" + } + } + }, + sys_mail_association: { + label: "Mail Association", + pluralLabel: "Mail Associations", + description: "Links a mirrored inbound message to a record it belongs to", + fields: { + id: { + label: "Association ID" + }, + mail_message_id: { + label: "Message" + }, + object_name: { + label: "Object", + help: "Machine name of the object the associated record belongs to" + }, + record_id: { + label: "Record ID", + help: "Id of the associated record, in the object named by Object" + }, + matched_address: { + label: "Matched Address", + help: "The participant address that matched the record" + }, + matched_field: { + label: "Matched Field", + help: "The email field of the record whose value equalled the matched address" + }, + matched_by: { + label: "Matched By", + help: "Whether the link was stamped when the message arrived or added by a later re-match", + options: { + ingest: "Ingest", + rematch: "Re-match" + } + } + } + } +}; diff --git a/packages/services/service-mailbox/src/translations/es-ES.objects.generated.ts b/packages/services/service-mailbox/src/translations/es-ES.objects.generated.ts new file mode 100644 index 00000000000..a498a454f66 --- /dev/null +++ b/packages/services/service-mailbox/src/translations/es-ES.objects.generated.ts @@ -0,0 +1,161 @@ +// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * Auto-generated by 'os i18n extract' for locale 'es-ES'. + * Edit translations in place; re-run extract (with --merge) to fill new gaps. + * Do not hand-edit the structure — only the leaf string values. + * Merge only fills gaps: correcting a source label/description does not + * push the correction into a leaf here that already holds a translation — + * a present-but-stale string is not a gap, so it is left as-is. Re-translate + * it by hand when its source changes; nothing here or in `os i18n check` + * tells a leaf a translator updated on purpose from one nobody has looked + * at since the source moved. + */ + +import type { TranslationData } from '@objectstack/spec/system'; + +export const esESObjects: NonNullable = { + sys_mailbox: { + label: "Buzón", + pluralLabel: "Buzones", + description: "Buzón entrante del que la plataforma refleja el correo: su dirección y su propietario", + fields: { + id: { + label: "ID del buzón" + }, + name: { + label: "Nombre", + help: "Nombre visible del buzón (por ejemplo, «Bandeja de ventas»)" + }, + address: { + label: "Dirección", + help: "La dirección del buzón. Nunca se compara como participante: un mensaje a este buzón no trata sobre el propio buzón." + }, + owner_user_id: { + label: "Propietario", + help: "El usuario propietario de este buzón. El propietario ve todos los mensajes que llegaron a él, incluidos los no asociados." + } + } + }, + sys_mail_message: { + label: "Mensaje de correo", + pluralLabel: "Mensajes de correo", + description: "Mensaje de correo entrante reflejado desde un buzón", + fields: { + id: { + label: "ID del mensaje" + }, + mailbox_id: { + label: "Buzón", + help: "El buzón al que llegó el mensaje" + }, + message_id: { + label: "Message-ID", + help: "El encabezado Message-ID, sin los corchetes angulares" + }, + in_reply_to: { + label: "In-Reply-To", + help: "El encabezado In-Reply-To: los identificadores de los mensajes a los que responde, separados por espacios" + }, + reference_ids: { + label: "References", + help: "El encabezado References: los identificadores de los mensajes del hilo, separados por espacios" + }, + from_address: { + label: "De", + help: "La primera dirección De. Un mensaje sin ella se refleja igualmente; nunca se descarta." + }, + from_name: { + label: "Nombre del remitente", + help: "El nombre visible de la primera dirección De" + }, + sender_address: { + label: "Sender", + help: "El encabezado Sender, cuando el mensaje lo incluye" + }, + reply_to: { + label: "Reply-To", + help: "Direcciones Reply-To separadas por comas" + }, + to_addresses: { + label: "Para", + help: "Direcciones Para separadas por comas" + }, + cc_addresses: { + label: "Cc", + help: "Direcciones Cc separadas por comas" + }, + sent_at: { + label: "Enviado el", + help: "El encabezado Date: cuándo dice el remitente que se escribió el mensaje" + }, + received_at: { + label: "Recibido el", + help: "Cuándo recibió el buzón el mensaje" + }, + subject: { + label: "Asunto" + }, + body_text: { + label: "Cuerpo (texto)" + }, + body_html: { + label: "Cuerpo (HTML)", + help: "La parte HTML tal como la escribió el remitente, guardada literalmente y nunca mostrada sin sanear" + }, + match_addresses: { + label: "Direcciones de coincidencia", + help: "Las direcciones de los participantes con las que se compararon los registros, sin la dirección del propio buzón; una nueva comparación reproduce exactamente estas" + }, + association_state: { + label: "Asociación", + help: "Si algún registro coincidió con el mensaje. Un mensaje no asociado espera en el grupo restringido.", + options: { + associated: "Asociado", + unassociated: "No asociado" + } + }, + dedupe_key: { + label: "Clave de deduplicación", + help: "Hash del Message-ID normalizado (o del bloque de encabezados si no lo hay); una fila por mensaje y buzón" + } + } + }, + sys_mail_association: { + label: "Asociación de correo", + pluralLabel: "Asociaciones de correo", + description: "Vincula un mensaje entrante reflejado con un registro al que pertenece", + fields: { + id: { + label: "ID de la asociación" + }, + mail_message_id: { + label: "Mensaje" + }, + object_name: { + label: "Objeto", + help: "Nombre interno del objeto al que pertenece el registro asociado" + }, + record_id: { + label: "ID del registro", + help: "Id del registro asociado, en el objeto indicado por Objeto" + }, + matched_address: { + label: "Dirección coincidente", + help: "La dirección del participante que coincidió con el registro" + }, + matched_field: { + label: "Campo coincidente", + help: "El campo de correo del registro cuyo valor era igual a la dirección coincidente" + }, + matched_by: { + label: "Coincidencia por", + help: "Si el vínculo se registró al llegar el mensaje o lo añadió una nueva comparación posterior", + options: { + ingest: "Ingesta", + rematch: "Nueva comparación" + } + } + } + } +}; diff --git a/packages/services/service-mailbox/src/translations/es-ES.source-hashes.generated.ts b/packages/services/service-mailbox/src/translations/es-ES.source-hashes.generated.ts new file mode 100644 index 00000000000..55defc345cf --- /dev/null +++ b/packages/services/service-mailbox/src/translations/es-ES.source-hashes.generated.ts @@ -0,0 +1,27 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * Auto-generated by 'os i18n extract' for locale 'es-ES'. Do not hand-edit. + * + * Each entry is the digest of the SOURCE REVISION that this locale's leaf at + * that path is still a byte copy of — provenance for the generated half of the + * bundles (commit 09b4f4e4e): a leaf whose digest no longer matches its source is stale and serves the source text instead. + * + * An entry exists only while the leaf IS such a copy. Re-translate the leaf in + * `.objects.generated.ts` and the next extract drops its entry by + * itself — the table makes no claim about text a translator wrote. A path with + * no entry is LEGACY-TRUSTED and never reported stale. + * + * ⚠️ Do not "fix" a staleness report by editing this file. Refreshing a digest + * records that the current text was copied from the current source, which is + * the false claim the mechanism exists to detect. Fix the TRANSLATION. + */ + +export const esESGeneratedSourceHashes: Readonly> = { + "objects.sys_mail_message.fields.cc_addresses.label": "02946d952cf15623", + "objects.sys_mail_message.fields.in_reply_to.label": "71bb10a36255d815", + "objects.sys_mail_message.fields.message_id.label": "14cd089a4062f4b1", + "objects.sys_mail_message.fields.reference_ids.label": "5102fd1664d35c0b", + "objects.sys_mail_message.fields.reply_to.label": "90ad8f2ca791a95a", + "objects.sys_mail_message.fields.sender_address.label": "89db06253eee9632", +}; diff --git a/packages/services/service-mailbox/src/translations/index.ts b/packages/services/service-mailbox/src/translations/index.ts new file mode 100644 index 00000000000..838d1fd20c9 --- /dev/null +++ b/packages/services/service-mailbox/src/translations/index.ts @@ -0,0 +1,58 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * MailboxTranslations — i18n bundle owned by this service plugin + * (ADR-0029 D8). + * + * Object label/field/view/action translations for sys_mailbox, + * sys_mail_message and sys_mail_association, the objects this plugin owns. + * Loaded at runtime via the plugin's `kernel:ready` hook + * (`i18n.loadTranslations`). Regenerate with `os i18n extract` against + * `scripts/i18n-extract.config.ts`. + */ + +import type { TranslationBundle, TranslationData } from '@objectstack/spec/system'; +import { withSourceFallback } from '@objectstack/platform-objects/apps'; +import { enObjects } from './en.objects.generated.js'; +import { zhCNObjects } from './zh-CN.objects.generated.js'; +import { jaJPObjects } from './ja-JP.objects.generated.js'; +import { esESObjects } from './es-ES.objects.generated.js'; +import { zhCNGeneratedSourceHashes } from './zh-CN.source-hashes.generated.js'; +import { jaJPGeneratedSourceHashes } from './ja-JP.source-hashes.generated.js'; +import { esESGeneratedSourceHashes } from './es-ES.source-hashes.generated.js'; + +/** + * ## The provenance companions are READ here, not merely recorded + * + * `os i18n extract --source-hashes` writes `.source-hashes.generated.ts` + * beside these bundles (maintainer ruling #12069 Option A, commit 09b4f4e4e). A record + * says: "this locale's leaf at that path is still a byte copy of THAT source + * revision". Recording alone changes nothing a user sees — the substitution is + * what {@link withSourceFallback} does, and until it was wired here this set + * recorded the drift and went on serving the superseded draft. + * + * That gap was invisible by construction: a leaf revised in ONE locale is + * reported by `findStaleFills`, and every gate stays green — `check:i18n` + * compares key sets, `check:i18n-coverage` counts a stale leaf as translated, + * and `check:i18n-stale-fill` needs two locales holding the same stale bytes + * before it can testify. So the only reader-visible consequence was the wrong + * string on the page. + * + * `recorded` (3rd argument) stays `undefined` on purpose: it judges the + * HAND-AUTHORED sections (`apps` / `dashboards` / `pages`), which this set does + * not have — its bundles are entirely generated. The companion goes in the 4th + * slot, which judges the generated ones. This is the shape + * `@objectstack/platform-objects`'s own `metadata-translations/index.ts` uses. + * + * ⛔ Do not drop the 4th argument to quiet a staleness report. Serving the + * superseded draft is the bug; `check:i18n-stale-fill`'s UNSERVED PROVENANCE + * verdict fails the build if a committed companion stops being consulted here. + */ +const enSource: TranslationData = { objects: enObjects }; + +export const MailboxTranslations: TranslationBundle = { + en: enSource, + 'zh-CN': withSourceFallback({ objects: zhCNObjects }, enSource, undefined, zhCNGeneratedSourceHashes), + 'ja-JP': withSourceFallback({ objects: jaJPObjects }, enSource, undefined, jaJPGeneratedSourceHashes), + 'es-ES': withSourceFallback({ objects: esESObjects }, enSource, undefined, esESGeneratedSourceHashes), +}; diff --git a/packages/services/service-mailbox/src/translations/ja-JP.objects.generated.ts b/packages/services/service-mailbox/src/translations/ja-JP.objects.generated.ts new file mode 100644 index 00000000000..d90eda6e400 --- /dev/null +++ b/packages/services/service-mailbox/src/translations/ja-JP.objects.generated.ts @@ -0,0 +1,161 @@ +// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * Auto-generated by 'os i18n extract' for locale 'ja-JP'. + * Edit translations in place; re-run extract (with --merge) to fill new gaps. + * Do not hand-edit the structure — only the leaf string values. + * Merge only fills gaps: correcting a source label/description does not + * push the correction into a leaf here that already holds a translation — + * a present-but-stale string is not a gap, so it is left as-is. Re-translate + * it by hand when its source changes; nothing here or in `os i18n check` + * tells a leaf a translator updated on purpose from one nobody has looked + * at since the source moved. + */ + +import type { TranslationData } from '@objectstack/spec/system'; + +export const jaJPObjects: NonNullable = { + sys_mailbox: { + label: "メールボックス", + pluralLabel: "メールボックス", + description: "プラットフォームが受信メールをミラーするメールボックス:そのアドレスと所有者", + fields: { + id: { + label: "メールボックス ID" + }, + name: { + label: "名前", + help: "メールボックスの表示名(例: 「営業受信箱」)" + }, + address: { + label: "アドレス", + help: "メールボックスのアドレス。参加者として照合されることはありません。このメールボックス宛てのメッセージは、メールボックス自体に関するものではないためです。" + }, + owner_user_id: { + label: "所有者", + help: "このメールボックスを所有するユーザー。所有者には、未関連付けのものを含め、ここに届いたすべてのメッセージが表示されます。" + } + } + }, + sys_mail_message: { + label: "メールメッセージ", + pluralLabel: "メールメッセージ", + description: "メールボックスからミラーされた受信メールメッセージ", + fields: { + id: { + label: "メッセージ ID" + }, + mailbox_id: { + label: "メールボックス", + help: "メッセージが届いたメールボックス" + }, + message_id: { + label: "Message-ID", + help: "Message-ID ヘッダー(山括弧を除去)" + }, + in_reply_to: { + label: "In-Reply-To", + help: "In-Reply-To ヘッダー: このメッセージが返信しているメッセージ ID(空白区切り)" + }, + reference_ids: { + label: "References", + help: "References ヘッダー: スレッドのメッセージ ID(空白区切り)" + }, + from_address: { + label: "差出人", + help: "最初の差出人アドレス。差出人のないメッセージもミラーされ、破棄されることはありません。" + }, + from_name: { + label: "差出人名", + help: "最初の差出人アドレスの表示名" + }, + sender_address: { + label: "Sender", + help: "Sender ヘッダー(メッセージにある場合)" + }, + reply_to: { + label: "Reply-To", + help: "カンマ区切りの Reply-To アドレス" + }, + to_addresses: { + label: "宛先", + help: "カンマ区切りの宛先アドレス" + }, + cc_addresses: { + label: "Cc", + help: "カンマ区切りの Cc アドレス" + }, + sent_at: { + label: "送信日時", + help: "Date ヘッダー: 差出人が示すメッセージの作成日時" + }, + received_at: { + label: "受信日時", + help: "メールボックスがメッセージを受信した日時" + }, + subject: { + label: "件名" + }, + body_text: { + label: "本文(テキスト)" + }, + body_html: { + label: "本文(HTML)", + help: "差出人が書いたままの HTML パート。そのまま保存され、サニタイズせずに表示されることはありません" + }, + match_addresses: { + label: "照合アドレス", + help: "レコードの照合に使われた参加者アドレス(メールボックス自身のアドレスを除く)。再照合ではまさにこれらが再生されます" + }, + association_state: { + label: "関連付け", + help: "いずれかのレコードがメッセージに一致したかどうか。未関連付けのメッセージは制限付きプールで待機します。", + options: { + associated: "関連付け済み", + unassociated: "未関連付け" + } + }, + dedupe_key: { + label: "重複排除キー", + help: "正規化した Message-ID のハッシュ(ない場合はヘッダーブロックのハッシュ)。メールボックスごとにメッセージ 1 件につき 1 行" + } + } + }, + sys_mail_association: { + label: "メール関連付け", + pluralLabel: "メール関連付け", + description: "ミラーされた受信メッセージを、それが属するレコードに関連付けます", + fields: { + id: { + label: "関連付け ID" + }, + mail_message_id: { + label: "メッセージ" + }, + object_name: { + label: "オブジェクト", + help: "関連付けられたレコードが属するオブジェクトのマシン名" + }, + record_id: { + label: "レコード ID", + help: "関連付けられたレコードの ID(「オブジェクト」が示すオブジェクト内)" + }, + matched_address: { + label: "一致したアドレス", + help: "レコードに一致した参加者アドレス" + }, + matched_field: { + label: "一致したフィールド", + help: "値が一致したアドレスと等しかったレコードのメールフィールド" + }, + matched_by: { + label: "照合方法", + help: "この関連付けがメッセージ到着時に記録されたか、後の再照合で追加されたか", + options: { + ingest: "取り込み", + rematch: "再照合" + } + } + } + } +}; diff --git a/packages/services/service-mailbox/src/translations/ja-JP.source-hashes.generated.ts b/packages/services/service-mailbox/src/translations/ja-JP.source-hashes.generated.ts new file mode 100644 index 00000000000..628d7f3933d --- /dev/null +++ b/packages/services/service-mailbox/src/translations/ja-JP.source-hashes.generated.ts @@ -0,0 +1,27 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * Auto-generated by 'os i18n extract' for locale 'ja-JP'. Do not hand-edit. + * + * Each entry is the digest of the SOURCE REVISION that this locale's leaf at + * that path is still a byte copy of — provenance for the generated half of the + * bundles (commit 09b4f4e4e): a leaf whose digest no longer matches its source is stale and serves the source text instead. + * + * An entry exists only while the leaf IS such a copy. Re-translate the leaf in + * `.objects.generated.ts` and the next extract drops its entry by + * itself — the table makes no claim about text a translator wrote. A path with + * no entry is LEGACY-TRUSTED and never reported stale. + * + * ⚠️ Do not "fix" a staleness report by editing this file. Refreshing a digest + * records that the current text was copied from the current source, which is + * the false claim the mechanism exists to detect. Fix the TRANSLATION. + */ + +export const jaJPGeneratedSourceHashes: Readonly> = { + "objects.sys_mail_message.fields.cc_addresses.label": "02946d952cf15623", + "objects.sys_mail_message.fields.in_reply_to.label": "71bb10a36255d815", + "objects.sys_mail_message.fields.message_id.label": "14cd089a4062f4b1", + "objects.sys_mail_message.fields.reference_ids.label": "5102fd1664d35c0b", + "objects.sys_mail_message.fields.reply_to.label": "90ad8f2ca791a95a", + "objects.sys_mail_message.fields.sender_address.label": "89db06253eee9632", +}; diff --git a/packages/services/service-mailbox/src/translations/zh-CN.objects.generated.ts b/packages/services/service-mailbox/src/translations/zh-CN.objects.generated.ts new file mode 100644 index 00000000000..c05c9f09f9b --- /dev/null +++ b/packages/services/service-mailbox/src/translations/zh-CN.objects.generated.ts @@ -0,0 +1,161 @@ +// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * Auto-generated by 'os i18n extract' for locale 'zh-CN'. + * Edit translations in place; re-run extract (with --merge) to fill new gaps. + * Do not hand-edit the structure — only the leaf string values. + * Merge only fills gaps: correcting a source label/description does not + * push the correction into a leaf here that already holds a translation — + * a present-but-stale string is not a gap, so it is left as-is. Re-translate + * it by hand when its source changes; nothing here or in `os i18n check` + * tells a leaf a translator updated on purpose from one nobody has looked + * at since the source moved. + */ + +import type { TranslationData } from '@objectstack/spec/system'; + +export const zhCNObjects: NonNullable = { + sys_mailbox: { + label: "邮箱", + pluralLabel: "邮箱", + description: "平台从中镜像入站邮件的邮箱:其地址与所有者", + fields: { + id: { + label: "邮箱 ID" + }, + name: { + label: "名称", + help: "邮箱的显示名称(例如“销售收件箱”)" + }, + address: { + label: "地址", + help: "邮箱地址。它从不作为参与者参与匹配:发到此邮箱的邮件与邮箱本身无关。" + }, + owner_user_id: { + label: "所有者", + help: "拥有此邮箱的用户。所有者可以读取到达此邮箱的每一封邮件,包括未关联的邮件。" + } + } + }, + sys_mail_message: { + label: "邮件", + pluralLabel: "邮件", + description: "从邮箱镜像的入站邮件", + fields: { + id: { + label: "邮件 ID" + }, + mailbox_id: { + label: "邮箱", + help: "邮件到达的邮箱" + }, + message_id: { + label: "Message-ID", + help: "Message-ID 邮件头,已去除尖括号" + }, + in_reply_to: { + label: "In-Reply-To", + help: "In-Reply-To 邮件头:本邮件所回复邮件的 ID,以空格分隔" + }, + reference_ids: { + label: "References", + help: "References 邮件头:会话中各邮件的 ID,以空格分隔" + }, + from_address: { + label: "发件人", + help: "第一个发件人地址。没有发件人的邮件仍会被镜像,绝不丢弃。" + }, + from_name: { + label: "发件人名称", + help: "第一个发件人地址的显示名称" + }, + sender_address: { + label: "Sender", + help: "Sender 邮件头(如邮件带有)" + }, + reply_to: { + label: "Reply-To", + help: "以逗号分隔的 Reply-To 地址" + }, + to_addresses: { + label: "收件人", + help: "以逗号分隔的收件人地址" + }, + cc_addresses: { + label: "抄送", + help: "以逗号分隔的抄送地址" + }, + sent_at: { + label: "发送时间", + help: "Date 邮件头:发件人声明的邮件撰写时间" + }, + received_at: { + label: "接收时间", + help: "邮箱接收到邮件的时间" + }, + subject: { + label: "主题" + }, + body_text: { + label: "正文(文本)" + }, + body_html: { + label: "正文(HTML)", + help: "发件人编写的 HTML 部分,按原样存储,绝不在未净化的情况下渲染" + }, + match_addresses: { + label: "匹配地址", + help: "用于匹配记录的参与者地址(不含邮箱自身地址);重新匹配时按原样重放这些地址" + }, + association_state: { + label: "关联", + help: "是否有记录与该邮件匹配。未关联的邮件在受限池中等待。", + options: { + associated: "已关联", + unassociated: "未关联" + } + }, + dedupe_key: { + label: "去重键", + help: "规范化 Message-ID 的哈希(若无,则为邮件头块的哈希);每个邮箱中每封邮件一行" + } + } + }, + sys_mail_association: { + label: "邮件关联", + pluralLabel: "邮件关联", + description: "将镜像的入站邮件关联到其所属的记录", + fields: { + id: { + label: "关联 ID" + }, + mail_message_id: { + label: "邮件" + }, + object_name: { + label: "对象", + help: "关联记录所属对象的机器名" + }, + record_id: { + label: "记录 ID", + help: "关联记录的 ID,位于“对象”所指的对象中" + }, + matched_address: { + label: "匹配的地址", + help: "与该记录匹配的参与者地址" + }, + matched_field: { + label: "匹配的字段", + help: "记录中其值等于匹配地址的邮箱字段" + }, + matched_by: { + label: "匹配方式", + help: "该关联是在邮件到达时写入,还是由之后的重新匹配添加", + options: { + ingest: "接收", + rematch: "重新匹配" + } + } + } + } +}; diff --git a/packages/services/service-mailbox/src/translations/zh-CN.source-hashes.generated.ts b/packages/services/service-mailbox/src/translations/zh-CN.source-hashes.generated.ts new file mode 100644 index 00000000000..cbc42519e30 --- /dev/null +++ b/packages/services/service-mailbox/src/translations/zh-CN.source-hashes.generated.ts @@ -0,0 +1,26 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * Auto-generated by 'os i18n extract' for locale 'zh-CN'. Do not hand-edit. + * + * Each entry is the digest of the SOURCE REVISION that this locale's leaf at + * that path is still a byte copy of — provenance for the generated half of the + * bundles (commit 09b4f4e4e): a leaf whose digest no longer matches its source is stale and serves the source text instead. + * + * An entry exists only while the leaf IS such a copy. Re-translate the leaf in + * `.objects.generated.ts` and the next extract drops its entry by + * itself — the table makes no claim about text a translator wrote. A path with + * no entry is LEGACY-TRUSTED and never reported stale. + * + * ⚠️ Do not "fix" a staleness report by editing this file. Refreshing a digest + * records that the current text was copied from the current source, which is + * the false claim the mechanism exists to detect. Fix the TRANSLATION. + */ + +export const zhCNGeneratedSourceHashes: Readonly> = { + "objects.sys_mail_message.fields.in_reply_to.label": "71bb10a36255d815", + "objects.sys_mail_message.fields.message_id.label": "14cd089a4062f4b1", + "objects.sys_mail_message.fields.reference_ids.label": "5102fd1664d35c0b", + "objects.sys_mail_message.fields.reply_to.label": "90ad8f2ca791a95a", + "objects.sys_mail_message.fields.sender_address.label": "89db06253eee9632", +}; diff --git a/packages/services/service-mailbox/tsconfig.json b/packages/services/service-mailbox/tsconfig.json new file mode 100644 index 00000000000..0b8b99d8820 --- /dev/null +++ b/packages/services/service-mailbox/tsconfig.json @@ -0,0 +1,17 @@ +{ + "extends": "../../../tsconfig.json", + "compilerOptions": { + "outDir": "dist", + "rootDir": "src", + "types": [ + "node" + ] + }, + "include": [ + "src" + ], + "exclude": [ + "node_modules", + "dist" + ] +} diff --git a/packages/services/service-mailbox/tsconfig.scripts.json b/packages/services/service-mailbox/tsconfig.scripts.json new file mode 100644 index 00000000000..474c1c78c63 --- /dev/null +++ b/packages/services/service-mailbox/tsconfig.scripts.json @@ -0,0 +1,37 @@ +// The SCRIPTS-layer type-check program for this package (#11351). +// +// `scripts/i18n-extract.config.ts` is the input to this package's i18n +// extraction: it composes the package's own objects and translation bundles +// into an `ObjectStackDefinition`. It is real source, and until this file +// existed no tsc program read a line of it -- `tsconfig.json` selects only +// `src`, so the package passed `check:type-check-coverage` as COVERED with +// the directory unread. It was carried as an UNCHECKED_SOURCE_DEBT entry +// (#10756); that entry is deleted in the same change that adds this file, +// which RECONCILED forces anyway. +// +// A SIBLING rather than a wider `include` on `tsconfig.json`, the +// distinction #5475 drew for `packages/spec` and #10756 for +// `packages/objectql/scripts`: that config emits, so widening it to reach +// `scripts/` would put the directory in front of the emit. This program +// emits nothing. +// +// STRICTNESS IS INHERITED and deliberately not relaxed: `strict`, +// `noUnusedLocals`, `noUnusedParameters`, `noImplicitReturns` and the rest +// come from the root config through `tsconfig.json`. The directory +// type-checks clean under them -- it enters with ZERO recorded debt, and +// there is no ledger here to record any in. Module semantics are inherited +// too (NodeNext): these configs spell their relative imports with `.js` +// extensions already, so nothing here needs `packages/spec`'s bundler +// resolution or `allowImportingTsExtensions`. +// +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "noEmit": true, + // `.` rather than the inherited `src`, because the file this program + // checks is the one outside `src`. Safe precisely because nothing is + // emitted from here -- see the header. + "rootDir": "." + }, + "include": ["scripts/**/*"] +} diff --git a/packages/services/service-mailbox/vitest.config.ts b/packages/services/service-mailbox/vitest.config.ts new file mode 100644 index 00000000000..431ebf16e79 --- /dev/null +++ b/packages/services/service-mailbox/vitest.config.ts @@ -0,0 +1,31 @@ +import { defineConfig } from 'vitest/config'; +import path from 'node:path'; + +/** + * Every workspace package these tests import resolves to its SOURCE in this + * checkout, so a verdict here is a verdict about the source, never about a + * stale `dist/` (`pnpm check:test-source-alias`). Anchored, array form: a bare + * string `find` matches by prefix and would swallow the subpaths. + */ + +export default defineConfig({ + test: { + // A late console.* must not redden a green suite (#10374): vitest's worker + // forwards console output over RPC and discards the promise, and a write + // landing after teardown's rpcDone() snapshot is rejected into an unhandled + // error — a fully green run that exits 1. Disarming removes the mechanism. + // Mechanism + measured costs: examples/app-showcase/vitest.config.ts. + // Enforced repo-wide by scripts/check-console-intercept-disarm.mjs. + disableConsoleIntercept: true, + }, + resolve: { + alias: [ + { find: /^@objectstack\/lint$/, replacement: path.resolve(__dirname, '../../lint/src/index.ts') }, + { find: /^@objectstack\/core$/, replacement: path.resolve(__dirname, '../../core/src/index.ts') }, + { find: /^@objectstack\/platform-objects\/apps$/, replacement: path.resolve(__dirname, '../../platform-objects/src/apps/index.ts') }, + { find: /^@objectstack\/spec$/, replacement: path.resolve(__dirname, '../../spec/src/index.ts') }, + { find: /^@objectstack\/spec\/data$/, replacement: path.resolve(__dirname, '../../spec/src/data/index.ts') }, + { find: /^@objectstack\/spec\/security$/, replacement: path.resolve(__dirname, '../../spec/src/security/index.ts') }, + ], + }, +}); diff --git a/packages/services/service-storage/src/attachment-access-hooks.ts b/packages/services/service-storage/src/attachment-access-hooks.ts index 4f39b0c69bd..d6f749b45dd 100644 --- a/packages/services/service-storage/src/attachment-access-hooks.ts +++ b/packages/services/service-storage/src/attachment-access-hooks.ts @@ -1,6 +1,6 @@ // Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. -import { withoutOperationPrivateKeys } from '@objectstack/core'; +import { resolveReadableParentIds, withoutOperationPrivateKeys } from '@objectstack/core'; import type { StandardErrorCode } from '@objectstack/spec/api'; import type { ISecurityService, ISharingService } from '@objectstack/spec/contracts'; import type { ExecutionContext } from '@objectstack/spec/kernel'; @@ -221,54 +221,6 @@ function attachmentParentOf(row: Record): { object: string; id: return { object: po, id: String(pid) }; } -/** - * Which of these parent records can the CALLER read? Answered per parent - * object by ONE caller-scoped engine read of the candidate ids, so the parent - * object's own OWD/sharing, RLS and object-level CRUD decide. - * - * This is the one answer both halves of this module ask: the read-visibility - * middleware (which attachments a read may return) and the write gate's - * not-visible check (#21755). One evaluator, so an attachment can never be - * hidden by the read door and named by the write door. - * - * An object whose read throws (unknown to the engine, refused, driver fault) - * contributes no visible id — fail closed. - * - * [#7145] The caller's envelope, minus the operation-private keys: this probe - * reads a DIFFERENT object than the one the middleware resolved its depth for, - * and `__readScope` / `__expandRead` are widening inputs that would arrive - * attached to the wrong question (the security middleware re-stamps the depth - * for THIS object when it resolves any set, so the only thing dropping them can - * do is leave the owner-match at its narrowest — the safe direction). Same rule - * as `callerContext` below, and the same half of #7141 / PR #7143 the comment - * kit already carries. - */ -async function resolveReadableParentIds( - engine: Pick, - callerContext: Record | undefined, - idsByObject: ReadonlyMap>, -): Promise>> { - const callerEnvelope = withoutOperationPrivateKeys((callerContext ?? {}) as Record); - const visibleByObject = new Map>(); - for (const [parentObject, idSet] of idsByObject) { - const ids = [...idSet]; - let visible: string[] = []; - try { - const rows = await engine.find(parentObject, { - where: { id: { $in: ids } }, - fields: ['id'], - limit: ids.length, - context: { ...callerEnvelope }, - }); - visible = rows.map((r) => String(r.id)).filter(Boolean); - } catch { - // Unknown/failing parent object → none visible (fail closed). - visible = []; - } - visibleByObject.set(parentObject, new Set(visible)); - } - return visibleByObject; -} function asIdList(id: unknown): Array | null { if (typeof id === 'string' || typeof id === 'number') return [id]; diff --git a/packages/spec/llms.txt b/packages/spec/llms.txt index f317552992c..e9b7f56c5fb 100644 --- a/packages/spec/llms.txt +++ b/packages/spec/llms.txt @@ -178,9 +178,9 @@ function registerObject(rawConfig: unknown) { --- -## 7. Package Ecosystem (69 packages) +## 7. Package Ecosystem (70 packages) -The workspace publishes 69 packages under the `@objectstack` scope. The table +The workspace publishes 70 packages under the `@objectstack` scope. The table below is a curated entry-point list, not the full set — drivers, connectors, triggers, plugins and kernel-managed services each form their own family. diff --git a/packages/spec/src/identity/eval-user.zod.ts b/packages/spec/src/identity/eval-user.zod.ts index 73dc1f88880..f0ae93559bd 100644 --- a/packages/spec/src/identity/eval-user.zod.ts +++ b/packages/spec/src/identity/eval-user.zod.ts @@ -150,6 +150,10 @@ export const ADMIN_FULL_ACCESS_CAPABILITIES: Pick p.includes('`Row 35`') && p.includes('is row 36')) && - falsifiedRefs.some((p) => p.includes('`rows 1–61`')), + falsifiedRefs.some((p) => p.includes('`rows 1–62`')), ablated.problems.join(' | ') ); t( '⭐ ABLATION: and the `why:` strings for the two `why:` references #15687 falsified ' + '(the seams #15687 knew as rows 34 and 60; the page has since grown a row above them)', falsifiedWhy.some((p) => p.includes('`row 35`') && p.includes('is row 36')) && - falsifiedWhy.some((p) => p.includes('`row 59`') && p.includes('is row 60')), + falsifiedWhy.some((p) => p.includes('`row 60`') && p.includes('is row 61')), falsifiedWhy.join(' | ') ); t( diff --git a/scripts/i18n-coverage-baseline.json b/scripts/i18n-coverage-baseline.json index ad3d313a33e..6747701aa35 100644 --- a/scripts/i18n-coverage-baseline.json +++ b/scripts/i18n-coverage-baseline.json @@ -9,6 +9,7 @@ "packages/plugins/plugin-security/scripts/i18n-extract.config.ts": 0, "packages/plugins/plugin-sharing/scripts/i18n-extract.config.ts": 0, "packages/plugins/plugin-webhooks/scripts/i18n-extract.config.ts": 0, + "packages/services/service-mailbox/scripts/i18n-extract.config.ts": 0, "packages/services/service-messaging/scripts/i18n-extract.config.ts": 0, "packages/services/service-realtime/scripts/i18n-extract.config.ts": 0, "packages/services/service-storage/scripts/i18n-extract.config.ts": 0 diff --git a/scripts/platform-object-tenancy-census.json b/scripts/platform-object-tenancy-census.json index bfea2ed6d67..0ca3b874695 100644 --- a/scripts/platform-object-tenancy-census.json +++ b/scripts/platform-object-tenancy-census.json @@ -24,8 +24,8 @@ "predicate": "resolveTenantFieldName(registered schema) !== null, where the registered schema is the authored schema plus the columns resolveInjectedSystemColumns says the registration injects (the applySystemFields pass). Both functions are loaded from source and executed; neither is re-spelled here.", "population": "every object registered by a tracked packages/**/*.object.ts module whose name carries a platform prefix (sys_ / cloud_ / ai_). The cloud repository's own cloud_ objects are not in this tree and so not in this census.", "totals": { - "registered": 84, - "inReach": 49, + "registered": 87, + "inReach": 52, "outOfReach": 35 }, "reasonTotals": { @@ -273,6 +273,27 @@ "managedBy: 'better-auth'" ] }, + { + "name": "sys_mail_association", + "file": "packages/services/service-mailbox/src/objects/sys-mail-association.object.ts", + "reach": "in", + "tenantField": "organization_id", + "reasons": [] + }, + { + "name": "sys_mail_message", + "file": "packages/services/service-mailbox/src/objects/sys-mail-message.object.ts", + "reach": "in", + "tenantField": "organization_id", + "reasons": [] + }, + { + "name": "sys_mailbox", + "file": "packages/services/service-mailbox/src/objects/sys-mailbox.object.ts", + "reach": "in", + "tenantField": "organization_id", + "reasons": [] + }, { "name": "sys_member", "file": "packages/platform-objects/src/identity/sys-member.object.ts", diff --git a/scripts/publish-smoke-pack.mjs b/scripts/publish-smoke-pack.mjs index 57e6d07e07a..7ec3f588874 100644 --- a/scripts/publish-smoke-pack.mjs +++ b/scripts/publish-smoke-pack.mjs @@ -33,7 +33,7 @@ * a future exclusion cannot re-open the hole silently. * * Source of truth: the workspace itself. The Changesets `fixed` group in - * .changeset/config.json enumerates the same 70 names, but it is a DERIVED + * .changeset/config.json enumerates the same 71 names, but it is a DERIVED * declaration validated against the workspace by scripts/check-changeset-fixed.mjs * (which reddens both when a public package is missing from the group and * when a group name no longer exists) — deriving from the group would mean