From 8ed3c0638a8d81b57ea95ece49bf79ec25ea3da5 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 20 Oct 2026 14:34:30 +0000 Subject: [PATCH 1/5] fix(service-messaging): resolve a notification template in the recipient's organization sys_notification_template is organization-owned (ADR-0131). The template store now keys its read by the delivery's organization, the spelling the preference resolver scopes its rows by, and the email and SMS channels hand it the delivery's organization. A call that names no organization reads no row and renders the producer's own title and body. Pins: the store's read shape (template-renderer.test.ts) and, on a real ObjectQL over SqlDriver holding two organizations under isolated and group, the email and SMS channels each rendering the row of the delivery's own organization (template-organization.integration.test.ts), with the single posture's sole organization as the preservation control. Claude-Session: https://claude.ai/code/session_01UmqzD97e14iws6hmVhgZ5y Co-authored-by: Claude --- .../24865-template-recipient-organization.md | 13 ++ .../src/email-channel.test.ts | 19 +- .../service-messaging/src/email-channel.ts | 4 +- .../service-messaging/src/sms-channel.test.ts | 8 +- .../service-messaging/src/sms-channel.ts | 4 +- .../src/system-context.pin.test.ts | 4 +- .../template-organization.integration.test.ts | 210 ++++++++++++++++++ .../src/template-renderer.test.ts | 40 +++- .../src/template-renderer.ts | 42 +++- 9 files changed, 317 insertions(+), 27 deletions(-) create mode 100644 .changeset/24865-template-recipient-organization.md create mode 100644 packages/services/service-messaging/src/template-organization.integration.test.ts diff --git a/.changeset/24865-template-recipient-organization.md b/.changeset/24865-template-recipient-organization.md new file mode 100644 index 00000000000..0bb90a6cb1f --- /dev/null +++ b/.changeset/24865-template-recipient-organization.md @@ -0,0 +1,13 @@ +--- +'@objectstack/service-messaging': patch +--- + +fix(service-messaging): a notification template is resolved in the recipient's organization + +Clause-②: no + +`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. +- **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/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; From 076daa7672ae58fb54e5ac04ee3a7e31c9f35800 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 20 Oct 2026 16:18:39 +0000 Subject: [PATCH 2/5] fix(plugin-auth): read the auth SMS template row in the organization the send resolves to sys_notification_template is organization-owned (ADR-0131). The phone OTP and invitation SMS carry no organization of their own, so AuthManager.renderPhoneSmsBody hands the resolver tenancy.defaultOrgId() (ADR-0093 D3) and every template read in phone-sms-texts.ts is keyed by it: the Default Organization's row under single; under a requested wall no organization resolves, no row is read and the built-in text is sent. Pins: phone-sms-texts.test.ts (read shape, another organization's row, no organization) and, through the real AuthManager, tenancy service and an ObjectQL over SqlDriver holding two organizations, phone-sms-template-organization.integration.test.ts per posture. Claude-Session: https://claude.ai/code/session_01UmqzD97e14iws6hmVhgZ5y Co-authored-by: Claude --- .../24865-auth-sms-template-organization.md | 14 ++ .../plugins/plugin-auth/src/auth-manager.ts | 16 +- .../src/find-envelope-limb-removal.test.ts | 6 +- .../src/phone-sms-seed-retired.test.ts | 24 ++- ...-template-organization.integration.test.ts | 159 ++++++++++++++++++ .../plugin-auth/src/phone-sms-texts.test.ts | 55 ++++-- .../plugin-auth/src/phone-sms-texts.ts | 34 +++- 7 files changed, 279 insertions(+), 29 deletions(-) create mode 100644 .changeset/24865-auth-sms-template-organization.md create mode 100644 packages/plugins/plugin-auth/src/phone-sms-template-organization.integration.test.ts 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/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..8454c256550 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 = 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; From aa935bf5041be9bf130a8a4ce768fa6fb1d25ecd Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 20 Oct 2026 16:27:58 +0000 Subject: [PATCH 3/5] test(plugin-auth): type the seed-equivalence store's rows as rows The store double stamps each row with its organization, and the spread narrowed the inferred element type to that one key, so the retired-read oracle's `.body` access failed check:test-typecheck (TS2339). Annotating the array as `Row[]` restores the row type; the double's behaviour is unchanged. Claude-Session: https://claude.ai/code/session_01UmqzD97e14iws6hmVhgZ5y Co-authored-by: Claude --- packages/plugins/plugin-auth/src/phone-sms-seed-retired.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 8454c256550..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 @@ -59,7 +59,7 @@ function matchesWhere(row: Row, where: Record = {}): boolean { } function store(rows: Row[]) { - const all = rows.map((r) => ({ organization_id: ORG, ...r })); + const all: Row[] = rows.map((r) => ({ organization_id: ORG, ...r })); return { all, async find(_object: string, q: any) { From 386f5d4bdc592805d066def3f65a1a8d57cbbc2a Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 20 Oct 2026 16:40:06 +0000 Subject: [PATCH 4/5] test(plugin-auth): the tenant SMS template row is read in the resolved organization The AuthManager test that pins "a tenant row overrides the built-in text" now composes a tenancy service answering the Default Organization and serves the row only to a read keyed by that organization, the way the phone SMS resolver reads since the template row is organization-scoped. Claude-Session: https://claude.ai/code/session_01UmqzD97e14iws6hmVhgZ5y Co-authored-by: Claude --- packages/plugins/plugin-auth/src/auth-manager.test.ts | 5 +++++ 1 file changed, 5 insertions(+) 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' From 5abbc0802575de00ea74577e9dc15ac4a0105891 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 20 Oct 2026 17:15:36 +0000 Subject: [PATCH 5/5] chore(changeset): service-messaging takes minor for the optional organizationId on NotificationTemplateStore.load MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit NotificationTemplateStore is exported from the package's `.` entry, so the new optional parameter widens the published surface: the changeset declares Clause-② yes (widening), names the parameter, and keeps the no-organization answer for direct callers. The plugin-auth changeset is unchanged. Claude-Session: https://claude.ai/code/session_01UmqzD97e14iws6hmVhgZ5y Co-authored-by: Claude --- .changeset/24865-template-recipient-organization.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.changeset/24865-template-recipient-organization.md b/.changeset/24865-template-recipient-organization.md index 0bb90a6cb1f..296f9aa9251 100644 --- a/.changeset/24865-template-recipient-organization.md +++ b/.changeset/24865-template-recipient-organization.md @@ -1,13 +1,13 @@ --- -'@objectstack/service-messaging': patch +'@objectstack/service-messaging': minor --- fix(service-messaging): a notification template is resolved in the recipient's organization -Clause-②: no +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. -- **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. +- **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.