Skip to content

Commit aa0ca3a

Browse files
committed
Merge remote-tracking branch 'origin/main' into claude/project-thread-fdvrys
2 parents 3b4a693 + 49185cd commit aa0ca3a

10 files changed

Lines changed: 706 additions & 413 deletions
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
---
2+
'@objectstack/service-messaging': patch
3+
---
4+
5+
fix(service-messaging)!: retire `renderNotification`, the render export that takes a notification template body with no save moment
6+
7+
The hole-placement rule for notification templates is judged where a template is saved: the `sys_notification_template` save hooks. `renderNotification` took a template body inline and rendered it at once, with no save moment, so the rule could not see it. It leaves the published entry of `@objectstack/service-messaging`. Nothing in this repository imported it from that entry. The renderer stays inside the package: the email and sms channels still render every stored template through it, unchanged.
8+
9+
Clause-②: no (narrowing)
10+
Metadata-protocol: none
11+
12+
<!-- adr-0087: not-required (runtime-export-retired @objectstack/service-messaging#renderNotification) One runtime function leaves a published package entry; no key of any metadata schema, no object definition and no stored shape is added, removed, renamed or re-shaped, so there is nothing for `objectstack migrate meta` to rewrite and no tombstone. A TypeScript consumer meets the break at its own import, and the line below names the door to use instead. -->
13+
14+
**BREAKING** (a runtime export is removed). It ships as `patch` because the metadata protocol does not move. There is no staged window.
15+
16+
- FROM `renderNotification` TO a `sys_notification_template` row for the topic, channel and locale, which the delivering channel renders when the notification is emitted.
17+
18+
Unchanged: `interpolate` (the plain-text face, which the placement rule judges at no door), `NotificationTemplateStore`, `TEMPLATE_OBJECT`, `DEFAULT_LOCALE` and the template types; and how every stored template renders.

‎packages/plugins/plugin-auth/src/api-error-code.pin.test.ts‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -173,9 +173,12 @@ describe('every new APIError( in plugin-auth/src carries an ADR-0112 code (#2424
173173
// 24, `objectql-adapter.ts` 3, `impersonation-bearer-rotation.ts` 2,
174174
// `admin-impersonate-endpoint.ts` 1). A floor, not an exact count, so a new
175175
// throw does not have to edit this line; only losing them does.
176+
// [#25224] `AuthManager`'s throws are counted over the files its methods'
177+
// bodies live in: `auth-manager.ts` and the bodies moved out of it whole.
178+
const authManagerFiles = ['auth-manager.ts', 'auth-manager-actor-guards.ts'];
176179
expect(sites.length).toBeGreaterThanOrEqual(30);
177180
expect(new Set(sites.map((s) => s.file)).size).toBeGreaterThanOrEqual(4);
178-
expect(perFile.find((f) => f.file === 'auth-manager.ts')?.sites.length ?? 0).toBeGreaterThanOrEqual(24);
181+
expect(perFile.filter((f) => authManagerFiles.includes(f.file)).reduce((n, f) => n + f.sites.length, 0)).toBeGreaterThanOrEqual(24);
179182
});
180183

181184
it('the textual control agrees with the walk in every file', () => {

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

Lines changed: 504 additions & 0 deletions
Large diffs are not rendered by default.

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

Lines changed: 62 additions & 407 deletions
Large diffs are not rendered by default.

‎packages/plugins/plugin-auth/src/better-auth-schema-parity.test.ts‎

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -319,7 +319,11 @@ function betterAuthPluginSet(): unknown[] {
319319
* they declare no schema.
320320
*/
321321
function authManagerPluginFactories(): string[] {
322-
const source = readFileSync(join(PKG, 'src', 'auth-manager.ts'), 'utf8');
322+
// [#25224] The auth manager's source: `auth-manager.ts` and the method
323+
// bodies moved out of it whole. A factory imported in either is one it loads.
324+
const source = ['auth-manager.ts', 'auth-manager-actor-guards.ts']
325+
.map((file) => readFileSync(join(PKG, 'src', file), 'utf8'))
326+
.join('\n');
323327
const found = new Set<string>();
324328
const pattern =
325329
/const\s*\{\s*([A-Za-z0-9_]+)\s*\}\s*=\s*await import\('((?:better-auth\/plugins|@better-auth\/)[^']*)'\)/g;

‎packages/plugins/plugin-auth/src/managed-extension-fields.test.ts‎

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -629,7 +629,11 @@ function findCollisions(
629629
* they declare no schema.
630630
*/
631631
function authManagerPluginFactories(): string[] {
632-
const source = readFileSync(join(HERE, 'auth-manager.ts'), 'utf8');
632+
// [#25224] The auth manager's source: `auth-manager.ts` and the method
633+
// bodies moved out of it whole. A factory imported in either is one it loads.
634+
const source = ['auth-manager.ts', 'auth-manager-actor-guards.ts']
635+
.map((file) => readFileSync(join(HERE, file), 'utf8'))
636+
.join('\n');
633637
const found = new Set<string>();
634638
const pattern =
635639
/const\s*\{\s*([A-Za-z0-9_]+)\s*\}\s*=\s*await import\('((?:better-auth\/plugins|@better-auth\/)[^']*)'\)/g;

‎packages/plugins/plugin-auth/src/organization-delete-read-fault.test.ts‎

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -154,6 +154,21 @@ function recordAttemptedWrites(engine: ObjectQL): string[] {
154154
return attempted;
155155
}
156156

157+
/**
158+
* The first `AuthManager` method on `stack`: an `AuthManager.<method>` frame,
159+
* or [#25224] a frame of a method's body moved whole into
160+
* `auth-manager-actor-guards.ts`, which runs under the method's own name (a
161+
* helper of that module that is no method of the class does not count).
162+
*/
163+
function firstAuthManagerMethod(stack: string): string | undefined {
164+
const methods = new Set(Object.getOwnPropertyNames(AuthManager.prototype));
165+
for (const match of stack.matchAll(/AuthManager\.(\w+)|at (?:async )?(?:Object\.)?(\w+) \([^)\n]*auth-manager-actor-guards\.ts:/g)) {
166+
if (match[1] !== undefined) return match[1];
167+
if (methods.has(match[2])) return match[2];
168+
}
169+
return undefined;
170+
}
171+
157172
/** The current stack, deep enough to reach the request's frames (V8 keeps ten by default). */
158173
function deepStack(): string {
159174
const limit = Error.stackTraceLimit;
@@ -191,7 +206,7 @@ function failAdmissionRead(engine: ObjectQL, read: AdmissionRead) {
191206
const real = target[method].bind(engine);
192207
return vi.spyOn(target, method).mockImplementation(async (name: string, ...rest: unknown[]) => {
193208
const stack = deepStack();
194-
const first = /AuthManager\.(\w+)/.exec(stack)?.[1];
209+
const first = firstAuthManagerMethod(stack);
195210
if (fired < times && name === object && stack.includes('assertOrganizationDeletable') && stack.includes(frame) && first === caller) {
196211
fired += 1;
197212
throw new Error(`${object} store unavailable (injected)`);

‎packages/runtime/src/dispatcher-error-vocabulary.ts‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -434,7 +434,8 @@ export const UNREGISTERED_CODE_SITES: readonly UnregisteredCodeSite[] = [
434434
},
435435
{
436436
code: 'YOU_ARE_NOT_ALLOWED_TO_DELETE_THIS_MEMBER',
437-
file: 'packages/plugins/plugin-auth/src/auth-manager.ts',
437+
// [#25224] The stamp moved with `assertRemoveMemberPermitted`'s body.
438+
file: 'packages/plugins/plugin-auth/src/auth-manager-actor-guards.ts',
438439
shape: 'objlitconst',
439440
door: 'none',
440441
verdict: 'foreign-vocabulary',

‎packages/services/service-messaging/src/index.ts‎

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -87,9 +87,16 @@ export type {
8787
} from './notification-text.js';
8888

8989
// Templates + renderer (ADR-0030 P3)
90+
//
91+
// [#25190] `renderNotification` is not on this entry: it took a template body
92+
// inline and rendered its html face with no save moment, so the hole-placement
93+
// rule the template object's save hooks run could not see it (ruling
94+
// 6335348115 on #25082, F2 B3). A notification template is a
95+
// `sys_notification_template` row, judged when it is saved and rendered by the
96+
// channels that deliver it. `interpolate` stays: it renders a plain-text face,
97+
// which that rule judges at no door.
9098
export {
9199
NotificationTemplateStore,
92-
renderNotification,
93100
interpolate,
94101
TEMPLATE_OBJECT,
95102
DEFAULT_LOCALE,
Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* #25190 — `renderNotification`, the renderer that took a template body inline,
5+
* is retired from this package's published entry (ruling 6335348115 on #25082,
6+
* F2 B3; ADR-0087 `runtime-export-retired`). The hole-placement rule is judged
7+
* by the `sys_notification_template` save hooks, and a body handed straight to
8+
* it was never saved, so it was the one door the rule could not see.
9+
* `interpolate` stays on the entry: it renders a plain-text face, which the
10+
* rule judges at no door.
11+
*
12+
* Pinned both ways: the entry no longer names it (the template store and the
13+
* channels stay), and a stored template row still renders through a channel
14+
* built from the entry — the html body with every hole encoded, the subject
15+
* (the plain-text face) verbatim.
16+
*/
17+
18+
import { describe, it, expect } from 'vitest';
19+
import * as entry from './index.js';
20+
21+
describe('service-messaging entry: the body-taking renderer is retired (#25190)', () => {
22+
it('the published entry does not export renderNotification', () => {
23+
expect('renderNotification' in entry).toBe(false);
24+
});
25+
26+
it('THE CONTROL: the entry still exports the template store, the plain-text face and the channels that render', () => {
27+
expect(typeof entry.NotificationTemplateStore).toBe('function');
28+
expect(typeof entry.interpolate).toBe('function');
29+
expect(entry.TEMPLATE_OBJECT).toBe('sys_notification_template');
30+
expect(typeof entry.createEmailChannel).toBe('function');
31+
expect(typeof entry.createSmsChannel).toBe('function');
32+
});
33+
34+
it('a stored html template row still renders through the email channel', async () => {
35+
const row = {
36+
organization_id: 'org_1',
37+
topic: 'deal.won',
38+
channel: 'email',
39+
locale: 'en',
40+
is_active: true,
41+
format: 'html',
42+
subject: 'Won {{ payload.title }}',
43+
body: '<h1>{{ payload.title }}</h1>',
44+
};
45+
const data = {
46+
async findOne(object: string, query: any) {
47+
const w = query?.where ?? {};
48+
if (object === 'sys_user') return w.id === 'user_1' ? { email: 'ada@example.com' } : null;
49+
if (object === entry.TEMPLATE_OBJECT) {
50+
return row.organization_id === w.organization_id && row.topic === w.topic
51+
&& row.channel === w.channel && row.locale === w.locale ? row : null;
52+
}
53+
return null;
54+
},
55+
} as any;
56+
const sent: any[] = [];
57+
const ch = entry.createEmailChannel({
58+
getEmail: () => ({ async send(input: any) { sent.push(input); return { id: 'email_row_1' }; } }) as any,
59+
getData: () => data,
60+
store: new entry.NotificationTemplateStore({ getData: () => data }),
61+
});
62+
const logger = { info: () => {}, warn: () => {}, error: () => {} };
63+
64+
const r = await ch.send({ logger } as any, {
65+
channel: 'email',
66+
recipient: 'user_1',
67+
notification: {
68+
notificationId: 'evt_1',
69+
organizationId: 'org_1',
70+
topic: 'deal.won',
71+
title: 'A<b>',
72+
body: '',
73+
severity: 'info',
74+
recipients: ['user_1'],
75+
payload: { title: 'A<b>' },
76+
},
77+
} as any);
78+
79+
expect(r.ok).toBe(true);
80+
expect(sent).toEqual([{ to: 'ada@example.com', subject: 'Won A<b>', html: '<h1>A&lt;b&gt;</h1>', organizationId: 'org_1' }]);
81+
});
82+
});

0 commit comments

Comments
 (0)