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

110 lines
5.4 KiB
JavaScript

// scripts/ci/lib/portable-alias-plan.mjs
// *-latest 별칭 게시의 순수 정책 (IO 없음). 유스케이스는 ./portable-publish-policy.mjs.
//
// 별칭 교체 원칙
// - 패키지 버전 전체를 지우지 않는다. 바뀐 파일만 파일 단위로 DELETE→PUT 한다
// (Forgejo는 같은 이름 덮어쓰기를 409로 거부한다). 404 구간은 파일 하나를 바꾸는 수 초뿐이다.
// - 인덱스(runtime.json / portable.json)는 항상 마지막에 바꾼다 — 인덱스 교체가 커밋 지점이다.
// 새 부품을 올리는 동안에는 옛 인덱스와 옛 부품이 그대로 살아 있다.
// - 새 인덱스가 올라간 뒤에만 별칭 집합에 없는 파일(이전 버전 부품, 예전에 별칭에 올리던 볼륨)을 정리한다.
// - 별칭에는 클라이언트가 별칭 경로로 직접 받는 파일만 둔다. 버전 경로 URL로 참조되는 부품은 올리지 않는다.
/**
* @typedef {import("./portable-publish-policy.mjs").HashedPayload} HashedPayload
* @typedef {{ item: HashedPayload, replace: boolean }} AliasWrite
* @typedef {{ action: "skip" | "update", writes: AliasWrite[], prunes: string[] }} AliasPlan
*/
export class AliasSelectionError extends Error {
/** @param {string} message */
constructor(message) {
super(message);
this.name = "AliasSelectionError";
}
}
/**
* 별칭에 올릴 항목을 고른다. aliasNames가 없으면 모든 payload(하위 호환).
* payload 순서를 유지하되 인덱스는 맨 뒤로 보낸다(커밋 지점).
*
* @param {{ items: readonly HashedPayload[], indexName: string, aliasNames?: readonly string[] }} input
* @returns {HashedPayload[]}
*/
export function selectAliasItems({ items, indexName, aliasNames }) {
let selected = [...items];
if (aliasNames !== undefined) {
const wanted = new Set(aliasNames);
const known = new Set(items.map((item) => item.name));
const unknown = [...wanted].filter((name) => !known.has(name));
if (unknown.length > 0) {
throw new AliasSelectionError(`별칭 파일이 게시 payload에 없습니다: ${unknown.join(", ")}`);
}
if (!wanted.has(indexName)) {
throw new AliasSelectionError(`별칭에는 인덱스(${indexName})가 있어야 합니다.`);
}
selected = items.filter((item) => wanted.has(item.name));
}
return [
...selected.filter((item) => item.name !== indexName),
...selected.filter((item) => item.name === indexName),
];
}
/**
* 별칭 파일 단위 교체 계획.
* - writes: 원격과 바이트가 다른 항목(항목 순서 유지). replace=true면 같은 이름이 원격에 있어 먼저 지워야 한다.
* - prunes: 원격 별칭에만 있는 파일. 모든 writes(인덱스 포함)가 끝난 뒤에 지운다.
*
* @param {{ items: readonly HashedPayload[], remoteHashes: ReadonlyMap<string, string> }} input
* @returns {AliasPlan}
*/
export function planAliasFiles({ items, remoteHashes }) {
const writes = items
.filter((item) => remoteHashes.get(item.name) !== item.sha256)
.map((item) => ({ item, replace: remoteHashes.has(item.name) }));
const keep = new Set(items.map((item) => item.name));
const prunes = [...remoteHashes.keys()].filter((name) => !keep.has(name));
const action = writes.length > 0 || prunes.length > 0 ? "update" : "skip";
return { action, writes, prunes };
}
/**
* 버전 경로가 이미 완성돼 있는데(재빌드 바이트가 달라 버전 단계가 abort) 별칭을 그 게시본으로
* 복구할 수 있는지 판단한다. 원격 버전 경로의 바이트가 정본이다.
* - 별칭 항목이 버전 경로에 하나라도 없으면 복구 불가(버전 경로 미완성).
* - 별칭에 이미 같은 sha256으로 있는 항목은 그대로 둔다(retain).
* - 다른 항목은 텍스트(인덱스·설치 스크립트)일 때만 버전 경로에서 읽어 옮길 수 있다(reads).
* 이진 부품이 어긋나 있으면 복구 불가.
*
* @param {{
* aliasItems: readonly HashedPayload[],
* versionedHashes: ReadonlyMap<string, string>,
* aliasHashes: ReadonlyMap<string, string>,
* }} input
* @returns {{ restorable: false, reason: string } |
* { restorable: true, reads: Array<{ name: string, contentType: string, sha256: string }>, prunes: string[] }}
*/
export function planAliasRestore({ aliasItems, versionedHashes, aliasHashes }) {
const missing = aliasItems.filter((item) => !versionedHashes.has(item.name));
if (missing.length > 0) {
return { restorable: false, reason: `버전 경로에 없음: ${missing.map((item) => item.name).join(", ")}` };
}
/** @type {Array<{ name: string, contentType: string, sha256: string }>} */
const reads = [];
for (const item of aliasItems) {
const sha256 = /** @type {string} */ (versionedHashes.get(item.name));
if (aliasHashes.get(item.name) === sha256) continue;
if (!isTextContentType(item.contentType)) {
return { restorable: false, reason: `이진 파일은 게시본에서 옮길 수 없음: ${item.name}` };
}
reads.push({ name: item.name, contentType: item.contentType, sha256 });
}
const keep = new Set(aliasItems.map((item) => item.name));
const prunes = [...aliasHashes.keys()].filter((name) => !keep.has(name));
return { restorable: true, reads, prunes };
}
/** @param {string} contentType */
export function isTextContentType(contentType) {
return contentType === "application/json" || contentType.startsWith("text/");
}