diff --git a/.changeset/24865-auth-sms-template-organization.md b/.changeset/24865-auth-sms-template-organization.md new file mode 100644 index 00000000000..88674da6529 --- /dev/null +++ b/.changeset/24865-auth-sms-template-organization.md @@ -0,0 +1,14 @@ +--- +'@objectstack/plugin-auth': patch +--- + +fix(plugin-auth): the phone OTP and invitation SMS read their template row in the organization the send resolves to + +Clause-②: no + +`sys_notification_template` is organization-owned (ADR-0131: organization ownership is total). The phone OTP and invitation SMS are deployment-level sends whose callbacks carry no organization, so their template row is now read in the organization that act resolves to, the tenancy service's `defaultOrgId()` (ADR-0093 D3), the same answer the membership reconciler binds a new user to: + +- **`single`:** the Default Organization's `sys_notification_template` row for `(auth.phone_otp | auth.phone_invite, sms, locale)` decides the text, as an operator authored it in Setup. +- **`isolated` and `group`:** no organization is resolved, so no template row is read and the built-in text is sent. No organization's row decides the text a user receives. +- **No tenancy service, or a failed resolution:** the built-in text. A template outage still never blocks a send. +- **Unchanged:** the locale ladder (the recipient's stored locale, then the deployment default, then `en`), how a deactivated or blank row passes its rung on, the built-in texts themselves, and the rendered holes. diff --git a/.changeset/24865-template-recipient-organization.md b/.changeset/24865-template-recipient-organization.md new file mode 100644 index 00000000000..296f9aa9251 --- /dev/null +++ b/.changeset/24865-template-recipient-organization.md @@ -0,0 +1,13 @@ +--- +'@objectstack/service-messaging': minor +--- + +fix(service-messaging): a notification template is resolved in the recipient's organization + +Clause-②: yes (widening) — `NotificationTemplateStore.load` (exported from the package's `.` entry) gains an optional fourth parameter, `organizationId`. + +`sys_notification_template` is organization-owned (ADR-0131: organization ownership is total). The email and SMS channels now render the template row of the delivery's own organization: `NotificationTemplateStore.load(topic, channel, locale, organizationId)` keys its read by the organization the delivery names (`Notification.organizationId`, the organization the notification event row carries), the same way the preference resolver scopes its rows. + +- **An organization with no row of its own** for a `(topic, channel, locale)` renders the notification's own title and body, the documented no-template fallback. +- **The new parameter:** `NotificationTemplateStore.load(topic, channel, locale, organizationId?)`. A call that names no organization reads no row and answers `null`, so the channel renders that same fallback. Every delivery the messaging service fans out names its organization (the event row takes it from the producer or from the engine's system-write rule, and an emit with neither is refused), so this changes nothing a composed deployment renders. A direct caller of `NotificationTemplateStore.load` passes the recipient's organization as the fourth argument. +- **Unchanged:** the locale ladder (the recipient's locale, its language, then `en`), the `is_active` filter, how a row renders, and the best-effort answer on a failed lookup. diff --git a/packages/plugins/plugin-auth/src/auth-manager.test.ts b/packages/plugins/plugin-auth/src/auth-manager.test.ts index cc689f88f43..c0cddcbb4d9 100644 --- a/packages/plugins/plugin-auth/src/auth-manager.test.ts +++ b/packages/plugins/plugin-auth/src/auth-manager.test.ts @@ -2688,11 +2688,16 @@ describe('AuthManager', () => { }); it('a tenant sys_notification_template row overrides the built-in text', async () => { + // The row is read in the organization the send resolves to, + // `tenancy.defaultOrgId()` — the Default Organization under `single` + // (phone-sms-texts.ts, "Which organization's row"). const { manager, opts } = await bootOtp({ + getTenancy: () => ({ defaultOrgId: async () => 'org_default' }), dataEngine: { async find(object: string, q: any) { if ( object === 'sys_notification_template' && + q?.where?.organization_id === 'org_default' && q?.where?.topic === 'auth.phone_otp' && q?.where?.channel === 'sms' && q?.where?.locale === 'zh-CN' diff --git a/packages/plugins/plugin-auth/src/auth-manager.ts b/packages/plugins/plugin-auth/src/auth-manager.ts index fc103b25b5d..87392c6086b 100644 --- a/packages/plugins/plugin-auth/src/auth-manager.ts +++ b/packages/plugins/plugin-auth/src/auth-manager.ts @@ -6453,6 +6453,14 @@ export class AuthManager { * gets exactly the deployment default, as before commit 35e94c96b. #14641 made the * SMS invite path a passer rather than an abstainer; it is no longer the * standing example of a caller that names nothing. + * + * The template row is read in the organization this deployment-level act + * resolves to — `tenancy.defaultOrgId()`, the same answer the membership + * reconciler binds a new user to (ADR-0093 D3): the Default Organization + * when no wall is requested, `null` under a requested wall, where no row is + * read and the built-in text is sent (`phone-sms-texts.ts`, "Which + * organization's row"). No tenancy service, or a failed resolution, is + * `null` too: the built-in text, never a blocked send. */ private async renderPhoneSmsBody( topic: string, @@ -6460,7 +6468,13 @@ export class AuthManager { storedLocale?: string, ): Promise { const locale = storedLocale ?? this.smsLocale; - const template = await resolvePhoneSmsTemplateBody(this.getDataEngine(), topic, locale); + let organizationId: string | null = null; + try { + organizationId = (await this.config.getTenancy?.()?.defaultOrgId()) ?? null; + } catch { + organizationId = null; + } + const template = await resolvePhoneSmsTemplateBody(this.getDataEngine(), topic, locale, organizationId); return interpolatePhoneSms(template, data); } diff --git a/packages/plugins/plugin-auth/src/find-envelope-limb-removal.test.ts b/packages/plugins/plugin-auth/src/find-envelope-limb-removal.test.ts index 027d917ca90..5d6b4fc808c 100644 --- a/packages/plugins/plugin-auth/src/find-envelope-limb-removal.test.ts +++ b/packages/plugins/plugin-auth/src/find-envelope-limb-removal.test.ts @@ -154,7 +154,7 @@ async function seedAll(engine: ObjectQL): Promise { await engine.insert('sys_invitation', { id: 'inv_1', email: 'alice@corp.example', status: 'pending', organization_id: 'org_1', inviter_id: 'usr_1', expires_at: new Date(Date.now() + 86_400_000).toISOString() }, SYSTEM); await engine.insert('sys_permission_set', { id: 'ps_1', name: 'member_default', label: 'Member' }, SYSTEM); await engine.insert('sys_user_permission_set', { id: 'ups_1', user_id: 'usr_1', permission_set_id: 'ps_1' }, SYSTEM); - await engine.insert('sys_notification_template', { id: 'nt_1', topic: 'otp', channel: 'sms', locale: 'en', body: 'code {{code}}', is_active: true }, SYSTEM); + await engine.insert('sys_notification_template', { id: 'nt_1', organization_id: 'org_1', topic: 'otp', channel: 'sms', locale: 'en', body: 'code {{code}}', is_active: true }, SYSTEM); } /** @@ -406,11 +406,11 @@ describe('#15597 — the blocks driven through their real production entry point it('phone-sms template read reads the bare array (B14, the `data` limb)', async () => { const engine = await bootEngine(); await seedAll(engine); - expect(await resolvePhoneSmsTemplateBody(engine as never, 'otp', 'en')).toBe('code {{code}}'); + expect(await resolvePhoneSmsTemplateBody(engine as never, 'otp', 'en', 'org_1')).toBe('code {{code}}'); // No row and no built-in text for the topic: the built-in walk's empty // floor, not a row invented from a misread envelope. (The seeder's // existence read, this site's second `rowsOf` call, retired with the seed.) - expect(await resolvePhoneSmsTemplateBody(engine as never, 'nosuchtopic', 'en')).toBe(''); + expect(await resolvePhoneSmsTemplateBody(engine as never, 'nosuchtopic', 'en', 'org_1')).toBe(''); }); it('tenancy resolveDefaultOrgId reads the bare array (B7)', async () => { diff --git a/packages/plugins/plugin-auth/src/phone-sms-seed-retired.test.ts b/packages/plugins/plugin-auth/src/phone-sms-seed-retired.test.ts index 547ba4a6c56..6e3c292fe9c 100644 --- a/packages/plugins/plugin-auth/src/phone-sms-seed-retired.test.ts +++ b/packages/plugins/plugin-auth/src/phone-sms-seed-retired.test.ts @@ -33,6 +33,12 @@ import { } from './phone-sms-texts.js'; const TEMPLATE_OBJECT = 'sys_notification_template'; +/** + * The organization every store below belongs to — the one-organization world + * the seeded store lived in. Every row is that organization's, and the read + * is handed it, as `AuthManager` hands the Default Organization under `single`. + */ +const ORG = 'org_default'; const SYSTEM_CTX = { isSystem: true, positions: [], permissions: [] } as const; const SECRET = 'test-secret-at-least-32-chars-long'; @@ -53,14 +59,14 @@ function matchesWhere(row: Row, where: Record = {}): boolean { } function store(rows: Row[]) { - const all = rows.map((r) => ({ ...r })); + const all: Row[] = rows.map((r) => ({ organization_id: ORG, ...r })); return { all, async find(_object: string, q: any) { const hit = all.filter((r) => matchesWhere(r, q?.where)); return typeof q?.limit === 'number' ? hit.slice(0, q.limit) : hit; }, - async insert(_object: string, row: Row) { all.push({ ...row }); return row; }, + async insert(_object: string, row: Row) { all.push({ organization_id: ORG, ...row }); return row; }, }; } @@ -131,7 +137,7 @@ describe('the retired auth SMS seed', () => { let compared = 0; for (const topic of TOPICS) { for (const locale of LOCALES) { - const now = await resolvePhoneSmsTemplateBody(store([]), topic, locale); + const now = await resolvePhoneSmsTemplateBody(store([]), topic, locale, ORG); expect(now, `${topic} @ ${String(locale)}`).toBe(await seededWorld([], topic, locale)); // …and that text IS the built-in one for the rung, rendered identically. expect(now).toBe(builtinPhoneSmsBody(topic, locale)); @@ -142,7 +148,7 @@ describe('the retired auth SMS seed', () => { } // Every built-in text is reached: both topics in both bundled languages. const reached = new Set(); - for (const topic of TOPICS) for (const locale of LOCALES) reached.add(await resolvePhoneSmsTemplateBody(store([]), topic, locale)); + for (const topic of TOPICS) for (const locale of LOCALES) reached.add(await resolvePhoneSmsTemplateBody(store([]), topic, locale, ORG)); expect([...reached].sort()).toEqual(BUILTIN_PHONE_SMS_TEMPLATES.map((t) => t.body).sort()); expect(compared).toBe(TOPICS.length * LOCALES.length); }); @@ -161,7 +167,7 @@ describe('the retired auth SMS seed', () => { for (const rows of storesOver(slots)) { for (const topic of TOPICS) { for (const locale of LOCALES) { - const now = await resolvePhoneSmsTemplateBody(store(rows), topic, locale); + const now = await resolvePhoneSmsTemplateBody(store(rows), topic, locale, ORG); const before = await seededWorld(rows, topic, locale); if (now !== before) diverged.push(`${topic} @ ${String(locale)} over ${JSON.stringify(rows)}: ${now} != ${before}`); compared += 1; @@ -175,13 +181,13 @@ describe('the retired auth SMS seed', () => { it('a row an operator authored still wins', async () => { const custom = '【定制】验证码 {{code}}'; const rows = [{ topic: PHONE_SMS_TOPICS.otp, channel: 'sms', locale: 'zh', is_active: true, body: custom }]; - expect(await resolvePhoneSmsTemplateBody(store(rows), PHONE_SMS_TOPICS.otp, 'zh-CN')).toBe(custom); - expect(await resolvePhoneSmsTemplateBody(store(rows), PHONE_SMS_TOPICS.otp, 'zh')).toBe(custom); + expect(await resolvePhoneSmsTemplateBody(store(rows), PHONE_SMS_TOPICS.otp, 'zh-CN', ORG)).toBe(custom); + expect(await resolvePhoneSmsTemplateBody(store(rows), PHONE_SMS_TOPICS.otp, 'zh', ORG)).toBe(custom); // The other topic, and the other language, keep their built-in texts. - expect(await resolvePhoneSmsTemplateBody(store(rows), PHONE_SMS_TOPICS.invite, 'zh')).toBe( + expect(await resolvePhoneSmsTemplateBody(store(rows), PHONE_SMS_TOPICS.invite, 'zh', ORG)).toBe( builtinPhoneSmsBody(PHONE_SMS_TOPICS.invite, 'zh'), ); - expect(await resolvePhoneSmsTemplateBody(store(rows), PHONE_SMS_TOPICS.otp, 'en')).toBe( + expect(await resolvePhoneSmsTemplateBody(store(rows), PHONE_SMS_TOPICS.otp, 'en', ORG)).toBe( builtinPhoneSmsBody(PHONE_SMS_TOPICS.otp, 'en'), ); }); diff --git a/packages/plugins/plugin-auth/src/phone-sms-template-organization.integration.test.ts b/packages/plugins/plugin-auth/src/phone-sms-template-organization.integration.test.ts new file mode 100644 index 00000000000..e7bdb210977 --- /dev/null +++ b/packages/plugins/plugin-auth/src/phone-sms-template-organization.integration.test.ts @@ -0,0 +1,159 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * An auth SMS reads its template row in the organization the send resolves to. + * + * `sys_notification_template` is organization-owned (ADR-0131: a row is the + * organization that authored it). The phone OTP and invitation SMS are + * deployment-level acts whose send callbacks carry no organization, so + * `AuthManager.renderPhoneSmsBody` hands the resolver the organization that + * act resolves to, `tenancy.defaultOrgId()` (ADR-0093 D3), and every template + * read is keyed by it (`phone-sms-texts.ts`, "Which organization's row"): + * + * - `single`: the Default Organization's row decides the text; + * - `isolated` and `group`: `defaultOrgId()` is `null`, so no row is read and + * the built-in text is sent — no organization's row decides it. + * + * Pinned through the real `AuthManager` (the public invitation send and the + * phone OTP send), the real tenancy service, and a real `ObjectQL` over + * `SqlDriver` (better-sqlite3) holding two organizations, each of which + * authored its own rows for both auth topics. + */ + +import { describe, it, expect, afterEach } from 'vitest'; +import { ObjectQL } from '@objectstack/objectql'; +import { SqlDriver } from '@objectstack/driver-sql'; +import { NotificationTemplate } from '@objectstack/service-messaging'; +import { AuthManager } from './auth-manager.js'; +import { authIdentityObjects } from './manifest.js'; +import { createTenancyService } from './tenancy-service.js'; +import { PHONE_SMS_TOPICS, builtinPhoneSmsBody, interpolatePhoneSms } from './phone-sms-texts.js'; + +const SECRET = 'test-secret-at-least-32-chars-long-24865'; +const ORIGIN = 'http://localhost:3000'; +const PHONE = '+15550124865'; +const CODE = '482913'; +const TEMPLATE_OBJECT = 'sys_notification_template'; +const SYSTEM = { context: { isSystem: true } } as never; + +type Posture = 'single' | 'isolated' | 'group'; + +const engines: ObjectQL[] = []; +afterEach(async () => { + while (engines.length) { + try { await engines.pop()!.destroy(); } catch { /* noop */ } + } +}); + +/** + * A real engine on `posture` holding the Default Organization and one more, + * each of which authored its own row for both auth SMS topics, and the + * recipient's account. + */ +async function bootEngine(posture: Posture): Promise { + const engine = new ObjectQL(); + engines.push(engine); + engine.registerDriver(new SqlDriver({ client: 'better-sqlite3', connection: { filename: ':memory:' }, useNullAsDefault: true }), true); + await engine.init(); + for (const object of authIdentityObjects) engine.registry.registerObject(object as never, '@objectstack/plugin-auth'); + engine.registry.registerObject(NotificationTemplate as never, '@objectstack/service-messaging'); + await engine.syncSchemas(); + engine.setTenancyPostureProvider(() => posture); + + await engine.insert('sys_organization', { id: 'org_default', name: 'Default Organization', slug: 'default' }, SYSTEM); + await engine.insert('sys_organization', { id: 'org_other', name: 'Other', slug: 'other' }, SYSTEM); + for (const organization of ['org_default', 'org_other']) { + for (const topic of [PHONE_SMS_TOPICS.otp, PHONE_SMS_TOPICS.invite]) { + // Written as that organization's write lands. + await engine.insert( + TEMPLATE_OBJECT, + { topic, channel: 'sms', locale: 'en', body: `${organization} ${topic} {{code}}`, format: 'text', is_active: true }, + { context: { isSystem: true, tenantId: organization } } as never, + ); + } + } + await engine.insert('sys_user', { id: 'usr_recipient', name: 'Recipient', email: 'recipient@example.test', phone_number: PHONE, locale: 'en' }, SYSTEM); + return engine; +} + +/** What each template row is stored with — the fixture's own control. */ +async function storedOrganizations(engine: ObjectQL): Promise { + const rows = (await engine.find(TEMPLATE_OBJECT, { fields: ['topic', 'organization_id'] }, SYSTEM)) ?? []; + return rows.map((r: Record) => `${String(r.organization_id ?? 'NULL')}:${String(r.topic)}`).sort(); +} + +/** The real manager over `engine`, with the real tenancy service and an SMS transport that records each body. */ +function bootManager(engine: ObjectQL, posture: Posture) { + const tenancy = createTenancyService({ + requested: posture, + probeIsolation: () => true, + getEngine: () => engine, + logger: { info: () => {}, warn: () => {} }, + }); + const bodies: string[] = []; + const manager = new AuthManager({ + secret: SECRET, + baseUrl: ORIGIN, + dataEngine: engine, + plugins: { phoneNumber: true }, + smsService: { + async send(input: { body: string }) { bodies.push(input.body); return { status: 'sent', messageId: 'm' }; }, + isConfigured: () => true, + }, + getTenancy: () => tenancy, + } as never); + return { manager, bodies, tenancy }; +} + +/** Both auth SMS sends: the public invitation, and the phone OTP the sign-in callbacks drive. */ +async function sendBoth(manager: AuthManager): Promise { + await manager.sendPhoneInviteSms(PHONE); + await (manager as unknown as { deliverPhoneOtp(phone: string, code: string): Promise }).deliverPhoneOtp(PHONE, CODE); +} + +/** The built-in `en` text for `topic` with every hole open — the invitation's URLs are the manager's own. */ +function builtinShape(topic: string): RegExp { + const escaped = builtinPhoneSmsBody(topic, 'en') + .split(/\{\{\s*[\w.$]+\s*\}\}/) + .map((literal) => literal.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')); + return new RegExp(`^${escaped.join('.+')}$`); +} + +const EXPECTED_ROWS = [ + `org_default:${PHONE_SMS_TOPICS.invite}`, `org_default:${PHONE_SMS_TOPICS.otp}`, + `org_other:${PHONE_SMS_TOPICS.invite}`, `org_other:${PHONE_SMS_TOPICS.otp}`, +]; + +describe("an auth SMS reads its template row in the organization the send resolves to", () => { + it("single: the Default Organization's row decides the invitation and the OTP text", async () => { + const engine = await bootEngine('single'); + expect(await storedOrganizations(engine)).toEqual(EXPECTED_ROWS); + const { manager, bodies, tenancy } = bootManager(engine, 'single'); + expect(await tenancy.defaultOrgId()).toBe('org_default'); + + await sendBoth(manager); + + expect(bodies).toEqual([ + `org_default ${PHONE_SMS_TOPICS.invite} `, + `org_default ${PHONE_SMS_TOPICS.otp} ${CODE}`, + ]); + }); + + for (const posture of ['isolated', 'group'] as const) { + it(`${posture}: the built-in text is sent, and no organization's row decides it`, async () => { + const engine = await bootEngine(posture); + expect(await storedOrganizations(engine)).toEqual(EXPECTED_ROWS); + const { manager, bodies, tenancy } = bootManager(engine, posture); + expect(await tenancy.defaultOrgId()).toBeNull(); + + await sendBoth(manager); + + expect(bodies).toHaveLength(2); + expect(bodies[1]).toBe( + interpolatePhoneSms(builtinPhoneSmsBody(PHONE_SMS_TOPICS.otp, 'en'), { code: CODE, appName: 'ObjectStack', minutes: 5 }), + ); + expect(bodies[0]).toMatch(builtinShape(PHONE_SMS_TOPICS.invite)); + for (const body of bodies) expect(body).not.toMatch(/org_default|org_other/); + }); + } +}); diff --git a/packages/plugins/plugin-auth/src/phone-sms-texts.test.ts b/packages/plugins/plugin-auth/src/phone-sms-texts.test.ts index 2fa456f05e0..39e80074f9b 100644 --- a/packages/plugins/plugin-auth/src/phone-sms-texts.test.ts +++ b/packages/plugins/plugin-auth/src/phone-sms-texts.test.ts @@ -69,16 +69,16 @@ describe('resolvePhoneSmsTemplateBody', () => { it('returns the tenant row for the exact locale', async () => { const engine = engineWith([ - { topic: 'auth.phone_otp', channel: 'sms', locale: 'zh-CN', is_active: true, body: '自定义 {{code}}' }, + { organization_id: 'org_1', topic: 'auth.phone_otp', channel: 'sms', locale: 'zh-CN', is_active: true, body: '自定义 {{code}}' }, ]); - await expect(resolvePhoneSmsTemplateBody(engine, 'auth.phone_otp', 'zh-CN')).resolves.toBe('自定义 {{code}}'); + await expect(resolvePhoneSmsTemplateBody(engine, 'auth.phone_otp', 'zh-CN', 'org_1')).resolves.toBe('自定义 {{code}}'); }); it('walks the locale chain (zh-CN → zh) to a row', async () => { const engine = engineWith([ - { topic: 'auth.phone_otp', channel: 'sms', locale: 'zh', is_active: true, body: 'zh 行 {{code}}' }, + { organization_id: 'org_1', topic: 'auth.phone_otp', channel: 'sms', locale: 'zh', is_active: true, body: 'zh 行 {{code}}' }, ]); - await expect(resolvePhoneSmsTemplateBody(engine, 'auth.phone_otp', 'zh-CN')).resolves.toBe('zh 行 {{code}}'); + await expect(resolvePhoneSmsTemplateBody(engine, 'auth.phone_otp', 'zh-CN', 'org_1')).resolves.toBe('zh 行 {{code}}'); }); it('renders the built-in text at the first rung that has one and no row', async () => { @@ -86,23 +86,56 @@ describe('resolvePhoneSmsTemplateBody', () => { // An `en` row is NOT reached for a zh-CN recipient: the `zh` rung has a // built-in text and no row, which is what the retired seed's `zh` row was. const engine = engineWith([ - { topic: 'auth.phone_otp', channel: 'sms', locale: 'en', is_active: true, body: 'en row {{code}}' }, + { organization_id: 'org_1', topic: 'auth.phone_otp', channel: 'sms', locale: 'en', is_active: true, body: 'en row {{code}}' }, ]); - await expect(resolvePhoneSmsTemplateBody(engine, 'auth.phone_otp', 'zh-CN')).resolves.toBe(zh); + await expect(resolvePhoneSmsTemplateBody(engine, 'auth.phone_otp', 'zh-CN', 'org_1')).resolves.toBe(zh); }); it('passes a deactivated row\'s rung on, as the seeded store did', async () => { const engine = engineWith([ - { topic: 'auth.phone_otp', channel: 'sms', locale: 'zh', is_active: false, body: '停用 {{code}}' }, - { topic: 'auth.phone_otp', channel: 'sms', locale: 'en', is_active: true, body: 'en row {{code}}' }, + { organization_id: 'org_1', topic: 'auth.phone_otp', channel: 'sms', locale: 'zh', is_active: false, body: '停用 {{code}}' }, + { organization_id: 'org_1', topic: 'auth.phone_otp', channel: 'sms', locale: 'en', is_active: true, body: 'en row {{code}}' }, ]); - await expect(resolvePhoneSmsTemplateBody(engine, 'auth.phone_otp', 'zh-CN')).resolves.toBe('en row {{code}}'); + await expect(resolvePhoneSmsTemplateBody(engine, 'auth.phone_otp', 'zh-CN', 'org_1')).resolves.toBe('en row {{code}}'); }); it('falls back to the built-in walk with no engine, or a broken lookup', async () => { const zh = builtinPhoneSmsBody(PHONE_SMS_TOPICS.otp, 'zh'); - await expect(resolvePhoneSmsTemplateBody(undefined, 'auth.phone_otp', 'zh')).resolves.toBe(zh); + await expect(resolvePhoneSmsTemplateBody(undefined, 'auth.phone_otp', 'zh', 'org_1')).resolves.toBe(zh); const broken = { find: vi.fn(async () => { throw new Error('no such table'); }) }; - await expect(resolvePhoneSmsTemplateBody(broken, 'auth.phone_otp', 'zh')).resolves.toBe(zh); + await expect(resolvePhoneSmsTemplateBody(broken, 'auth.phone_otp', 'zh', 'org_1')).resolves.toBe(zh); + }); + + // ADR-0131: the table is organization-owned, so an auth SMS reads its row in + // the organization the send resolves to (`tenancy.defaultOrgId()`, handed in + // by AuthManager). The real-engine reading per posture is + // `phone-sms-template-organization.integration.test.ts`. + describe('an auth SMS reads its template row in the organization the send resolves to', () => { + it('keys every read by the organization handed in', async () => { + const engine = engineWith([]); + await resolvePhoneSmsTemplateBody(engine, 'auth.phone_otp', 'zh-CN', 'org_1'); + const wheres = engine.find.mock.calls.map(([, q]) => q.where); + expect(wheres.length).toBeGreaterThan(0); + for (const where of wheres) expect(where.organization_id).toBe('org_1'); + }); + + it("another organization's row never decides the text", async () => { + const engine = engineWith([ + { organization_id: 'org_2', topic: 'auth.phone_otp', channel: 'sms', locale: 'en', is_active: true, body: 'org_2 row {{code}}' }, + ]); + await expect(resolvePhoneSmsTemplateBody(engine, 'auth.phone_otp', 'en', 'org_1')).resolves.toBe( + builtinPhoneSmsBody(PHONE_SMS_TOPICS.otp, 'en'), + ); + }); + + it('with no organization, no row is read and the built-in text is sent', async () => { + const engine = engineWith([ + { organization_id: 'org_1', topic: 'auth.phone_otp', channel: 'sms', locale: 'en', is_active: true, body: 'org_1 row {{code}}' }, + ]); + const builtin = builtinPhoneSmsBody(PHONE_SMS_TOPICS.otp, 'en'); + await expect(resolvePhoneSmsTemplateBody(engine, 'auth.phone_otp', 'en', null)).resolves.toBe(builtin); + await expect(resolvePhoneSmsTemplateBody(engine, 'auth.phone_otp', 'en', undefined)).resolves.toBe(builtin); + expect(engine.find).not.toHaveBeenCalled(); + }); }); }); diff --git a/packages/plugins/plugin-auth/src/phone-sms-texts.ts b/packages/plugins/plugin-auth/src/phone-sms-texts.ts index cb983c7076e..b11641cd060 100644 --- a/packages/plugins/plugin-auth/src/phone-sms-texts.ts +++ b/packages/plugins/plugin-auth/src/phone-sms-texts.ts @@ -8,9 +8,10 @@ * {@link resolvePhoneSmsTemplateBody}: * * 1. **A tenant template row** — a `sys_notification_template` row for - * `(topic, channel:'sms', locale)`, the same object the messaging `sms` - * channel renders. Operators author them in Setup; a row at a rung - * decides that rung. + * `(topic, channel:'sms', locale)` in the organization the send resolves + * to (see "Which organization's row" below), the same object the + * messaging `sms` channel renders. Operators author them in Setup; a row + * at a rung decides that rung. * 2. **The built-in text at that rung** — the bundled texts here (en + zh), * when no row exists at that locale. * @@ -44,6 +45,24 @@ * * Red line unchanged: the OTP code appears only in the rendered body handed * to the SMS service — never in logs or error messages. + * + * ## Which organization's row (ADR-0131, ADR-0093 D3) + * + * `sys_notification_template` is organization-owned: a row is the organization + * that authored it (ADR-0131 D1/D3). An auth SMS is a deployment-level act — + * the send callbacks carry no organization — so the caller hands in the + * organization that act resolves to, `tenancy.defaultOrgId()`: the Default + * Organization when no wall is requested, and `null` under a requested wall, + * where the framework never guesses an organization (ADR-0093 D3). + * + * - An organization ⇒ every template read below is keyed + * `where: { organization_id }`. ⛔ Never drop the term: the read runs under + * the explicit system opt-in, which names no organization, so the term IS + * the organization boundary. + * - No organization ⇒ no row is read and the built-in walk answers. The + * per-organization template door stays closed under a wall (ADR-0131 §1.4, + * D6): no organization's row decides the text another organization's user + * receives. */ /** Topics the auth phone SMS templates live under. */ @@ -186,6 +205,10 @@ async function firstTemplateRow( * If no rung answers, the built-in walk ({@link builtinPhoneSmsBody}) is the * floor, as it is when the template read fails (missing table, no engine). * + * Every read is keyed by `organizationId`, the organization the send resolves + * to; with none (`null` / `undefined`), no row is read and the built-in walk + * answers — see "Which organization's row" in the module doc. + * * This is exactly what the retired boot seed rendered. That seed inserted the * built-in text at every built-in `(topic, locale)` that held NO row, active or * not, and the read then took the first active, non-blank row along the chain, @@ -201,11 +224,12 @@ export async function resolvePhoneSmsTemplateBody( engine: PhoneSmsTemplateEngine | undefined, topic: string, locale: string | undefined, + organizationId: string | null | undefined, ): Promise { - if (!engine) return builtinPhoneSmsBody(topic, locale); + if (!engine || !organizationId) return builtinPhoneSmsBody(topic, locale); try { for (const loc of phoneSmsLocaleChain(locale)) { - const where = { topic, channel: 'sms', locale: loc }; + const where = { organization_id: organizationId, topic, channel: 'sms', locale: loc }; const active = await firstTemplateRow(engine, { ...where, is_active: true }); const body = active?.body; if (typeof body === 'string' && body.trim()) return body; diff --git a/packages/services/service-messaging/src/email-channel.test.ts b/packages/services/service-messaging/src/email-channel.test.ts index c72653b8c24..7893288694f 100644 --- a/packages/services/service-messaging/src/email-channel.test.ts +++ b/packages/services/service-messaging/src/email-channel.test.ts @@ -36,7 +36,8 @@ type FakeUser = string | { email: string; locale?: unknown }; /** Fake data engine: user id → email (+ locale), and template lookups. */ function fakeData(opts: { users?: Record; templates?: any[] } = {}) { const users = opts.users ?? { user_1: 'ada@example.com' }; - const templates = opts.templates ?? []; + // A fixture row is the delivery organization's unless it names another. + const templates = (opts.templates ?? []).map((t) => ({ organization_id: 'org_1', ...t })); const findOnes: Array<{ object: string; query: any }> = []; return { findOnes, @@ -50,7 +51,7 @@ function fakeData(opts: { users?: Record; templates?: any[] } return typeof user === 'string' ? { email: user } : { ...user }; } if (object === 'sys_notification_template') { - return templates.find((t) => t.topic === w.topic && t.channel === w.channel && t.locale === w.locale && t.is_active) ?? null; + return templates.find((t) => t.organization_id === w.organization_id && t.topic === w.topic && t.channel === w.channel && t.locale === w.locale && t.is_active) ?? null; } return null; }, @@ -206,8 +207,10 @@ describe('email channel', () => { templates: [{ topic: 'deal.won', channel: 'email', locale: 'en', is_active: true, subject: 'Won {{ payload.title }}', body: '

{{ payload.title }}

', format: 'html' }], }); const ch = channel(() => email.service, data); - await ch.send(silentCtx(), delivery()); - expect(email.sent[0]).toEqual({ to: 'ada@example.com', subject: 'Won Deal closed', html: '

Deal closed

' }); + // The row is the delivery organization's (a template is resolved in + // the recipient's organization), so the delivery names it. + await ch.send(silentCtx(), delivery({ organizationId: 'org_1' })); + expect(email.sent[0]).toEqual({ to: 'ada@example.com', subject: 'Won Deal closed', html: '

Deal closed

', organizationId: 'org_1' }); }); it('accepts an email-shaped recipient verbatim (no user lookup)', async () => { @@ -548,8 +551,8 @@ describe('email channel', () => { ], }); const ch = build(data, email, 'ja-JP'); - await ch.send(silentCtx(), delivery({}, 'ada')); - await ch.send(silentCtx(), delivery({}, 'bob')); + await ch.send(silentCtx(), delivery({ organizationId: 'org_1' }, 'ada')); + await ch.send(silentCtx(), delivery({ organizationId: 'org_1' }, 'bob')); expect(email.sent.map((s) => [s.to, s.subject])).toEqual([ ['ada@example.com', '成交 Deal closed'], // her own locale ['bob@example.com', '成約 Deal closed'], // no locale ⇒ deployment default @@ -562,7 +565,7 @@ describe('email channel', () => { users: { user_1: { email: 'ada@example.com', locale: 'undefined' } }, templates: [{ topic: 'deal.won', channel: 'email', locale: 'en', is_active: true, subject: 'Won {{ payload.title }}', body: 'en body', format: 'text' }], }); - await build(data, email).send(silentCtx(), delivery()); + await build(data, email).send(silentCtx(), delivery({ organizationId: 'org_1' })); expect(email.sent[0].subject).toBe('Won Deal closed'); }); @@ -575,7 +578,7 @@ describe('email channel', () => { { topic: 'deal.won', channel: 'email', locale: 'es-ES', is_active: true, subject: 'Ganado', body: 'es', format: 'text' }, ], }); - await build(data, email).send(silentCtx(), delivery({ payload: { title: 'x', body: 'y', locale: 'es-ES' } })); + await build(data, email).send(silentCtx(), delivery({ organizationId: 'org_1', payload: { title: 'x', body: 'y', locale: 'es-ES' } })); expect(email.sent[0].subject).toBe('成交'); }); }); diff --git a/packages/services/service-messaging/src/email-channel.ts b/packages/services/service-messaging/src/email-channel.ts index 258368b7e17..27d17e92d36 100644 --- a/packages/services/service-messaging/src/email-channel.ts +++ b/packages/services/service-messaging/src/email-channel.ts @@ -373,7 +373,9 @@ export function createEmailChannel(opts: EmailChannelOptions): MessagingChannel // arm (#13881); the store walks its own ladder (`zh-CN` → `zh` → // DEFAULT_LOCALE) underneath, so a tag with no row still renders. const locale = recipientLocale ?? defaultLocale; - const template = await opts.store.load(n.topic ?? '', 'email', locale); + // The row is the recipient organization's: the delivery's own + // organization keys the read (see NotificationTemplateStore). + const template = await opts.store.load(n.topic ?? '', 'email', locale, n.organizationId); const rendered = renderNotification(template, { topic: n.topic ?? '', payload, diff --git a/packages/services/service-messaging/src/sms-channel.test.ts b/packages/services/service-messaging/src/sms-channel.test.ts index 33d646f89cf..a3d9aaed42b 100644 --- a/packages/services/service-messaging/src/sms-channel.test.ts +++ b/packages/services/service-messaging/src/sms-channel.test.ts @@ -19,6 +19,9 @@ function delivery(over: Partial = {}, recipient = 'use recipient, notification: { notificationId: 'evt_1', + // Every composed delivery names its organization (the event row's); + // the template rows below are that organization's. + organizationId: 'org_1', topic: 'deal.won', title: 'Deal closed', body: 'Acme signed', @@ -36,7 +39,8 @@ type FakeUser = string | { phone_number: string; locale?: unknown }; /** Fake data engine: user id → phone_number (+ locale), and template lookups. */ function fakeData(opts: { users?: Record; templates?: any[] } = {}) { const users = opts.users ?? { user_1: '+8613800000000' }; - const templates = opts.templates ?? []; + // A fixture row is the delivery organization's unless it names another. + const templates = (opts.templates ?? []).map((t) => ({ organization_id: 'org_1', ...t })); const findOnes: Array<{ object: string; query: any }> = []; return { findOnes, @@ -50,7 +54,7 @@ function fakeData(opts: { users?: Record; templates?: any[] } return typeof user === 'string' ? { phone_number: user } : { ...user }; } if (object === 'sys_notification_template') { - return templates.find((t) => t.topic === w.topic && t.channel === w.channel && t.locale === w.locale && t.is_active) ?? null; + return templates.find((t) => t.organization_id === w.organization_id && t.topic === w.topic && t.channel === w.channel && t.locale === w.locale && t.is_active) ?? null; } return null; }, diff --git a/packages/services/service-messaging/src/sms-channel.ts b/packages/services/service-messaging/src/sms-channel.ts index 4fbbcaf78e5..319779bd543 100644 --- a/packages/services/service-messaging/src/sms-channel.ts +++ b/packages/services/service-messaging/src/sms-channel.ts @@ -280,7 +280,9 @@ export function createSmsChannel(opts: SmsChannelOptions): MessagingChannel { // channel's static default; the store walks its own ladder // underneath. `payload.locale` is no longer consulted. const locale = resolved.locale ?? defaultLocale; - const template = await opts.store.load(n.topic ?? '', 'sms', locale); + // The row is the recipient organization's: the delivery's own + // organization keys the read (see NotificationTemplateStore). + const template = await opts.store.load(n.topic ?? '', 'sms', locale, n.organizationId); const rendered = renderNotification(template, { topic: n.topic ?? '', payload, diff --git a/packages/services/service-messaging/src/system-context.pin.test.ts b/packages/services/service-messaging/src/system-context.pin.test.ts index c05b26410e0..f9522a3cb3d 100644 --- a/packages/services/service-messaging/src/system-context.pin.test.ts +++ b/packages/services/service-messaging/src/system-context.pin.test.ts @@ -409,7 +409,9 @@ describe('[#21908] rows 24 onward — the remaining fan-out and outbox calls car const store = new NotificationTemplateStore({ getData: () => engine }); const email = createEmailChannel({ getEmail: () => ({ async send() { return { id: 'e1' }; } }) as any, getData: () => engine, store }); const sms = createSmsChannel({ getSms: () => ({ async send() { return { id: 's1' }; } }) as any, getData: () => engine, store }); - const notification = { title: 't', body: 'b', recipients: ['u1'], topic: 'deal.won' }; + // The delivery names its organization, so the template read happens + // (a call that names none reads no row — NotificationTemplateStore). + const notification = { title: 't', body: 'b', recipients: ['u1'], topic: 'deal.won', organizationId: 'org_1' }; expect((await email.send({ logger: silentLogger() }, { notification, channel: 'email', recipient: 'u1' } as any)).ok).toBe(true); expect((await sms.send({ logger: silentLogger() }, { notification, channel: 'sms', recipient: 'u1' } as any)).ok).toBe(true); diff --git a/packages/services/service-messaging/src/template-organization.integration.test.ts b/packages/services/service-messaging/src/template-organization.integration.test.ts new file mode 100644 index 00000000000..85de401c626 --- /dev/null +++ b/packages/services/service-messaging/src/template-organization.integration.test.ts @@ -0,0 +1,210 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * A notification template is resolved in the recipient's organization. + * + * `sys_notification_template` is organization-owned (ADR-0131: organization + * ownership is total — the table carries `organization_id` and the + * platform-object tenancy census lists it), so the row a delivery renders is + * one the delivery's own organization authored. `NotificationTemplateStore` + * keys its read by the delivery's organization, the spelling + * `PreferenceResolver` scopes its rows by; a call that names no organization + * reads no row. + * + * Pinned on a real `ObjectQL` over `SqlDriver` (better-sqlite3) holding two + * organizations, under each walled posture, through the real email and SMS + * channels: + * + * - each organization's delivery renders its own organization's row; + * - an organization that authored no row renders the producer's own title + * and body (the renderer's documented no-template fallback); + * - a delivery that names no organization renders that same fallback. + * + * And the preservation half, under `single`: the sole organization's row + * still renders for its own deliveries. + */ + +import { describe, it, expect, afterEach } from 'vitest'; +import { ObjectQL } from '@objectstack/objectql'; +import type { ExecutionContext } from '@objectstack/spec/kernel'; +import { SqlDriver } from '@objectstack/driver-sql'; +import { NotificationTemplate } from './objects/index.js'; +import { NotificationTemplateStore, TEMPLATE_OBJECT } from './template-renderer.js'; +import { createEmailChannel } from './email-channel.js'; +import { createSmsChannel } from './sms-channel.js'; +import type { Delivery, MessagingChannel } from './channel.js'; + +// Declared as the platform declares them: better-auth owns both tables, so the +// registration injects no organization column. +const SYS_ORGANIZATION = { name: 'sys_organization', label: 'Organization', managedBy: 'better-auth', fields: { name: { type: 'text', label: 'Name' } } }; +const SYS_USER = { + name: 'sys_user', + label: 'User', + managedBy: 'better-auth', + fields: { + name: { type: 'text', label: 'Name' }, + email: { type: 'text', label: 'Email' }, + phone_number: { type: 'text', label: 'Phone' }, + locale: { type: 'text', label: 'Locale' }, + }, +}; + +const SYSTEM = { context: { isSystem: true } as ExecutionContext }; +const TOPIC = 'deal.won'; +const silentLogger: any = { info: () => {}, warn: () => {}, error: () => {}, debug: () => {} }; +silentLogger.child = () => silentLogger; +const channelCtx = { logger: silentLogger }; + +let engine: ObjectQL | undefined; + +afterEach(async () => { + try { await engine?.destroy(); } catch { /* noop */ } + engine = undefined; +}); + +type Posture = 'isolated' | 'group' | 'single'; + +/** A real engine on `posture` holding `organizations`, and one recipient per organization. */ +async function boot(posture: Posture, organizations: readonly string[]): Promise { + const ql = new ObjectQL(); + ql.registerDriver(new SqlDriver({ client: 'better-sqlite3', connection: { filename: ':memory:' }, useNullAsDefault: true }), true); + await ql.init(); + for (const def of [SYS_ORGANIZATION, SYS_USER, NotificationTemplate]) { + ql.registry.registerObject(def as any, '@objectstack/service-messaging'); + } + await ql.syncSchemas(); + ql.setTenancyPostureProvider(() => posture); + for (const [i, id] of organizations.entries()) { + await ql.insert('sys_organization', { id, name: id }, SYSTEM); + await ql.insert('sys_user', { id: `user_${id}`, name: id, email: `${id}@example.test`, phone_number: `+1555000000${i}`, locale: 'en' }, SYSTEM); + } + engine = ql; + return ql; +} + +/** The row `organization` authors in Setup for (TOPIC, channel, 'en'), written as that organization's write lands. */ +async function author(ql: ObjectQL, organization: string, channel: 'email' | 'sms', text: string): Promise { + await ql.insert( + TEMPLATE_OBJECT, + { topic: TOPIC, channel, locale: 'en', subject: `${text} subject`, body: `${text} body`, format: 'text', is_active: true }, + { context: { isSystem: true, tenantId: organization } as ExecutionContext }, + ); +} + +/** What each row is stored with — the fixture's own control, so no pin passes on a mis-stamped row. */ +async function storedOrganizations(ql: ObjectQL): Promise { + const rows = (await ql.find(TEMPLATE_OBJECT, { fields: ['channel', 'organization_id'] }, SYSTEM)) ?? []; + return rows.map((r: Record) => `${String(r.channel)}:${String(r.organization_id ?? 'NULL')}`).sort(); +} + +/** The real email + SMS channels over `ql`, with transports that record what they were handed. */ +function channels(ql: ObjectQL) { + const sent: Array<{ channel: string; subject?: string; text: string }> = []; + const store = new NotificationTemplateStore({ getData: () => ql as any }); + const email = createEmailChannel({ + getEmail: () => ({ async send(i: any) { sent.push({ channel: 'email', subject: i.subject, text: i.text ?? i.html }); return { id: 'e' }; } }) as any, + getData: () => ql as any, + store, + }); + const sms = createSmsChannel({ + getSms: () => ({ async send(i: any) { sent.push({ channel: 'sms', text: i.body }); return { messageId: 's' }; } }) as any, + getData: () => ql as any, + store, + }); + return { sent, email, sms }; +} + +function delivery(channel: 'email' | 'sms', organizationId: string | undefined, recipient: string): Delivery { + return { + channel, + recipient, + notification: { + notificationId: 'evt_1', + ...(organizationId !== undefined ? { organizationId } : {}), + topic: TOPIC, + title: 'Producer title', + body: 'Producer body', + recipients: [recipient], + payload: { title: 'Producer title', body: 'Producer body' }, + }, + }; +} + +async function send(ch: MessagingChannel, d: Delivery): Promise { + const result = await ch.send(channelCtx as any, d); + expect(result.ok, `${d.channel} delivery reported failure: ${result.error}`).toBe(true); +} + +describe.each(['isolated', 'group'] as const)("%s, two organizations: a template is resolved in the recipient's organization", (posture) => { + it("each organization's delivery renders its own organization's row, on email and on SMS", async () => { + const ql = await boot(posture, ['org_a', 'org_b']); + for (const org of ['org_a', 'org_b']) { + await author(ql, org, 'email', org); + await author(ql, org, 'sms', org); + } + expect(await storedOrganizations(ql)).toEqual(['email:org_a', 'email:org_b', 'sms:org_a', 'sms:org_b']); + + const { sent, email, sms } = channels(ql); + for (const org of ['org_a', 'org_b']) { + await send(email, delivery('email', org, `${org}@example.test`)); + await send(sms, delivery('sms', org, `user_${org}`)); + } + + expect(sent).toEqual([ + { channel: 'email', subject: 'org_a subject', text: 'org_a body' }, + { channel: 'sms', text: 'org_a body' }, + { channel: 'email', subject: 'org_b subject', text: 'org_b body' }, + { channel: 'sms', text: 'org_b body' }, + ]); + }); + + it("an organization that authored no row renders the producer's own title and body", async () => { + const ql = await boot(posture, ['org_a', 'org_b']); + await author(ql, 'org_a', 'email', 'org_a'); + await author(ql, 'org_a', 'sms', 'org_a'); + expect(await storedOrganizations(ql)).toEqual(['email:org_a', 'sms:org_a']); + + const { sent, email, sms } = channels(ql); + await send(email, delivery('email', 'org_b', 'org_b@example.test')); + await send(sms, delivery('sms', 'org_b', 'user_org_b')); + + expect(sent).toEqual([ + { channel: 'email', subject: 'Producer title', text: 'Producer body' }, + { channel: 'sms', text: 'Producer body' }, + ]); + }); + + it("a delivery that names no organization renders the producer's own title and body", async () => { + const ql = await boot(posture, ['org_a', 'org_b']); + await author(ql, 'org_a', 'email', 'org_a'); + await author(ql, 'org_a', 'sms', 'org_a'); + expect(await storedOrganizations(ql)).toEqual(['email:org_a', 'sms:org_a']); + + const { sent, email, sms } = channels(ql); + await send(email, delivery('email', undefined, 'org_b@example.test')); + await send(sms, delivery('sms', undefined, 'user_org_b')); + + expect(sent).toEqual([ + { channel: 'email', subject: 'Producer title', text: 'Producer body' }, + { channel: 'sms', text: 'Producer body' }, + ]); + }); +}); + +describe("single, one organization: the organization's own row still renders", () => { + it("a delivery of the sole organization renders that organization's row, on email and on SMS", async () => { + const ql = await boot('single', ['org_sole']); + await author(ql, 'org_sole', 'email', 'org_sole'); + await author(ql, 'org_sole', 'sms', 'org_sole'); + expect(await storedOrganizations(ql)).toEqual(['email:org_sole', 'sms:org_sole']); + + const { sent, email, sms } = channels(ql); + await send(email, delivery('email', 'org_sole', 'org_sole@example.test')); + await send(sms, delivery('sms', 'org_sole', 'user_org_sole')); + + expect(sent).toEqual([ + { channel: 'email', subject: 'org_sole subject', text: 'org_sole body' }, + { channel: 'sms', text: 'org_sole body' }, + ]); + }); +}); diff --git a/packages/services/service-messaging/src/template-renderer.test.ts b/packages/services/service-messaging/src/template-renderer.test.ts index b8efd1f4487..c8777cd0e5a 100644 --- a/packages/services/service-messaging/src/template-renderer.test.ts +++ b/packages/services/service-messaging/src/template-renderer.test.ts @@ -75,7 +75,9 @@ describe('NotificationTemplateStore', () => { const w = query?.where ?? {}; return ( rows.find( - (r) => r.topic === w.topic && r.channel === w.channel && r.locale === w.locale && r.is_active, + (r) => + r.organization_id === w.organization_id && + r.topic === w.topic && r.channel === w.channel && r.locale === w.locale && r.is_active, ) ?? null ); }, @@ -91,20 +93,20 @@ describe('NotificationTemplateStore', () => { it('returns null when there is no data engine', async () => { const store = new NotificationTemplateStore({ getData: () => undefined }); - expect(await store.load('t', 'email', 'en')).toBeNull(); + expect(await store.load('t', 'email', 'en', 'org_1')).toBeNull(); }); it('loads the exact (topic, channel, locale) active template', async () => { - const data = fakeData([{ topic: 't', channel: 'email', locale: 'en', is_active: true, subject: 'S', body: 'B' }]); + const data = fakeData([{ organization_id: 'org_1', topic: 't', channel: 'email', locale: 'en', is_active: true, subject: 'S', body: 'B' }]); const store = new NotificationTemplateStore({ getData: () => data.engine }); - const row = await store.load('t', 'email', 'en'); + const row = await store.load('t', 'email', 'en', 'org_1'); expect(row).toMatchObject({ subject: 'S', body: 'B' }); }); it('falls back en-US → en → default locale', async () => { - const data = fakeData([{ topic: 't', channel: 'email', locale: 'en', is_active: true, subject: 'EN' }]); + const data = fakeData([{ organization_id: 'org_1', topic: 't', channel: 'email', locale: 'en', is_active: true, subject: 'EN' }]); const store = new NotificationTemplateStore({ getData: () => data.engine }); - const row = await store.load('t', 'email', 'en-US'); + const row = await store.load('t', 'email', 'en-US', 'org_1'); expect(row).toMatchObject({ subject: 'EN' }); // First tried en-US, then en. expect(data.queries.map((q) => q.where.locale)).toEqual(['en-US', 'en']); @@ -113,6 +115,30 @@ describe('NotificationTemplateStore', () => { it('returns null (generic fallback) on a lookup error', async () => { const engine = { async findOne() { throw new Error('locked'); } } as any; const store = new NotificationTemplateStore({ getData: () => engine }); - expect(await store.load('t', 'email', 'en')).toBeNull(); + expect(await store.load('t', 'email', 'en', 'org_1')).toBeNull(); + }); + + // ADR-0131: the table is organization-owned, so a template is resolved in + // the recipient's organization. The two-organization reading on a real + // engine is `template-organization.integration.test.ts`; these pin the + // read's own shape. + describe("a template is resolved in the recipient's organization", () => { + it('keys every locale rung of the read by the organization it was handed', async () => { + const data = fakeData([]); + const store = new NotificationTemplateStore({ getData: () => data.engine }); + expect(await store.load('t', 'email', 'en-US', 'org_1')).toBeNull(); + expect(data.queries.map((q) => q.where)).toEqual([ + { organization_id: 'org_1', topic: 't', channel: 'email', locale: 'en-US', is_active: true }, + { organization_id: 'org_1', topic: 't', channel: 'email', locale: 'en', is_active: true }, + ]); + }); + + it('a call that names no organization reads no row and answers the generic fallback', async () => { + const data = fakeData([{ organization_id: 'org_1', topic: 't', channel: 'email', locale: 'en', is_active: true, subject: 'S' }]); + const store = new NotificationTemplateStore({ getData: () => data.engine }); + expect(await store.load('t', 'email', 'en')).toBeNull(); + expect(await store.load('t', 'email', 'en', '')).toBeNull(); + expect(data.queries).toEqual([]); + }); }); }); diff --git a/packages/services/service-messaging/src/template-renderer.ts b/packages/services/service-messaging/src/template-renderer.ts index df3c24c99ef..5573c74f096 100644 --- a/packages/services/service-messaging/src/template-renderer.ts +++ b/packages/services/service-messaging/src/template-renderer.ts @@ -100,10 +100,31 @@ export interface NotificationTemplateStoreOptions { } /** - * Loads `sys_notification_template` rows by `(topic, channel, locale)`, with a - * locale fallback (`en-US` → `en` → {@link DEFAULT_LOCALE}). Best-effort: no - * data engine or a lookup error yields `null` (→ the renderer's generic - * fallback), never a throw — a template outage must not block delivery. + * Loads `sys_notification_template` rows by `(topic, channel, locale)` in the + * recipient's organization, with a locale fallback (`en-US` → `en` → + * {@link DEFAULT_LOCALE}). Best-effort: no data engine or a lookup error yields + * `null` (→ the renderer's generic fallback), never a throw — a template outage + * must not block delivery. + * + * ## A template is resolved in the recipient's organization (ADR-0131) + * + * `sys_notification_template` is organization-owned: a row is the organization + * that authored it (ADR-0131 D1/D3), so the row a delivery renders is one its + * OWN organization authored — `organizationId` is the delivery's + * (`Notification.organizationId`, the organization the event row carries). The + * read is keyed `where: { organization_id }`, the same spelling + * `PreferenceResolver.loadRows` scopes its rows by. + * + * - ⛔ Never drop the `organization_id` term. The read runs under the explicit + * system opt-in ({@link FAN_OUT_SYSTEM_CONTEXT}), which names no + * organization: here the term IS the organization boundary, as the + * `user_id` term is under `INBOX_SYSTEM_CONTEXT`. + * - A call that names no organization reads NO row and answers `null`, so the + * channel renders the producer's own title and body. There is no row with no + * organization (ADR-0131 D1), so there is no row such a delivery could own. + * The composed fan-out never hands a channel such a delivery: the event row + * takes its organization from the producer or from the engine's system-write + * rule (ADR-0131 D9), and an emit with neither is refused. */ export class NotificationTemplateStore { private readonly objectName: string; @@ -111,16 +132,23 @@ export class NotificationTemplateStore { this.objectName = opts.objectName ?? TEMPLATE_OBJECT; } - async load(topic: string, channel: string, locale?: string): Promise { + async load( + topic: string, + channel: string, + locale?: string, + organizationId?: string, + ): Promise { const data = this.opts.getData(); if (!data) return null; + if (!organizationId) return null; const candidates = localeCandidates(locale); for (const loc of candidates) { try { // The explicit system opt-in — see FAN_OUT_SYSTEM_CONTEXT: the - // platform's own template row, read to render a delivery. + // recipient organization's template row, read to render a + // delivery to that recipient. const row = await data.findOne(this.objectName, { - where: { topic, channel, locale: loc, is_active: true }, + where: { organization_id: organizationId, topic, channel, locale: loc, is_active: true }, fields: ['subject', 'body', 'format'], }, { context: FAN_OUT_SYSTEM_CONTEXT }); if (row) return row as NotificationTemplateRow;