d3ro-voice/scripts/ci/lib/runtime-feed-gate.mjs
Yun Chan ba9ef9741e fix: red-team round 3 hardening across desktop, mobile, core and server
Batch of red-team r3 fixes that were in the working tree before the
2026-09-28 design overhaul, committed as one unit with their tests.

- desktop main: STT timeouts and sidecar, voice recording store, sync
  (credentials, audio, knowledge reindex, push gates), runtime
  provisioner, update policy, AltGr keybindings, voice-command policy,
  dictionary file codec/limits, meeting transcript condensing and a
  local recording ledger so interrupted-session recovery only closes
  meetings this device recorded (a phone's live meeting is left alone).
- mobile: login CSRF via implicit token callbacks rejected, account
  deletion/retention, durable queue retention, knowledge realtime
  without unfiltered DELETE, meeting re-record failure paths, cloud STT
  client, preferences store/resync.
- core: text chunking splits long unbroken transcripts to fit, template
  field policy, dictionary limits, meeting markdown inline handling.
- server: payple webhook policy and cancellation order scope, meeting
  document generation quota, team RPC null-role guard, unified LLM
  quota in-flight accounting, knowledge chunk vector index, meeting
  re-record failure paths (migrations 20260929*).
- ci: portable/runtime feed gates, update-policy schema, Forgejo file
  delete and alias planning.

Four older tests are updated to the new contracts rather than the old
behavior: token-pair auth callbacks are rejected, knowledge realtime no
longer subscribes to DELETE, long transcript lines are split, and
meeting recovery requires the local recording ledger for empty rows.
2026-09-28 20:45:52 +09:00

121 lines
6 KiB
JavaScript

// scripts/ci/lib/runtime-feed-gate.mjs
// 앱 업데이트(latest.yml)를 게시하기 전에 runtime-latest 가 이 빌드와 맞는지 확인하는 게이트.
//
// 왜: 앱은 사이드카 엔진을 설치본에 넣지 않고 runtime-latest 에서 받는다. 앱이
// RUNTIME_MIN_VERSION 을 올렸는데 runtime-latest 가 아직 예전 버전이면, 업데이트한
// 사용자는 설치된 엔진도 "낡았다" 고 거부하고 새 엔진도 받지 못해 로컬 STT 가 멈춘다.
// 태그 파이프라인(release.yml)과 런타임 파이프라인(portable.yml)은 서로 기다리지 않고,
// 로컬 게시(release:updater)도 "런타임 먼저" 가 문서 절차일 뿐이었다. 그래서 두
// publisher 가 latest.yml 을 올리기 전에 이 게이트를 fail-closed 로 돌린다.
//
// 구성:
// 1) parseRuntimeMinVersions — 앱 소스(runtime-index.ts)의 RUNTIME_MIN_VERSION 을 읽는다 (정본은 앱)
// 2) evaluateRuntimeFeed — 순수 판정 (문제 목록)
// 3) assertRuntimeFeedCompatible — fetch 를 주입받는 IO 어댑터
import { compareSemver, parseSemver } from "./latest-feed-guard.mjs";
/** 앱 쪽 최소 버전 정본 (저장소 루트 기준) */
export const RUNTIME_MIN_VERSION_SOURCE = "apps/desktop/src/main/services/runtime/runtime-index.ts";
/** 자동 업데이트 feed(latest.yml)가 배포하는 데스크톱 빌드의 대상 — electron-builder --win --x64 */
export const WINDOWS_X64_TARGET = Object.freeze({ platform: "win32", arch: "x64" });
/**
* runtime-index.ts 소스에서 RUNTIME_MIN_VERSION 객체를 읽는다.
* 형식이 바뀌어 읽을 수 없으면 조용히 넘어가지 않고 예외를 던진다.
* @param {string} source
* @returns {Record<string, string | null>}
*/
export function parseRuntimeMinVersions(source) {
const block = /export const RUNTIME_MIN_VERSION\b[^=]*=\s*\{([\s\S]*?)\}/.exec(String(source ?? ""));
if (!block) throw new Error("RUNTIME_MIN_VERSION not found in runtime-index.ts");
/** @type {Record<string, string | null>} */
const result = {};
for (const match of block[1].matchAll(/^\s*([A-Za-z][\w-]*)\s*:\s*(null|'([^']*)'|"([^"]*)")\s*,?\s*$/gm)) {
const value = match[2] === "null" ? null : (match[3] ?? match[4]);
if (value !== null && !parseSemver(value)) {
throw new Error(`RUNTIME_MIN_VERSION.${match[1]} is not semver: ${value}`);
}
result[match[1]] = value;
}
if (Object.keys(result).length === 0) throw new Error("RUNTIME_MIN_VERSION has no components");
return result;
}
function isRecord(value) {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
/**
* runtime.json 이 이 빌드의 요구를 만족하는지 판정한다. 비어 있으면 통과.
* - version 은 semver 이고, 최소 버전이 있는 모든 구성 요소의 최소 버전 이상이어야 한다.
* - 앱이 받는 모든 구성 요소(minVersions 의 키)가 components 에 있어야 한다.
* - platform/arch 를 밝혀야 하고, 대상과 같아야 한다 (다른 플랫폼 엔진을 받는 루프 방지).
* @param {{ index: unknown, minVersions: Record<string, string | null>, target: { platform: string, arch: string } }} input
* @returns {string[]}
*/
export function evaluateRuntimeFeed({ index, minVersions, target }) {
if (!isRecord(index)) return ["runtime.json is not an object"];
const problems = [];
const version = typeof index.version === "string" ? index.version : null;
if (!version || !parseSemver(version)) problems.push(`runtime.json version is not semver: ${String(index.version)}`);
if (typeof index.platform !== "string" || typeof index.arch !== "string") {
problems.push("runtime.json does not declare platform/arch — republish the runtime with the current build-portable.mjs");
} else if (index.platform !== target.platform || index.arch !== target.arch) {
problems.push(
`runtime.json targets ${index.platform}-${index.arch}, but this app build is ${target.platform}-${target.arch}`,
);
}
const components = isRecord(index.components) ? index.components : {};
for (const [component, minimum] of Object.entries(minVersions)) {
if (!isRecord(components[component])) {
problems.push(`runtime.json has no ${component} component`);
continue;
}
if (minimum !== null && version && parseSemver(version) && compareSemver(version, minimum) < 0) {
problems.push(
`runtime.json version ${version} is below this build's RUNTIME_MIN_VERSION.${component} ${minimum}`,
);
}
}
return problems;
}
/**
* 게시된 runtime.json 을 읽어 판정하고, 문제가 있으면 예외를 던진다 (fail-closed).
* 읽을 수 없어도(404·네트워크·JSON 오류) 통과시키지 않는다.
* @param {{
* url: string,
* fetchImpl: (url: string, init?: RequestInit) => Promise<Response>,
* minVersions: Record<string, string | null>,
* target?: { platform: string, arch: string },
* }} input
* @returns {Promise<{ version: string }>}
*/
export async function assertRuntimeFeedCompatible({ url, fetchImpl, minVersions, target = WINDOWS_X64_TARGET }) {
let index;
try {
const response = await fetchImpl(url, { cache: "no-store" });
if (!response.ok) throw new Error(`HTTP ${response.status}`);
index = await response.json();
} catch (error) {
throw new Error(
`Runtime feed gate: cannot read ${url} (${error instanceof Error ? error.message : String(error)}). ` +
"Publish the runtime first (npm run release:portable) — refusing to publish latest.yml.",
);
}
const problems = evaluateRuntimeFeed({ index, minVersions, target });
if (problems.length > 0) {
throw new Error(
[
`Runtime feed gate: ${url} cannot serve this app build — refusing to publish latest.yml.`,
...problems.map((problem) => ` - ${problem}`),
" Publish the runtime first (npm run release:portable / portable.yml), then rerun this publisher.",
].join("\n"),
);
}
return { version: /** @type {{ version: string }} */ (index).version };
}