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.
This commit is contained in:
parent
2428ede03d
commit
ba9ef9741e
161 changed files with 17056 additions and 2379 deletions
|
|
@ -7,29 +7,45 @@
|
|||
// 이 경로의 파일과 sha256을 직접 가리키므로, 교체하면 이미 배포된 클라이언트가 깨진다.
|
||||
// 재실행(예: Cloudflare 524 후)이 부분 업로드를 복구할 수 있도록, 원격에 없는 파일만
|
||||
// 이어서 올리고 같은 이름에 다른 바이트가 있으면 중단한다(fail-closed).
|
||||
// - 별칭 경로 <kind>-latest : 모든 태그가 공유. 버전 경로가 완성된 뒤에만, 그리고
|
||||
// - 별칭 경로 <kind>-latest : 모든 태그가 공유. 버전 경로가 모두 완성된 뒤에만, 그리고
|
||||
// 이미 게시된 인덱스(runtime.json / portable.json)의 버전보다 오래된 버전이 아닐 때만
|
||||
// 교체한다. 게시된 버전을 읽을 수 없으면 건드리지 않는다(fail-closed).
|
||||
// 교체는 패키지 삭제가 아니라 바뀐 파일만 파일 단위 DELETE→PUT, 인덱스가 마지막이다
|
||||
// (./portable-alias-plan.mjs). 별칭에는 spec.aliasNames 파일만 둔다.
|
||||
// - 재실행 복구: 버전 경로가 이미 완성돼 있고 재빌드 바이트만 달라 abort 되는 경우(빌드가
|
||||
// 재현 불가 — generatedAt 등), 중간에 끊긴 별칭을 원격 버전 경로의 인덱스로 복구한 뒤 중단한다.
|
||||
//
|
||||
// 구조
|
||||
// 1) 순수 정책: planVersionedPackage / planAliasPackage / parsePublishedIndexVersion /
|
||||
// decideAliasUpdate
|
||||
// 1) 순수 정책: planVersionedPackage / parsePublishedIndexVersion / decideAliasUpdate
|
||||
// + ./portable-alias-plan.mjs (selectAliasItems / planAliasFiles / planAliasRestore)
|
||||
// 2) 유스케이스: publishPortablePackages — registry 포트(IO)를 주입받는다.
|
||||
// IO 어댑터는 ./forgejo-generic-registry.mjs 에 있다.
|
||||
// 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>>,
|
||||
* deleteVersion: (versionPath: string) => Promise<void>,
|
||||
* 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
|
||||
* @typedef {{ kind: string, indexName: string, payloads: readonly Payload[] }} PackageSpec
|
||||
* deleteFile 이 있으면 별칭을 파일 단위로 교체한다(권장). 없으면 deleteVersion 으로
|
||||
* 별칭 전체를 지우고 다시 올리는 예전 방식으로 동작한다(인덱스 404 구간이 길다).
|
||||
* @typedef {{ kind: string, indexName: string, payloads: readonly Payload[], aliasNames?: readonly string[] }} PackageSpec
|
||||
* aliasNames: *-latest 별칭에 둘 파일(인덱스 포함). 생략하면 모든 payload.
|
||||
*/
|
||||
|
||||
export class PortablePublishError extends Error {
|
||||
|
|
@ -47,10 +63,15 @@ export class PortablePublishError extends Error {
|
|||
export function hashPayloads(payloads) {
|
||||
return payloads.map((payload) => ({
|
||||
...payload,
|
||||
sha256: createHash("sha256").update(payload.bytes).digest("hex"),
|
||||
sha256: sha256Hex(payload.bytes),
|
||||
}));
|
||||
}
|
||||
|
||||
/** @param {Uint8Array} bytes */
|
||||
function sha256Hex(bytes) {
|
||||
return createHash("sha256").update(bytes).digest("hex");
|
||||
}
|
||||
|
||||
/**
|
||||
* 불변 버전 경로 게시 계획.
|
||||
* - 같은 이름에 다른 sha256이 원격에 있음 → abort (절대 삭제/교체하지 않는다)
|
||||
|
|
@ -71,15 +92,16 @@ export function planVersionedPackage({ items, remoteHashes }) {
|
|||
}
|
||||
|
||||
/**
|
||||
* 별칭 경로 게시 계획. Forgejo는 파일 단위 덮어쓰기를 거부(409)하므로, 다른 파일이 하나라도
|
||||
* 있으면 버전 전체를 지우고 모든 파일을 다시 올린다(낡은 바이트와 새 바이트가 섞이지 않게).
|
||||
* 별칭 교체 여부 요약(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 differing = items.filter((item) => remoteHashes.get(item.name) !== item.sha256);
|
||||
if (differing.length === 0) return { action: "skip", deleteFirst: false };
|
||||
const { writes } = planAliasFiles({ items, remoteHashes });
|
||||
if (writes.length === 0) return { action: "skip", deleteFirst: false };
|
||||
return { action: "replace", deleteFirst: remoteHashes.size > 0 };
|
||||
}
|
||||
|
||||
|
|
@ -128,6 +150,150 @@ async function readAliasVersion(registry, aliasPath, indexName) {
|
|||
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`
|
||||
: "") +
|
||||
" (게시가 중간에 실패해 어떤 별칭도 이 버전을 가리키지 않는 것이 확실할 때만 " +
|
||||
"패키지 버전을 수동으로 삭제한 뒤 다시 실행하세요.)",
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 버전 경로를 모두 완성한 뒤 별칭을 갱신한다.
|
||||
*
|
||||
|
|
@ -154,7 +320,22 @@ export async function publishPortablePackages({
|
|||
}
|
||||
const active = packages
|
||||
.filter((spec) => spec.payloads.length > 0)
|
||||
.map((spec) => ({ ...spec, items: hashPayloads(spec.payloads) }));
|
||||
.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 = {};
|
||||
|
|
@ -162,69 +343,57 @@ export async function publishPortablePackages({
|
|||
const aliases = {};
|
||||
|
||||
if (dryRun) {
|
||||
for (const spec of active) {
|
||||
for (const path of [`${spec.kind}-${version}`, `${spec.kind}-latest`]) {
|
||||
for (const item of spec.items) {
|
||||
log(`(check) PUT ${describeUrl(path, item.name)} (${item.bytes.length} bytes)`);
|
||||
}
|
||||
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) 불변 버전 경로 — 모두 완성되기 전에는 어떤 별칭도 건드리지 않는다.
|
||||
for (const spec of active) {
|
||||
const versionPath = `${spec.kind}-${version}`;
|
||||
const plan = planVersionedPackage({
|
||||
items: spec.items,
|
||||
remoteHashes: await registry.listFileHashes(versionPath),
|
||||
// 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 }),
|
||||
});
|
||||
if (plan.action === "abort") {
|
||||
throw new PortablePublishError(
|
||||
`${versionPath} 에 같은 이름의 다른 바이트가 이미 게시돼 있습니다: ` +
|
||||
`${plan.conflicts.map((item) => item.name).join(", ")}\n` +
|
||||
" 버전 경로는 불변이라 지우거나 덮어쓰지 않습니다. 새 버전으로 게시하세요.\n" +
|
||||
" (게시가 중간에 실패해 어떤 별칭도 이 버전을 가리키지 않는 것이 확실할 때만 " +
|
||||
"패키지 버전을 수동으로 삭제한 뒤 다시 실행하세요.)",
|
||||
);
|
||||
}
|
||||
if (plan.action === "skip") {
|
||||
log(`변경 없음(건너뜀): ${versionPath}`);
|
||||
versioned[spec.kind] = "unchanged";
|
||||
continue;
|
||||
}
|
||||
for (const item of plan.uploads) await registry.uploadFile(versionPath, item);
|
||||
versioned[spec.kind] = plan.uploads.length === spec.items.length ? "uploaded" : "resumed";
|
||||
}
|
||||
|
||||
// 2) 공유 별칭 — 더 새로운 버전이 게시돼 있으면 되돌리지 않는다.
|
||||
for (const spec of active) {
|
||||
const aliasPath = `${spec.kind}-latest`;
|
||||
const publishedVersion = await readAliasVersion(registry, aliasPath, spec.indexName);
|
||||
const decision = decideAliasUpdate({ publishingVersion: version, publishedVersion });
|
||||
if (decision.abort) {
|
||||
throw new PortablePublishError(
|
||||
`${aliasPath}/${spec.indexName} 의 게시 버전을 읽을 수 없습니다. ` +
|
||||
"버전을 모른 채 별칭을 덮어쓰지 않습니다(롤백 방지).",
|
||||
);
|
||||
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);
|
||||
}
|
||||
if (!decision.update) {
|
||||
log(`${aliasPath} 건너뜀: 더 새로운 버전(${publishedVersion})이 이미 게시돼 있습니다 (이번 ${version})`);
|
||||
aliases[spec.kind] = decision.reason;
|
||||
throw conflictError(aborted, aliases);
|
||||
}
|
||||
|
||||
// 2) 버전 경로 업로드 — 모두 완성되기 전에는 어떤 별칭도 건드리지 않는다.
|
||||
for (const pkg of planned) {
|
||||
if (pkg.plan.action === "skip") {
|
||||
log(`변경 없음(건너뜀): ${pkg.versionPath}`);
|
||||
versioned[pkg.spec.kind] = "unchanged";
|
||||
continue;
|
||||
}
|
||||
const plan = planAliasPackage({
|
||||
items: spec.items,
|
||||
remoteHashes: await registry.listFileHashes(aliasPath),
|
||||
});
|
||||
if (plan.action === "skip") {
|
||||
log(`변경 없음(건너뜀): ${aliasPath}`);
|
||||
aliases[spec.kind] = "unchanged";
|
||||
continue;
|
||||
}
|
||||
if (plan.deleteFirst) await registry.deleteVersion(aliasPath);
|
||||
for (const item of spec.items) await registry.uploadFile(aliasPath, item);
|
||||
aliases[spec.kind] = "replaced";
|
||||
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 };
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue