feat: add backup verification and restore preview

This commit is contained in:
AI Bot
2026-06-06 19:08:24 +08:00
parent 643da5b738
commit 1edfa6ecd5
3 changed files with 403 additions and 4 deletions

View File

@@ -1,14 +1,28 @@
import { NextRequest } from "next/server"; import { NextRequest } from "next/server";
import { jsonNoStore } from "@/lib/api-response"; import { jsonNoStore } from "@/lib/api-response";
import { buildRequestAuditMeta } from "@/lib/boss-audit";
import { requireRequestSession } from "@/lib/boss-auth"; import { requireRequestSession } from "@/lib/boss-auth";
import { requireCsrfSafeMutation } from "@/lib/boss-csrf"; import { requireCsrfSafeMutation } from "@/lib/boss-csrf";
import { appendPermissionAuditLog, type PermissionAuditLog } from "@/lib/boss-data";
import { import {
createBossStateBackup, createBossStateBackup,
getBossStateBackupStatus, getBossStateBackupStatus,
listBossStateBackups, listBossStateBackupProjections,
previewBossStateRestore,
projectBossStateBackupSnapshot,
restoreBossStateBackup, restoreBossStateBackup,
verifyBossStateBackup,
} from "@/lib/boss-state-backups"; } 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() { function forbidden() {
return jsonNoStore({ ok: false, message: "FORBIDDEN" }, { status: 403 }); return jsonNoStore({ ok: false, message: "FORBIDDEN" }, { status: 403 });
} }
@@ -17,6 +31,21 @@ function stringValue(value: unknown) {
return typeof value === "string" ? value.trim() : ""; 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) { async function requireHighestAdmin(request: NextRequest) {
const session = await requireRequestSession(request); const session = await requireRequestSession(request);
if (!session) { if (!session) {
@@ -33,7 +62,7 @@ export async function GET(request: NextRequest) {
if (auth.response) return auth.response; if (auth.response) return auth.response;
const status = await getBossStateBackupStatus(); const status = await getBossStateBackupStatus();
const snapshots = await listBossStateBackups(50); const snapshots = await listBossStateBackupProjections(50);
return jsonNoStore({ ok: true, status, snapshots }); return jsonNoStore({ ok: true, status, snapshots });
} }
@@ -53,8 +82,44 @@ export async function POST(request: NextRequest) {
actorAccount: auth.session.account, actorAccount: auth.session.account,
reason: stringValue(body.reason) || "manual", 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(); 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") { if (action === "restore_snapshot") {
@@ -66,6 +131,11 @@ export async function POST(request: NextRequest) {
snapshotId, snapshotId,
actorAccount: auth.session.account, actorAccount: auth.session.account,
}); });
await appendBackupAuditLog(request, {
actorAccount: auth.session.account,
action: "backup.snapshot_restored",
snapshotId,
});
const status = await getBossStateBackupStatus(); const status = await getBossStateBackupStatus();
return jsonNoStore({ ok: true, action, ...result, status }); return jsonNoStore({ ok: true, action, ...result, status });
} }

View File

@@ -25,6 +25,59 @@ export interface BossStateBackupStatus {
detail?: string; 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() { function stateFilePath() {
const configuredStateFile = process.env.BOSS_STATE_FILE?.trim(); const configuredStateFile = process.env.BOSS_STATE_FILE?.trim();
if (configuredStateFile) { if (configuredStateFile) {
@@ -65,6 +118,17 @@ function snapshotPath(snapshotId: string) {
return path.join(backupDirPath(), `${snapshotId}.json`); 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() { async function readStateText() {
const state = await readState(); const state = await readState();
return `${JSON.stringify(state, null, 2)}\n`; 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<BossStateBackupSnapshot> {
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<BossState>;
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: { export async function createBossStateBackup(input: {
actorAccount: string; actorAccount: string;
reason?: string; reason?: string;
@@ -130,6 +302,32 @@ export async function createBossStateBackup(input: {
}; };
} }
export async function verifyBossStateBackup(snapshotId: string): Promise<BossStateBackupVerification> {
const snapshot = await loadBossStateBackupSnapshot(snapshotId);
const text = await readSnapshotText(snapshotId);
return verifySnapshotText(snapshot, text);
}
export async function projectBossStateBackupSnapshot(snapshot: BossStateBackupSnapshot): Promise<BossStateBackupProjection> {
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<BossStateBackupSnapshot[]> { export async function listBossStateBackups(limit = 20): Promise<BossStateBackupSnapshot[]> {
const dir = backupDirPath(); const dir = backupDirPath();
const entries = await fs.readdir(dir).catch(() => []); const entries = await fs.readdir(dir).catch(() => []);
@@ -167,6 +365,11 @@ export async function listBossStateBackups(limit = 20): Promise<BossStateBackupS
.slice(0, Math.max(1, Math.min(100, limit))); .slice(0, Math.max(1, Math.min(100, limit)));
} }
export async function listBossStateBackupProjections(limit = 20): Promise<BossStateBackupProjection[]> {
const snapshots = await listBossStateBackups(limit);
return Promise.all(snapshots.map((snapshot) => projectBossStateBackupSnapshot(snapshot)));
}
export async function getBossStateBackupStatus(): Promise<BossStateBackupStatus> { export async function getBossStateBackupStatus(): Promise<BossStateBackupStatus> {
try { try {
const snapshots = await listBossStateBackups(100); const snapshots = await listBossStateBackups(100);
@@ -191,6 +394,34 @@ export async function getBossStateBackupStatus(): Promise<BossStateBackupStatus>
} }
} }
export async function previewBossStateRestore(input: {
snapshotId: string;
willWriteState?: boolean;
}): Promise<BossStateRestorePreview> {
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: { export async function restoreBossStateBackup(input: {
snapshotId: string; snapshotId: string;
actorAccount: string; actorAccount: string;

View File

@@ -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 () => { test("admin backups require highest admin", async () => {
await setup(); await setup();
const unauthenticated = await getBackups(new NextRequest("http://127.0.0.1:3000/api/v1/admin/backups")); 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.reason, "before risky operation");
assert.equal(createPayload.snapshot.actorAccount, "owner@boss.com"); assert.equal(createPayload.snapshot.actorAccount, "owner@boss.com");
assert.match(createPayload.snapshot.snapshotId, /^state-snapshot-/); assert.match(createPayload.snapshot.snapshotId, /^state-snapshot-/);
assertBackupProjection(createPayload.snapshot);
const mutated = await data.readState(); const mutated = await data.readState();
mutated.projects[0]!.name = "误操作后的项目"; 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(); const listPayload = await listResponse.json();
assert.equal(listPayload.ok, true); assert.equal(listPayload.ok, true);
assert.equal(listPayload.status.restorePointCount >= 1, 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( const restoreResponse = await postBackups(
await requestFor("owner@boss.com", "highest_admin", { 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(); const restored = await data.readState();
assert.equal(restored.projects.find((project) => project.id === "project-before")?.name, "备份前项目"); 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 () => { test("state writes create automatic restore points", async () => {