Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions packages/cli/src/commands/serve.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ import {
resolveConsolePath,
hasConsoleDist,
createConsoleStaticPlugin,
createRuntimeAssetsPlugin,
} from '../utils/console.js';
import dotenvFlow from 'dotenv-flow';

Expand Down Expand Up @@ -814,6 +815,18 @@ export default class Serve extends Command {
}
}

// Serve /runtime/assets/* unconditionally — branding logos, favicons,
// and other static runtime assets must resolve even when the Console
// dist hasn't been built yet. The directory is resolved as:
// 1. OS_RUNTIME_ASSETS_DIR env var (explicit override)
// 2. process.cwd() + '/assets' (when CLI cwd is a runtime/ package)
// Silently skips if no assets directory exists.
const runtimeAssetsDir = (
process.env.OS_RUNTIME_ASSETS_DIR?.trim() ||
path.resolve(process.cwd(), 'assets')
);
await kernel.use(createRuntimeAssetsPlugin(runtimeAssetsDir));

// Unknown-environment hostname guard.
//
// In multi-tenant cloud deployments (e.g. *.objectos.ai), every
Expand Down
48 changes: 48 additions & 0 deletions packages/cli/src/utils/console.ts
Original file line number Diff line number Diff line change
Expand Up @@ -450,6 +450,54 @@ export function createConsoleStaticPlugin(distPath: string, options?: { isDev?:
};
}

// ─── Runtime Assets Plugin ──────────────────────────────────────────

/**
* Create a plugin that serves static runtime assets at /runtime/assets/*.
* Decoupled from the console plugin so branding assets (logos, favicons) are
* served even when the console dist hasn't been built yet.
*
* The `distPath` should point at the host project's `runtime/assets` directory
* (i.e. `path.resolve(process.cwd(), 'assets')` when the CLI cwd is the
* `runtime/` package).
*/
export function createRuntimeAssetsPlugin(distPath: string) {
return {
name: 'com.objectstack.runtime-assets',

init: async () => {},

start: async (ctx: any) => {
const httpServer = ctx.getService?.('http.server');
if (!httpServer?.getRawApp) return;

const app = httpServer.getRawApp();
const assetsDir = path.resolve(distPath);
if (!fs.existsSync(assetsDir)) return;

app.get('/runtime/assets/:filename', async (c: any) => {
const filename = String(c.req.param?.('filename') ?? '').replace(/[/\\]+/g, '');
const filePath = path.join(assetsDir, filename);
// Path-traversal guard: reject any path that escapes assetsDir.
if (!path.resolve(filePath).startsWith(path.resolve(assetsDir))) {
return c.text('Forbidden', 403);
}
try {
const content = fs.readFileSync(filePath);
return new Response(content, {
headers: {
'content-type': mimeType(filePath),
'cache-control': 'public, max-age=3600',
},
});
} catch {
return c.text('Not Found', 404);
}
});
},
};
}

// ─── Helpers ────────────────────────────────────────────────────────

const MIME_TYPES: Record<string, string> = {
Expand Down
56 changes: 56 additions & 0 deletions packages/cli/test/runtime-assets.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.

/**
* createRuntimeAssetsPlugin() — serves /runtime/assets/* unconditionally.
*
* The route must resolve even when the Console dist isn't built — unlike
* the rest of createConsoleStaticPlugin which early-returns when
* dist/index.html is missing.
*/
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import fs from 'fs';
import os from 'os';
import path from 'path';
import { createRuntimeAssetsPlugin } from '../src/utils/console.js';

// Atomically create a uniquely-named temp dir (random suffix) instead of a
// predictable `Date.now()` name — avoids the temp-file race/symlink attack
// flagged by CodeQL js/insecure-temporary-file.
const assetsDir = fs.mkdtempSync(path.join(os.tmpdir(), 'os-test-runtime-assets-'));
const testPng = path.join(assetsDir, 'test-logo.png');

beforeAll(() => {
fs.writeFileSync(testPng, Buffer.from('fake-png-content'));
Comment thread
github-advanced-security[bot] marked this conversation as resolved.
Fixed
});

afterAll(() => {
fs.rmSync(assetsDir, { recursive: true, force: true });
});

describe('createRuntimeAssetsPlugin', () => {
it('returns a plugin object with name, init, and start', () => {
const plugin = createRuntimeAssetsPlugin(assetsDir);
expect(plugin).toHaveProperty('name', 'com.objectstack.runtime-assets');
expect(plugin).toHaveProperty('init');
expect(plugin).toHaveProperty('start');
});

it('skips registration when assets dir does not exist', async () => {
const noopPlugin = createRuntimeAssetsPlugin('/nonexistent/dir');
const ctx = {
getService: () => ({
getRawApp: () => ({
get: () => { throw new Error('should not be called'); },
}),
}),
};
// Should not throw when dir doesn't exist — silently skips.
await expect(noopPlugin.start(ctx as any)).resolves.toBeUndefined();
});

it('skips registration when http server service is missing', async () => {
const plugin = createRuntimeAssetsPlugin(assetsDir);
const ctx = { getService: () => null };
await expect(plugin.start(ctx as any)).resolves.toBeUndefined();
});
});
32 changes: 31 additions & 1 deletion packages/cloud-connection/src/runtime-config-plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@
* singleEnvironment: boolean,
* defaultOrgId?, defaultEnvironmentId?, // multi-tenant, per-hostname
* features: { installLocal, marketplace, aiStudio, autoPublishAiBuilds, ... },
* branding: { productName, productShortName }
* branding: { productName, productShortName, logoUrl, faviconUrl, brandColor, pwaDescription, pwaThemeColor }
* }
*
* ## Feature seam (open-core boundary — cloud ADR-0012)
Expand Down Expand Up @@ -95,6 +95,16 @@ export interface RuntimeConfigPluginConfig {
productName?: string;
/** Short product name (PWA shortName, compact spots). Defaults to productName. */
productShortName?: string;
/** Absolute or relative URL for the product logo. Falls back to OS_LOGO_URL env var. */
logoUrl?: string;
/** Absolute or relative URL for the favicon. Falls back to OS_FAVICON_URL env var. */
faviconUrl?: string;
/** Primary brand hex color (e.g. '#4F46E5'). Falls back to OS_BRAND_COLOR env var. */
brandColor?: string;
/** PWA manifest description. Falls back to OS_PWA_DESCRIPTION env var. Default: "<productName> — runtime console". */
pwaDescription?: string;
/** PWA theme color hex. Falls back to OS_PWA_THEME_COLOR env var. Default: brandColor or '#4f46e5'. */
pwaThemeColor?: string;
/**
* Distribution feature-policy hook (open-core seam — cloud ADR-0012).
* Called with `undefined` for the static default (no environment resolved
Expand Down Expand Up @@ -123,6 +133,11 @@ export class RuntimeConfigPlugin implements Plugin {
private readonly singleEnvironment: boolean;
private readonly productName: string;
private readonly productShortName: string;
private readonly logoUrl: string | undefined;
private readonly faviconUrl: string | undefined;
private readonly brandColor: string | undefined;
private readonly pwaDescription: string;
private readonly pwaThemeColor: string;
private readonly resolveFeatures?: (token: string | undefined) => RuntimeFeatureOverrides;

constructor(config: RuntimeConfigPluginConfig = {}) {
Expand All @@ -140,6 +155,16 @@ export class RuntimeConfigPlugin implements Plugin {
const envShort = (typeof process !== 'undefined' ? process.env?.OS_PRODUCT_SHORT_NAME : undefined)?.trim();
this.productName = (config.productName ?? envName ?? 'ObjectOS').trim() || 'ObjectOS';
this.productShortName = (config.productShortName ?? envShort ?? this.productName).trim() || this.productName;
const envLogoUrl = (typeof process !== 'undefined' ? process.env?.OS_LOGO_URL : undefined)?.trim();
const envFaviconUrl = (typeof process !== 'undefined' ? process.env?.OS_FAVICON_URL : undefined)?.trim();
const envBrandColor = (typeof process !== 'undefined' ? process.env?.OS_BRAND_COLOR : undefined)?.trim();
const envPwaDescription = (typeof process !== 'undefined' ? process.env?.OS_PWA_DESCRIPTION : undefined)?.trim();
const envPwaThemeColor = (typeof process !== 'undefined' ? process.env?.OS_PWA_THEME_COLOR : undefined)?.trim();
this.logoUrl = config.logoUrl ?? envLogoUrl;
this.faviconUrl = config.faviconUrl ?? envFaviconUrl;
this.brandColor = config.brandColor ?? envBrandColor;
this.pwaDescription = config.pwaDescription ?? envPwaDescription ?? `${this.productName} — runtime console`;
this.pwaThemeColor = config.pwaThemeColor ?? envPwaThemeColor ?? this.brandColor ?? '#4f46e5';
}

init = async (_ctx: PluginContext): Promise<void> => {};
Expand Down Expand Up @@ -244,6 +269,11 @@ export class RuntimeConfigPlugin implements Plugin {
branding: {
productName: this.productName,
productShortName: this.productShortName,
logoUrl: this.logoUrl,
faviconUrl: this.faviconUrl,
brandColor: this.brandColor,
pwaDescription: this.pwaDescription,
pwaThemeColor: this.pwaThemeColor,
},
});
};
Expand Down
Loading