Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 27 additions & 0 deletions .changeset/22870-service-mailbox.md
Original file line number Diff line number Diff line change
@@ -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.
1 change: 1 addition & 0 deletions .changeset/config.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
41 changes: 21 additions & 20 deletions content/docs/permissions/system-context.mdx

Large diffs are not rendered by default.

4 changes: 2 additions & 2 deletions content/docs/protocol/backward-compatibility.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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).

---

Expand Down Expand Up @@ -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.

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
5 changes: 4 additions & 1 deletion examples/app-showcase/objectstack.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
1 change: 1 addition & 0 deletions packages/cli/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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:*",
Expand Down
6 changes: 6 additions & 0 deletions packages/cli/src/utils/organization-ownership-inventory.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: [] },

Expand Down
1 change: 1 addition & 0 deletions packages/cli/test/serve-capability-identity.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,7 @@ const EXPECTED_PROVIDER_NAME: Record<string, string> = {
// 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',
Expand Down
7 changes: 7 additions & 0 deletions packages/core/src/capability-providers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -181,6 +181,13 @@ export const CAPABILITY_PROVIDERS: Readonly<Record<string, CapabilitySpec>> = {
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`
Expand Down
8 changes: 8 additions & 0 deletions packages/core/src/security/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
64 changes: 64 additions & 0 deletions packages/core/src/security/readable-parent-ids.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown>): Promise<Array<Record<string, unknown>>>;
}

/**
* 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<string, unknown> | undefined,
idsByObject: ReadonlyMap<string, ReadonlySet<string>>,
): Promise<Map<string, Set<string>>> {
const callerEnvelope = withoutOperationPrivateKeys((callerContext ?? {}) as Record<string, unknown>);
const visibleByObject = new Map<string, Set<string>>();
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;
}
51 changes: 1 addition & 50 deletions packages/plugins/plugin-audit/src/comment-access-hooks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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<CommentAccessEngine, 'find'>,
callerContext: Record<string, unknown> | undefined,
idsByObject: ReadonlyMap<string, ReadonlySet<string>>,
): Promise<Map<string, Set<string>>> {
const callerEnvelope = withoutOperationPrivateKeys((callerContext ?? {}) as Record<string, unknown>);
const visibleByObject = new Map<string, Set<string>>();
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;
}
4 changes: 2 additions & 2 deletions packages/plugins/plugin-audit/src/parent-record-read-gate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -53,8 +53,8 @@
* (the activity stream) exempts no caller.
*/

import { resolveReadableParentIds } from '@objectstack/core';
import {
resolveReadableParentIds,
type CommentAccessEngine,
type CommentAccessLogger,
type CommentReadMiddlewareCtx,
Expand Down
Loading
Loading