Skip to content

Commit 865d2c8

Browse files
fix(service-messaging): resolve a notification template in the recipient's organization (#24930)
Fixes #24865 Clause-②: yes (widening) — `NotificationTemplateStore.load` (exported from `@objectstack/service-messaging`'s `.` entry) gains the optional `organizationId` parameter. Corrected from `no` by the `domain:services` seat at patch round 2. ## What this changes `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). The messaging template store now resolves the row in the recipient's organization: - `NotificationTemplateStore.load(topic, channel, locale, organizationId)` (`packages/services/service-messaging/src/template-renderer.ts`) keys its read `where: { organization_id }` under `FAN_OUT_SYSTEM_CONTEXT`, the spelling `PreferenceResolver.loadRows` already scopes its rows by. No new read primitive. - The email and SMS channels (`email-channel.ts`, `sms-channel.ts`) hand the store the delivery's own organization, `Notification.organizationId`. The inline fan-out and the outbox dispatcher both carry it there from the event row. - A call that names no organization reads no row and answers `null`, so the channel renders the producer's own title and body (the renderer's documented no-template fallback). Reasoning: there is no row with no organization (ADR-0131 D1), so there is no row such a delivery could own. Measured on the same two-organization harness, the composed fan-out never hands a channel such a delivery: an emit that names no organization is refused (`ERR_SYSTEM_WRITE_ORGANIZATION_REQUIRED`) under `isolated` and `group`, and under `single` with one organization the event row carries that organization (ADR-0131 D9). So this branch changes nothing a composed deployment renders. It closes the shape for direct callers of the exported store. ## The `plugin-auth` reader (patch round 1) `AuthManager.renderPhoneSmsBody` (the only change in `auth-manager.ts`) hands `phone-sms-texts.ts` the organization a deployment-level act resolves to, `tenancy.defaultOrgId()` (ADR-0093 D3). Every template read there (the active-row read and the existence read) is keyed `where: { organization_id }`. - Under `single`, the Default Organization's row decides the text. - Under `isolated` and `group`, no organization resolves, no row is read, and the built-in text is sent. The per-organization template door stays closed under a wall (ADR-0131 §1.4, D6). - No tenancy service, or a failed resolution, counts as no organization: the built-in text, never a blocked send. This follows the seat ruling `6295270052`. Its premise was measured on the real tenancy service, under `single`, `isolated` and `group`, enforced and degraded. - **Pins:** `phone-sms-texts.test.ts`, and `phone-sms-template-organization.integration.test.ts` (the real `AuthManager` invitation and OTP sends, the real tenancy service, two organizations). - **Ablation:** E 3 red, F 1 red, G 3 red, H 1 red, each restored with blob == `HEAD`. - **Changeset:** `.changeset/24865-auth-sms-template-organization.md` (`@objectstack/plugin-auth` patch). - **Fixture triage:** forced by the scoped read, in `phone-sms-seed-retired.test.ts` (including the `Row[]` type fix CI caught), `find-envelope-limb-removal.test.ts` and `auth-manager.test.ts`. ## Reachability, as an abstract statement Measured at `a7199ef97` on a real `ObjectQL` over `SqlDriver` (better-sqlite3) holding two organizations, under the `isolated` and the `group` posture: before this change, an email or SMS notification delivery to one organization's recipient could render a template row another organization authored, and so could the phone OTP and invitation SMS. `single` enforces no organization wall. ## Pins - `template-renderer.test.ts`, block "a template is resolved in the recipient's organization": every locale rung of the read is keyed by the organization handed in; a call that names no organization reads nothing. - `template-organization.integration.test.ts`, on a real engine with two organizations under `isolated` and `group`: each organization's delivery renders its own organization's row, on email and on SMS; an organization that authored no row renders the producer's own title and body; a delivery that names no organization renders the same. Preservation control under `single`: the sole organization's row still renders. Each case first asserts the organization every fixture row is stored with, so no pin passes on a mis-stamped row. - Existing fixtures (`email-channel.test.ts`, `sms-channel.test.ts`, `system-context.pin.test.ts`): the deliveries that render a template row now name their organization, as every composed delivery does, and the fakes match `organization_id`. ## Ablation (one-off; the fix was committed first, each leg via `scripts/ablation-replace.mjs`, restored with blob == `HEAD` and `git diff HEAD` empty) | leg | mutation in `src/` | reading | |:---|:---|:---| | A | the `organization_id` term removed from the read | red, 7: the four integration pins "each organization's delivery renders its own organization's row" and "an organization that authored no row renders the producer's own title and body" (`isolated` + `group`), and three store unit pins | | B | the no-organization guard removed, nothing else | red, 1: the unit read-shape pin. The real-engine pin stayed green: on this engine an `organization_id` term whose value is `undefined` matched no row (it was not dropped as an absent term). Fewer reds than expected, reported as observed; leg C is the forbidden behaviour spelled the way the package spells it | | C | the guard replaced by the sibling resolver's absent-organization branch (no organization, no term) | red, 3: the integration pin "a delivery that names no organization renders the producer's own title and body" (`isolated` + `group`) and the unit pin | | D | the organization hand-off dropped from `email-channel.ts`, then from `sms-channel.ts` | red, 3 each: "each organization's delivery renders its own organization's row" (`isolated` + `group`) and the `single` preservation pin | Subject resolution: the pins import `template-renderer.ts`, `email-channel.ts` and `sms-channel.ts` relatively from `src/`, so no `dist/` sits between a mutation and the run. ## Tests Patch round 1, head `386f5d4bd`: - `plugin-auth`: typecheck exit 0, including `check:test-typecheck` with the ledger unchanged; vitest 172 files, 3287 passed. - `service-messaging`: typecheck exit 0; vitest 53 files, 593 passed. - Gates: 72 derived, 72 run, 0 NOT-MEASURED, 0 UNRUN. Round 0, below, is kept for the record. ### Round 0 (at `bb37f9c62`, the fix merged with `origin/main` at `19484af53`) - Dependency closure rebuilt after the merge: `turbo run build --filter=@objectstack/service-messaging^...`, 15 tasks successful. - `pnpm --filter @objectstack/service-messaging typecheck`: exit 0. `tsc --listFiles` includes `template-organization.integration.test.ts` and `template-renderer.test.ts`. - `pnpm --filter @objectstack/service-messaging exec vitest run --maxWorkers=2`: 53 files, 593 tests passed. The same numbers were read at `8ed3c0638` before the merge. - Round 0 touched only `@objectstack/service-messaging`. The public surface of `NotificationTemplateStore.load` gains an optional fourth parameter, declared above as `Clause-②: yes`. Its one in-repo consumer is this package (`messaging-service-plugin.ts` and the two channels), so no importer owes a test. ## Gates Local subset at `bb37f9c62`: `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` (no paths) derives 69 commands. Each was run with its exit code captured before any pipe: **69 x exit 0**. Reconciliation `dispatch-gates --ran`: `69 derived, 69 run, 0 NOT-MEASURED, 0 UNRUN`. An earlier round at `8ed3c0638` had two `PREREQUISITE NOT MET` (exit 3) results, `check:dual-build-cjs-loads` and `check:i18n`, because their build prerequisites were missing. Both measured green in the round above (`check-i18n-bundles: OK (10 package(s) ...)`; service-messaging bundles in sync). The families CI runs (`Lint & Repo Gates`, `TypeScript Type Check`, `Test Core`, `Build Core`, and the rest) are read from this PR's check-runs. They were not re-run locally. The branch was current with `origin/main` at `19484af53` when this PR opened. Two later commits on `main` touch neither `service-messaging` nor the engine, driver or spec its pins run on. ## Acceptance notes - **Changeset:** `.changeset/24865-template-recipient-organization.md`, `@objectstack/service-messaging` patch. It states the fourth argument and the no-organization answer for direct callers of the exported store. - **Observed, not filed (dormant):** `MessagingService.emit` hands the preference filter and the recipient resolver `input.organizationId` (what the producer passed), not the event row's organization the delivery family follows. The two differ only when the producer names none, and under a wall that emit is refused, so no walled delivery reaches them unscoped. Carrier: none. - `content/docs/kernel/runtime-services/sms-service.mdx` describes the SMS channel rendering the `(topic, 'sms', locale)` row without naming the organization. It is not wrong, so it is left unchanged. --- _Generated by [Claude Code](https://claude.ai/code/session_01UmqzD97e14iws6hmVhgZ5y)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 223ae93 commit 865d2c8

17 files changed

Lines changed: 601 additions & 56 deletions
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
---
2+
'@objectstack/plugin-auth': patch
3+
---
4+
5+
fix(plugin-auth): the phone OTP and invitation SMS read their template row in the organization the send resolves to
6+
7+
Clause-②: no
8+
9+
`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:
10+
11+
- **`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.
12+
- **`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.
13+
- **No tenancy service, or a failed resolution:** the built-in text. A template outage still never blocks a send.
14+
- **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.
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
---
2+
'@objectstack/service-messaging': minor
3+
---
4+
5+
fix(service-messaging): a notification template is resolved in the recipient's organization
6+
7+
Clause-②: yes (widening) — `NotificationTemplateStore.load` (exported from the package's `.` entry) gains an optional fourth parameter, `organizationId`.
8+
9+
`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.
10+
11+
- **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.
12+
- **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.
13+
- **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.

‎packages/plugins/plugin-auth/src/auth-manager.test.ts‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2688,11 +2688,16 @@ describe('AuthManager', () => {
26882688
});
26892689

26902690
it('a tenant sys_notification_template row overrides the built-in text', async () => {
2691+
// The row is read in the organization the send resolves to,
2692+
// `tenancy.defaultOrgId()` — the Default Organization under `single`
2693+
// (phone-sms-texts.ts, "Which organization's row").
26912694
const { manager, opts } = await bootOtp({
2695+
getTenancy: () => ({ defaultOrgId: async () => 'org_default' }),
26922696
dataEngine: {
26932697
async find(object: string, q: any) {
26942698
if (
26952699
object === 'sys_notification_template' &&
2700+
q?.where?.organization_id === 'org_default' &&
26962701
q?.where?.topic === 'auth.phone_otp' &&
26972702
q?.where?.channel === 'sms' &&
26982703
q?.where?.locale === 'zh-CN'

‎packages/plugins/plugin-auth/src/auth-manager.ts‎

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6453,14 +6453,28 @@ export class AuthManager {
64536453
* gets exactly the deployment default, as before commit 35e94c96b. #14641 made the
64546454
* SMS invite path a passer rather than an abstainer; it is no longer the
64556455
* standing example of a caller that names nothing.
6456+
*
6457+
* The template row is read in the organization this deployment-level act
6458+
* resolves to — `tenancy.defaultOrgId()`, the same answer the membership
6459+
* reconciler binds a new user to (ADR-0093 D3): the Default Organization
6460+
* when no wall is requested, `null` under a requested wall, where no row is
6461+
* read and the built-in text is sent (`phone-sms-texts.ts`, "Which
6462+
* organization's row"). No tenancy service, or a failed resolution, is
6463+
* `null` too: the built-in text, never a blocked send.
64566464
*/
64576465
private async renderPhoneSmsBody(
64586466
topic: string,
64596467
data: Record<string, unknown>,
64606468
storedLocale?: string,
64616469
): Promise<string> {
64626470
const locale = storedLocale ?? this.smsLocale;
6463-
const template = await resolvePhoneSmsTemplateBody(this.getDataEngine(), topic, locale);
6471+
let organizationId: string | null = null;
6472+
try {
6473+
organizationId = (await this.config.getTenancy?.()?.defaultOrgId()) ?? null;
6474+
} catch {
6475+
organizationId = null;
6476+
}
6477+
const template = await resolvePhoneSmsTemplateBody(this.getDataEngine(), topic, locale, organizationId);
64646478
return interpolatePhoneSms(template, data);
64656479
}
64666480

‎packages/plugins/plugin-auth/src/find-envelope-limb-removal.test.ts‎

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -154,7 +154,7 @@ async function seedAll(engine: ObjectQL): Promise<void> {
154154
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);
155155
await engine.insert('sys_permission_set', { id: 'ps_1', name: 'member_default', label: 'Member' }, SYSTEM);
156156
await engine.insert('sys_user_permission_set', { id: 'ups_1', user_id: 'usr_1', permission_set_id: 'ps_1' }, SYSTEM);
157-
await engine.insert('sys_notification_template', { id: 'nt_1', topic: 'otp', channel: 'sms', locale: 'en', body: 'code {{code}}', is_active: true }, SYSTEM);
157+
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);
158158
}
159159

160160
/**
@@ -406,11 +406,11 @@ describe('#15597 — the blocks driven through their real production entry point
406406
it('phone-sms template read reads the bare array (B14, the `data` limb)', async () => {
407407
const engine = await bootEngine();
408408
await seedAll(engine);
409-
expect(await resolvePhoneSmsTemplateBody(engine as never, 'otp', 'en')).toBe('code {{code}}');
409+
expect(await resolvePhoneSmsTemplateBody(engine as never, 'otp', 'en', 'org_1')).toBe('code {{code}}');
410410
// No row and no built-in text for the topic: the built-in walk's empty
411411
// floor, not a row invented from a misread envelope. (The seeder's
412412
// existence read, this site's second `rowsOf` call, retired with the seed.)
413-
expect(await resolvePhoneSmsTemplateBody(engine as never, 'nosuchtopic', 'en')).toBe('');
413+
expect(await resolvePhoneSmsTemplateBody(engine as never, 'nosuchtopic', 'en', 'org_1')).toBe('');
414414
});
415415

416416
it('tenancy resolveDefaultOrgId reads the bare array (B7)', async () => {

‎packages/plugins/plugin-auth/src/phone-sms-seed-retired.test.ts‎

Lines changed: 15 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,12 @@ import {
3333
} from './phone-sms-texts.js';
3434

3535
const TEMPLATE_OBJECT = 'sys_notification_template';
36+
/**
37+
* The organization every store below belongs to — the one-organization world
38+
* the seeded store lived in. Every row is that organization's, and the read
39+
* is handed it, as `AuthManager` hands the Default Organization under `single`.
40+
*/
41+
const ORG = 'org_default';
3642
const SYSTEM_CTX = { isSystem: true, positions: [], permissions: [] } as const;
3743
const SECRET = 'test-secret-at-least-32-chars-long';
3844

@@ -53,14 +59,14 @@ function matchesWhere(row: Row, where: Record<string, unknown> = {}): boolean {
5359
}
5460

5561
function store(rows: Row[]) {
56-
const all = rows.map((r) => ({ ...r }));
62+
const all: Row[] = rows.map((r) => ({ organization_id: ORG, ...r }));
5763
return {
5864
all,
5965
async find(_object: string, q: any) {
6066
const hit = all.filter((r) => matchesWhere(r, q?.where));
6167
return typeof q?.limit === 'number' ? hit.slice(0, q.limit) : hit;
6268
},
63-
async insert(_object: string, row: Row) { all.push({ ...row }); return row; },
69+
async insert(_object: string, row: Row) { all.push({ organization_id: ORG, ...row }); return row; },
6470
};
6571
}
6672

@@ -131,7 +137,7 @@ describe('the retired auth SMS seed', () => {
131137
let compared = 0;
132138
for (const topic of TOPICS) {
133139
for (const locale of LOCALES) {
134-
const now = await resolvePhoneSmsTemplateBody(store([]), topic, locale);
140+
const now = await resolvePhoneSmsTemplateBody(store([]), topic, locale, ORG);
135141
expect(now, `${topic} @ ${String(locale)}`).toBe(await seededWorld([], topic, locale));
136142
// …and that text IS the built-in one for the rung, rendered identically.
137143
expect(now).toBe(builtinPhoneSmsBody(topic, locale));
@@ -142,7 +148,7 @@ describe('the retired auth SMS seed', () => {
142148
}
143149
// Every built-in text is reached: both topics in both bundled languages.
144150
const reached = new Set<string>();
145-
for (const topic of TOPICS) for (const locale of LOCALES) reached.add(await resolvePhoneSmsTemplateBody(store([]), topic, locale));
151+
for (const topic of TOPICS) for (const locale of LOCALES) reached.add(await resolvePhoneSmsTemplateBody(store([]), topic, locale, ORG));
146152
expect([...reached].sort()).toEqual(BUILTIN_PHONE_SMS_TEMPLATES.map((t) => t.body).sort());
147153
expect(compared).toBe(TOPICS.length * LOCALES.length);
148154
});
@@ -161,7 +167,7 @@ describe('the retired auth SMS seed', () => {
161167
for (const rows of storesOver(slots)) {
162168
for (const topic of TOPICS) {
163169
for (const locale of LOCALES) {
164-
const now = await resolvePhoneSmsTemplateBody(store(rows), topic, locale);
170+
const now = await resolvePhoneSmsTemplateBody(store(rows), topic, locale, ORG);
165171
const before = await seededWorld(rows, topic, locale);
166172
if (now !== before) diverged.push(`${topic} @ ${String(locale)} over ${JSON.stringify(rows)}: ${now} != ${before}`);
167173
compared += 1;
@@ -175,13 +181,13 @@ describe('the retired auth SMS seed', () => {
175181
it('a row an operator authored still wins', async () => {
176182
const custom = '【定制】验证码 {{code}}';
177183
const rows = [{ topic: PHONE_SMS_TOPICS.otp, channel: 'sms', locale: 'zh', is_active: true, body: custom }];
178-
expect(await resolvePhoneSmsTemplateBody(store(rows), PHONE_SMS_TOPICS.otp, 'zh-CN')).toBe(custom);
179-
expect(await resolvePhoneSmsTemplateBody(store(rows), PHONE_SMS_TOPICS.otp, 'zh')).toBe(custom);
184+
expect(await resolvePhoneSmsTemplateBody(store(rows), PHONE_SMS_TOPICS.otp, 'zh-CN', ORG)).toBe(custom);
185+
expect(await resolvePhoneSmsTemplateBody(store(rows), PHONE_SMS_TOPICS.otp, 'zh', ORG)).toBe(custom);
180186
// The other topic, and the other language, keep their built-in texts.
181-
expect(await resolvePhoneSmsTemplateBody(store(rows), PHONE_SMS_TOPICS.invite, 'zh')).toBe(
187+
expect(await resolvePhoneSmsTemplateBody(store(rows), PHONE_SMS_TOPICS.invite, 'zh', ORG)).toBe(
182188
builtinPhoneSmsBody(PHONE_SMS_TOPICS.invite, 'zh'),
183189
);
184-
expect(await resolvePhoneSmsTemplateBody(store(rows), PHONE_SMS_TOPICS.otp, 'en')).toBe(
190+
expect(await resolvePhoneSmsTemplateBody(store(rows), PHONE_SMS_TOPICS.otp, 'en', ORG)).toBe(
185191
builtinPhoneSmsBody(PHONE_SMS_TOPICS.otp, 'en'),
186192
);
187193
});
Lines changed: 159 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,159 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* An auth SMS reads its template row in the organization the send resolves to.
5+
*
6+
* `sys_notification_template` is organization-owned (ADR-0131: a row is the
7+
* organization that authored it). The phone OTP and invitation SMS are
8+
* deployment-level acts whose send callbacks carry no organization, so
9+
* `AuthManager.renderPhoneSmsBody` hands the resolver the organization that
10+
* act resolves to, `tenancy.defaultOrgId()` (ADR-0093 D3), and every template
11+
* read is keyed by it (`phone-sms-texts.ts`, "Which organization's row"):
12+
*
13+
* - `single`: the Default Organization's row decides the text;
14+
* - `isolated` and `group`: `defaultOrgId()` is `null`, so no row is read and
15+
* the built-in text is sent — no organization's row decides it.
16+
*
17+
* Pinned through the real `AuthManager` (the public invitation send and the
18+
* phone OTP send), the real tenancy service, and a real `ObjectQL` over
19+
* `SqlDriver` (better-sqlite3) holding two organizations, each of which
20+
* authored its own rows for both auth topics.
21+
*/
22+
23+
import { describe, it, expect, afterEach } from 'vitest';
24+
import { ObjectQL } from '@objectstack/objectql';
25+
import { SqlDriver } from '@objectstack/driver-sql';
26+
import { NotificationTemplate } from '@objectstack/service-messaging';
27+
import { AuthManager } from './auth-manager.js';
28+
import { authIdentityObjects } from './manifest.js';
29+
import { createTenancyService } from './tenancy-service.js';
30+
import { PHONE_SMS_TOPICS, builtinPhoneSmsBody, interpolatePhoneSms } from './phone-sms-texts.js';
31+
32+
const SECRET = 'test-secret-at-least-32-chars-long-24865';
33+
const ORIGIN = 'http://localhost:3000';
34+
const PHONE = '+15550124865';
35+
const CODE = '482913';
36+
const TEMPLATE_OBJECT = 'sys_notification_template';
37+
const SYSTEM = { context: { isSystem: true } } as never;
38+
39+
type Posture = 'single' | 'isolated' | 'group';
40+
41+
const engines: ObjectQL[] = [];
42+
afterEach(async () => {
43+
while (engines.length) {
44+
try { await engines.pop()!.destroy(); } catch { /* noop */ }
45+
}
46+
});
47+
48+
/**
49+
* A real engine on `posture` holding the Default Organization and one more,
50+
* each of which authored its own row for both auth SMS topics, and the
51+
* recipient's account.
52+
*/
53+
async function bootEngine(posture: Posture): Promise<ObjectQL> {
54+
const engine = new ObjectQL();
55+
engines.push(engine);
56+
engine.registerDriver(new SqlDriver({ client: 'better-sqlite3', connection: { filename: ':memory:' }, useNullAsDefault: true }), true);
57+
await engine.init();
58+
for (const object of authIdentityObjects) engine.registry.registerObject(object as never, '@objectstack/plugin-auth');
59+
engine.registry.registerObject(NotificationTemplate as never, '@objectstack/service-messaging');
60+
await engine.syncSchemas();
61+
engine.setTenancyPostureProvider(() => posture);
62+
63+
await engine.insert('sys_organization', { id: 'org_default', name: 'Default Organization', slug: 'default' }, SYSTEM);
64+
await engine.insert('sys_organization', { id: 'org_other', name: 'Other', slug: 'other' }, SYSTEM);
65+
for (const organization of ['org_default', 'org_other']) {
66+
for (const topic of [PHONE_SMS_TOPICS.otp, PHONE_SMS_TOPICS.invite]) {
67+
// Written as that organization's write lands.
68+
await engine.insert(
69+
TEMPLATE_OBJECT,
70+
{ topic, channel: 'sms', locale: 'en', body: `${organization} ${topic} {{code}}`, format: 'text', is_active: true },
71+
{ context: { isSystem: true, tenantId: organization } } as never,
72+
);
73+
}
74+
}
75+
await engine.insert('sys_user', { id: 'usr_recipient', name: 'Recipient', email: 'recipient@example.test', phone_number: PHONE, locale: 'en' }, SYSTEM);
76+
return engine;
77+
}
78+
79+
/** What each template row is stored with — the fixture's own control. */
80+
async function storedOrganizations(engine: ObjectQL): Promise<string[]> {
81+
const rows = (await engine.find(TEMPLATE_OBJECT, { fields: ['topic', 'organization_id'] }, SYSTEM)) ?? [];
82+
return rows.map((r: Record<string, unknown>) => `${String(r.organization_id ?? 'NULL')}:${String(r.topic)}`).sort();
83+
}
84+
85+
/** The real manager over `engine`, with the real tenancy service and an SMS transport that records each body. */
86+
function bootManager(engine: ObjectQL, posture: Posture) {
87+
const tenancy = createTenancyService({
88+
requested: posture,
89+
probeIsolation: () => true,
90+
getEngine: () => engine,
91+
logger: { info: () => {}, warn: () => {} },
92+
});
93+
const bodies: string[] = [];
94+
const manager = new AuthManager({
95+
secret: SECRET,
96+
baseUrl: ORIGIN,
97+
dataEngine: engine,
98+
plugins: { phoneNumber: true },
99+
smsService: {
100+
async send(input: { body: string }) { bodies.push(input.body); return { status: 'sent', messageId: 'm' }; },
101+
isConfigured: () => true,
102+
},
103+
getTenancy: () => tenancy,
104+
} as never);
105+
return { manager, bodies, tenancy };
106+
}
107+
108+
/** Both auth SMS sends: the public invitation, and the phone OTP the sign-in callbacks drive. */
109+
async function sendBoth(manager: AuthManager): Promise<void> {
110+
await manager.sendPhoneInviteSms(PHONE);
111+
await (manager as unknown as { deliverPhoneOtp(phone: string, code: string): Promise<void> }).deliverPhoneOtp(PHONE, CODE);
112+
}
113+
114+
/** The built-in `en` text for `topic` with every hole open — the invitation's URLs are the manager's own. */
115+
function builtinShape(topic: string): RegExp {
116+
const escaped = builtinPhoneSmsBody(topic, 'en')
117+
.split(/\{\{\s*[\w.$]+\s*\}\}/)
118+
.map((literal) => literal.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'));
119+
return new RegExp(`^${escaped.join('.+')}$`);
120+
}
121+
122+
const EXPECTED_ROWS = [
123+
`org_default:${PHONE_SMS_TOPICS.invite}`, `org_default:${PHONE_SMS_TOPICS.otp}`,
124+
`org_other:${PHONE_SMS_TOPICS.invite}`, `org_other:${PHONE_SMS_TOPICS.otp}`,
125+
];
126+
127+
describe("an auth SMS reads its template row in the organization the send resolves to", () => {
128+
it("single: the Default Organization's row decides the invitation and the OTP text", async () => {
129+
const engine = await bootEngine('single');
130+
expect(await storedOrganizations(engine)).toEqual(EXPECTED_ROWS);
131+
const { manager, bodies, tenancy } = bootManager(engine, 'single');
132+
expect(await tenancy.defaultOrgId()).toBe('org_default');
133+
134+
await sendBoth(manager);
135+
136+
expect(bodies).toEqual([
137+
`org_default ${PHONE_SMS_TOPICS.invite} `,
138+
`org_default ${PHONE_SMS_TOPICS.otp} ${CODE}`,
139+
]);
140+
});
141+
142+
for (const posture of ['isolated', 'group'] as const) {
143+
it(`${posture}: the built-in text is sent, and no organization's row decides it`, async () => {
144+
const engine = await bootEngine(posture);
145+
expect(await storedOrganizations(engine)).toEqual(EXPECTED_ROWS);
146+
const { manager, bodies, tenancy } = bootManager(engine, posture);
147+
expect(await tenancy.defaultOrgId()).toBeNull();
148+
149+
await sendBoth(manager);
150+
151+
expect(bodies).toHaveLength(2);
152+
expect(bodies[1]).toBe(
153+
interpolatePhoneSms(builtinPhoneSmsBody(PHONE_SMS_TOPICS.otp, 'en'), { code: CODE, appName: 'ObjectStack', minutes: 5 }),
154+
);
155+
expect(bodies[0]).toMatch(builtinShape(PHONE_SMS_TOPICS.invite));
156+
for (const body of bodies) expect(body).not.toMatch(/org_default|org_other/);
157+
});
158+
}
159+
});

0 commit comments

Comments
 (0)