Skip to content

Commit bafb58b

Browse files
fix(cli): warn at boot when an app's branding names a runtime asset the server will not serve (#22089)
Fixes #22071 Clause-②: no ## What changed `os serve` resolves the runtime assets directory as `OS_RUNTIME_ASSETS_DIR`, else the `assets/` directory under its working directory. `createRuntimeAssetsPlugin` mounted `GET /runtime/assets/:filename` only 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's `branding.logo` / `branding.favicon` and 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: - every app and key that uses the file; - the file; - the directory searched; - `OS_RUNTIME_ASSETS_DIR`, and whether the directory came from it or from the working-directory default; - when the directory does not exist, that fact, and that nothing under `/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/cli` `patch` changeset. ## The PM's readings (dispatch H1 to H5), measured **H1, reproduce first. Held.** Measured on `origin/main` `3d918850` with the CLI run from source. The artifact had one app with `branding: { 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 same `serve` that `start --artifact` spawns in its own working directory). | boot | `GET /runtime/assets/icon.svg` | boot lines mentioning assets, branding or `icon.svg` | | --- | --- | --- | | before, no `assets/` | 404 | 0 | | before, `assets/icon.svg` beside the artifact | 200 `image/svg+xml` | 0 | | before, `OS_RUNTIME_ASSETS_DIR` naming a directory with `icon.svg` | 200 `image/svg+xml` | 0 | | after, no `assets/` | 404 | **1**, in *Boot diagnostics* | | after, `assets/` control | 200 `image/svg+xml` | 0 | | after, `OS_RUNTIME_ASSETS_DIR` control | 200 `image/svg+xml` | 0 | The line printed on the first after-boot (temp path shortened to `DEPLOY`): ```text WARN Branding asset not served: app 'brand_app' (branding.logo, branding.favicon) → /runtime/assets/icon.svg, but the directory searched, DEPLOY/assets (the CWD/assets default, since OS_RUNTIME_ASSETS_DIR is unset), does not exist, so /runtime/assets/ is not mounted this run; the console will draw a broken image. To fix, put icon.svg in that directory and restart, or set OS_RUNTIME_ASSETS_DIR to the directory that holds it. ``` The real line spells `CWD` as `cwd` inside angle brackets. It is written `CWD` here 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`, the `served` audience). `GET /api/v1/meta/app` answers from the same call (`packages/rest/src/rest-server.ts`, the `GET /meta/:type` list door, `p.getMetaItems(listRequest)`). The console's app list and chrome are drawn from that route: objectui `MetadataProvider` calls `client.meta.getItems('app')`, read at objectui `9dfaca654`, not at the `.objectui-sha` pin. Config boots and artifact boots both register their apps with that protocol. The artifact boot above is the measurement: the warning names `brand_app`, which only that read could have supplied. Both `{ type, items }` and a bare array are accepted, as the REST door's own comment says `getMetaItems` can 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 (`null` maps 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's `readFileSync` needs: 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 by `test/published-subpath-console.pin.test.ts`, and a 14th export would need that pin edited. **H4, what the matcher accepts.** A `branding.logo` / `branding.favicon` string that: - after trimming, is a root path (one leading `/`); - resolved the way a browser resolves an `img` `src` on 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 `:filename` parameter 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`, at `kernel:bootstrapped`, inside `runtime.start()`: - under `serve`'s boot-quiet window, `BootLogCapture` captures it and *Boot diagnostics* (`printBootDiagnostics`) replays it once; - at `--log-level debug` or `info` it streams live; - at `error` or `silent` it 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 `debug` and the hook returns. ## Tests Final head is `c397a0f3`. Every run below is from that head; `origin/main` had not moved from `3d918850`. - `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: - the absent-directory warning appears ONCE for a file both keys name; - the file-present control: a 200 from the route and no line, with a positive control that the app list WAS read; - a present directory with the file missing: no claim that the directory is absent; - one line per file across apps; - the H4 matcher table; - a URL by URL check that the warning and the captured route handler agree (query, fragment, dot segment, whitespace, percent-encoding, a directory, a subdirectory, a missing file); - the subdirectory reason; - a protocol that is absent or throws never fails the boot. - Result: `Tests 12 passed (12)`. - `packages/cli/test/serve-runtime-assets-branding-warning.e2e.test.ts`, integration project, `e2e` tier. The triage pins as three real boots of one artifact from a fresh directory: no `assets/`; the `OS_RUNTIME_ASSETS_DIR` control; the `assets/` control. It asserts exactly one boot line naming the URL (with the app, both keys, the directory, `OS_RUNTIME_ASSETS_DIR` and 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 with `OS_TEST_TIERS=nightly`: `Tests 3 passed (3)`. - Ablations, committed first, mutated and restored by `scripts/ablation-replace.mjs` (anchor hit 1 to 0, blob changed, restored blob equal to HEAD, `git diff HEAD` empty). Both subjects are imported from `src`: the unit test imports `../src/utils/console.js` and the e2e child runs `bin/run-dev.js` from source through tsx. No `dist/` is in the path, so no rebuild leg applies. - `kernel:bootstrapped` renamed so the check never fires: unit `9 failed | 3 passed`. The e2e no-`assets/` boot went red; both controls stayed green. - The parameter decode dropped, so the matcher diverges from the route: unit `1 failed | 11 passed`, the route-agreement case. - The first unit leg of the first ablation was run under `OS_TEST_TIERS=nightly` and 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, `e2e` tier excluded by default): `Test Files 352 passed (352)`, `Tests 4692 passed | 2 skipped (4694)`, exit 0. `pnpm --filter @objectstack/cli typecheck`: exit 0. It is `tsc --noEmit` plus `check:test-typecheck: OK`, with 3 files, 28 errors and 6 pinned signatures held in `test-typecheck-debt.json`, unchanged. ## Gates All of these ran at `c397a0f3`, and each exit code was captured before any pipe. - The 67 families `node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands` derives over this diff all exited 0. That list is identical to the dispatch's. - `check:dual-build-cjs-loads` and `check:i18n-coverage` first answered exit 3, PREREQUISITE NOT MET: some packages had no `dist/`, so nothing was measured. - Both were re-run after `turbo run build --filter='!@objectstack/docs'` (72 tasks, 71 cached) and both exited 0: `106 published require entry point(s) across 66 package(s) load` and `OK (13 config(s), 621 baselined untranslated string(s), none new)`. - `--ran` reconcile: `67 derived, 67 run, 0 NOT-MEASURED, 0 UNRUN`. - `pnpm lint`, the full `eslint . --no-inline-config` the dispatch adds: exit 0, no findings. - `pnpm check:startup-registry-verdict` exited 0, with `43 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.ts` sets `branding.logo: '/assets/todo-logo.png'` and `favicon: '/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. - The boot check reads the environment-wide app list (no organization), the same list an unscoped `GET /api/v1/meta/app` answers. An app that exists only as one organization's overlay row is not checked at boot. - When the directory is absent, the route stays unmounted for the life of the process; the line says "restart". Mounting it lazily would change what `/runtime/assets/*` serves, which this card rules out. - Every boot measured here printed `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 on `origin/main` before this change. It is unrelated to this card and is reported to the seat in the dev report. --- _Generated by [Claude Code](https://claude.ai/code/session_01RWZbGvPFcRKvUqASZtunCU)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent b04a529 commit bafb58b

5 files changed

Lines changed: 717 additions & 26 deletions

File tree

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
---
2+
"@objectstack/cli": patch
3+
---
4+
5+
`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.
6+
7+
Clause-②: no
8+
9+
- 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.
10+
- 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.
11+
- 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.
12+
- 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.
13+
- ⛔ 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.

‎packages/cli/src/commands/serve.ts‎

Lines changed: 10 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -3715,12 +3715,16 @@ export default class Serve extends Command {
37153715
// dist hasn't been built yet. The directory is resolved as:
37163716
// 1. OS_RUNTIME_ASSETS_DIR env var (explicit override)
37173717
// 2. process.cwd() + '/assets' (when CLI cwd is a runtime/ package)
3718-
// Silently skips if no assets directory exists.
3719-
const runtimeAssetsDir = (
3720-
process.env.OS_RUNTIME_ASSETS_DIR?.trim() ||
3721-
path.resolve(process.cwd(), 'assets')
3722-
);
3723-
await kernel.use(createRuntimeAssetsPlugin(runtimeAssetsDir));
3718+
// No route is mounted when the directory does not exist. That is not
3719+
// silent (#22071): once the boot settles, the plugin warns for every
3720+
// loaded app's branding logo / favicon under /runtime/assets/ it will not
3721+
// serve, naming the directory searched and which of the two it came from.
3722+
const runtimeAssetsDirFromEnv = process.env.OS_RUNTIME_ASSETS_DIR?.trim();
3723+
const runtimeAssetsDir = runtimeAssetsDirFromEnv || path.resolve(process.cwd(), 'assets');
3724+
await kernel.use(createRuntimeAssetsPlugin(
3725+
runtimeAssetsDir,
3726+
runtimeAssetsDirFromEnv ? 'OS_RUNTIME_ASSETS_DIR' : 'cwd',
3727+
));
37243728

37253729
// Unknown-environment hostname guard.
37263730
//

‎packages/cli/src/utils/console.ts‎

Lines changed: 179 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -591,16 +591,172 @@ export function createConsoleStaticPlugin(distPath: string, options?: { isDev?:
591591

592592
// ─── Runtime Assets Plugin ──────────────────────────────────────────
593593

594+
/** The URL prefix the runtime assets route is mounted under. */
595+
const RUNTIME_ASSETS_URL_PREFIX = '/runtime/assets/';
596+
597+
/**
598+
* Where the host found the directory it handed {@link createRuntimeAssetsPlugin}:
599+
* named by `OS_RUNTIME_ASSETS_DIR`, or the `<cwd>/assets` default taken because
600+
* that variable is unset. Only the boot warning reads it, to name the remedy.
601+
* Not exported: this module's export set is pinned
602+
* (`test/published-subpath-console.pin.test.ts`), and the union is spelled out
603+
* structurally in the plugin's signature.
604+
*/
605+
type RuntimeAssetsDirSource = 'OS_RUNTIME_ASSETS_DIR' | 'cwd';
606+
607+
/**
608+
* The ONE resolution of a `/runtime/assets/:filename` parameter to a path on
609+
* disk. The route serves through it and the boot check judges through it
610+
* (#22071), so the two cannot disagree about which file a name means or which
611+
* names are refused. `null` means the name escapes `assetsDir`, which the route
612+
* answers 403.
613+
*/
614+
function resolveRuntimeAssetPath(assetsDir: string, filename: string): string | null {
615+
const filePath = path.join(assetsDir, filename.replace(/[/\\]+/g, ''));
616+
// Path-traversal guard: reject any path that escapes assetsDir.
617+
if (!path.resolve(filePath).startsWith(path.resolve(assetsDir))) return null;
618+
return filePath;
619+
}
620+
621+
/**
622+
* Whether the route answers 200 for this `:filename`: the same resolution, then
623+
* what the route's `readFileSync` needs, a readable regular file. A directory,
624+
* a missing file and a refused name all answer no.
625+
*/
626+
function runtimeAssetIsServable(assetsDir: string, filename: string): boolean {
627+
const filePath = resolveRuntimeAssetPath(assetsDir, filename);
628+
if (filePath === null) return false;
629+
try {
630+
if (!fs.statSync(filePath).isFile()) return false;
631+
fs.accessSync(filePath, fs.constants.R_OK);
632+
return true;
633+
} catch {
634+
return false;
635+
}
636+
}
637+
638+
/**
639+
* What a branding URL asks the runtime assets route for, read the way a browser
640+
* resolves an `<img src>` on this origin (dot segments, query and fragment
641+
* removed). `undefined` when the value is not this route's URL: an absolute or
642+
* protocol-relative URL, a data URI, a relative path or any other root path
643+
* names something this plugin does not serve, so it is not checked.
644+
* `filename: null` is a path below a subdirectory, which the route's single
645+
* `:filename` segment never matches.
646+
*/
647+
function runtimeAssetRequest(value: unknown): { url: string; filename: string | null } | undefined {
648+
if (typeof value !== 'string') return undefined;
649+
const trimmed = value.trim();
650+
// A root path only: a relative one resolves against the console page, not `/`.
651+
if (!trimmed.startsWith('/')) return undefined;
652+
const thisOrigin = 'http://runtime-assets.invalid';
653+
let resolved: URL;
654+
try {
655+
resolved = new URL(trimmed, thisOrigin);
656+
} catch {
657+
return undefined;
658+
}
659+
// `//host/…`, and `/\host/…` which a browser reads the same way, name another host.
660+
if (resolved.origin !== thisOrigin) return undefined;
661+
const pathname = resolved.pathname;
662+
if (!pathname.startsWith(RUNTIME_ASSETS_URL_PREFIX)) return undefined;
663+
const segment = pathname.slice(RUNTIME_ASSETS_URL_PREFIX.length);
664+
if (segment === '') return undefined;
665+
if (segment.includes('/')) return { url: pathname, filename: null };
666+
// The route reads its parameter decoded; an undecodable one stays raw here.
667+
let filename = segment;
668+
try {
669+
filename = decodeURIComponent(segment);
670+
} catch {
671+
/* keep the raw segment */
672+
}
673+
return { url: pathname, filename };
674+
}
675+
676+
/** The branding keys whose value the console draws as an image URL. */
677+
const BRANDING_IMAGE_KEYS = ['logo', 'favicon'] as const;
678+
679+
/**
680+
* The apps the console is served: the same `protocol.getMetaItems({ type:
681+
* 'app' })` read `GET /api/v1/meta/app` answers from, which the console's app
682+
* list and chrome (logo, favicon) are drawn from. It covers config boots and
683+
* artifact boots alike, because both register their apps with that protocol.
684+
*/
685+
async function readServedApps(ctx: any): Promise<any[]> {
686+
const protocol = ctx.getService('protocol');
687+
if (typeof protocol?.getMetaItems !== 'function') return [];
688+
const answer = await protocol.getMetaItems({ type: 'app' });
689+
const items = Array.isArray(answer) ? answer : answer?.items;
690+
return Array.isArray(items) ? items : [];
691+
}
692+
693+
/**
694+
* One line per branding URL under `/runtime/assets/` that this boot will not
695+
* serve, naming every app and key that uses it, the file, the directory
696+
* searched, `OS_RUNTIME_ASSETS_DIR`, and whether that directory exists at all.
697+
* Empty when every such URL resolves to a servable file.
698+
*/
699+
function describeUnservedBrandingAssets(
700+
apps: any[],
701+
assetsDir: string,
702+
mounted: boolean,
703+
dirSource: RuntimeAssetsDirSource,
704+
): string[] {
705+
const unserved = new Map<string, { filename: string | null; uses: Map<string, string[]> }>();
706+
for (const app of apps) {
707+
const appName = String(app?.name ?? app?.id ?? '(unnamed)');
708+
for (const key of BRANDING_IMAGE_KEYS) {
709+
const request = runtimeAssetRequest(app?.branding?.[key]);
710+
if (!request) continue;
711+
if (mounted && request.filename !== null && runtimeAssetIsServable(assetsDir, request.filename)) continue;
712+
const entry = unserved.get(request.url) ?? { filename: request.filename, uses: new Map<string, string[]>() };
713+
entry.uses.set(appName, [...(entry.uses.get(appName) ?? []), `branding.${key}`]);
714+
unserved.set(request.url, entry);
715+
}
716+
}
717+
718+
const searched = dirSource === 'OS_RUNTIME_ASSETS_DIR'
719+
? `${assetsDir} (named by OS_RUNTIME_ASSETS_DIR)`
720+
: `${assetsDir} (the <cwd>/assets default, since OS_RUNTIME_ASSETS_DIR is unset)`;
721+
const lines: string[] = [];
722+
for (const [url, { filename, uses }] of unserved) {
723+
const users = [...uses].map(([appName, keys]) => `app '${appName}' (${keys.join(', ')})`).join(' and ');
724+
const file = filename ?? url.slice(RUNTIME_ASSETS_URL_PREFIX.length);
725+
const reason = !mounted
726+
? `the directory searched, ${searched}, does not exist, so ${RUNTIME_ASSETS_URL_PREFIX} is not mounted this run`
727+
: filename === null
728+
? `${RUNTIME_ASSETS_URL_PREFIX} serves only files directly inside the directory searched, ${searched}, never a subdirectory`
729+
: `${file} is not a readable file in the directory searched, ${searched}`;
730+
const remedy = filename === null
731+
? `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`
732+
: `put ${file} in that directory${mounted ? '' : ' and restart'}, or set OS_RUNTIME_ASSETS_DIR to the directory that holds it`;
733+
lines.push(
734+
`Branding asset not served: ${users} → ${url}, but ${reason}; the console will draw a broken image. To fix, ${remedy}.`,
735+
);
736+
}
737+
return lines;
738+
}
739+
594740
/**
595741
* Create a plugin that serves static runtime assets at /runtime/assets/*.
596742
* Decoupled from the console plugin so branding assets (logos, favicons) are
597743
* served even when the console dist hasn't been built yet.
598744
*
599745
* The `distPath` should point at the host project's `runtime/assets` directory
600746
* (i.e. `path.resolve(process.cwd(), 'assets')` when the CLI cwd is the
601-
* `runtime/` package).
747+
* `runtime/` package); `dirSource` says which of the two it came from.
748+
*
749+
* When the directory is absent the route is not mounted. Either way, once the
750+
* boot has settled (`kernel:bootstrapped`), every loaded app's `branding.logo`
751+
* / `branding.favicon` that names a file under `/runtime/assets/` which this
752+
* route will not serve is reported ONCE, through this plugin's logger at
753+
* `warn` (#22071): an artifact booted outside its project directory otherwise
754+
* drew a broken logo and favicon with nothing said on either side. That is the
755+
* one channel: under `serve`'s boot-quiet window the line is replayed in the
756+
* banner's *Boot diagnostics* block, and at `--log-level debug|info` it streams
757+
* live. It changes nothing about what the route serves.
602758
*/
603-
export function createRuntimeAssetsPlugin(distPath: string) {
759+
export function createRuntimeAssetsPlugin(distPath: string, dirSource: 'OS_RUNTIME_ASSETS_DIR' | 'cwd') {
604760
return {
605761
name: 'com.objectstack.runtime-assets',
606762

@@ -612,13 +768,29 @@ export function createRuntimeAssetsPlugin(distPath: string) {
612768

613769
const app = httpServer.getRawApp();
614770
const assetsDir = path.resolve(distPath);
615-
if (!fs.existsSync(assetsDir)) return;
771+
const mounted = fs.existsSync(assetsDir);
772+
773+
// After every `kernel:ready` handler has settled, so an app a later
774+
// plugin registers on that hook is read too. A best-effort report: it
775+
// must never fail the boot it reports on.
776+
ctx.hook('kernel:bootstrapped', async () => {
777+
let apps: any[];
778+
try {
779+
apps = await readServedApps(ctx);
780+
} catch (err: any) {
781+
ctx.logger.debug(`Branding asset check skipped: the served app list could not be read (${err?.message ?? err})`);
782+
return;
783+
}
784+
for (const line of describeUnservedBrandingAssets(apps, assetsDir, mounted, dirSource)) {
785+
ctx.logger.warn(line);
786+
}
787+
});
788+
789+
if (!mounted) return;
616790

617791
app.get('/runtime/assets/:filename', async (c: any) => {
618-
const filename = String(c.req.param?.('filename') ?? '').replace(/[/\\]+/g, '');
619-
const filePath = path.join(assetsDir, filename);
620-
// Path-traversal guard: reject any path that escapes assetsDir.
621-
if (!path.resolve(filePath).startsWith(path.resolve(assetsDir))) {
792+
const filePath = resolveRuntimeAssetPath(assetsDir, String(c.req.param?.('filename') ?? ''));
793+
if (filePath === null) {
622794
return c.text('Forbidden', 403);
623795
}
624796
try {

0 commit comments

Comments
 (0)