Skip to content

Commit 587872f

Browse files
committed
wip(cloud-connection): runtime-config branding reads the Branding settings per request
Claude-Session: https://claude.ai/code/session_01UmqzD97e14iws6hmVhgZ5y Co-authored-by: Claude <noreply@anthropic.com>
1 parent 40cf959 commit 587872f

1 file changed

Lines changed: 248 additions & 0 deletions

File tree

Lines changed: 248 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,248 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* `branding` reads the Branding settings, then the deployment's own (#24766,
5+
* the maintainer's ruling B′ and its amendment).
6+
*
7+
* productName ← branding.workspace_name
8+
* logoUrl ← branding.logo_url
9+
* brandColor ← branding.accent_color
10+
*
11+
* A value somebody set wins; a key nobody set falls back to the deployment
12+
* (`OS_PRODUCT_NAME` / `OS_LOGO_URL` / `OS_BRAND_COLOR`); the read happens on
13+
* every request, so a save shows on the next load. Under a walled posture the
14+
* settings service does not list the namespace (`service-settings`' own pin,
15+
* `settings-branding-posture.pin.test.ts`), and then this anonymous door must
16+
* not read it at all.
17+
*
18+
* The `settings` service here is a stand-in with the real service's contract
19+
* for the two calls this door makes: `listManifests()` (a trusted in-process
20+
* caller sees every registered manifest) and `getMany()` answering
21+
* `{ value, source }` per key, `source: 'default'` for a key nobody set. Its
22+
* `getMany` ANSWERS even when the namespace is not listed — deliberately, so a
23+
* door that skipped the listing check would serve the saved values and the
24+
* walled-posture pin would go red. This package does not depend on
25+
* `@objectstack/service-settings`.
26+
*/
27+
28+
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
29+
import { RuntimeConfigPlugin, type RuntimeConfigPluginConfig } from './runtime-config-plugin.js';
30+
31+
const BRANDING_ENV = [
32+
'OS_PRODUCT_NAME', 'OS_PRODUCT_SHORT_NAME', 'OS_LOGO_URL', 'OS_BRAND_COLOR',
33+
'OS_PWA_DESCRIPTION', 'OS_PWA_THEME_COLOR',
34+
] as const;
35+
36+
/** The manifest defaults the real Branding manifest declares (`accent_color` and `logo_url` declare none). */
37+
const MANIFEST_DEFAULTS: Record<string, unknown> = { workspace_name: 'ObjectStack' };
38+
39+
interface Stub {
40+
/** Whether `listManifests()` lists `branding` — false is the walled posture. */
41+
offered: boolean;
42+
/** Values somebody set, by key, with the `source` the service would report. */
43+
saved: Record<string, { value: unknown; source: string }>;
44+
/** Make every `getMany` reject. */
45+
fail?: Error;
46+
/** Every `getMany` call, in order. */
47+
calls: Array<{ namespace: string; keys: readonly string[]; ctx: unknown }>;
48+
}
49+
50+
function settingsService(stub: Stub) {
51+
return {
52+
listManifests: () => [
53+
{ namespace: 'localization', specifiers: [{ key: 'timezone' }] },
54+
...(stub.offered
55+
? [{
56+
namespace: 'branding',
57+
specifiers: [
58+
{ type: 'group', id: 'identity' }, { key: 'workspace_name' },
59+
{ type: 'group', id: 'appearance' }, { key: 'accent_color' }, { key: 'logo_url' },
60+
],
61+
}]
62+
: []),
63+
],
64+
getMany: async (namespace: string, keys: readonly string[], ctx?: unknown) => {
65+
stub.calls.push({ namespace, keys, ctx });
66+
if (stub.fail) throw stub.fail;
67+
return Object.fromEntries(keys.map((k) => [
68+
k,
69+
stub.saved[k] ?? { value: MANIFEST_DEFAULTS[k], source: 'default' },
70+
]));
71+
},
72+
};
73+
}
74+
75+
/**
76+
* Mount the plugin on a Hono-shaped raw app, the harness shape of
77+
* `runtime-config-stage.test.ts`, with a `settings` service when given one.
78+
* Returns a function serving one request per call, and the warnings.
79+
*/
80+
async function mount(opts: { pluginConfig?: RuntimeConfigPluginConfig; settings?: unknown } = {}) {
81+
let handler: ((c: any) => Promise<any>) | undefined;
82+
const rawApp = {
83+
routes: [] as Array<{ method: string; path: string }>,
84+
get(path: string, h: (c: any) => Promise<any>) {
85+
this.routes.push({ method: 'GET', path });
86+
if (path === '/api/v1/runtime/config') handler = h;
87+
},
88+
};
89+
const warnings: string[] = [];
90+
const ctx: any = {
91+
logger: { info() {}, warn: (m: string) => { warnings.push(String(m)); } },
92+
getService: (n: string) => {
93+
if (n === 'http-server') return { getRawApp: () => rawApp };
94+
if (n === 'settings' && opts.settings) return opts.settings;
95+
throw new Error(`no ${n}`);
96+
},
97+
hooks: [] as Array<() => Promise<void>>,
98+
hook(_e: string, cb: () => Promise<void>) { this.hooks.push(cb); },
99+
};
100+
const plugin = new RuntimeConfigPlugin({ controlPlaneUrl: '', singleEnvironment: true, ...opts.pluginConfig });
101+
await plugin.start(ctx);
102+
for (const cb of ctx.hooks) await cb();
103+
if (!handler) throw new Error('handler not mounted');
104+
const serve = async () => (await handler!({ req: { header: () => undefined }, json: (b: any) => b })).branding;
105+
return { serve, warnings };
106+
}
107+
108+
const newStub = (over: Partial<Stub> = {}): Stub => ({ offered: true, saved: {}, calls: [], ...over });
109+
110+
describe('RuntimeConfigPlugin — branding from the Branding settings (#24766)', () => {
111+
const saved: Record<string, string | undefined> = {};
112+
beforeEach(() => {
113+
for (const k of BRANDING_ENV) { saved[k] = process.env[k]; delete process.env[k]; }
114+
process.env.OS_PRODUCT_NAME = 'Env Product';
115+
process.env.OS_LOGO_URL = 'https://env.test/logo.svg';
116+
process.env.OS_BRAND_COLOR = '#111111';
117+
});
118+
afterEach(() => {
119+
for (const k of BRANDING_ENV) {
120+
if (saved[k] === undefined) delete process.env[k];
121+
else process.env[k] = saved[k];
122+
}
123+
});
124+
125+
describe('under single (the namespace is offered)', () => {
126+
it('a saved logo URL is the one served', async () => {
127+
const stub = newStub({ saved: { logo_url: { value: 'https://acme.test/logo.svg', source: 'tenant' } } });
128+
const { serve } = await mount({ settings: settingsService(stub) });
129+
expect((await serve()).logoUrl).toBe('https://acme.test/logo.svg');
130+
});
131+
132+
it('an unset logo URL falls back to OS_LOGO_URL', async () => {
133+
const { serve } = await mount({ settings: settingsService(newStub()) });
134+
expect((await serve()).logoUrl).toBe('https://env.test/logo.svg');
135+
});
136+
137+
it('a saved name is the product name; an unset one falls back to OS_PRODUCT_NAME, never the manifest default', async () => {
138+
const set = await mount({
139+
settings: settingsService(newStub({ saved: { workspace_name: { value: 'Acme Ops', source: 'tenant' } } })),
140+
});
141+
expect((await set.serve()).productName).toBe('Acme Ops');
142+
143+
// `workspace_name` declares the default 'ObjectStack'; nobody set it.
144+
const unset = await mount({ settings: settingsService(newStub()) });
145+
expect((await unset.serve()).productName).toBe('Env Product');
146+
});
147+
148+
it('a saved colour is the brand colour; an unset one falls back to OS_BRAND_COLOR', async () => {
149+
const set = await mount({
150+
settings: settingsService(newStub({ saved: { accent_color: { value: '#e11d48', source: 'tenant' } } })),
151+
});
152+
expect((await set.serve()).brandColor).toBe('#e11d48');
153+
154+
const unset = await mount({ settings: settingsService(newStub()) });
155+
expect((await unset.serve()).brandColor).toBe('#111111');
156+
});
157+
158+
it('the derived fields follow the served name and colour unless configured themselves', async () => {
159+
const stub = newStub({
160+
saved: {
161+
workspace_name: { value: 'Acme Ops', source: 'tenant' },
162+
accent_color: { value: '#e11d48', source: 'tenant' },
163+
},
164+
});
165+
const derived = await mount({ settings: settingsService(stub) });
166+
expect(await derived.serve()).toMatchObject({
167+
productShortName: 'Acme Ops',
168+
pwaDescription: 'Acme Ops — runtime console',
169+
pwaThemeColor: '#e11d48',
170+
});
171+
172+
process.env.OS_PRODUCT_SHORT_NAME = 'EP';
173+
process.env.OS_PWA_THEME_COLOR = '#222222';
174+
const configured = await mount({ settings: settingsService(stub) });
175+
expect(await configured.serve()).toMatchObject({ productShortName: 'EP', pwaThemeColor: '#222222' });
176+
});
177+
178+
it('a saved value outranks the host option too: the option is the deployment default, like the env var', async () => {
179+
const stub = newStub({ saved: { logo_url: { value: 'https://acme.test/logo.svg', source: 'tenant' } } });
180+
const { serve } = await mount({
181+
pluginConfig: { logoUrl: '/host-logo.svg', productName: 'Host Product' },
182+
settings: settingsService(stub),
183+
});
184+
const branding = await serve();
185+
expect(branding.logoUrl).toBe('https://acme.test/logo.svg');
186+
// Nobody set a name: the host option stays the fallback, ahead of the env var.
187+
expect(branding.productName).toBe('Host Product');
188+
});
189+
190+
it('reads per request, not once at construction: a save shows on the next load', async () => {
191+
const stub = newStub();
192+
const { serve } = await mount({ settings: settingsService(stub) });
193+
expect((await serve()).productName).toBe('Env Product');
194+
stub.saved.workspace_name = { value: 'Renamed', source: 'tenant' };
195+
expect((await serve()).productName).toBe('Renamed');
196+
// One batched read per request, of the three declared keys, naming no organization.
197+
expect(stub.calls).toHaveLength(2);
198+
expect(stub.calls[1]).toEqual({
199+
namespace: 'branding',
200+
keys: ['workspace_name', 'logo_url', 'accent_color'],
201+
ctx: {},
202+
});
203+
});
204+
205+
it('the settings layer\'s own env override counts as set (source env)', async () => {
206+
const stub = newStub({ saved: { workspace_name: { value: 'Pinned', source: 'env' } } });
207+
const { serve } = await mount({ settings: settingsService(stub) });
208+
expect((await serve()).productName).toBe('Pinned');
209+
});
210+
211+
it('a read that fails serves the deployment branding and warns once, never a 500', async () => {
212+
const stub = newStub({ fail: new Error('store down') });
213+
const { serve, warnings } = await mount({ settings: settingsService(stub) });
214+
expect(await serve()).toMatchObject({
215+
productName: 'Env Product', logoUrl: 'https://env.test/logo.svg', brandColor: '#111111',
216+
});
217+
await serve();
218+
const lines = warnings.filter((w) => w.includes('store down'));
219+
expect(lines).toHaveLength(1);
220+
});
221+
});
222+
223+
describe('under group / isolated (the namespace is not offered)', () => {
224+
it('the setting is not served on the anonymous door, and the door never reads it', async () => {
225+
const stub = newStub({
226+
offered: false,
227+
saved: {
228+
workspace_name: { value: 'Tenant Org', source: 'tenant' },
229+
logo_url: { value: 'https://tenant.test/logo.svg', source: 'tenant' },
230+
accent_color: { value: '#abcdef', source: 'tenant' },
231+
},
232+
});
233+
const { serve } = await mount({ settings: settingsService(stub) });
234+
expect(await serve()).toMatchObject({
235+
productName: 'Env Product', logoUrl: 'https://env.test/logo.svg', brandColor: '#111111',
236+
});
237+
expect(stub.calls).toEqual([]);
238+
});
239+
});
240+
241+
it('with no settings service the deployment branding is served, as before', async () => {
242+
const { serve } = await mount();
243+
expect(await serve()).toMatchObject({
244+
productName: 'Env Product', logoUrl: 'https://env.test/logo.svg', brandColor: '#111111',
245+
productShortName: 'Env Product', pwaThemeColor: '#111111',
246+
});
247+
});
248+
});

0 commit comments

Comments
 (0)