fix(housekeeping): make rank synchronization recoverable
This commit is contained in:
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.delete",
|
||||
"system.access.rank.update",
|
||||
"system.access.rank.sync-retry",
|
||||
"system.access.permissions.update",
|
||||
"system.access.permissions.repair",
|
||||
"system.configuration.settings.save",
|
||||
@@ -40,6 +41,7 @@ const expectedCommandIds = [
|
||||
"system.operations.rcon.give-badge",
|
||||
"system.operations.rcon.set-motto",
|
||||
"system.operations.rcon.set-rank",
|
||||
"system.operations.rcon.set-rank-retry",
|
||||
"system.operations.rcon.execute-command",
|
||||
"system.operations.rcon.send-gift",
|
||||
"system.operations.maintenance.update",
|
||||
@@ -165,6 +167,7 @@ describe("SYSTEM_COMMANDS", () => {
|
||||
capability: capabilityContext([PERMS.RCON_EXECUTE]),
|
||||
correlationId: "command-correlation",
|
||||
ipAddress: "198.51.100.8",
|
||||
reason: "Approved credit correction",
|
||||
},
|
||||
parsed,
|
||||
);
|
||||
@@ -172,6 +175,7 @@ describe("SYSTEM_COMMANDS", () => {
|
||||
expect(result).toMatchObject({
|
||||
ok: true,
|
||||
data: {
|
||||
context: { reason: "Approved credit correction" },
|
||||
operation: "rcon.give-credits",
|
||||
input: { userId: 7, amount: 25 },
|
||||
},
|
||||
|
||||
@@ -14,6 +14,7 @@ export const SYSTEM_COMMAND_IDS = [
|
||||
"system.access.rank.create",
|
||||
"system.access.rank.delete",
|
||||
"system.access.rank.update",
|
||||
"system.access.rank.sync-retry",
|
||||
"system.access.permissions.update",
|
||||
"system.access.permissions.repair",
|
||||
"system.configuration.settings.save",
|
||||
@@ -37,6 +38,7 @@ export const SYSTEM_COMMAND_IDS = [
|
||||
"system.operations.rcon.give-badge",
|
||||
"system.operations.rcon.set-motto",
|
||||
"system.operations.rcon.set-rank",
|
||||
"system.operations.rcon.set-rank-retry",
|
||||
"system.operations.rcon.execute-command",
|
||||
"system.operations.rcon.send-gift",
|
||||
"system.operations.maintenance.update",
|
||||
@@ -68,6 +70,7 @@ function systemCommand<I>(
|
||||
{
|
||||
capability: context.capability,
|
||||
correlationId: context.correlationId,
|
||||
reason: context.reason,
|
||||
},
|
||||
options.operation,
|
||||
input,
|
||||
@@ -111,6 +114,14 @@ export function createSystemCommands(
|
||||
}),
|
||||
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, {
|
||||
id: "system.access.permissions.update",
|
||||
operation: "access.permissions.update",
|
||||
@@ -278,6 +289,14 @@ export function createSystemCommands(
|
||||
requiresReason: true,
|
||||
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, {
|
||||
id: "system.operations.rcon.execute-command",
|
||||
operation: "rcon.execute-command",
|
||||
|
||||
@@ -20,16 +20,21 @@ import {
|
||||
normalizeHabboGamedataHotel,
|
||||
} from "@/lib/habbo-gamedata-hotel";
|
||||
import { PERMS } from "@/lib/permission-slugs";
|
||||
import { logAudit } from "@/lib/services/audit";
|
||||
import { clearOfficialHabboFurnidataCache } from "@/lib/services/habbo-furnidata-cache";
|
||||
import { clearBadgeCache } from "@/lib/services/habboassets";
|
||||
import {
|
||||
createEmulatorRank,
|
||||
deleteEmulatorRank,
|
||||
createEmulatorRankInTransaction,
|
||||
deleteEmulatorRankInTransaction,
|
||||
prepareEmulatorRankCreation,
|
||||
updateEmulatorRank,
|
||||
} from "@/lib/services/permission-ranks";
|
||||
import { rcon } from "@/lib/services/rcon";
|
||||
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 {
|
||||
anyCapability,
|
||||
@@ -44,6 +49,7 @@ export type SystemMutationOperation =
|
||||
| "access.rank.create"
|
||||
| "access.rank.delete"
|
||||
| "access.rank.update"
|
||||
| "access.rank.sync.retry"
|
||||
| "access.permissions.update"
|
||||
| "access.permissions.repair"
|
||||
| "configuration.settings.save"
|
||||
@@ -67,6 +73,7 @@ export type SystemMutationOperation =
|
||||
| "rcon.give-badge"
|
||||
| "rcon.set-motto"
|
||||
| "rcon.set-rank"
|
||||
| "rcon.set-rank.retry"
|
||||
| "rcon.execute-command"
|
||||
| "rcon.send-gift"
|
||||
| "operations.maintenance.update";
|
||||
@@ -74,6 +81,7 @@ export type SystemMutationOperation =
|
||||
export interface SystemMutationContext {
|
||||
readonly capability: HousekeepingCapabilityContext;
|
||||
readonly correlationId: string;
|
||||
readonly reason?: string;
|
||||
}
|
||||
|
||||
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) {
|
||||
if (operation.startsWith("access.")) {
|
||||
return anyCapability(PERMS.PERMISSIONS_MANAGE);
|
||||
@@ -143,6 +164,13 @@ export function createSystemMutationService(
|
||||
context.correlationId,
|
||||
);
|
||||
} catch (error) {
|
||||
if (error instanceof SystemCommittedExternalFailure) {
|
||||
return ok(error.data, context.correlationId, {
|
||||
status: "partial",
|
||||
external: error.external,
|
||||
audit: error.audit,
|
||||
});
|
||||
}
|
||||
if (error instanceof SystemMutationFailure) {
|
||||
return fail(
|
||||
error.code,
|
||||
@@ -208,23 +236,225 @@ async function requireRcon(result: boolean): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
async function requireRankPermissionSynchronization(
|
||||
operation: "access.rank.create" | "access.rank.delete" | "access.rank.update",
|
||||
): Promise<void> {
|
||||
const SYSTEM_EXTERNAL_SYNC_ACTION = "system.external-sync";
|
||||
|
||||
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 {
|
||||
if (await rcon.send("updatepermissions")) return;
|
||||
synchronized = await executeExternalSynchronization(intent);
|
||||
} 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",
|
||||
"errors.housekeeping.system.rankSynchronizationIncomplete",
|
||||
{
|
||||
operation: [operation],
|
||||
completed: ["database", "audit"],
|
||||
pending: ["emulator-permission-cache"],
|
||||
},
|
||||
);
|
||||
|
||||
if (!synchronized) {
|
||||
try {
|
||||
await logAudit(syncAuditEntry(intent, context, recoveryId, "partial"));
|
||||
} catch {
|
||||
// The transactionally persisted intent is sufficient for recovery.
|
||||
}
|
||||
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(
|
||||
@@ -244,51 +474,76 @@ async function executeAccessMutation(
|
||||
context: SystemMutationContext,
|
||||
): Promise<unknown> {
|
||||
const data = record(input);
|
||||
if (operation === "access.rank.sync.retry") {
|
||||
return retryExternalSynchronization("rank-permission-cache", data, context);
|
||||
}
|
||||
if (operation === "access.rank.create") {
|
||||
const name = text(data.name, 25);
|
||||
const level = positiveInteger(data.level);
|
||||
const id = await createEmulatorRank(db, { rank_name: name, level });
|
||||
await db
|
||||
.insert(AclRole)
|
||||
.values({
|
||||
slug: `rank_${id}`,
|
||||
title: name,
|
||||
description: "CMS role synchronized from permission_ranks",
|
||||
})
|
||||
.onDuplicateKeyUpdate({ set: { title: name } });
|
||||
await logStaffActivity({
|
||||
staffId: context.capability.actor.id,
|
||||
action: "rank_create",
|
||||
description: `Created rank #${id}`,
|
||||
targetType: "rank",
|
||||
targetId: id,
|
||||
const id = await prepareEmulatorRankCreation(db);
|
||||
const intent = {
|
||||
kind: "rank-permission-cache",
|
||||
operation,
|
||||
rankId: id,
|
||||
} as const satisfies SystemExternalSyncIntent;
|
||||
await db.transaction(async (tx) => {
|
||||
await createEmulatorRankInTransaction(tx, id, {
|
||||
rank_name: name,
|
||||
level,
|
||||
});
|
||||
await tx
|
||||
.insert(AclRole)
|
||||
.values({
|
||||
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 { id };
|
||||
return completeExternalSynchronization(intent, context, undefined, { id });
|
||||
}
|
||||
|
||||
const id = positiveInteger(data.id);
|
||||
if (operation === "access.rank.delete") {
|
||||
const [userCount] = await db
|
||||
.select({ total: count() })
|
||||
.from(User)
|
||||
.where(eq(User.rank, id));
|
||||
const users = Number(userCount?.total ?? 0);
|
||||
if (users > 0) {
|
||||
throw new SystemMutationFailure(
|
||||
"CONFLICT",
|
||||
"errors.housekeeping.system.rankInUse",
|
||||
{ rank: [String(users)] },
|
||||
);
|
||||
}
|
||||
const [role] = await db
|
||||
.select({ id: AclRole.id })
|
||||
.from(AclRole)
|
||||
.where(eq(AclRole.slug, `rank_${id}`))
|
||||
.limit(1);
|
||||
await deleteEmulatorRank(db, id);
|
||||
if (role) {
|
||||
await db.transaction(async (tx) => {
|
||||
const intent = {
|
||||
kind: "rank-permission-cache",
|
||||
operation,
|
||||
rankId: id,
|
||||
} as const satisfies SystemExternalSyncIntent;
|
||||
await db.transaction(async (tx) => {
|
||||
const [userCount] = await tx
|
||||
.select({ total: count() })
|
||||
.from(User)
|
||||
.where(eq(User.rank, id));
|
||||
const users = Number(userCount?.total ?? 0);
|
||||
if (users > 0) {
|
||||
throw new SystemMutationFailure(
|
||||
"CONFLICT",
|
||||
"errors.housekeeping.system.rankInUse",
|
||||
{ rank: [String(users)] },
|
||||
);
|
||||
}
|
||||
const [role] = await tx
|
||||
.select({ id: AclRole.id })
|
||||
.from(AclRole)
|
||||
.where(eq(AclRole.slug, `rank_${id}`))
|
||||
.limit(1);
|
||||
await deleteEmulatorRankInTransaction(tx, id);
|
||||
if (role) {
|
||||
await tx
|
||||
.delete(AclModelPermission)
|
||||
.where(
|
||||
@@ -299,17 +554,23 @@ async function executeAccessMutation(
|
||||
);
|
||||
await tx.delete(AclModelRole).where(eq(AclModelRole.roleId, role.id));
|
||||
await tx.delete(AclRole).where(eq(AclRole.id, role.id));
|
||||
});
|
||||
}
|
||||
await logStaffActivity({
|
||||
staffId: context.capability.actor.id,
|
||||
action: "rank_delete",
|
||||
description: `Deleted rank #${id}`,
|
||||
targetType: "rank",
|
||||
targetId: id,
|
||||
}
|
||||
await logStaffActivityInTransaction(
|
||||
{
|
||||
staffId: context.capability.actor.id,
|
||||
action: "rank_delete",
|
||||
description: `Deleted rank #${id}`,
|
||||
targetType: "rank",
|
||||
targetId: id,
|
||||
},
|
||||
tx,
|
||||
);
|
||||
await logAudit(
|
||||
syncAuditEntry(intent, context, context.correlationId, "intent"),
|
||||
tx,
|
||||
);
|
||||
});
|
||||
await requireRankPermissionSynchronization("access.rank.delete");
|
||||
return null;
|
||||
return completeExternalSynchronization(intent, context, undefined, { id });
|
||||
}
|
||||
|
||||
if (operation === "access.rank.update") {
|
||||
@@ -321,22 +582,35 @@ async function executeAccessMutation(
|
||||
: [],
|
||||
),
|
||||
);
|
||||
await updateEmulatorRank(db, id, normalizedFields);
|
||||
if (typeof normalizedFields.rank_name === "string") {
|
||||
await db
|
||||
.update(AclRole)
|
||||
.set({ title: normalizedFields.rank_name })
|
||||
.where(eq(AclRole.slug, `rank_${id}`));
|
||||
}
|
||||
await logStaffActivity({
|
||||
staffId: context.capability.actor.id,
|
||||
action: "rank_update",
|
||||
description: `Updated rank #${id}`,
|
||||
targetType: "rank",
|
||||
targetId: id,
|
||||
const intent = {
|
||||
kind: "rank-permission-cache",
|
||||
operation,
|
||||
rankId: id,
|
||||
} as const satisfies SystemExternalSyncIntent;
|
||||
await db.transaction(async (tx) => {
|
||||
await updateEmulatorRank(tx, id, normalizedFields);
|
||||
if (typeof normalizedFields.rank_name === "string") {
|
||||
await tx
|
||||
.update(AclRole)
|
||||
.set({ title: normalizedFields.rank_name })
|
||||
.where(eq(AclRole.slug, `rank_${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 null;
|
||||
return completeExternalSynchronization(intent, context, undefined, { id });
|
||||
}
|
||||
|
||||
if (operation === "access.permissions.update") {
|
||||
@@ -632,65 +906,79 @@ async function executeRconMutation(
|
||||
),
|
||||
);
|
||||
break;
|
||||
case "rcon.set-rank.retry":
|
||||
return retryExternalSynchronization("set-rank", data, context);
|
||||
case "rcon.set-rank": {
|
||||
const userId = positiveInteger(data.userId);
|
||||
const rank = positiveInteger(data.rank);
|
||||
const [target] = await db
|
||||
.select({ rank: User.rank })
|
||||
.from(User)
|
||||
.where(eq(User.id, userId))
|
||||
.limit(1);
|
||||
if (!target) {
|
||||
throw new SystemMutationFailure(
|
||||
"NOT_FOUND",
|
||||
"errors.housekeeping.system.userNotFound",
|
||||
);
|
||||
}
|
||||
let rankRows: { id: number }[] = [];
|
||||
try {
|
||||
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) {
|
||||
const intent = {
|
||||
kind: "set-rank",
|
||||
operation,
|
||||
userId,
|
||||
rank,
|
||||
} as const satisfies SystemExternalSyncIntent;
|
||||
await db.transaction(async (tx) => {
|
||||
const [target] = await tx
|
||||
.select({ rank: User.rank })
|
||||
.from(User)
|
||||
.where(eq(User.id, userId))
|
||||
.limit(1);
|
||||
if (!target) {
|
||||
throw new SystemMutationFailure(
|
||||
"FORBIDDEN",
|
||||
"errors.housekeeping.system.cannotChangePeerRank",
|
||||
"NOT_FOUND",
|
||||
"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(
|
||||
"FORBIDDEN",
|
||||
"errors.housekeeping.system.cannotAssignPeerRank",
|
||||
"NOT_FOUND",
|
||||
"errors.housekeeping.system.rankNotFound",
|
||||
);
|
||||
}
|
||||
}
|
||||
await requireRcon(await rcon.setRank(userId, rank));
|
||||
try {
|
||||
await db.update(User).set({ rank }).where(eq(User.id, userId));
|
||||
} catch {
|
||||
throw new SystemMutationFailure(
|
||||
"DEPENDENCY_UNAVAILABLE",
|
||||
"errors.housekeeping.system.rankPersistenceIncomplete",
|
||||
if (!context.capability.isSuperAdmin) {
|
||||
const actorRank = context.capability.actor.rank;
|
||||
if (target.rank >= actorRank) {
|
||||
throw new SystemMutationFailure(
|
||||
"FORBIDDEN",
|
||||
"errors.housekeeping.system.cannotChangePeerRank",
|
||||
);
|
||||
}
|
||||
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"],
|
||||
completed: ["emulator"],
|
||||
pending: ["database"],
|
||||
staffId: context.capability.actor.id,
|
||||
action: "rank_assign",
|
||||
description: `Assigned rank #${rank} to user #${userId}`,
|
||||
targetType: "user",
|
||||
targetId: userId,
|
||||
},
|
||||
tx,
|
||||
);
|
||||
}
|
||||
break;
|
||||
await logAudit(
|
||||
syncAuditEntry(intent, context, context.correlationId, "intent"),
|
||||
tx,
|
||||
);
|
||||
});
|
||||
return completeExternalSynchronization(intent, context, undefined, {
|
||||
userId,
|
||||
rank,
|
||||
});
|
||||
}
|
||||
case "rcon.execute-command":
|
||||
await requireRcon(
|
||||
|
||||
+249
-46
@@ -9,24 +9,39 @@ import type { HousekeepingCapabilityContext } from "../../../foundation/contract
|
||||
|
||||
const doubles = vi.hoisted(() => ({
|
||||
createEmulatorRank: vi.fn(),
|
||||
createEmulatorRankInTransaction: vi.fn(),
|
||||
dbDelete: vi.fn(),
|
||||
dbExecute: vi.fn(),
|
||||
dbInsert: vi.fn(),
|
||||
dbSelect: vi.fn(),
|
||||
dbTransaction: vi.fn(),
|
||||
dbUpdate: vi.fn(),
|
||||
deleteEmulatorRank: vi.fn(),
|
||||
deleteEmulatorRankInTransaction: vi.fn(),
|
||||
logAudit: vi.fn(),
|
||||
logStaffActivity: vi.fn(),
|
||||
logStaffActivityInTransaction: vi.fn(),
|
||||
prepareEmulatorRankCreation: vi.fn(),
|
||||
rconSend: vi.fn(),
|
||||
rconSetRank: 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) => {
|
||||
const actual = await importOriginal<typeof import("@/lib/db")>();
|
||||
return {
|
||||
...actual,
|
||||
db: {
|
||||
...actual.db,
|
||||
delete: doubles.dbDelete,
|
||||
execute: doubles.dbExecute,
|
||||
insert: doubles.dbInsert,
|
||||
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", () => ({
|
||||
createEmulatorRank: doubles.createEmulatorRank,
|
||||
createEmulatorRankInTransaction: doubles.createEmulatorRankInTransaction,
|
||||
deleteEmulatorRank: doubles.deleteEmulatorRank,
|
||||
deleteEmulatorRankInTransaction: doubles.deleteEmulatorRankInTransaction,
|
||||
prepareEmulatorRankCreation: doubles.prepareEmulatorRankCreation,
|
||||
updateEmulatorRank: doubles.updateEmulatorRank,
|
||||
}));
|
||||
|
||||
@@ -48,6 +70,7 @@ vi.mock("@/lib/services/rcon", () => ({
|
||||
|
||||
vi.mock("@/lib/services/staff-activity", () => ({
|
||||
logStaffActivity: doubles.logStaffActivity,
|
||||
logStaffActivityInTransaction: doubles.logStaffActivityInTransaction,
|
||||
}));
|
||||
|
||||
import { createSystemCommands } from "../commands/system-commands";
|
||||
@@ -73,6 +96,7 @@ function serviceContext(permission: string) {
|
||||
return {
|
||||
capability: capabilityContext([permission]),
|
||||
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[]) {
|
||||
return {
|
||||
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(() => {
|
||||
for (const mock of Object.values(doubles)) mock.mockReset();
|
||||
doubles.createEmulatorRank.mockResolvedValue(7);
|
||||
doubles.prepareEmulatorRankCreation.mockResolvedValue(7);
|
||||
doubles.createEmulatorRankInTransaction.mockResolvedValue(undefined);
|
||||
doubles.deleteEmulatorRank.mockResolvedValue(undefined);
|
||||
doubles.deleteEmulatorRankInTransaction.mockResolvedValue(undefined);
|
||||
doubles.updateEmulatorRank.mockResolvedValue(undefined);
|
||||
doubles.logAudit.mockResolvedValue(undefined);
|
||||
doubles.logStaffActivity.mockResolvedValue(undefined);
|
||||
doubles.logStaffActivityInTransaction.mockResolvedValue(undefined);
|
||||
doubles.dbDelete.mockImplementation(deleteChain);
|
||||
doubles.dbInsert.mockImplementation(insertChain);
|
||||
doubles.dbTransaction.mockImplementation(async (run) =>
|
||||
run(transactionToken),
|
||||
);
|
||||
doubles.dbUpdate.mockImplementation(() => updateChain());
|
||||
});
|
||||
|
||||
describe("rank synchronization failures", () => {
|
||||
it("returns failure and audits failure when create committed but updatepermissions returned false", async () => {
|
||||
function expectPendingSynchronization(
|
||||
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);
|
||||
const command = createSystemCommands(systemMutationService).find(
|
||||
(entry) => entry.id === "system.access.rank.create",
|
||||
@@ -137,19 +230,24 @@ describe("rank synchronization failures", () => {
|
||||
},
|
||||
);
|
||||
|
||||
expect(result).toMatchObject({
|
||||
ok: false,
|
||||
error: {
|
||||
code: "DEPENDENCY_UNAVAILABLE",
|
||||
messageKey: "errors.housekeeping.system.rankSynchronizationIncomplete",
|
||||
fieldErrors: {
|
||||
operation: ["access.rank.create"],
|
||||
completed: ["database", "audit"],
|
||||
pending: ["emulator-permission-cache"],
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(outcomes).toEqual(["intent", "failure"]);
|
||||
expectPendingSynchronization(result, "access.rank.create");
|
||||
expect(outcomes).toEqual(["intent", "partial"]);
|
||||
expect(doubles.createEmulatorRankInTransaction).toHaveBeenCalledWith(
|
||||
transactionToken,
|
||||
7,
|
||||
{ rank_name: "Administrator", level: 7 },
|
||||
);
|
||||
expect(doubles.logStaffActivityInTransaction).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ action: "rank_create", targetId: 7 }),
|
||||
transactionToken,
|
||||
);
|
||||
expect(doubles.logAudit).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
correlationId: expect.any(String),
|
||||
outcome: "intent",
|
||||
}),
|
||||
transactionToken,
|
||||
);
|
||||
});
|
||||
|
||||
it.each([
|
||||
@@ -161,11 +259,17 @@ describe("rank synchronization failures", () => {
|
||||
.mockImplementationOnce(() => plainSelection([{ total: 0 }]))
|
||||
.mockImplementationOnce(() => limitedSelection([]));
|
||||
},
|
||||
"deleteEmulatorRankInTransaction",
|
||||
],
|
||||
[
|
||||
"access.rank.update",
|
||||
{ id: 7, fields: { badge: "ADM" } },
|
||||
() => {},
|
||||
"updateEmulatorRank",
|
||||
],
|
||||
["access.rank.update", { id: 7, fields: { badge: "ADM" } }, () => {}],
|
||||
] as const)(
|
||||
"returns failure when %s committed but updatepermissions returned false",
|
||||
async (operation, input, prepare) => {
|
||||
"returns a recoverable partial after atomic %s commit when cache refresh fails",
|
||||
async (operation, input, prepare, helperName) => {
|
||||
prepare();
|
||||
doubles.rconSend.mockResolvedValue(false);
|
||||
|
||||
@@ -175,33 +279,23 @@ describe("rank synchronization failures", () => {
|
||||
input,
|
||||
);
|
||||
|
||||
expect(result).toMatchObject({
|
||||
ok: false,
|
||||
error: {
|
||||
code: "DEPENDENCY_UNAVAILABLE",
|
||||
messageKey:
|
||||
"errors.housekeeping.system.rankSynchronizationIncomplete",
|
||||
fieldErrors: {
|
||||
operation: [operation],
|
||||
completed: ["database", "audit"],
|
||||
pending: ["emulator-permission-cache"],
|
||||
},
|
||||
},
|
||||
});
|
||||
expectPendingSynchronization(result, operation);
|
||||
expect(doubles[helperName]).toHaveBeenCalledWith(
|
||||
transactionToken,
|
||||
7,
|
||||
...(operation === "access.rank.update" ? [{ badge: "ADM" }] : []),
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
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(() =>
|
||||
limitedSelection([{ rank: 3 }]),
|
||||
);
|
||||
doubles.dbExecute.mockResolvedValue([[{ id: 4 }]]);
|
||||
doubles.rconSetRank.mockResolvedValue(true);
|
||||
doubles.dbUpdate.mockReturnValue({
|
||||
set: () => ({
|
||||
where: () => Promise.reject(new Error("users update unavailable")),
|
||||
}),
|
||||
});
|
||||
doubles.dbUpdate.mockImplementationOnce(() =>
|
||||
updateChain(Promise.reject(new Error("users update unavailable"))),
|
||||
);
|
||||
|
||||
const result = await systemMutationService.execute(
|
||||
serviceContext(PERMS.RCON_EXECUTE),
|
||||
@@ -211,15 +305,124 @@ describe("rank synchronization failures", () => {
|
||||
|
||||
expect(result).toMatchObject({
|
||||
ok: false,
|
||||
error: {
|
||||
code: "DEPENDENCY_UNAVAILABLE",
|
||||
messageKey: "errors.housekeeping.system.rankPersistenceIncomplete",
|
||||
fieldErrors: {
|
||||
operation: ["rcon.set-rank"],
|
||||
completed: ["emulator"],
|
||||
pending: ["database"],
|
||||
error: { code: "DEPENDENCY_UNAVAILABLE" },
|
||||
});
|
||||
expect(doubles.rconSetRank).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("returns the durable recovery key when set-rank RCON fails after commit", async () => {
|
||||
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();
|
||||
});
|
||||
});
|
||||
@@ -3,6 +3,8 @@ import { MySqlDialect } from "drizzle-orm/mysql-core";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
createEmulatorRankInTransaction,
|
||||
deleteEmulatorRankInTransaction,
|
||||
fetchEmulatorRankForEdit,
|
||||
getRankPermissionColumn,
|
||||
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", () => {
|
||||
it("loads permissions from the legacy table when permission_definitions is absent", async () => {
|
||||
const db = {
|
||||
|
||||
@@ -43,15 +43,16 @@ export interface EmulatorRankForEdit extends EmulatorRankSummary {
|
||||
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">;
|
||||
|
||||
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);
|
||||
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);
|
||||
return (result as ResultSetHeader).affectedRows;
|
||||
}
|
||||
@@ -195,6 +196,19 @@ export async function fetchEmulatorRankForEdit(
|
||||
export async function createEmulatorRank(
|
||||
db: RawDb,
|
||||
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> {
|
||||
const rows = await rawRows<{ next_id: number }[]>(
|
||||
db,
|
||||
@@ -203,38 +217,46 @@ export async function createEmulatorRank(
|
||||
const id = Number(rows[0]?.next_id ?? 1);
|
||||
await ensurePermissionRankColumn(db, id);
|
||||
await resetPermissionRankColumn(db, id);
|
||||
return id;
|
||||
}
|
||||
|
||||
await db.transaction(async (tx) => {
|
||||
await rawExec(
|
||||
tx,
|
||||
sql`
|
||||
export async function createEmulatorRankInTransaction(
|
||||
db: SqlExecutor,
|
||||
id: number,
|
||||
data: { rank_name: string; level: number },
|
||||
): Promise<void> {
|
||||
await rawExec(
|
||||
db,
|
||||
sql`
|
||||
INSERT INTO permission_ranks (id, rank_name, level)
|
||||
VALUES (${id}, ${data.rank_name}, ${data.level})
|
||||
`,
|
||||
);
|
||||
await rawExec(
|
||||
tx,
|
||||
sql`
|
||||
);
|
||||
await rawExec(
|
||||
db,
|
||||
sql`
|
||||
INSERT INTO permissions (id, rank_name, level)
|
||||
VALUES (${id}, ${data.rank_name}, ${data.level})
|
||||
ON DUPLICATE KEY UPDATE rank_name = VALUES(rank_name), level = VALUES(level)
|
||||
`,
|
||||
);
|
||||
});
|
||||
|
||||
return id;
|
||||
);
|
||||
}
|
||||
|
||||
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 db.transaction(async (tx) => {
|
||||
await rawExec(tx, sql`DELETE FROM permission_ranks WHERE id = ${rankId}`);
|
||||
await rawExec(tx, sql`DELETE FROM permissions WHERE id = ${rankId}`);
|
||||
});
|
||||
await rawExec(db, sql`DELETE FROM permission_ranks WHERE id = ${rankId}`);
|
||||
await rawExec(db, sql`DELETE FROM permissions WHERE id = ${rankId}`);
|
||||
}
|
||||
|
||||
export async function updateEmulatorRank(
|
||||
db: RawDb,
|
||||
db: SqlExecutor,
|
||||
rankId: number,
|
||||
fields: Record<string, string | number>,
|
||||
) {
|
||||
@@ -252,7 +274,7 @@ export async function updateEmulatorRank(
|
||||
}
|
||||
|
||||
async function updateTableFields(
|
||||
db: RawDb,
|
||||
db: SqlExecutor,
|
||||
table: string,
|
||||
rankId: number,
|
||||
fields: Record<string, string | number>,
|
||||
@@ -271,7 +293,7 @@ async function updateTableFields(
|
||||
}
|
||||
|
||||
async function updatePermissionDefinitionValues(
|
||||
db: RawDb,
|
||||
db: SqlExecutor,
|
||||
rankId: number,
|
||||
permissionFields: Record<string, number>,
|
||||
) {
|
||||
@@ -297,7 +319,7 @@ async function updatePermissionDefinitionValues(
|
||||
}
|
||||
|
||||
async function updateLegacyPermissionFields(
|
||||
db: RawDb,
|
||||
db: SqlExecutor,
|
||||
rankId: number,
|
||||
fields: Record<string, string | number>,
|
||||
) {
|
||||
@@ -309,7 +331,7 @@ async function updateLegacyPermissionFields(
|
||||
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 exists = await rawRows<{ c: number }[]>(
|
||||
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);
|
||||
await rawExec(
|
||||
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 }[]>(
|
||||
db,
|
||||
sql`
|
||||
|
||||
@@ -1,37 +1,54 @@
|
||||
import { headers } from "next/headers";
|
||||
import { db, StaffActivities } from "@/lib/db";
|
||||
import { type Db, db, StaffActivities } from "@/lib/db";
|
||||
|
||||
/**
|
||||
* Append a staff-action audit entry (AtomCMS StaffActivity). Never throws —
|
||||
* logging must not block the action it records.
|
||||
*/
|
||||
export async function logStaffActivity(opts: {
|
||||
interface StaffActivityOptions {
|
||||
staffId: number;
|
||||
action: string;
|
||||
description: string;
|
||||
targetType?: string;
|
||||
targetId?: number;
|
||||
}): Promise<void> {
|
||||
}
|
||||
|
||||
type StaffActivityWriter = Pick<Db, "insert">;
|
||||
|
||||
async function resolveStaffIp(): Promise<string | null> {
|
||||
try {
|
||||
let ip: string | null = null;
|
||||
try {
|
||||
const h = await headers();
|
||||
ip =
|
||||
h.get("x-real-client-ip") ??
|
||||
h.get("x-forwarded-for")?.split(",")[0]?.trim() ??
|
||||
null;
|
||||
} catch {
|
||||
ip = null;
|
||||
}
|
||||
await db.insert(StaffActivities).values({
|
||||
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: ip,
|
||||
createdAt: new Date(),
|
||||
});
|
||||
const h = await headers();
|
||||
return (
|
||||
h.get("x-real-client-ip") ??
|
||||
h.get("x-forwarded-for")?.split(",")[0]?.trim() ??
|
||||
null
|
||||
);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** Write an audit row through the caller's transaction and propagate failure. */
|
||||
export async function logStaffActivityInTransaction(
|
||||
opts: StaffActivityOptions,
|
||||
transaction: StaffActivityWriter,
|
||||
): Promise<void> {
|
||||
await transaction.insert(StaffActivities).values({
|
||||
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 {
|
||||
// swallow — audit logging is best-effort
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user