d3ro-voice/scripts/ci/lib/portable-publish-policy.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

400 lines
17 KiB
JavaScript

// scripts/ci/lib/portable-publish-policy.mjs
// 휴대용 배포본(portable-*) · 로컬 AI 런타임(runtime-*) 게시 정책과 유스케이스.
//
// Forgejo generic registry 경로는 두 종류다.
// - 버전 경로 <kind>-<version> : 불변. 한 번 게시된 파일은 절대 지우거나 바꾸지 않는다.
// runtime-latest/runtime.json 과 커밋된 Scoop 매니페스트(bucket/d3ro-voice.json)가
// 이 경로의 파일과 sha256을 직접 가리키므로, 교체하면 이미 배포된 클라이언트가 깨진다.
// 재실행(예: Cloudflare 524 후)이 부분 업로드를 복구할 수 있도록, 원격에 없는 파일만
// 이어서 올리고 같은 이름에 다른 바이트가 있으면 중단한다(fail-closed).
// - 별칭 경로 <kind>-latest : 모든 태그가 공유. 버전 경로가 모두 완성된 뒤에만, 그리고
// 이미 게시된 인덱스(runtime.json / portable.json)의 버전보다 오래된 버전이 아닐 때만
// 교체한다. 게시된 버전을 읽을 수 없으면 건드리지 않는다(fail-closed).
// 교체는 패키지 삭제가 아니라 바뀐 파일만 파일 단위 DELETE→PUT, 인덱스가 마지막이다
// (./portable-alias-plan.mjs). 별칭에는 spec.aliasNames 파일만 둔다.
// - 재실행 복구: 버전 경로가 이미 완성돼 있고 재빌드 바이트만 달라 abort 되는 경우(빌드가
// 재현 불가 — generatedAt 등), 중간에 끊긴 별칭을 원격 버전 경로의 인덱스로 복구한 뒤 중단한다.
//
// 구조
// 1) 순수 정책: planVersionedPackage / parsePublishedIndexVersion / decideAliasUpdate
// + ./portable-alias-plan.mjs (selectAliasItems / planAliasFiles / planAliasRestore)
// 2) 유스케이스: publishPortablePackages — registry 포트(IO)를 주입받는다.
// IO 어댑터는 ./forgejo-generic-registry.mjs (+ ./forgejo-generic-file-delete.mjs) 에 있다.
import { createHash } from "node:crypto";
import { decideLatestFeedUpdate, parseSemver } from "./latest-feed-guard.mjs";
import {
AliasSelectionError,
planAliasFiles,
planAliasRestore,
selectAliasItems,
} from "./portable-alias-plan.mjs";
export { planAliasFiles, planAliasRestore, selectAliasItems } from "./portable-alias-plan.mjs";
/**
* @typedef {{ name: string, bytes: Uint8Array, contentType: string }} Payload
* @typedef {Payload & { sha256: string }} HashedPayload
* @typedef {{
* listFileHashes: (versionPath: string) => Promise<Map<string, string>>,
* uploadFile: (versionPath: string, file: HashedPayload) => Promise<void>,
* readTextFile: (versionPath: string, name: string) => Promise<string | undefined>,
* deleteFile?: (versionPath: string, name: string) => Promise<void>,
* deleteVersion?: (versionPath: string) => Promise<void>,
* }} PackageRegistry
* deleteFile 이 있으면 별칭을 파일 단위로 교체한다(권장). 없으면 deleteVersion 으로
* 별칭 전체를 지우고 다시 올리는 예전 방식으로 동작한다(인덱스 404 구간이 길다).
* @typedef {{ kind: string, indexName: string, payloads: readonly Payload[], aliasNames?: readonly string[] }} PackageSpec
* aliasNames: *-latest 별칭에 둘 파일(인덱스 포함). 생략하면 모든 payload.
*/
export class PortablePublishError extends Error {
/** @param {string} message */
constructor(message) {
super(message);
this.name = "PortablePublishError";
}
}
/**
* @param {readonly Payload[]} payloads
* @returns {HashedPayload[]}
*/
export function hashPayloads(payloads) {
return payloads.map((payload) => ({
...payload,
sha256: sha256Hex(payload.bytes),
}));
}
/** @param {Uint8Array} bytes */
function sha256Hex(bytes) {
return createHash("sha256").update(bytes).digest("hex");
}
/**
* 불변 버전 경로 게시 계획.
* - 같은 이름에 다른 sha256이 원격에 있음 → abort (절대 삭제/교체하지 않는다)
* - 로컬 파일이 모두 같은 바이트로 원격에 있음 → skip
* - 일부만 있음(부분 업로드 재실행) 또는 비어 있음 → 없는 파일만 upload (순서 유지: 인덱스가 마지막)
*
* @param {{ items: readonly HashedPayload[], remoteHashes: ReadonlyMap<string, string> }} input
* @returns {{ action: "abort" | "skip" | "upload", conflicts: HashedPayload[], uploads: HashedPayload[] }}
*/
export function planVersionedPackage({ items, remoteHashes }) {
const conflicts = items.filter(
(item) => remoteHashes.has(item.name) && remoteHashes.get(item.name) !== item.sha256,
);
if (conflicts.length > 0) return { action: "abort", conflicts, uploads: [] };
const uploads = items.filter((item) => !remoteHashes.has(item.name));
if (uploads.length === 0) return { action: "skip", conflicts: [], uploads: [] };
return { action: "upload", conflicts: [], uploads };
}
/**
* 별칭 교체 여부 요약(skip/replace). 유스케이스는 planAliasFiles 의 파일 단위 계획을 쓴다.
* deleteFirst 는 원격 별칭에 파일이 있어 교체 시 삭제가 필요하다는 뜻이다.
*
* @deprecated planAliasFiles 를 쓴다. 기존 호출부 호환용 요약이다.
* @param {{ items: readonly HashedPayload[], remoteHashes: ReadonlyMap<string, string> }} input
* @returns {{ action: "skip" | "replace", deleteFirst: boolean }}
*/
export function planAliasPackage({ items, remoteHashes }) {
const { writes } = planAliasFiles({ items, remoteHashes });
if (writes.length === 0) return { action: "skip", deleteFirst: false };
return { action: "replace", deleteFirst: remoteHashes.size > 0 };
}
/**
* 게시된 인덱스(runtime.json / portable.json)의 최상위 version 을 읽는다.
* @param {string} text
* @returns {string | null} 파싱할 수 없거나 semver가 아니면 null
*/
export function parsePublishedIndexVersion(text) {
let parsed;
try {
parsed = JSON.parse(String(text ?? ""));
} catch {
return null;
}
const value = parsed && typeof parsed === "object" ? parsed.version : undefined;
if (typeof value !== "string" || !parseSemver(value)) return null;
return value.trim().replace(/^v/, "");
}
/**
* 별칭 교체 여부. decideLatestFeedUpdate 를 재사용하되, 게시된 인덱스를 읽었는데 버전을
* 알 수 없으면(null) 교체하지 않고 중단한다 — 스키마가 바뀐 더 새로운 버전을 오래된
* 버전으로 덮어쓰는 롤백을 막기 위한 fail-closed.
*
* @param {{ publishingVersion: string, publishedVersion: string | null | undefined }} input
* @returns {{ update: boolean, abort: boolean, reason: string }}
*/
export function decideAliasUpdate({ publishingVersion, publishedVersion }) {
if (publishedVersion === null) {
return { update: false, abort: true, reason: "unreadable-feed" };
}
const decision = decideLatestFeedUpdate({ publishingVersion, publishedVersion });
return { ...decision, abort: false };
}
/**
* @param {PackageRegistry} registry
* @param {string} aliasPath
* @param {string} indexName
* @returns {Promise<string | null | undefined>} undefined=별칭 없음, null=버전 읽기 불가
*/
async function readAliasVersion(registry, aliasPath, indexName) {
const text = await registry.readTextFile(aliasPath, indexName);
if (text === undefined) return undefined;
return parsePublishedIndexVersion(text);
}
/**
* @typedef {HashedPayload[]} AliasItems
* @typedef {{
* spec: PackageSpec & { items: HashedPayload[] },
* versionPath: string,
* aliasPath: string,
* aliasItems: AliasItems,
* remoteHashes: Map<string, string>,
* plan: ReturnType<typeof planVersionedPackage>,
* }} PlannedPackage
*/
/**
* 롤백 방지 판정. 교체해도 되면 true, 더 새로운 버전이 있으면 false, 읽을 수 없으면 예외.
* @param {PackageRegistry} registry
* @param {PlannedPackage} pkg
* @param {string} version
* @param {(message: string) => void} log
* @param {Record<string, string>} aliases
*/
async function aliasMayAdvance(registry, pkg, version, log, aliases) {
const { aliasPath, spec } = pkg;
const publishedVersion = await readAliasVersion(registry, aliasPath, spec.indexName);
const decision = decideAliasUpdate({ publishingVersion: version, publishedVersion });
if (decision.abort) {
throw new PortablePublishError(
`${aliasPath}/${spec.indexName} 의 게시 버전을 읽을 수 없습니다. ` +
"버전을 모른 채 별칭을 덮어쓰지 않습니다(롤백 방지).",
);
}
if (!decision.update) {
log(`${aliasPath} 건너뜀: 더 새로운 버전(${publishedVersion})이 이미 게시돼 있습니다 (이번 ${version})`);
aliases[spec.kind] = decision.reason;
}
return decision.update;
}
/**
* 별칭 계획을 실행한다. deleteFile 포트가 있으면 파일 단위 교체(인덱스가 마지막, 정리는 그 뒤),
* 없으면 예전 방식(별칭 전체 삭제 후 전부 업로드)으로 대체한다.
*
* @param {PackageRegistry} registry
* @param {string} aliasPath
* @param {AliasItems} aliasItems
* @param {{ writes: Array<{ item: HashedPayload, replace: boolean }>, prunes: readonly string[] }} plan
*/
async function applyAliasPlan(registry, aliasPath, aliasItems, plan) {
if (typeof registry.deleteFile === "function") {
for (const { item, replace } of plan.writes) {
if (replace) await registry.deleteFile(aliasPath, item.name);
await registry.uploadFile(aliasPath, item);
}
for (const name of plan.prunes) await registry.deleteFile(aliasPath, name);
return;
}
const needsDelete = plan.prunes.length > 0 || plan.writes.some((write) => write.replace);
if (!needsDelete) {
for (const { item } of plan.writes) await registry.uploadFile(aliasPath, item);
return;
}
if (typeof registry.deleteVersion !== "function") {
throw new PortablePublishError(`${aliasPath} 를 교체할 삭제 수단(deleteFile/deleteVersion)이 registry에 없습니다.`);
}
await registry.deleteVersion(aliasPath);
for (const item of aliasItems) await registry.uploadFile(aliasPath, item);
}
/**
* 로컬 빌드로 별칭을 갱신한다(버전 경로가 완성된 뒤에만 호출).
* @param {PackageRegistry} registry
* @param {PlannedPackage} pkg
* @returns {Promise<"unchanged" | "replaced">}
*/
async function publishAlias(registry, pkg) {
const plan = planAliasFiles({
items: pkg.aliasItems,
remoteHashes: await registry.listFileHashes(pkg.aliasPath),
});
if (plan.action === "skip") return "unchanged";
await applyAliasPlan(registry, pkg.aliasPath, pkg.aliasItems, plan);
return "replaced";
}
/**
* 버전 경로가 이미 완성돼 있는데 로컬 재빌드가 달라 게시를 중단하는 경우, 별칭을 원격 버전 경로의
* 게시본(정본)으로 맞춘다 — 이전 실행이 별칭 교체 도중 끊겼다면 CI 재실행이 이를 복구한다.
* 파일 단위 삭제 포트가 없거나 복구할 수 없으면 아무것도 쓰지 않는다.
*
* @param {PackageRegistry} registry
* @param {PlannedPackage} pkg
* @returns {Promise<"restored" | "unchanged" | "unrestorable">}
*/
async function restoreAliasFromPublished(registry, pkg) {
if (typeof registry.deleteFile !== "function") return "unrestorable";
const aliasHashes = await registry.listFileHashes(pkg.aliasPath);
const restore = planAliasRestore({
aliasItems: pkg.aliasItems,
versionedHashes: pkg.remoteHashes,
aliasHashes,
});
if (!restore.restorable) return "unrestorable";
if (restore.reads.length === 0 && restore.prunes.length === 0) return "unchanged";
/** @type {Array<{ item: HashedPayload, replace: boolean }>} */
const writes = [];
for (const read of restore.reads) {
const text = await registry.readTextFile(pkg.versionPath, read.name);
if (text === undefined) return "unrestorable";
const bytes = Buffer.from(text, "utf8");
// 원격 바이트와 정확히 같은지 확인한다(인코딩 손실이 있으면 옮기지 않는다).
if (sha256Hex(bytes) !== read.sha256) return "unrestorable";
writes.push({
item: { name: read.name, bytes, contentType: read.contentType, sha256: read.sha256 },
replace: aliasHashes.has(read.name),
});
}
await applyAliasPlan(registry, pkg.aliasPath, [], { writes, prunes: restore.prunes });
return "restored";
}
/**
* @param {PlannedPackage[]} aborted
* @param {Record<string, string>} aliases
*/
function conflictError(aborted, aliases) {
const lines = aborted.map(
(pkg) =>
`${pkg.versionPath} 에 같은 이름의 다른 바이트가 이미 게시돼 있습니다: ` +
pkg.plan.conflicts.map((item) => item.name).join(", "),
);
const restored = Object.entries(aliases)
.filter(([, status]) => status === "restored")
.map(([kind]) => `${kind}-latest`);
return new PortablePublishError(
`${lines.join("\n")}\n` +
" 버전 경로는 불변이라 지우거나 덮어쓰지 않습니다. 새 버전으로 게시하세요.\n" +
(restored.length > 0
? ` (중단된 별칭은 게시된 버전 경로의 인덱스로 복구했습니다: ${restored.join(", ")})\n`
: "") +
" (게시가 중간에 실패해 어떤 별칭도 이 버전을 가리키지 않는 것이 확실할 때만 " +
"패키지 버전을 수동으로 삭제한 뒤 다시 실행하세요.)",
);
}
/**
* 버전 경로를 모두 완성한 뒤 별칭을 갱신한다.
*
* @param {{
* version: string,
* packages: readonly PackageSpec[],
* registry: PackageRegistry,
* log: (message: string) => void,
* dryRun?: boolean,
* describeUrl?: (versionPath: string, name: string) => string,
* }} input
* @returns {Promise<{ versioned: Record<string, string>, aliases: Record<string, string> }>}
*/
export async function publishPortablePackages({
version,
packages,
registry,
log,
dryRun = false,
describeUrl = (versionPath, name) => `${versionPath}/${name}`,
}) {
if (!parseSemver(version)) {
throw new PortablePublishError(`게시 버전이 semver가 아닙니다: ${version}`);
}
const active = packages
.filter((spec) => spec.payloads.length > 0)
.map((spec) => {
const items = hashPayloads(spec.payloads);
let aliasItems;
try {
aliasItems = selectAliasItems({ items, indexName: spec.indexName, aliasNames: spec.aliasNames });
} catch (error) {
if (error instanceof AliasSelectionError) throw new PortablePublishError(`${spec.kind}: ${error.message}`);
throw error;
}
return {
spec: { ...spec, items },
versionPath: `${spec.kind}-${version}`,
aliasPath: `${spec.kind}-latest`,
aliasItems,
};
});
/** @type {Record<string, string>} */
const versioned = {};
/** @type {Record<string, string>} */
const aliases = {};
if (dryRun) {
for (const pkg of active) {
for (const item of pkg.spec.items) {
log(`(check) PUT ${describeUrl(pkg.versionPath, item.name)} (${item.bytes.length} bytes)`);
}
for (const item of pkg.aliasItems) {
log(`(check) PUT ${describeUrl(pkg.aliasPath, item.name)} (${item.bytes.length} bytes)`);
}
}
return { versioned, aliases };
}
// 1) 불변 버전 경로 계획 — 어느 하나라도 충돌하면 어떤 버전 경로에도 쓰지 않는다.
/** @type {PlannedPackage[]} */
const planned = [];
for (const pkg of active) {
const remoteHashes = await registry.listFileHashes(pkg.versionPath);
planned.push({
...pkg,
remoteHashes,
plan: planVersionedPackage({ items: pkg.spec.items, remoteHashes }),
});
}
const aborted = planned.filter((pkg) => pkg.plan.action === "abort");
if (aborted.length > 0) {
// 재실행 복구: 이미 완성된 버전 경로(게시본)로 끊긴 별칭을 맞춘 뒤 중단한다.
for (const pkg of aborted) {
if (!(await aliasMayAdvance(registry, pkg, version, log, aliases))) continue;
aliases[pkg.spec.kind] = await restoreAliasFromPublished(registry, pkg);
}
throw conflictError(aborted, aliases);
}
// 2) 버전 경로 업로드 — 모두 완성되기 전에는 어떤 별칭도 건드리지 않는다.
for (const pkg of planned) {
if (pkg.plan.action === "skip") {
log(`변경 없음(건너뜀): ${pkg.versionPath}`);
versioned[pkg.spec.kind] = "unchanged";
continue;
}
for (const item of pkg.plan.uploads) await registry.uploadFile(pkg.versionPath, item);
versioned[pkg.spec.kind] =
pkg.plan.uploads.length === pkg.spec.items.length ? "uploaded" : "resumed";
}
// 3) 공유 별칭 — 더 새로운 버전이 게시돼 있으면 되돌리지 않는다. 파일 단위, 인덱스가 마지막.
for (const pkg of planned) {
if (!(await aliasMayAdvance(registry, pkg, version, log, aliases))) continue;
const status = await publishAlias(registry, pkg);
if (status === "unchanged") log(`변경 없음(건너뜀): ${pkg.aliasPath}`);
aliases[pkg.spec.kind] = status;
}
return { versioned, aliases };
}