diff --git a/.github/workflows/ql3-ci.yml b/.github/workflows/ql3-ci.yml index 761d1cb7..68496595 100644 --- a/.github/workflows/ql3-ci.yml +++ b/.github/workflows/ql3-ci.yml @@ -132,6 +132,21 @@ jobs: run: pnpm install --frozen-lockfile --ignore-scripts - name: Build the capability-gated legacy panel source run: pnpm build:front + - name: Materialize and audit the bounded legacy panel artifact + run: >- + node scripts/ql3-legacy-panel-bundle.cjs + --source="${GITHUB_WORKSPACE}/static/dist" + --output="${RUNNER_TEMP}/ql3-legacy-panel" + - name: Upload the bounded legacy panel artifact + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: ql3-legacy-panel-${{ github.run_id }}-${{ github.run_attempt }} + path: ${{ runner.temp }}/ql3-legacy-panel + if-no-files-found: error + retention-days: 14 + compression-level: 0 + overwrite: false + include-hidden-files: false service-manager-bridge: name: systemd/OpenRC dual-authority bridge @@ -373,6 +388,7 @@ jobs: local-image: name: Local application image (${{ matrix.image_arch }}) + needs: legacy-panel-compatibility runs-on: ${{ matrix.runner }} strategy: fail-fast: false @@ -386,6 +402,11 @@ jobs: image_arch: arm64 steps: - uses: actions/checkout@v6 + - name: Download the bounded legacy panel artifact + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: ql3-legacy-panel-${{ github.run_id }}-${{ github.run_attempt }} + path: .ql3-panel-dist - uses: pnpm/action-setup@v6 with: version: '8.3.1' @@ -396,6 +417,8 @@ jobs: cache-dependency-path: pnpm-lock.yaml - name: Verify native runner architecture run: node -e "if (process.arch !== '${{ matrix.node_arch }}') throw new Error('unexpected architecture ' + process.arch)" + - name: Audit the downloaded legacy panel closure + run: node scripts/ql3-legacy-panel-bundle.cjs --audit="${GITHUB_WORKSPACE}/.ql3-panel-dist" - name: Install workspace dependencies without lifecycle scripts run: pnpm install --frozen-lockfile --ignore-scripts - name: Audit immutable local image inputs @@ -515,10 +538,10 @@ jobs: - name: Verify the opt-in Local Console identity env: CONSOLE_IMAGE: qinglong3-local-console:ci-${{ matrix.image_arch }} - EXPECTED: ${{ matrix.image_arch }} 65532:65532 edge-application-api,standalone-application-api offline-loopback + EXPECTED: ${{ matrix.image_arch }} 65532:65532 edge-application-api,standalone-application-api offline-loopback legacy-capability-gated@v1 256 13631488 run: | set -euo pipefail - actual="$(docker image inspect --format '{{.Architecture}} {{.Config.User}} {{index .Config.Labels "io.qinglong.profile"}} {{index .Config.Labels "io.qinglong.local.console"}}' "${CONSOLE_IMAGE}")" + actual="$(docker image inspect --format '{{.Architecture}} {{.Config.User}} {{index .Config.Labels "io.qinglong.profile"}} {{index .Config.Labels "io.qinglong.local.console"}} {{index .Config.Labels "io.qinglong.local.panel"}} {{index .Config.Labels "io.qinglong.local.panel-max-files"}} {{index .Config.Labels "io.qinglong.local.panel-max-bytes"}}' "${CONSOLE_IMAGE}")" if [[ "${actual}" != "${EXPECTED}" ]]; then echo "unexpected Local Console image contract: ${actual}" >&2 exit 1 @@ -565,6 +588,20 @@ jobs: "${CONSOLE_IMAGE}" scripts/ql3-local-console-image-inventory.cjs --inventory-root=/opt/qinglong/node_modules + - name: Audit the installed legacy panel closure + env: + CONSOLE_IMAGE: qinglong3-local-console:ci-${{ matrix.image_arch }} + run: >- + docker run --rm --read-only + --network none + --cap-drop ALL + --security-opt no-new-privileges + --volume "${{ github.workspace }}:/audit:ro" + --workdir /audit + --entrypoint node + "${CONSOLE_IMAGE}" + scripts/ql3-legacy-panel-bundle.cjs + --audit=/opt/qinglong/node_modules/@qinglong/local-api/assets/panel - name: Generate and reconcile the reviewed CycloneDX SBOM env: IMAGE: qinglong3-local-application:ci-${{ matrix.image_arch }} diff --git a/.gitignore b/.gitignore index 9f4bf873..a89de788 100644 --- a/.gitignore +++ b/.gitignore @@ -11,6 +11,7 @@ # production /static +/.ql3-panel-dist /data # misc diff --git a/deploy/containers/ql3-local-application/Dockerfile b/deploy/containers/ql3-local-application/Dockerfile index 21d77904..ad8de557 100644 --- a/deploy/containers/ql3-local-application/Dockerfile +++ b/deploy/containers/ql3-local-application/Dockerfile @@ -215,6 +215,8 @@ RUN rm -rf node_modules/.bin \ --retain-js=local-api/assets/console/console.js \ && rm /tmp/ql3-prune-runtime-artifact.cjs +COPY .ql3-panel-dist node_modules/@qinglong/local-api/assets/panel + FROM node:24.18.0-alpine3.23@sha256:595398b0081eacda8e1c4c5b97b76cd1020e4d58a8ebcb4843b9bca1e79e7436 AS runtime-platform RUN apk add --no-cache --upgrade \ @@ -262,6 +264,9 @@ LABEL org.opencontainers.image.title="QingLong 3.0 Local Console Application" \ io.qinglong.profile="edge-application-api,standalone-application-api" \ io.qinglong.ai="excluded" \ io.qinglong.local.console="offline-loopback" \ + io.qinglong.local.panel="legacy-capability-gated@v1" \ + io.qinglong.local.panel-max-files="256" \ + io.qinglong.local.panel-max-bytes="13631488" \ io.qinglong.local.application-config="2,3,4" \ io.qinglong.local.sqlite-contract-min="51" \ io.qinglong.local.sqlite-contract-max="52" \ diff --git a/docs/adr/ADR-0530-bounded-local-panel-capability-bootstrap.md b/docs/adr/ADR-0530-bounded-local-panel-capability-bootstrap.md index 5667e1cb..4802045b 100644 --- a/docs/adr/ADR-0530-bounded-local-panel-capability-bootstrap.md +++ b/docs/adr/ADR-0530-bounded-local-panel-capability-bootstrap.md @@ -1,6 +1,6 @@ # ADR-0530:有界 Local 面板能力发现与启动适配 -- 状态:Proposed(D-428 源码候选,等待可下载同源装配与双架构实物门) +- 状态:Accepted(D-428 本地同源装配候选已闭合,等待远端 CI 与双架构可下载实物门) - 日期:2026-09-02 - 关联 RFC:QL-RFC-0001 D-428、D-427、D-423、D-424 @@ -30,20 +30,23 @@ ADR-0529 已交付认证后的只读 `/api/crons` Adapter,但现有 2.x 面板 ## 部署与资源边界 - 默认 headless 产物不包含 `@qinglong/local-api` 或旧面板,不新增端口、连接、timer、watcher、后台进程或稳态内存。 -- 当前约 33 MiB 的旧面板源码构建结果不自动塞入 headless/Console Alpha;同源静态资源装配与体积预算必须作为独立门完成。 +- 旧面板仍不进入 headless 产物。Console opt-in 镜像只装配经过闭包裁剪与哈希锁定的静态资源:240 files、11,947,127 bytes,上限为 256 files / 13 MiB / 单文件 3 MiB;`.gz` 副本与 Monaco Editor 不进入产物。 +- 装配后的 `/`、`/login`、`/crontab` 与 `/error` 服务受限旧面板,原生 3.0 管理 Console 固定保留在 `/console`,二者共享同一个 loopback Local API,不启动第二个 Web 服务。 +- 静态资源使用 64 KiB stream;Edge 的 API admission 为 4、静态资源 admission 为 16,Standalone 分别为 32/64。两类请求共享 drain 生命周期但不互相挤占预算,避免旧 Umi 并行加载 chunk 时饿死 API 或收到 503。 - 旧 Umi 前端暂用 Node 20 构建只是 legacy migration toolchain,不改变 QingLong 3.0 Node 24 runtime、双架构镜像或支持等级。该过渡门必须在 CI 中独立命名,不能让 Node 20 定义新 package 的运行时兼容性。 - Edge 与 Standalone 使用同一代码、不同预算;Cluster 节点不加载 Local SQLite/POSIX authority,也不通过本 Adapter 访问控制面。 ## 验证与剩余门禁 -源码候选已通过 Local API 12-package closure build、89/89 测试、真实 SQLite/credential/Policy/audit HTTP 集成、Node 20 的旧面板 production build、18-package 完整测试与 package/import/dependency audit。Playwright 同源源码旅程也已验证 capability 登录页、内存 credential、只读 Crontab、隐藏写入口与排序/过滤,以及刷新后回到登录页;最终页为 0 console error,仅保留既有国际化 warning。远端 CI 和可下载实物仍待闭合。 +本地装配候选已经通过 12-package Console closure、719 files / 16,162,123 bytes(上限 768 files / 20 MiB)镜像审计,以及面板 240-file 哈希/磁盘闭包复核。最终 arm64 Edge 镜像 `sha256:27472cf1bdd66d9fa4e937622ef69d4e36f74918a759de62818b4e59d265714c` 已完成 fresh setup/replay、Owner provision/challenge/claim/presentation/ack、首个 Task 执行与日志标记、graceful stop、SQLite integrity 和 HTTP 200/401 边界,结果为 `compatible=true`。 + +真实浏览器已验证现有页面使用内存中的 `ql3c_` credential 完成 `/login` → `/crontab`,53 个同源静态请求全部为 200,`/api/health`、`/api/system`、`/api/v3/capabilities`、`/api/user`、`/api/system/config` 和 `/api/crons` 均为 200;最终镜像同时验证 `/console` 原生管理台与 `/login` 旧页面共存。旧页面仍引用的外部图标/装饰图片被严格 CSP 阻止,页面功能可用但这些装饰会缺失;本阶段不为消除装饰错误而放宽网络或 CSP。远端 CI 和可下载双架构实物仍待闭合。 仍未完成: -1. 在一个可下载 Console 产物中同源装配改造后的面板静态资源,并证明 CSP、缓存和 API 路由优先级; -2. 在装配后的 exact Console + Local API + SQLite 上使用真实 `ql3c_` credential 重跑登录 → `/crontab` → 分页 → 401/刷新清凭据的浏览器 journey; +1. 推送后通过远端完整 CI,并生成、下载和离线复核 exact amd64/arm64 Console Trial Kit 与 milestone; +2. 把旧面板引用的外部图标和装饰图片转为受审本地资产,做到严格 CSP 下无外部请求; 3. 为旧页面增加 Run/Log 只读 adapter 后再开放日志入口; -4. 双架构资源与 artifact gate,以及对面板体积的可解释预算; -5. 写操作必须逐项映射到 3.0 revision、Policy、presence/approval、audit 和 mutation fence,不能用通配兼容路由一次性开放。 +4. 写操作必须逐项映射到 3.0 revision、Policy、presence/approval、audit 和 mutation fence,不能用通配兼容路由一次性开放。 -在上述门禁完成前,本 ADR 只说明“现有面板源码能受控接入”,不声明当前已发布 Console artifact 包含该页面,也不声明 2.x 面板可以零修改直连。 +在远端实物门完成前,本 ADR 声明的是“当前提交可装配并实跑现有面板的受控子集”,不是 Public Release,也不声明完整 2.x 面板可以零修改直连。 diff --git a/docs/operations/ql3-local-alpha-trial-kit.md b/docs/operations/ql3-local-alpha-trial-kit.md index 6a9dc9c1..56b6051d 100644 --- a/docs/operations/ql3-local-alpha-trial-kit.md +++ b/docs/operations/ql3-local-alpha-trial-kit.md @@ -11,7 +11,7 @@ - fresh、隔离的测试数据目录; - 离线导入、设备兼容验证和 3.0 Alpha 用户旅程验证。 -`headless` 是默认且最小的低配设备变体,不打开端口。`console` 是显式选择的 Linux-only 变体,携带离线 Web Console,并仅通过宿主 `127.0.0.1:5700` 提供操作面。二者是独立 archive,不应同时下载;远程 Console 只允许经 SSH tunnel 访问,不得暴露到 LAN 或公网。 +`headless` 是默认且最小的低配设备变体,不打开端口。`console` 是显式选择的 Linux-only 变体,携带离线 3.0 原生 Web Console 和现有面板的有界只读兼容层,并仅通过宿主 `127.0.0.1:5700` 提供操作面。二者是独立 archive,不应同时下载;远程 Console 只允许经 SSH tunnel 访问,不得暴露到 LAN 或公网。 不要把它直接用于生产数据、2.x 唯一数据目录或生产 Secret。Cluster/Kubernetes 节点应使用 Cluster Integration Candidate;本套件不包含 PostgreSQL HA、Worker 或 Cluster Admin。 @@ -73,8 +73,8 @@ Owner delivery 保留在新数据目录的 `owner-delivery/`,operator command Headless Application 是无外部 listener、AI-excluded 的最小 Alpha runtime。Console 变体同样 AI-excluded,但 quickstart 会在 Linux 上使用 host network,让容器内仍严格绑定 `127.0.0.1:5700` 的 Local API 可由宿主浏览器访问。成功后打开 -`http://127.0.0.1:5700/`;远程主机必须建立 SSH tunnel。两种变体都只用于 fresh Alpha, -不是 2.x Web UI 的生产替代版本。Console 能力和凭据边界见 +`http://127.0.0.1:5700/console` 使用 3.0 原生管理台,或打开 `/login` 使用现有面板的只读 Crontab 兼容入口;远程主机必须建立 SSH tunnel。两种变体都只用于 fresh Alpha, +不是完整 2.x Web UI 的生产替代版本。Console 能力和凭据边界见 [Local Web Console](./ql3-local-web-console.md)。 Console quickstart 还会通过 strong local operator 创建一个默认不自动运行的 diff --git a/docs/operations/ql3-local-web-console.md b/docs/operations/ql3-local-web-console.md index 709edb13..5f5c19a4 100644 --- a/docs/operations/ql3-local-web-console.md +++ b/docs/operations/ql3-local-web-console.md @@ -1,6 +1,6 @@ # QingLong 3.0 Local Web Console -Local Web Console 是 `@qinglong/local-api` 的 opt-in 操作界面,用来创建和编辑 command Task、管理加密 Secret 绑定、配置 cron Trigger、查看 Task/Run/执行事件,并显式启动或取消一次运行。它由 Console Local Alpha Trial Kit 交付,但不进入默认 headless 变体,也不是 2.x Web UI 的完整替代品。 +Local Web Console 是 `@qinglong/local-api` 的 opt-in 操作界面。Console Trial Kit 现在同源提供两个边界清晰的入口:`/console` 是 3.0 原生管理台,用来创建和编辑 command Task、管理加密 Secret 绑定、配置 cron Trigger、查看 Task/Run/执行事件,并显式启动或取消一次运行;`/login` 和 `/crontab` 是现有 2.x 面板的 capability-gated 只读兼容入口。它们不进入默认 headless 变体,兼容入口也不是完整 2.x Web UI 的替代品。 ## 选择部署档位 @@ -43,7 +43,7 @@ D-426b2c 又补齐了 Console 镜像的 adopted-target 入口证据:切换演 ql3-local-api --config /srv/qinglong3/private/local-api.json ``` -在设备本机打开 `http://127.0.0.1:5701/`。服务只接受 `127.0.0.1` 或 `::1`,不会监听 LAN 地址。 +在设备本机打开 `http://127.0.0.1:5701/console` 使用 3.0 原生管理台;打开 `http://127.0.0.1:5701/login` 使用现有面板的只读 Crontab 兼容入口。当前 Console 产物的 `/` 指向兼容面板。服务只接受 `127.0.0.1` 或 `::1`,不会监听 LAN 地址。 从管理电脑访问路由/NAS 时,显式建立受信 SSH tunnel: @@ -72,13 +72,19 @@ Credential 只存在当前页面内存,不进入 URL、Cookie 或 Web Storage ## 当前阶段可用边界 -D-424 阶段实物的可操作闭环是内建 argv command Task create/list/read/update/enable/disable/start、Task pinned Secret binding、Secret current metadata/create/rotate、`qinglong/cron@v1` Trigger list/read/create/update/enable/disable,以及 Run list/read/events/steps/log/cancel。Task 编辑器只修改当前展示字段并保留完整快照中的其他 config/labels;其他 Task kind 或 Trigger schema 继续使用受信管理入口。页面暂不负责: +D-424 阶段实物的原生 `/console` 可操作闭环是内建 argv command Task create/list/read/update/enable/disable/start、Task pinned Secret binding、Secret current metadata/create/rotate、`qinglong/cron@v1` Trigger list/read/create/update/enable/disable,以及 Run list/read/events/steps/log/cancel。Task 编辑器只修改当前展示字段并保留完整快照中的其他 config/labels;其他 Task kind 或 Trigger schema 继续使用受信管理入口。 + +现有面板兼容入口只开放 `/login`、`/crontab`、`/error` 与只读 Cron 投影。它接受 `ql3c_` credential,不保存在 Web Storage,隐藏创建、批量操作、脚本、订阅、环境变量、日志和所有写 Modal。Env、Script、Subscription、Run/Log 页面和 2.x 写接口都不在兼容承诺内。 + +页面暂不负责: - Identity、Policy、Secret 明文读取/删除/历史浏览、Plugin Package 或 AI 配置管理; - Trigger 删除、通用 Trigger provider/schema 编辑或 Cluster Trigger 管理; - 日志整文件下载、终端、文件管理或 2.x 数据迁移; - LAN/public 暴露、TLS termination、多用户 Web session 或 Cluster 管理。 -D-424 的三项静态资产总计 102,182 bytes,不依赖 CDN、网络字体或前端框架,仍低于 192 KiB 总闭包和单文件 96 KiB 门。`edge-application-api|standalone-application-api` 为 4,210,024 / 4,210,168 bytes、482 files、12 packages、111 loaded modules,仍低于 6 MiB/640-file 门;本机 RSS delta 为 20,447,232 / 18,399,232 bytes,低于 28 MiB。默认 headless Edge 为 2,760,847 bytes、332 files、3 packages、59 modules,RSS delta 11,026,432 bytes;它不携带 Console/API 资产、listener 或 Secret mutation surface,只增加复用现有 SQLite connection 的有界 metadata 装配。 +原生 `/console` 的三项静态资产仍低于 192 KiB 总闭包和单文件 96 KiB 门;兼容面板经裁剪后为 240 files / 11,947,127 bytes,上限 256 files / 13 MiB / 单文件 3 MiB,不携带 `.gz` 副本或 Monaco Editor。最终 Console 镜像闭包为 12 packages、719 files、16,162,123 bytes,低于 768 files / 20 MiB 门;AI 依赖仍被排除。默认 headless Edge 不携带 Console/API/兼容面板资产、listener 或 Secret mutation surface。 + +静态资源采用 64 KiB 流式发送;Edge 为 API 保留 4 个并发名额、另给静态资源 16 个,Standalone 分别为 32/64。兼容面板残留的外部图标和装饰图片会被严格 CSP 阻止,不影响登录、只读 Crontab 或原生 `/console`;不要为恢复装饰资源放宽 CSP 或联网边界。 停止正常 Local API 进程走与 Application 相同的 drain/shutdown 路径。Console 没有独立数据库、后台任务或需要额外清理的持久状态;一次性 cutover probe 不绑定端口,也不会进入这条常驻生命周期。 diff --git a/packages/ql3-local-api/src/console/localConsoleAssets.ts b/packages/ql3-local-api/src/console/localConsoleAssets.ts index 707f2d0d..10698296 100644 --- a/packages/ql3-local-api/src/console/localConsoleAssets.ts +++ b/packages/ql3-local-api/src/console/localConsoleAssets.ts @@ -1,9 +1,35 @@ import { createHash } from 'node:crypto'; -import { lstatSync, readFileSync, realpathSync } from 'node:fs'; +import { + existsSync, + lstatSync, + readFileSync, + readdirSync, + realpathSync, +} from 'node:fs'; import path from 'node:path'; -const MAX_ASSET_BYTES = 96 * 1_024; -const MAX_TOTAL_BYTES = 192 * 1_024; +const MAX_LITE_ASSET_BYTES = 96 * 1_024; +const MAX_LITE_TOTAL_BYTES = 192 * 1_024; +const MAX_PANEL_FILES = 256; +const MAX_PANEL_TOTAL_BYTES = 13 * 1_024 * 1_024; +const MAX_PANEL_FILE_BYTES = 3 * 1_024 * 1_024; +const MAX_MANIFEST_BYTES = 128 * 1_024; +const PANEL_SCHEMA = 'qinglong/local-legacy-panel-assets@v1'; +const IMMUTABLE_CACHE = 'public, max-age=31536000, immutable'; +const NO_STORE_CACHE = 'no-store'; +const LITE_CONTENT_SECURITY_POLICY = + "default-src 'none'; script-src 'self'; style-src 'self'; connect-src 'self'; img-src 'self'; base-uri 'none'; form-action 'none'; frame-ancestors 'none'"; +const PANEL_CONTENT_SECURITY_POLICY = + "default-src 'none'; script-src 'self'; style-src 'self' 'unsafe-inline'; connect-src 'self'; img-src 'self' data:; font-src 'self' data:; base-uri 'none'; form-action 'none'; frame-ancestors 'none'; object-src 'none'; worker-src 'none'; manifest-src 'none'"; +const PANEL_CONTENT_TYPES = new Set([ + 'text/css; charset=utf-8', + 'text/html; charset=utf-8', + 'text/javascript; charset=utf-8', + 'font/ttf', + 'font/woff', + 'font/woff2', +]); +const PANEL_SUPPORTED_ROUTES = Object.freeze(['/login', '/crontab', '/error']); const DEFINITIONS = Object.freeze([ Object.freeze({ @@ -23,12 +49,26 @@ const DEFINITIONS = Object.freeze([ }), ]); -export interface LocalConsoleAsset { +interface LocalConsoleAssetBase { readonly contentType: string; readonly etag: string; - readonly body: Buffer; + readonly byteLength: number; + readonly cacheControl: string; + readonly contentSecurityPolicy: string; } +export type LocalConsoleAsset = + | (LocalConsoleAssetBase & + Readonly<{ + body: Buffer; + filePath?: never; + }>) + | (LocalConsoleAssetBase & + Readonly<{ + body?: never; + filePath: string; + }>); + export type LocalConsoleAssets = ReadonlyMap; export class LocalConsoleAssetError extends Error { @@ -40,7 +80,34 @@ export class LocalConsoleAssetError extends Error { } } -function loadAsset( +function exactKeys(value: unknown, expected: readonly string[]): boolean { + return ( + !!value && + typeof value === 'object' && + !Array.isArray(value) && + JSON.stringify(Object.keys(value).sort()) === + JSON.stringify([...expected].sort()) + ); +} + +function canonicalDirectory(directory: string, label: string): string { + const resolved = path.resolve(directory); + try { + const stat = lstatSync(resolved); + if ( + !stat.isDirectory() || + stat.isSymbolicLink() || + realpathSync(resolved) !== resolved + ) { + throw new TypeError(); + } + } catch (error) { + throw new LocalConsoleAssetError(label, { cause: error }); + } + return resolved; +} + +function loadLiteAsset( root: string, definition: (typeof DEFINITIONS)[number], ): Readonly { @@ -53,7 +120,7 @@ function loadAsset( !stat.isFile() || stat.isSymbolicLink() || stat.size < 2 || - stat.size > MAX_ASSET_BYTES || + stat.size > MAX_LITE_ASSET_BYTES || realpathSync(filePath) !== filePath ) { throw new TypeError('asset identity is incompatible'); @@ -70,29 +137,231 @@ function loadAsset( return Object.freeze({ contentType: definition.contentType, etag: `"${createHash('sha256').update(body).digest('hex')}"`, + byteLength: body.byteLength, + cacheControl: NO_STORE_CACHE, + contentSecurityPolicy: LITE_CONTENT_SECURITY_POLICY, body, }); } -export function loadLocalConsoleAssets(): LocalConsoleAssets { - const root = path.resolve(__dirname, '../../assets/console'); - let canonicalRoot: string; - try { - const stat = lstatSync(root); - if (!stat.isDirectory() || stat.isSymbolicLink()) throw new TypeError(); - canonicalRoot = realpathSync(root); - } catch (error) { - throw new LocalConsoleAssetError('asset root', { cause: error }); - } +function loadLiteAssets(root: string): LocalConsoleAssets { + const canonicalRoot = canonicalDirectory(root, 'asset root'); const assets = new Map>(); let totalBytes = 0; for (const definition of DEFINITIONS) { - const asset = loadAsset(canonicalRoot, definition); - totalBytes += asset.body.byteLength; - if (totalBytes > MAX_TOTAL_BYTES) { + const asset = loadLiteAsset(canonicalRoot, definition); + totalBytes += asset.byteLength; + if (totalBytes > MAX_LITE_TOTAL_BYTES) { throw new LocalConsoleAssetError('asset set exceeds its byte budget'); } assets.set(definition.requestPath, asset); } return assets; } + +function parsePanelManifest(root: string): Record { + const manifestPath = path.join(root, 'manifest.json'); + try { + const stat = lstatSync(manifestPath); + if ( + !stat.isFile() || + stat.isSymbolicLink() || + stat.size < 256 || + stat.size > MAX_MANIFEST_BYTES || + realpathSync(manifestPath) !== manifestPath + ) { + throw new TypeError(); + } + const value: unknown = JSON.parse(readFileSync(manifestPath, 'utf8')); + if (!value || typeof value !== 'object' || Array.isArray(value)) { + throw new TypeError(); + } + return value as Record; + } catch (error) { + throw new LocalConsoleAssetError('panel manifest', { cause: error }); + } +} + +function panelDiskFiles(root: string): readonly string[] { + const result: string[] = []; + const pending = [root]; + while (pending.length > 0) { + const directory = pending.pop()!; + for (const entry of readdirSync(directory, { withFileTypes: true })) { + const target = path.join(directory, entry.name); + const stat = lstatSync(target); + if (stat.isSymbolicLink()) { + throw new LocalConsoleAssetError('panel closure contains a symlink'); + } + if (stat.isDirectory()) pending.push(target); + else if (stat.isFile()) { + result.push(path.relative(root, target).split(path.sep).join('/')); + } else { + throw new LocalConsoleAssetError( + 'panel closure contains a special file', + ); + } + } + } + return result.sort(); +} + +export function loadLocalConsolePanelAssets( + directory: string, +): LocalConsoleAssets { + const root = canonicalDirectory(directory, 'panel asset root'); + const manifest = parsePanelManifest(root); + if ( + !exactKeys(manifest, [ + 'schema', + 'source', + 'supportedRoutes', + 'fileCount', + 'totalBytes', + 'limits', + 'files', + ]) || + manifest.schema !== PANEL_SCHEMA || + manifest.source !== 'qinglong-2.x-capability-gated-panel' || + JSON.stringify(manifest.supportedRoutes) !== + JSON.stringify(PANEL_SUPPORTED_ROUTES) || + !exactKeys(manifest.limits, [ + 'maxFiles', + 'maxTotalBytes', + 'maxFileBytes', + ]) || + (manifest.limits as Record).maxFiles !== MAX_PANEL_FILES || + (manifest.limits as Record).maxTotalBytes !== + MAX_PANEL_TOTAL_BYTES || + (manifest.limits as Record).maxFileBytes !== + MAX_PANEL_FILE_BYTES || + !Array.isArray(manifest.files) || + manifest.files.length < 4 || + manifest.files.length > MAX_PANEL_FILES + ) { + throw new LocalConsoleAssetError('panel manifest contract drifted'); + } + const assets = new Map(); + const seenFiles = new Set(['manifest.json']); + let previousRequestPath = ''; + let totalBytes = 0; + for (const raw of manifest.files) { + if ( + !exactKeys(raw, [ + 'requestPath', + 'file', + 'bytes', + 'sha256', + 'contentType', + 'cacheControl', + ]) + ) { + throw new LocalConsoleAssetError('panel asset entry shape drifted'); + } + const entry = raw as Record; + if ( + typeof entry.requestPath !== 'string' || + !entry.requestPath.startsWith('/') || + entry.requestPath.includes('?') || + entry.requestPath <= previousRequestPath || + (entry.requestPath.startsWith('/api/') && + entry.requestPath !== '/api/env.js') || + typeof entry.file !== 'string' || + !/^[A-Za-z0-9._-]+(?:\/[A-Za-z0-9._-]+)*$/u.test(entry.file) || + entry.file.split('/').includes('..') || + seenFiles.has(entry.file) || + !Number.isSafeInteger(entry.bytes) || + Number(entry.bytes) < 0 || + Number(entry.bytes) > MAX_PANEL_FILE_BYTES || + typeof entry.sha256 !== 'string' || + !/^[0-9a-f]{64}$/u.test(entry.sha256) || + typeof entry.contentType !== 'string' || + !PANEL_CONTENT_TYPES.has(entry.contentType) || + (entry.cacheControl !== NO_STORE_CACHE && + entry.cacheControl !== IMMUTABLE_CACHE) + ) { + throw new LocalConsoleAssetError('panel asset entry is invalid'); + } + previousRequestPath = entry.requestPath; + seenFiles.add(entry.file); + const filePath = path.join(root, ...entry.file.split('/')); + let body: Buffer; + let stat; + try { + stat = lstatSync(filePath); + if ( + !stat.isFile() || + stat.isSymbolicLink() || + stat.size !== entry.bytes || + realpathSync(filePath) !== filePath + ) { + throw new TypeError(); + } + body = readFileSync(filePath); + } catch (error) { + throw new LocalConsoleAssetError(`panel asset ${entry.file}`, { + cause: error, + }); + } + if ( + body.byteLength !== entry.bytes || + createHash('sha256').update(body).digest('hex') !== entry.sha256 + ) { + throw new LocalConsoleAssetError(`panel asset ${entry.file} drifted`); + } + totalBytes += body.byteLength; + assets.set( + entry.requestPath, + Object.freeze({ + contentType: entry.contentType, + etag: `"${entry.sha256}"`, + byteLength: body.byteLength, + cacheControl: entry.cacheControl, + contentSecurityPolicy: PANEL_CONTENT_SECURITY_POLICY, + filePath, + }), + ); + } + if ( + manifest.fileCount !== assets.size || + manifest.totalBytes !== totalBytes || + totalBytes > MAX_PANEL_TOTAL_BYTES || + !assets.has('/') || + !assets.has('/api/env.js') + ) { + throw new LocalConsoleAssetError('panel asset closure drifted'); + } + const diskFiles = panelDiskFiles(root); + if ( + diskFiles.length !== seenFiles.size || + diskFiles.some((file) => !seenFiles.has(file)) + ) { + throw new LocalConsoleAssetError('panel disk closure drifted'); + } + const index = assets.get('/')!; + for (const route of PANEL_SUPPORTED_ROUTES) assets.set(route, index); + return assets; +} + +export function loadLocalConsoleAssets(): LocalConsoleAssets { + const assetsRoot = path.resolve(__dirname, '../../assets'); + const panelRoot = path.join(assetsRoot, 'panel'); + const liteAssets = loadLiteAssets(path.join(assetsRoot, 'console')); + if (!existsSync(path.join(panelRoot, 'manifest.json'))) return liteAssets; + const assets = new Map(loadLocalConsolePanelAssets(panelRoot)); + for (const requestPath of ['/console.css', '/console.js']) { + if (assets.has(requestPath)) { + throw new LocalConsoleAssetError( + `panel conflicts with native Console asset ${requestPath}`, + ); + } + assets.set(requestPath, liteAssets.get(requestPath)!); + } + if (assets.has('/console')) { + throw new LocalConsoleAssetError( + 'panel conflicts with native Console route /console', + ); + } + assets.set('/console', liteAssets.get('/')!); + return assets; +} diff --git a/packages/ql3-local-api/src/transport/httpSurface.ts b/packages/ql3-local-api/src/transport/httpSurface.ts index 23eb591c..6cef390d 100644 --- a/packages/ql3-local-api/src/transport/httpSurface.ts +++ b/packages/ql3-local-api/src/transport/httpSurface.ts @@ -1,6 +1,8 @@ import { randomUUID } from 'node:crypto'; +import { createReadStream } from 'node:fs'; import http, { type IncomingMessage, type ServerResponse } from 'node:http'; import type { Socket } from 'node:net'; +import { pipeline } from 'node:stream/promises'; import type { LocalApplicationProfile } from '@qinglong/local-application'; @@ -52,9 +54,6 @@ const SECRET_ROUTE_PATTERN = /^\/api\/v3\/projects\/([A-Za-z0-9][A-Za-z0-9._:-]{0,127})\/secrets$/; const RUN_ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/; const TASK_ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/; -const LOCAL_CONSOLE_CONTENT_SECURITY_POLICY = - "default-src 'none'; script-src 'self'; style-src 'self'; connect-src 'self'; img-src 'self'; base-uri 'none'; form-action 'none'; frame-ancestors 'none'"; - type LocalApiRouteResolution = | LocalApiAdmissionOperation | Readonly<{ @@ -895,19 +894,22 @@ function send( response.end(body); } -function sendConsoleAsset( +async function sendConsoleAsset( + request: IncomingMessage, response: ServerResponse, requestId: string, asset: Readonly, -): void { +): Promise { if (response.destroyed || response.headersSent) return; - response.statusCode = 200; + const ifNoneMatch = rawHeaderValues(request, 'if-none-match'); + const notModified = + asset.cacheControl !== 'no-store' && + ifNoneMatch.length === 1 && + ifNoneMatch[0] === asset.etag; + response.statusCode = notModified ? 304 : 200; response.setHeader('content-type', asset.contentType); - response.setHeader('cache-control', 'no-store'); - response.setHeader( - 'content-security-policy', - LOCAL_CONSOLE_CONTENT_SECURITY_POLICY, - ); + response.setHeader('cache-control', asset.cacheControl); + response.setHeader('content-security-policy', asset.contentSecurityPolicy); response.setHeader('cross-origin-opener-policy', 'same-origin'); response.setHeader('cross-origin-resource-policy', 'same-origin'); response.setHeader( @@ -919,8 +921,28 @@ function sendConsoleAsset( response.setHeader('x-frame-options', 'DENY'); response.setHeader('x-request-id', requestId); response.setHeader('etag', asset.etag); - response.setHeader('content-length', asset.body.byteLength); - response.end(asset.body); + if (notModified) { + response.end(); + return; + } + response.setHeader('content-length', asset.byteLength); + if ('body' in asset) { + await new Promise((resolve, reject) => { + const finish = () => { + response.off('error', reject); + response.off('close', finish); + resolve(); + }; + response.once('error', reject); + response.once('close', finish); + response.end(asset.body, finish); + }); + return; + } + await pipeline( + createReadStream(asset.filePath, { highWaterMark: 64 * 1_024 }), + response, + ); } function sendConsoleFavicon(response: ServerResponse, requestId: string): void { @@ -965,10 +987,13 @@ export async function startLocalApiHttpSurface( validateOptions(options); const consoleAssets = loadLocalConsoleAssets(); const uuid = options.randomUuid ?? randomUUID; - const maxConcurrentRequests = options.profile === 'edge' ? 4 : 32; + const maxConcurrentApiRequests = options.profile === 'edge' ? 4 : 32; + const maxConcurrentAssetRequests = options.profile === 'edge' ? 16 : 64; const drainTimeoutMs = options.profile === 'edge' ? 5_000 : 10_000; let accepting = true; const inFlight = new Set>(); + const apiInFlight = new Set>(); + const assetInFlight = new Set>(); const sockets = new Set(); const server = http.createServer( @@ -983,21 +1008,39 @@ export async function startLocalApiHttpSurface( send(response, requestId, errorResponse(503, 'server_draining')); return; } - if (inFlight.size >= maxConcurrentRequests) { - send(response, requestId, errorResponse(503, 'server_overloaded')); - return; - } const consoleAsset = request.method === 'GET' && typeof request.url === 'string' ? consoleAssets.get(request.url) : undefined; if (consoleAsset) { + if (assetInFlight.size >= maxConcurrentAssetRequests) { + send(response, requestId, errorResponse(503, 'server_overloaded')); + return; + } if (hasRequestBody(request)) { send(response, requestId, errorResponse(400, 'invalid_request_body')); request.resume(); return; } - sendConsoleAsset(response, requestId, consoleAsset); + let operation: Promise; + operation = sendConsoleAsset(request, response, requestId, consoleAsset) + .catch(() => { + if (!response.headersSent) { + send( + response, + requestId, + errorResponse(503, 'response_unavailable'), + ); + } else if (!response.destroyed) { + response.destroy(); + } + }) + .finally(() => { + assetInFlight.delete(operation); + inFlight.delete(operation); + }); + assetInFlight.add(operation); + inFlight.add(operation); return; } if (request.method === 'GET' && request.url === '/favicon.ico') { @@ -1025,6 +1068,10 @@ export async function startLocalApiHttpSurface( ); return; } + if (apiInFlight.size >= maxConcurrentApiRequests) { + send(response, requestId, errorResponse(503, 'server_overloaded')); + return; + } const resolvedRoute = route(request, options.profile); if (!resolvedRoute) { send(response, requestId, errorResponse(404, 'route_not_found')); @@ -1126,15 +1173,17 @@ export async function startLocalApiHttpSurface( send(response, requestId, errorResponse(503, 'request_unavailable')), ) .finally(() => { + apiInFlight.delete(operation); inFlight.delete(operation); }); + apiInFlight.add(operation); inFlight.add(operation); }, ); server.headersTimeout = 5_000; server.keepAliveTimeout = 5_000; server.maxRequestsPerSocket = 100; - server.maxConnections = maxConcurrentRequests * 2; + server.maxConnections = maxConcurrentApiRequests + maxConcurrentAssetRequests; server.on('connection', (socket) => { sockets.add(socket); socket.once('close', () => sockets.delete(socket)); diff --git a/packages/ql3-local-api/test/consoleAssets.test.cjs b/packages/ql3-local-api/test/consoleAssets.test.cjs index 67854068..8cb550a3 100644 --- a/packages/ql3-local-api/test/consoleAssets.test.cjs +++ b/packages/ql3-local-api/test/consoleAssets.test.cjs @@ -1,14 +1,56 @@ const assert = require('node:assert/strict'); +const fs = require('node:fs'); const http = require('node:http'); const net = require('node:net'); +const os = require('node:os'); +const path = require('node:path'); const { test } = require('node:test'); const { loadLocalConsoleAssets, + loadLocalConsolePanelAssets, } = require('../dist/console/localConsoleAssets.js'); const { startLocalApiHttpSurface, } = require('../dist/transport/httpSurface.js'); +const { + bundleLegacyPanel, +} = require('../../../scripts/ql3-legacy-panel-bundle.cjs'); + +function panelFixture() { + const root = fs.realpathSync( + fs.mkdtempSync(path.join(os.tmpdir(), 'ql3-local-api-panel-')), + ); + const source = path.join(root, 'source'); + const output = path.join(root, 'output'); + fs.mkdirSync(source); + fs.writeFileSync( + path.join(source, 'index.html'), + '\n' + + '\n' + + '\n' + + '\n' + + '\n' + + '
\n' + + '\n', + ); + fs.writeFileSync( + path.join(source, 'umi.1234abcd.css'), + 'body { color: #123; }\n', + ); + fs.writeFileSync( + path.join(source, 'umi.1234abcd.js'), + 'globalThis.__panel = true;\n', + ); + bundleLegacyPanel(source, output); + return { + root, + output, + close() { + fs.rmSync(root, { recursive: true, force: true }); + }, + }; +} function reservePort() { return new Promise((resolve, reject) => { @@ -117,6 +159,63 @@ test('loads one bounded offline Console asset closure', () => { assert.ok(totalBytes <= 192 * 1024); }); +test('keeps the native Console route beside a manifested legacy panel', () => { + const source = fs.readFileSync( + path.join(__dirname, '../dist/console/localConsoleAssets.js'), + 'utf8', + ); + assert.match(source, /assets\.set\('\/console', liteAssets\.get\('\/'\)\)/u); + assert.match(source, /panel conflicts with native Console asset/u); +}); + +test('loads the manifested legacy panel as a streamed bounded closure', (t) => { + const current = panelFixture(); + t.after(() => current.close()); + const assets = loadLocalConsolePanelAssets(current.output); + assert.deepEqual( + [...assets.keys()], + [ + '/', + '/api/env.js', + '/umi.1234abcd.css', + '/umi.1234abcd.js', + '/login', + '/crontab', + '/error', + ], + ); + const index = assets.get('/'); + assert.equal(index, assets.get('/login')); + assert.equal(index, assets.get('/crontab')); + assert.equal(index, assets.get('/error')); + assert.equal(index.body, undefined); + assert.equal(path.isAbsolute(index.filePath), true); + assert.equal(index.cacheControl, 'no-store'); + assert.match( + index.contentSecurityPolicy, + /style-src 'self' 'unsafe-inline'/u, + ); + assert.match(index.contentSecurityPolicy, /connect-src 'self'/u); + const script = assets.get('/umi.1234abcd.js'); + assert.equal(script.body, undefined); + assert.equal(script.cacheControl, 'public, max-age=31536000, immutable'); + assert.equal(script.byteLength, 27); + assert.match(script.etag, /^"[0-9a-f]{64}"$/u); + assert.equal(assets.get('/api/env.js').cacheControl, 'no-store'); +}); + +test('rejects a manifested panel whose immutable asset changed', (t) => { + const current = panelFixture(); + t.after(() => current.close()); + const scriptPath = path.join(current.output, 'umi.1234abcd.js'); + fs.chmodSync(scriptPath, 0o600); + fs.appendFileSync(scriptPath, 'drift'); + assert.throws( + () => loadLocalConsolePanelAssets(current.output), + /panel asset /u, + ); +}); + test('serves the Console without authentication and preserves API admission', async (t) => { const calls = []; const port = await reservePort(); @@ -177,6 +276,31 @@ test('serves the Console without authentication and preserves API admission', as assert.deepEqual(calls, ['task.list']); }); +test('serves an Edge browser asset burst without consuming API admission slots', async (t) => { + const port = await reservePort(); + const active = await startLocalApiHttpSurface({ + profile: 'edge', + host: '127.0.0.1', + port, + admission: { + async prepare() { + throw new Error('static assets must not reach admission'); + }, + }, + }); + t.after(() => active.stopAndDrain()); + + const responses = await Promise.all( + Array.from({ length: 12 }, (_, index) => + request(port, index % 2 === 0 ? '/console.js' : '/console.css'), + ), + ); + assert.deepEqual( + responses.map(({ statusCode }) => statusCode), + Array.from({ length: 12 }, () => 200), + ); +}); + test('rejects request bodies and query aliases on Console assets', async (t) => { const port = await reservePort(); const active = await startLocalApiHttpSurface({ diff --git a/scripts/ql3-legacy-panel-bundle.cjs b/scripts/ql3-legacy-panel-bundle.cjs new file mode 100644 index 00000000..894bcb4d --- /dev/null +++ b/scripts/ql3-legacy-panel-bundle.cjs @@ -0,0 +1,408 @@ +#!/usr/bin/env node + +'use strict'; + +const crypto = require('node:crypto'); +const fs = require('node:fs'); +const path = require('node:path'); + +const SCHEMA = 'qinglong/local-legacy-panel-assets@v1'; +const MAX_FILES = 256; +const MAX_TOTAL_BYTES = 13 * 1024 * 1024; +const MAX_FILE_BYTES = 3 * 1024 * 1024; +const SUPPORTED_ROUTES = Object.freeze(['/login', '/crontab', '/error']); +const CONTENT_TYPES = Object.freeze({ + '.css': 'text/css; charset=utf-8', + '.html': 'text/html; charset=utf-8', + '.js': 'text/javascript; charset=utf-8', + '.ttf': 'font/ttf', + '.woff': 'font/woff', + '.woff2': 'font/woff2', +}); +const HASHED_ASSET = + /\.[0-9a-f]{8}(?:\.[A-Za-z0-9_-]+)*\.(?:css|js|ttf|woff2?)$/u; +const EXTERNAL_FAVICON = + /\r?\n?/u; +const ENVIRONMENT_SOURCE = + "window.__ENV__ = Object.freeze({ QlBaseUrl: '/', DeployEnv: '', QL_DIR: '' });\n"; + +function fail(message) { + throw new Error(`QingLong legacy panel bundle failed: ${message}`); +} + +function sha256(body) { + return crypto.createHash('sha256').update(body).digest('hex'); +} + +function canonicalDirectory(directory, label) { + const resolved = path.resolve(directory); + if (resolved === path.parse(resolved).root) fail(`${label} is too broad`); + let stat; + try { + stat = fs.lstatSync(resolved); + } catch { + fail(`${label} is unavailable`); + } + if ( + !stat.isDirectory() || + stat.isSymbolicLink() || + fs.realpathSync(resolved) !== resolved + ) { + fail(`${label} must be a canonical directory`); + } + return resolved; +} + +function outputDirectory(directory, sourceRoot) { + const resolved = path.resolve(directory); + if ( + resolved === path.parse(resolved).root || + resolved === sourceRoot || + resolved.startsWith(`${sourceRoot}${path.sep}`) || + sourceRoot.startsWith(`${resolved}${path.sep}`) || + fs.existsSync(resolved) + ) { + fail('output must be an absent directory outside the source closure'); + } + const parent = canonicalDirectory(path.dirname(resolved), 'output parent'); + if (path.dirname(resolved) !== parent) fail('output parent drifted'); + return resolved; +} + +function sourceFiles(root) { + const files = []; + const pending = [root]; + while (pending.length > 0) { + const directory = pending.pop(); + const entries = fs + .readdirSync(directory, { withFileTypes: true }) + .sort((left, right) => left.name.localeCompare(right.name)); + for (const entry of entries) { + const filePath = path.join(directory, entry.name); + const stat = fs.lstatSync(filePath); + if (stat.isSymbolicLink()) fail('source closure contains a symlink'); + if (stat.isDirectory()) { + pending.push(filePath); + continue; + } + if (!stat.isFile()) fail('source closure contains a special file'); + const relative = path.relative(root, filePath).split(path.sep).join('/'); + if (relative.endsWith('.gz') || relative.startsWith('monaco-editor/')) { + continue; + } + const extension = path.extname(relative); + if (!Object.hasOwn(CONTENT_TYPES, extension)) { + fail(`unsupported source asset ${relative}`); + } + if (relative !== 'index.html' && !HASHED_ASSET.test(relative)) { + fail(`mutable source asset ${relative}`); + } + files.push( + Object.freeze({ filePath, relative, extension, bytes: stat.size }), + ); + } + } + files.sort((left, right) => left.relative.localeCompare(right.relative)); + if ( + files.length < 3 || + files.length > MAX_FILES - 1 || + files[0]?.relative === undefined || + !files.some(({ relative }) => relative === 'index.html') + ) { + fail('source asset count or entrypoint is invalid'); + } + return files; +} + +function normalizedIndex(source) { + const index = source.toString('utf8'); + if ( + !index.includes('
') || + !index.includes('') || + !/\n' + + '
\n' + + '\n', + ); + fs.writeFileSync( + path.join(source, 'umi.1234abcd.css'), + 'body { color: #123; }\n', + ); + fs.writeFileSync( + path.join(source, 'umi.1234abcd.js'), + 'globalThis.__panel = true;\n', + ); + fs.writeFileSync(path.join(source, 'umi.1234abcd.js.gz'), 'not shipped'); + fs.writeFileSync( + path.join(source, 'monaco-editor', 'editor.1234abcd.js'), + 'not shipped', + ); + return { + root, + source, + output, + close() { + fs.rmSync(root, { recursive: true, force: true }); + }, + }; +} + +test('materializes one bounded offline legacy panel closure', (t) => { + const current = fixture(); + t.after(() => current.close()); + const report = bundleLegacyPanel(current.source, current.output); + assert.deepEqual(report, { + schema: 'qinglong/local-legacy-panel-assets@v1', + files: 4, + bytes: report.bytes, + maxFiles: 256, + maxBytes: 13 * 1024 * 1024, + supportedRoutes: ['/login', '/crontab', '/error'], + compatible: true, + }); + assert.ok(report.bytes > 200); + assert.equal( + fs.existsSync(path.join(current.output, 'umi.1234abcd.js.gz')), + false, + ); + assert.equal( + fs.existsSync(path.join(current.output, 'monaco-editor')), + false, + ); + const index = fs.readFileSync( + path.join(current.output, 'index.html'), + 'utf8', + ); + assert.equal(index.includes('https://'), false); + const environment = fs.readFileSync( + path.join(current.output, 'api/env.js'), + 'utf8', + ); + assert.equal(environment.includes("QlBaseUrl: '/'"), true); + const manifest = JSON.parse( + fs.readFileSync(path.join(current.output, 'manifest.json'), 'utf8'), + ); + assert.equal(manifest.files[0].requestPath, '/'); + assert.equal( + manifest.files.find(({ requestPath }) => requestPath === '/api/env.js') + .cacheControl, + 'no-store', + ); + assert.equal( + manifest.files.find(({ requestPath }) => + /\.[0-9a-f]{8}\.js$/u.test(requestPath), + ).cacheControl, + 'public, max-age=31536000, immutable', + ); + assert.deepEqual(auditLegacyPanelBundle(current.output), report); +}); + +test('rejects bundle replacement and post-build asset drift', (t) => { + const current = fixture(); + t.after(() => current.close()); + bundleLegacyPanel(current.source, current.output); + assert.throws( + () => bundleLegacyPanel(current.source, current.output), + /output must be an absent directory/u, + ); + const assetPath = path.join(current.output, 'umi.1234abcd.js'); + fs.chmodSync(assetPath, 0o600); + fs.appendFileSync(assetPath, 'drift'); + assert.throws( + () => auditLegacyPanelBundle(current.output), + /asset identity drifted|asset digest drifted/u, + ); +}); + +test('rejects mutable assets and external entrypoint drift', (t) => { + const mutable = fixture(); + t.after(() => mutable.close()); + fs.writeFileSync(path.join(mutable.source, 'runtime.js'), 'mutable'); + assert.throws( + () => bundleLegacyPanel(mutable.source, mutable.output), + /mutable source asset/u, + ); + + const external = fixture(); + t.after(() => external.close()); + fs.appendFileSync( + path.join(external.source, 'index.html'), + '\n', + ); + assert.throws( + () => bundleLegacyPanel(external.source, external.output), + /retains an external origin/u, + ); +}); diff --git a/test/back/ql3LocalConsoleImageInventory.test.cjs b/test/back/ql3LocalConsoleImageInventory.test.cjs index 95c046ee..39f4e2fd 100644 --- a/test/back/ql3LocalConsoleImageInventory.test.cjs +++ b/test/back/ql3LocalConsoleImageInventory.test.cjs @@ -60,6 +60,8 @@ test('accepts the exact bounded Local Console package closure', (t) => { assert.equal(report.packageCount, 12); assert.equal(report.ai, 'excluded'); assert.equal(report.listener, 'loopback-only'); + assert.equal(report.maxFiles, 768); + assert.equal(report.maxBytes, 20 * 1024 * 1024); assert.equal(report.compatible, true); });