diff --git a/docs/superpowers/evidence/2026-09-05-housekeeping-rank-convergence.md b/docs/superpowers/evidence/2026-09-05-housekeeping-rank-convergence.md new file mode 100644 index 00000000..3a3bd1cf --- /dev/null +++ b/docs/superpowers/evidence/2026-09-05-housekeeping-rank-convergence.md @@ -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. diff --git a/src/actions/commandocentrum.ts b/src/actions/commandocentrum.ts index 025c497a..107320e3 100644 --- a/src/actions/commandocentrum.ts +++ b/src/actions/commandocentrum.ts @@ -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); }, ); diff --git a/src/actions/permissions.ts b/src/actions/permissions.ts index ac11d18c..f3bed5bf 100644 --- a/src/actions/permissions.ts +++ b/src/actions/permissions.ts @@ -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(); }, ); diff --git a/src/actions/shop-rank.test.ts b/src/actions/shop-rank.test.ts new file mode 100644 index 00000000..5220ddda --- /dev/null +++ b/src/actions/shop-rank.test.ts @@ -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(); + 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()), + 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), + }), + ); + }); +}); diff --git a/src/actions/shop.ts b/src/actions/shop.ts index 33ec7283..242e5267 100644 --- a/src/actions/shop.ts +++ b/src/actions/shop.ts @@ -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 { 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 { 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 { } }); + 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, diff --git a/src/actions/users.ts b/src/actions/users.ts index d4526acc..e2191fdb 100644 --- a/src/actions/users.ts +++ b/src/actions/users.ts @@ -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(); }, ); diff --git a/src/app/api/admin/users/actions/route.ts b/src/app/api/admin/users/actions/route.ts index 551aed09..6df3d721 100644 --- a/src/app/api/admin/users/actions/route.ts +++ b/src/app/api/admin/users/actions/route.ts @@ -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 }, ); } diff --git a/src/features/housekeeping/domains/people/services/mutations-production-workflows.test.ts b/src/features/housekeeping/domains/people/services/mutations-production-workflows.test.ts index 513c4712..b7acb383 100644 --- a/src/features/housekeeping/domains/people/services/mutations-production-workflows.test.ts +++ b/src/features/housekeeping/domains/people/services/mutations-production-workflows.test.ts @@ -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([ diff --git a/src/features/housekeeping/domains/people/services/mutations.ts b/src/features/housekeeping/domains/people/services/mutations.ts index 43521051..6cf459ad 100644 --- a/src/features/housekeeping/domains/people/services/mutations.ts +++ b/src/features/housekeeping/domains/people/services/mutations.ts @@ -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 { - 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 = { ...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") { diff --git a/src/features/housekeeping/domains/system/services/mutations.ts b/src/features/housekeeping/domains/system/services/mutations.ts index 5587a5be..0ba4166c 100644 --- a/src/features/housekeeping/domains/system/services/mutations.ts +++ b/src/features/housekeeping/domains/system/services/mutations.ts @@ -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 { 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, diff --git a/src/features/housekeeping/domains/system/services/rank-mutations-production.test.ts b/src/features/housekeeping/domains/system/services/rank-mutations-production.test.ts index 1b7b7012..7b568323 100644 --- a/src/features/housekeeping/domains/system/services/rank-mutations-production.test.ts +++ b/src/features/housekeeping/domains/system/services/rank-mutations-production.test.ts @@ -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); diff --git a/src/lib/services/rank-assignment.test.ts b/src/lib/services/rank-assignment.test.ts new file mode 100644 index 00000000..fab33fa4 --- /dev/null +++ b/src/lib/services/rank-assignment.test.ts @@ -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 = {}) { + 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((resolve) => { + started = resolve; + }); + const { adapter, events } = adapterFixture({ + deliverRank: () => { + started(); + return new Promise((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, + }); + }); +}); diff --git a/src/lib/services/rank-assignment.ts b/src/lib/services/rank-assignment.ts new file mode 100644 index 00000000..7b165d16 --- /dev/null +++ b/src/lib/services/rank-assignment.ts @@ -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 { + readonly userId: number; + readonly rank: number; + readonly authorize?: ( + currentRank: number, + transaction: TTransaction, + ) => Promise | void; + readonly mutate?: ( + transaction: TTransaction, + previousRank: number, + ) => Promise | void; +} + +export interface RankAssignmentAdapter { + transaction(run: (transaction: TTransaction) => Promise): Promise; + lockRank(transaction: TTransaction, rank: number): Promise; + lockUser( + transaction: TTransaction, + userId: number, + ): Promise<{ readonly rank: number } | null>; + updateUserRank( + transaction: TTransaction, + userId: number, + rank: number, + ): Promise; + deliverRank(userId: number, rank: number): Promise; +} + +export interface RankAssignmentCoordinator { + assign( + options: RankAssignmentOptions, + ): Promise; + commit(options: RankAssignmentOptions): Promise; + synchronize(userId: number): Promise; +} + +export function createRankAssignmentCoordinator( + adapter: RankAssignmentAdapter, +): RankAssignmentCoordinator { + const commit = async ( + options: RankAssignmentOptions, + ): Promise => + 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 => + 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, + rank: number, +): Promise { + 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 = + { + 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, +); diff --git a/src/lib/services/rank-entrypoints.test.ts b/src/lib/services/rank-entrypoints.test.ts new file mode 100644 index 00000000..1b637b35 --- /dev/null +++ b/src/lib/services/rank-entrypoints.test.ts @@ -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()), + 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 } }, + ); + }); +});