feat: add backup verification and restore preview
This commit is contained in:
@@ -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 });
|
||||
}
|
||||
|
||||
@@ -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<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: {
|
||||
actorAccount: 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[]> {
|
||||
const dir = backupDirPath();
|
||||
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)));
|
||||
}
|
||||
|
||||
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> {
|
||||
try {
|
||||
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: {
|
||||
snapshotId: string;
|
||||
actorAccount: string;
|
||||
|
||||
Reference in New Issue
Block a user