Repository navigation
fix(cli): warn at boot when an app's branding names a runtime asset the server will not serve - #22089
Conversation
…he server will not serve createRuntimeAssetsPlugin skipped its /runtime/assets/:filename mount without a word when the assets directory was absent, so an artifact booted outside its project directory drew a broken logo and favicon with nothing said. Once the boot settles, the plugin now reads the served app list and warns once per branding logo / favicon URL under /runtime/assets/ that the route will not serve, naming the apps, the file, the directory searched, OS_RUNTIME_ASSETS_DIR and whether that directory exists. The route and the check share one resolution of the filename; what the route serves is unchanged. Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RWZbGvPFcRKvUqASZtunCU
…end to end Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RWZbGvPFcRKvUqASZtunCU
Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RWZbGvPFcRKvUqASZtunCU
📓 Docs Drift CheckThis PR changes 1 package(s): 1 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:
⛔ 1 release-owned page(s) also name something this change touched. These are read-only:
What this run could not see
Coarse fallback — 28 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # while this PR is open — GitHub drops the merge commit once it closes
git fetch origin fb8e309f2f02892f83b9d582eaa2cf2eac86401a && git checkout fb8e309f2f02892f83b9d582eaa2cf2eac86401a
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 3d9188502e1b07ae70df8b0b5e733fce44a74cfa c397a0f3c6c7f45e2bd74b1fce270b911fc09cba && git checkout -B drift-repro 3d9188502e1b07ae70df8b0b5e733fce44a74cfa && git merge --no-ff c397a0f3c6c7f45e2bd74b1fce270b911fc09cba
node scripts/docs-audit/affected-docs.mjs --json 3d9188502e1b07ae70df8b0b5e733fce44a74cfa
|
Fixes #22071
Clause-②: no
What changed
os serveresolves the runtime assets directory asOS_RUNTIME_ASSETS_DIR, else theassets/directory under its working directory.createRuntimeAssetsPluginmountedGET /runtime/assets/:filenameonly when that directory existed, and said nothing when it did not. An artifact booted from any other directory therefore served a 404 for an app'sbranding.logo/branding.faviconand printed nothing about it.Now, once the boot has settled (
kernel:bootstrapped), the plugin reads the served app list. It prints ONE warning per branding URL under/runtime/assets/that this boot will not serve. The line names:OS_RUNTIME_ASSETS_DIR, and whether the directory came from it or from the working-directory default;/runtime/assets/is mounted for this run.What the route serves does not change. No route is added or removed (triage ruling
6038494234; carrying the files in the artifact stays out of scope).Files:
packages/cli/src/utils/console.ts(the plugin),packages/cli/src/commands/serve.ts(the runtime assets block now passes which source the directory came from), the two test files below, and one@objectstack/clipatchchangeset.The PM's readings (dispatch H1 to H5), measured
H1, reproduce first. Held. Measured on
origin/main3d918850with the CLI run from source. The artifact had one app withbranding: { logo: '/runtime/assets/icon.svg', favicon: '/runtime/assets/icon.svg' }. Each boot ran from a fresh directory holding only that artifact (OS_ARTIFACT_PATH, the sameservethatstart --artifactspawns in its own working directory).GET /runtime/assets/icon.svgicon.svgassets/assets/icon.svgbeside the artifactimage/svg+xmlOS_RUNTIME_ASSETS_DIRnaming a directory withicon.svgimage/svg+xmlassets/assets/controlimage/svg+xmlOS_RUNTIME_ASSETS_DIRcontrolimage/svg+xmlThe line printed on the first after-boot (temp path shortened to
DEPLOY):The real line spells
CWDascwdinside angle brackets. It is writtenCWDhere because GitHub strips angle-bracket fragments from bodies.H2, where the loaded apps' branding is read.
kernel.getService('protocol').getMetaItems({ type: 'app' })(packages/metadata-protocol/src/protocol.ts,getMetaItems, theservedaudience).GET /api/v1/meta/appanswers from the same call (packages/rest/src/rest-server.ts, theGET /meta/:typelist door,p.getMetaItems(listRequest)). The console's app list and chrome are drawn from that route: objectuiMetadataProvidercallsclient.meta.getItems('app'), read at objectui9dfaca654, not at the.objectui-shapin. Config boots and artifact boots both register their apps with that protocol. The artifact boot above is the measurement: the warning namesbrand_app, which only that read could have supplied. Both{ type, items }and a bare array are accepted, as the REST door's own comment saysgetMetaItemscan answer either.H3, one resolution. Took the PM's lean. A private helper in
utils/console.ts,resolveRuntimeAssetPath(assetsDir, filename), holds the route's own steps: strip separators,path.join, then the traversal guard (nullmaps to 403). The route now serves through it and the check judges through it. The route's behaviour is byte-for-byte what it was. "Servable" adds only what the route'sreadFileSyncneeds: a readable regular file. A directory and a missing file both answer no, exactly as the route 404s them. The helper is not exported: this module's export set is pinned bytest/published-subpath-console.pin.test.ts, and a 14th export would need that pin edited.H4, what the matcher accepts. A
branding.logo/branding.faviconstring that:/);imgsrcon this origin, stays on this origin and has a pathname that starts with/runtime/assets/and names something after it.Query, fragment and dot segments are removed by that resolution. The segment is percent-decoded the way the route's
:filenameparameter is. A path below a subdirectory is reported as a subdirectory, because the route's single segment never matches it.These print nothing: absolute URLs, protocol-relative URLs (including the backslash spelling a browser reads the same way), data URIs, relative paths, and any other root path (
/assets/…,/runtime/assetsx/…). The unit test pins every one of them, with a positive control in the same test case.H5, the channel. One, named. The line goes out through the plugin's own
ctx.logger.warn, atkernel:bootstrapped, insideruntime.start():serve's boot-quiet window,BootLogCapturecaptures it and Boot diagnostics (printBootDiagnostics) replays it once;--log-level debugorinfoit streams live;errororsilentit is hidden, like every boot warning.It is never printed from the banner list. The sibling #22073 de-duplicates the banner list against Boot diagnostics, and that work sees this line in exactly one of the two.
Failure handling: the hook must never fail the boot it reports on. A missing protocol, or a read that throws, is logged at
debugand the hook returns.Tests
Final head is
c397a0f3. Every run below is from that head;origin/mainhad not moved from3d918850.packages/cli/test/runtime-assets.test.ts, unit tier, per PR. The existing three cases were kept and their two-argument call updated. The old "silently skips" case now asserts that no route is mounted AND the check is registered. New cases:Tests 12 passed (12).packages/cli/test/serve-runtime-assets-branding-warning.e2e.test.ts, integration project,e2etier. The triage pins as three real boots of one artifact from a fresh directory: noassets/; theOS_RUNTIME_ASSETS_DIRcontrol; theassets/control. It asserts exactly one boot line naming the URL (with the app, both keys, the directory,OS_RUNTIME_ASSETS_DIRand the absent directory), and a 404 that does not change. In both controls, no line and a 200 with the exact bytes. By its name it runs on the nightly tier (scripts/nightly-tiers.mjs); the per-PR half is the unit file. Local run withOS_TEST_TIERS=nightly:Tests 3 passed (3).scripts/ablation-replace.mjs(anchor hit 1 to 0, blob changed, restored blob equal to HEAD,git diff HEADempty). Both subjects are imported fromsrc: the unit test imports../src/utils/console.jsand the e2e child runsbin/run-dev.jsfrom source through tsx. Nodist/is in the path, so no rebuild leg applies.kernel:bootstrappedrenamed so the check never fires: unit9 failed | 3 passed. The e2e no-assets/boot went red; both controls stayed green.1 failed | 11 passed, the route-agreement case.OS_TEST_TIERS=nightlyand selected nothing ("FILTER SELECTED NOTHING"). It was a no-op and is not counted; it was re-run without the switch.pnpm --filter @objectstack/cli test(unit and integration projects,e2etier excluded by default):Test Files 352 passed (352),Tests 4692 passed | 2 skipped (4694), exit 0.pnpm --filter @objectstack/cli typecheck: exit 0. It istsc --noEmitpluscheck:test-typecheck: OK, with 3 files, 28 errors and 6 pinned signatures held intest-typecheck-debt.json, unchanged.Gates
All of these ran at
c397a0f3, and each exit code was captured before any pipe.node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commandsderives over this diff all exited 0. That list is identical to the dispatch's.check:dual-build-cjs-loadsandcheck:i18n-coveragefirst answered exit 3, PREREQUISITE NOT MET: some packages had nodist/, so nothing was measured.turbo run build --filter='!@objectstack/docs'(72 tasks, 71 cached) and both exited 0:106 published require entry point(s) across 66 package(s) loadandOK (13 config(s), 621 baselined untranslated string(s), none new).--ranreconcile:67 derived, 67 run, 0 NOT-MEASURED, 0 UNRUN.pnpm lint, the fulleslint . --no-inline-configthe dispatch adds: exit 0, no findings.pnpm check:startup-registry-verdictexited 0, with43 startup/open-registry seam(s) … none recording a verdict the boot can contradict. It is not derived for this diff; it was run because the change adds a boot-time registry read.Acceptance notes
examples/app-todo/src/apps/todo.app.tssetsbranding.logo: '/assets/todo-logo.png'andfavicon: '/assets/todo-favicon.ico'. Neither file exists in the example, and nothing serves/assets/at the root. This is a read-only inference: the example was not booted or measured. By H4 it is outside this check, which reads only/runtime/assets/. No carrier.GET /api/v1/meta/appanswers. An app that exists only as one organization's overlay row is not checked at boot./runtime/assets/*serves, which this card rules out.WARN Insert operation failed {"object":"sys_migration", … UNIQUE constraint failed: sys_migration.id …}with a full knex stack in Boot diagnostics. That is 6 of 6 artifact boots on a fresh:memory:database, 3 of them onorigin/mainbefore this change. It is unrelated to this card and is reported to the seat in the dev report.Generated by Claude Code