WIP: Unified Housekeeping rebuild / Gecombineerde Housekeeping-herbouw #53

Closed
Simo wants to merge 68 commits from codex/housekeeping-rebuild-stepwise into main
pull from: codex/housekeeping-rebuild-stepwise
14 changed files with 1049 additions and 296 deletions
Showing only changes of commit 866f38818b - Show all commits

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.
+23 -38
View File
@@ -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`,
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,
);
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));
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>);
},
);
+23 -43
View File
@@ -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"),
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,
);
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");
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();
},
);
File diff suppressed because it is too large. Load diff
+61 -1
View File
@@ -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
View File
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
@@ -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([
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
@@ -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);
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff