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

97 lines
3.9 KiB
JavaScript

// scripts/ci/lib/update-policy-schema.mjs
// release/update-policy.json 의 엄격한 스키마 검증 (게시 직전 게이트).
//
// 왜: 클라이언트(apps/desktop/src/main/update-policy.ts)는 모르는 형식의 필드를 버린다.
// 예전에는 `"killSwitch": "true"`, `"stagingPercentage": "5"` 같은 오타가 가장 허용적인
// 기본값(킬 스위치 꺼짐, 100% 배포)으로 조용히 바뀌었고, publisher 는 schemaVersion 만
// 보거나(forgejo) 아무것도 보지 않고(updater) 그대로 올렸다. 게시 경로마다 같은 규칙으로
// 막도록 규칙을 여기 한 곳에 둔다 (verify-release-metadata.mjs 의 CI 검사와 같은 규칙).
const CHANNELS = ["latest", "beta", "alpha"];
const STABLE_SEMVER = /^\d+\.\d+\.\d+$/;
const KNOWN_KEYS = new Set([
"schemaVersion",
"defaultChannel",
"channels",
"minimumSupportedVersion",
"forceInstallBelow",
"fullInstallOnMajorChange",
"fullInstallVersionGap",
"stagingPercentage",
"killSwitch",
]);
function isRecord(value) {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
/**
* 모든 필드가 있고 형식이 맞는지 본다. 모르는 필드도 오류로 본다(오타 방지).
* @param {unknown} doc
* @returns {string[]} 문제 목록 (비면 통과)
*/
export function validateUpdatePolicyDocument(doc) {
if (!isRecord(doc)) return ["update-policy.json must be a JSON object"];
const errors = [];
const fail = (condition, message) => {
if (!condition) errors.push(message);
};
for (const key of Object.keys(doc)) fail(KNOWN_KEYS.has(key), `unknown field "${key}"`);
fail(doc.schemaVersion === 1, "schemaVersion must be 1");
fail(CHANNELS.includes(doc.defaultChannel), `defaultChannel must be one of ${CHANNELS.join("/")}`);
if (!isRecord(doc.channels)) {
errors.push("channels must be an object");
} else {
for (const key of Object.keys(doc.channels)) fail(CHANNELS.includes(key), `unknown channel "${key}"`);
for (const channel of CHANNELS) {
const entry = doc.channels[channel];
fail(
isRecord(entry) && typeof entry.allowPrerelease === "boolean" && Object.keys(entry).length === 1,
`channels.${channel} must be { "allowPrerelease": boolean }`,
);
}
}
fail(
typeof doc.minimumSupportedVersion === "string" && STABLE_SEMVER.test(doc.minimumSupportedVersion),
"minimumSupportedVersion must be a stable semver string (x.y.z)",
);
fail(
doc.forceInstallBelow === null ||
(typeof doc.forceInstallBelow === "string" && STABLE_SEMVER.test(doc.forceInstallBelow)),
"forceInstallBelow must be null or a stable semver string (x.y.z)",
);
fail(typeof doc.fullInstallOnMajorChange === "boolean", "fullInstallOnMajorChange must be a boolean");
fail(
Number.isSafeInteger(doc.fullInstallVersionGap) && doc.fullInstallVersionGap >= 0,
"fullInstallVersionGap must be a non-negative integer",
);
fail(
Number.isSafeInteger(doc.stagingPercentage) && doc.stagingPercentage >= 0 && doc.stagingPercentage <= 100,
"stagingPercentage must be an integer between 0 and 100",
);
fail(typeof doc.killSwitch === "boolean", "killSwitch must be a boolean (true/false, not a string)");
return errors;
}
/**
* 원문 바이트/문자열을 파싱해 검증한다. 문제가 있으면 모두 모아 예외를 던진다.
* @param {string | Buffer} raw
* @param {string} [label]
*/
export function assertValidUpdatePolicy(raw, label = "update-policy.json") {
let doc;
try {
doc = JSON.parse(Buffer.isBuffer(raw) ? raw.toString("utf8") : String(raw));
} catch (error) {
throw new Error(`${label} is not valid JSON: ${error instanceof Error ? error.message : String(error)}`);
}
const errors = validateUpdatePolicyDocument(doc);
if (errors.length > 0) {
throw new Error(`${label} is invalid — refusing to publish:\n${errors.map((error) => ` - ${error}`).join("\n")}`);
}
return doc;
}