fix(housekeeping): make rank synchronization recoverable

This commit is contained in:
Simo committed 2026-09-03 20:54:34 +02:00
1 parent 11ddf6cf0b
commit daf272bad4
7 files changed
+804 -224

No files matched your search

@@ -17,6 +17,7 @@ const expectedCommandIds = [
"system.access.rank.create", "system.access.rank.create",
"system.access.rank.delete", "system.access.rank.delete",
"system.access.rank.update", "system.access.rank.update",
"system.access.rank.sync-retry",
"system.access.permissions.update", "system.access.permissions.update",
"system.access.permissions.repair", "system.access.permissions.repair",
"system.configuration.settings.save", "system.configuration.settings.save",
@@ -40,6 +41,7 @@ const expectedCommandIds = [
"system.operations.rcon.give-badge", "system.operations.rcon.give-badge",
"system.operations.rcon.set-motto", "system.operations.rcon.set-motto",
"system.operations.rcon.set-rank", "system.operations.rcon.set-rank",
"system.operations.rcon.set-rank-retry",
"system.operations.rcon.execute-command", "system.operations.rcon.execute-command",
"system.operations.rcon.send-gift", "system.operations.rcon.send-gift",
"system.operations.maintenance.update", "system.operations.maintenance.update",
@@ -165,6 +167,7 @@ describe("SYSTEM_COMMANDS", () => {
capability: capabilityContext([PERMS.RCON_EXECUTE]), capability: capabilityContext([PERMS.RCON_EXECUTE]),
correlationId: "command-correlation", correlationId: "command-correlation",
ipAddress: "198.51.100.8", ipAddress: "198.51.100.8",
reason: "Approved credit correction",
}, },
parsed, parsed,
); );
@@ -172,6 +175,7 @@ describe("SYSTEM_COMMANDS", () => {
expect(result).toMatchObject({ expect(result).toMatchObject({
ok: true, ok: true,
data: { data: {
context: { reason: "Approved credit correction" },
operation: "rcon.give-credits", operation: "rcon.give-credits",
input: { userId: 7, amount: 25 }, input: { userId: 7, amount: 25 },
}, },
@@ -14,6 +14,7 @@ export const SYSTEM_COMMAND_IDS = [
"system.access.rank.create", "system.access.rank.create",
"system.access.rank.delete", "system.access.rank.delete",
"system.access.rank.update", "system.access.rank.update",
"system.access.rank.sync-retry",
"system.access.permissions.update", "system.access.permissions.update",
"system.access.permissions.repair", "system.access.permissions.repair",
"system.configuration.settings.save", "system.configuration.settings.save",
@@ -37,6 +38,7 @@ export const SYSTEM_COMMAND_IDS = [
"system.operations.rcon.give-badge", "system.operations.rcon.give-badge",
"system.operations.rcon.set-motto", "system.operations.rcon.set-motto",
"system.operations.rcon.set-rank", "system.operations.rcon.set-rank",
"system.operations.rcon.set-rank-retry",
"system.operations.rcon.execute-command", "system.operations.rcon.execute-command",
"system.operations.rcon.send-gift", "system.operations.rcon.send-gift",
"system.operations.maintenance.update", "system.operations.maintenance.update",
@@ -68,6 +70,7 @@ function systemCommand<I>(
{ {
capability: context.capability, capability: context.capability,
correlationId: context.correlationId, correlationId: context.correlationId,
reason: context.reason,
}, },
options.operation, options.operation,
input, input,
@@ -111,6 +114,14 @@ export function createSystemCommands(
}), }),
requiresReason: true, requiresReason: true,
}), }),
systemCommand(service, {
id: "system.access.rank.sync-retry",
operation: "access.rank.sync.retry",
capability: PERMS.PERMISSIONS_MANAGE,
input: z.object({ recoveryId: requiredText(64) }),
requiresReason: true,
attempts: 5,
}),
systemCommand(service, { systemCommand(service, {
id: "system.access.permissions.update", id: "system.access.permissions.update",
operation: "access.permissions.update", operation: "access.permissions.update",
@@ -278,6 +289,14 @@ export function createSystemCommands(
requiresReason: true, requiresReason: true,
attempts: 5, attempts: 5,
}), }),
systemCommand(service, {
id: "system.operations.rcon.set-rank-retry",
operation: "rcon.set-rank.retry",
capability: PERMS.RCON_EXECUTE,
input: z.object({ recoveryId: requiredText(64) }),
requiresReason: true,
attempts: 5,
}),
systemCommand(service, { systemCommand(service, {
id: "system.operations.rcon.execute-command", id: "system.operations.rcon.execute-command",
operation: "rcon.execute-command", operation: "rcon.execute-command",
@@ -20,16 +20,21 @@ import {
normalizeHabboGamedataHotel, normalizeHabboGamedataHotel,
} from "@/lib/habbo-gamedata-hotel"; } from "@/lib/habbo-gamedata-hotel";
import { PERMS } from "@/lib/permission-slugs"; import { PERMS } from "@/lib/permission-slugs";
import { logAudit } from "@/lib/services/audit";
import { clearOfficialHabboFurnidataCache } from "@/lib/services/habbo-furnidata-cache"; import { clearOfficialHabboFurnidataCache } from "@/lib/services/habbo-furnidata-cache";
import { clearBadgeCache } from "@/lib/services/habboassets"; import { clearBadgeCache } from "@/lib/services/habboassets";
import { import {
createEmulatorRank, createEmulatorRankInTransaction,
deleteEmulatorRank, deleteEmulatorRankInTransaction,
prepareEmulatorRankCreation,
updateEmulatorRank, updateEmulatorRank,
} from "@/lib/services/permission-ranks"; } from "@/lib/services/permission-ranks";
import { rcon } from "@/lib/services/rcon"; import { rcon } from "@/lib/services/rcon";
import { siteSettings } from "@/lib/services/site-settings"; import { siteSettings } from "@/lib/services/site-settings";
import { logStaffActivity } from "@/lib/services/staff-activity"; import {
logStaffActivity,
logStaffActivityInTransaction,
} from "@/lib/services/staff-activity";
import { satisfiesCapability } from "../../../foundation/capability-context"; import { satisfiesCapability } from "../../../foundation/capability-context";
import { import {
anyCapability, anyCapability,
@@ -44,6 +49,7 @@ export type SystemMutationOperation =
| "access.rank.create" | "access.rank.create"
| "access.rank.delete" | "access.rank.delete"
| "access.rank.update" | "access.rank.update"
| "access.rank.sync.retry"
| "access.permissions.update" | "access.permissions.update"
| "access.permissions.repair" | "access.permissions.repair"
| "configuration.settings.save" | "configuration.settings.save"
@@ -67,6 +73,7 @@ export type SystemMutationOperation =
| "rcon.give-badge" | "rcon.give-badge"
| "rcon.set-motto" | "rcon.set-motto"
| "rcon.set-rank" | "rcon.set-rank"
| "rcon.set-rank.retry"
| "rcon.execute-command" | "rcon.execute-command"
| "rcon.send-gift" | "rcon.send-gift"
| "operations.maintenance.update"; | "operations.maintenance.update";
@@ -74,6 +81,7 @@ export type SystemMutationOperation =
export interface SystemMutationContext { export interface SystemMutationContext {
readonly capability: HousekeepingCapabilityContext; readonly capability: HousekeepingCapabilityContext;
readonly correlationId: string; readonly correlationId: string;
readonly reason?: string;
} }
export interface SystemMutationAdapter { export interface SystemMutationAdapter {
@@ -103,6 +111,19 @@ class SystemMutationFailure extends Error {
} }
} }
class SystemCommittedExternalFailure extends Error {
constructor(
readonly data: unknown,
readonly external: "completed" | "failed",
readonly audit: "persisted" | "unavailable",
) {
super(
"System database change committed but external synchronization is incomplete",
);
this.name = "SystemCommittedExternalFailure";
}
}
function operationCapability(operation: SystemMutationOperation) { function operationCapability(operation: SystemMutationOperation) {
if (operation.startsWith("access.")) { if (operation.startsWith("access.")) {
return anyCapability(PERMS.PERMISSIONS_MANAGE); return anyCapability(PERMS.PERMISSIONS_MANAGE);
@@ -143,6 +164,13 @@ export function createSystemMutationService(
context.correlationId, context.correlationId,
); );
} catch (error) { } catch (error) {
if (error instanceof SystemCommittedExternalFailure) {
return ok(error.data, context.correlationId, {
status: "partial",
external: error.external,
audit: error.audit,
});
}
if (error instanceof SystemMutationFailure) { if (error instanceof SystemMutationFailure) {
return fail( return fail(
error.code, error.code,
@@ -208,23 +236,225 @@ async function requireRcon(result: boolean): Promise<void> {
} }
} }
async function requireRankPermissionSynchronization( const SYSTEM_EXTERNAL_SYNC_ACTION = "system.external-sync";
operation: "access.rank.create" | "access.rank.delete" | "access.rank.update",
): Promise<void> { type RankLifecycleOperation =
| "access.rank.create"
| "access.rank.delete"
| "access.rank.update";
type SystemExternalSyncIntent =
| {
readonly kind: "rank-permission-cache";
readonly operation: RankLifecycleOperation;
readonly rankId: number;
}
| {
readonly kind: "set-rank";
readonly operation: "rcon.set-rank";
readonly userId: number;
readonly rank: number;
};
function syncTargetId(intent: SystemExternalSyncIntent): number {
return intent.kind === "set-rank" ? intent.userId : intent.rankId;
}
function pendingExternal(intent: SystemExternalSyncIntent): string {
return intent.kind === "set-rank"
? "emulator-user-rank"
: "emulator-permission-cache";
}
function syncAuditEntry(
intent: SystemExternalSyncIntent,
context: SystemMutationContext,
recoveryId: string,
outcome: "intent" | "success" | "partial",
) {
return {
userId: context.capability.actor.id,
action: SYSTEM_EXTERNAL_SYNC_ACTION,
target: intent.operation,
targetId: syncTargetId(intent),
correlationId: recoveryId,
outcome,
reason: context.reason,
domain: "system" as const,
after: { ...intent },
};
}
function synchronizationData(
intent: SystemExternalSyncIntent,
recoveryId: string,
synchronization: "completed" | "pending",
extra: Readonly<Record<string, unknown>> = {},
) {
return {
...extra,
operation: intent.operation,
recoveryId,
synchronization,
completed:
synchronization === "completed"
? ["database", "audit", pendingExternal(intent)]
: ["database", "audit"],
pending: synchronization === "completed" ? [] : [pendingExternal(intent)],
};
}
async function executeExternalSynchronization(
intent: SystemExternalSyncIntent,
): Promise<boolean> {
return intent.kind === "set-rank"
? rcon.setRank(intent.userId, intent.rank)
: rcon.send("updatepermissions");
}
async function completeExternalSynchronization(
intent: SystemExternalSyncIntent,
context: SystemMutationContext,
recoveryId = context.correlationId,
extra: Readonly<Record<string, unknown>> = {},
): Promise<unknown> {
let synchronized = false;
try { try {
if (await rcon.send("updatepermissions")) return; synchronized = await executeExternalSynchronization(intent);
} catch { } catch {
// Report the already committed local changes through the typed envelope. // The committed intent below remains the retry source of truth.
} }
throw new SystemMutationFailure(
"DEPENDENCY_UNAVAILABLE", if (!synchronized) {
"errors.housekeeping.system.rankSynchronizationIncomplete", try {
{ await logAudit(syncAuditEntry(intent, context, recoveryId, "partial"));
operation: [operation], } catch {
completed: ["database", "audit"], // The transactionally persisted intent is sufficient for recovery.
pending: ["emulator-permission-cache"], }
}, throw new SystemCommittedExternalFailure(
); synchronizationData(intent, recoveryId, "pending", extra),
"failed",
"persisted",
);
}
try {
await logAudit(syncAuditEntry(intent, context, recoveryId, "success"));
} catch {
throw new SystemCommittedExternalFailure(
{
...synchronizationData(intent, recoveryId, "completed", extra),
pending: ["completion-audit"],
},
"completed",
"unavailable",
);
}
return synchronizationData(intent, recoveryId, "completed", extra);
}
function parseExternalSyncIntent(
value: unknown,
): SystemExternalSyncIntent | null {
let parsed: unknown = value;
if (typeof value === "string") {
try {
parsed = JSON.parse(value);
} catch {
return null;
}
}
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
return null;
}
const candidate = parsed as Record<string, unknown>;
if (
candidate.kind === "rank-permission-cache" &&
(candidate.operation === "access.rank.create" ||
candidate.operation === "access.rank.delete" ||
candidate.operation === "access.rank.update") &&
Number.isInteger(candidate.rankId) &&
Number(candidate.rankId) > 0
) {
return {
kind: candidate.kind,
operation: candidate.operation,
rankId: Number(candidate.rankId),
};
}
if (
candidate.kind === "set-rank" &&
candidate.operation === "rcon.set-rank" &&
Number.isInteger(candidate.userId) &&
Number(candidate.userId) > 0 &&
Number.isInteger(candidate.rank) &&
Number(candidate.rank) > 0
) {
return {
kind: candidate.kind,
operation: candidate.operation,
userId: Number(candidate.userId),
rank: Number(candidate.rank),
};
}
return null;
}
async function loadExternalSyncIntent(recoveryId: string): Promise<{
readonly intent: SystemExternalSyncIntent;
readonly completed: boolean;
} | null> {
const [result] = await db.execute(sql`
SELECT after, outcome
FROM admin_audit_log
WHERE correlation_id = ${recoveryId}
AND action = ${SYSTEM_EXTERNAL_SYNC_ACTION}
ORDER BY id DESC
LIMIT 1
`);
const row = (result as unknown as { after: unknown; outcome: string }[])[0];
const intent = parseExternalSyncIntent(row?.after);
return intent ? { intent, completed: row.outcome === "success" } : null;
}
function recoveryIdentifier(value: unknown): string {
const recoveryId = text(value, 64);
if (!recoveryId) {
throw new SystemMutationFailure(
"VALIDATION",
"errors.housekeeping.validation",
);
}
return recoveryId;
}
async function retryExternalSynchronization(
expectedKind: SystemExternalSyncIntent["kind"],
input: Record<string, unknown>,
context: SystemMutationContext,
): Promise<unknown> {
const recoveryId = recoveryIdentifier(input.recoveryId);
const recovered = await loadExternalSyncIntent(recoveryId);
if (!recovered) {
throw new SystemMutationFailure(
"NOT_FOUND",
"errors.housekeeping.notFound",
);
}
if (recovered.intent.kind !== expectedKind) {
throw new SystemMutationFailure(
"CONFLICT",
"errors.housekeeping.validation",
);
}
if (recovered.completed) {
return {
...synchronizationData(recovered.intent, recoveryId, "completed"),
alreadyCompleted: true,
};
}
return completeExternalSynchronization(recovered.intent, context, recoveryId);
} }
async function upsertWebsiteSetting( async function upsertWebsiteSetting(
@@ -244,51 +474,76 @@ async function executeAccessMutation(
context: SystemMutationContext, context: SystemMutationContext,
): Promise<unknown> { ): Promise<unknown> {
const data = record(input); const data = record(input);
if (operation === "access.rank.sync.retry") {
return retryExternalSynchronization("rank-permission-cache", data, context);
}
if (operation === "access.rank.create") { if (operation === "access.rank.create") {
const name = text(data.name, 25); const name = text(data.name, 25);
const level = positiveInteger(data.level); const level = positiveInteger(data.level);
const id = await createEmulatorRank(db, { rank_name: name, level }); const id = await prepareEmulatorRankCreation(db);
await db const intent = {
.insert(AclRole) kind: "rank-permission-cache",
.values({ operation,
slug: `rank_${id}`, rankId: id,
title: name, } as const satisfies SystemExternalSyncIntent;
description: "CMS role synchronized from permission_ranks", await db.transaction(async (tx) => {
}) await createEmulatorRankInTransaction(tx, id, {
.onDuplicateKeyUpdate({ set: { title: name } }); rank_name: name,
await logStaffActivity({ level,
staffId: context.capability.actor.id, });
action: "rank_create", await tx
description: `Created rank #${id}`, .insert(AclRole)
targetType: "rank", .values({
targetId: id, slug: `rank_${id}`,
title: name,
description: "CMS role synchronized from permission_ranks",
})
.onDuplicateKeyUpdate({ set: { title: name } });
await logStaffActivityInTransaction(
{
staffId: context.capability.actor.id,
action: "rank_create",
description: `Created rank #${id}`,
targetType: "rank",
targetId: id,
},
tx,
);
await logAudit(
syncAuditEntry(intent, context, context.correlationId, "intent"),
tx,
);
}); });
await requireRankPermissionSynchronization("access.rank.create"); return completeExternalSynchronization(intent, context, undefined, { id });
return { id };
} }
const id = positiveInteger(data.id); const id = positiveInteger(data.id);
if (operation === "access.rank.delete") { if (operation === "access.rank.delete") {
const [userCount] = await db const intent = {
.select({ total: count() }) kind: "rank-permission-cache",
.from(User) operation,
.where(eq(User.rank, id)); rankId: id,
const users = Number(userCount?.total ?? 0); } as const satisfies SystemExternalSyncIntent;
if (users > 0) { await db.transaction(async (tx) => {
throw new SystemMutationFailure( const [userCount] = await tx
"CONFLICT", .select({ total: count() })
"errors.housekeeping.system.rankInUse", .from(User)
{ rank: [String(users)] }, .where(eq(User.rank, id));
); const users = Number(userCount?.total ?? 0);
} if (users > 0) {
const [role] = await db throw new SystemMutationFailure(
.select({ id: AclRole.id }) "CONFLICT",
.from(AclRole) "errors.housekeeping.system.rankInUse",
.where(eq(AclRole.slug, `rank_${id}`)) { rank: [String(users)] },
.limit(1); );
await deleteEmulatorRank(db, id); }
if (role) { const [role] = await tx
await db.transaction(async (tx) => { .select({ id: AclRole.id })
.from(AclRole)
.where(eq(AclRole.slug, `rank_${id}`))
.limit(1);
await deleteEmulatorRankInTransaction(tx, id);
if (role) {
await tx await tx
.delete(AclModelPermission) .delete(AclModelPermission)
.where( .where(
@@ -299,17 +554,23 @@ async function executeAccessMutation(
); );
await tx.delete(AclModelRole).where(eq(AclModelRole.roleId, role.id)); await tx.delete(AclModelRole).where(eq(AclModelRole.roleId, role.id));
await tx.delete(AclRole).where(eq(AclRole.id, role.id)); await tx.delete(AclRole).where(eq(AclRole.id, role.id));
}); }
} await logStaffActivityInTransaction(
await logStaffActivity({ {
staffId: context.capability.actor.id, staffId: context.capability.actor.id,
action: "rank_delete", action: "rank_delete",
description: `Deleted rank #${id}`, description: `Deleted rank #${id}`,
targetType: "rank", targetType: "rank",
targetId: id, targetId: id,
},
tx,
);
await logAudit(
syncAuditEntry(intent, context, context.correlationId, "intent"),
tx,
);
}); });
await requireRankPermissionSynchronization("access.rank.delete"); return completeExternalSynchronization(intent, context, undefined, { id });
return null;
} }
if (operation === "access.rank.update") { if (operation === "access.rank.update") {
@@ -321,22 +582,35 @@ async function executeAccessMutation(
: [], : [],
), ),
); );
await updateEmulatorRank(db, id, normalizedFields); const intent = {
if (typeof normalizedFields.rank_name === "string") { kind: "rank-permission-cache",
await db operation,
.update(AclRole) rankId: id,
.set({ title: normalizedFields.rank_name }) } as const satisfies SystemExternalSyncIntent;
.where(eq(AclRole.slug, `rank_${id}`)); await db.transaction(async (tx) => {
} await updateEmulatorRank(tx, id, normalizedFields);
await logStaffActivity({ if (typeof normalizedFields.rank_name === "string") {
staffId: context.capability.actor.id, await tx
action: "rank_update", .update(AclRole)
description: `Updated rank #${id}`, .set({ title: normalizedFields.rank_name })
targetType: "rank", .where(eq(AclRole.slug, `rank_${id}`));
targetId: id, }
await logStaffActivityInTransaction(
{
staffId: context.capability.actor.id,
action: "rank_update",
description: `Updated rank #${id}`,
targetType: "rank",
targetId: id,
},
tx,
);
await logAudit(
syncAuditEntry(intent, context, context.correlationId, "intent"),
tx,
);
}); });
await requireRankPermissionSynchronization("access.rank.update"); return completeExternalSynchronization(intent, context, undefined, { id });
return null;
} }
if (operation === "access.permissions.update") { if (operation === "access.permissions.update") {
@@ -632,65 +906,79 @@ async function executeRconMutation(
), ),
); );
break; break;
case "rcon.set-rank.retry":
return retryExternalSynchronization("set-rank", data, context);
case "rcon.set-rank": { case "rcon.set-rank": {
const userId = positiveInteger(data.userId); const userId = positiveInteger(data.userId);
const rank = positiveInteger(data.rank); const rank = positiveInteger(data.rank);
const [target] = await db const intent = {
.select({ rank: User.rank }) kind: "set-rank",
.from(User) operation,
.where(eq(User.id, userId)) userId,
.limit(1); rank,
if (!target) { } as const satisfies SystemExternalSyncIntent;
throw new SystemMutationFailure( await db.transaction(async (tx) => {
"NOT_FOUND", const [target] = await tx
"errors.housekeeping.system.userNotFound", .select({ rank: User.rank })
); .from(User)
} .where(eq(User.id, userId))
let rankRows: { id: number }[] = []; .limit(1);
try { if (!target) {
const [rows] = await db.execute(
sql`SELECT id FROM permission_ranks WHERE id = ${rank} LIMIT 1`,
);
rankRows = rows as unknown as { id: number }[];
} catch {
rankRows = [];
}
if (rankRows.length === 0) {
throw new SystemMutationFailure(
"NOT_FOUND",
"errors.housekeeping.system.rankNotFound",
);
}
if (!context.capability.isSuperAdmin) {
const actorRank = context.capability.actor.rank;
if (target.rank >= actorRank) {
throw new SystemMutationFailure( throw new SystemMutationFailure(
"FORBIDDEN", "NOT_FOUND",
"errors.housekeeping.system.cannotChangePeerRank", "errors.housekeeping.system.userNotFound",
); );
} }
if (rank >= actorRank) { let rankRows: { id: number }[] = [];
try {
const [rows] = await tx.execute(
sql`SELECT id FROM permission_ranks WHERE id = ${rank} LIMIT 1 FOR UPDATE`,
);
rankRows = rows as unknown as { id: number }[];
} catch {
rankRows = [];
}
if (rankRows.length === 0) {
throw new SystemMutationFailure( throw new SystemMutationFailure(
"FORBIDDEN", "NOT_FOUND",
"errors.housekeeping.system.cannotAssignPeerRank", "errors.housekeeping.system.rankNotFound",
); );
} }
} if (!context.capability.isSuperAdmin) {
await requireRcon(await rcon.setRank(userId, rank)); const actorRank = context.capability.actor.rank;
try { if (target.rank >= actorRank) {
await db.update(User).set({ rank }).where(eq(User.id, userId)); throw new SystemMutationFailure(
} catch { "FORBIDDEN",
throw new SystemMutationFailure( "errors.housekeeping.system.cannotChangePeerRank",
"DEPENDENCY_UNAVAILABLE", );
"errors.housekeeping.system.rankPersistenceIncomplete", }
if (rank >= actorRank) {
throw new SystemMutationFailure(
"FORBIDDEN",
"errors.housekeeping.system.cannotAssignPeerRank",
);
}
}
await tx.update(User).set({ rank }).where(eq(User.id, userId));
await logStaffActivityInTransaction(
{ {
operation: ["rcon.set-rank"], staffId: context.capability.actor.id,
completed: ["emulator"], action: "rank_assign",
pending: ["database"], description: `Assigned rank #${rank} to user #${userId}`,
targetType: "user",
targetId: userId,
}, },
tx,
); );
} await logAudit(
break; syncAuditEntry(intent, context, context.correlationId, "intent"),
tx,
);
});
return completeExternalSynchronization(intent, context, undefined, {
userId,
rank,
});
} }
case "rcon.execute-command": case "rcon.execute-command":
await requireRcon( await requireRcon(
@@ -9,24 +9,39 @@ import type { HousekeepingCapabilityContext } from "../../../foundation/contract
const doubles = vi.hoisted(() => ({ const doubles = vi.hoisted(() => ({
createEmulatorRank: vi.fn(), createEmulatorRank: vi.fn(),
createEmulatorRankInTransaction: vi.fn(),
dbDelete: vi.fn(),
dbExecute: vi.fn(), dbExecute: vi.fn(),
dbInsert: vi.fn(), dbInsert: vi.fn(),
dbSelect: vi.fn(), dbSelect: vi.fn(),
dbTransaction: vi.fn(), dbTransaction: vi.fn(),
dbUpdate: vi.fn(), dbUpdate: vi.fn(),
deleteEmulatorRank: vi.fn(), deleteEmulatorRank: vi.fn(),
deleteEmulatorRankInTransaction: vi.fn(),
logAudit: vi.fn(),
logStaffActivity: vi.fn(), logStaffActivity: vi.fn(),
logStaffActivityInTransaction: vi.fn(),
prepareEmulatorRankCreation: vi.fn(),
rconSend: vi.fn(), rconSend: vi.fn(),
rconSetRank: vi.fn(), rconSetRank: vi.fn(),
updateEmulatorRank: vi.fn(), updateEmulatorRank: vi.fn(),
})); }));
const transactionToken = {
delete: doubles.dbDelete,
execute: doubles.dbExecute,
insert: doubles.dbInsert,
select: doubles.dbSelect,
update: doubles.dbUpdate,
};
vi.mock("@/lib/db", async (importOriginal) => { vi.mock("@/lib/db", async (importOriginal) => {
const actual = await importOriginal<typeof import("@/lib/db")>(); const actual = await importOriginal<typeof import("@/lib/db")>();
return { return {
...actual, ...actual,
db: { db: {
...actual.db, ...actual.db,
delete: doubles.dbDelete,
execute: doubles.dbExecute, execute: doubles.dbExecute,
insert: doubles.dbInsert, insert: doubles.dbInsert,
select: doubles.dbSelect, select: doubles.dbSelect,
@@ -36,9 +51,16 @@ vi.mock("@/lib/db", async (importOriginal) => {
}; };
}); });
vi.mock("@/lib/services/audit", () => ({
logAudit: doubles.logAudit,
}));
vi.mock("@/lib/services/permission-ranks", () => ({ vi.mock("@/lib/services/permission-ranks", () => ({
createEmulatorRank: doubles.createEmulatorRank, createEmulatorRank: doubles.createEmulatorRank,
createEmulatorRankInTransaction: doubles.createEmulatorRankInTransaction,
deleteEmulatorRank: doubles.deleteEmulatorRank, deleteEmulatorRank: doubles.deleteEmulatorRank,
deleteEmulatorRankInTransaction: doubles.deleteEmulatorRankInTransaction,
prepareEmulatorRankCreation: doubles.prepareEmulatorRankCreation,
updateEmulatorRank: doubles.updateEmulatorRank, updateEmulatorRank: doubles.updateEmulatorRank,
})); }));
@@ -48,6 +70,7 @@ vi.mock("@/lib/services/rcon", () => ({
vi.mock("@/lib/services/staff-activity", () => ({ vi.mock("@/lib/services/staff-activity", () => ({
logStaffActivity: doubles.logStaffActivity, logStaffActivity: doubles.logStaffActivity,
logStaffActivityInTransaction: doubles.logStaffActivityInTransaction,
})); }));
import { createSystemCommands } from "../commands/system-commands"; import { createSystemCommands } from "../commands/system-commands";
@@ -73,6 +96,7 @@ function serviceContext(permission: string) {
return { return {
capability: capabilityContext([permission]), capability: capabilityContext([permission]),
correlationId: "rank-mutation-correlation", correlationId: "rank-mutation-correlation",
reason: "Approved rank lifecycle change",
}; };
} }
@@ -84,10 +108,21 @@ function insertChain() {
}; };
} }
function deleteChain() {
return { where: vi.fn().mockResolvedValue(undefined) };
}
function updateChain(result: Promise<unknown> = Promise.resolve(undefined)) {
return { set: () => ({ where: () => result }) };
}
function limitedSelection(rows: readonly unknown[]) { function limitedSelection(rows: readonly unknown[]) {
return { return {
from: () => ({ from: () => ({
where: () => ({ limit: () => Promise.resolve(rows) }), where: () => ({
for: () => Promise.resolve(rows),
limit: () => Promise.resolve(rows),
}),
}), }),
}; };
} }
@@ -101,14 +136,72 @@ function plainSelection(rows: readonly unknown[]) {
beforeEach(() => { beforeEach(() => {
for (const mock of Object.values(doubles)) mock.mockReset(); for (const mock of Object.values(doubles)) mock.mockReset();
doubles.createEmulatorRank.mockResolvedValue(7); doubles.createEmulatorRank.mockResolvedValue(7);
doubles.prepareEmulatorRankCreation.mockResolvedValue(7);
doubles.createEmulatorRankInTransaction.mockResolvedValue(undefined);
doubles.deleteEmulatorRank.mockResolvedValue(undefined); doubles.deleteEmulatorRank.mockResolvedValue(undefined);
doubles.deleteEmulatorRankInTransaction.mockResolvedValue(undefined);
doubles.updateEmulatorRank.mockResolvedValue(undefined); doubles.updateEmulatorRank.mockResolvedValue(undefined);
doubles.logAudit.mockResolvedValue(undefined);
doubles.logStaffActivity.mockResolvedValue(undefined); doubles.logStaffActivity.mockResolvedValue(undefined);
doubles.logStaffActivityInTransaction.mockResolvedValue(undefined);
doubles.dbDelete.mockImplementation(deleteChain);
doubles.dbInsert.mockImplementation(insertChain); doubles.dbInsert.mockImplementation(insertChain);
doubles.dbTransaction.mockImplementation(async (run) =>
run(transactionToken),
);
doubles.dbUpdate.mockImplementation(() => updateChain());
}); });
describe("rank synchronization failures", () => { function expectPendingSynchronization(
it("returns failure and audits failure when create committed but updatepermissions returned false", async () => { result: unknown,
operation:
| "access.rank.create"
| "access.rank.delete"
| "access.rank.update"
| "rcon.set-rank",
) {
expect(result).toMatchObject({
ok: true,
data: {
operation,
recoveryId: expect.any(String),
completed: ["database", "audit"],
},
completion: {
status: "partial",
external: "failed",
audit: "persisted",
},
});
if (typeof result === "object" && result !== null && "ok" in result) {
const typed = result as {
ok: boolean;
correlationId?: string;
data?: { recoveryId?: string };
};
if (typed.ok) expect(typed.data?.recoveryId).toBe(typed.correlationId);
}
}
describe("durable rank synchronization", () => {
it("rolls back before RCON when the transaction-bound recovery intent cannot be audited", async () => {
doubles.logAudit.mockRejectedValueOnce(new Error("audit unavailable"));
doubles.rconSend.mockResolvedValue(true);
const result = await systemMutationService.execute(
serviceContext(PERMS.PERMISSIONS_MANAGE),
"access.rank.create",
{ name: "Administrator", level: 7 },
);
expect(result).toMatchObject({
ok: false,
error: { code: "DEPENDENCY_UNAVAILABLE" },
});
expect(doubles.rconSend).not.toHaveBeenCalled();
});
it("commits create, ACL, staff audit, and recovery intent together before returning a retry key", async () => {
doubles.rconSend.mockResolvedValue(false); doubles.rconSend.mockResolvedValue(false);
const command = createSystemCommands(systemMutationService).find( const command = createSystemCommands(systemMutationService).find(
(entry) => entry.id === "system.access.rank.create", (entry) => entry.id === "system.access.rank.create",
@@ -137,19 +230,24 @@ describe("rank synchronization failures", () => {
}, },
); );
expect(result).toMatchObject({ expectPendingSynchronization(result, "access.rank.create");
ok: false, expect(outcomes).toEqual(["intent", "partial"]);
error: { expect(doubles.createEmulatorRankInTransaction).toHaveBeenCalledWith(
code: "DEPENDENCY_UNAVAILABLE", transactionToken,
messageKey: "errors.housekeeping.system.rankSynchronizationIncomplete", 7,
fieldErrors: { { rank_name: "Administrator", level: 7 },
operation: ["access.rank.create"], );
completed: ["database", "audit"], expect(doubles.logStaffActivityInTransaction).toHaveBeenCalledWith(
pending: ["emulator-permission-cache"], expect.objectContaining({ action: "rank_create", targetId: 7 }),
}, transactionToken,
}, );
}); expect(doubles.logAudit).toHaveBeenCalledWith(
expect(outcomes).toEqual(["intent", "failure"]); expect.objectContaining({
correlationId: expect.any(String),
outcome: "intent",
}),
transactionToken,
);
}); });
it.each([ it.each([
@@ -161,11 +259,17 @@ describe("rank synchronization failures", () => {
.mockImplementationOnce(() => plainSelection([{ total: 0 }])) .mockImplementationOnce(() => plainSelection([{ total: 0 }]))
.mockImplementationOnce(() => limitedSelection([])); .mockImplementationOnce(() => limitedSelection([]));
}, },
"deleteEmulatorRankInTransaction",
],
[
"access.rank.update",
{ id: 7, fields: { badge: "ADM" } },
() => {},
"updateEmulatorRank",
], ],
["access.rank.update", { id: 7, fields: { badge: "ADM" } }, () => {}],
] as const)( ] as const)(
"returns failure when %s committed but updatepermissions returned false", "returns a recoverable partial after atomic %s commit when cache refresh fails",
async (operation, input, prepare) => { async (operation, input, prepare, helperName) => {
prepare(); prepare();
doubles.rconSend.mockResolvedValue(false); doubles.rconSend.mockResolvedValue(false);
@@ -175,33 +279,23 @@ describe("rank synchronization failures", () => {
input, input,
); );
expect(result).toMatchObject({ expectPendingSynchronization(result, operation);
ok: false, expect(doubles[helperName]).toHaveBeenCalledWith(
error: { transactionToken,
code: "DEPENDENCY_UNAVAILABLE", 7,
messageKey: ...(operation === "access.rank.update" ? [{ badge: "ADM" }] : []),
"errors.housekeeping.system.rankSynchronizationIncomplete", );
fieldErrors: {
operation: [operation],
completed: ["database", "audit"],
pending: ["emulator-permission-cache"],
},
},
});
}, },
); );
it("reports external completion when set-rank RCON succeeded but DB persistence failed", async () => { it("never calls RCON when set-rank database persistence rolls back", async () => {
doubles.dbSelect.mockImplementationOnce(() => doubles.dbSelect.mockImplementationOnce(() =>
limitedSelection([{ rank: 3 }]), limitedSelection([{ rank: 3 }]),
); );
doubles.dbExecute.mockResolvedValue([[{ id: 4 }]]); doubles.dbExecute.mockResolvedValue([[{ id: 4 }]]);
doubles.rconSetRank.mockResolvedValue(true); doubles.dbUpdate.mockImplementationOnce(() =>
doubles.dbUpdate.mockReturnValue({ updateChain(Promise.reject(new Error("users update unavailable"))),
set: () => ({ );
where: () => Promise.reject(new Error("users update unavailable")),
}),
});
const result = await systemMutationService.execute( const result = await systemMutationService.execute(
serviceContext(PERMS.RCON_EXECUTE), serviceContext(PERMS.RCON_EXECUTE),
@@ -211,15 +305,124 @@ describe("rank synchronization failures", () => {
expect(result).toMatchObject({ expect(result).toMatchObject({
ok: false, ok: false,
error: { error: { code: "DEPENDENCY_UNAVAILABLE" },
code: "DEPENDENCY_UNAVAILABLE", });
messageKey: "errors.housekeeping.system.rankPersistenceIncomplete", expect(doubles.rconSetRank).not.toHaveBeenCalled();
fieldErrors: { });
operation: ["rcon.set-rank"],
completed: ["emulator"], it("returns the durable recovery key when set-rank RCON fails after commit", async () => {
pending: ["database"], doubles.dbSelect.mockImplementationOnce(() =>
limitedSelection([{ rank: 3 }]),
);
doubles.dbExecute.mockResolvedValue([[{ id: 4 }]]);
doubles.rconSetRank.mockResolvedValue(false);
const result = await systemMutationService.execute(
serviceContext(PERMS.RCON_EXECUTE),
"rcon.set-rank",
{ userId: 8, rank: 4 },
);
expectPendingSynchronization(result, "rcon.set-rank");
expect(doubles.dbUpdate).toHaveBeenCalledBefore(doubles.rconSetRank);
});
it("retries only the convergent cache refresh, never the original rank mutation", async () => {
doubles.dbExecute.mockResolvedValueOnce([
[
{
after: JSON.stringify({
kind: "rank-permission-cache",
operation: "access.rank.create",
rankId: 7,
}),
outcome: "partial",
}, },
],
]);
doubles.rconSend.mockResolvedValue(true);
const result = await systemMutationService.execute(
serviceContext(PERMS.PERMISSIONS_MANAGE),
"access.rank.sync.retry" as SystemMutationOperation,
{ recoveryId: "original-rank-correlation" },
);
expect(result).toMatchObject({
ok: true,
data: {
recoveryId: "original-rank-correlation",
synchronization: "completed",
}, },
}); });
expect(doubles.rconSend).toHaveBeenCalledWith("updatepermissions");
expect(doubles.createEmulatorRank).not.toHaveBeenCalled();
expect(doubles.createEmulatorRankInTransaction).not.toHaveBeenCalled();
expect(doubles.deleteEmulatorRank).not.toHaveBeenCalled();
expect(doubles.deleteEmulatorRankInTransaction).not.toHaveBeenCalled();
expect(doubles.updateEmulatorRank).not.toHaveBeenCalled();
});
it("retries set-rank from its committed desired-state intent without another database mutation", async () => {
doubles.dbExecute.mockResolvedValueOnce([
[
{
after: JSON.stringify({
kind: "set-rank",
operation: "rcon.set-rank",
userId: 8,
rank: 4,
}),
outcome: "intent",
},
],
]);
doubles.rconSetRank.mockResolvedValue(true);
const result = await systemMutationService.execute(
serviceContext(PERMS.RCON_EXECUTE),
"rcon.set-rank.retry" as SystemMutationOperation,
{ recoveryId: "original-set-rank-correlation" },
);
expect(result).toMatchObject({
ok: true,
data: {
recoveryId: "original-set-rank-correlation",
synchronization: "completed",
},
});
expect(doubles.rconSetRank).toHaveBeenCalledWith(8, 4);
expect(doubles.dbUpdate).not.toHaveBeenCalled();
});
it("does not repeat an already completed external synchronization", async () => {
doubles.dbExecute.mockResolvedValueOnce([
[
{
after: JSON.stringify({
kind: "rank-permission-cache",
operation: "access.rank.update",
rankId: 7,
}),
outcome: "success",
},
],
]);
const result = await systemMutationService.execute(
serviceContext(PERMS.PERMISSIONS_MANAGE),
"access.rank.sync.retry",
{ recoveryId: "completed-rank-correlation" },
);
expect(result).toMatchObject({
ok: true,
data: {
alreadyCompleted: true,
recoveryId: "completed-rank-correlation",
},
});
expect(doubles.rconSend).not.toHaveBeenCalled();
}); });
}); });
+27
View File
@@ -3,6 +3,8 @@ import { MySqlDialect } from "drizzle-orm/mysql-core";
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import { import {
createEmulatorRankInTransaction,
deleteEmulatorRankInTransaction,
fetchEmulatorRankForEdit, fetchEmulatorRankForEdit,
getRankPermissionColumn, getRankPermissionColumn,
RANK_GENERAL_FIELDS, RANK_GENERAL_FIELDS,
@@ -86,6 +88,31 @@ describe("RANK_GENERAL_FIELDS", () => {
}); });
}); });
describe("transaction-aware rank lifecycle helpers", () => {
it("runs coupled create and delete statements through the supplied executor", async () => {
const statements: string[] = [];
const executor = {
execute: async (query: SQL | string) => {
statements.push(queryText(query));
return [{ affectedRows: 1 }, []];
},
};
await createEmulatorRankInTransaction(executor as never, 7, {
rank_name: "Administrator",
level: 7,
});
await deleteEmulatorRankInTransaction(executor as never, 7);
expect(statements).toHaveLength(5);
expect(statements[0]).toContain("INSERT INTO permission_ranks");
expect(statements[1]).toContain("INSERT INTO permissions");
expect(statements[2]).toContain("UPDATE permission_definitions");
expect(statements[3]).toContain("DELETE FROM permission_ranks");
expect(statements[4]).toContain("DELETE FROM permissions");
});
});
describe("fetchEmulatorRankForEdit", () => { describe("fetchEmulatorRankForEdit", () => {
it("loads permissions from the legacy table when permission_definitions is absent", async () => { it("loads permissions from the legacy table when permission_definitions is absent", async () => {
const db = { const db = {
+48 -26
View File
@@ -43,15 +43,16 @@ export interface EmulatorRankForEdit extends EmulatorRankSummary {
permissionMaxValues: Record<string, number>; permissionMaxValues: Record<string, number>;
} }
/** Surface of `Db` used by these helpers (plain raw-SQL exec + transactions). */ /** Surfaces used by rank helpers, including database transaction handles. */
type SqlExecutor = Pick<Db, "execute">;
type RawDb = Pick<Db, "execute" | "transaction">; type RawDb = Pick<Db, "execute" | "transaction">;
async function rawRows<T>(db: RawDb, query: SQL | string): Promise<T> { async function rawRows<T>(db: SqlExecutor, query: SQL | string): Promise<T> {
const [rows] = await db.execute(query); const [rows] = await db.execute(query);
return rows as T; return rows as T;
} }
async function rawExec(db: RawDb, query: SQL | string): Promise<number> { async function rawExec(db: SqlExecutor, query: SQL | string): Promise<number> {
const [result] = await db.execute(query); const [result] = await db.execute(query);
return (result as ResultSetHeader).affectedRows; return (result as ResultSetHeader).affectedRows;
} }
@@ -195,6 +196,19 @@ export async function fetchEmulatorRankForEdit(
export async function createEmulatorRank( export async function createEmulatorRank(
db: RawDb, db: RawDb,
data: { rank_name: string; level: number }, data: { rank_name: string; level: number },
): Promise<number> {
const id = await prepareEmulatorRankCreation(db);
await db.transaction((tx) => createEmulatorRankInTransaction(tx, id, data));
return id;
}
/**
* Prepare the dynamic permission column before opening the coupled DML
* transaction. MySQL ALTER TABLE commits implicitly, so this schema-only step
* is deliberately kept outside the atomic rank/ACL/audit unit.
*/
export async function prepareEmulatorRankCreation(
db: SqlExecutor,
): Promise<number> { ): Promise<number> {
const rows = await rawRows<{ next_id: number }[]>( const rows = await rawRows<{ next_id: number }[]>(
db, db,
@@ -203,38 +217,46 @@ export async function createEmulatorRank(
const id = Number(rows[0]?.next_id ?? 1); const id = Number(rows[0]?.next_id ?? 1);
await ensurePermissionRankColumn(db, id); await ensurePermissionRankColumn(db, id);
await resetPermissionRankColumn(db, id); await resetPermissionRankColumn(db, id);
return id;
}
await db.transaction(async (tx) => { export async function createEmulatorRankInTransaction(
await rawExec( db: SqlExecutor,
tx, id: number,
sql` data: { rank_name: string; level: number },
): Promise<void> {
await rawExec(
db,
sql`
INSERT INTO permission_ranks (id, rank_name, level) INSERT INTO permission_ranks (id, rank_name, level)
VALUES (${id}, ${data.rank_name}, ${data.level}) VALUES (${id}, ${data.rank_name}, ${data.level})
`, `,
); );
await rawExec( await rawExec(
tx, db,
sql` sql`
INSERT INTO permissions (id, rank_name, level) INSERT INTO permissions (id, rank_name, level)
VALUES (${id}, ${data.rank_name}, ${data.level}) VALUES (${id}, ${data.rank_name}, ${data.level})
ON DUPLICATE KEY UPDATE rank_name = VALUES(rank_name), level = VALUES(level) ON DUPLICATE KEY UPDATE rank_name = VALUES(rank_name), level = VALUES(level)
`, `,
); );
});
return id;
} }
export async function deleteEmulatorRank(db: RawDb, rankId: number) { export async function deleteEmulatorRank(db: RawDb, rankId: number) {
await db.transaction((tx) => deleteEmulatorRankInTransaction(tx, rankId));
}
export async function deleteEmulatorRankInTransaction(
db: SqlExecutor,
rankId: number,
): Promise<void> {
await resetPermissionRankColumn(db, rankId); await resetPermissionRankColumn(db, rankId);
await db.transaction(async (tx) => { await rawExec(db, sql`DELETE FROM permission_ranks WHERE id = ${rankId}`);
await rawExec(tx, sql`DELETE FROM permission_ranks WHERE id = ${rankId}`); await rawExec(db, sql`DELETE FROM permissions WHERE id = ${rankId}`);
await rawExec(tx, sql`DELETE FROM permissions WHERE id = ${rankId}`);
});
} }
export async function updateEmulatorRank( export async function updateEmulatorRank(
db: RawDb, db: SqlExecutor,
rankId: number, rankId: number,
fields: Record<string, string | number>, fields: Record<string, string | number>,
) { ) {
@@ -252,7 +274,7 @@ export async function updateEmulatorRank(
} }
async function updateTableFields( async function updateTableFields(
db: RawDb, db: SqlExecutor,
table: string, table: string,
rankId: number, rankId: number,
fields: Record<string, string | number>, fields: Record<string, string | number>,
@@ -271,7 +293,7 @@ async function updateTableFields(
} }
async function updatePermissionDefinitionValues( async function updatePermissionDefinitionValues(
db: RawDb, db: SqlExecutor,
rankId: number, rankId: number,
permissionFields: Record<string, number>, permissionFields: Record<string, number>,
) { ) {
@@ -297,7 +319,7 @@ async function updatePermissionDefinitionValues(
} }
async function updateLegacyPermissionFields( async function updateLegacyPermissionFields(
db: RawDb, db: SqlExecutor,
rankId: number, rankId: number,
fields: Record<string, string | number>, fields: Record<string, string | number>,
) { ) {
@@ -309,7 +331,7 @@ async function updateLegacyPermissionFields(
return updateTableFields(db, "permissions", rankId, existingFields); return updateTableFields(db, "permissions", rankId, existingFields);
} }
async function ensurePermissionRankColumn(db: RawDb, rankId: number) { async function ensurePermissionRankColumn(db: SqlExecutor, rankId: number) {
const rankColumn = getRankPermissionColumn(rankId); const rankColumn = getRankPermissionColumn(rankId);
const exists = await rawRows<{ c: number }[]>( const exists = await rawRows<{ c: number }[]>(
db, db,
@@ -332,7 +354,7 @@ async function ensurePermissionRankColumn(db: RawDb, rankId: number) {
); );
} }
async function resetPermissionRankColumn(db: RawDb, rankId: number) { async function resetPermissionRankColumn(db: SqlExecutor, rankId: number) {
const rankColumn = getRankPermissionColumn(rankId); const rankColumn = getRankPermissionColumn(rankId);
await rawExec( await rawExec(
db, db,
@@ -343,7 +365,7 @@ async function resetPermissionRankColumn(db: RawDb, rankId: number) {
); );
} }
async function getTableColumns(db: RawDb, table: string) { async function getTableColumns(db: SqlExecutor, table: string) {
const rows = await rawRows<{ column_name: string }[]>( const rows = await rawRows<{ column_name: string }[]>(
db, db,
sql` sql`
+43 -26
View File
@@ -1,37 +1,54 @@
import { headers } from "next/headers"; import { headers } from "next/headers";
import { db, StaffActivities } from "@/lib/db"; import { type Db, db, StaffActivities } from "@/lib/db";
/** interface StaffActivityOptions {
* Append a staff-action audit entry (AtomCMS StaffActivity). Never throws —
* logging must not block the action it records.
*/
export async function logStaffActivity(opts: {
staffId: number; staffId: number;
action: string; action: string;
description: string; description: string;
targetType?: string; targetType?: string;
targetId?: number; targetId?: number;
}): Promise<void> { }
type StaffActivityWriter = Pick<Db, "insert">;
async function resolveStaffIp(): Promise<string | null> {
try { try {
let ip: string | null = null; const h = await headers();
try { return (
const h = await headers(); h.get("x-real-client-ip") ??
ip = h.get("x-forwarded-for")?.split(",")[0]?.trim() ??
h.get("x-real-client-ip") ?? null
h.get("x-forwarded-for")?.split(",")[0]?.trim() ?? );
null; } catch {
} catch { return null;
ip = null; }
} }
await db.insert(StaffActivities).values({
userId: BigInt(opts.staffId), /** Write an audit row through the caller's transaction and propagate failure. */
action: opts.action.slice(0, 50), export async function logStaffActivityInTransaction(
description: opts.description, opts: StaffActivityOptions,
targetType: opts.targetType ?? null, transaction: StaffActivityWriter,
targetId: opts.targetId != null ? BigInt(opts.targetId) : null, ): Promise<void> {
ipAddress: ip, await transaction.insert(StaffActivities).values({
createdAt: new Date(), userId: BigInt(opts.staffId),
}); action: opts.action.slice(0, 50),
description: opts.description,
targetType: opts.targetType ?? null,
targetId: opts.targetId != null ? BigInt(opts.targetId) : null,
ipAddress: await resolveStaffIp(),
createdAt: new Date(),
});
}
/**
* Append a staff-action audit entry (AtomCMS StaffActivity). Never throws —
* logging must not block the action it records.
*/
export async function logStaffActivity(
opts: StaffActivityOptions,
): Promise<void> {
try {
await logStaffActivityInTransaction(opts, db);
} catch { } catch {
// swallow — audit logging is best-effort // swallow — audit logging is best-effort
} }