diff --git a/.changeset/21913-principal-less-producers.md b/.changeset/21913-principal-less-producers.md new file mode 100644 index 00000000000..f685843e1ed --- /dev/null +++ b/.changeset/21913-principal-less-producers.md @@ -0,0 +1,19 @@ +--- +"@objectstack/service-settings": minor +"@objectstack/service-messaging": patch +"@objectstack/service-datasource": minor +"@objectstack/plugin-webhooks": patch +--- + +Platform plumbing in these four packages now passes the explicit system opt-in (`{ isSystem: true }`) on its data-engine calls. Until now it reached the engine with no principal and no opt-in, and the security middleware let that through only because of its principal-less hand-off. + +Clause-②: yes (widening) + +- **Why `yes (widening)`:** two exported option types gain an optional `context` that an adapter must forward as-is. They are `SettingsEngine.find` / `.insert` (`@objectstack/service-settings`) and `SecretStoreEngineLike.delete` (`@objectstack/service-datasource`), so both packages take a `minor`. An implementation written against the old types still type-checks, and nothing accepted or refused at any door changes. +- **service-settings:** `SettingsService` reads and writes its own `sys_setting` rows under the opt-in: `loadRows`, plus the existence probe and insert in `upsertRow` (the update already used it). The `sys_setting_audit` writer does too. +- **service-datasource:** the `sys_metadata` helpers behind runtime datasources use the opt-in. They cover boot restore, cluster convergence, and persist and delete behind the admin doors. So do the `sys_secret` binder's `bind`, `unbind` and `resolve`. +- **plugin-webhooks:** the auto-enqueuer's subscription refresh and the redeliver guard's subscription lookup use the opt-in. +- **service-messaging:** two paths use the opt-in. One is the dispatcher's claim path: `claim`, `claimDigest` and the visibility-timeout reap on both outboxes. The other is the emit fan-out: the `sys_notification` row, the recipient's address and locale reads, the preference reads, the inbox row and the delivered receipt. +- **A user reference that names no user is still refused.** The engine skips its dangling-reference check for an `isSystem` write, so each producer that writes a user reference checks it first. The checked references are the `actor_id` of `sys_notification`, `sys_inbox_message` and `sys_setting_audit`, and the `user_id` of a user-scope `sys_setting` row. An unknown id is refused with the engine's own answer: `VALIDATION_FAILED`, one `reference_not_found` finding, and the same message. A write that names no user is unchanged. +- What each call reads and writes is otherwise unchanged. None of the gates the middleware runs before its hand-off applies to these objects. +- ⛔ No new export on any package entry, and no new elevation API. diff --git a/content/docs/permissions/tenant-audit-census.mdx b/content/docs/permissions/tenant-audit-census.mdx index 01a3aad9f6c..2fe206b417a 100644 --- a/content/docs/permissions/tenant-audit-census.mdx +++ b/content/docs/permissions/tenant-audit-census.mdx @@ -122,7 +122,7 @@ are reported as `undecidable` rather than assumed either way. The same holds twice over for the context. An options argument spelled as a literal can be read; one spelled `options`, `{ ...opts }`, or handed through a -forwarding shim cannot, and **67 of the 233 sites are spelled that way**. A +forwarding shim cannot, and **60 of the 233 sites are spelled that way**. A context resolved from an inline literal or a local `const` can be tested for `isSystem`; one arriving from a helper call cannot. @@ -150,8 +150,8 @@ now **0**: nothing on this surface threads a context that provably lacks the fla **"No tenant context" counted sites it had not read.** An options argument the walker could not parse was folded into the same bucket as one it had read and -found empty. That published **84 sites "carrying no tenant context at all"** -when 17 said so and 67 were simply unread — an over-claim in the *alarming* +found empty. That published **69 sites "carrying no tenant context at all"** +when 9 said so and 60 were simply unread — an over-claim in the *alarming* direction, on the very figure this page tells other cards to cite. `carries` is now three-valued, and an unreadable argument can never contribute to the provable count. @@ -188,9 +188,9 @@ reproduce them. Where it disagrees, it disagrees on the page: | carried figure | where it survives | this census | | :--- | :--- | ---: | | 175 write call sites | quoted in the merged changeset | **233** | -| 24 carrying no tenant context | quoted in the merged changeset | **9** provable and tenancy-enabled; **34** more whose options argument is unreadable | +| 24 carrying no tenant context | quoted in the merged changeset | **2** provable and tenancy-enabled; **33** more whose options argument is unreadable | | 127 of 175 statically decidable, 48 runtime-parameter-name sites | restated on the `isSystem`-scoping card | **155 of 233** decidable, **78** undecidable | -| 135 (77%) silenced by the `isSystem` guard before the posture gate | the lost issue body — **no surviving corroboration** | **not reproduced**: 114 decidably elevated, 0 decidably not, 102 undecidable | +| 135 (77%) silenced by the `isSystem` guard before the posture gate | the lost issue body — **no surviving corroboration** | **not reproduced**: 121 decidably elevated, 0 decidably not, 103 undecidable | | 141 and 132, two independent re-derivations | the card that filed this work | — | **The differences are not reconciled, and deliberately so.** The old census's @@ -207,14 +207,14 @@ would report a smaller number and would not say so. The fourth row is the one worth flagging to anyone citing it. **The 135 / 77% figure has no surviving corroboration anywhere in the tree.** This census reads -114 of 233 (49%) as decidably elevated, with 102 more whose elevation is a +121 of 233 (52%) as decidably elevated, with 103 more whose elevation is a run-time fact — so the claim is neither confirmed nor refuted, and the honest answer is that a static reading cannot settle it. -⇒ **Cite `9 / 233`, and say what it is**: the sites whose options argument was +⇒ **Cite `2 / 233`, and say what it is**: the sites whose options argument was READ and holds no tenant context, against a decidably tenancy-enabled object. That is the control's provable yield surface. ⛔ Do not cite it as "the sites -without tenant context" — **34 further sites** have an options argument this +without tenant context" — **33 further sites** have an options argument this cannot read, and they are neither in nor out. {/* BEGIN GENERATED: tenant-audit-census (scripts/tenant-audit-census.mjs) — DO NOT EDIT */} @@ -228,14 +228,14 @@ cannot read, and they are neither in nor out. | …whose object name is chosen at run time | 78 | | …against an object with tenancy ENABLED | 154 | | …against an object that declares tenancy off | 1 | -| threading a tenant context | 149 | -| PROVABLY carrying none (options read, no context key) | **17** | -| …of those, against a decidably tenancy-enabled object | **9** | -| options argument UNREADABLE — may or may not carry one | 67 | -| …of those, against a decidably tenancy-enabled object | 34 | -| threading a decidably ELEVATED (`isSystem`) context | 114 | +| threading a tenant context | 164 | +| PROVABLY carrying none (options read, no context key) | **9** | +| …of those, against a decidably tenancy-enabled object | **2** | +| options argument UNREADABLE — may or may not carry one | 60 | +| …of those, against a decidably tenancy-enabled object | 33 | +| threading a decidably ELEVATED (`isSystem`) context | 121 | | threading a context that is decidably NOT elevated | 0 | -| threading a context whose elevation is a run-time fact | 102 | +| threading a context whose elevation is a run-time fact | 103 | | how the instrument reached the site | count | | :--- | ---: | @@ -297,13 +297,13 @@ holds still. They are required to be HERE and to say WHEN they were true; their values are not compared. The reasoning, and the measurement behind it, are in `scripts/check-tenant-audit-census.mjs`. -Measured on 2026-10-05 at `3d34c6efd`. +Measured on 2026-10-06 at `3832674ac`. | corpus scale (not enforced) | count | | :--- | ---: | -| tracked non-test sources scanned | 607 | -| engine-shaped types recognised | 69 | +| tracked non-test sources scanned | 609 | +| engine-shaped types recognised | 70 | | declared objects in the registry | 116 | -| same-named calls subtracted as non-engine | 158 | +| same-named calls subtracted as non-engine | 159 | {/* END GENERATED: tenant-audit-census */} diff --git a/docs/audits/2026-08-tenant-audit-write-call-sites.counts.md b/docs/audits/2026-08-tenant-audit-write-call-sites.counts.md index 0f8e8e087e1..f5eaac2edcf 100644 --- a/docs/audits/2026-08-tenant-audit-write-call-sites.counts.md +++ b/docs/audits/2026-08-tenant-audit-write-call-sites.counts.md @@ -38,14 +38,14 @@ silent, and `node scripts/tenant-audit-census.mjs --write` is the resolution. | Object name chosen at run time | 78 | | Against a tenancy-enabled object | 154 | | Against an object declaring tenancy off | 1 | -| Threading a tenant context | 149 | -| Provably carrying none | 17 | -| …and decidably tenancy-enabled | 9 | -| Options argument unreadable | 67 | -| …and decidably tenancy-enabled | 34 | -| Threading a decidably elevated context | 114 | +| Threading a tenant context | 164 | +| Provably carrying none | 9 | +| …and decidably tenancy-enabled | 2 | +| Options argument unreadable | 60 | +| …and decidably tenancy-enabled | 33 | +| Threading a decidably elevated context | 121 | | Threading a decidably non-elevated context | 0 | -| Threading a context of undecidable elevation | 102 | +| Threading a context of undecidable elevation | 103 | ## Subtractions the census could NOT defend — enforced @@ -90,14 +90,14 @@ holds still. They are required to be HERE and to say WHEN they were true; their values are not compared. The reasoning, and the measurement behind it, are in `scripts/check-tenant-audit-census.mjs`. -Measured on 2026-10-05 at `3d34c6efd`. +Measured on 2026-10-06 at `3832674ac`. | corpus scale (not enforced) | count | | :--- | ---: | -| tracked non-test sources scanned | 607 | -| engine-shaped types recognised | 69 | +| tracked non-test sources scanned | 609 | +| engine-shaped types recognised | 70 | | declared objects in the registry | 116 | -| same-named calls subtracted as non-engine | 158 | +| same-named calls subtracted as non-engine | 159 | ## Every site @@ -208,24 +208,26 @@ Measured on 2026-10-05 at `3d34c6efd`. | `packages/services/service-automation/src/suspended-run-store.ts` | `delete` | `sys_automation_run` | enabled | elevated | 3 | | `packages/services/service-automation/src/suspended-run-store.ts` | `insert` | `sys_automation_run` | enabled | elevated | 2 | | `packages/services/service-automation/src/suspended-run-store.ts` | `update` | `sys_automation_run` | enabled | elevated | 2 | -| `packages/services/service-datasource/src/datasource-admin-plugin.ts` | `delete` | `sys_metadata` | enabled | PROVABLY NONE | 1 | -| `packages/services/service-datasource/src/datasource-admin-plugin.ts` | `insert` | `sys_metadata` | enabled | PROVABLY NONE | 1 | -| `packages/services/service-datasource/src/datasource-admin-plugin.ts` | `update` | `sys_metadata` | enabled | PROVABLY NONE | 2 | -| `packages/services/service-datasource/src/datasource-secret-binder.ts` | `delete` | `sys_secret` | enabled | PROVABLY NONE | 1 | -| `packages/services/service-datasource/src/datasource-secret-binder.ts` | `insert` | `sys_secret` | enabled | PROVABLY NONE | 1 | +| `packages/services/service-datasource/src/datasource-admin-plugin.ts` | `delete` | `sys_metadata` | enabled | elevated | 1 | +| `packages/services/service-datasource/src/datasource-admin-plugin.ts` | `insert` | `sys_metadata` | enabled | elevated | 1 | +| `packages/services/service-datasource/src/datasource-admin-plugin.ts` | `update` | `sys_metadata` | enabled | elevated | 2 | +| `packages/services/service-datasource/src/datasource-secret-binder.ts` | `delete` | `sys_secret` | enabled | elevated | 1 | +| `packages/services/service-datasource/src/datasource-secret-binder.ts` | `insert` | `sys_secret` | enabled | elevated | 1 | | `packages/services/service-job/src/db-job-adapter.ts` | `insert` | `sys_job` | enabled | elevated | 1 | | `packages/services/service-job/src/db-job-adapter.ts` | `update` | `sys_job` | enabled | elevated | 3 | | `packages/services/service-job/src/db-job-adapter.ts` | `insert` | `sys_job_run` | enabled | elevated | 1 | | `packages/services/service-job/src/db-job-adapter.ts` | `update` | `sys_job_run` | enabled | elevated | 1 | -| `packages/services/service-messaging/src/inbox-channel.ts` | `insert` | `objectName` | undecidable | options unreadable | 1 | -| `packages/services/service-messaging/src/inbox-channel.ts` | `insert` | `receiptObject` | undecidable | PROVABLY NONE | 1 | +| `packages/services/service-messaging/src/inbox-channel.ts` | `insert` | `objectName` | undecidable | context, elevation undecidable | 1 | +| `packages/services/service-messaging/src/inbox-channel.ts` | `insert` | `receiptObject` | undecidable | context, elevation undecidable | 1 | | `packages/services/service-messaging/src/messaging-service.ts` | `insert` | `RECEIPT_OBJECT` | undecidable | PROVABLY NONE | 1 | | `packages/services/service-messaging/src/messaging-service.ts` | `update` | `RECEIPT_OBJECT` | undecidable | options unreadable | 1 | -| `packages/services/service-messaging/src/messaging-service.ts` | `insert` | `sys_notification` | enabled | options unreadable | 1 | +| `packages/services/service-messaging/src/messaging-service.ts` | `insert` | `sys_notification` | enabled | context, elevation undecidable | 1 | | `packages/services/service-messaging/src/sql-http-outbox.ts` | `insert` | `this.objectName` | undecidable | options unreadable | 1 | -| `packages/services/service-messaging/src/sql-http-outbox.ts` | `update` | `this.objectName` | undecidable | options unreadable | 5 | +| `packages/services/service-messaging/src/sql-http-outbox.ts` | `update` | `this.objectName` | undecidable | context, elevation undecidable | 2 | +| `packages/services/service-messaging/src/sql-http-outbox.ts` | `update` | `this.objectName` | undecidable | options unreadable | 3 | | `packages/services/service-messaging/src/sql-outbox.ts` | `insert` | `this.objectName` | undecidable | options unreadable | 1 | -| `packages/services/service-messaging/src/sql-outbox.ts` | `update` | `this.objectName` | undecidable | options unreadable | 4 | +| `packages/services/service-messaging/src/sql-outbox.ts` | `update` | `this.objectName` | undecidable | context, elevation undecidable | 3 | +| `packages/services/service-messaging/src/sql-outbox.ts` | `update` | `this.objectName` | undecidable | options unreadable | 1 | | `packages/services/service-queue/src/db-queue-adapter.ts` | `delete` | `sys_job_queue` | enabled | context, elevation undecidable | 2 | | `packages/services/service-queue/src/db-queue-adapter.ts` | `insert` | `sys_job_queue` | enabled | context, elevation undecidable | 1 | | `packages/services/service-queue/src/db-queue-adapter.ts` | `update` | `sys_job_queue` | enabled | context, elevation undecidable | 6 | @@ -235,7 +237,7 @@ Measured on 2026-10-05 at `3d34c6efd`. | `packages/services/service-settings/src/settings-service-plugin.ts` | `delete` | `sys_secret` | enabled | elevated | 1 | | `packages/services/service-settings/src/settings-service-plugin.ts` | `insert` | `sys_secret` | enabled | options unreadable | 1 | | `packages/services/service-settings/src/settings-service-plugin.ts` | `update` | `sys_secret` | enabled | options unreadable | 1 | -| `packages/services/service-settings/src/settings-service-plugin.ts` | `insert` | `sys_setting_audit` | enabled | PROVABLY NONE | 1 | +| `packages/services/service-settings/src/settings-service-plugin.ts` | `insert` | `sys_setting_audit` | enabled | elevated | 1 | | `packages/services/service-settings/src/settings-service.ts` | `insert` | `this.objectName` | undecidable | options unreadable | 1 | | `packages/services/service-settings/src/settings-service.ts` | `update` | `this.objectName` | undecidable | options unreadable | 1 | | `packages/services/service-storage/src/attachment-lifecycle.ts` | `update` | `sys_file` | enabled | elevated | 3 | diff --git a/packages/plugins/plugin-webhooks/src/auto-enqueuer.ts b/packages/plugins/plugin-webhooks/src/auto-enqueuer.ts index c36bc9b1713..a7afb3b09e3 100644 --- a/packages/plugins/plugin-webhooks/src/auto-enqueuer.ts +++ b/packages/plugins/plugin-webhooks/src/auto-enqueuer.ts @@ -22,6 +22,16 @@ import { type LegacyDefinitionCredentialKey, } from './webhook-legacy-cleartext.js'; +/** + * [#21913] The execution context the subscription cache refresh + * ({@link AutoEnqueuer.refresh}) reads `sys_webhook` under: the explicit system + * opt-in. It is the platform reading its own delivery configuration on a boot + * and timer path that has no caller, so it may not rely on a missing principal + * to pass the security middleware's principal-less hand-off, which ADR-0096 D5 + * closes. + */ +const SYSTEM_CTX = { isSystem: true } as const; + /** * The authored trigger vocabulary, taken from the spec rather than restated * here — this file both validates authored triggers and maps events onto them, @@ -378,9 +388,11 @@ export class AutoEnqueuer { private async doRefresh(): Promise { let rows: any[]; try { - rows = await this.engine.find(this.subscriptionsObject, { - where: { active: true }, - }); + rows = await this.engine.find( + this.subscriptionsObject, + { where: { active: true } }, + { context: SYSTEM_CTX }, + ); } catch (err) { this.logger?.warn?.( `[webhook-auto-enqueuer] failed to load ${this.subscriptionsObject}`, diff --git a/packages/plugins/plugin-webhooks/src/redeliver-guard.ts b/packages/plugins/plugin-webhooks/src/redeliver-guard.ts index 3d01ae83eae..609a6d92970 100644 --- a/packages/plugins/plugin-webhooks/src/redeliver-guard.ts +++ b/packages/plugins/plugin-webhooks/src/redeliver-guard.ts @@ -59,6 +59,18 @@ import { resolveWebhookSecret, } from './webhook-secret.js'; +/** + * [#21913] The execution context the guard reads `sys_webhook` under: the + * explicit system opt-in. The guard runs inside the messaging service's + * redeliver path, after the delivery row has been read under the requesting + * caller's organization, and reads the subscription that row belongs to only + * for its existence, name and secret posture — the inputs of the refusal + * reason it returns. What it reads and returns is unchanged by the opt-in; it + * may simply no longer rely on a missing principal to pass the security + * middleware's principal-less hand-off, which ADR-0096 D5 closes. + */ +const SYSTEM_CTX = { isSystem: true } as const; + /** The delivery-row fields this guard reads. Structural — no messaging import. */ export interface RedeliverGuardRow { /** Producer domain; only `'webhook'` rows are this guard's business. */ @@ -79,9 +91,11 @@ export function createWebhookRedeliverGuard( return async (row) => { if (row.source !== 'webhook') return undefined; - const subscription = (await engine.findOne(subscriptionsObject, { - where: { id: row.refId }, - })) as Record | null; + const subscription = (await engine.findOne( + subscriptionsObject, + { where: { id: row.refId } }, + { context: SYSTEM_CTX }, + )) as Record | null; if (!subscription) { return ( diff --git a/packages/plugins/plugin-webhooks/src/webhook-system-context.pin.test.ts b/packages/plugins/plugin-webhooks/src/webhook-system-context.pin.test.ts new file mode 100644 index 00000000000..91d561e1254 --- /dev/null +++ b/packages/plugins/plugin-webhooks/src/webhook-system-context.pin.test.ts @@ -0,0 +1,58 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#21913] This plugin's two `sys_webhook` reads carry the explicit system + * opt-in (`isSystem: true`): the auto-enqueuer's subscription cache refresh + * (`AutoEnqueuer.refresh`, boot and timer) and the redeliver guard's + * subscription lookup (`createWebhookRedeliverGuard`). + * + * Both used to reach the data engine with no context at all — no principal and + * no system opt-in — and passed the security middleware only through its + * principal-less hand-off (ADR-0096 E1), which D5 closes. The refresh has no + * caller; the guard runs inside the redeliver path after the delivery row was + * read under the requesting caller's organization, and what it reads is the + * subscription's existence, name and secret posture. + */ + +import { describe, it, expect } from 'vitest'; +import { AutoEnqueuer } from './auto-enqueuer.js'; +import { createWebhookRedeliverGuard } from './redeliver-guard.js'; +import { assertEngineFindOnePredicate } from '@objectstack/metadata-core'; + +type Call = { verb: string; object: string; context: unknown }; + +/** A read-only double: a write this pin does not expect has no method to land on. */ +function recordingEngine(rows: Array>) { + const calls: Call[] = []; + const ctxOf = (query: any, options: any) => options?.context ?? query?.context; + const engine = { + async find(object: string, query: any, options?: any) { + calls.push({ verb: 'find', object, context: ctxOf(query, options) }); + return rows; + }, + async findOne(object: string, query: any, options?: any) { + assertEngineFindOnePredicate(object, query); + calls.push({ verb: 'findOne', object, context: ctxOf(query, options) }); + return rows.find((r) => r.id === query?.where?.id) ?? null; + }, + }; + return { engine: engine as any, calls }; +} + +describe('[#21913] plugin-webhooks sys_webhook reads carry the explicit system opt-in', () => { + it('AutoEnqueuer.refresh reads the subscriptions under isSystem', async () => { + const { engine, calls } = recordingEngine([]); + const realtime = { subscribe: async () => 'sub_1', unsubscribe: async () => {} } as any; + const ae = new AutoEnqueuer(engine, realtime, async () => 'del_1', { refreshIntervalMs: 0 }); + await ae.refresh(); + expect(calls).toEqual([{ verb: 'find', object: 'sys_webhook', context: { isSystem: true } }]); + }); + + it('the redeliver guard reads the subscription under isSystem', async () => { + const { engine, calls } = recordingEngine([{ id: 'wh_1', name: 'hook', signing_secret: null }]); + const guard = createWebhookRedeliverGuard(engine); + // A subscription that stores no secret is allowed: the read ran and answered. + expect(await guard({ source: 'webhook', refId: 'wh_1' })).toBeUndefined(); + expect(calls).toEqual([{ verb: 'findOne', object: 'sys_webhook', context: { isSystem: true } }]); + }); +}); diff --git a/packages/services/service-datasource/src/__tests__/datasource-system-context.pin.test.ts b/packages/services/service-datasource/src/__tests__/datasource-system-context.pin.test.ts new file mode 100644 index 00000000000..e650ed37b9b --- /dev/null +++ b/packages/services/service-datasource/src/__tests__/datasource-system-context.pin.test.ts @@ -0,0 +1,174 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#21913] The runtime-datasource persistence this package owns carries the + * explicit system opt-in (`isSystem: true`) on every engine call: + * + * - the `sys_metadata` helpers behind `DatasourceAdminServicePlugin` — + * `persistDatasourceRow` (existence probe + insert, or probe + update), + * `deleteDatasourceRow` (probe + delete), `loadDatasourceRows` (boot + * restore) and `loadDatasourceRow` (cluster convergence); + * - the `sys_secret` binder — `bind` (insert), `unbind` (delete) and + * `resolve` (read). + * + * They used to reach the data engine with no context at all — no principal + * and no system opt-in — and passed the security middleware only through its + * principal-less hand-off (ADR-0096 E1), which D5 closes. The helpers are + * module-private, so they are driven through the plugin's own doors; the + * convergence read is reached through `convergePool`, the receive half of the + * cluster bridge, called directly because a pin about the read it makes does + * not need a second replica. + */ + +import { describe, it, expect } from 'vitest'; +import type { CryptoContext, CryptoHandle, ICryptoProvider } from '@objectstack/spec/contracts'; +import { DatasourceAdminServicePlugin } from '../datasource-admin-plugin.js'; +import { createDatasourceSecretBinder } from '../datasource-secret-binder.js'; +import { + assertEngineDeleteDispatch, + assertEngineFindOnePredicate, + assertEngineUpdateDispatch, +} from '@objectstack/metadata-core'; +// Pay the dist-resolved spec subpath's first transform at module load, as the +// sibling plugin suite does (`check-test-source-alias`, clocked-window rule). +import '@objectstack/spec/kernel'; + +type Call = { verb: string; object: string; context: unknown }; + +/** An in-memory store that records the context each engine call carried. */ +function recordingStore() { + const rows: Array> = []; + const calls: Call[] = []; + const ctxOf = (query: any, options: any) => options?.context ?? query?.context; + const match = (r: Record, w: Record = {}) => + Object.entries(w).every(([k, v]) => { + if (k.startsWith('$')) throw new Error(`recording store: unsupported operator ${k}`); + return r[k] === v; + }); + return { + rows, + calls, + registerDriver() {}, + registerDatasourceDef() {}, + getDriverByName() { return undefined; }, + async findOne(object: string, query: any, options?: any) { + assertEngineFindOnePredicate(object, query); + calls.push({ verb: 'findOne', object, context: ctxOf(query, options) }); + return rows.find((r) => match(r, query?.where)) ?? null; + }, + async find(object: string, query: any, options?: any) { + calls.push({ verb: 'find', object, context: ctxOf(query, options) }); + const hits = rows.filter((r) => match(r, query?.where)); + return typeof query?.limit === 'number' ? hits.slice(0, query.limit) : hits; + }, + async insert(object: string, row: Record, options?: any) { + calls.push({ verb: 'insert', object, context: options?.context }); + rows.push({ ...row }); + return row; + }, + async update(object: string, row: Record, options?: any) { + assertEngineUpdateDispatch(row, options); + calls.push({ verb: 'update', object, context: options?.context }); + const i = rows.findIndex((r) => r.id === options?.where?.id); + if (i >= 0) rows[i] = { ...rows[i], ...row }; + return 1; + }, + async delete(object: string, options?: any) { + assertEngineDeleteDispatch(options); + calls.push({ verb: 'delete', object, context: options?.context }); + const i = rows.findIndex((r) => r.id === options?.where?.id); + if (i >= 0) rows.splice(i, 1); + return true; + }, + }; +} + +function expectAllSystem(calls: Call[]): void { + for (const call of calls) { + expect(call.context, `${call.verb} on ${call.object}`).toEqual({ isSystem: true }); + } +} + +async function boot(data: ReturnType) { + const registry = new Map>(); + const metadata = { + get: async (t: string, n: string) => registry.get(t)?.get(n), + list: async (t: string) => [...(registry.get(t)?.values() ?? [])], + register: async (t: string, n: string, d: unknown) => { + if (!registry.has(t)) registry.set(t, new Map()); + registry.get(t)!.set(n, d); + }, + unregister: async (t: string, n: string) => { registry.get(t)?.delete(n); }, + listObjects: async () => [...(registry.get('object')?.values() ?? [])], + }; + const services: Record = { metadata, data }; + let service: any; + const ctx: any = { + getService: (name: string) => { + if (name in services) return services[name]; + throw new Error(`no service ${name}`); + }, + registerService: (name: string, svc: unknown) => { if (name === 'datasource-admin') service = svc; }, + trigger: async () => {}, + logger: { warn() {}, info() {}, error() {}, debug() {} }, + }; + const plugin = new DatasourceAdminServicePlugin({}); + await plugin.init(ctx); + return { plugin, ctx, service }; +} + +describe('[#21913] runtime-datasource sys_metadata helpers carry the explicit system opt-in', () => { + it('persist (insert and update branches), boot restore, convergence read and delete', async () => { + const data = recordingStore(); + const first = await boot(data); + await first.service.createDatasource({ name: 'pin_ds', driver: 'sqlite', active: false, config: { filename: '/tmp/pin.db' } }); + await first.service.updateDatasource('pin_ds', { label: 'Pin' }); + + // A "restart" over the same store: start() restores runtime rows from it. + const second = await boot(data); + await second.plugin.start(second.ctx); + expect((await second.service.listDatasources()).map((d: any) => d.name)).toContain('pin_ds'); + await (second.plugin as any).convergePool('pin_ds', () => data); + + await second.service.removeDatasource('pin_ds'); + expect(data.rows.some((r) => r.name === 'pin_ds')).toBe(false); + + const onMetadata = data.calls.filter((c) => c.object === 'sys_metadata'); + // The population first: every helper this pin names actually ran. + const verbs = onMetadata.map((c) => c.verb); + expect(verbs.filter((v) => v === 'insert')).toHaveLength(1); + expect(verbs.filter((v) => v === 'update')).toHaveLength(1); + expect(verbs.filter((v) => v === 'delete')).toHaveLength(1); + expect(verbs.filter((v) => v === 'find').length).toBeGreaterThanOrEqual(1); + expect(verbs.filter((v) => v === 'findOne').length).toBeGreaterThanOrEqual(4); + expect(onMetadata).toHaveLength(data.calls.length); + expectAllSystem(onMetadata); + }); +}); + +describe('[#21913] the sys_secret binder carries the explicit system opt-in', () => { + it('bind, resolve and unbind', async () => { + const data = recordingStore(); + const crypto: ICryptoProvider = { + async encrypt(plain: string, ctx: CryptoContext): Promise { + return { id: `sec_${ctx.key}`, kmsKeyId: 'k', alg: 'a', version: 1, ciphertext: plain }; + }, + async decrypt(handle: CryptoHandle): Promise { return handle.ciphertext; }, + async rotateKey(handle: CryptoHandle): Promise { return handle; }, + digest: (plain: string) => `sha256:${plain}`, + keyedDigest: async (plain: string) => `k:${plain.length}`, + }; + const binder = createDatasourceSecretBinder({ engine: data as any, cryptoProvider: crypto }); + + const ref = await binder.bind({ value: 's3cret' }, { name: 'pin_ds' }); + expect(await binder.resolve(ref)).toBe('s3cret'); + await binder.unbind(ref); + + expect(data.calls.map((c) => `${c.verb}:${c.object}`)).toEqual([ + 'insert:sys_secret', + 'find:sys_secret', + 'delete:sys_secret', + ]); + expectAllSystem(data.calls); + }); +}); diff --git a/packages/services/service-datasource/src/datasource-admin-plugin.ts b/packages/services/service-datasource/src/datasource-admin-plugin.ts index c118564f6ed..b02267b3817 100644 --- a/packages/services/service-datasource/src/datasource-admin-plugin.ts +++ b/packages/services/service-datasource/src/datasource-admin-plugin.ts @@ -82,6 +82,17 @@ type DataEngineLike = Partial< const DS_META_TYPE = 'datasource'; const SYS_METADATA = 'sys_metadata'; +/** + * [#21913] The execution context every `sys_metadata` read and write below runs + * under: the explicit system opt-in. These helpers are the platform persisting + * its own runtime-datasource records — at boot ({@link loadDatasourceRows}), on + * cluster convergence ({@link loadDatasourceRow}) and behind the datasource + * admin doors, which authorize the caller before any of them runs. None of them + * may rely on a missing principal to pass the security middleware's + * principal-less hand-off, which ADR-0096 D5 closes. + */ +const SYSTEM_CTX = { isSystem: true } as const; + function newMetaId(): string { return typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function' ? crypto.randomUUID() @@ -91,14 +102,16 @@ function newMetaId(): string { async function persistDatasourceRow(engine: DataEngineLike | undefined, record: { name: string }): Promise { if (!engine?.insert || !engine.findOne) return; // no durable store — in-memory only const now = new Date().toISOString(); - const existing = await engine.findOne(SYS_METADATA, { - where: { type: DS_META_TYPE, name: record.name, state: 'active' }, - }); + const existing = await engine.findOne( + SYS_METADATA, + { where: { type: DS_META_TYPE, name: record.name, state: 'active' } }, + { context: SYSTEM_CTX }, + ); if (existing) { await engine.update?.( SYS_METADATA, { metadata: JSON.stringify(record), updated_at: now, version: ((existing.version as number) || 0) + 1, state: 'active' }, - { where: { id: existing.id } }, + { where: { id: existing.id }, context: SYSTEM_CTX }, ); } else { await engine.insert(SYS_METADATA, { @@ -111,21 +124,25 @@ async function persistDatasourceRow(engine: DataEngineLike | undefined, record: version: 1, created_at: now, updated_at: now, - }); + }, { context: SYSTEM_CTX }); } } async function deleteDatasourceRow(engine: DataEngineLike | undefined, name: string): Promise { if (!engine?.findOne) return; - const existing = await engine.findOne(SYS_METADATA, { where: { type: DS_META_TYPE, name, state: 'active' } }); + const existing = await engine.findOne( + SYS_METADATA, + { where: { type: DS_META_TYPE, name, state: 'active' } }, + { context: SYSTEM_CTX }, + ); if (!existing) return; - if (engine.delete) await engine.delete(SYS_METADATA, { where: { id: existing.id } }); - else await engine.update?.(SYS_METADATA, { state: 'inactive' }, { where: { id: existing.id } }); + if (engine.delete) await engine.delete(SYS_METADATA, { where: { id: existing.id }, context: SYSTEM_CTX }); + else await engine.update?.(SYS_METADATA, { state: 'inactive' }, { where: { id: existing.id }, context: SYSTEM_CTX }); } async function loadDatasourceRows(engine: DataEngineLike | undefined): Promise>> { if (!engine?.find) return []; - const rows = await engine.find(SYS_METADATA, { where: { type: DS_META_TYPE, state: 'active' } }); + const rows = await engine.find(SYS_METADATA, { where: { type: DS_META_TYPE, state: 'active' } }, { context: SYSTEM_CTX }); const out: Array> = []; for (const r of rows ?? []) { const raw = (r as { metadata?: unknown }).metadata; @@ -163,7 +180,11 @@ async function loadDatasourceRow( name: string, ): Promise { if (!engine?.findOne) return undefined; - const row = await engine.findOne(SYS_METADATA, { where: { type: DS_META_TYPE, name, state: 'active' } }); + const row = await engine.findOne( + SYS_METADATA, + { where: { type: DS_META_TYPE, name, state: 'active' } }, + { context: SYSTEM_CTX }, + ); const raw = (row as { metadata?: unknown } | null | undefined)?.metadata; if (raw == null) return undefined; let parsed: Record; diff --git a/packages/services/service-datasource/src/datasource-secret-binder.ts b/packages/services/service-datasource/src/datasource-secret-binder.ts index 1e138c1d585..fc23b089bcb 100644 --- a/packages/services/service-datasource/src/datasource-secret-binder.ts +++ b/packages/services/service-datasource/src/datasource-secret-binder.ts @@ -31,10 +31,24 @@ interface SecretRow { ciphertext: string; } -/** Minimal data-engine surface used to read/write the `sys_secret` store. */ +/** + * [#21913] The execution context every `sys_secret` read and write below runs + * under: the explicit system opt-in. `sys_secret` is the platform's own cipher + * store, written and read here on behalf of a datasource record the admin + * doors already authorized (or of boot rehydration, which has no caller at + * all). None of these calls may rely on a missing principal to pass the + * security middleware's principal-less hand-off, which ADR-0096 D5 closes. + */ +const SYSTEM_CTX = { isSystem: true } as const; + +/** + * Minimal data-engine surface used to read/write the `sys_secret` store. Every + * call passes `context` (the explicit system opt-in); an adapter over a data + * engine forwards it verbatim. + */ export interface SecretStoreEngineLike { insert(object: string, data: Record, options?: unknown): Promise; - delete(object: string, options: { where: Record }): Promise; + delete(object: string, options: { where: Record; context?: Record }): Promise; /** * Read `sys_secret` rows for the `resolve()` path. Optional so existing * callers that only bind/unbind keep working; `resolve()` no-ops when absent. @@ -110,14 +124,14 @@ export function createDatasourceSecretBinder(deps: DatasourceSecretBinderDeps): alg: handle.alg, version: handle.version, ciphertext: handle.ciphertext, - }); + }, { context: SYSTEM_CTX }); return toCredentialsRef(handle.id); }, async unbind(credentialsRef) { const id = parseCredentialsRef(credentialsRef); if (!id) return; // not ours (or already cleared) — nothing to do - await engine.delete('sys_secret', { where: { id } }); + await engine.delete('sys_secret', { where: { id }, context: SYSTEM_CTX }); }, async resolve(credentialsRef) { @@ -130,6 +144,7 @@ export function createDatasourceSecretBinder(deps: DatasourceSecretBinderDeps): // Secrets are scoped through their owning datasource artefact, so // skip the tenant-audit warning (mirrors SettingsService's store). bypassTenantAudit: true, + context: SYSTEM_CTX, }); const rows = (Array.isArray(result) ? result : (result as { data?: unknown[] })?.data) ?? []; const row = rows[0] as SecretRow | undefined; diff --git a/packages/services/service-messaging/src/actor-reference.pin.test.ts b/packages/services/service-messaging/src/actor-reference.pin.test.ts new file mode 100644 index 00000000000..0bc3e2077a6 --- /dev/null +++ b/packages/services/service-messaging/src/actor-reference.pin.test.ts @@ -0,0 +1,117 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#21913] The fan-out writes carry the explicit system opt-in, and the engine + * skips its referential-integrity check for an `isSystem` write. So the + * producers keep the refusal an `actor_id` naming no user met before the + * opt-in (`assertActorReferenceResolves`). + * + * The pin is DIFFERENTIAL, over a real engine: the answer each producer gives + * for an unknown actor is held equal — name, `code`, `status`, message and + * findings — to the refusal the engine itself gives the context-less write the + * producer made before the opt-in. A known actor is written, and a write that + * names no actor is unchanged. + */ + +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { ObjectQL } from '@objectstack/objectql'; +import { SqlDriver } from '@objectstack/driver-sql'; +import { SysUser } from '@objectstack/platform-objects/identity'; +import { SysNotification } from '@objectstack/platform-objects/audit'; +import { InboxMessage } from './objects/inbox-message.object.js'; +import { NotificationReceipt } from './objects/notification-receipt.object.js'; +import { MessagingService } from './messaging-service.js'; +import { createInboxChannel } from './inbox-channel.js'; + +const SYS = { context: { isSystem: true } } as const; +const GHOST = 'usr_ghost_21913'; + +function silentLogger() { + return { info: () => {}, warn: () => {}, error: () => {}, debug: () => {} }; +} + +/** The refusal's observable envelope — everything but the stack. */ +function envelope(e: any) { + return { name: e?.name, code: e?.code, status: e?.status, message: e?.message, fields: e?.fields }; +} + +let engine: ObjectQL; +let userId: string; + +beforeEach(async () => { + engine = new ObjectQL(); + engine.registerDriver(new SqlDriver({ client: 'better-sqlite3', connection: { filename: ':memory:' }, useNullAsDefault: true }), true); + await engine.init(); + engine.registry.registerObject(SysUser as any, '@objectstack/platform-objects'); + engine.registry.registerObject(SysNotification as any, '@objectstack/platform-objects'); + engine.registry.registerObject(InboxMessage as any, '@objectstack/service-messaging'); + engine.registry.registerObject(NotificationReceipt as any, '@objectstack/service-messaging'); + await engine.syncSchemas(); + const user = await engine.insert('sys_user', { name: 'Ada', email: 'ada@example.test' }, SYS); + userId = String((user as any).id); +}); + +afterEach(async () => { + try { await engine?.destroy(); } catch { /* noop */ } +}); + +async function count(object: string): Promise { + return ((await engine.find(object, {}, SYS)) ?? []).length; +} + +describe('[#21913] writeEvent keeps the dangling-actor refusal', () => { + const service = () => new MessagingService({ logger: silentLogger(), getData: () => engine } as any); + const event = (actorId?: string) => ({ topic: 'pin.actor', audience: [], channels: ['inbox'], ...(actorId ? { actorId } : {}) }); + + it('an unknown actor_id is refused exactly as the engine refused the context-less write, and nothing is written', async () => { + // The answer before the opt-in: the same insert with no context. + const before = await engine.insert('sys_notification', { + topic: 'pin.actor', payload: null, severity: 'info', dedup_key: null, source_object: null, + source_id: null, actor_id: GHOST, organization_id: null, created_at: new Date().toISOString(), + }).then(() => null, (e) => e); + expect(before?.code).toBe('VALIDATION_FAILED'); + expect(await count('sys_notification')).toBe(0); + + const refusal = await service().emit(event(GHOST) as any).then(() => null, (e) => e); + expect(refusal, 'the emit must refuse').not.toBeNull(); + expect(refusal.code).toBe('VALIDATION_FAILED'); + expect(refusal.status).toBe(before.status); + expect(refusal.fields?.[0]).toMatchObject({ field: 'actor_id', code: 'reference_not_found', constraint: { target: 'sys_user' } }); + expect(envelope(refusal)).toEqual(envelope(before)); + expect(await count('sys_notification')).toBe(0); + }); + + it('a known actor is written, and an event naming no actor is unchanged', async () => { + await service().emit(event(userId) as any); + await service().emit(event() as any); + const rows = await engine.find('sys_notification', {}, SYS); + expect(rows.map((r: any) => r.actor_id ?? null).sort()).toEqual([null, userId].sort()); + }); +}); + +describe('[#21913] the inbox send keeps the dangling-actor refusal', () => { + const delivery = (actorId?: string) => ({ + channel: 'inbox', + recipient: userId, + notification: { topic: 'pin.actor', title: 'Pin', body: '', severity: 'info', recipients: [userId], ...(actorId ? { actorId } : {}) }, + }); + + it('an unknown actor_id answers the SendResult the engine refusal produced, and no inbox row is written', async () => { + const before = await engine.insert('sys_inbox_message', { + user_id: userId, notification_id: null, actor_id: GHOST, topic: 'pin.actor', title: 'Pin', body_md: '', + severity: 'info', organization_id: null, created_at: new Date().toISOString(), + }).then(() => null, (e) => e); + expect(before?.code).toBe('VALIDATION_FAILED'); + + const result = await createInboxChannel({ getData: () => engine }).send({ logger: silentLogger() } as any, delivery(GHOST) as any); + expect(result).toEqual({ ok: false, error: `inbox insert failed: ${before.message}` }); + expect(await count('sys_inbox_message')).toBe(0); + }); + + it('a known actor is written, and a delivery naming no actor is unchanged', async () => { + const channel = createInboxChannel({ getData: () => engine }); + expect((await channel.send({ logger: silentLogger() } as any, delivery(userId) as any)).ok).toBe(true); + expect((await channel.send({ logger: silentLogger() } as any, delivery() as any)).ok).toBe(true); + expect(await count('sys_inbox_message')).toBe(2); + }); +}); diff --git a/packages/services/service-messaging/src/actor-reference.ts b/packages/services/service-messaging/src/actor-reference.ts new file mode 100644 index 00000000000..0c439888fe1 --- /dev/null +++ b/packages/services/service-messaging/src/actor-reference.ts @@ -0,0 +1,76 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import type { IDataEngine } from '@objectstack/spec/contracts'; +import { renderValidationMessage } from '@objectstack/spec/system'; +import { validationFailure } from '@objectstack/types'; +import { FAN_OUT_SYSTEM_CONTEXT } from './fan-out-system-context.js'; +import { USER_OBJECT } from './recipient-locale.js'; + +/** The column every fan-out row records its actor in — a lookup to `sys_user`. */ +const ACTOR_FIELD = 'actor_id'; + +/** + * [#21913] The dangling-actor refusal, kept at the producer. + * + * The fan-out writes (`writeEvent`'s `sys_notification` row, the inbox + * channel's `sys_inbox_message` row) carry the explicit system opt-in, and the + * engine skips its referential-integrity check for an `isSystem` write + * (`ObjectQL.assertReferencesResolve`; row 23 of the `isSystem` census page). + * Before the opt-in these writes ran with no context, so an `actor_id` naming + * no `sys_user` row was REFUSED. A flow's notify node authors that value, and a + * declared reference the runtime cannot honour must stay a loud refusal, not a + * stored dangling id. So the check the engine no longer runs is run here, with + * the engine's own answer, before the write: + * + * - the same probe — `findOne(sys_user, { where: { id }, fields: ['id'] })` + * under the elevated context the engine's probe used for a context-less + * write; + * - the same verdict — refuse only when the probe RAN and found nothing; a + * probe that cannot run (no `sys_user` object, a throwing store) lets the + * write through, as the engine's fail-open does; + * - the same answer — `VALIDATION_FAILED` carrying one `reference_not_found` + * finding on `actor_id`, whose message is rendered from the same catalog + * entry and the field's declared label. It is built by `validationFailure` + * (`@objectstack/types`), the shared constructor for the shape every door + * serves as `400 VALIDATION_FAILED`, so this package stamps no code of its + * own. The differential pin holds it equal to the engine's refusal over a + * real engine. + * + * A write that names no actor (`null`, `undefined`, `''`) is unchanged: the + * engine never checked an empty reference either. + */ +export async function assertActorReferenceResolves( + data: IDataEngine, + object: string, + actorId: unknown, +): Promise { + if (actorId === null || actorId === undefined || actorId === '' || typeof actorId === 'object') return; + let found: unknown; + try { + found = await data.findOne( + USER_OBJECT, + { where: { id: actorId }, fields: ['id'] }, + { context: FAN_OUT_SYSTEM_CONTEXT }, + ); + } catch { + return; + } + if (found) return; + throw actorReferenceNotFound(data, object, String(actorId)); +} + +/** The engine's `ValidationError` for one unresolved `actor_id`, field for field. */ +function actorReferenceNotFound(data: IDataEngine, object: string, value: string): Error { + const def = (data as { getSchema?: (name: string) => { fields?: Record } | undefined }) + .getSchema?.(object)?.fields?.[ACTOR_FIELD]; + const declared = def?.label?.trim(); + const label = declared && declared.length > 0 ? declared : ACTOR_FIELD; + const constraint = { target: USER_OBJECT }; + const message = renderValidationMessage({ + messageKey: 'reference_not_found', + label, + field: ACTOR_FIELD, + params: { ...constraint, value }, + }); + return validationFailure(message, [{ field: ACTOR_FIELD, code: 'reference_not_found', message, label, constraint, value }]); +} diff --git a/packages/services/service-messaging/src/fan-out-system-context.ts b/packages/services/service-messaging/src/fan-out-system-context.ts new file mode 100644 index 00000000000..be688ec7f35 --- /dev/null +++ b/packages/services/service-messaging/src/fan-out-system-context.ts @@ -0,0 +1,28 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#21913] The execution context the emit FAN-OUT's own reads and writes run + * under: the explicit system opt-in. + * + * Carried by `MessagingService.writeEvent` (the `sys_notification` row), the + * inbox channel's `send` (the `sys_inbox_message` row, the recipient's locale + * read, and `writeDeliveredReceipt`'s `sys_notification_receipt` row), + * `PreferenceResolver.loadRows` (`sys_notification_preference`) and + * `RecipientResolver.resolveEmail` (`sys_user`). + * + * `emit()` takes no caller context. The door in front of an emitting caller + * has already decided whether it may notify, and every row these calls read or + * write belongs to a RECIPIENT, not to the emitter — so no caller's grants + * could decide them. Without this they reach the data engine with no principal + * and no system opt-in, which is the principal-less hand-off ADR-0096 D5 + * closes. + * + * ⚠️ The reads are made on another principal's behalf. What they return — a + * user id for an address, a locale, a preference row — is consumed inside the + * fan-out. `emit()` answers the notification id, counts and per-delivery + * outcomes; the one read-derived value in those is a delivery's recipient id, + * resolved from an address the emitter itself named, and no in-repo caller of + * `emit()` relays the outcomes to a door. Keep it that way. A read under this + * context whose result reaches the caller would widen what that caller can see. + */ +export const FAN_OUT_SYSTEM_CONTEXT = { isSystem: true } as const; diff --git a/packages/services/service-messaging/src/inbox-channel.test.ts b/packages/services/service-messaging/src/inbox-channel.test.ts index 55b56fe6b7d..a1864e4f618 100644 --- a/packages/services/service-messaging/src/inbox-channel.test.ts +++ b/packages/services/service-messaging/src/inbox-channel.test.ts @@ -409,8 +409,13 @@ describe('inbox channel', () => { * because four green legs do not prove a connected path. */ describe('inbox channel — actor materialization (#16974)', () => { + /** Every `sys_user` id names a user — the actor-reference check's read. */ + const knownUsers = (object: string, query: any) => (object === 'sys_user' ? { id: query?.where?.id } : null); + it('writes the notification actor onto the row', async () => { - const data = fakeData(); + // [#21913] The actor must name a user — the channel refuses one that + // does not, as the engine did — so the double answers its sys_user row. + const data = fakeData(undefined, knownUsers); const ch = createInboxChannel({ getData: () => data.engine, now: () => '2026-06-01T00:00:00.000Z' }); await ch.send(silentCtx(), delivery({ actorId: 'user_9' }, 'user_42')); @@ -431,7 +436,7 @@ describe('inbox channel — actor materialization (#16974)', () => { }); it('carries the actor through emit → outbox snapshot → dispatcher → row (P1)', async () => { - const data = fakeData(); + const data = fakeData(undefined, knownUsers); const outbox = new MemoryNotificationOutbox(1); const inbox = createInboxChannel({ getData: () => data.engine, now: () => '2026-06-01T00:00:00.000Z' }); diff --git a/packages/services/service-messaging/src/inbox-channel.ts b/packages/services/service-messaging/src/inbox-channel.ts index 651620bb862..a463ba83828 100644 --- a/packages/services/service-messaging/src/inbox-channel.ts +++ b/packages/services/service-messaging/src/inbox-channel.ts @@ -10,6 +10,8 @@ import type { } from './channel.js'; import type { EmailSenderSurface } from './email-channel.js'; import { RECIPIENT_LOCALE_FIELD, USER_OBJECT, resolveRecipientLocale } from './recipient-locale.js'; +import { FAN_OUT_SYSTEM_CONTEXT } from './fan-out-system-context.js'; +import { assertActorReferenceResolves } from './actor-reference.js'; /** The object the inbox channel writes rows to. */ export const INBOX_OBJECT = 'sys_inbox_message'; @@ -85,7 +87,11 @@ export function createInboxChannel(opts: InboxChannelOptions): MessagingChannel userId: string, ): Promise { try { - const user = await data.findOne(userObject, { where: { id: userId }, fields: [RECIPIENT_LOCALE_FIELD] }); + const user = await data.findOne( + userObject, + { where: { id: userId }, fields: [RECIPIENT_LOCALE_FIELD] }, + { context: FAN_OUT_SYSTEM_CONTEXT }, + ); return user?.[RECIPIENT_LOCALE_FIELD]; } catch (err) { ctx.logger.warn( @@ -118,7 +124,7 @@ export function createInboxChannel(opts: InboxChannelOptions): MessagingChannel at: r.at, organization_id: r.organizationId ?? null, created_at: r.at, - }); + }, { context: FAN_OUT_SYSTEM_CONTEXT }); } catch (err) { ctx.logger.warn( `[inbox] delivered receipt write failed for '${r.userId}' (${(err as Error).message}); inbox row stands`, @@ -210,7 +216,10 @@ export function createInboxChannel(opts: InboxChannelOptions): MessagingChannel let inboxId: string | undefined; try { - const created = await data.insert(objectName, row); + // Inside this `try` so an unknown actor answers the SendResult + // the engine's own refusal did — see assertActorReferenceResolves. + await assertActorReferenceResolves(data, objectName, row.actor_id); + const created = await data.insert(objectName, row, { context: FAN_OUT_SYSTEM_CONTEXT }); const id = Array.isArray(created) ? created[0]?.id : created?.id ?? created; inboxId = id != null ? String(id) : undefined; } catch (err) { diff --git a/packages/services/service-messaging/src/messaging-service.test.ts b/packages/services/service-messaging/src/messaging-service.test.ts index d957d31dc4b..3d29e4462aa 100644 --- a/packages/services/service-messaging/src/messaging-service.test.ts +++ b/packages/services/service-messaging/src/messaging-service.test.ts @@ -399,7 +399,9 @@ describe('MessagingService', () => { describe('emit() L2 event persistence', () => { it('writes one sys_notification event row carrying topic/payload/severity/source/actor', async () => { - const data = fakeData(); + // [#21913] The actor must name a user — the producer refuses one that + // does not, as the engine did — so the double answers its sys_user row. + const data = fakeData((obj, q) => (obj === 'sys_user' ? { id: q?.where?.id } : null)); service = new MessagingService({ logger: silentLogger(), getData: data.getData, now: () => '2026-06-01T00:00:00.000Z' }); service.registerChannel(recordingChannel('inbox').channel); diff --git a/packages/services/service-messaging/src/messaging-service.ts b/packages/services/service-messaging/src/messaging-service.ts index e786cf421df..d978e84d929 100644 --- a/packages/services/service-messaging/src/messaging-service.ts +++ b/packages/services/service-messaging/src/messaging-service.ts @@ -22,6 +22,8 @@ import type { } from './http-outbox.js'; import { INBOX_OBJECT, RECEIPT_OBJECT } from './inbox-channel.js'; import { type InboxCaller, resolveInboxRecipient } from './inbox-caller.js'; +import { FAN_OUT_SYSTEM_CONTEXT } from './fan-out-system-context.js'; +import { assertActorReferenceResolves } from './actor-reference.js'; /** The L2 event object every `emit()` writes one row to (ADR-0030). */ export const NOTIFICATION_EVENT_OBJECT = 'sys_notification'; @@ -1347,7 +1349,11 @@ export class MessagingService { if (suppressed.length > 0) { row.suppressed_channels = suppressed.map((s) => ({ ...s })); } - const created = await data.insert(NOTIFICATION_EVENT_OBJECT, row); + // The explicit system opt-in — see FAN_OUT_SYSTEM_CONTEXT. Under it the + // engine no longer checks that `actor_id` names a user, so the producer + // keeps that refusal (see assertActorReferenceResolves). + await assertActorReferenceResolves(data, NOTIFICATION_EVENT_OBJECT, row.actor_id); + const created = await data.insert(NOTIFICATION_EVENT_OBJECT, row, { context: FAN_OUT_SYSTEM_CONTEXT }); const id = Array.isArray(created) ? created[0]?.id : created?.id ?? created; return id != null ? String(id) : `evt_${Math.random().toString(36).slice(2)}`; } diff --git a/packages/services/service-messaging/src/outbox-dispatcher-scope.ts b/packages/services/service-messaging/src/outbox-dispatcher-scope.ts index 472a222076b..4f9acdc9c4d 100644 --- a/packages/services/service-messaging/src/outbox-dispatcher-scope.ts +++ b/packages/services/service-messaging/src/outbox-dispatcher-scope.ts @@ -74,6 +74,26 @@ export function dispatcherSweepOptions( } +/** + * [#21913] The execution context the dispatcher's CLAIM path runs under: the + * explicit system opt-in, carried by every read and write + * `SqlNotificationOutbox.claim` / `claimDigest` / `reapExpired` and + * `SqlHttpOutbox.claim` / `reapExpired` issue. + * + * The warrant is {@link dispatcherSweepOptions}'s, read for authorization + * instead of tenancy: no request, session or principal exists on the + * `setInterval` tick that reaches these sites, so no caller's grants could + * decide them — the tick is the platform acting for itself. Without it they + * reach the data engine with no principal and no system opt-in, which is the + * principal-less hand-off ADR-0096 D5 closes; a deny there would stall every + * queue. + * + * ⛔ Never on `redeliver`: it is request-reachable and threads the caller's + * tenant, the line this file draws for `bypassTenantAudit` too. + */ +export const DISPATCHER_SYSTEM_CONTEXT = { isSystem: true } as const; + + /** * The write options for a dispatcher **`ack`** — the single-record * (`multi: false`) write that records one delivery attempt's outcome on diff --git a/packages/services/service-messaging/src/preference-resolver.ts b/packages/services/service-messaging/src/preference-resolver.ts index fde369a6ea6..fd64c86688e 100644 --- a/packages/services/service-messaging/src/preference-resolver.ts +++ b/packages/services/service-messaging/src/preference-resolver.ts @@ -1,6 +1,7 @@ // Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. import type { IDataEngine } from '@objectstack/spec/contracts'; +import { FAN_OUT_SYSTEM_CONTEXT } from './fan-out-system-context.js'; /** The object the preference matrix lives in. */ export const PREFERENCE_OBJECT = 'sys_notification_preference'; @@ -178,9 +179,11 @@ export class PreferenceResolver { // driver-specific IN support; user filtering is done in memory. const base: Record = {}; if (ctx.organizationId) base.organization_id = ctx.organizationId; + // The explicit system opt-in — see FAN_OUT_SYSTEM_CONTEXT: the rows are + // the recipients' preferences, read on their behalf, not the emitter's. const [specific, wildcard] = await Promise.all([ - data.find(this.objectName, { where: { ...base, topic: ctx.topic }, limit: 10000 }), - data.find(this.objectName, { where: { ...base, topic: WILDCARD }, limit: 10000 }), + data.find(this.objectName, { where: { ...base, topic: ctx.topic }, limit: 10000 }, { context: FAN_OUT_SYSTEM_CONTEXT }), + data.find(this.objectName, { where: { ...base, topic: WILDCARD }, limit: 10000 }, { context: FAN_OUT_SYSTEM_CONTEXT }), ]); return [...(specific ?? []), ...(wildcard ?? [])]; } diff --git a/packages/services/service-messaging/src/recipient-resolver.ts b/packages/services/service-messaging/src/recipient-resolver.ts index 1a68af66890..d69aa303b3b 100644 --- a/packages/services/service-messaging/src/recipient-resolver.ts +++ b/packages/services/service-messaging/src/recipient-resolver.ts @@ -2,6 +2,7 @@ import type { IDataEngine } from '@objectstack/spec/contracts'; import type { Audience, AudienceSpec } from './messaging-service.js'; +import { FAN_OUT_SYSTEM_CONTEXT } from './fan-out-system-context.js'; /** * Cheap "looks like an email" heuristic so we attempt id resolution. Hand-rolled @@ -200,7 +201,13 @@ export class RecipientResolver { private async resolveEmail(email: string, data: IDataEngine | undefined): Promise { if (!data) return email; try { - const user = await data.findOne(this.userObject, { where: { email }, fields: ['id'] }); + // The explicit system opt-in — see FAN_OUT_SYSTEM_CONTEXT: a + // directory read on the recipient's behalf whose only use is the id. + const user = await data.findOne( + this.userObject, + { where: { email }, fields: ['id'] }, + { context: FAN_OUT_SYSTEM_CONTEXT }, + ); const id = user?.id; if (id != null && String(id).length > 0) return String(id); this.opts.logger.warn(`[recipients] no '${this.userObject}' matched email '${email}'; keeping verbatim`); diff --git a/packages/services/service-messaging/src/sql-http-outbox.ts b/packages/services/service-messaging/src/sql-http-outbox.ts index 412a80f3c38..27b16c1a119 100644 --- a/packages/services/service-messaging/src/sql-http-outbox.ts +++ b/packages/services/service-messaging/src/sql-http-outbox.ts @@ -4,7 +4,12 @@ import { randomUUID } from 'node:crypto'; import type { IDataEngine } from '@objectstack/spec/contracts'; import { hashPartition } from './backoff.js'; import { toEpochMs } from './audit-timestamp.js'; -import { dispatcherAckCasOptions, dispatcherAckOptions, dispatcherSweepOptions } from './outbox-dispatcher-scope.js'; +import { + DISPATCHER_SYSTEM_CONTEXT, + dispatcherAckCasOptions, + dispatcherAckOptions, + dispatcherSweepOptions, +} from './outbox-dispatcher-scope.js'; import { deliveryBody, signBody } from './http-sender.js'; import { HttpAckError, @@ -230,7 +235,7 @@ export class SqlHttpOutbox implements IHttpOutbox { }, fields: ['id'], limit: opts.limit, - }); + }, { context: DISPATCHER_SYSTEM_CONTEXT }); if (candidates.length === 0) return []; const ids = (candidates as Array<{ id: string }>).map((c) => c.id); @@ -241,13 +246,13 @@ export class SqlHttpOutbox implements IHttpOutbox { { status: 'in_flight', claimed_by: opts.nodeId, claimed_at: now }, // Environment-wide by design: the dispatcher drains every // organization's queue. Warrant in `outbox-dispatcher-scope.ts`. - dispatcherSweepOptions({ id: { $in: ids }, status: 'pending' }), + { ...dispatcherSweepOptions({ id: { $in: ids }, status: 'pending' }), context: DISPATCHER_SYSTEM_CONTEXT }, ); // 4. Read back the rows we actually own. const claimed = (await this.engine.find(this.objectName, { where: { id: { $in: ids }, claimed_by: opts.nodeId, claimed_at: now, status: 'in_flight' }, - })) as DeliveryRow[]; + }, { context: DISPATCHER_SYSTEM_CONTEXT })) as DeliveryRow[]; // 5. [#8118] Recover the redacted header column for the rows this // claim now owns — the one read that must see the authored map. @@ -271,10 +276,13 @@ export class SqlHttpOutbox implements IHttpOutbox { { status: 'pending', claimed_by: null, claimed_at: null }, // Environment-wide by design: recovers rows a crashed node abandoned, // for every organization. Warrant in `outbox-dispatcher-scope.ts`. - dispatcherSweepOptions({ - status: 'in_flight', - claimed_at: { $lt: now - claimTtlMs }, - }), + { + ...dispatcherSweepOptions({ + status: 'in_flight', + claimed_at: { $lt: now - claimTtlMs }, + }), + context: DISPATCHER_SYSTEM_CONTEXT, + }, ); } diff --git a/packages/services/service-messaging/src/sql-outbox.ts b/packages/services/service-messaging/src/sql-outbox.ts index 1b351dfcb60..464d2e00346 100644 --- a/packages/services/service-messaging/src/sql-outbox.ts +++ b/packages/services/service-messaging/src/sql-outbox.ts @@ -14,7 +14,7 @@ import type { } from './outbox.js'; import { hashPartition } from './backoff.js'; import { toEpochMs } from './audit-timestamp.js'; -import { dispatcherAckCasOptions, dispatcherSweepOptions } from './outbox-dispatcher-scope.js'; +import { DISPATCHER_SYSTEM_CONTEXT, dispatcherAckCasOptions, dispatcherSweepOptions } from './outbox-dispatcher-scope.js'; import { NotificationAckError, notificationAckLostClaimMessage, @@ -153,7 +153,7 @@ export class SqlNotificationOutbox implements INotificationOutbox { }, fields: ['id'], limit: opts.limit, - }); + }, { context: DISPATCHER_SYSTEM_CONTEXT }); if (!candidates.length) return []; const ids = (candidates as Array<{ id: string }>).map((c) => c.id); @@ -163,7 +163,7 @@ export class SqlNotificationOutbox implements INotificationOutbox { { status: 'in_flight', claimed_by: opts.nodeId, claimed_at: now }, // Environment-wide by design: the dispatcher drains every // organization's queue. Warrant in `outbox-dispatcher-scope.ts`. - dispatcherSweepOptions({ id: { $in: ids }, status: 'pending' }), + { ...dispatcherSweepOptions({ id: { $in: ids }, status: 'pending' }), context: DISPATCHER_SYSTEM_CONTEXT }, ); // 4. Read back only the rows we own. [commit d9cf78eaa] The read-back WHERE just @@ -172,7 +172,7 @@ export class SqlNotificationOutbox implements INotificationOutbox { // cast, and states nothing the query did not already establish. const claimed = (await this.engine.find(this.objectName, { where: { id: { $in: ids }, claimed_by: opts.nodeId, claimed_at: now, status: 'in_flight' }, - })) as DeliveryRow[]; + }, { context: DISPATCHER_SYSTEM_CONTEXT })) as DeliveryRow[]; return claimed.map((r) => ({ ...this.toRecord(r), claimedBy: opts.nodeId, claimedAt: now })); } @@ -194,7 +194,7 @@ export class SqlNotificationOutbox implements INotificationOutbox { }, fields: ['id'], limit: 10000, - }); + }, { context: DISPATCHER_SYSTEM_CONTEXT }); if (!candidates.length) return []; const ids = (candidates as Array<{ id: string }>).map((c) => c.id); @@ -204,13 +204,13 @@ export class SqlNotificationOutbox implements INotificationOutbox { { status: 'in_flight', claimed_by: opts.nodeId, claimed_at: now }, // Environment-wide by design: the dispatcher drains every // organization's queue. Warrant in `outbox-dispatcher-scope.ts`. - dispatcherSweepOptions({ id: { $in: ids }, status: 'pending' }), + { ...dispatcherSweepOptions({ id: { $in: ids }, status: 'pending' }), context: DISPATCHER_SYSTEM_CONTEXT }, ); // 4. Read back the rows we own — same credential stamp as claim(). const claimed = (await this.engine.find(this.objectName, { where: { id: { $in: ids }, claimed_by: opts.nodeId, claimed_at: now, status: 'in_flight' }, - })) as DeliveryRow[]; + }, { context: DISPATCHER_SYSTEM_CONTEXT })) as DeliveryRow[]; return claimed.map((r) => ({ ...this.toRecord(r), claimedBy: opts.nodeId, claimedAt: now })); } @@ -342,7 +342,10 @@ export class SqlNotificationOutbox implements INotificationOutbox { { status: 'pending', claimed_by: null, claimed_at: null }, // Environment-wide by design: recovers rows a crashed node abandoned, // for every organization. Warrant in `outbox-dispatcher-scope.ts`. - dispatcherSweepOptions({ status: 'in_flight', claimed_at: { $lt: now - claimTtlMs } }), + { + ...dispatcherSweepOptions({ status: 'in_flight', claimed_at: { $lt: now - claimTtlMs } }), + context: DISPATCHER_SYSTEM_CONTEXT, + }, ); } diff --git a/packages/services/service-messaging/src/system-context.pin.test.ts b/packages/services/service-messaging/src/system-context.pin.test.ts new file mode 100644 index 00000000000..3442a014053 --- /dev/null +++ b/packages/services/service-messaging/src/system-context.pin.test.ts @@ -0,0 +1,164 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#21913] The messaging plumbing that used to reach the data engine with no + * principal and no system opt-in carries the explicit opt-in (`isSystem: + * true`) on every engine call: + * + * - the dispatcher CLAIM path — `SqlNotificationOutbox.claim` / `claimDigest` + * / the visibility-timeout reap, and `SqlHttpOutbox.claim` / its reap: the + * candidate read, the claiming UPDATE and the read-back, each; + * - the emit FAN-OUT — `writeEvent`'s `sys_notification` row, + * `RecipientResolver.resolveEmail`'s `sys_user` read, + * `PreferenceResolver.loadRows`' two `sys_notification_preference` reads, and + * the inbox channel's `send`: its recipient-locale read, the + * `sys_inbox_message` row and the delivered receipt. + * + * Those calls passed the security middleware only through its principal-less + * hand-off (ADR-0096 E1), which D5 closes; on a dispatcher tick there is no + * caller at all, and in the fan-out every row belongs to a recipient rather + * than the emitter. The double answers the claim path's candidate read with a + * row, so the UPDATE and the read-back really run and are counted below. + */ + +import { describe, it, expect } from 'vitest'; +import { SqlNotificationOutbox } from './sql-outbox.js'; +import { SqlHttpOutbox } from './sql-http-outbox.js'; +import { MessagingService } from './messaging-service.js'; +import { createInboxChannel } from './inbox-channel.js'; +import { assertEngineFindOnePredicate, assertEngineUpdateDispatch } from '@objectstack/metadata-core'; + +type Call = { verb: string; object: string; context: unknown }; + +function silentLogger() { + return { info: () => {}, warn: () => {}, error: () => {}, debug: () => {} }; +} + +/** + * An `IDataEngine`-shaped double that records the context each call carried — + * the trailing options argument, or the query bag for a read that put it there + * (the contract accepts both; options wins). + */ +function recordingEngine(answer: (verb: string, object: string, query: any) => unknown) { + const calls: Call[] = []; + const ctxOf = (query: any, options: any) => options?.context ?? query?.context; + const engine = { + async find(object: string, query: any, options?: any) { + calls.push({ verb: 'find', object, context: ctxOf(query, options) }); + return (answer('find', object, query) as unknown[]) ?? []; + }, + async findOne(object: string, query: any, options?: any) { + assertEngineFindOnePredicate(object, query); + calls.push({ verb: 'findOne', object, context: ctxOf(query, options) }); + return answer('findOne', object, query) ?? null; + }, + async insert(object: string, data: Record, options?: any) { + calls.push({ verb: 'insert', object, context: options?.context }); + return { id: `${object}_1`, ...data }; + }, + async update(object: string, data: Record, options?: any) { + assertEngineUpdateDispatch(data, options); + calls.push({ verb: 'update', object, context: options?.context }); + return 1; + }, + async count() { return 0; }, + async aggregate() { return []; }, + }; + return { engine: engine as any, calls }; +} + +function expectAllSystem(calls: Call[]): void { + for (const call of calls) { + expect(call.context, `${call.verb} on ${call.object}`).toEqual({ isSystem: true }); + } +} + +const NOW = 1_800_000_000_000; + +describe('[#21913] the dispatcher claim path carries the explicit system opt-in', () => { + it('SqlNotificationOutbox: claim, claimDigest and reap — candidate read, claiming UPDATE and read-back', async () => { + const { engine, calls } = recordingEngine((verb, _object, query) => { + if (verb !== 'find') return null; + // The candidate read projects `id` alone; the read-back asks for whole rows. + if (Array.isArray(query?.fields) && query.fields.length === 1) return [{ id: 'd1' }]; + return [{ + id: 'd1', notification_id: 'n1', recipient_id: 'u1', channel: 'inbox', payload: '{}', + partition_key: 0, status: 'in_flight', attempts: 0, claimed_by: 'node-a', claimed_at: NOW, + created_at: NOW, updated_at: NOW, + }]; + }); + const outbox = new SqlNotificationOutbox(engine, { partitionCount: 1 }); + + expect(await outbox.claim({ nodeId: 'node-a', limit: 5, claimTtlMs: 60_000, now: NOW })).toHaveLength(1); + expect(await outbox.claimDigest({ nodeId: 'node-a', limit: 5, claimTtlMs: 60_000, now: NOW })).toHaveLength(1); + await outbox.reap({ claimTtlMs: 60_000, now: NOW }); + + // The population first: each claim ran reap + candidate read + UPDATE + + // read-back, and the standalone reap ran once more. + expect(calls.filter((c) => c.verb === 'find')).toHaveLength(4); + expect(calls.filter((c) => c.verb === 'update')).toHaveLength(5); + expect(calls.every((c) => c.object === 'sys_notification_delivery')).toBe(true); + expectAllSystem(calls); + }); + + it('SqlHttpOutbox: claim and reap — candidate read, claiming UPDATE and read-back', async () => { + const { engine, calls } = recordingEngine((verb, _object, query) => { + if (verb !== 'find') return null; + if (Array.isArray(query?.fields) && query.fields.length === 1) return [{ id: 'h1' }]; + return [{ + id: 'h1', source: 'webhook', ref_id: 'wh1', dedup_key: 'k1', url: 'https://example.test/hook', + payload_json: '{}', status: 'in_flight', attempts: 0, partition_key: 0, + claimed_by: 'node-a', claimed_at: NOW, created_at: NOW, updated_at: NOW, + }]; + }); + const outbox = new SqlHttpOutbox(engine, { partitionCount: 1 }); + + expect(await outbox.claim({ nodeId: 'node-a', limit: 5, claimTtlMs: 60_000, now: NOW })).toHaveLength(1); + await outbox.reap({ claimTtlMs: 60_000, now: NOW }); + + expect(calls.filter((c) => c.verb === 'find')).toHaveLength(2); + expect(calls.filter((c) => c.verb === 'update')).toHaveLength(3); + expect(calls.every((c) => c.object === 'sys_http_delivery')).toBe(true); + expectAllSystem(calls); + }); +}); + +describe('[#21913] the emit fan-out carries the explicit system opt-in', () => { + it('writeEvent, resolveEmail, the preference reads and the inbox send (locale read, row, receipt)', async () => { + const { engine, calls } = recordingEngine((verb, object, query) => { + if (verb === 'findOne' && object === 'sys_user' && query?.where?.email) return { id: 'usr_ada' }; + if (verb === 'findOne' && object === 'sys_user') return { locale: 'ja-JP' }; + return verb === 'find' ? [] : null; + }); + const service = new MessagingService({ logger: silentLogger(), getData: () => engine } as any); + service.registerChannel(createInboxChannel({ + getData: () => engine, + // The template path is the one that reads the recipient's locale. + getEmail: () => ({ + renderTemplate: async () => ({ subject: 'subject', text: 'body' }), + }) as any, + getDefaultTemplateLocale: () => 'en-US', + })); + + const result = await service.emit({ + topic: 'deal.won', + audience: ['ada@example.com'], + channels: ['inbox'], + payload: { template: 'deal_won' }, + } as any); + expect(result.delivered).toBe(1); + + // The population first: every producer this pin names actually ran. + const seen = calls.map((c) => `${c.verb}:${c.object}`); + expect(seen).toEqual(expect.arrayContaining([ + 'insert:sys_notification', + 'findOne:sys_user', + 'find:sys_notification_preference', + 'insert:sys_inbox_message', + 'insert:sys_notification_receipt', + ])); + expect(calls.filter((c) => c.object === 'sys_user')).toHaveLength(2); // address + locale + expect(calls.filter((c) => c.object === 'sys_notification_preference')).toHaveLength(2); + expectAllSystem(calls); + }); +}); diff --git a/packages/services/service-settings/src/actor-reference.ts b/packages/services/service-settings/src/actor-reference.ts new file mode 100644 index 00000000000..4d6818a62f2 --- /dev/null +++ b/packages/services/service-settings/src/actor-reference.ts @@ -0,0 +1,87 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import { renderValidationMessage } from '@objectstack/spec/system'; +import { validationFailure } from '@objectstack/types'; + +/** The object every user reference below targets. */ +export const USER_OBJECT = 'sys_user'; + +/** + * Reads one `sys_user` row by id under the explicit system opt-in. Resolves + * with the row, or with nothing when there is none. Throws when the read could + * not run. + */ +export type UserProbe = (id: string) => Promise; + +/** The field a reference is written into, and its declared label. */ +export interface UserReferenceField { + object: string; + field: string; + /** The field's label as registered, or as declared when no schema is reachable. */ + label: string; +} + +/** + * [#21913] The dangling-user refusal, kept at the producer. + * + * `SettingsService.upsertRow`'s insert (`sys_setting.user_id`) and the + * setting-audit write (`sys_setting_audit.actor_id`) carry the explicit system + * opt-in, and the engine skips its referential-integrity check for an + * `isSystem` write (`ObjectQL.assertReferencesResolve`; row 23 of the + * `isSystem` census page). Before the opt-in both ran with no context, so a + * user reference naming no `sys_user` row was REFUSED. A settings row or an + * audit entry pointing at a user nobody can resolve is the wrong direction for + * this lane, so the check the engine no longer runs is run here, with the + * engine's own answer, before the write: + * + * - the same probe: one `sys_user` read by id under the elevated context the + * engine's probe used for a context-less write; + * - the same verdict: refuse only when the probe RAN and found nothing. A + * probe that cannot run lets the write through, as the engine's fail-open + * does; + * - the same answer: `VALIDATION_FAILED` carrying one `reference_not_found` + * finding on the field, rendered from the same catalog entry and the + * field's label. It is built by `validationFailure` (`@objectstack/types`), + * the shared constructor for the shape every door serves as + * `400 VALIDATION_FAILED`, so this package stamps no code of its own. The + * differential pin holds it equal to the engine's refusal over a real + * engine. + * + * A write that names no user (`null`, `undefined`, `''`) is unchanged: the + * engine never checked an empty reference either. + */ +export async function assertUserReferenceResolves( + probe: UserProbe, + ref: UserReferenceField, + value: unknown, +): Promise { + if (value === null || value === undefined || value === '' || typeof value === 'object') return; + let found: unknown; + try { + found = await probe(String(value)); + } catch { + return; + } + if (found) return; + const constraint = { target: USER_OBJECT }; + const message = renderValidationMessage({ + messageKey: 'reference_not_found', + label: ref.label, + field: ref.field, + params: { ...constraint, value: String(value) }, + }); + throw validationFailure(message, [ + { field: ref.field, code: 'reference_not_found', message, label: ref.label, constraint, value: String(value) }, + ]); +} + +/** + * The label the engine would name `field` by: the registered schema's, when the + * engine exposes one, else the label the object declares. + */ +export function registeredLabel(engine: unknown, ref: Omit, declared: string): string { + const def = (engine as { getSchema?: (name: string) => { fields?: Record } | undefined }) + ?.getSchema?.(ref.object)?.fields?.[ref.field]; + const label = def?.label?.trim(); + return label && label.length > 0 ? label : declared; +} diff --git a/packages/services/service-settings/src/settings-service-plugin.ts b/packages/services/service-settings/src/settings-service-plugin.ts index a2fac9ad1f0..d468299688f 100644 --- a/packages/services/service-settings/src/settings-service-plugin.ts +++ b/packages/services/service-settings/src/settings-service-plugin.ts @@ -22,6 +22,7 @@ import type { SettingsAuditWriter, SettingsEngine, SettingsSecretStore } from '. import type { CryptoAdapter } from './crypto-adapter.js'; import { LocalCryptoProvider } from './local-crypto-provider.js'; import { buildConfigChangeAuditSink } from './config-change-audit.js'; +import { USER_OBJECT, assertUserReferenceResolves, registeredLabel } from './actor-reference.js'; import { registerSettingsRoutes } from './settings-routes.js'; import { settingsObjects, @@ -476,6 +477,18 @@ export function buildSettingAuditWriter( return { write: async (entry) => { try { + // Under the opt-in below the engine no longer checks that `actor_id` + // names a user, so the writer keeps that refusal; this `catch` reports + // it exactly as it reported the engine's (see assertUserReferenceResolves). + await assertUserReferenceResolves( + (id) => eng.findOne(USER_OBJECT, { where: { id }, fields: ['id'] }, { context: { isSystem: true } }), + { + object: 'sys_setting_audit', + field: 'actor_id', + label: registeredLabel(eng, { object: 'sys_setting_audit', field: 'actor_id' }, 'Actor'), + }, + entry.actorId ?? null, + ); await eng.insert('sys_setting_audit', { namespace: entry.namespace, key: entry.key, @@ -489,7 +502,12 @@ export function buildSettingAuditWriter( request_id: entry.requestId ?? null, reason: entry.reason ?? null, created_at: new Date().toISOString(), - }, { bypassTenantAudit: true }); + // The explicit system opt-in, as the settings row write carries: + // `sys_setting_audit` is the platform's own ledger, written after + // the settings door already authorized the change, and not a + // write that may rely on a missing principal to pass the security + // middleware's principal-less hand-off (ADR-0096 D5). + }, { bypassTenantAudit: true, context: { isSystem: true } }); } catch (err: any) { logger?.warn?.('SettingsServicePlugin: setting-audit write failed: ' + (err?.message ?? err)); } diff --git a/packages/services/service-settings/src/settings-service.ts b/packages/services/service-settings/src/settings-service.ts index a148f503e35..745d1930ec9 100644 --- a/packages/services/service-settings/src/settings-service.ts +++ b/packages/services/service-settings/src/settings-service.ts @@ -39,6 +39,7 @@ import { // hand-written sentence, unchanged. import { renderValidationMessage } from '@objectstack/spec/system'; import { SETTINGS_SECRET_MASK } from './settings-secret-redaction.js'; +import { USER_OBJECT, assertUserReferenceResolves, registeredLabel } from './actor-reference.js'; import { firstRejectedDomainMember, knownValueDomain, @@ -54,7 +55,8 @@ import { const DEFAULT_OBJECT = 'sys_setting'; /** - * The execution context `SettingsService`'s own row writes run under (#8030). + * The execution context `SettingsService`'s own `sys_setting` reads and writes + * run under (#8030, #21913). * * `sys_setting` is a platform-owned table with platform-owned columns * (`value_enc`, `updated_by` are declared `readonly: true`), and this service @@ -62,10 +64,18 @@ const DEFAULT_OBJECT = 'sys_setting'; * gates. See {@link SettingsService.upsertRow} for the full argument and for * why the field stays `readonly` for everybody else. * + * The reads ({@link SettingsService.loadRows}, `upsertRow`'s existence probe) + * and `upsertRow`'s insert carry it too. They are plumbing: the door in front + * of them, when there is one, has already authorized the caller, and + * `loadRows` runs on every request's execution-context build. Without it they + * reach the data engine with no principal and no system opt-in — the + * principal-less hand-off ADR-0096 D5 closes — so the explicit opt-in is what + * keeps them working once that hand-off denies. + * * Frozen so a downstream engine adapter cannot mutate the service's posture by * writing into the bag it was handed. */ -const SETTINGS_SYSTEM_WRITE_CONTEXT = Object.freeze({ isSystem: true as const }); +const SETTINGS_SYSTEM_CONTEXT = Object.freeze({ isSystem: true as const }); /** * Value-bearing specifier types — drives which entries we expect to @@ -2321,9 +2331,11 @@ export class SettingsService { // uniformly across global/tenant/user without log noise. Per-tenant // isolation for `tenant`-scope rows is still enforced by the engine // once an ExecutionContext.tenantId is plumbed through (Phase 2+). + // The explicit system opt-in: see SETTINGS_SYSTEM_CONTEXT. const rows = await this.engine.find(this.objectName, { where, bypassTenantAudit: true, + context: SETTINGS_SYSTEM_CONTEXT, } as any); return rows.map((r) => ({ namespace: r.namespace, @@ -2449,22 +2461,41 @@ export class SettingsService { private async upsertRow(row: SettingsRow): Promise { if (this.engine) { const { where, bypass } = this.rowIdentity(row); + // All three engine calls carry the explicit system opt-in + // (SETTINGS_SYSTEM_CONTEXT): the probe and the insert for the same + // reason as the update below, and none of them relies on a missing + // principal to pass the security middleware. const existing = await this.engine.find(this.objectName, { where, limit: 1, ...bypass, + context: SETTINGS_SYSTEM_CONTEXT, } as any); if (existing[0]) { const previousEnc = (existing[0] as { value_enc?: unknown }).value_enc; await this.engine.update(this.objectName, { where, data: { ...row }, - context: SETTINGS_SYSTEM_WRITE_CONTEXT, + context: SETTINGS_SYSTEM_CONTEXT, ...bypass, } as any); return SettingsService.handleOf(previousEnc); } - await this.engine.insert(this.objectName, { ...row }, bypass as any); + // Under the opt-in the engine no longer checks that a user-scope row's + // `user_id` names a user, so the service keeps that refusal before the + // insert (see assertUserReferenceResolves). The update branch above was + // already a system write and is unchanged. + const engine = this.engine; + await assertUserReferenceResolves( + async (id) => (await engine.find(USER_OBJECT, { where: { id }, limit: 1, context: SETTINGS_SYSTEM_CONTEXT }))[0], + { + object: this.objectName, + field: 'user_id', + label: registeredLabel(engine, { object: this.objectName, field: 'user_id' }, 'User'), + }, + row.user_id, + ); + await this.engine.insert(this.objectName, { ...row }, { ...bypass, context: SETTINGS_SYSTEM_CONTEXT } as any); return null; } const idx = this.memoryIndexOf(row); diff --git a/packages/services/service-settings/src/settings-service.types.ts b/packages/services/service-settings/src/settings-service.types.ts index 90c096661f1..1739ed1fb8c 100644 --- a/packages/services/service-settings/src/settings-service.types.ts +++ b/packages/services/service-settings/src/settings-service.types.ts @@ -94,12 +94,28 @@ export interface SettingsRow { export interface SettingsEngine { find( objectName: string, - opts: { where?: Record; limit?: number; bypassTenantAudit?: boolean }, + opts: { + where?: Record; + limit?: number; + bypassTenantAudit?: boolean; + /** + * Execution context for the read, forwarded VERBATIM to the data engine. + * `SettingsService` sends `{ isSystem: true }` on its own `sys_setting` + * reads; an adapter that drops it hands the engine a context with no + * principal and no system opt-in. Same forwarding rule as `update`'s + * `context` below. + */ + context?: Record; + }, ): Promise; insert( objectName: string, data: Record, - opts?: { bypassTenantAudit?: boolean }, + opts?: { + bypassTenantAudit?: boolean; + /** Forwarded VERBATIM, as on `find` and `update`. */ + context?: Record; + }, ): Promise; update( objectName: string, diff --git a/packages/services/service-settings/src/settings-system-context.pin.test.ts b/packages/services/service-settings/src/settings-system-context.pin.test.ts new file mode 100644 index 00000000000..f6ca92a5d8e --- /dev/null +++ b/packages/services/service-settings/src/settings-system-context.pin.test.ts @@ -0,0 +1,111 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#21913] Every engine call `SettingsService` makes on its own `sys_setting` + * rows, and the `sys_setting_audit` writer the plugin builds, carries the + * explicit system opt-in (`isSystem: true`). + * + * Before this, `loadRows`, `upsertRow`'s existence probe and insert, and the + * audit insert reached the data engine with NO context at all — no principal + * and no system opt-in — and passed the security middleware only through its + * principal-less hand-off (ADR-0096 E1), which D5 closes. `loadRows` sits on + * every request's execution-context build, so a deny landing before this + * producer moved would break the platform, not one feature. + * + * The double sits BEHIND the production adapter (`wrapEngineAsSettingsEngine`), + * so the pin also holds the adapter to forwarding the context it is handed on + * `find` and `insert` as it already must on `update`. + */ + +import { describe, it, expect } from 'vitest'; +import { SettingsService } from './settings-service.js'; +import { buildSettingAuditWriter, wrapEngineAsSettingsEngine } from './settings-service-plugin.js'; + +type Call = { verb: 'find' | 'insert' | 'update' | 'delete'; object: string; context: unknown }; + +// `$or` is the one combinator `loadRows` writes; anything else is refused so a +// silently ignored predicate cannot make this double answer too much. +function matches(row: Record, where: Record): boolean { + return Object.entries(where).every(([k, v]) => { + if (k === '$or') return (v as Array>).some((b) => matches(row, b)); + if (k.startsWith('$')) throw new Error(`recording engine: unimplemented combinator ${k}`); + return (row[k] ?? null) === (v ?? null); + }); +} + +/** + * An `IDataEngine`-shaped double that records the context each call carried. + * It implements only the verbs the moved calls use — `find` and `insert` — so + * a call this pin does not expect fails loudly instead of being answered. + */ +function recordingEngine() { + const rows: Array> = []; + const calls: Call[] = []; + // A read's context may arrive in the query bag or the trailing options + // argument (the contract accepts both; options wins) — record whichever came. + const readContext = (query: any, options: any) => options?.context ?? query?.context; + const engine = { + async find(object: string, query: any, options?: any) { + calls.push({ verb: 'find', object, context: readContext(query, options) }); + // The user-scope insert first proves its `user_id` names a user (#21913). + if (object === 'sys_user') return [{ id: query?.where?.id }]; + const hits = rows.filter((r) => matches(r, query?.where ?? {})); + return typeof query?.limit === 'number' ? hits.slice(0, query.limit) : hits; + }, + async insert(object: string, data: Record, options?: any) { + calls.push({ verb: 'insert', object, context: options?.context }); + if (object === 'sys_setting') rows.push({ ...data }); + return { ...data }; + }, + }; + return { engine, calls, rows }; +} + +const MANIFEST = { + namespace: 'localization', + label: 'Localization', + specifiers: [{ key: 'timezone', type: 'string', scope: 'user', default: 'UTC' }], +} as any; + +describe('[#21913] SettingsService engine calls carry the explicit system opt-in', () => { + it('loadRows, and upsertRow on its existence probe and insert, pass isSystem on every sys_setting call', async () => { + const { engine, calls, rows } = recordingEngine(); + const svc = new SettingsService(); + svc.registerManifest(MANIFEST); + svc.bindEngine(wrapEngineAsSettingsEngine(engine as any)); + + // loadRows (read path), then upsertRow's existence probe and insert branch. + // (Its update branch already carried the opt-in before this change.) + expect((await svc.get('localization', 'timezone', { userId: 'u1' })).value).toBe('UTC'); + await svc.set('localization', 'timezone', 'Asia/Tokyo', { userId: 'u1' }); + expect(rows).toHaveLength(1); + expect((await svc.get('localization', 'timezone', { userId: 'u1' })).value).toBe('Asia/Tokyo'); + + const onSettings = calls.filter((c) => c.object === 'sys_setting'); + // The pin is about a population, so it first proves the population is there: + // the reads ran, and the write took the insert branch. + expect(onSettings.filter((c) => c.verb === 'find').length).toBeGreaterThanOrEqual(3); + expect(onSettings.filter((c) => c.verb === 'insert')).toHaveLength(1); + // …and the insert's user-reference probe ran too, under the same opt-in. + expect(calls.filter((c) => c.object === 'sys_user')).toHaveLength(1); + expect(onSettings.length + 1).toBe(calls.length); + for (const call of calls) { + expect(call.context, `${call.verb} on ${call.object}`).toEqual({ isSystem: true }); + } + }); + + it('the sys_setting_audit writer inserts under isSystem', async () => { + const { engine, calls } = recordingEngine(); + await buildSettingAuditWriter(engine as any).write({ + namespace: 'localization', + key: 'timezone', + scope: 'user', + action: 'set', + actorId: 'u1', + oldHash: null, + newHash: 'hmac-sha256:x', + encrypted: false, + } as any); + expect(calls).toEqual([{ verb: 'insert', object: 'sys_setting_audit', context: { isSystem: true } }]); + }); +}); diff --git a/packages/services/service-settings/src/settings-user-reference.pin.test.ts b/packages/services/service-settings/src/settings-user-reference.pin.test.ts new file mode 100644 index 00000000000..155f93552e7 --- /dev/null +++ b/packages/services/service-settings/src/settings-user-reference.pin.test.ts @@ -0,0 +1,156 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#21913] `SettingsService.upsertRow`'s insert (`sys_setting.user_id`) and the + * setting-audit writer (`sys_setting_audit.actor_id`) carry the explicit system + * opt-in, and the engine skips its referential-integrity check for an + * `isSystem` write. So the producers keep the refusal a user reference naming + * no user met before the opt-in (`assertUserReferenceResolves`). + * + * The pin is DIFFERENTIAL, over a real engine: each producer's answer for an + * unknown user is held equal — name, `code`, `status`, message and findings — + * to the refusal the engine itself gives the context-less insert the producer + * made before the opt-in. A known user is written, and a write that names no + * user is unchanged. + */ + +import { describe, it, expect, beforeEach } from 'vitest'; +import { ObjectQL } from '@objectstack/objectql'; +import { SysUser } from '@objectstack/platform-objects/identity'; +import { SysSetting, SysSettingAudit } from '@objectstack/platform-objects/system'; +import { SettingsService } from './settings-service.js'; +import { buildSettingAuditWriter, wrapEngineAsSettingsEngine } from './settings-service-plugin.js'; + +const SYS = { context: { isSystem: true } } as const; +const GHOST = 'usr_ghost_21913'; + +/** A driver over plain Maps — enough of `IDataDriver` for these inserts and reads. */ +function makeMemoryDriver() { + const store = new Map>>(); + let nextId = 0; + const rowsOf = (object: string) => { + let s = store.get(object); + if (!s) { s = new Map(); store.set(object, s); } + return s; + }; + const matches = (row: Record, where: any): boolean => { + if (!where || typeof where !== 'object') return true; + return Object.entries(where).every(([k, v]) => { + if (k === '$or') return (v as any[]).some((b) => matches(row, b)); + if (k.startsWith('$')) throw new Error(`fake driver: unsupported operator ${k}`); + return (row[k] ?? null) === (v ?? null); + }); + }; + const driver: any = { + name: 'memory', version: '0.0.0', supports: {} as any, + async connect() {}, async disconnect() {}, async checkHealth() { return true; }, + async execute() { return null; }, + async find(object: string, ast: any) { + const hits = [...rowsOf(object).values()].filter((r) => matches(r, ast?.where)); + const page = typeof ast?.limit === 'number' ? hits.slice(0, ast.limit) : hits; + return page.map((r) => ({ ...r })); + }, + async findOne(object: string, ast: any) { + for (const r of rowsOf(object).values()) if (matches(r, ast?.where)) return { ...r }; + return null; + }, + async create(object: string, data: Record) { + nextId += 1; + const row = { ...data, id: (data.id as string) ?? `row_${nextId}` }; + rowsOf(object).set(row.id as string, row); + return { ...row }; + }, + async count(object: string, ast: any) { return (await this.find(object, ast)).length; }, + async syncSchema() {}, async dropTable() {}, + async beginTransaction() { return { commit: async () => {}, rollback: async () => {} }; }, + async commit() {}, async rollback() {}, + }; + return { driver, rowsOf }; +} + +/** The refusal's observable envelope — everything but the stack. */ +function envelope(e: any) { + return { name: e?.name, code: e?.code, status: e?.status, message: e?.message, fields: e?.fields }; +} + +let engine: ObjectQL; +let rowsOf: (object: string) => Map>; +let userId: string; + +beforeEach(async () => { + engine = new ObjectQL(); + const memory = makeMemoryDriver(); + rowsOf = memory.rowsOf; + engine.registerDriver(memory.driver, true); + await engine.init(); + for (const o of [SysUser, SysSetting, SysSettingAudit]) engine.registry.registerObject(o as any, '@objectstack/platform-objects'); + const user = await engine.insert('sys_user', { name: 'Ada', email: 'ada@example.test' }, SYS); + userId = String((user as any).id); +}); + +const MANIFEST = { + namespace: 'localization', + label: 'Localization', + specifiers: [{ key: 'timezone', type: 'string', scope: 'user', default: 'UTC' }], +} as any; + +function settings(): SettingsService { + const svc = new SettingsService(); + svc.registerManifest(MANIFEST); + svc.bindEngine(wrapEngineAsSettingsEngine(engine as any)); + return svc; +} + +describe('[#21913] upsertRow keeps the dangling user_id refusal', () => { + it('a user-scope write for an unknown user is refused exactly as the engine refused the context-less insert; nothing is written', async () => { + const before = await engine.insert('sys_setting', { + namespace: 'localization', key: 'timezone', scope: 'user', user_id: GHOST, + value: 'Asia/Tokyo', value_enc: null, encrypted: false, locked: false, locked_reason: null, + }).then(() => null, (e) => e); + expect(before?.code).toBe('VALIDATION_FAILED'); + expect(rowsOf('sys_setting').size).toBe(0); + + const refusal = await settings().set('localization', 'timezone', 'Asia/Tokyo', { userId: GHOST }).then(() => null, (e) => e); + expect(refusal, 'the write must refuse').not.toBeNull(); + expect(refusal.code).toBe('VALIDATION_FAILED'); + expect(refusal.status).toBe(before.status); + expect(refusal.fields?.[0]).toMatchObject({ field: 'user_id', code: 'reference_not_found', constraint: { target: 'sys_user' } }); + expect(envelope(refusal)).toEqual(envelope(before)); + expect(rowsOf('sys_setting').size).toBe(0); + }); + + it('a known user is written', async () => { + await settings().set('localization', 'timezone', 'Asia/Tokyo', { userId }); + expect([...rowsOf('sys_setting').values()].map((r) => r.user_id)).toEqual([userId]); + }); +}); + +describe('[#21913] the setting-audit writer keeps the dangling actor_id refusal', () => { + const entry = (actorId?: string) => ({ + namespace: 'localization', key: 'timezone', scope: 'user', action: 'set', + ...(actorId !== undefined ? { actorId } : {}), oldHash: null, newHash: 'hmac-sha256:x', encrypted: false, + }); + + it('an unknown actor_id is reported exactly as the engine refusal was, and nothing is written', async () => { + const before = await engine.insert('sys_setting_audit', { + namespace: 'localization', key: 'timezone', scope: 'user', action: 'set', source: 'api', actor_id: GHOST, + old_hash: null, new_hash: 'hmac-sha256:x', encrypted: false, request_id: null, reason: null, + created_at: new Date().toISOString(), + }).then(() => null, (e) => e); + expect(before?.code).toBe('VALIDATION_FAILED'); + + const warned: string[] = []; + await buildSettingAuditWriter(engine as any, { warn: (m) => warned.push(m) }).write(entry(GHOST) as any); + expect(warned).toEqual([`SettingsServicePlugin: setting-audit write failed: ${before.message}`]); + expect(rowsOf('sys_setting_audit').size).toBe(0); + }); + + it('a known actor is written, and an entry naming no actor is unchanged', async () => { + const warned: string[] = []; + const writer = buildSettingAuditWriter(engine as any, { warn: (m) => warned.push(m) }); + await writer.write(entry(userId) as any); + await writer.write(entry() as any); + expect(warned).toEqual([]); + expect([...rowsOf('sys_setting_audit').values()].map((r) => r.actor_id ?? null).sort()).toEqual([null, userId].sort()); + }); +}); diff --git a/packages/services/service-settings/vitest.config.ts b/packages/services/service-settings/vitest.config.ts index 2dcc5aaa268..d39ab2c9e4d 100644 --- a/packages/services/service-settings/vitest.config.ts +++ b/packages/services/service-settings/vitest.config.ts @@ -73,6 +73,13 @@ export default defineConfig({ find: /^@objectstack\/platform-objects\/system$/, replacement: path.resolve(__dirname, '../../platform-objects/src/system/index.ts'), }, + // `platform-objects/identity` arrived with the user-reference pin + // (`settings-user-reference.pin.test.ts`), which registers `sys_user` on a + // real engine; same explicit-subpath shape as `system` above. + { + find: /^@objectstack\/platform-objects\/identity$/, + replacement: path.resolve(__dirname, '../../platform-objects/src/identity/index.ts'), + }, { find: /^@objectstack\/platform-objects$/, replacement: path.resolve(__dirname, '../../platform-objects/src/index.ts'), diff --git a/scripts/engine-double-contract.pinned.json b/scripts/engine-double-contract.pinned.json index bba7511cf05..22e7c40bcf0 100644 --- a/scripts/engine-double-contract.pinned.json +++ b/scripts/engine-double-contract.pinned.json @@ -4071,6 +4071,21 @@ "verb": "update", "pinned": 1 }, + { + "file": "packages/services/service-datasource/src/__tests__/datasource-system-context.pin.test.ts", + "verb": "delete", + "pinned": 1 + }, + { + "file": "packages/services/service-datasource/src/__tests__/datasource-system-context.pin.test.ts", + "verb": "findOne", + "pinned": 1 + }, + { + "file": "packages/services/service-datasource/src/__tests__/datasource-system-context.pin.test.ts", + "verb": "update", + "pinned": 1 + }, { "file": "packages/services/service-job/src/db-job-adapter.degraded-outcome.test.ts", "verb": "update", @@ -4176,6 +4191,16 @@ "verb": "findOne", "pinned": 1 }, + { + "file": "packages/services/service-messaging/src/system-context.pin.test.ts", + "verb": "findOne", + "pinned": 1 + }, + { + "file": "packages/services/service-messaging/src/system-context.pin.test.ts", + "verb": "update", + "pinned": 1 + }, { "file": "packages/services/service-messaging/src/template-renderer.test.ts", "verb": "findOne",