fix(housekeeping): coordinate all rank mutation paths
This commit is contained in:
1 parent
3d53321575
commit
866f38818b
14 files changed
+1070
-317
No files matched your search
@@ -0,0 +1,52 @@
|
||||
# Housekeeping rank synchronization
|
||||
|
||||
## Behavior
|
||||
|
||||
Rank assignments commit the configured rank, user update, and audit intent before
|
||||
attempting emulator synchronization. Assignment and rank deletion acquire the
|
||||
configured-rank row lock first. Delivery acquires the user row lock, reads the
|
||||
current database rank, and holds that lock until the transport settles.
|
||||
|
||||
Retrying an old recovery reference therefore dispatches the current committed
|
||||
rank rather than replaying the rank stored in the old audit record. A user deleted
|
||||
after persistence produces an audited superseded result. SQL lock errors remain
|
||||
dependency failures rather than being reported as missing ranks.
|
||||
|
||||
The coordinated paths cover System operations, People user editing, the legacy
|
||||
command centre, legacy user editing, the user-actions API, legacy rank deletion,
|
||||
and shop rank upgrades. Administrative user creation also locks the selected rank.
|
||||
Shop upgrades compare the locked current rank so they cannot overwrite a newer
|
||||
staff promotion. A failed post-purchase rank delivery leaves a recovery intent
|
||||
without turning the completed purchase into another charge.
|
||||
|
||||
People and legacy responses preserve partial-completion information and recovery
|
||||
references. Failure of the completion audit alone does not misreport successful
|
||||
transport delivery as a transport failure.
|
||||
|
||||
## Verification
|
||||
|
||||
- Focused rank, legacy-entrypoint, shop, System and People tests: 94 passed.
|
||||
- Full suite: 280 files passed, 3 skipped; 1,861 tests passed, 5 skipped.
|
||||
- Next.js 16.3.4 production build passed and generated all 245 pages.
|
||||
- TypeScript and canonical Knip checks passed.
|
||||
- Biome passed on all 13 changed source/test files.
|
||||
- Project-source lint without formatting passed on 1,412 files, with one existing
|
||||
Catalog Studio warning. The full Windows checkout check also includes local
|
||||
untracked brainstorm HTML and reports CRLF/LF formatting differences; those
|
||||
local files were preserved and are not part of this change.
|
||||
- Migration matrix: 138 historical rows valid; 138 legacy pages retained;
|
||||
runtime discovered/mapped/verified 138/138/138, with 2 intentional removals.
|
||||
- Independent read-only review found no remaining Important or Critical issues.
|
||||
|
||||
## Validation boundary
|
||||
|
||||
Tests exercise the parameterized locking SQL and controlled transaction/transport
|
||||
ordering. No live two-connection MariaDB race test or production emulator
|
||||
acceptance test was performed.
|
||||
|
||||
TCP RCON success means the socket write completed. CMS dispatch is serialized,
|
||||
but the current protocol does not acknowledge emulator processing or enforce its
|
||||
processing order. End-to-end confirmation would require an emulator protocol
|
||||
change. The existing transport timeout and retry policy bounds the delivery wait.
|
||||
|
||||
The PR remains draft; these checks are not a deployment or preview-cutover claim.
|
||||
@@ -1,9 +1,10 @@
|
||||
"use server";
|
||||
|
||||
import { eq, sql } from "drizzle-orm";
|
||||
import crypto from "node:crypto";
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { z } from "zod";
|
||||
import { db, User } from "@/lib/db";
|
||||
import { systemMutationService } from "@/features/housekeeping/domains/system/services/mutations";
|
||||
import { createHousekeepingCapabilityContext } from "@/features/housekeeping/foundation/capability-context";
|
||||
import { PERMS } from "@/lib/permissions";
|
||||
import { adminAction } from "@/lib/safe-action";
|
||||
import { ActionError, actionOk } from "@/lib/safe-action-shared";
|
||||
@@ -198,44 +199,28 @@ const setRankSchema = z.object({
|
||||
export const setRank = adminAction(
|
||||
{ permission: PERMS.RCON_EXECUTE, schema: setRankSchema },
|
||||
async (ctx) => {
|
||||
const staffRank = Number(ctx.session.user.rank);
|
||||
const isSuper = ctx.permissions.isSuperAdmin;
|
||||
const [target] = await db
|
||||
.select({ rank: User.rank })
|
||||
.from(User)
|
||||
.where(eq(User.id, ctx.data.userId))
|
||||
.limit(1);
|
||||
if (!target) throw new ActionError("User not found");
|
||||
|
||||
let rankExists: { id: number }[] = [];
|
||||
try {
|
||||
const [rows] = await db.execute(
|
||||
sql`SELECT id FROM permission_ranks WHERE id = ${ctx.data.rank} LIMIT 1`,
|
||||
);
|
||||
rankExists = rows as unknown as { id: number }[];
|
||||
} catch {
|
||||
rankExists = [];
|
||||
}
|
||||
if (rankExists.length === 0) throw new ActionError("Rank does not exist");
|
||||
|
||||
if (!isSuper) {
|
||||
if (target.rank >= staffRank) {
|
||||
throw new ActionError(
|
||||
"Cannot change rank of a user at or above your rank",
|
||||
);
|
||||
}
|
||||
if (ctx.data.rank >= staffRank) {
|
||||
throw new ActionError("Cannot set a rank equal to or above your own");
|
||||
}
|
||||
}
|
||||
|
||||
await requireRconOk(await rcon.setRank(ctx.data.userId, ctx.data.rank));
|
||||
await db
|
||||
.update(User)
|
||||
.set({ rank: ctx.data.rank })
|
||||
.where(eq(User.id, ctx.data.userId));
|
||||
const result = await systemMutationService.execute(
|
||||
{
|
||||
capability: createHousekeepingCapabilityContext(
|
||||
{
|
||||
id: Number(ctx.session.user.id),
|
||||
username: ctx.session.user.username,
|
||||
rank: Number(ctx.session.user.rank),
|
||||
},
|
||||
ctx.permissions,
|
||||
),
|
||||
correlationId: crypto.randomUUID(),
|
||||
},
|
||||
"rcon.set-rank",
|
||||
ctx.data,
|
||||
);
|
||||
if (!result.ok) throw new ActionError(result.error.messageKey);
|
||||
revalidatePath(PATH);
|
||||
return actionOk();
|
||||
if (result.completion)
|
||||
throw new ActionError(
|
||||
`Rank saved; emulator synchronization is pending. ASE recovery reference: ${result.correlationId}`,
|
||||
);
|
||||
return actionOk(result.data as Record<string, unknown>);
|
||||
},
|
||||
);
|
||||
|
||||
|
||||
+25
-45
@@ -1,23 +1,18 @@
|
||||
"use server";
|
||||
|
||||
import { and, count, eq, inArray, sql } from "drizzle-orm";
|
||||
import crypto from "node:crypto";
|
||||
import { and, eq, inArray, sql } from "drizzle-orm";
|
||||
import type { ResultSetHeader } from "mysql2";
|
||||
import { revalidateTag } from "next/cache";
|
||||
import { z } from "zod";
|
||||
import {
|
||||
AclModelPermission,
|
||||
AclModelRole,
|
||||
AclPermission,
|
||||
AclRole,
|
||||
db,
|
||||
User,
|
||||
} from "@/lib/db";
|
||||
import { systemMutationService } from "@/features/housekeeping/domains/system/services/mutations";
|
||||
import { createHousekeepingCapabilityContext } from "@/features/housekeeping/foundation/capability-context";
|
||||
import { AclModelPermission, AclPermission, AclRole, db } from "@/lib/db";
|
||||
import { PERMS } from "@/lib/permission-slugs";
|
||||
import { adminAction } from "@/lib/safe-action";
|
||||
import { ActionError, actionOk } from "@/lib/safe-action-shared";
|
||||
import {
|
||||
createEmulatorRank,
|
||||
deleteEmulatorRank,
|
||||
updateEmulatorRank,
|
||||
} from "@/lib/services/permission-ranks";
|
||||
import { rcon } from "@/lib/services/rcon";
|
||||
@@ -58,42 +53,27 @@ const deleteRankSchema = z.object({ id: z.coerce.number().int().positive() });
|
||||
export const deleteRank = adminAction(
|
||||
{ schema: deleteRankSchema, permission: PERMS.PERMISSIONS_MANAGE },
|
||||
async (ctx) => {
|
||||
const [userCount] = await db
|
||||
.select({ total: count() })
|
||||
.from(User)
|
||||
.where(eq(User.rank, ctx.data.id));
|
||||
const users = userCount?.total ?? 0;
|
||||
if (users > 0)
|
||||
throw new ActionError(`Cannot delete: ${users} users have this rank`);
|
||||
const [role] = await db
|
||||
.select({ id: AclRole.id })
|
||||
.from(AclRole)
|
||||
.where(eq(AclRole.slug, `rank_${ctx.data.id}`))
|
||||
.limit(1);
|
||||
await deleteEmulatorRank(db, ctx.data.id);
|
||||
if (role) {
|
||||
await db.transaction(async (tx) => {
|
||||
await tx
|
||||
.delete(AclModelPermission)
|
||||
.where(
|
||||
and(
|
||||
eq(AclModelPermission.modelId, role.id),
|
||||
eq(AclModelPermission.modelType, "Role"),
|
||||
),
|
||||
);
|
||||
await tx.delete(AclModelRole).where(eq(AclModelRole.roleId, role.id));
|
||||
await tx.delete(AclRole).where(eq(AclRole.id, role.id));
|
||||
});
|
||||
}
|
||||
await logStaffActivity({
|
||||
staffId: ctx.session.user.id,
|
||||
action: "rank_delete",
|
||||
description: `Deleted rank #${ctx.data.id}`,
|
||||
targetType: "rank",
|
||||
targetId: ctx.data.id,
|
||||
});
|
||||
await rcon.send("updatepermissions");
|
||||
const result = await systemMutationService.execute(
|
||||
{
|
||||
capability: createHousekeepingCapabilityContext(
|
||||
{
|
||||
id: Number(ctx.session.user.id),
|
||||
username: ctx.session.user.username,
|
||||
rank: Number(ctx.session.user.rank),
|
||||
},
|
||||
ctx.permissions,
|
||||
),
|
||||
correlationId: crypto.randomUUID(),
|
||||
},
|
||||
"access.rank.delete",
|
||||
ctx.data,
|
||||
);
|
||||
if (!result.ok) throw new ActionError(result.error.messageKey);
|
||||
revalidateTag("permissions", { expire: 0 });
|
||||
if (result.completion)
|
||||
throw new ActionError(
|
||||
`Rank deleted; emulator synchronization is pending. ASE recovery reference: ${result.correlationId}`,
|
||||
);
|
||||
return actionOk();
|
||||
},
|
||||
);
|
||||
|
||||
@@ -0,0 +1,148 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const d = vi.hoisted(() => ({
|
||||
rows: [] as unknown[][],
|
||||
events: [] as string[],
|
||||
set: vi.fn(),
|
||||
execute: vi.fn(),
|
||||
audit: vi.fn(),
|
||||
synchronize: vi.fn(),
|
||||
}));
|
||||
vi.mock("server-only", () => ({}));
|
||||
vi.mock("@/lib/auth", () => ({ auth: async () => ({ user: { id: 8 } }) }));
|
||||
vi.mock("@/lib/rate-limit", () => ({
|
||||
clientIp: async () => "local",
|
||||
rateLimit: async () => ({ ok: true }),
|
||||
}));
|
||||
vi.mock("@/lib/services/paypal", () => ({ creditsPerUnit: () => 10 }));
|
||||
vi.mock("@/lib/services/send-currency", () => ({
|
||||
currencyDb: {},
|
||||
sendCurrency: vi.fn(),
|
||||
}));
|
||||
vi.mock("@/lib/services/rcon", () => ({ rcon: { giveBadge: vi.fn() } }));
|
||||
vi.mock("@/lib/services/audit", () => ({ logAudit: d.audit }));
|
||||
vi.mock("next/cache", () => ({ revalidatePath: vi.fn() }));
|
||||
vi.mock("next/navigation", () => ({
|
||||
redirect: (url: string) => {
|
||||
throw Object.assign(new Error(url), { digest: "NEXT_REDIRECT" });
|
||||
},
|
||||
}));
|
||||
vi.mock("@/lib/db", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("@/lib/db")>();
|
||||
const facade = {
|
||||
execute: d.execute,
|
||||
select: () => ({
|
||||
from: () => ({
|
||||
where: () => ({
|
||||
limit: async () => d.rows.shift() ?? [],
|
||||
for: async () => {
|
||||
d.events.push("user-lock");
|
||||
return d.rows.shift() ?? [];
|
||||
},
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
update: () => ({ set: d.set }),
|
||||
};
|
||||
return {
|
||||
...actual,
|
||||
db: {
|
||||
...facade,
|
||||
transaction: async (run: (tx: typeof facade) => unknown) => {
|
||||
const result = await run(facade);
|
||||
d.events.push("commit");
|
||||
return result;
|
||||
},
|
||||
},
|
||||
};
|
||||
});
|
||||
vi.mock("@/lib/services/rank-assignment", async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import("@/lib/services/rank-assignment")>()),
|
||||
rankAssignmentCoordinator: { synchronize: d.synchronize },
|
||||
}));
|
||||
|
||||
import { buyShopArticle } from "./shop";
|
||||
|
||||
function input() {
|
||||
const data = new FormData();
|
||||
data.set("articleId", "1");
|
||||
data.set("categoryId", "1");
|
||||
return data;
|
||||
}
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
d.rows.length = 0;
|
||||
d.events.length = 0;
|
||||
d.rows.push(
|
||||
[
|
||||
{
|
||||
id: 1,
|
||||
name: "Member",
|
||||
costs: 100,
|
||||
giveRank: 4,
|
||||
badges: "",
|
||||
credits: 0,
|
||||
duckets: 0,
|
||||
diamonds: 0,
|
||||
},
|
||||
],
|
||||
[{ credits: 100, rank: 2 }],
|
||||
);
|
||||
d.execute.mockImplementation(async () => {
|
||||
d.events.push("rank-lock");
|
||||
return [[{ id: 4 }]];
|
||||
});
|
||||
d.set.mockImplementation(() => ({
|
||||
where: async () => {
|
||||
d.events.push("update");
|
||||
},
|
||||
}));
|
||||
d.audit.mockResolvedValue(undefined);
|
||||
d.synchronize.mockImplementation(async () => {
|
||||
d.events.push("delivery");
|
||||
return { status: "delivered", rank: 4 };
|
||||
});
|
||||
});
|
||||
|
||||
describe("shop rank coordination", () => {
|
||||
it("locks rank before user, commits the purchase, then synchronizes", async () => {
|
||||
d.rows.push([{ credits: 100, rank: 2 }]);
|
||||
await expect(buyShopArticle(input())).rejects.toThrow("bought=1");
|
||||
expect(d.events).toEqual([
|
||||
"rank-lock",
|
||||
"user-lock",
|
||||
"update",
|
||||
"update",
|
||||
"commit",
|
||||
"delivery",
|
||||
]);
|
||||
expect(d.set).toHaveBeenCalledWith({ rank: 4 });
|
||||
expect(d.audit.mock.calls.map(([entry]) => entry.outcome)).toEqual([
|
||||
"intent",
|
||||
"success",
|
||||
]);
|
||||
});
|
||||
it("does not overwrite a newer staff promotion with the previously observed buyer rank", async () => {
|
||||
d.rows.push([{ credits: 100, rank: 6 }]);
|
||||
await expect(buyShopArticle(input())).rejects.toThrow("bought=1");
|
||||
expect(d.set).not.toHaveBeenCalledWith({ rank: 4 });
|
||||
expect(d.synchronize).not.toHaveBeenCalled();
|
||||
});
|
||||
it("does not charge when the configured package rank was deleted", async () => {
|
||||
d.execute.mockResolvedValueOnce([[]]);
|
||||
await expect(buyShopArticle(input())).rejects.toThrow("error=error");
|
||||
expect(d.set).not.toHaveBeenCalled();
|
||||
expect(d.synchronize).not.toHaveBeenCalled();
|
||||
});
|
||||
it("keeps a durable partial audit without turning a committed purchase into a retry", async () => {
|
||||
d.rows.push([{ credits: 100, rank: 2 }]);
|
||||
d.synchronize.mockResolvedValueOnce({ status: "pending", rank: 4 });
|
||||
await expect(buyShopArticle(input())).rejects.toThrow("bought=1");
|
||||
expect(d.audit).toHaveBeenLastCalledWith(
|
||||
expect.objectContaining({
|
||||
outcome: "partial",
|
||||
correlationId: expect.any(String),
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
+61
-1
@@ -1,5 +1,6 @@
|
||||
"use server";
|
||||
|
||||
import crypto from "node:crypto";
|
||||
import { and, eq, max, sql } from "drizzle-orm";
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { redirect } from "next/navigation";
|
||||
@@ -7,7 +8,12 @@ import { auth } from "@/lib/auth";
|
||||
import { db, User, UsersBadges, WebsiteShopArticles } from "@/lib/db";
|
||||
import { clientIp, rateLimit } from "@/lib/rate-limit";
|
||||
import { logServerError } from "@/lib/server-log";
|
||||
import { logAudit } from "@/lib/services/audit";
|
||||
import { creditsPerUnit } from "@/lib/services/paypal";
|
||||
import {
|
||||
lockConfiguredRank,
|
||||
rankAssignmentCoordinator,
|
||||
} from "@/lib/services/rank-assignment";
|
||||
import { rcon } from "@/lib/services/rcon";
|
||||
import { currencyDb, sendCurrency } from "@/lib/services/send-currency";
|
||||
|
||||
@@ -118,8 +124,38 @@ export async function buyShopArticle(formData: FormData): Promise<void> {
|
||||
outcome = "credits";
|
||||
} else {
|
||||
const badgeCodes = parseBadgeCodes(article.badges);
|
||||
let rankChanged = false;
|
||||
const rankRecoveryId = crypto.randomUUID();
|
||||
const rankAudit = {
|
||||
userId,
|
||||
action: "system.external-sync",
|
||||
target: "rcon.set-rank",
|
||||
targetId: userId,
|
||||
correlationId: rankRecoveryId,
|
||||
domain: "system" as const,
|
||||
after: {
|
||||
kind: "set-rank",
|
||||
operation: "rcon.set-rank",
|
||||
userId,
|
||||
rank: article.giveRank,
|
||||
},
|
||||
};
|
||||
|
||||
await db.transaction(async (tx) => {
|
||||
if (
|
||||
article.giveRank != null &&
|
||||
article.giveRank > 0 &&
|
||||
!(await lockConfiguredRank(tx, article.giveRank))
|
||||
) {
|
||||
throw new Error("Package rank no longer exists");
|
||||
}
|
||||
const [lockedBuyer] = await tx
|
||||
.select({ credits: User.credits, rank: User.rank })
|
||||
.from(User)
|
||||
.where(eq(User.id, userId))
|
||||
.for("update");
|
||||
if (!lockedBuyer || lockedBuyer.credits < price)
|
||||
throw new Error("Insufficient credits");
|
||||
if (price > 0) {
|
||||
await tx
|
||||
.update(User)
|
||||
@@ -130,12 +166,14 @@ export async function buyShopArticle(formData: FormData): Promise<void> {
|
||||
if (
|
||||
article.giveRank != null &&
|
||||
article.giveRank > 0 &&
|
||||
article.giveRank > buyer.rank
|
||||
article.giveRank > lockedBuyer.rank
|
||||
) {
|
||||
await tx
|
||||
.update(User)
|
||||
.set({ rank: article.giveRank })
|
||||
.where(eq(User.id, userId));
|
||||
rankChanged = true;
|
||||
await logAudit({ ...rankAudit, outcome: "intent" }, tx);
|
||||
}
|
||||
|
||||
for (const code of badgeCodes) {
|
||||
@@ -164,6 +202,28 @@ export async function buyShopArticle(formData: FormData): Promise<void> {
|
||||
}
|
||||
});
|
||||
|
||||
if (rankChanged) {
|
||||
try {
|
||||
const delivery =
|
||||
await rankAssignmentCoordinator.synchronize(userId);
|
||||
await logAudit({
|
||||
...rankAudit,
|
||||
after: {
|
||||
...rankAudit.after,
|
||||
...(delivery.status === "superseded"
|
||||
? { superseded: true }
|
||||
: { rank: delivery.rank }),
|
||||
},
|
||||
outcome:
|
||||
delivery.status === "pending" ? "partial" : "success",
|
||||
});
|
||||
} catch (error) {
|
||||
logServerError("shop.rank_sync_pending", error, {
|
||||
userId,
|
||||
correlationId: rankRecoveryId,
|
||||
});
|
||||
}
|
||||
}
|
||||
await sendCurrency(
|
||||
{ rcon, db: currencyDb },
|
||||
userId,
|
||||
|
||||
+19
-60
@@ -3,6 +3,7 @@
|
||||
import crypto from "node:crypto";
|
||||
import { and, eq } from "drizzle-orm";
|
||||
import { z } from "zod";
|
||||
import { peopleMutationService } from "@/features/housekeeping/domains/people/services/mutations";
|
||||
import { invalidateLoginCache } from "@/lib/auth";
|
||||
import { hashPassword } from "@/lib/auth/password";
|
||||
import {
|
||||
@@ -17,6 +18,7 @@ import { PERMS } from "@/lib/permissions";
|
||||
import { adminAction } from "@/lib/safe-action";
|
||||
import { ActionError, actionOk } from "@/lib/safe-action-shared";
|
||||
import { logAudit } from "@/lib/services/audit";
|
||||
import { lockConfiguredRank } from "@/lib/services/rank-assignment";
|
||||
import { rcon } from "@/lib/services/rcon";
|
||||
import { notify } from "@/lib/services/webhook";
|
||||
import {
|
||||
@@ -55,7 +57,7 @@ export const createUser = adminAction(
|
||||
async (ctx) => {
|
||||
const { username, mail, password, rank, motto } = ctx.data;
|
||||
|
||||
if (rank >= ctx.session.user.rank && ctx.session.user.rank < 7) {
|
||||
if (rank >= ctx.session.user.rank && !ctx.permissions.isSuperAdmin) {
|
||||
throw new ActionError("Cannot assign rank equal or higher than your own");
|
||||
}
|
||||
|
||||
@@ -64,6 +66,9 @@ export const createUser = adminAction(
|
||||
|
||||
try {
|
||||
const user = await db.transaction(async (tx) => {
|
||||
if (!(await lockConfiguredRank(tx, rank))) {
|
||||
throw new ActionError("Rank does not exist");
|
||||
}
|
||||
const [result] = await tx.insert(User).values({
|
||||
username,
|
||||
mail,
|
||||
@@ -123,66 +128,20 @@ const updateUserInput = updateUserSchema.extend({
|
||||
export const updateUser = adminAction(
|
||||
{ permission: PERMS.USERS_EDIT, schema: updateUserInput },
|
||||
async (ctx) => {
|
||||
const { id, diamonds, duckets, ...userData } = ctx.data;
|
||||
|
||||
const targetUser = await guardRank(id, ctx.session.user.rank);
|
||||
|
||||
if (
|
||||
userData.rank !== undefined &&
|
||||
userData.rank >= ctx.session.user.rank &&
|
||||
ctx.session.user.rank < 7
|
||||
) {
|
||||
throw new ActionError("Cannot assign rank equal or higher than your own");
|
||||
}
|
||||
|
||||
const patch = Object.fromEntries(
|
||||
Object.entries(userData).filter(([, v]) => v !== undefined),
|
||||
) as Partial<{
|
||||
username: string;
|
||||
mail: string;
|
||||
rank: number;
|
||||
motto: string;
|
||||
credits: number;
|
||||
pixels: number;
|
||||
}>;
|
||||
if (Object.keys(patch).length > 0) {
|
||||
await db.update(User).set(patch).where(eq(User.id, id));
|
||||
}
|
||||
invalidateLoginCache(targetUser.username);
|
||||
|
||||
if (diamonds !== undefined) {
|
||||
await db
|
||||
.insert(UsersCurrency)
|
||||
.values({ userId: id, type: 5, amount: diamonds })
|
||||
.onDuplicateKeyUpdate({ set: { amount: diamonds } });
|
||||
}
|
||||
if (duckets !== undefined) {
|
||||
await db
|
||||
.insert(UsersCurrency)
|
||||
.values({ userId: id, type: 0, amount: duckets })
|
||||
.onDuplicateKeyUpdate({ set: { amount: duckets } });
|
||||
}
|
||||
|
||||
logAudit({
|
||||
userId: ctx.session.user.id,
|
||||
action: "user_edit",
|
||||
target: "User",
|
||||
targetId: id,
|
||||
before: {
|
||||
username: targetUser.username,
|
||||
mail: targetUser.mail,
|
||||
rank: targetUser.rank,
|
||||
const { id, ...fields } = ctx.data;
|
||||
const result = await peopleMutationService.execute(
|
||||
{
|
||||
correlationId: crypto.randomUUID(),
|
||||
expectedActorId: Number(ctx.session.user.id),
|
||||
},
|
||||
after: userData,
|
||||
});
|
||||
|
||||
notify({
|
||||
action: "user_edit",
|
||||
actor: ctx.session.user.username,
|
||||
target: targetUser.username,
|
||||
targetId: id,
|
||||
});
|
||||
|
||||
"user.update",
|
||||
{ userId: id, fields },
|
||||
);
|
||||
if (!result.ok) throw new ActionError(result.error.messageKey);
|
||||
if (result.completion)
|
||||
throw new ActionError(
|
||||
`User saved; synchronization or completion audit is pending. Reference: ${result.correlationId}`,
|
||||
);
|
||||
return actionOk();
|
||||
},
|
||||
);
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { eq, sql } from "drizzle-orm";
|
||||
import crypto from "node:crypto";
|
||||
import { NextResponse } from "next/server";
|
||||
import { peopleMutationService } from "@/features/housekeeping/domains/people/services/mutations";
|
||||
import { withAdmin } from "@/lib/api-handler";
|
||||
import { db, User } from "@/lib/db";
|
||||
import { PERMS } from "@/lib/permissions";
|
||||
import { rcon } from "@/lib/services/rcon";
|
||||
import { logStaffActivity } from "@/lib/services/staff-activity";
|
||||
@@ -10,7 +10,6 @@ export const POST = withAdmin(
|
||||
{ permission: PERMS.USERS_EDIT },
|
||||
async (request, context) => {
|
||||
const staffId = context.session.user.id;
|
||||
const staffRank = context.session.user.rank;
|
||||
const formData = await request.formData();
|
||||
const userId = Number(formData.get("userId"));
|
||||
const username = String(formData.get("username") || "");
|
||||
@@ -32,68 +31,38 @@ export const POST = withAdmin(
|
||||
);
|
||||
}
|
||||
|
||||
const [rankExists] = (await db
|
||||
.execute(
|
||||
sql`SELECT id FROM permission_ranks WHERE id = ${rank} LIMIT 1`,
|
||||
)
|
||||
.catch(() => [[]] as unknown as [unknown[], unknown])) as unknown as [
|
||||
{ id: number }[],
|
||||
unknown,
|
||||
];
|
||||
if (rankExists.length === 0) {
|
||||
const result = await peopleMutationService.execute(
|
||||
{
|
||||
correlationId: crypto.randomUUID(),
|
||||
expectedActorId: Number(staffId),
|
||||
},
|
||||
"user.update",
|
||||
{ userId, fields: { rank } },
|
||||
);
|
||||
if (!result.ok) {
|
||||
const status =
|
||||
result.error.code === "FORBIDDEN"
|
||||
? 403
|
||||
: result.error.code === "NOT_FOUND"
|
||||
? 404
|
||||
: result.error.code === "VALIDATION"
|
||||
? 400
|
||||
: 503;
|
||||
return NextResponse.json(
|
||||
{ success: false, message: "Rank does not exist" },
|
||||
{ status: 400 },
|
||||
{ success: false, message: result.error.messageKey },
|
||||
{ status },
|
||||
);
|
||||
}
|
||||
|
||||
const [target] = await db
|
||||
.select({ rank: User.rank })
|
||||
.from(User)
|
||||
.where(eq(User.id, userId))
|
||||
.limit(1);
|
||||
if (!target) {
|
||||
return NextResponse.json(
|
||||
{ success: false, message: "User not found" },
|
||||
{ status: 404 },
|
||||
);
|
||||
}
|
||||
|
||||
const isSuper = context.permissions.isSuperAdmin;
|
||||
if (!isSuper) {
|
||||
if (target.rank >= staffRank) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
message: "Cannot change rank of a user at or above your rank",
|
||||
},
|
||||
{ status: 403 },
|
||||
);
|
||||
}
|
||||
if (rank >= staffRank) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
message: "Cannot set a rank equal to or above your own",
|
||||
},
|
||||
{ status: 403 },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
await db.update(User).set({ rank }).where(eq(User.id, userId));
|
||||
await rcon.setRank(userId, rank);
|
||||
await logStaffActivity({
|
||||
staffId,
|
||||
action: "rank_change",
|
||||
description: `Set rank of user #${userId} to ${rank}`,
|
||||
targetType: "user",
|
||||
targetId: userId,
|
||||
});
|
||||
|
||||
return NextResponse.json(
|
||||
{ success: true, message: `Set rank of user #${userId} to ${rank}` },
|
||||
{ status: 200 },
|
||||
{
|
||||
success: true,
|
||||
message: result.completion
|
||||
? "Rank saved; emulator synchronization or completion audit is pending."
|
||||
: "Rank updated",
|
||||
completion: result.completion,
|
||||
correlationId: result.correlationId,
|
||||
},
|
||||
{ status: result.completion ? 202 : 200 },
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
+37
-2
@@ -22,6 +22,7 @@ const mocks = vi.hoisted(() => ({
|
||||
kickAll: vi.fn(),
|
||||
muteUser: vi.fn(),
|
||||
setTradeLock: vi.fn(),
|
||||
setRank: vi.fn(),
|
||||
staffAlert: vi.fn(),
|
||||
unmuteUser: vi.fn(),
|
||||
updateWordFilter: vi.fn(),
|
||||
@@ -46,6 +47,7 @@ function mutationPromise(
|
||||
function selectResult() {
|
||||
const value = mocks.selectQueue.shift() ?? [];
|
||||
return Object.assign(Promise.resolve(value), {
|
||||
for: vi.fn(async () => value),
|
||||
limit: vi.fn(async () => value),
|
||||
orderBy: vi.fn(() =>
|
||||
Object.assign(Promise.resolve(value), {
|
||||
@@ -173,6 +175,36 @@ beforeEach(() => {
|
||||
});
|
||||
|
||||
describe("People production workflow adapter", () => {
|
||||
it("preserves completed rank delivery when only the synchronization audit fails", async () => {
|
||||
mocks.selectQueue.push([target()], [target()], [target({ rank: 4 })]);
|
||||
mocks.audit.mockImplementation(async (entry) => {
|
||||
if (
|
||||
entry.action === "system.external-sync" &&
|
||||
entry.outcome === "success"
|
||||
)
|
||||
throw new Error("audit unavailable");
|
||||
});
|
||||
const result = await peopleMutationService.execute(
|
||||
{ ...invocation, legacy: false },
|
||||
"user.update",
|
||||
{ userId: 7, fields: { rank: 4 } },
|
||||
);
|
||||
expect(result).toMatchObject({
|
||||
ok: true,
|
||||
completion: {
|
||||
status: "partial",
|
||||
external: "completed",
|
||||
audit: "unavailable",
|
||||
},
|
||||
data: {
|
||||
after: {
|
||||
rankRecoveryId: "production-workflow",
|
||||
rankCompletionAudit: "pending",
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(mocks.rcon.setRank).toHaveBeenCalledWith(7, 4);
|
||||
});
|
||||
it("does not treat a high numeric rank as a super-admin hierarchy bypass", async () => {
|
||||
mocks.resolveServerContext.mockResolvedValue({
|
||||
...context(),
|
||||
@@ -231,7 +263,8 @@ describe("People production workflow adapter", () => {
|
||||
fieldErrors: { rank: ["errors.validation.invalid"] },
|
||||
},
|
||||
});
|
||||
expect(mocks.transaction).not.toHaveBeenCalled();
|
||||
expect(mocks.transaction).toHaveBeenCalledTimes(1);
|
||||
expect(mocks.updateSet).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("assigns an existing configured rank above 7 for a super-admin", async () => {
|
||||
@@ -242,6 +275,7 @@ describe("People production workflow adapter", () => {
|
||||
});
|
||||
mocks.selectQueue.push([target({ rank: 2 })]);
|
||||
mocks.execute.mockResolvedValueOnce([[{ id: 9 }], []]);
|
||||
mocks.selectQueue.push([target({ rank: 2 })], [target({ rank: 9 })]);
|
||||
|
||||
const result = await peopleMutationService.execute(
|
||||
invocation,
|
||||
@@ -253,7 +287,8 @@ describe("People production workflow adapter", () => {
|
||||
expect(mocks.updateSet).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ rank: 9 }),
|
||||
);
|
||||
expect(mocks.transaction).toHaveBeenCalledTimes(1);
|
||||
expect(mocks.transaction).toHaveBeenCalledTimes(2);
|
||||
expect(mocks.rcon.setRank).toHaveBeenCalledWith(7, 9);
|
||||
});
|
||||
|
||||
it.each([
|
||||
|
||||
@@ -34,6 +34,11 @@ import {
|
||||
executeModerationAction,
|
||||
reloadWordFilter,
|
||||
} from "@/lib/services/moderation";
|
||||
import {
|
||||
RankAssignmentFailure,
|
||||
type RankAssignmentTransaction,
|
||||
rankAssignmentCoordinator,
|
||||
} from "@/lib/services/rank-assignment";
|
||||
import { rcon } from "@/lib/services/rcon";
|
||||
import { siteSettings } from "@/lib/services/site-settings";
|
||||
import { logStaffActivity } from "@/lib/services/staff-activity";
|
||||
@@ -438,17 +443,6 @@ async function assertBulkTargetHierarchy(
|
||||
}
|
||||
}
|
||||
|
||||
async function assertConfiguredRankExists(rank: number): Promise<void> {
|
||||
const [rows] = (await db.execute(
|
||||
sql`SELECT id FROM permission_ranks WHERE id = ${rank} LIMIT 1`,
|
||||
)) as unknown as [unknown[], unknown];
|
||||
if (!Array.isArray(rows) || rows.length === 0) {
|
||||
throw new PeopleMutationFailure("VALIDATION", "errors.validation.invalid", {
|
||||
rank: ["errors.validation.invalid"],
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function targetSnapshot(target: TargetUser) {
|
||||
return {
|
||||
id: target.id,
|
||||
@@ -642,22 +636,39 @@ async function executeUserMutation(
|
||||
"errors.housekeeping.forbidden",
|
||||
);
|
||||
}
|
||||
await assertConfiguredRankExists(rank);
|
||||
}
|
||||
const userPatch = Object.fromEntries(
|
||||
["username", "mail", "rank", "motto", "credits", "pixels"].flatMap(
|
||||
(key) => (fields[key] === undefined ? [] : [[key, fields[key]]]),
|
||||
["username", "mail", "motto", "credits", "pixels"].flatMap((key) =>
|
||||
fields[key] === undefined ? [] : [[key, fields[key]]],
|
||||
),
|
||||
);
|
||||
const safeFields = Object.fromEntries(
|
||||
Object.entries(fields).filter(([key]) => key !== "mail"),
|
||||
);
|
||||
if (fields.mail !== undefined) safeFields.mailChanged = true;
|
||||
const after: Record<string, unknown> = { ...before, ...safeFields };
|
||||
const rankSyncAudit = {
|
||||
userId: context.capability.actor.id,
|
||||
action: "system.external-sync",
|
||||
target: "rcon.set-rank",
|
||||
targetId: userId,
|
||||
correlationId: context.correlationId,
|
||||
domain: "system" as const,
|
||||
after: {
|
||||
kind: "set-rank",
|
||||
operation: "rcon.set-rank",
|
||||
userId,
|
||||
rank: nextRank,
|
||||
},
|
||||
};
|
||||
if (nextRank !== undefined) after.rankRecoveryId = context.correlationId;
|
||||
const snapshot: PeopleMutationSnapshot = {
|
||||
before,
|
||||
after: { ...before, ...safeFields },
|
||||
after,
|
||||
};
|
||||
await db.transaction(async (tx) => {
|
||||
const persist = async (tx: RankAssignmentTransaction) => {
|
||||
if (nextRank !== undefined)
|
||||
await logAudit({ ...rankSyncAudit, outcome: "intent" }, tx);
|
||||
if (Object.keys(userPatch).length > 0) {
|
||||
await tx.update(User).set(userPatch).where(eq(User.id, userId));
|
||||
}
|
||||
@@ -683,8 +694,49 @@ async function executeUserMutation(
|
||||
),
|
||||
tx,
|
||||
);
|
||||
});
|
||||
return finalizeExternalWithAudit(
|
||||
};
|
||||
if (nextRank !== undefined) {
|
||||
try {
|
||||
await rankAssignmentCoordinator.commit({
|
||||
userId,
|
||||
rank: positiveInteger(nextRank),
|
||||
authorize(currentRank) {
|
||||
before.rank = currentRank;
|
||||
if (
|
||||
!context.capability.isSuperAdmin &&
|
||||
currentRank >= context.capability.actor.rank
|
||||
) {
|
||||
throw new PeopleMutationFailure(
|
||||
"FORBIDDEN",
|
||||
"errors.housekeeping.forbidden",
|
||||
);
|
||||
}
|
||||
},
|
||||
mutate: persist,
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof RankAssignmentFailure) {
|
||||
if (error.code === "RANK_NOT_FOUND") {
|
||||
throw new PeopleMutationFailure(
|
||||
"VALIDATION",
|
||||
"errors.validation.invalid",
|
||||
{
|
||||
rank: ["errors.validation.invalid"],
|
||||
},
|
||||
);
|
||||
}
|
||||
throw new PeopleMutationFailure(
|
||||
"NOT_FOUND",
|
||||
"errors.housekeeping.notFound",
|
||||
);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
} else {
|
||||
await db.transaction(persist);
|
||||
}
|
||||
let rankAuditUnavailable = false;
|
||||
const completed = await finalizeExternalWithAudit(
|
||||
context,
|
||||
operation,
|
||||
"User",
|
||||
@@ -692,6 +744,33 @@ async function executeUserMutation(
|
||||
snapshot,
|
||||
async () => {
|
||||
invalidateLoginCache(target.username);
|
||||
if (nextRank !== undefined) {
|
||||
const delivery = await rankAssignmentCoordinator.synchronize(userId);
|
||||
after.rankSynchronization = delivery.status;
|
||||
if (delivery.status !== "superseded")
|
||||
after.synchronizedRank = delivery.rank;
|
||||
try {
|
||||
await logAudit({
|
||||
...rankSyncAudit,
|
||||
after: {
|
||||
...rankSyncAudit.after,
|
||||
...(delivery.status === "superseded"
|
||||
? { superseded: true }
|
||||
: { rank: delivery.rank }),
|
||||
},
|
||||
outcome: delivery.status === "pending" ? "partial" : "success",
|
||||
});
|
||||
} catch {
|
||||
rankAuditUnavailable = true;
|
||||
after.rankCompletionAudit = "pending";
|
||||
}
|
||||
if (delivery.status === "pending") {
|
||||
throw new PeopleMutationFailure(
|
||||
"DEPENDENCY_UNAVAILABLE",
|
||||
"errors.housekeeping.dependencyUnavailable",
|
||||
);
|
||||
}
|
||||
}
|
||||
void notify({
|
||||
action: "user_edit",
|
||||
actor: context.capability.actor.username,
|
||||
@@ -700,6 +779,14 @@ async function executeUserMutation(
|
||||
});
|
||||
},
|
||||
);
|
||||
if (rankAuditUnavailable) {
|
||||
return withPartialCompletion(completed, {
|
||||
status: "partial",
|
||||
external: completed.completion?.external ?? "completed",
|
||||
audit: "unavailable",
|
||||
});
|
||||
}
|
||||
return completed;
|
||||
}
|
||||
|
||||
if (operation === "user.ban") {
|
||||
|
||||
@@ -29,6 +29,11 @@ import {
|
||||
prepareEmulatorRankCreation,
|
||||
updateEmulatorRank,
|
||||
} from "@/lib/services/permission-ranks";
|
||||
import {
|
||||
lockConfiguredRank,
|
||||
RankAssignmentFailure,
|
||||
rankAssignmentCoordinator,
|
||||
} from "@/lib/services/rank-assignment";
|
||||
import { rcon } from "@/lib/services/rcon";
|
||||
import { siteSettings } from "@/lib/services/site-settings";
|
||||
import {
|
||||
@@ -164,6 +169,15 @@ export function createSystemMutationService(
|
||||
context.correlationId,
|
||||
);
|
||||
} catch (error) {
|
||||
if (error instanceof RankAssignmentFailure) {
|
||||
return fail(
|
||||
"NOT_FOUND",
|
||||
error.code === "RANK_NOT_FOUND"
|
||||
? "errors.housekeeping.system.rankNotFound"
|
||||
: "errors.housekeeping.system.userNotFound",
|
||||
context.correlationId,
|
||||
);
|
||||
}
|
||||
if (error instanceof SystemCommittedExternalFailure) {
|
||||
return ok(error.data, context.correlationId, {
|
||||
status: "partial",
|
||||
@@ -301,7 +315,11 @@ function synchronizationData(
|
||||
synchronization,
|
||||
completed:
|
||||
synchronization === "completed"
|
||||
? ["database", "audit", pendingExternal(intent)]
|
||||
? [
|
||||
"database",
|
||||
"audit",
|
||||
extra.superseded ? "target-deleted" : pendingExternal(intent),
|
||||
]
|
||||
: ["database", "audit"],
|
||||
pending: synchronization === "completed" ? [] : [pendingExternal(intent)],
|
||||
};
|
||||
@@ -312,6 +330,7 @@ async function executeExternalSynchronization(
|
||||
): Promise<{
|
||||
readonly intent: SystemExternalSyncIntent;
|
||||
readonly synchronized: boolean;
|
||||
readonly superseded?: boolean;
|
||||
}> {
|
||||
if (intent.kind !== "set-rank") {
|
||||
return {
|
||||
@@ -320,34 +339,14 @@ async function executeExternalSynchronization(
|
||||
};
|
||||
}
|
||||
|
||||
return db.transaction(async (tx) => {
|
||||
const [target] = await tx
|
||||
.select({ rank: User.rank })
|
||||
.from(User)
|
||||
.where(eq(User.id, intent.userId))
|
||||
.for("update");
|
||||
if (!target) {
|
||||
throw new SystemMutationFailure(
|
||||
"NOT_FOUND",
|
||||
"errors.housekeeping.system.userNotFound",
|
||||
);
|
||||
}
|
||||
|
||||
const currentIntent = {
|
||||
...intent,
|
||||
rank: positiveInteger(target.rank),
|
||||
} as const satisfies SystemExternalSyncIntent;
|
||||
let synchronized = false;
|
||||
try {
|
||||
synchronized = await rcon.setRank(
|
||||
currentIntent.userId,
|
||||
currentIntent.rank,
|
||||
);
|
||||
} catch {
|
||||
// Preserve the resolved current intent for a later convergent retry.
|
||||
}
|
||||
return { intent: currentIntent, synchronized };
|
||||
});
|
||||
const delivery = await rankAssignmentCoordinator.synchronize(intent.userId);
|
||||
if (delivery.status === "superseded") {
|
||||
return { intent, synchronized: true, superseded: true };
|
||||
}
|
||||
return {
|
||||
intent: { ...intent, rank: delivery.rank },
|
||||
synchronized: delivery.status === "delivered",
|
||||
};
|
||||
}
|
||||
|
||||
async function completeExternalSynchronization(
|
||||
@@ -358,10 +357,12 @@ async function completeExternalSynchronization(
|
||||
): Promise<unknown> {
|
||||
let currentIntent = intent;
|
||||
let synchronized = false;
|
||||
let superseded = false;
|
||||
try {
|
||||
const execution = await executeExternalSynchronization(intent);
|
||||
currentIntent = execution.intent;
|
||||
synchronized = execution.synchronized;
|
||||
superseded = execution.superseded ?? false;
|
||||
} catch (error) {
|
||||
if (error instanceof SystemMutationFailure) throw error;
|
||||
}
|
||||
@@ -382,13 +383,18 @@ async function completeExternalSynchronization(
|
||||
}
|
||||
|
||||
try {
|
||||
await logAudit(
|
||||
syncAuditEntry(currentIntent, context, recoveryId, "success"),
|
||||
);
|
||||
const entry = syncAuditEntry(currentIntent, context, recoveryId, "success");
|
||||
await logAudit({
|
||||
...entry,
|
||||
after: { ...entry.after, ...(superseded ? { superseded: true } : {}) },
|
||||
});
|
||||
} catch {
|
||||
throw new SystemCommittedExternalFailure(
|
||||
{
|
||||
...synchronizationData(currentIntent, recoveryId, "completed", extra),
|
||||
...synchronizationData(currentIntent, recoveryId, "completed", {
|
||||
...extra,
|
||||
...(superseded ? { superseded: true } : {}),
|
||||
}),
|
||||
pending: ["completion-audit"],
|
||||
},
|
||||
"completed",
|
||||
@@ -396,7 +402,10 @@ async function completeExternalSynchronization(
|
||||
);
|
||||
}
|
||||
|
||||
return synchronizationData(currentIntent, recoveryId, "completed", extra);
|
||||
return synchronizationData(currentIntent, recoveryId, "completed", {
|
||||
...extra,
|
||||
...(superseded ? { superseded: true } : {}),
|
||||
});
|
||||
}
|
||||
|
||||
function parseExternalSyncIntent(
|
||||
@@ -570,16 +579,7 @@ async function executeAccessMutation(
|
||||
rankId: id,
|
||||
} as const satisfies SystemExternalSyncIntent;
|
||||
await db.transaction(async (tx) => {
|
||||
let rankRows: { id: number }[] = [];
|
||||
try {
|
||||
const [rows] = await tx.execute(
|
||||
sql`SELECT id FROM permission_ranks WHERE id = ${id} LIMIT 1 FOR UPDATE`,
|
||||
);
|
||||
rankRows = rows as unknown as { id: number }[];
|
||||
} catch {
|
||||
rankRows = [];
|
||||
}
|
||||
if (rankRows.length === 0) {
|
||||
if (!(await lockConfiguredRank(tx, id))) {
|
||||
throw new SystemMutationFailure(
|
||||
"NOT_FOUND",
|
||||
"errors.housekeeping.system.rankNotFound",
|
||||
@@ -978,63 +978,40 @@ async function executeRconMutation(
|
||||
userId,
|
||||
rank,
|
||||
} as const satisfies SystemExternalSyncIntent;
|
||||
await db.transaction(async (tx) => {
|
||||
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(
|
||||
"NOT_FOUND",
|
||||
"errors.housekeeping.system.rankNotFound",
|
||||
);
|
||||
}
|
||||
const [target] = await tx
|
||||
.select({ rank: User.rank })
|
||||
.from(User)
|
||||
.where(eq(User.id, userId))
|
||||
.for("update");
|
||||
if (!target) {
|
||||
throw new SystemMutationFailure(
|
||||
"NOT_FOUND",
|
||||
"errors.housekeeping.system.userNotFound",
|
||||
);
|
||||
}
|
||||
if (!context.capability.isSuperAdmin) {
|
||||
const actorRank = context.capability.actor.rank;
|
||||
if (target.rank >= actorRank) {
|
||||
await rankAssignmentCoordinator.commit({
|
||||
userId,
|
||||
rank,
|
||||
authorize(currentRank) {
|
||||
if (context.capability.isSuperAdmin) return;
|
||||
if (currentRank >= context.capability.actor.rank) {
|
||||
throw new SystemMutationFailure(
|
||||
"FORBIDDEN",
|
||||
"errors.housekeeping.system.cannotChangePeerRank",
|
||||
);
|
||||
}
|
||||
if (rank >= actorRank) {
|
||||
if (rank >= context.capability.actor.rank) {
|
||||
throw new SystemMutationFailure(
|
||||
"FORBIDDEN",
|
||||
"errors.housekeeping.system.cannotAssignPeerRank",
|
||||
);
|
||||
}
|
||||
}
|
||||
await tx.update(User).set({ rank }).where(eq(User.id, userId));
|
||||
await logStaffActivityInTransaction(
|
||||
{
|
||||
staffId: context.capability.actor.id,
|
||||
action: "rank_assign",
|
||||
description: `Assigned rank #${rank} to user #${userId}`,
|
||||
targetType: "user",
|
||||
targetId: userId,
|
||||
},
|
||||
tx,
|
||||
);
|
||||
await logAudit(
|
||||
syncAuditEntry(intent, context, context.correlationId, "intent"),
|
||||
tx,
|
||||
);
|
||||
},
|
||||
async mutate(tx) {
|
||||
await logStaffActivityInTransaction(
|
||||
{
|
||||
staffId: context.capability.actor.id,
|
||||
action: "rank_assign",
|
||||
description: `Assigned rank #${rank} to user #${userId}`,
|
||||
targetType: "user",
|
||||
targetId: userId,
|
||||
},
|
||||
tx,
|
||||
);
|
||||
await logAudit(
|
||||
syncAuditEntry(intent, context, context.correlationId, "intent"),
|
||||
tx,
|
||||
);
|
||||
},
|
||||
});
|
||||
return completeExternalSynchronization(intent, context, undefined, {
|
||||
userId,
|
||||
|
||||
@@ -186,6 +186,47 @@ function expectPendingSynchronization(
|
||||
}
|
||||
|
||||
describe("durable rank synchronization", () => {
|
||||
it.each([
|
||||
["access.rank.delete", PERMS.PERMISSIONS_MANAGE, { id: 7 }],
|
||||
["rcon.set-rank", PERMS.RCON_EXECUTE, { userId: 8, rank: 4 }],
|
||||
] as const)(
|
||||
"reports SQL lock failure for %s as dependency unavailable",
|
||||
async (operation, permission, input) => {
|
||||
doubles.dbExecute.mockRejectedValueOnce(new Error("lock wait timeout"));
|
||||
const result = await systemMutationService.execute(
|
||||
serviceContext(permission),
|
||||
operation,
|
||||
input,
|
||||
);
|
||||
expect(result).toMatchObject({
|
||||
ok: false,
|
||||
error: { code: "DEPENDENCY_UNAVAILABLE" },
|
||||
});
|
||||
expect(doubles.dbUpdate).not.toHaveBeenCalled();
|
||||
expect(doubles.rconSetRank).not.toHaveBeenCalled();
|
||||
expect(doubles.deleteEmulatorRankInTransaction).not.toHaveBeenCalled();
|
||||
},
|
||||
);
|
||||
|
||||
it("audits a committed assignment as superseded when the user was subsequently deleted", async () => {
|
||||
doubles.dbExecute.mockResolvedValueOnce([[{ id: 4 }]]);
|
||||
doubles.dbSelect
|
||||
.mockImplementationOnce(() => limitedSelection([{ rank: 3 }]))
|
||||
.mockImplementationOnce(() => limitedSelection([]));
|
||||
const result = await systemMutationService.execute(
|
||||
serviceContext(PERMS.RCON_EXECUTE),
|
||||
"rcon.set-rank",
|
||||
{ userId: 8, rank: 4 },
|
||||
);
|
||||
expect(result).toMatchObject({
|
||||
ok: true,
|
||||
data: { superseded: true, recoveryId: "rank-mutation-correlation" },
|
||||
});
|
||||
expect(doubles.rconSetRank).not.toHaveBeenCalled();
|
||||
expect(doubles.logAudit).toHaveBeenLastCalledWith(
|
||||
expect.objectContaining({ outcome: "success" }),
|
||||
);
|
||||
});
|
||||
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);
|
||||
|
||||
@@ -0,0 +1,185 @@
|
||||
import { MySqlDialect } from "drizzle-orm/mysql-core";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
vi.mock("server-only", () => ({}));
|
||||
|
||||
import {
|
||||
createRankAssignmentCoordinator,
|
||||
lockConfiguredRank,
|
||||
type RankAssignmentAdapter,
|
||||
} from "./rank-assignment";
|
||||
|
||||
function adapterFixture(overrides: Partial<RankAssignmentAdapter> = {}) {
|
||||
const events: string[] = [];
|
||||
let transactionNumber = 0;
|
||||
const adapter: RankAssignmentAdapter = {
|
||||
transaction: async (run) => {
|
||||
transactionNumber += 1;
|
||||
const transaction = { transactionNumber };
|
||||
events.push(`transaction:${transactionNumber}:start`);
|
||||
const result = await run(transaction);
|
||||
events.push(`transaction:${transactionNumber}:commit`);
|
||||
return result;
|
||||
},
|
||||
lockRank: async (_transaction, rank) => {
|
||||
events.push(`rank:${rank}:for-update`);
|
||||
return true;
|
||||
},
|
||||
lockUser: vi
|
||||
.fn()
|
||||
.mockImplementationOnce(async (_transaction, userId) => {
|
||||
events.push(`user:${userId}:for-update`);
|
||||
return { rank: 3 };
|
||||
})
|
||||
.mockImplementationOnce(async (_transaction, userId) => {
|
||||
events.push(`user:${userId}:for-update`);
|
||||
return { rank: 5 };
|
||||
}),
|
||||
updateUserRank: async (_transaction, userId, rank) => {
|
||||
events.push(`user:${userId}:update:${rank}`);
|
||||
},
|
||||
deliverRank: async (userId, rank) => {
|
||||
events.push(`rcon:${userId}:${rank}`);
|
||||
return true;
|
||||
},
|
||||
...overrides,
|
||||
};
|
||||
return { adapter, events };
|
||||
}
|
||||
|
||||
describe("rank assignment coordinator", () => {
|
||||
it("uses a parameterized FOR UPDATE query for the configured rank lock", async () => {
|
||||
const execute = vi.fn().mockResolvedValue([[{ id: 4 }]]);
|
||||
expect(await lockConfiguredRank({ execute } as never, 4)).toBe(true);
|
||||
const query = new MySqlDialect().sqlToQuery(execute.mock.calls[0][0]);
|
||||
expect(query.sql).toBe(
|
||||
"SELECT id FROM permission_ranks WHERE id = ? LIMIT 1 FOR UPDATE",
|
||||
);
|
||||
expect(query.params).toEqual([4]);
|
||||
});
|
||||
|
||||
it("does not release the delivery transaction before the transport settles", async () => {
|
||||
let finishDelivery!: (value: boolean) => void;
|
||||
let started!: () => void;
|
||||
const deliveryStarted = new Promise<void>((resolve) => {
|
||||
started = resolve;
|
||||
});
|
||||
const { adapter, events } = adapterFixture({
|
||||
deliverRank: () => {
|
||||
started();
|
||||
return new Promise<boolean>((resolve) => {
|
||||
finishDelivery = resolve;
|
||||
});
|
||||
},
|
||||
});
|
||||
const pending = createRankAssignmentCoordinator(adapter).assign({
|
||||
userId: 8,
|
||||
rank: 4,
|
||||
});
|
||||
await deliveryStarted;
|
||||
expect(events).toContain("transaction:1:commit");
|
||||
expect(events).not.toContain("transaction:2:commit");
|
||||
finishDelivery(true);
|
||||
await expect(pending).resolves.toEqual({ status: "delivered", rank: 5 });
|
||||
expect(events.at(-1)).toBe("transaction:2:commit");
|
||||
});
|
||||
|
||||
it("never sends RCON when the transactional audit fails", async () => {
|
||||
const { adapter, events } = adapterFixture();
|
||||
await expect(
|
||||
createRankAssignmentCoordinator(adapter).assign({
|
||||
userId: 8,
|
||||
rank: 4,
|
||||
mutate: async () => {
|
||||
throw new Error("audit unavailable");
|
||||
},
|
||||
}),
|
||||
).rejects.toThrow("audit unavailable");
|
||||
expect(events.some((event) => event.startsWith("rcon:"))).toBe(false);
|
||||
expect(events).not.toContain("transaction:1:commit");
|
||||
});
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("commits rank then user before starting delivery and holds the delivery lock through RCON", async () => {
|
||||
const { adapter, events } = adapterFixture();
|
||||
const coordinator = createRankAssignmentCoordinator(adapter);
|
||||
|
||||
const result = await coordinator.assign({
|
||||
userId: 8,
|
||||
rank: 4,
|
||||
mutate: async () => {
|
||||
events.push("mutation:audit");
|
||||
},
|
||||
});
|
||||
|
||||
expect(result).toEqual({ status: "delivered", rank: 5 });
|
||||
expect(events).toEqual([
|
||||
"transaction:1:start",
|
||||
"rank:4:for-update",
|
||||
"user:8:for-update",
|
||||
"user:8:update:4",
|
||||
"mutation:audit",
|
||||
"transaction:1:commit",
|
||||
"transaction:2:start",
|
||||
"user:8:for-update",
|
||||
"rcon:8:5",
|
||||
"transaction:2:commit",
|
||||
]);
|
||||
});
|
||||
|
||||
it("propagates rank-lock dependency failures instead of converting them to not found", async () => {
|
||||
const dependencyFailure = new Error("lock wait timeout");
|
||||
const { adapter, events } = adapterFixture({
|
||||
lockRank: async () => {
|
||||
throw dependencyFailure;
|
||||
},
|
||||
});
|
||||
const coordinator = createRankAssignmentCoordinator(adapter);
|
||||
|
||||
await expect(coordinator.assign({ userId: 8, rank: 4 })).rejects.toBe(
|
||||
dependencyFailure,
|
||||
);
|
||||
expect(events.some((event) => event.startsWith("rcon:"))).toBe(false);
|
||||
});
|
||||
|
||||
it("distinguishes a missing configured rank from a dependency failure", async () => {
|
||||
const { adapter } = adapterFixture({ lockRank: async () => false });
|
||||
const coordinator = createRankAssignmentCoordinator(adapter);
|
||||
|
||||
await expect(
|
||||
coordinator.assign({ userId: 8, rank: 4 }),
|
||||
).rejects.toMatchObject({ code: "RANK_NOT_FOUND" });
|
||||
});
|
||||
|
||||
it("reports a deleted user after commit as a superseded delivery", async () => {
|
||||
const { adapter, events } = adapterFixture();
|
||||
vi.mocked(adapter.lockUser)
|
||||
.mockReset()
|
||||
.mockImplementationOnce(async (_transaction, userId) => {
|
||||
events.push(`user:${userId}:for-update`);
|
||||
return { rank: 3 };
|
||||
})
|
||||
.mockImplementationOnce(async (_transaction, userId) => {
|
||||
events.push(`user:${userId}:for-update`);
|
||||
return null;
|
||||
});
|
||||
const coordinator = createRankAssignmentCoordinator(adapter);
|
||||
|
||||
const result = await coordinator.assign({ userId: 8, rank: 4 });
|
||||
|
||||
expect(result).toEqual({ status: "superseded" });
|
||||
expect(events).not.toContain("rcon:8:4");
|
||||
});
|
||||
|
||||
it("returns the current committed rank as pending when bounded delivery fails", async () => {
|
||||
const { adapter } = adapterFixture({ deliverRank: async () => false });
|
||||
const coordinator = createRankAssignmentCoordinator(adapter);
|
||||
|
||||
await expect(coordinator.assign({ userId: 8, rank: 4 })).resolves.toEqual({
|
||||
status: "pending",
|
||||
rank: 5,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,136 @@
|
||||
import "server-only";
|
||||
|
||||
import { eq, sql } from "drizzle-orm";
|
||||
import { type Db, db, User } from "@/lib/db";
|
||||
import { rcon } from "@/lib/services/rcon";
|
||||
|
||||
export type RankAssignmentFailureCode = "RANK_NOT_FOUND" | "USER_NOT_FOUND";
|
||||
|
||||
export class RankAssignmentFailure extends Error {
|
||||
constructor(readonly code: RankAssignmentFailureCode) {
|
||||
super(code);
|
||||
this.name = "RankAssignmentFailure";
|
||||
}
|
||||
}
|
||||
|
||||
export type RankDeliveryResult =
|
||||
| { readonly status: "delivered" | "pending"; readonly rank: number }
|
||||
| { readonly status: "superseded" };
|
||||
|
||||
export interface RankAssignmentOptions<TTransaction = unknown> {
|
||||
readonly userId: number;
|
||||
readonly rank: number;
|
||||
readonly authorize?: (
|
||||
currentRank: number,
|
||||
transaction: TTransaction,
|
||||
) => Promise<void> | void;
|
||||
readonly mutate?: (
|
||||
transaction: TTransaction,
|
||||
previousRank: number,
|
||||
) => Promise<void> | void;
|
||||
}
|
||||
|
||||
export interface RankAssignmentAdapter<TTransaction = unknown> {
|
||||
transaction<T>(run: (transaction: TTransaction) => Promise<T>): Promise<T>;
|
||||
lockRank(transaction: TTransaction, rank: number): Promise<boolean>;
|
||||
lockUser(
|
||||
transaction: TTransaction,
|
||||
userId: number,
|
||||
): Promise<{ readonly rank: number } | null>;
|
||||
updateUserRank(
|
||||
transaction: TTransaction,
|
||||
userId: number,
|
||||
rank: number,
|
||||
): Promise<void>;
|
||||
deliverRank(userId: number, rank: number): Promise<boolean>;
|
||||
}
|
||||
|
||||
export interface RankAssignmentCoordinator<TTransaction = unknown> {
|
||||
assign(
|
||||
options: RankAssignmentOptions<TTransaction>,
|
||||
): Promise<RankDeliveryResult>;
|
||||
commit(options: RankAssignmentOptions<TTransaction>): Promise<number>;
|
||||
synchronize(userId: number): Promise<RankDeliveryResult>;
|
||||
}
|
||||
|
||||
export function createRankAssignmentCoordinator<TTransaction>(
|
||||
adapter: RankAssignmentAdapter<TTransaction>,
|
||||
): RankAssignmentCoordinator<TTransaction> {
|
||||
const commit = async (
|
||||
options: RankAssignmentOptions<TTransaction>,
|
||||
): Promise<number> =>
|
||||
adapter.transaction(async (transaction) => {
|
||||
if (!(await adapter.lockRank(transaction, options.rank))) {
|
||||
throw new RankAssignmentFailure("RANK_NOT_FOUND");
|
||||
}
|
||||
const target = await adapter.lockUser(transaction, options.userId);
|
||||
if (!target) throw new RankAssignmentFailure("USER_NOT_FOUND");
|
||||
await options.authorize?.(target.rank, transaction);
|
||||
await adapter.updateUserRank(transaction, options.userId, options.rank);
|
||||
await options.mutate?.(transaction, target.rank);
|
||||
return target.rank;
|
||||
});
|
||||
|
||||
const synchronize = async (userId: number): Promise<RankDeliveryResult> =>
|
||||
adapter.transaction(async (transaction) => {
|
||||
const target = await adapter.lockUser(transaction, userId);
|
||||
if (!target) return { status: "superseded" };
|
||||
let delivered = false;
|
||||
try {
|
||||
delivered = await adapter.deliverRank(userId, target.rank);
|
||||
} catch {
|
||||
// The committed database rank remains the source of truth for retry.
|
||||
}
|
||||
return {
|
||||
status: delivered ? "delivered" : "pending",
|
||||
rank: target.rank,
|
||||
};
|
||||
});
|
||||
|
||||
return {
|
||||
commit,
|
||||
synchronize,
|
||||
async assign(options) {
|
||||
await commit(options);
|
||||
return synchronize(options.userId);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export type RankAssignmentTransaction = Pick<
|
||||
Db,
|
||||
"execute" | "insert" | "select" | "update"
|
||||
>;
|
||||
|
||||
/** Rank writers and rank deletion acquire this lock before touching users. */
|
||||
export async function lockConfiguredRank(
|
||||
transaction: Pick<Db, "execute">,
|
||||
rank: number,
|
||||
): Promise<boolean> {
|
||||
const [rows] = await transaction.execute(
|
||||
sql`SELECT id FROM permission_ranks WHERE id = ${rank} LIMIT 1 FOR UPDATE`,
|
||||
);
|
||||
return (rows as unknown as unknown[]).length > 0;
|
||||
}
|
||||
|
||||
const productionRankAssignmentAdapter: RankAssignmentAdapter<RankAssignmentTransaction> =
|
||||
{
|
||||
transaction: (run) => db.transaction((transaction) => run(transaction)),
|
||||
lockRank: lockConfiguredRank,
|
||||
async lockUser(transaction, userId) {
|
||||
const [target] = await transaction
|
||||
.select({ rank: User.rank })
|
||||
.from(User)
|
||||
.where(eq(User.id, userId))
|
||||
.for("update");
|
||||
return target ?? null;
|
||||
},
|
||||
async updateUserRank(transaction, userId, rank) {
|
||||
await transaction.update(User).set({ rank }).where(eq(User.id, userId));
|
||||
},
|
||||
deliverRank: (userId, rank) => rcon.setRank(userId, rank),
|
||||
};
|
||||
|
||||
export const rankAssignmentCoordinator = createRankAssignmentCoordinator(
|
||||
productionRankAssignmentAdapter,
|
||||
);
|
||||
@@ -0,0 +1,139 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const doubles = vi.hoisted(() => ({
|
||||
people: vi.fn(),
|
||||
system: vi.fn(),
|
||||
revalidate: vi.fn(),
|
||||
}));
|
||||
vi.mock("server-only", () => ({}));
|
||||
vi.mock("@/lib/safe-action", () => ({
|
||||
adminAction: (_options: unknown, handler: unknown) => handler,
|
||||
}));
|
||||
vi.mock("@/lib/api-handler", () => ({
|
||||
withAdmin: (_options: unknown, handler: unknown) => handler,
|
||||
}));
|
||||
vi.mock("next/cache", async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import("next/cache")>()),
|
||||
revalidatePath: doubles.revalidate,
|
||||
revalidateTag: doubles.revalidate,
|
||||
}));
|
||||
vi.mock("@/features/housekeeping/domains/people/services/mutations", () => ({
|
||||
peopleMutationService: { execute: doubles.people },
|
||||
}));
|
||||
vi.mock("@/features/housekeeping/domains/system/services/mutations", () => ({
|
||||
systemMutationService: { execute: doubles.system },
|
||||
}));
|
||||
vi.mock("@/lib/auth", () => ({ invalidateLoginCache: vi.fn() }));
|
||||
|
||||
import { setRank } from "@/actions/commandocentrum";
|
||||
import { deleteRank } from "@/actions/permissions";
|
||||
import { updateUser } from "@/actions/users";
|
||||
import { POST } from "@/app/api/admin/users/actions/route";
|
||||
|
||||
const context = {
|
||||
session: { user: { id: 42, username: "operator", rank: 6 } },
|
||||
permissions: {
|
||||
isSuperAdmin: false,
|
||||
has: () => true,
|
||||
hasAny: () => true,
|
||||
hasAll: () => true,
|
||||
},
|
||||
};
|
||||
const partial = {
|
||||
ok: true,
|
||||
data: {},
|
||||
correlationId: "recovery-42",
|
||||
completion: { status: "partial", external: "failed", audit: "persisted" },
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
doubles.people.mockResolvedValue({
|
||||
ok: true,
|
||||
data: {},
|
||||
correlationId: "operation-42",
|
||||
});
|
||||
doubles.system.mockResolvedValue({
|
||||
ok: true,
|
||||
data: { rank: 4 },
|
||||
correlationId: "operation-42",
|
||||
});
|
||||
});
|
||||
|
||||
describe("legacy rank entrypoints", () => {
|
||||
it("delegates legacy rank deletion to the same System transaction", async () => {
|
||||
await deleteRank({ ...context, data: { id: 4 } } as never);
|
||||
expect(doubles.system).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
capability: expect.objectContaining({ actor: context.session.user }),
|
||||
}),
|
||||
"access.rank.delete",
|
||||
{ id: 4 },
|
||||
);
|
||||
});
|
||||
it("routes command-centre assignments through the authorized System service", async () => {
|
||||
await setRank({ ...context, data: { userId: 8, rank: 4 } } as never);
|
||||
expect(doubles.system).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
capability: expect.objectContaining({ actor: context.session.user }),
|
||||
correlationId: expect.any(String),
|
||||
}),
|
||||
"rcon.set-rank",
|
||||
{ userId: 8, rank: 4 },
|
||||
);
|
||||
});
|
||||
|
||||
it("does not present a partially synchronized command-centre assignment as complete", async () => {
|
||||
doubles.system.mockResolvedValue(partial);
|
||||
await expect(
|
||||
setRank({ ...context, data: { userId: 8, rank: 4 } } as never),
|
||||
).rejects.toThrow("Rank saved");
|
||||
});
|
||||
|
||||
it("routes the old user editor through People, preserving fields and actor identity", async () => {
|
||||
await updateUser({
|
||||
...context,
|
||||
data: { id: 8, rank: 4, motto: "Updated" },
|
||||
} as never);
|
||||
expect(doubles.people).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
expectedActorId: 42,
|
||||
correlationId: expect.any(String),
|
||||
}),
|
||||
"user.update",
|
||||
{ userId: 8, fields: { rank: 4, motto: "Updated" } },
|
||||
);
|
||||
});
|
||||
|
||||
it("surfaces committed partial completion in the old user editor", async () => {
|
||||
doubles.people.mockResolvedValue(partial);
|
||||
await expect(
|
||||
updateUser({ ...context, data: { id: 8, rank: 4 } } as never),
|
||||
).rejects.toThrow("User saved");
|
||||
});
|
||||
|
||||
it("returns 202 and completion metadata when the user API committed but delivery is pending", async () => {
|
||||
doubles.people.mockResolvedValue(partial);
|
||||
const request = new Request("http://localhost/api/admin/users/actions", {
|
||||
method: "POST",
|
||||
body: new URLSearchParams({
|
||||
action: "set_rank",
|
||||
userId: "8",
|
||||
username: "Alice",
|
||||
rank: "4",
|
||||
}),
|
||||
});
|
||||
const response = await POST(request as never, context as never);
|
||||
expect(response.status).toBe(202);
|
||||
expect(await response.json()).toMatchObject({
|
||||
success: true,
|
||||
correlationId: "recovery-42",
|
||||
completion: partial.completion,
|
||||
});
|
||||
expect(doubles.people).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ expectedActorId: 42 }),
|
||||
"user.update",
|
||||
{ userId: 8, fields: { rank: 4 } },
|
||||
);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user