Skip to content
14 changes: 14 additions & 0 deletions .changeset/24865-auth-sms-template-organization.md
Original file line number Diff line number Diff line change
@@ -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.
13 changes: 13 additions & 0 deletions .changeset/24865-template-recipient-organization.md
Original file line number Diff line number Diff line change
@@ -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.
5 changes: 5 additions & 0 deletions packages/plugins/plugin-auth/src/auth-manager.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down
16 changes: 15 additions & 1 deletion packages/plugins/plugin-auth/src/auth-manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6453,14 +6453,28 @@ 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,
data: Record<string, unknown>,
storedLocale?: string,
): Promise<string> {
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);
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -154,7 +154,7 @@ async function seedAll(engine: ObjectQL): Promise<void> {
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);
}

/**
Expand Down Expand Up @@ -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 () => {
Expand Down
24 changes: 15 additions & 9 deletions packages/plugins/plugin-auth/src/phone-sms-seed-retired.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand All @@ -53,14 +59,14 @@ function matchesWhere(row: Row, where: Record<string, unknown> = {}): 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; },
};
}

Expand Down Expand Up @@ -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));
Expand All @@ -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<string>();
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);
});
Expand All @@ -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;
Expand All @@ -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'),
);
});
Expand Down
Original file line number Diff line number Diff line change
@@ -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<ObjectQL> {
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<string[]> {
const rows = (await engine.find(TEMPLATE_OBJECT, { fields: ['topic', 'organization_id'] }, SYSTEM)) ?? [];
return rows.map((r: Record<string, unknown>) => `${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<void> {
await manager.sendPhoneInviteSms(PHONE);
await (manager as unknown as { deliverPhoneOtp(phone: string, code: string): Promise<void> }).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/);
});
}
});
Loading
Loading