diff --git a/src/app/api/v1/admin/backups/route.ts b/src/app/api/v1/admin/backups/route.ts index 5f782e8..53afc07 100644 --- a/src/app/api/v1/admin/backups/route.ts +++ b/src/app/api/v1/admin/backups/route.ts @@ -1,14 +1,28 @@ import { NextRequest } from "next/server"; import { jsonNoStore } from "@/lib/api-response"; +import { buildRequestAuditMeta } from "@/lib/boss-audit"; import { requireRequestSession } from "@/lib/boss-auth"; import { requireCsrfSafeMutation } from "@/lib/boss-csrf"; +import { appendPermissionAuditLog, type PermissionAuditLog } from "@/lib/boss-data"; import { createBossStateBackup, getBossStateBackupStatus, - listBossStateBackups, + listBossStateBackupProjections, + previewBossStateRestore, + projectBossStateBackupSnapshot, restoreBossStateBackup, + verifyBossStateBackup, } from "@/lib/boss-state-backups"; +type BackupAuditAction = Extract< + PermissionAuditLog["action"], + | "backup.snapshot_created" + | "backup.snapshot_verified" + | "backup.restore_previewed" + | "backup.restore_dry_run" + | "backup.snapshot_restored" +>; + function forbidden() { return jsonNoStore({ ok: false, message: "FORBIDDEN" }, { status: 403 }); } @@ -17,6 +31,21 @@ function stringValue(value: unknown) { return typeof value === "string" ? value.trim() : ""; } +async function appendBackupAuditLog(request: NextRequest, input: { + actorAccount: string; + action: BackupAuditAction; + snapshotId: string; + detail?: string; +}) { + await appendPermissionAuditLog({ + actorAccount: input.actorAccount, + action: input.action, + detail: input.detail ?? `snapshot:${input.snapshotId}`, + afterJson: { snapshotId: input.snapshotId }, + ...buildRequestAuditMeta(request), + }); +} + async function requireHighestAdmin(request: NextRequest) { const session = await requireRequestSession(request); if (!session) { @@ -33,7 +62,7 @@ export async function GET(request: NextRequest) { if (auth.response) return auth.response; const status = await getBossStateBackupStatus(); - const snapshots = await listBossStateBackups(50); + const snapshots = await listBossStateBackupProjections(50); return jsonNoStore({ ok: true, status, snapshots }); } @@ -53,8 +82,44 @@ export async function POST(request: NextRequest) { actorAccount: auth.session.account, reason: stringValue(body.reason) || "manual", }); + const projectedSnapshot = await projectBossStateBackupSnapshot(snapshot); + await appendBackupAuditLog(request, { + actorAccount: auth.session.account, + action: "backup.snapshot_created", + snapshotId: snapshot.snapshotId, + }); const status = await getBossStateBackupStatus(); - return jsonNoStore({ ok: true, action, snapshot, status }); + return jsonNoStore({ ok: true, action, snapshot: projectedSnapshot, status }); + } + + if (action === "verify_snapshot") { + const snapshotId = stringValue(body.snapshotId); + if (!snapshotId) { + return jsonNoStore({ ok: false, message: "BACKUP_SNAPSHOT_ID_REQUIRED" }, { status: 400 }); + } + const verification = await verifyBossStateBackup(snapshotId); + await appendBackupAuditLog(request, { + actorAccount: auth.session.account, + action: "backup.snapshot_verified", + snapshotId, + detail: `snapshot:${snapshotId}; ok:${verification.ok}`, + }); + return jsonNoStore({ ok: true, action, verification }); + } + + if (action === "preview_restore" || action === "dry_run_restore") { + const snapshotId = stringValue(body.snapshotId); + if (!snapshotId) { + return jsonNoStore({ ok: false, message: "BACKUP_SNAPSHOT_ID_REQUIRED" }, { status: 400 }); + } + const preview = await previewBossStateRestore({ snapshotId, willWriteState: false }); + await appendBackupAuditLog(request, { + actorAccount: auth.session.account, + action: action === "preview_restore" ? "backup.restore_previewed" : "backup.restore_dry_run", + snapshotId, + detail: `snapshot:${snapshotId}; projects:${preview.impact.projects.current}->${preview.impact.projects.after}`, + }); + return jsonNoStore({ ok: true, action, preview }); } if (action === "restore_snapshot") { @@ -66,6 +131,11 @@ export async function POST(request: NextRequest) { snapshotId, actorAccount: auth.session.account, }); + await appendBackupAuditLog(request, { + actorAccount: auth.session.account, + action: "backup.snapshot_restored", + snapshotId, + }); const status = await getBossStateBackupStatus(); return jsonNoStore({ ok: true, action, ...result, status }); } diff --git a/src/lib/boss-state-backups.ts b/src/lib/boss-state-backups.ts index 377ccf8..cce2df3 100644 --- a/src/lib/boss-state-backups.ts +++ b/src/lib/boss-state-backups.ts @@ -25,6 +25,59 @@ export interface BossStateBackupStatus { detail?: string; } +export type BossBackupScope = "global" | "company" | "project" | "conversation" | "config" | "skill" | "task"; + +export interface BossStateBackupBusinessSummary { + companyCount: number; + accountCount: number; + deviceCount: number; + projectCount: number; + messageCount: number; + taskCount: number; + skillCount: number; + auditLogCount: number; +} + +export interface BossStateBackupVerification { + snapshotId: string; + checkedAt: string; + ok: boolean; + sha256: string; + expectedSha256?: string; + message: string; +} + +export interface BossStateBackupProjection extends BossStateBackupSnapshot { + scope: BossBackupScope; + storageKind: "file"; + status: "ready" | "error"; + verification: BossStateBackupVerification; + businessSummary: BossStateBackupBusinessSummary; +} + +export interface BossStateRestoreImpactCounter { + current: number; + after: number; + delta: number; +} + +export interface BossStateRestorePreview { + snapshotId: string; + willWriteState: boolean; + generatedAt: string; + summary: string; + impact: { + companies: BossStateRestoreImpactCounter; + accounts: BossStateRestoreImpactCounter; + devices: BossStateRestoreImpactCounter; + projects: BossStateRestoreImpactCounter; + messages: BossStateRestoreImpactCounter; + tasks: BossStateRestoreImpactCounter; + skills: BossStateRestoreImpactCounter; + auditLogs: BossStateRestoreImpactCounter; + }; +} + function stateFilePath() { const configuredStateFile = process.env.BOSS_STATE_FILE?.trim(); if (configuredStateFile) { @@ -65,6 +118,17 @@ function snapshotPath(snapshotId: string) { return path.join(backupDirPath(), `${snapshotId}.json`); } +async function readSnapshotText(snapshotId: string) { + try { + return await fs.readFile(snapshotPath(snapshotId), "utf8"); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") { + throw new Error("BACKUP_SNAPSHOT_NOT_FOUND"); + } + throw error; + } +} + async function readStateText() { const state = await readState(); return `${JSON.stringify(state, null, 2)}\n`; @@ -91,6 +155,114 @@ async function readMeta(snapshotId: string) { } } +function emptyBusinessSummary(): BossStateBackupBusinessSummary { + return { + companyCount: 0, + accountCount: 0, + deviceCount: 0, + projectCount: 0, + messageCount: 0, + taskCount: 0, + skillCount: 0, + auditLogCount: 0, + }; +} + +function summarizeBusinessState(state: BossState): BossStateBackupBusinessSummary { + const skillIds = new Set([ + ...state.skillCatalog.map((skill) => skill.skillId), + ...state.deviceSkills.map((skill) => skill.skillId), + ]); + const businessProjects = state.projects.filter((project) => project.id !== "master-agent"); + + return { + companyCount: state.adminCompanies.length, + accountCount: state.authAccounts.length, + deviceCount: state.devices.length, + projectCount: businessProjects.length, + messageCount: businessProjects.reduce((total, project) => total + project.messages.length, 0), + taskCount: state.masterAgentTasks.length, + skillCount: skillIds.size, + auditLogCount: state.permissionAuditLogs.length, + }; +} + +function impactCounter(current: number, after: number): BossStateRestoreImpactCounter { + return { + current, + after, + delta: after - current, + }; +} + +function buildRestorePreviewSummary(impact: BossStateRestorePreview["impact"]) { + const labels: Array<[keyof BossStateRestorePreview["impact"], string]> = [ + ["companies", "公司"], + ["accounts", "账号"], + ["devices", "设备"], + ["projects", "项目"], + ["messages", "消息"], + ["tasks", "任务"], + ["skills", "Skill"], + ["auditLogs", "审计日志"], + ]; + const changed = labels + .map(([key, label]) => ({ label, counter: impact[key] })) + .filter(({ counter }) => counter.delta !== 0) + .map(({ label, counter }) => `${label} ${counter.current} -> ${counter.after}`); + return changed.length > 0 ? `恢复预览:${changed.join(",")}` : "恢复预览:业务计数不变"; +} + +function verifySnapshotText(snapshot: BossStateBackupSnapshot, text: string): BossStateBackupVerification { + const sha256 = createHash("sha256").update(text).digest("hex"); + const checksumOk = snapshot.sha256 === sha256; + let stateOk = true; + try { + parseStateText(text, snapshot.absolutePath); + } catch { + stateOk = false; + } + + return { + snapshotId: snapshot.snapshotId, + checkedAt: new Date().toISOString(), + ok: checksumOk && stateOk, + sha256, + expectedSha256: snapshot.sha256, + message: checksumOk && stateOk + ? "checksum verified" + : stateOk + ? "checksum mismatch" + : "snapshot state is invalid", + }; +} + +async function loadBossStateBackupSnapshot(snapshotId: string): Promise { + const absolutePath = snapshotPath(snapshotId); + const text = await readSnapshotText(snapshotId); + const stat = await fs.stat(absolutePath); + const meta = await readMeta(snapshotId); + let schemaVersion: number | undefined; + try { + const parsed = JSON.parse(text) as Partial; + schemaVersion = typeof parsed.schemaVersion === "number" ? parsed.schemaVersion : undefined; + } catch { + schemaVersion = undefined; + } + + return { + snapshotId, + fileName: `${snapshotId}.json`, + absolutePath, + bytes: typeof meta.bytes === "number" ? meta.bytes : stat.size, + sha256: typeof meta.sha256 === "string" ? meta.sha256 : createHash("sha256").update(text).digest("hex"), + createdAt: typeof meta.createdAt === "string" ? meta.createdAt : stat.mtime.toISOString(), + actorAccount: typeof meta.actorAccount === "string" ? meta.actorAccount : undefined, + reason: typeof meta.reason === "string" ? meta.reason : undefined, + schemaVersion: typeof meta.schemaVersion === "number" ? meta.schemaVersion : schemaVersion, + }; +} + export async function createBossStateBackup(input: { actorAccount: string; reason?: string; @@ -130,6 +302,32 @@ export async function createBossStateBackup(input: { }; } +export async function verifyBossStateBackup(snapshotId: string): Promise { + const snapshot = await loadBossStateBackupSnapshot(snapshotId); + const text = await readSnapshotText(snapshotId); + return verifySnapshotText(snapshot, text); +} + +export async function projectBossStateBackupSnapshot(snapshot: BossStateBackupSnapshot): Promise { + const text = await readSnapshotText(snapshot.snapshotId); + const verification = verifySnapshotText(snapshot, text); + let businessSummary = emptyBusinessSummary(); + try { + businessSummary = summarizeBusinessState(parseStateText(text, snapshot.absolutePath)); + } catch { + businessSummary = emptyBusinessSummary(); + } + + return { + ...snapshot, + scope: "global", + storageKind: "file", + status: verification.ok ? "ready" : "error", + verification, + businessSummary, + }; +} + export async function listBossStateBackups(limit = 20): Promise { const dir = backupDirPath(); const entries = await fs.readdir(dir).catch(() => []); @@ -167,6 +365,11 @@ export async function listBossStateBackups(limit = 20): Promise { + const snapshots = await listBossStateBackups(limit); + return Promise.all(snapshots.map((snapshot) => projectBossStateBackupSnapshot(snapshot))); +} + export async function getBossStateBackupStatus(): Promise { try { const snapshots = await listBossStateBackups(100); @@ -191,6 +394,34 @@ export async function getBossStateBackupStatus(): Promise } } +export async function previewBossStateRestore(input: { + snapshotId: string; + willWriteState?: boolean; +}): Promise { + const snapshot = await loadBossStateBackupSnapshot(input.snapshotId); + const snapshotState = parseStateText(await readSnapshotText(input.snapshotId), snapshot.absolutePath); + const currentSummary = summarizeBusinessState(await readState()); + const afterSummary = summarizeBusinessState(snapshotState); + const impact = { + companies: impactCounter(currentSummary.companyCount, afterSummary.companyCount), + accounts: impactCounter(currentSummary.accountCount, afterSummary.accountCount), + devices: impactCounter(currentSummary.deviceCount, afterSummary.deviceCount), + projects: impactCounter(currentSummary.projectCount, afterSummary.projectCount), + messages: impactCounter(currentSummary.messageCount, afterSummary.messageCount), + tasks: impactCounter(currentSummary.taskCount, afterSummary.taskCount), + skills: impactCounter(currentSummary.skillCount, afterSummary.skillCount), + auditLogs: impactCounter(currentSummary.auditLogCount, afterSummary.auditLogCount), + }; + + return { + snapshotId: input.snapshotId, + willWriteState: Boolean(input.willWriteState), + generatedAt: new Date().toISOString(), + summary: buildRestorePreviewSummary(impact), + impact, + }; +} + export async function restoreBossStateBackup(input: { snapshotId: string; actorAccount: string; diff --git a/tests/admin-backups-route.test.ts b/tests/admin-backups-route.test.ts index 23b5679..ef227cf 100644 --- a/tests/admin-backups-route.test.ts +++ b/tests/admin-backups-route.test.ts @@ -112,6 +112,21 @@ async function requestFor(account: string, role: "member" | "admin" | "highest_a }); } +function assertBackupProjection(snapshot: { + scope?: string; + storageKind?: string; + status?: string; + verification?: { ok?: boolean }; + businessSummary?: { projectCount?: unknown; accountCount?: unknown }; +}) { + assert.equal(snapshot.scope, "global"); + assert.equal(snapshot.storageKind, "file"); + assert.equal(snapshot.status, "ready"); + assert.equal(snapshot.verification?.ok, true); + assert.equal(typeof snapshot.businessSummary?.projectCount, "number"); + assert.equal(typeof snapshot.businessSummary?.accountCount, "number"); +} + test("admin backups require highest admin", async () => { await setup(); const unauthenticated = await getBackups(new NextRequest("http://127.0.0.1:3000/api/v1/admin/backups")); @@ -134,6 +149,7 @@ test("highest admin can create, list, and restore a state snapshot", async () => assert.equal(createPayload.snapshot.reason, "before risky operation"); assert.equal(createPayload.snapshot.actorAccount, "owner@boss.com"); assert.match(createPayload.snapshot.snapshotId, /^state-snapshot-/); + assertBackupProjection(createPayload.snapshot); const mutated = await data.readState(); mutated.projects[0]!.name = "误操作后的项目"; @@ -144,7 +160,11 @@ test("highest admin can create, list, and restore a state snapshot", async () => const listPayload = await listResponse.json(); assert.equal(listPayload.ok, true); assert.equal(listPayload.status.restorePointCount >= 1, true); - assert.equal(listPayload.snapshots.some((snapshot: { snapshotId: string }) => snapshot.snapshotId === createPayload.snapshot.snapshotId), true); + const listedSnapshot = listPayload.snapshots.find( + (snapshot: { snapshotId: string }) => snapshot.snapshotId === createPayload.snapshot.snapshotId, + ); + assert.ok(listedSnapshot); + assertBackupProjection(listedSnapshot); const restoreResponse = await postBackups( await requestFor("owner@boss.com", "highest_admin", { @@ -160,6 +180,84 @@ test("highest admin can create, list, and restore a state snapshot", async () => const restored = await data.readState(); assert.equal(restored.projects.find((project) => project.id === "project-before")?.name, "备份前项目"); + assert.equal(restored.permissionAuditLogs.some((log) => log.action === "backup.snapshot_restored"), true); +}); + +test("highest admin can verify a backup snapshot checksum", async () => { + const createResponse = await postBackups( + await requestFor("owner@boss.com", "highest_admin", { + method: "POST", + body: JSON.stringify({ action: "create_snapshot", reason: "checksum verification" }), + }), + ); + assert.equal(createResponse.status, 200); + const createPayload = await createResponse.json(); + + const verifyResponse = await postBackups( + await requestFor("owner@boss.com", "highest_admin", { + method: "POST", + body: JSON.stringify({ action: "verify_snapshot", snapshotId: createPayload.snapshot.snapshotId }), + }), + ); + assert.equal(verifyResponse.status, 200); + const verifyPayload = await verifyResponse.json(); + assert.equal(verifyPayload.ok, true); + assert.equal(verifyPayload.action, "verify_snapshot"); + assert.equal(verifyPayload.verification.snapshotId, createPayload.snapshot.snapshotId); + assert.equal(verifyPayload.verification.ok, true); + assert.equal(verifyPayload.verification.sha256, createPayload.snapshot.sha256); +}); + +test("restore preview and dry run report impact without writing state", async () => { + const createResponse = await postBackups( + await requestFor("owner@boss.com", "highest_admin", { + method: "POST", + body: JSON.stringify({ action: "create_snapshot", reason: "preview restore" }), + }), + ); + assert.equal(createResponse.status, 200); + const createPayload = await createResponse.json(); + + const state = await data.readState(); + state.projects.push({ + ...structuredClone(state.projects[0]!), + id: "project-after", + name: "备份后新增项目", + threadMeta: { + ...state.projects[0]!.threadMeta, + projectId: "project-after", + threadId: "thread-after", + threadDisplayName: "备份后线程", + }, + }); + await data.writeState(state); + + const previewResponse = await postBackups( + await requestFor("owner@boss.com", "highest_admin", { + method: "POST", + body: JSON.stringify({ action: "preview_restore", snapshotId: createPayload.snapshot.snapshotId }), + }), + ); + assert.equal(previewResponse.status, 200); + const previewPayload = await previewResponse.json(); + assert.equal(previewPayload.ok, true); + assert.equal(previewPayload.preview.willWriteState, false); + assert.equal(previewPayload.preview.impact.projects.after, 1); + assert.equal(previewPayload.preview.impact.projects.current, 2); + assert.match(previewPayload.preview.summary, /项目/); + + const dryRunResponse = await postBackups( + await requestFor("owner@boss.com", "highest_admin", { + method: "POST", + body: JSON.stringify({ action: "dry_run_restore", snapshotId: createPayload.snapshot.snapshotId }), + }), + ); + assert.equal(dryRunResponse.status, 200); + const dryRunPayload = await dryRunResponse.json(); + assert.equal(dryRunPayload.preview.willWriteState, false); + + const afterDryRun = await data.readState(); + assert.equal(afterDryRun.projects.some((project) => project.id === "project-after"), true); }); test("state writes create automatic restore points", async () => {