diff --git a/.changeset/22071-runtime-assets-boot-warning.md b/.changeset/22071-runtime-assets-boot-warning.md new file mode 100644 index 00000000000..0091966d5dd --- /dev/null +++ b/.changeset/22071-runtime-assets-boot-warning.md @@ -0,0 +1,13 @@ +--- +"@objectstack/cli": patch +--- + +`os serve` (and `objectstack start`, which runs it) now says at boot when an app's branding logo or favicon will not be served. Before, the runtime assets route was skipped without a word when its directory was absent, so an artifact booted outside its project directory drew a broken logo and favicon and nothing in the boot output said why. + +Clause-②: no + +- Once the boot settles, every loaded app whose `branding.logo` or `branding.favicon` is a root path under `/runtime/assets/` that the route will not serve gets one warning line per file. The line names the apps and keys that use the file, the directory searched, and whether that directory came from `OS_RUNTIME_ASSETS_DIR` or the `assets/` default under the working directory. When the directory does not exist, the line says so and says nothing under `/runtime/assets/` is mounted for this run. +- The apps read are the ones the console is served, through the same metadata protocol read that `GET /api/v1/meta/app` answers from. Config boots and artifact boots are both covered. +- Whether a file is servable is decided by the route's own filename resolution, so the warning and the route cannot disagree. Absolute URLs, protocol-relative URLs, data URIs, relative paths and other root paths are not checked. +- The line goes through the kernel logger at `warn`. It shows in the banner's *Boot diagnostics* block, streams live at `--log-level debug` or `info`, and is hidden at `error` or `silent` like every other boot warning. +- ⛔ What `/runtime/assets/*` serves does not change. No route is added or removed, and the artifact still carries no asset files: ship the `assets/` directory beside it, or point `OS_RUNTIME_ASSETS_DIR` at the files. diff --git a/packages/cli/src/commands/serve.ts b/packages/cli/src/commands/serve.ts index 3948606244a..649706c768d 100644 --- a/packages/cli/src/commands/serve.ts +++ b/packages/cli/src/commands/serve.ts @@ -3715,12 +3715,16 @@ export default class Serve extends Command { // 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)); + // No route is mounted when the directory does not exist. That is not + // silent (#22071): once the boot settles, the plugin warns for every + // loaded app's branding logo / favicon under /runtime/assets/ it will not + // serve, naming the directory searched and which of the two it came from. + const runtimeAssetsDirFromEnv = process.env.OS_RUNTIME_ASSETS_DIR?.trim(); + const runtimeAssetsDir = runtimeAssetsDirFromEnv || path.resolve(process.cwd(), 'assets'); + await kernel.use(createRuntimeAssetsPlugin( + runtimeAssetsDir, + runtimeAssetsDirFromEnv ? 'OS_RUNTIME_ASSETS_DIR' : 'cwd', + )); // Unknown-environment hostname guard. // diff --git a/packages/cli/src/utils/console.ts b/packages/cli/src/utils/console.ts index 22d9c5763ea..d34b216472a 100644 --- a/packages/cli/src/utils/console.ts +++ b/packages/cli/src/utils/console.ts @@ -591,6 +591,152 @@ export function createConsoleStaticPlugin(distPath: string, options?: { isDev?: // ─── Runtime Assets Plugin ────────────────────────────────────────── +/** The URL prefix the runtime assets route is mounted under. */ +const RUNTIME_ASSETS_URL_PREFIX = '/runtime/assets/'; + +/** + * Where the host found the directory it handed {@link createRuntimeAssetsPlugin}: + * named by `OS_RUNTIME_ASSETS_DIR`, or the `/assets` default taken because + * that variable is unset. Only the boot warning reads it, to name the remedy. + * Not exported: this module's export set is pinned + * (`test/published-subpath-console.pin.test.ts`), and the union is spelled out + * structurally in the plugin's signature. + */ +type RuntimeAssetsDirSource = 'OS_RUNTIME_ASSETS_DIR' | 'cwd'; + +/** + * The ONE resolution of a `/runtime/assets/:filename` parameter to a path on + * disk. The route serves through it and the boot check judges through it + * (#22071), so the two cannot disagree about which file a name means or which + * names are refused. `null` means the name escapes `assetsDir`, which the route + * answers 403. + */ +function resolveRuntimeAssetPath(assetsDir: string, filename: string): string | null { + const filePath = path.join(assetsDir, filename.replace(/[/\\]+/g, '')); + // Path-traversal guard: reject any path that escapes assetsDir. + if (!path.resolve(filePath).startsWith(path.resolve(assetsDir))) return null; + return filePath; +} + +/** + * Whether the route answers 200 for this `:filename`: the same resolution, then + * what the route's `readFileSync` needs, a readable regular file. A directory, + * a missing file and a refused name all answer no. + */ +function runtimeAssetIsServable(assetsDir: string, filename: string): boolean { + const filePath = resolveRuntimeAssetPath(assetsDir, filename); + if (filePath === null) return false; + try { + if (!fs.statSync(filePath).isFile()) return false; + fs.accessSync(filePath, fs.constants.R_OK); + return true; + } catch { + return false; + } +} + +/** + * What a branding URL asks the runtime assets route for, read the way a browser + * resolves an `` on this origin (dot segments, query and fragment + * removed). `undefined` when the value is not this route's URL: an absolute or + * protocol-relative URL, a data URI, a relative path or any other root path + * names something this plugin does not serve, so it is not checked. + * `filename: null` is a path below a subdirectory, which the route's single + * `:filename` segment never matches. + */ +function runtimeAssetRequest(value: unknown): { url: string; filename: string | null } | undefined { + if (typeof value !== 'string') return undefined; + const trimmed = value.trim(); + // A root path only: a relative one resolves against the console page, not `/`. + if (!trimmed.startsWith('/')) return undefined; + const thisOrigin = 'http://runtime-assets.invalid'; + let resolved: URL; + try { + resolved = new URL(trimmed, thisOrigin); + } catch { + return undefined; + } + // `//host/…`, and `/\host/…` which a browser reads the same way, name another host. + if (resolved.origin !== thisOrigin) return undefined; + const pathname = resolved.pathname; + if (!pathname.startsWith(RUNTIME_ASSETS_URL_PREFIX)) return undefined; + const segment = pathname.slice(RUNTIME_ASSETS_URL_PREFIX.length); + if (segment === '') return undefined; + if (segment.includes('/')) return { url: pathname, filename: null }; + // The route reads its parameter decoded; an undecodable one stays raw here. + let filename = segment; + try { + filename = decodeURIComponent(segment); + } catch { + /* keep the raw segment */ + } + return { url: pathname, filename }; +} + +/** The branding keys whose value the console draws as an image URL. */ +const BRANDING_IMAGE_KEYS = ['logo', 'favicon'] as const; + +/** + * The apps the console is served: the same `protocol.getMetaItems({ type: + * 'app' })` read `GET /api/v1/meta/app` answers from, which the console's app + * list and chrome (logo, favicon) are drawn from. It covers config boots and + * artifact boots alike, because both register their apps with that protocol. + */ +async function readServedApps(ctx: any): Promise { + const protocol = ctx.getService('protocol'); + if (typeof protocol?.getMetaItems !== 'function') return []; + const answer = await protocol.getMetaItems({ type: 'app' }); + const items = Array.isArray(answer) ? answer : answer?.items; + return Array.isArray(items) ? items : []; +} + +/** + * One line per branding URL under `/runtime/assets/` that this boot will not + * serve, naming every app and key that uses it, the file, the directory + * searched, `OS_RUNTIME_ASSETS_DIR`, and whether that directory exists at all. + * Empty when every such URL resolves to a servable file. + */ +function describeUnservedBrandingAssets( + apps: any[], + assetsDir: string, + mounted: boolean, + dirSource: RuntimeAssetsDirSource, +): string[] { + const unserved = new Map }>(); + for (const app of apps) { + const appName = String(app?.name ?? app?.id ?? '(unnamed)'); + for (const key of BRANDING_IMAGE_KEYS) { + const request = runtimeAssetRequest(app?.branding?.[key]); + if (!request) continue; + if (mounted && request.filename !== null && runtimeAssetIsServable(assetsDir, request.filename)) continue; + const entry = unserved.get(request.url) ?? { filename: request.filename, uses: new Map() }; + entry.uses.set(appName, [...(entry.uses.get(appName) ?? []), `branding.${key}`]); + unserved.set(request.url, entry); + } + } + + const searched = dirSource === 'OS_RUNTIME_ASSETS_DIR' + ? `${assetsDir} (named by OS_RUNTIME_ASSETS_DIR)` + : `${assetsDir} (the /assets default, since OS_RUNTIME_ASSETS_DIR is unset)`; + const lines: string[] = []; + for (const [url, { filename, uses }] of unserved) { + const users = [...uses].map(([appName, keys]) => `app '${appName}' (${keys.join(', ')})`).join(' and '); + const file = filename ?? url.slice(RUNTIME_ASSETS_URL_PREFIX.length); + const reason = !mounted + ? `the directory searched, ${searched}, does not exist, so ${RUNTIME_ASSETS_URL_PREFIX} is not mounted this run` + : filename === null + ? `${RUNTIME_ASSETS_URL_PREFIX} serves only files directly inside the directory searched, ${searched}, never a subdirectory` + : `${file} is not a readable file in the directory searched, ${searched}`; + const remedy = filename === null + ? `move the file to the top of that directory and drop the subdirectory from the URL, or set OS_RUNTIME_ASSETS_DIR to a directory that holds it at the top` + : `put ${file} in that directory${mounted ? '' : ' and restart'}, or set OS_RUNTIME_ASSETS_DIR to the directory that holds it`; + lines.push( + `Branding asset not served: ${users} → ${url}, but ${reason}; the console will draw a broken image. To fix, ${remedy}.`, + ); + } + return lines; +} + /** * Create a plugin that serves static runtime assets at /runtime/assets/*. * Decoupled from the console plugin so branding assets (logos, favicons) are @@ -598,9 +744,19 @@ export function createConsoleStaticPlugin(distPath: string, options?: { isDev?: * * 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). + * `runtime/` package); `dirSource` says which of the two it came from. + * + * When the directory is absent the route is not mounted. Either way, once the + * boot has settled (`kernel:bootstrapped`), every loaded app's `branding.logo` + * / `branding.favicon` that names a file under `/runtime/assets/` which this + * route will not serve is reported ONCE, through this plugin's logger at + * `warn` (#22071): an artifact booted outside its project directory otherwise + * drew a broken logo and favicon with nothing said on either side. That is the + * one channel: under `serve`'s boot-quiet window the line is replayed in the + * banner's *Boot diagnostics* block, and at `--log-level debug|info` it streams + * live. It changes nothing about what the route serves. */ -export function createRuntimeAssetsPlugin(distPath: string) { +export function createRuntimeAssetsPlugin(distPath: string, dirSource: 'OS_RUNTIME_ASSETS_DIR' | 'cwd') { return { name: 'com.objectstack.runtime-assets', @@ -612,13 +768,29 @@ export function createRuntimeAssetsPlugin(distPath: string) { const app = httpServer.getRawApp(); const assetsDir = path.resolve(distPath); - if (!fs.existsSync(assetsDir)) return; + const mounted = fs.existsSync(assetsDir); + + // After every `kernel:ready` handler has settled, so an app a later + // plugin registers on that hook is read too. A best-effort report: it + // must never fail the boot it reports on. + ctx.hook('kernel:bootstrapped', async () => { + let apps: any[]; + try { + apps = await readServedApps(ctx); + } catch (err: any) { + ctx.logger.debug(`Branding asset check skipped: the served app list could not be read (${err?.message ?? err})`); + return; + } + for (const line of describeUnservedBrandingAssets(apps, assetsDir, mounted, dirSource)) { + ctx.logger.warn(line); + } + }); + + if (!mounted) 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))) { + const filePath = resolveRuntimeAssetPath(assetsDir, String(c.req.param?.('filename') ?? '')); + if (filePath === null) { return c.text('Forbidden', 403); } try { diff --git a/packages/cli/test/runtime-assets.test.ts b/packages/cli/test/runtime-assets.test.ts index 80ff9a9239c..ffa79345168 100644 --- a/packages/cli/test/runtime-assets.test.ts +++ b/packages/cli/test/runtime-assets.test.ts @@ -6,6 +6,14 @@ * 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. + * + * #22071 — and when the route is NOT mounted (the assets directory is absent), + * or a branding URL names a file it will not serve, the boot says so: once the + * kernel has bootstrapped, the plugin reads the served app list and warns once + * per unserved `/runtime/assets/` URL. The end-to-end boot half of that pin + * (an artifact booted from a directory without `assets/`, and the + * `OS_RUNTIME_ASSETS_DIR` / `assets/` controls) is + * `serve-runtime-assets-branding-warning.e2e.test.ts`. */ import { describe, it, expect, beforeAll, afterAll } from 'vitest'; import fs from 'fs'; @@ -18,39 +26,257 @@ import { createRuntimeAssetsPlugin } from '../src/utils/console.js'; // 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'); +const SVG = ''; + +/** A directory that is never created: the "no assets/ here" boot. */ +const absentDir = path.join(assetsDir, 'never-created'); beforeAll(() => { fs.writeFileSync(testPng, Buffer.from('fake-png-content')); + fs.writeFileSync(path.join(assetsDir, 'icon.svg'), SVG); + fs.writeFileSync(path.join(assetsDir, 'my logo.svg'), SVG); + fs.mkdirSync(path.join(assetsDir, 'sub')); + fs.writeFileSync(path.join(assetsDir, 'sub', 'deep.svg'), SVG); }); afterAll(() => { fs.rmSync(assetsDir, { recursive: true, force: true }); }); +type Handler = (c: any) => Promise; + +/** + * The kernel surface the plugin touches, recorded: the routes it mounts, the + * hooks it registers, and every line it logs. `apps` is what the `protocol` + * service answers `getMetaItems({ type: 'app' })` with — the `{ type, items }` + * envelope the real protocol answers. + */ +function fakeKernel(opts: { apps?: any[]; protocol?: 'present' | 'absent' | 'throws' } = {}) { + const routes = new Map(); + const hooks = new Map Promise | void>>(); + const warnings: string[] = []; + const debugs: string[] = []; + const appReads: unknown[] = []; + const protocol = opts.protocol ?? 'present'; + const ctx = { + getService: (name: string) => { + if (name === 'http.server') { + return { getRawApp: () => ({ get: (route: string, handler: Handler) => { routes.set(route, handler); } }) }; + } + if (name === 'protocol' && protocol !== 'absent') { + return { + getMetaItems: async (request: { type: string }) => { + appReads.push(request); + if (protocol === 'throws') throw new Error('sys_metadata unreachable'); + return { type: request.type, items: opts.apps ?? [] }; + }, + }; + } + // The real kernel accessor throws on an unregistered service. + throw new Error(`[Kernel] Service '${name}' not found`); + }, + hook: (name: string, handler: () => Promise | void) => { + hooks.set(name, [...(hooks.get(name) ?? []), handler]); + }, + logger: { + warn: (line: string) => { warnings.push(line); }, + debug: (line: string) => { debugs.push(line); }, + }, + }; + return { + ctx, + routes, + hooks, + warnings, + debugs, + appReads, + /** Run what the kernel runs once every `kernel:ready` handler has settled. */ + async bootstrapped() { + for (const handler of hooks.get('kernel:bootstrapped') ?? []) await handler(); + }, + }; +} + +/** Ask the mounted route for `filename`, as Hono hands it the decoded `:filename`. */ +async function serve(routes: Map, filename: string): Promise { + const handler = routes.get('/runtime/assets/:filename'); + if (!handler) throw new Error('the /runtime/assets/:filename route is not mounted'); + return handler({ + req: { param: (key: string) => (key === 'filename' ? filename : undefined) }, + text: (body: string, status: number) => new Response(body, { status }), + }); +} + +const brandApp = (name: string, branding: Record) => ({ name, label: name, branding }); + describe('createRuntimeAssetsPlugin', () => { it('returns a plugin object with name, init, and start', () => { - const plugin = createRuntimeAssetsPlugin(assetsDir); + const plugin = createRuntimeAssetsPlugin(assetsDir, 'cwd'); 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('mounts no route when the assets dir does not exist — and registers the boot check that says so', async () => { + const kernel = fakeKernel(); + await expect(createRuntimeAssetsPlugin('/nonexistent/dir', 'cwd').start(kernel.ctx)).resolves.toBeUndefined(); + expect([...kernel.routes.keys()]).toEqual([]); + expect(kernel.hooks.get('kernel:bootstrapped')).toHaveLength(1); }); it('skips registration when http server service is missing', async () => { - const plugin = createRuntimeAssetsPlugin(assetsDir); + const plugin = createRuntimeAssetsPlugin(assetsDir, 'cwd'); const ctx = { getService: () => null }; await expect(plugin.start(ctx as any)).resolves.toBeUndefined(); }); + + it('serves a file in the directory, 404s a missing one and refuses a name that escapes it', async () => { + const kernel = fakeKernel(); + await createRuntimeAssetsPlugin(assetsDir, 'cwd').start(kernel.ctx); + + const ok = await serve(kernel.routes, 'icon.svg'); + expect(ok.status).toBe(200); + expect(ok.headers.get('content-type')).toBe('image/svg+xml'); + expect(await ok.text()).toBe(SVG); + expect((await serve(kernel.routes, 'missing.svg')).status).toBe(404); + expect((await serve(kernel.routes, '..')).status).toBe(403); + }); +}); + +describe('the boot check: a branding URL under /runtime/assets/ this boot will not serve (#22071)', () => { + it('assets dir absent: ONE warning for a file both logo and favicon name, naming the app, both keys, the file, the directory and OS_RUNTIME_ASSETS_DIR, and saying the directory does not exist', async () => { + const kernel = fakeKernel({ + apps: [brandApp('crm', { logo: '/runtime/assets/icon.svg', favicon: '/runtime/assets/icon.svg' })], + }); + await createRuntimeAssetsPlugin(absentDir, 'cwd').start(kernel.ctx); + // Nothing is said before the boot has settled — the apps are read then. + expect(kernel.warnings).toEqual([]); + await kernel.bootstrapped(); + + expect(kernel.appReads).toEqual([{ type: 'app' }]); + expect(kernel.warnings).toHaveLength(1); + const [line] = kernel.warnings; + expect(line).toContain("'crm'"); + expect(line).toContain('branding.logo'); + expect(line).toContain('branding.favicon'); + expect(line).toContain('icon.svg'); + expect(line).toContain(absentDir); + expect(line).toContain('OS_RUNTIME_ASSETS_DIR'); + expect(line).toContain('does not exist'); + }); + + it('control: the file present — the route serves it and nothing is printed', async () => { + const kernel = fakeKernel({ + apps: [brandApp('crm', { logo: '/runtime/assets/icon.svg', favicon: '/runtime/assets/icon.svg' })], + }); + await createRuntimeAssetsPlugin(assetsDir, 'OS_RUNTIME_ASSETS_DIR').start(kernel.ctx); + await kernel.bootstrapped(); + + // Positive control on the read itself, so the silence below is a verdict. + expect(kernel.appReads).toEqual([{ type: 'app' }]); + expect(kernel.warnings).toEqual([]); + expect((await serve(kernel.routes, 'icon.svg')).status).toBe(200); + }); + + it('assets dir present but the file missing: warns naming the file and the directory, and does NOT claim the directory is absent', async () => { + const kernel = fakeKernel({ apps: [brandApp('crm', { logo: '/runtime/assets/missing-logo.svg' })] }); + await createRuntimeAssetsPlugin(assetsDir, 'OS_RUNTIME_ASSETS_DIR').start(kernel.ctx); + await kernel.bootstrapped(); + + expect(kernel.warnings).toHaveLength(1); + const [line] = kernel.warnings; + expect(line).toContain("'crm'"); + expect(line).toContain('missing-logo.svg'); + expect(line).toContain(assetsDir); + expect(line).toContain('OS_RUNTIME_ASSETS_DIR'); + expect(line).not.toContain('does not exist'); + expect((await serve(kernel.routes, 'missing-logo.svg')).status).toBe(404); + }); + + it('one line per unserved file, naming every app that uses it', async () => { + const kernel = fakeKernel({ + apps: [ + brandApp('crm', { logo: '/runtime/assets/shared.svg', favicon: '/runtime/assets/crm.ico' }), + brandApp('hr', { favicon: '/runtime/assets/shared.svg' }), + ], + }); + await createRuntimeAssetsPlugin(absentDir, 'cwd').start(kernel.ctx); + await kernel.bootstrapped(); + + expect(kernel.warnings).toHaveLength(2); + const shared = kernel.warnings.find((l) => l.includes('shared.svg')); + expect(shared).toContain("'crm'"); + expect(shared).toContain("'hr'"); + const crmOnly = kernel.warnings.find((l) => l.includes('crm.ico')); + expect(crmOnly).toContain("'crm'"); + expect(crmOnly).not.toContain("'hr'"); + }); + + it('checks only root paths under /runtime/assets/ — every other spelling prints nothing (with a positive control in the same boot)', async () => { + const kernel = fakeKernel({ + apps: [ + brandApp('absolute', { logo: 'https://cdn.example.com/runtime/assets/icon.svg' }), + brandApp('protocol_relative', { logo: '//cdn.example.com/runtime/assets/icon.svg' }), + brandApp('backslash_host', { logo: '/\\cdn.example.com/runtime/assets/icon.svg' }), + brandApp('data_uri', { logo: 'data:image/svg+xml;base64,PHN2Zy8+' }), + brandApp('relative', { logo: 'runtime/assets/icon.svg' }), + brandApp('other_root', { logo: '/assets/todo-logo.png', favicon: '/runtime/assetsx/icon.svg' }), + brandApp('bare_prefix', { logo: '/runtime/assets/' }), + brandApp('not_a_string', { logo: 42 }), + { name: 'no_branding', label: 'No branding' }, + brandApp('control', { logo: '/runtime/assets/icon.svg' }), + ], + }); + // Absent directory, so any value the check accepted WOULD warn. + await createRuntimeAssetsPlugin(absentDir, 'cwd').start(kernel.ctx); + await kernel.bootstrapped(); + + expect(kernel.warnings).toHaveLength(1); + expect(kernel.warnings[0]).toContain("'control'"); + }); + + it('judges a URL by the same resolution the route serves through — query, fragment, dot segments, encoding, directories and subdirectories', async () => { + // [url, the :filename the route receives — or null when the route's single + // segment cannot match at all] + const cases: Array<[string, string | null]> = [ + ['/runtime/assets/icon.svg?v=2#top', 'icon.svg'], + ['/runtime/assets/./icon.svg', 'icon.svg'], + [' /runtime/assets/icon.svg ', 'icon.svg'], + ['/runtime/assets/my%20logo.svg', 'my logo.svg'], + ['/runtime/assets/sub', 'sub'], + ['/runtime/assets/sub/deep.svg', null], + ['/runtime/assets/gone.svg', 'gone.svg'], + ]; + const kernel = fakeKernel({ apps: cases.map(([url], i) => brandApp(`app_${i}`, { logo: url })) }); + await createRuntimeAssetsPlugin(assetsDir, 'cwd').start(kernel.ctx); + await kernel.bootstrapped(); + + for (const [i, [url, filename]] of cases.entries()) { + const warned = kernel.warnings.some((l) => l.includes(`'app_${i}'`)); + const served = filename !== null && (await serve(kernel.routes, filename)).status === 200; + expect({ url, warned }).toEqual({ url, warned: !served }); + } + // Four the route serves and three it does not, so neither half is vacuous. + expect(kernel.warnings).toHaveLength(3); + }); + + it('a subdirectory URL is reported as one, not as a missing file', async () => { + const kernel = fakeKernel({ apps: [brandApp('crm', { logo: '/runtime/assets/sub/deep.svg' })] }); + await createRuntimeAssetsPlugin(assetsDir, 'cwd').start(kernel.ctx); + await kernel.bootstrapped(); + + expect(kernel.warnings).toHaveLength(1); + expect(kernel.warnings[0]).toContain('/runtime/assets/sub/deep.svg'); + expect(kernel.warnings[0]).toContain('subdirectory'); + }); + + it('never fails the boot it reports on: no protocol, or a protocol that throws, logs at debug and warns nothing', async () => { + for (const protocol of ['absent', 'throws'] as const) { + const kernel = fakeKernel({ protocol, apps: [brandApp('crm', { logo: '/runtime/assets/icon.svg' })] }); + await createRuntimeAssetsPlugin(absentDir, 'cwd').start(kernel.ctx); + await expect(kernel.bootstrapped()).resolves.toBeUndefined(); + expect(kernel.warnings).toEqual([]); + expect(kernel.debugs).toHaveLength(1); + } + }); }); diff --git a/packages/cli/test/serve-runtime-assets-branding-warning.e2e.test.ts b/packages/cli/test/serve-runtime-assets-branding-warning.e2e.test.ts new file mode 100644 index 00000000000..84cbee35514 --- /dev/null +++ b/packages/cli/test/serve-runtime-assets-branding-warning.e2e.test.ts @@ -0,0 +1,276 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * An artifact booted outside its project directory says when its branding + * logo / favicon will not be served (#22071) — end to end. + * + * ## What this pins + * + * `os serve` resolves the runtime assets directory as `OS_RUNTIME_ASSETS_DIR`, + * else `/assets`, and mounts `GET /runtime/assets/:filename` only when + * that directory exists. An artifact is the deployable unit and carries no + * asset files, so an app whose `branding.logo` / `branding.favicon` names + * `/runtime/assets/`, booted from a directory without `assets/`, served a + * 404 for its logo and favicon and printed nothing about it. Three boots of ONE + * artifact, each from a fresh directory holding only that artifact: + * + * - no `assets/`, `OS_RUNTIME_ASSETS_DIR` unset → the boot prints ONE line + * about the file, naming the app, the directory searched and + * `OS_RUNTIME_ASSETS_DIR`, and saying the directory does not exist; the + * file still answers 404 (nothing about what is served changes); + * - control: `OS_RUNTIME_ASSETS_DIR` naming a directory that holds the file + * → nothing printed, the file served; + * - control: `assets/` beside the artifact → nothing printed, the file served. + * + * `objectstack start --artifact ` spawns this same `serve` in its own + * cwd, so the artifact is named here the way that child receives it, + * `OS_ARTIFACT_PATH`, with the cwd the assets default is read from. + * + * ## Why a real child, and why the `e2e` tier + * + * Both halves of the defect are about the process's surroundings — the cwd, + * the environment, the boot's own output and a real HTTP answer — so only a + * child standing in that directory measures them. The plugin's own logic + * (which URLs are checked, the resolution it shares with the route, one line + * per file, never failing the boot) is pinned per pull request by + * `runtime-assets.test.ts`; this file is the composed boot, and by its name it + * runs on the nightly tier (`scripts/nightly-tiers.mjs`). + */ + +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import { spawn, type ChildProcessByStdio } from 'node:child_process'; +import { mkdirSync, mkdtempSync, readdirSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import type { Readable } from 'node:stream'; +import { PROTOCOL_MAJOR } from '@objectstack/spec/kernel'; +import { + CLI, + TSX, + E2E_SECRET_KEY, + boundPortFromBanner, + childEnv, + portContentionError, + probeThroughChild, + reservePort, +} from './helpers/serve-process.js'; + +type ServeChild = ChildProcessByStdio; + +const LOGO_URL = '/runtime/assets/icon.svg'; +const SVG = ''; + +/** A minimal but real compiled artifact: one object, one app branded from /runtime/assets/. */ +const ARTIFACT = JSON.stringify( + { + manifest: { + id: 'com.example.brandassets', + name: 'brandassets', + version: '1.0.0', + type: 'app', + engines: { protocol: `^${PROTOCOL_MAJOR}` }, + }, + objects: [{ name: 'brandassets_item', label: 'Item', fields: { name: { type: 'text', label: 'Name' } } }], + apps: [{ name: 'brandassets_app', label: 'Brand', branding: { logo: LOGO_URL, favicon: LOGO_URL } }], + views: [], + flows: [], + requires: [], + }, + null, + 2, +); + +const READY_BANNER_TAIL = /Press Ctrl\+C to stop/; +const BOOT_TIMEOUT = 240_000; + +let root: string; +const children: ServeChild[] = []; + +beforeAll(() => { + root = mkdtempSync(join(tmpdir(), 'os-22071-e2e-')); +}); + +afterAll(async () => { + for (const child of children) await stop(child); + if (root) rmSync(root, { recursive: true, force: true }); +}, 60_000); + +async function stop(child: ServeChild): Promise { + if (child.exitCode !== null || child.signalCode !== null) return; + await new Promise((done) => { + const give = setTimeout(() => { + try { child.kill('SIGKILL'); } catch { /* already gone */ } + done(); + }, 10_000); + child.once('exit', () => { clearTimeout(give); done(); }); + try { + child.kill('SIGTERM'); + } catch { + clearTimeout(give); + done(); + } + }); +} + +interface BootResult { + /** Everything the child printed, stdout and stderr, up to the ready banner. */ + output: string; + /** The `/assets` this boot's default resolves to. */ + cwdAssets: string; + status: number; + contentType: string | null; + body: string; +} + +/** + * Boot the artifact from a fresh directory laid out as `layout` says, wait for + * the complete ready banner, then ask the running server for the logo. + */ +async function bootAndFetchLogo(layout: 'no-assets' | 'cwd-assets' | 'env-assets'): Promise { + const base = mkdtempSync(join(root, `${layout}-`)); + const cwd = join(base, 'deploy'); + const home = join(base, 'home'); + mkdirSync(cwd); + mkdirSync(home); + const artifact = join(cwd, 'objectstack.json'); + writeFileSync(artifact, ARTIFACT, 'utf8'); + const cwdAssets = join(cwd, 'assets'); + + let runtimeAssetsDir: string | undefined; + if (layout === 'cwd-assets') { + mkdirSync(cwdAssets); + writeFileSync(join(cwdAssets, 'icon.svg'), SVG, 'utf8'); + } else if (layout === 'env-assets') { + runtimeAssetsDir = join(base, 'brand-files'); + mkdirSync(runtimeAssetsDir); + writeFileSync(join(runtimeAssetsDir, 'icon.svg'), SVG, 'utf8'); + } + // Guard the premise: the deployment directory holds the artifact and, + // only for the `assets/` control, that directory — nothing else. + expect(readdirSync(cwd).sort()).toEqual(layout === 'cwd-assets' ? ['assets', 'objectstack.json'] : ['objectstack.json']); + + const port = reservePort(); + const child = spawn(TSX, [CLI, 'serve', '-p', String(port)], { + cwd, + stdio: ['ignore', 'pipe', 'pipe'], + // `childEnv`, never a bare `...process.env` (`check:cli-test-child-env`). + env: childEnv({ + NO_COLOR: '1', + OS_HOME: home, + OS_DATABASE_URL: ':memory:', + OS_LOG_LEVEL: '', + OS_DISABLE_CONSOLE: '1', + OS_SECRET_KEY: E2E_SECRET_KEY, + OS_ARTIFACT_URL: undefined, + OS_ARTIFACT_PATH: artifact, + // Unset unless this boot is the env control — an inherited value would + // make the `/assets` legs measure some other directory. + OS_RUNTIME_ASSETS_DIR: runtimeAssetsDir, + }), + }) as ServeChild; + children.push(child); + + let output = ''; + await new Promise((ready, failed) => { + const timer = setTimeout(() => { + failed(new Error(`serve never printed its complete ready banner\n--- output ---\n${output}`)); + }, BOOT_TIMEOUT - 30_000); + const onData = (chunk: Buffer) => { + output += String(chunk); + if (READY_BANNER_TAIL.test(output)) { + clearTimeout(timer); + ready(); + } + }; + child.stdout.on('data', onData); + child.stderr.on('data', onData); + child.on('exit', (code) => { + clearTimeout(timer); + failed( + portContentionError(output, 'os serve (bin/run-dev.js, artifact boot)', port) + ?? new Error(`serve exited ${code} before its ready banner\n--- output ---\n${output}`), + ); + }); + }); + + // `bin/run-dev.js` pins NODE_ENV=development, where a taken port is a hop, + // not an error — so the port is read back off the child's own banner. + const readback = boundPortFromBanner(output); + if (readback.state !== 'bound') { + await stop(child); + throw new Error(`cannot read the bound port back (${readback.state})\n--- output ---\n${output}`); + } + + try { + const answer = await probeThroughChild( + { + child, + transcript: () => `\n--- child output ---\n${output}`, + label: 'serve-runtime-assets-branding-warning', + what: `GET ${LOGO_URL} on port ${readback.port}`, + }, + async () => { + const res = await fetch(`http://localhost:${readback.port}${LOGO_URL}`); + return { status: res.status, contentType: res.headers.get('content-type'), body: await res.text() }; + }, + ); + return { output, cwdAssets, ...answer }; + } finally { + await stop(child); + } +} + +/** The boot lines that mention the logo's URL — the warning names it; nothing else does. */ +function linesNaming(output: string, needle: string): string[] { + return output.split('\n').filter((line) => line.includes(needle)); +} + +describe('os serve — an artifact boot names a branding asset it will not serve (#22071)', () => { + it( + 'no assets/ and OS_RUNTIME_ASSETS_DIR unset: ONE line naming the app, the file, the directory and OS_RUNTIME_ASSETS_DIR; the file still 404s', + async () => { + const { output, cwdAssets, status } = await bootAndFetchLogo('no-assets'); + + const lines = linesNaming(output, LOGO_URL); + expect(lines, `expected exactly one boot line naming ${LOGO_URL}\n--- output ---\n${output}`).toHaveLength(1); + const [line] = lines; + expect(line).toContain('brandassets_app'); + expect(line).toContain('branding.logo'); + expect(line).toContain('branding.favicon'); + expect(line).toContain(cwdAssets); + expect(line).toContain('OS_RUNTIME_ASSETS_DIR'); + expect(line).toContain('does not exist'); + // What the route serves is unchanged: still nothing, so still 404. + expect(status).toBe(404); + }, + BOOT_TIMEOUT, + ); + + it( + 'control: OS_RUNTIME_ASSETS_DIR names a directory holding the file — nothing printed, the file served', + async () => { + const { output, status, contentType, body } = await bootAndFetchLogo('env-assets'); + + expect(status).toBe(200); + expect(contentType).toBe('image/svg+xml'); + expect(body).toBe(SVG); + expect(linesNaming(output, LOGO_URL), output).toEqual([]); + expect(linesNaming(output, 'icon.svg'), output).toEqual([]); + }, + BOOT_TIMEOUT, + ); + + it( + 'control: assets/ beside the artifact holds the file — nothing printed, the file served', + async () => { + const { output, status, contentType, body } = await bootAndFetchLogo('cwd-assets'); + + expect(status).toBe(200); + expect(contentType).toBe('image/svg+xml'); + expect(body).toBe(SVG); + expect(linesNaming(output, LOGO_URL), output).toEqual([]); + expect(linesNaming(output, 'icon.svg'), output).toEqual([]); + }, + BOOT_TIMEOUT, + ); +});