diff --git a/.changeset/25249-settings-self-bound-organization.md b/.changeset/25249-settings-self-bound-organization.md new file mode 100644 index 00000000000..0ce237212b6 --- /dev/null +++ b/.changeset/25249-settings-self-bound-organization.md @@ -0,0 +1,19 @@ +--- +'@objectstack/service-settings': patch +--- + +fix(service-settings)!: a host that binds its own engine and states no organization source reads and writes as the organization ADR-0131 D9 derives through that engine, not as no organization + +Clause-②: no (narrowing) +Metadata-protocol: none + + + +**BREAKING** read narrowing (ADR-0131 D8/D9, follow-up to the organization-less rung fix). It changes which organization a host's settings reads take, not what a metadata author can write, so it ships as `patch` with this banner. + +- FROM: a host that bound its own engine (`new SettingsService({ engine })` or `bindEngine(engine)`) and stated no `organization` source read and wrote, for a caller that names no organization, as a composition with no organization object: its rows with no organization answered, and its writes stored none. +- TO: such a host's service asks D9 itself, through the engine it holds (`resolveGuestOrganization`, the call the plugin's source makes). Under `single` with one organization it reads and writes as that organization; at none or several it has no tenant or user rung. An engine that exposes no `registry` cannot say the organization object is absent, so D9 asks `sys_organization`. Over a store with no such object, a tenant-rung read or write by a caller naming none is then refused as organization-less (reads answer from the global rung or the default, tenant writes are refused with `SETTINGS_VALIDATION`), or raises `AuthzStoreUnavailableError` naming `sys_organization` where the engine does not know that object. No engine at all (the in-memory store), a stated source, an engine whose `registry.getObject` answers, and `SettingsServicePlugin` (`os serve`, `os dev`) are unchanged. + +**Who is affected.** Only a host that binds its own engine and states no source: a control-plane mock or an embedded runtime. On a composition with an organization object it now reads and writes the derived organization's rows, and its rows with no organization stop answering. On one without, it meets the refusal above. A deployment booted through `SettingsServicePlugin` is not affected. + +**The fix.** State the composition at the bind site: `service.bindEngine(engine, audit, { organization: async () => ({ kind: 'no-organization-object' }) })` (a host that hands its engine to the constructor binds it this way instead), or hand over an engine whose `registry.getObject` can answer. diff --git a/packages/services/service-settings/src/__tests__/settings-organization.testkit.ts b/packages/services/service-settings/src/__tests__/settings-organization.testkit.ts new file mode 100644 index 00000000000..ed0ae86831d --- /dev/null +++ b/packages/services/service-settings/src/__tests__/settings-organization.testkit.ts @@ -0,0 +1,28 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * Test scaffolding: how a suite that binds its own engine states the + * composition's organization facts (#25249, ADR-0131 D9). + * + * A host that states no organization source has the service ask D9 itself, + * through the engine it holds — and an engine with no registry to ask is read + * the fail-closed way, as one that DOES register `sys_organization`. So a + * suite states it, one of two ways: + * + * - {@link LEAN_ORGANIZATION_SOURCE}, at the bind site, for a double: this + * composition registers no organization object. + * - {@link withEngineRegistry}, for a suite that runs the real ObjectQL behind + * the production adapter: the real registry answers, as it would through + * the raw engine the plugin hands D9. + */ + +import type { SettingsOrganizationSource } from '../settings-service.js'; +import type { SettingsEngine } from '../settings-service.types.js'; + +/** A lean composition's answer: no organization object is registered. */ +export const LEAN_ORGANIZATION_SOURCE: SettingsOrganizationSource = async () => ({ kind: 'no-organization-object' }); + +/** `wrapped`, carrying the real engine's registry, so D9's registry question reaches it. */ +export function withEngineRegistry(wrapped: E, engine: { registry: unknown }): E & { registry: unknown } { + return Object.assign(wrapped, { registry: engine.registry }); +} diff --git a/packages/services/service-settings/src/index.ts b/packages/services/service-settings/src/index.ts index dd0491a22b9..c972831fa1f 100644 --- a/packages/services/service-settings/src/index.ts +++ b/packages/services/service-settings/src/index.ts @@ -78,7 +78,11 @@ export { // because a host that binds its own engine (control-plane mocks, embedded // runtimes) has to be able to get the SAME ledger row rather than re-deriving // its shape; the action value and target-object constants travel with it for the -// same reason a pin must not spell them by hand. +// same reason a pin must not spell them by hand. [#25249] Such a host with no +// organization object declares it (`bindEngine`'s `organization` extra answering +// `no-organization-object`); left out, a tenant-rung read or write by a caller naming +// none is refused as organization-less, or raises `AuthzStoreUnavailableError` +// naming `sys_organization`. export { buildConfigChangeAuditSink, CONFIG_CHANGE_ACTION, diff --git a/packages/services/service-settings/src/settings-getmany.test.ts b/packages/services/service-settings/src/settings-getmany.test.ts index d1c00ff43f5..557bc67b651 100644 --- a/packages/services/service-settings/src/settings-getmany.test.ts +++ b/packages/services/service-settings/src/settings-getmany.test.ts @@ -22,6 +22,7 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import { SettingsService } from './settings-service.js'; import { UnknownKeyError } from './settings-service.types.js'; +import { LEAN_ORGANIZATION_SOURCE } from './__tests__/settings-organization.testkit.js'; // WHERE-matcher gate: implement exactly the combinators the service emits and // THROW on the rest — a bare field-equality read of `$or` would silently match @@ -91,7 +92,7 @@ async function makeService(rows = ROWS) { const engine = makeEngine(rows); const svc = new SettingsService(); svc.registerManifest(MANIFEST); - svc.bindEngine(engine as any); + svc.bindEngine(engine as any, undefined, { organization: LEAN_ORGANIZATION_SOURCE }); return { svc, engine }; } diff --git a/packages/services/service-settings/src/settings-global-rung.test.ts b/packages/services/service-settings/src/settings-global-rung.test.ts index 0fe8e2c9ae5..66752b8c030 100644 --- a/packages/services/service-settings/src/settings-global-rung.test.ts +++ b/packages/services/service-settings/src/settings-global-rung.test.ts @@ -31,6 +31,7 @@ import { SysPlatformSetting, SysSecret, SysSetting } from '@objectstack/platform import type { SettingsManifest } from '@objectstack/spec/system'; import { SettingsService } from './settings-service.js'; import { SettingsServicePlugin, wrapEngineAsSettingsEngine } from './settings-service-plugin.js'; +import { withEngineRegistry } from './__tests__/settings-organization.testkit.js'; import { LocalCryptoProvider } from './local-crypto-provider.js'; import { SettingsLockedError } from './settings-service.types.js'; @@ -151,7 +152,7 @@ async function boot() { const cryptoProvider = new LocalCryptoProvider(); const svc = new SettingsService({ env: {}, - engine: wrapEngineAsSettingsEngine(engine as any), + engine: withEngineRegistry(wrapEngineAsSettingsEngine(engine as any), engine), cryptoProvider, secretStore: (new SettingsServicePlugin() as any).buildSecretStore(engine), }); diff --git a/packages/services/service-settings/src/settings-organization-reach.pin.test.ts b/packages/services/service-settings/src/settings-organization-reach.pin.test.ts index 323afda5448..9d0a195997e 100644 --- a/packages/services/service-settings/src/settings-organization-reach.pin.test.ts +++ b/packages/services/service-settings/src/settings-organization-reach.pin.test.ts @@ -18,15 +18,21 @@ * The callers this stands for read with an empty context: plugin-auth's email * brand name and boot-time locale, the anonymous runtime-config door's * branding read, and a signed-in caller with no active organization. + * + * [#25249] The last section pins a host that binds its OWN engine and states + * no organization source: the service asks D9 itself, through the engine it + * holds, and an engine with no registry to ask is read as one that registers + * the organization object. */ import { describe, it, expect } from 'vitest'; -import { ObjectKernel } from '@objectstack/core'; -import { ObjectQL } from '@objectstack/objectql'; +import { isAuthzStoreUnavailableError, ObjectKernel } from '@objectstack/core'; +import { assertEngineUpdateDispatch, ObjectQL } from '@objectstack/objectql'; import { SysOrganization, SysUser } from '@objectstack/platform-objects/identity'; import { SysPlatformSetting, SysSetting } from '@objectstack/platform-objects/system'; -import { SettingsServicePlugin } from './settings-service-plugin.js'; -import type { SettingsService } from './settings-service.js'; +import { SettingsServicePlugin, wrapEngineAsSettingsEngine } from './settings-service-plugin.js'; +import { SettingsService } from './settings-service.js'; +import { LEAN_ORGANIZATION_SOURCE } from './__tests__/settings-organization.testkit.js'; const SYS = { context: { isSystem: true } } as const; const NS = 'org_reach_fixture'; @@ -39,6 +45,7 @@ const MANIFEST = { specifiers: [ { key: 'brand', type: 'text', label: 'Brand', scope: 'tenant', default: 'none' }, { key: 'legacy', type: 'text', label: 'Legacy', scope: 'tenant', default: 'none' }, + { key: 'theme', type: 'text', label: 'Theme', scope: 'user', default: 'none' }, ], } as any; @@ -227,3 +234,127 @@ describe('a composition with no organization object (the lean embedding, ADR-013 expect(b.rows()).toEqual([['brand', null, 'Lean again']]); }); }); + +/** + * [#25249] A host that binds its own engine — a control-plane mock, an embedded + * runtime — through the production adapter, which carries no registry. No + * kernel, no plugin, no posture source (`single`). `organizations` is as in + * {@link boot}; `source` is the organization source the host states, if any. + */ +async function bootSelfBound(organizations: string[] | null, source?: typeof LEAN_ORGANIZATION_SOURCE) { + const engine = new ObjectQL(); + engine.registry.logLevel = 'silent'; + const memory = makeMemoryDriver(); + engine.registerDriver(memory.driver, true); + await engine.init(); + const objects: any[] = [SysUser, SysSetting, SysPlatformSetting]; + if (organizations !== null) objects.push(SysOrganization); + for (const o of objects) engine.registry.registerObject(o, '@objectstack/platform-objects'); + for (const id of organizations ?? []) { + await engine.insert('sys_organization', { id, name: id, slug: id }, SYS); + } + // The user a user-rung write names, as the store already holds it. + memory.rowsOf('sys_user').set('usr_1', { id: 'usr_1', name: 'Ada', email: 'ada@example.test' }); + const service = new SettingsService({ env: {} }); + service.registerManifest(MANIFEST); + service.bindEngine(wrapEngineAsSettingsEngine(engine as any), undefined, source ? { organization: source } : undefined); + const seed = (row: Record) => { + const id = `seed_${memory.rowsOf('sys_setting').size + 1}`; + memory.rowsOf('sys_setting').set(id, { + id, namespace: NS, scope: 'tenant', user_id: null, value_enc: null, encrypted: false, + locked: false, locked_reason: null, ...row, + }); + }; + const rows = () => [...memory.rowsOf('sys_setting').values()] + .map((r) => [r.key, r.organization_id ?? null, r.value]) + .sort((a, b) => String(a[0]).localeCompare(String(b[0]))); + return { service, seed, rows }; +} + +/** + * The same host over a double: an `IDataEngine`-shaped object with no registry, + * whose `sys_organization` holds no rows, and one tenant row written with no + * organization. + */ +function selfBoundDouble(source?: typeof LEAN_ORGANIZATION_SOURCE) { + const stored: Array> = [ + { id: 'seed_1', namespace: NS, key: 'brand', scope: 'tenant', user_id: null, organization_id: null, value: 'Lean', encrypted: false, locked: false }, + ]; + const engine = { + async find(object: string, query: any) { + if (object !== 'sys_setting') return []; + const where = query?.where ?? {}; + const branches: Array> = Array.isArray(where.$or) ? where.$or : [where]; + const { $or: _or, ...rest } = where; + const hits = stored.filter((r) => branches.some((b) => Object.entries({ ...rest, ...b }).every(([k, v]) => (r[k] ?? null) === (v ?? null)))); + return typeof query?.limit === 'number' ? hits.slice(0, query.limit) : hits; + }, + async insert(_object: string, data: Record) { stored.push({ ...data }); return { ...data }; }, + async update(_object: string, data: Record, options?: any) { assertEngineUpdateDispatch(data, options); return 1; }, + }; + const service = new SettingsService({ env: {} }); + service.registerManifest(MANIFEST); + service.bindEngine(engine as any, undefined, source ? { organization: source } : undefined); + return { service }; +} + +describe('[#25249] a host that binds its own engine and states no organization source', () => { + it('(a) one organization: the tenant and user rungs read and write as that organization, through an adapter with no registry', async () => { + const b = await bootSelfBound([ORG]); + await b.seed({ key: 'brand', value: 'Acme', organization_id: ORG }); + await b.seed({ key: 'legacy', value: 'Unowned', organization_id: null }); + await b.seed({ key: 'theme', value: 'Unowned dark', scope: 'user', user_id: 'usr_1', organization_id: null }); + + expect((await b.service.get(NS, 'brand', {})).value).toBe('Acme'); + expect((await b.service.get(NS, 'legacy', {})).source).toBe('default'); + expect((await b.service.get(NS, 'theme', { userId: 'usr_1' })).source).toBe('default'); + await b.service.set(NS, 'brand', 'Acme two', {}); + await b.service.set(NS, 'theme', 'Light', { userId: 'usr_1' }); + await b.service.set(NS, 'theme', 'Light two', { userId: 'usr_1' }); + + expect((await b.service.get(NS, 'brand', { tenantId: ORG })).value).toBe('Acme two'); + expect((await b.service.get(NS, 'theme', { userId: 'usr_1' })).value).toBe('Light two'); + expect((await b.service.get(NS, 'theme', { userId: 'usr_1', tenantId: ORG })).value).toBe('Light two'); + expect(b.rows()).toEqual([ + ['brand', ORG, 'Acme two'], ['legacy', null, 'Unowned'], ['theme', null, 'Unowned dark'], ['theme', ORG, 'Light two'], + ]); + }); + + it('(b) no organization object, real engine: the probe is refused as a failed read, loudly', async () => { + // Measured, not converted: the adapter cannot say the object is absent, so + // D9 asks `sys_organization`, and the engine refuses an unregistered name + // as "object not found" — which is not a missing table. + const b = await bootSelfBound(null); + const refusal = await b.service.get(NS, 'brand', {}).then(() => null, (e) => e); + expect(isAuthzStoreUnavailableError(refusal)).toBe(true); + expect(refusal.object).toBe('sys_organization'); + }); + + it('(b) no organization object, a double with no registry: zero organizations, so no tenant row and no tenant write', async () => { + const d = selfBoundDouble(); + expect((await d.service.get(NS, 'brand', {})).source).toBe('default'); + const refusal = await d.service.set(NS, 'brand', 'Nobody', {}).then(() => null, (e) => e); + expect(refusal?.code).toBe('SETTINGS_VALIDATION'); + }); +}); + +describe('[#25249] the readings a stated source and an absent engine keep', () => { + it('(c) a host that states the lean composition reads and writes the rows with no organization', async () => { + const d = selfBoundDouble(LEAN_ORGANIZATION_SOURCE); + expect((await d.service.get(NS, 'brand', {})).value).toBe('Lean'); + + const b = await bootSelfBound(null, LEAN_ORGANIZATION_SOURCE); + await b.service.set(NS, 'brand', 'Lean', {}); + await b.service.set(NS, 'brand', 'Lean again', {}); + expect((await b.service.get(NS, 'brand', {})).value).toBe('Lean again'); + expect(b.rows()).toEqual([['brand', null, 'Lean again']]); + }); + + it('(d) no engine: the in-memory store keeps its rows with no organization', async () => { + const service = new SettingsService({ env: {} }); + service.registerManifest(MANIFEST); + await service.set(NS, 'brand', 'Memory', {}); + expect((await service.get(NS, 'brand', {})).value).toBe('Memory'); + expect(((service as any).memory as Array>).map((r) => r.organization_id ?? null)).toEqual([null]); + }); +}); diff --git a/packages/services/service-settings/src/settings-secret-rotation.test.ts b/packages/services/service-settings/src/settings-secret-rotation.test.ts index 1e8086f0d5c..139fa7af698 100644 --- a/packages/services/service-settings/src/settings-secret-rotation.test.ts +++ b/packages/services/service-settings/src/settings-secret-rotation.test.ts @@ -81,6 +81,7 @@ import { SysPlatformSetting, SysSecret, SysSetting } from '@objectstack/platform import type { SettingsManifest } from '@objectstack/spec/system'; import { SettingsService } from './settings-service.js'; import { wrapEngineAsSettingsEngine } from './settings-service-plugin.js'; +import { withEngineRegistry } from './__tests__/settings-organization.testkit.js'; import { LocalCryptoProvider } from './local-crypto-provider.js'; import { dropEchoedSecretMasks, SETTINGS_SECRET_MASK } from './settings-secret-redaction.js'; import type { SettingsEngine, SettingsSecretStore } from './settings-service.types.js'; @@ -306,11 +307,14 @@ async function boot(opts: { const logged: string[] = []; const svc = new SettingsService({ env: {}, - engine: opts.failVerificationRead - ? wrapEngineFailingPostUpdateReads(engine) - : opts.forwardContext === false - ? wrapEngineDroppingContext(engine) - : wrapEngineAsSettingsEngine(engine as any), + engine: withEngineRegistry( + opts.failVerificationRead + ? wrapEngineFailingPostUpdateReads(engine) + : opts.forwardContext === false + ? wrapEngineDroppingContext(engine) + : wrapEngineAsSettingsEngine(engine as any), + engine, + ), cryptoProvider: new LocalCryptoProvider(), secretStore: { ...baseStore, ...(opts.secretStoreOverrides ?? {}) }, logger: { error: (m) => { logged.push(m); } }, diff --git a/packages/services/service-settings/src/settings-service.ts b/packages/services/service-settings/src/settings-service.ts index dfe24d2da28..e4d1a6998ef 100644 --- a/packages/services/service-settings/src/settings-service.ts +++ b/packages/services/service-settings/src/settings-service.ts @@ -45,6 +45,7 @@ import { organizationIdOf, organizationReachAdmits, organizationReachRowOwner, + resolveGuestOrganization, resolveOrganizationReach, UNOWNED_ORGANIZATION_REACH, type OrganizationReach, @@ -198,8 +199,12 @@ export type SettingsTenancyPostureSource = () => * facts. The plugin supplies it at bind time; it is asked only for such a * caller, on a key below the global rung. * - * Unset — a service constructed directly — reads as a composition that - * registers no organization object: nothing names an organization to it. + * Unset, the service asks the same predicate itself, through the engine it + * holds ({@link SettingsService.organizationReachOf}). A host that binds its own + * engine on a composition with no organization object states that with a + * source answering `{ kind: 'no-organization-object' }`. A service with no + * engine at all (the in-memory store) holds no organization's rows, and keeps + * that reading. */ export type SettingsOrganizationSource = () => Promise; @@ -866,8 +871,8 @@ export class SettingsService { private tenancyPosture?: SettingsTenancyPostureSource; /** * The organization a caller that names none acts in where no wall stands, - * bound with the engine. Unset reads as a composition with no organization - * object. See {@link organizationReachOf}. + * bound with the engine. Unset, it is derived through the engine the service + * holds. See {@link organizationReachOf}. */ private organizationSource?: SettingsOrganizationSource; /** Change subscribers, optionally scoped to a namespace. */ @@ -914,7 +919,8 @@ export class SettingsService { /** * ADR-0131 D9's answer for a caller that names no organization where no * wall stands: the one organization it reads and writes as, none, or a - * composition with no organization object. + * composition with no organization object. Omitted, the service asks + * D9 through `engine` itself (see `organizationDecision`). */ organization?: SettingsOrganizationSource; }, @@ -1871,13 +1877,31 @@ export class SettingsService { return resolveOrganizationReach({ callerOrganizationId: ctx.tenantId, wallStands: () => this.organizationWallStands(), - deriveOrganization: async () => { - const source = this.organizationSource; - return source ? source() : { kind: 'no-organization-object' }; - }, + deriveOrganization: () => this.organizationDecision(), }); } + /** + * ADR-0131 D9's answer for a caller that names no organization where no wall + * stands: the bound source's when the host stated one, else the predicate + * asked here, through the engine this service holds. + * + * The second branch is the same call the plugin's source makes + * (`resolveGuestOrganization`, the one spelling of D9 for a caller with no + * organization of its own), so a host that binds its own engine, or hands + * one to the constructor, reads and writes as the organization the + * composition derives rather than as no organization. An engine that exposes + * no registry cannot say the organization object is absent, and the + * predicate reads that as registered: a host that means the lean composition + * says so with its source. No engine at all is the in-memory store, which + * holds no organization's rows. + */ + private async organizationDecision(): Promise { + if (this.organizationSource) return this.organizationSource(); + if (!this.engine) return { kind: 'no-organization-object' }; + return resolveGuestOrganization(this.engine, await this.tenancyPosture?.()); + } + /** * Does the tenancy posture IN FORCE wall organizations off from each other? * 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 index f8622b497f4..061901a7ed2 100644 --- a/packages/services/service-settings/src/settings-system-context.pin.test.ts +++ b/packages/services/service-settings/src/settings-system-context.pin.test.ts @@ -22,6 +22,7 @@ import { assertEngineDeleteDispatch, assertEngineUpdateDispatch } from '@objects import { SettingsService } from './settings-service.js'; import { SettingsServicePlugin, buildSettingAuditWriter, wrapEngineAsSettingsEngine } from './settings-service-plugin.js'; import { LocalCryptoProvider } from './local-crypto-provider.js'; +import { LEAN_ORGANIZATION_SOURCE } from './__tests__/settings-organization.testkit.js'; type Call = { verb: 'find' | 'insert' | 'update' | 'delete'; object: string; context: unknown }; @@ -90,7 +91,7 @@ describe('[#21913] SettingsService engine calls carry the explicit system opt-in const { engine, calls, rows } = recordingEngine(); const svc = new SettingsService(); svc.registerManifest(MANIFEST); - svc.bindEngine(wrapEngineAsSettingsEngine(engine as any)); + svc.bindEngine(wrapEngineAsSettingsEngine(engine as any), undefined, { organization: LEAN_ORGANIZATION_SOURCE }); // loadRows (read path), then upsertRow's existence probe and insert branch. // (Its update branch already carried the opt-in before this change.) @@ -193,12 +194,12 @@ describe('[#21908] the sys_secret store and readStoredHandle carry the explicit it('a rotation: the secret writes, the row update and the verification read after it all pass isSystem', async () => { const { engine, calls } = recordingEngine(); - const svc = new SettingsService({ - env: {}, - engine: wrapEngineAsSettingsEngine(engine as any), + const svc = new SettingsService({ env: {} }); + svc.bindEngine(wrapEngineAsSettingsEngine(engine as any), undefined, { cryptoProvider: new LocalCryptoProvider(), secretStore: (new SettingsServicePlugin() as any).buildSecretStore(engine), - } as any); + organization: LEAN_ORGANIZATION_SOURCE, + }); svc.registerManifest(SECRET_MANIFEST); await svc.set('sms', 'twilio_auth_token', 'alpha'); 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 index af14f030d0e..41e8a07ec8d 100644 --- a/packages/services/service-settings/src/settings-user-reference.pin.test.ts +++ b/packages/services/service-settings/src/settings-user-reference.pin.test.ts @@ -20,6 +20,7 @@ import { SysUser } from '@objectstack/platform-objects/identity'; import { SysPlatformSetting, SysSetting, SysSettingAudit } from '@objectstack/platform-objects/system'; import { SettingsService } from './settings-service.js'; import { buildSettingAuditWriter, wrapEngineAsSettingsEngine } from './settings-service-plugin.js'; +import { withEngineRegistry } from './__tests__/settings-organization.testkit.js'; const SYS = { context: { isSystem: true } } as const; const GHOST = 'usr_ghost_21913'; @@ -97,7 +98,7 @@ const MANIFEST = { function settings(): SettingsService { const svc = new SettingsService(); svc.registerManifest(MANIFEST); - svc.bindEngine(wrapEngineAsSettingsEngine(engine as any)); + svc.bindEngine(withEngineRegistry(wrapEngineAsSettingsEngine(engine as any), engine)); return svc; } diff --git a/packages/services/service-settings/src/settings-user-rung-owner.pin.test.ts b/packages/services/service-settings/src/settings-user-rung-owner.pin.test.ts index c2a8627ff14..d9bf8ee13d9 100644 --- a/packages/services/service-settings/src/settings-user-rung-owner.pin.test.ts +++ b/packages/services/service-settings/src/settings-user-rung-owner.pin.test.ts @@ -26,6 +26,7 @@ import { describe, it, expect, vi } from 'vitest'; import { SettingsService } from './settings-service.js'; import { SettingsValidationError, type SettingsContext } from './settings-service.types.js'; +import { LEAN_ORGANIZATION_SOURCE } from './__tests__/settings-organization.testkit.js'; type Row = Record; @@ -99,7 +100,7 @@ function engineStack(seed: Row[], opts: { overAnswer?: boolean } = {}) { const engine = { find, insert, update: vi.fn(), delete: vi.fn(), count: vi.fn() }; const svc = new SettingsService({ env: {} }); svc.registerManifest(MANIFEST); - svc.bindEngine(engine as any); + svc.bindEngine(engine as any, undefined, { organization: LEAN_ORGANIZATION_SOURCE }); return { svc, wrote: () => insert.mock.calls.length + engine.update.mock.calls.length, diff --git a/packages/services/service-settings/src/sys-secret-orphan-report.test.ts b/packages/services/service-settings/src/sys-secret-orphan-report.test.ts index 8b38b0676fb..7214f31c8c0 100644 --- a/packages/services/service-settings/src/sys-secret-orphan-report.test.ts +++ b/packages/services/service-settings/src/sys-secret-orphan-report.test.ts @@ -48,6 +48,7 @@ import type { SettingsManifest } from '@objectstack/spec/system'; import type { CryptoHandle, ICryptoProvider } from '@objectstack/spec/contracts'; import { SettingsService } from './settings-service.js'; import { wrapEngineAsSettingsEngine } from './settings-service-plugin.js'; +import { withEngineRegistry } from './__tests__/settings-organization.testkit.js'; import { LocalCryptoProvider } from './local-crypto-provider.js'; import type { SettingsEngine, SettingsSecretStore } from './settings-service.types.js'; import { @@ -274,10 +275,12 @@ async function boot(opts: { forwardContext?: boolean; withDelete?: boolean } = { const auditEntries: Array> = []; const svc = new SettingsService({ env: {}, - engine: + engine: withEngineRegistry( opts.forwardContext === false ? wrapEngineDroppingContext(engine) : wrapEngineAsSettingsEngine(engine as any), + engine, + ), cryptoProvider: new LocalCryptoProvider(), secretStore, auditWriter: { diff --git a/scripts/engine-double-contract.pinned.json b/scripts/engine-double-contract.pinned.json index 776c0f73571..dd34b7153be 100644 --- a/scripts/engine-double-contract.pinned.json +++ b/scripts/engine-double-contract.pinned.json @@ -4726,6 +4726,11 @@ "verb": "update", "pinned": 1 }, + { + "file": "packages/services/service-settings/src/settings-organization-reach.pin.test.ts", + "verb": "update", + "pinned": 1 + }, { "file": "packages/services/service-settings/src/settings-secret-rotation.test.ts", "verb": "update",