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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions .changeset/25249-settings-self-bound-organization.md
Original file line number Diff line number Diff line change
@@ -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

<!-- adr-0087: not-required (no-migration-prescription) Which organization a self-binding host's settings reads and writes take changes. No key of any metadata schema is removed, renamed or re-shaped, so `objectstack migrate meta` has nothing to rewrite and no tombstone is needed. No export, type, option, error code or route is added, removed or renamed: `SettingsServiceOptions` and the `bindEngine` extras are unchanged, and `SettingsServicePlugin` already states its source. The remedy for a host this narrows is one line at its bind site, stated below. The other categories are closed on facts: the package publishes (not unpublished); no ADR-0087 id covers this read (not registered or already-registered); and nothing exported is removed or narrowed (not runtime-interface-only, type-surface-only or runtime-export-retired). -->

**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.
Original file line number Diff line number Diff line change
@@ -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<E extends SettingsEngine>(wrapped: E, engine: { registry: unknown }): E & { registry: unknown } {
return Object.assign(wrapped, { registry: engine.registry });
}
6 changes: 5 additions & 1 deletion packages/services/service-settings/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 };
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -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),
});
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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;

Expand Down Expand Up @@ -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<string, unknown>) => {
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<Record<string, unknown>> = [
{ 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<Record<string, unknown>> = 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<string, unknown>) { stored.push({ ...data }); return { ...data }; },
async update(_object: string, data: Record<string, unknown>, 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<Record<string, unknown>>).map((r) => r.organization_id ?? null)).toEqual([null]);
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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); } },
Expand Down
Loading
Loading