Skip to content

Commit d9566cc

Browse files
akarma-synetalcursoragentos-zhuangclaude
authored
feat: expose extended branding fields in runtime config API (#2799)
* feat: expose extended branding fields in runtime config API Add logoUrl, faviconUrl, brandColor, pwaDescription, and pwaThemeColor to the /api/v1/runtime/config response, driven by corresponding OS_* environment variables. Also decouples /runtime/assets/* serving from the console plugin into a standalone createRuntimeAssetsPlugin so branding assets resolve even when the console dist hasn't been built yet. Accepts an optional OS_RUNTIME_ASSETS_DIR override; falls back to process.cwd()/assets. Co-authored-by: Cursor <cursoragent@cursor.com> * test(cli): use mkdtempSync for runtime-assets temp dir CodeQL flagged js/insecure-temporary-file (high) on the test: it built a temp dir from a predictable `Date.now()` name and wrote into it, which is open to a temp-file race / symlink attack in the shared OS temp dir. Use fs.mkdtempSync() to atomically create a uniquely-named directory with a random suffix — the pattern CodeQL recognizes as safe — and drop the now redundant mkdirSync. Behavior is otherwise unchanged; all 3 tests pass. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> --------- Co-authored-by: Cursor <cursoragent@cursor.com> Co-authored-by: os-zhuang <jack@objectstack.ai> Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
1 parent ba2075d commit d9566cc

4 files changed

Lines changed: 148 additions & 1 deletion

File tree

packages/cli/src/commands/serve.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ import {
2525
resolveConsolePath,
2626
hasConsoleDist,
2727
createConsoleStaticPlugin,
28+
createRuntimeAssetsPlugin,
2829
} from '../utils/console.js';
2930
import dotenvFlow from 'dotenv-flow';
3031

@@ -816,6 +817,18 @@ export default class Serve extends Command {
816817
}
817818
}
818819

820+
// Serve /runtime/assets/* unconditionally — branding logos, favicons,
821+
// and other static runtime assets must resolve even when the Console
822+
// dist hasn't been built yet. The directory is resolved as:
823+
// 1. OS_RUNTIME_ASSETS_DIR env var (explicit override)
824+
// 2. process.cwd() + '/assets' (when CLI cwd is a runtime/ package)
825+
// Silently skips if no assets directory exists.
826+
const runtimeAssetsDir = (
827+
process.env.OS_RUNTIME_ASSETS_DIR?.trim() ||
828+
path.resolve(process.cwd(), 'assets')
829+
);
830+
await kernel.use(createRuntimeAssetsPlugin(runtimeAssetsDir));
831+
819832
// Unknown-environment hostname guard.
820833
//
821834
// In multi-tenant cloud deployments (e.g. *.objectos.ai), every

packages/cli/src/utils/console.ts

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -450,6 +450,54 @@ export function createConsoleStaticPlugin(distPath: string, options?: { isDev?:
450450
};
451451
}
452452

453+
// ─── Runtime Assets Plugin ──────────────────────────────────────────
454+
455+
/**
456+
* Create a plugin that serves static runtime assets at /runtime/assets/*.
457+
* Decoupled from the console plugin so branding assets (logos, favicons) are
458+
* served even when the console dist hasn't been built yet.
459+
*
460+
* The `distPath` should point at the host project's `runtime/assets` directory
461+
* (i.e. `path.resolve(process.cwd(), 'assets')` when the CLI cwd is the
462+
* `runtime/` package).
463+
*/
464+
export function createRuntimeAssetsPlugin(distPath: string) {
465+
return {
466+
name: 'com.objectstack.runtime-assets',
467+
468+
init: async () => {},
469+
470+
start: async (ctx: any) => {
471+
const httpServer = ctx.getService?.('http.server');
472+
if (!httpServer?.getRawApp) return;
473+
474+
const app = httpServer.getRawApp();
475+
const assetsDir = path.resolve(distPath);
476+
if (!fs.existsSync(assetsDir)) return;
477+
478+
app.get('/runtime/assets/:filename', async (c: any) => {
479+
const filename = String(c.req.param?.('filename') ?? '').replace(/[/\\]+/g, '');
480+
const filePath = path.join(assetsDir, filename);
481+
// Path-traversal guard: reject any path that escapes assetsDir.
482+
if (!path.resolve(filePath).startsWith(path.resolve(assetsDir))) {
483+
return c.text('Forbidden', 403);
484+
}
485+
try {
486+
const content = fs.readFileSync(filePath);
487+
return new Response(content, {
488+
headers: {
489+
'content-type': mimeType(filePath),
490+
'cache-control': 'public, max-age=3600',
491+
},
492+
});
493+
} catch {
494+
return c.text('Not Found', 404);
495+
}
496+
});
497+
},
498+
};
499+
}
500+
453501
// ─── Helpers ────────────────────────────────────────────────────────
454502

455503
const MIME_TYPES: Record<string, string> = {
Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* createRuntimeAssetsPlugin() — serves /runtime/assets/* unconditionally.
5+
*
6+
* The route must resolve even when the Console dist isn't built — unlike
7+
* the rest of createConsoleStaticPlugin which early-returns when
8+
* dist/index.html is missing.
9+
*/
10+
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
11+
import fs from 'fs';
12+
import os from 'os';
13+
import path from 'path';
14+
import { createRuntimeAssetsPlugin } from '../src/utils/console.js';
15+
16+
// Atomically create a uniquely-named temp dir (random suffix) instead of a
17+
// predictable `Date.now()` name — avoids the temp-file race/symlink attack
18+
// flagged by CodeQL js/insecure-temporary-file.
19+
const assetsDir = fs.mkdtempSync(path.join(os.tmpdir(), 'os-test-runtime-assets-'));
20+
const testPng = path.join(assetsDir, 'test-logo.png');
21+
22+
beforeAll(() => {
23+
fs.writeFileSync(testPng, Buffer.from('fake-png-content'));
24+
});
25+
26+
afterAll(() => {
27+
fs.rmSync(assetsDir, { recursive: true, force: true });
28+
});
29+
30+
describe('createRuntimeAssetsPlugin', () => {
31+
it('returns a plugin object with name, init, and start', () => {
32+
const plugin = createRuntimeAssetsPlugin(assetsDir);
33+
expect(plugin).toHaveProperty('name', 'com.objectstack.runtime-assets');
34+
expect(plugin).toHaveProperty('init');
35+
expect(plugin).toHaveProperty('start');
36+
});
37+
38+
it('skips registration when assets dir does not exist', async () => {
39+
const noopPlugin = createRuntimeAssetsPlugin('/nonexistent/dir');
40+
const ctx = {
41+
getService: () => ({
42+
getRawApp: () => ({
43+
get: () => { throw new Error('should not be called'); },
44+
}),
45+
}),
46+
};
47+
// Should not throw when dir doesn't exist — silently skips.
48+
await expect(noopPlugin.start(ctx as any)).resolves.toBeUndefined();
49+
});
50+
51+
it('skips registration when http server service is missing', async () => {
52+
const plugin = createRuntimeAssetsPlugin(assetsDir);
53+
const ctx = { getService: () => null };
54+
await expect(plugin.start(ctx as any)).resolves.toBeUndefined();
55+
});
56+
});

packages/cloud-connection/src/runtime-config-plugin.ts

Lines changed: 31 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@
1515
* singleEnvironment: boolean,
1616
* defaultOrgId?, defaultEnvironmentId?, // multi-tenant, per-hostname
1717
* features: { installLocal, marketplace, aiStudio, autoPublishAiBuilds, ... },
18-
* branding: { productName, productShortName }
18+
* branding: { productName, productShortName, logoUrl, faviconUrl, brandColor, pwaDescription, pwaThemeColor }
1919
* }
2020
*
2121
* ## Feature seam (open-core boundary — cloud ADR-0012)
@@ -95,6 +95,16 @@ export interface RuntimeConfigPluginConfig {
9595
productName?: string;
9696
/** Short product name (PWA shortName, compact spots). Defaults to productName. */
9797
productShortName?: string;
98+
/** Absolute or relative URL for the product logo. Falls back to OS_LOGO_URL env var. */
99+
logoUrl?: string;
100+
/** Absolute or relative URL for the favicon. Falls back to OS_FAVICON_URL env var. */
101+
faviconUrl?: string;
102+
/** Primary brand hex color (e.g. '#4F46E5'). Falls back to OS_BRAND_COLOR env var. */
103+
brandColor?: string;
104+
/** PWA manifest description. Falls back to OS_PWA_DESCRIPTION env var. Default: "<productName> — runtime console". */
105+
pwaDescription?: string;
106+
/** PWA theme color hex. Falls back to OS_PWA_THEME_COLOR env var. Default: brandColor or '#4f46e5'. */
107+
pwaThemeColor?: string;
98108
/**
99109
* Distribution feature-policy hook (open-core seam — cloud ADR-0012).
100110
* Called with `undefined` for the static default (no environment resolved
@@ -123,6 +133,11 @@ export class RuntimeConfigPlugin implements Plugin {
123133
private readonly singleEnvironment: boolean;
124134
private readonly productName: string;
125135
private readonly productShortName: string;
136+
private readonly logoUrl: string | undefined;
137+
private readonly faviconUrl: string | undefined;
138+
private readonly brandColor: string | undefined;
139+
private readonly pwaDescription: string;
140+
private readonly pwaThemeColor: string;
126141
private readonly resolveFeatures?: (token: string | undefined) => RuntimeFeatureOverrides;
127142

128143
constructor(config: RuntimeConfigPluginConfig = {}) {
@@ -140,6 +155,16 @@ export class RuntimeConfigPlugin implements Plugin {
140155
const envShort = (typeof process !== 'undefined' ? process.env?.OS_PRODUCT_SHORT_NAME : undefined)?.trim();
141156
this.productName = (config.productName ?? envName ?? 'ObjectOS').trim() || 'ObjectOS';
142157
this.productShortName = (config.productShortName ?? envShort ?? this.productName).trim() || this.productName;
158+
const envLogoUrl = (typeof process !== 'undefined' ? process.env?.OS_LOGO_URL : undefined)?.trim();
159+
const envFaviconUrl = (typeof process !== 'undefined' ? process.env?.OS_FAVICON_URL : undefined)?.trim();
160+
const envBrandColor = (typeof process !== 'undefined' ? process.env?.OS_BRAND_COLOR : undefined)?.trim();
161+
const envPwaDescription = (typeof process !== 'undefined' ? process.env?.OS_PWA_DESCRIPTION : undefined)?.trim();
162+
const envPwaThemeColor = (typeof process !== 'undefined' ? process.env?.OS_PWA_THEME_COLOR : undefined)?.trim();
163+
this.logoUrl = config.logoUrl ?? envLogoUrl;
164+
this.faviconUrl = config.faviconUrl ?? envFaviconUrl;
165+
this.brandColor = config.brandColor ?? envBrandColor;
166+
this.pwaDescription = config.pwaDescription ?? envPwaDescription ?? `${this.productName} — runtime console`;
167+
this.pwaThemeColor = config.pwaThemeColor ?? envPwaThemeColor ?? this.brandColor ?? '#4f46e5';
143168
}
144169

145170
init = async (_ctx: PluginContext): Promise<void> => {};
@@ -244,6 +269,11 @@ export class RuntimeConfigPlugin implements Plugin {
244269
branding: {
245270
productName: this.productName,
246271
productShortName: this.productShortName,
272+
logoUrl: this.logoUrl,
273+
faviconUrl: this.faviconUrl,
274+
brandColor: this.brandColor,
275+
pwaDescription: this.pwaDescription,
276+
pwaThemeColor: this.pwaThemeColor,
247277
},
248278
});
249279
};

0 commit comments

Comments
 (0)