From 16abe2fbb47ca7c04c004c04fb87af1da569973e Mon Sep 17 00:00:00 2001 From: Jack Zhuang <277994282+os-zhuang@users.noreply.github.com> Date: Mon, 1 Jun 2026 13:53:46 +0800 Subject: [PATCH] feat(auth): source email brand name from branding.workspace_name setting MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The `{{appName}}` brand in built-in auth emails (password reset, email verification, org invitation, magic link) was a static config value. Wire it to the live `branding.workspace_name` setting so admins can rename the product from the Branding settings UI without a redeploy. AuthPlugin binds on `kernel:ready` and re-applies on `settings:changed`, mirroring EmailServicePlugin's settings binding. Only an *explicitly set* value overrides the configured `appName` — when the resolver returns the manifest default, AuthManager keeps the configured `appName` (e.g. the deployment's OS_APP_NAME), preserving backward compatibility. Resolution order: branding.workspace_name (when set) > config.appName > 'ObjectStack'. Refs objectstack-ai/framework#1447. Co-Authored-By: Claude Opus 4.8 --- .../plugins/plugin-auth/src/auth-manager.ts | 18 ++++- .../plugin-auth/src/auth-plugin.test.ts | 66 ++++++++++++++++++- .../plugins/plugin-auth/src/auth-plugin.ts | 37 +++++++++++ 3 files changed, 119 insertions(+), 2 deletions(-) diff --git a/packages/plugins/plugin-auth/src/auth-manager.ts b/packages/plugins/plugin-auth/src/auth-manager.ts index 509156e4d..a9cf4c202 100644 --- a/packages/plugins/plugin-auth/src/auth-manager.ts +++ b/packages/plugins/plugin-auth/src/auth-manager.ts @@ -1100,9 +1100,25 @@ export class AuthManager { return this.config.emailService; } + /** + * Override the brand name surfaced in built-in auth emails (`{{appName}}`), + * sourced from the live `branding.workspace_name` setting. + * + * AuthPlugin calls this on `kernel:ready` (and again whenever the setting + * changes) once the `settings` service resolves. Passing `undefined` clears + * the override so resolution falls back to the configured `appName`. The + * value only reflects an *explicitly set* setting — when the operator has + * not customised it, AuthPlugin passes `undefined` so a deployment's + * configured `appName` (e.g. `OS_APP_NAME`) keeps precedence. + */ + setAppName(name: string | undefined): void { + this.appNameOverride = name?.trim() || undefined; + } + private appNameOverride?: string; + /** @internal `{{appName}}` placeholder value for built-in templates. */ private getAppName(): string { - return this.config.appName ?? 'ObjectStack'; + return this.appNameOverride ?? this.config.appName ?? 'ObjectStack'; } /** diff --git a/packages/plugins/plugin-auth/src/auth-plugin.test.ts b/packages/plugins/plugin-auth/src/auth-plugin.test.ts index 9f703fcb9..85caa2d9c 100644 --- a/packages/plugins/plugin-auth/src/auth-plugin.test.ts +++ b/packages/plugins/plugin-auth/src/auth-plugin.test.ts @@ -1,7 +1,8 @@ // Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. -import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import { AuthPlugin } from './auth-plugin'; +import { AuthManager } from './auth-manager'; import type { PluginContext } from '@objectstack/core'; describe('AuthPlugin', () => { @@ -385,6 +386,69 @@ describe('AuthPlugin', () => { }); }); + describe('Brand name binding (branding.workspace_name)', () => { + let hookCapture: ReturnType; + let setAppNameSpy: ReturnType; + + const makeSettings = (resolved: { value: unknown; source: string } | Error) => ({ + get: vi.fn(async () => { + if (resolved instanceof Error) throw resolved; + return resolved; + }), + subscribe: vi.fn(), + }); + + const bootWithSettings = async (settings: unknown) => { + hookCapture = createHookCapture(); + mockContext.hook = hookCapture.hookFn; + mockContext.getService = vi.fn((name: string) => { + if (name === 'manifest') return { register: vi.fn() }; + if (name === 'settings') return settings; + // 'data', 'email', 'http-server' → absent in this harness + return undefined; + }); + setAppNameSpy = vi.spyOn(AuthManager.prototype, 'setAppName'); + authPlugin = new AuthPlugin({ + secret: 'test-secret-at-least-32-chars-long', + baseUrl: 'http://localhost:3000', + appName: 'Configured Default', + }); + await authPlugin.init(mockContext); + await authPlugin.start(mockContext); + await hookCapture.trigger('kernel:ready'); + }; + + afterEach(() => { + setAppNameSpy?.mockRestore(); + }); + + it('overrides appName when the setting is explicitly set', async () => { + const settings = makeSettings({ value: 'Acme Corp', source: 'global' }); + await bootWithSettings(settings); + + expect(settings.get).toHaveBeenCalledWith('branding', 'workspace_name', {}); + expect(setAppNameSpy).toHaveBeenCalledWith('Acme Corp'); + expect(settings.subscribe).toHaveBeenCalledWith('branding', expect.any(Function)); + }); + + it('clears the override when the setting falls through to its manifest default', async () => { + const settings = makeSettings({ value: 'ObjectStack', source: 'default' }); + await bootWithSettings(settings); + + // source === 'default' means the operator never customised it, so the + // configured appName (e.g. OS_APP_NAME) must keep precedence. + expect(setAppNameSpy).toHaveBeenCalledWith(undefined); + }); + + it('does not throw when reading the setting fails', async () => { + const settings = makeSettings(new Error('boom')); + await expect(bootWithSettings(settings)).resolves.toBeUndefined(); + expect(mockContext.logger.warn).toHaveBeenCalledWith( + expect.stringContaining('failed to apply branding.workspace_name'), + ); + }); + }); + describe('Destroy Phase', () => { it('should cleanup resources', async () => { authPlugin = new AuthPlugin({ diff --git a/packages/plugins/plugin-auth/src/auth-plugin.ts b/packages/plugins/plugin-auth/src/auth-plugin.ts index 9a410c030..5c4511606 100644 --- a/packages/plugins/plugin-auth/src/auth-plugin.ts +++ b/packages/plugins/plugin-auth/src/auth-plugin.ts @@ -201,6 +201,43 @@ export class AuthPlugin implements Plugin { } catch { ctx.logger.info('Auth: no email service registered — auth callbacks will log instead of sending'); } + + // Bind the email brand name (`{{appName}}`) to the live + // `branding.workspace_name` setting so the admin UI can rename the + // product without a redeploy. Only an *explicitly set* value + // overrides the configured `appName` — when the operator hasn't + // customised it (resolver returns the manifest default), we clear + // the override so the deployment's `appName` (e.g. `OS_APP_NAME`) + // keeps precedence. Mirrors EmailServicePlugin's settings binding. + try { + const settings = ctx.getService('settings'); + if (settings && typeof settings.get === 'function') { + const applyBrand = async () => { + try { + const resolved = await settings.get('branding', 'workspace_name', {}); + const explicit = resolved && resolved.source !== 'default' + ? resolved.value + : undefined; + this.authManager?.setAppName( + typeof explicit === 'string' ? explicit : undefined, + ); + } catch (err: any) { + ctx.logger.warn( + 'Auth: failed to apply branding.workspace_name: ' + (err?.message ?? err), + ); + } + }; + await applyBrand(); + if (typeof settings.subscribe === 'function') { + settings.subscribe('branding', () => { + void applyBrand(); + }); + ctx.logger.info('Auth: bound appName to settings namespace=branding'); + } + } + } catch { + // settings service is optional — keep the configured appName. + } } let httpServer: IHttpServer | null = null;