Skip to content

Commit fc9b204

Browse files
committed
Add global scope + reactive settings client
Introduce a platform-wide "global" settings scope and a Phase‑1 reactive settings client/events system. Changes: - Add 'global' to specifier schema and update cascade order (env > global > tenant > user > default). Update sys-setting object to show Global option. - Change mail manifest scope to 'global' and update tests to expect source='global'. - Implement SettingsService change bus: subscribe/emitChange, createClient/snapshotOf reactive client, and fire settings:changed events on mutations. Adjust load/save to handle global rows and bypass tenant-audit where appropriate. - Extend SettingsEngine types with bypassTenantAudit and updated insert/update/find signatures. - Add settings-client Zod spec for the reactive client and change event contract. - Add i18n support for source labels and resolveSettingsSourceLabel helper in i18n resolver; add translations for en / ja-JP / zh-CN. - Wire EmailServicePlugin to bind to the 'mail' namespace, subscribe to changes, register a mail/test action, and apply live settings; add EmailService.setTransport and setDefaultFrom to support hot-swapping. - Update and add tests covering global scope, change events, createClient behaviour and subscriber safety. These changes enable platform-wide settings and allow services (e.g. email) to pick up admin UI updates without restart.
1 parent ba92878 commit fc9b204

15 files changed

Lines changed: 678 additions & 21 deletions

File tree

packages/platform-objects/src/system/sys-setting.object.ts

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -14,9 +14,10 @@ import { ObjectSchema, Field } from '@objectstack/spec/data';
1414
*
1515
* Resolution order (handled by `SettingsService.get`):
1616
* 1. process.env override (source='env', locked=true)
17-
* 2. sys_setting WHERE scope='tenant' (source='tenant')
18-
* 3. sys_setting WHERE scope='user' (source='user')
19-
* 4. manifest specifier.default (source='default')
17+
* 2. sys_setting WHERE scope='global' (source='global')
18+
* 3. sys_setting WHERE scope='tenant' (source='tenant')
19+
* 4. sys_setting WHERE scope='user' (source='user')
20+
* 5. manifest specifier.default (source='default')
2021
*
2122
* Encryption: rows with `encrypted=true` store ciphertext in `value_enc`
2223
* and leave `value` null. The plain value is never written to audit log
@@ -119,6 +120,7 @@ export const SysSetting = ObjectSchema.create({
119120

120121
scope: Field.select(
121122
[
123+
{ label: 'Global', value: 'global' },
122124
{ label: 'Tenant', value: 'tenant' },
123125
{ label: 'User', value: 'user' },
124126
{ label: 'Runtime',value: 'runtime' },

packages/plugins/plugin-email/src/email-plugin.ts

Lines changed: 122 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -125,6 +125,72 @@ export class EmailServicePlugin implements Plugin {
125125
catch { try { engine = ctx.getService<IDataEngine>('data'); } catch { /* ignore */ } }
126126
if (!engine || !this.service) return;
127127

128+
// ── Bind to the `mail` settings namespace (Phase 1) ──────────────
129+
// Allows the admin UI to live-update SMTP/provider/from-address
130+
// without restarting the process. Env-locked fields still win at
131+
// the resolver level, so config-via-env keeps its precedence.
132+
try {
133+
const settings = ctx.getService<any>('settings');
134+
if (settings && typeof settings.createClient === 'function') {
135+
const applySettings = async () => {
136+
try {
137+
const payload = await settings.getNamespace('mail');
138+
const values: Record<string, unknown> = {};
139+
for (const [k, v] of Object.entries(payload.values as Record<string, any>)) {
140+
values[k] = v?.value;
141+
}
142+
this.applyMailSettings(values, ctx);
143+
} catch (err: any) {
144+
ctx.logger.warn('EmailServicePlugin: failed to apply mail settings: ' + (err?.message ?? err));
145+
}
146+
};
147+
await applySettings();
148+
// Subscribe to namespace changes; rebuild on every update.
149+
if (typeof settings.subscribe === 'function') {
150+
settings.subscribe('mail', () => {
151+
void applySettings();
152+
});
153+
ctx.logger.info('EmailServicePlugin: bound to settings:changed for namespace=mail');
154+
}
155+
156+
// Register the `mail/test` action handler so saving + sending
157+
// a test email actually exercises the live transport.
158+
if (typeof settings.registerAction === 'function') {
159+
const svc = this.service;
160+
settings.registerAction('mail', 'test', async ({ values, ctx: actionCtx }: any) => {
161+
const to = (actionCtx?.body?.to as string | undefined)
162+
?? (values.from_email as string | undefined);
163+
if (!to) {
164+
return { ok: false, severity: 'error', message: 'Provide a "to" address (or set from_email).' };
165+
}
166+
try {
167+
const result = await svc.send({
168+
to,
169+
from: values.from_email ? {
170+
address: String(values.from_email),
171+
name: values.from_name ? String(values.from_name) : undefined,
172+
} : undefined,
173+
subject: 'ObjectStack mail test',
174+
text: 'This is a test email from the ObjectStack settings page.',
175+
});
176+
if (result.status === 'failed') {
177+
return { ok: false, severity: 'error', message: result.error ?? 'Send failed.' };
178+
}
179+
return {
180+
ok: true,
181+
severity: 'info',
182+
message: `Sent test email to ${to} (id=${result.id}).`,
183+
};
184+
} catch (err: any) {
185+
return { ok: false, severity: 'error', message: err?.message ?? String(err) };
186+
}
187+
});
188+
}
189+
}
190+
} catch {
191+
// settings service not registered — env/constructor opts remain authoritative.
192+
}
193+
128194
const persistence: EmailPersistence | undefined = this.options.persist === false
129195
? undefined
130196
: {
@@ -198,6 +264,62 @@ export class EmailServicePlugin implements Plugin {
198264
});
199265
}
200266

267+
/**
268+
* Translate the `mail` settings namespace snapshot into a transport
269+
* and `defaultFrom`, then hot-swap them on the running EmailService.
270+
*
271+
* Behaviour:
272+
* - `provider = 'log' | 'smtp'` keeps the LogTransport (real SMTP
273+
* delivery requires `@objectstack/plugin-mail-smtp`, which is not
274+
* a dependency of this package). The from-address is still applied.
275+
* - `provider = 'resend' | 'postmark'` rebuilds the transport using
276+
* `api_key` from settings. If `api_key` is missing the swap is
277+
* skipped and a warning is logged — the previous transport stays.
278+
*
279+
* Env-locked fields (handled in SettingsService.get) still resolve
280+
* before this method ever sees them, so an env override transparently
281+
* wins.
282+
*/
283+
private applyMailSettings(values: Record<string, unknown>, ctx: PluginContext): void {
284+
if (!this.service) return;
285+
286+
const fromEmail = typeof values.from_email === 'string' ? values.from_email : undefined;
287+
const fromName = typeof values.from_name === 'string' ? values.from_name : undefined;
288+
if (fromEmail) this.service.setDefaultFrom({ address: fromEmail, name: fromName });
289+
290+
const provider = String(values.provider ?? 'smtp');
291+
if (provider === 'smtp' || provider === 'log') {
292+
// No SMTP transport ships in core; settings-only edits become
293+
// a no-op for transport but still apply `defaultFrom`. Users
294+
// wanting real SMTP install `@objectstack/plugin-mail-smtp`
295+
// and configure it via constructor opts.
296+
ctx.logger.info(
297+
`EmailServicePlugin: mail settings applied (provider=${provider}, from=${fromEmail ?? '∅'}); transport unchanged.`,
298+
);
299+
return;
300+
}
301+
302+
const apiKey = typeof values.api_key === 'string' ? values.api_key : undefined;
303+
if (!apiKey) {
304+
ctx.logger.warn(
305+
`EmailServicePlugin: provider='${provider}' selected but api_key is empty — transport NOT rebuilt.`,
306+
);
307+
return;
308+
}
309+
310+
try {
311+
const transport = makeTransport({
312+
provider: provider as 'resend' | 'postmark',
313+
apiKey,
314+
logger: ctx.logger,
315+
});
316+
this.service.setTransport(transport);
317+
ctx.logger.info(`EmailServicePlugin: transport rebuilt from settings (provider=${provider}).`);
318+
} catch (err: any) {
319+
ctx.logger.warn('EmailServicePlugin: failed to rebuild transport: ' + (err?.message ?? err));
320+
}
321+
}
322+
201323
private async upsertTemplate(engine: IDataEngine, tpl: EmailTemplate): Promise<void> {
202324
const row = {
203325
name: tpl.name,

packages/plugins/plugin-email/src/email-service.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -200,6 +200,21 @@ export class EmailService implements IEmailService {
200200
this.options.persistence = persistence;
201201
}
202202

203+
/**
204+
* Hot-swap the underlying transport. Used by EmailServicePlugin when
205+
* the `mail` settings namespace changes (e.g. SMTP host updated in
206+
* the admin UI) so subsequent `send()` calls go through the new
207+
* transport without restarting the process.
208+
*/
209+
setTransport(transport: IEmailTransport): void {
210+
this.options.transport = transport;
211+
}
212+
213+
/** Replace the default `from` address used when callers omit `input.from`. */
214+
setDefaultFrom(from: EmailAddress | undefined): void {
215+
this.options.defaultFrom = from;
216+
}
217+
203218
async send(input: SendEmailInput): Promise<SendEmailResult> {
204219
let normalized: NormalizedEmailMessage;
205220
try {

packages/services/service-settings/src/manifests/mail.manifest.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ const manifest = {
1414
label: 'Mail Delivery',
1515
icon: 'Mail',
1616
description: 'SMTP and transactional email provider configuration.',
17-
scope: 'tenant',
17+
scope: 'global',
1818
readPermission: 'setup.access',
1919
writePermission: 'setup.write',
2020
category: 'Communication',

packages/services/service-settings/src/settings-service.test.ts

Lines changed: 114 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -90,7 +90,42 @@ describe('SettingsService — encryption round-trip', () => {
9090
await svc.setMany('mail', { provider: 'sendgrid', api_key: 'sg-secret-123', from_email: 'a@b.com' });
9191
const ns = await svc.getNamespace('mail');
9292
expect(ns.values.api_key.value).toBe('sg-secret-123');
93-
expect(ns.values.api_key.source).toBe('tenant');
93+
expect(ns.values.api_key.source).toBe('global');
94+
});
95+
});
96+
97+
describe('SettingsService — global scope', () => {
98+
it('mail manifest defaults to global scope', () => {
99+
expect(mailSettingsManifest.scope).toBe('global');
100+
});
101+
102+
it('returns source="global" for platform-wide values', async () => {
103+
const svc = new SettingsService({ env: {} });
104+
svc.registerManifest(mailSettingsManifest);
105+
await svc.setMany('mail', { provider: 'smtp', from_email: 'ops@example.com' });
106+
const r = await svc.get('mail', 'from_email');
107+
expect(r.source).toBe('global');
108+
expect(r.value).toBe('ops@example.com');
109+
expect(r.locked).toBe(false);
110+
});
111+
112+
it('global value is visible from any user context (no per-user isolation)', async () => {
113+
const svc = new SettingsService({ env: {} });
114+
svc.registerManifest(mailSettingsManifest);
115+
await svc.setMany('mail', { provider: 'smtp', from_email: 'ops@example.com' }, { userId: 'u1' });
116+
const fromU2 = await svc.get('mail', 'from_email', { userId: 'u2' });
117+
expect(fromU2.source).toBe('global');
118+
expect(fromU2.value).toBe('ops@example.com');
119+
});
120+
121+
it('env still wins over global', async () => {
122+
const svc = new SettingsService({ env: { MAIL_FROM_EMAIL: 'env@example.com' } });
123+
svc.registerManifest(mailSettingsManifest);
124+
await svc.set('mail', 'from_email', 'global@example.com').catch(() => {});
125+
const r = await svc.get('mail', 'from_email');
126+
expect(r.source).toBe('env');
127+
expect(r.value).toBe('env@example.com');
128+
expect(r.locked).toBe(true);
94129
});
95130
});
96131

@@ -178,3 +213,81 @@ describe('SettingsService — user-scoped values', () => {
178213
expect((await svc.get('prefs', 'nick', { userId: 'u3' })).value).toBe('anon');
179214
});
180215
});
216+
217+
describe('SettingsService — Phase 1 change events + client', () => {
218+
it('fires settings:changed on set with namespace, key, scope, action', async () => {
219+
const svc = new SettingsService({ env: {} });
220+
svc.registerManifest(mailSettingsManifest);
221+
const events: any[] = [];
222+
const off = svc.subscribe('mail', (e) => events.push(e));
223+
224+
await svc.set('mail', 'from_email', 'a@b.c');
225+
await svc.set('mail', 'from_email', null);
226+
227+
expect(events).toHaveLength(2);
228+
expect(events[0]).toMatchObject({ namespace: 'mail', key: 'from_email', scope: 'global', action: 'set' });
229+
expect(events[1]).toMatchObject({ namespace: 'mail', key: 'from_email', scope: 'global', action: 'reset' });
230+
expect(typeof events[0].at).toBe('string');
231+
232+
off();
233+
await svc.set('mail', 'from_email', 'x@y.z');
234+
expect(events).toHaveLength(2);
235+
});
236+
237+
it('filters subscribers by namespace', async () => {
238+
const svc = new SettingsService({ env: {} });
239+
svc.registerManifest(mailSettingsManifest);
240+
svc.registerManifest(brandingSettingsManifest);
241+
const mailEvents: any[] = [];
242+
const allEvents: any[] = [];
243+
svc.subscribe('mail', (e) => mailEvents.push(e));
244+
svc.subscribe(undefined, (e) => allEvents.push(e));
245+
246+
await svc.set('mail', 'from_email', 'a@b.c');
247+
await svc.set('branding', 'workspace_name', 'X');
248+
249+
expect(mailEvents).toHaveLength(1);
250+
expect(allEvents).toHaveLength(2);
251+
});
252+
253+
it('createClient exposes reactive snapshot that refreshes after set', async () => {
254+
const svc = new SettingsService({ env: {} });
255+
svc.registerManifest(mailSettingsManifest);
256+
await svc.set('mail', 'from_email', 'initial@x.y');
257+
258+
const client = await svc.createClient<{ from_email?: string; provider?: string }>('mail');
259+
expect(client.current.from_email).toBe('initial@x.y');
260+
expect(client.get('provider')).toBe('smtp');
261+
262+
await svc.set('mail', 'from_email', 'updated@x.y');
263+
// Allow microtask drain so the subscriber callback completes.
264+
await new Promise((r) => setImmediate(r));
265+
expect(client.current.from_email).toBe('updated@x.y');
266+
267+
client.dispose();
268+
});
269+
270+
it('createClient honours an explicit parser', async () => {
271+
const svc = new SettingsService({ env: {} });
272+
svc.registerManifest(mailSettingsManifest);
273+
await svc.set('mail', 'smtp_port', 2525);
274+
275+
const client = await svc.createClient<{ smtp_port: number; provider: string }>('mail', {
276+
parse: (raw) => ({
277+
smtp_port: Number(raw.smtp_port ?? 0),
278+
provider: String(raw.provider ?? 'smtp'),
279+
}),
280+
});
281+
expect(client.current).toEqual({ smtp_port: 2525, provider: 'smtp' });
282+
});
283+
284+
it('handler exceptions do not break the writer', async () => {
285+
const svc = new SettingsService({ env: {} });
286+
svc.registerManifest(mailSettingsManifest);
287+
svc.subscribe('mail', () => {
288+
throw new Error('listener boom');
289+
});
290+
// Must not throw despite the bad listener.
291+
await expect(svc.set('mail', 'from_email', 'ok@x.y')).resolves.toBeDefined();
292+
});
293+
});

0 commit comments

Comments
 (0)