fix(housekeeping): harden people read boundaries

This commit is contained in:
Simo committed 2026-08-29 02:13:53 +02:00
1 parent e3f8b51d31
commit d1382c839e
12 files changed
+1610 -421

No files matched your search

File diff suppressed because it is too large. Load diff
@@ -41,9 +41,12 @@ describe("People list normalization", () => {
"id", "id",
), ),
).toMatchObject({ pageSize: 20, offset: 0 }); ).toMatchObject({ pageSize: 20, offset: 0 });
expect(
normalizePeopleListInput({ offset: 999_999 }, ["id"], "id").offset,
).toBe(10_000);
}); });
it("sorts a page deterministically with an id tie breaker", () => { it("wraps rows already sorted and paged by the adapter", () => {
const input = normalizePeopleListInput( const input = normalizePeopleListInput(
{ pageSize: 2, offset: 1, sort: "username", order: "asc" }, { pageSize: 2, offset: 1, sort: "username", order: "asc" },
["id", "username"], ["id", "username"],
@@ -51,20 +54,17 @@ describe("People list normalization", () => {
); );
const page = createPeoplePage( const page = createPeoplePage(
[ [
{ id: 4, username: "Bob" },
{ id: 3, username: "alice" }, { id: 3, username: "alice" },
{ id: 1, username: "Alice" }, { id: 1, username: "Alice" },
{ id: 2, username: "alice" },
], ],
4, 4,
input, input,
(row) => row.username,
); );
expect(page).toEqual({ expect(page).toEqual({
items: [ items: [
{ id: 2, username: "alice" },
{ id: 3, username: "alice" }, { id: 3, username: "alice" },
{ id: 1, username: "Alice" },
], ],
total: 4, total: 4,
pageSize: 2, pageSize: 2,
@@ -116,6 +116,11 @@ describe("People canonical models", () => {
accountCreated: toPeopleIsoDate(new Date("2026-08-29T10:20:30.000Z")), accountCreated: toPeopleIsoDate(new Date("2026-08-29T10:20:30.000Z")),
lastLogin: toPeopleIsoDate(1_700_000_000), lastLogin: toPeopleIsoDate(1_700_000_000),
sanctions: [], sanctions: [],
watched: false,
permission: {
rankName: "Moderator",
ranks: [{ id: 5, name: "Moderator" }],
},
}; };
expect(JSON.parse(JSON.stringify(detail))).toEqual({ expect(JSON.parse(JSON.stringify(detail))).toEqual({
@@ -132,6 +137,11 @@ describe("People canonical models", () => {
accountCreated: "2026-08-29T10:20:30.000Z", accountCreated: "2026-08-29T10:20:30.000Z",
lastLogin: "2023-11-14T22:13:20.000Z", lastLogin: "2023-11-14T22:13:20.000Z",
sanctions: [], sanctions: [],
watched: false,
permission: {
rankName: "Moderator",
ranks: [{ id: 5, name: "Moderator" }],
},
}); });
}); });
@@ -150,4 +160,16 @@ describe("People canonical models", () => {
"/ase/people/moderation/cfh/8", "/ase/people/moderation/cfh/8",
]); ]);
}); });
it("rejects corrupt identifiers and non-serializable date values", () => {
expect(() =>
normalizePeopleUser(
{ ...rawUser, id: 0 },
{ includeMail: false, includeIp: false },
),
).toThrow();
expect(() => peopleUserHref(Number.MAX_SAFE_INTEGER + 1)).toThrow();
expect(() => peopleGuildHref(-4)).toThrow();
expect(() => toPeopleIsoDate("not-a-date")).toThrow();
});
}); });
File diff suppressed because it is too large. Load diff
@@ -9,6 +9,7 @@ import {
ok, ok,
} from "../../../foundation/contracts"; } from "../../../foundation/contracts";
import { import {
assertPeopleSerializable,
createPeoplePage, createPeoplePage,
type ListInput, type ListInput,
normalizePeopleListInput, normalizePeopleListInput,
@@ -77,9 +78,15 @@ export function createPeopleCommunityQuery(
} }
try { try {
const guild = await adapters.loadGuild(input.id); const guild = await adapters.loadGuild(input.id);
return guild === null if (guild === null) {
? fail("NOT_FOUND", "errors.housekeeping.notFound", correlationId) return fail(
: ok({ kind: "guild" as const, guild }, correlationId); "NOT_FOUND",
"errors.housekeeping.notFound",
correlationId,
);
}
assertPeopleSerializable(guild);
return ok({ kind: "guild" as const, guild }, correlationId);
} catch { } catch {
return unavailable(correlationId); return unavailable(correlationId);
} }
@@ -87,7 +94,9 @@ export function createPeopleCommunityQuery(
const list = normalizePeopleListInput( const list = normalizePeopleListInput(
input.list, input.list,
["id", "username", "name"], input.routeId === "people.community.online"
? ["id", "username"]
: ["id", "name"],
"id", "id",
); );
try { try {
@@ -96,9 +105,7 @@ export function createPeopleCommunityQuery(
return ok( return ok(
{ {
kind: "online" as const, kind: "online" as const,
page: createPeoplePage(result.rows, result.total, list, (row) => page: createPeoplePage(result.rows, result.total, list),
list.sort === "username" ? row.username : row.id,
),
}, },
correlationId, correlationId,
); );
@@ -108,9 +115,7 @@ export function createPeopleCommunityQuery(
return ok( return ok(
{ {
kind: "guilds" as const, kind: "guilds" as const,
page: createPeoplePage(result.rows, result.total, list, (row) => page: createPeoplePage(result.rows, result.total, list),
list.sort === "name" ? row.name : row.id,
),
}, },
correlationId, correlationId,
); );
@@ -122,11 +127,13 @@ export function createPeopleCommunityQuery(
} }
function resultRows<T>(result: unknown): T[] { function resultRows<T>(result: unknown): T[] {
if (!Array.isArray(result)) return []; if (!Array.isArray(result) || !Array.isArray(result[0])) {
return Array.isArray(result[0]) ? (result[0] as T[]) : []; throw new Error("invalid People driver result");
}
return result[0] as T[];
} }
export const peopleCommunityAdapters: PeopleCommunityAdapters = { const peopleCommunityAdapters: PeopleCommunityAdapters = {
async loadOnline(input) { async loadOnline(input) {
const [{ sql }, { db }] = await Promise.all([ const [{ sql }, { db }] = await Promise.all([
import("drizzle-orm"), import("drizzle-orm"),
@@ -144,7 +151,7 @@ export const peopleCommunityAdapters: PeopleCommunityAdapters = {
ORDER BY ${input.sort === "username" ? sql`username` : sql`id`} ${ ORDER BY ${input.sort === "username" ? sql`username` : sql`id`} ${
input.order === "desc" ? sql`DESC` : sql`ASC` input.order === "desc" ? sql`DESC` : sql`ASC`
}, id ASC }, id ASC
LIMIT ${input.offset + input.pageSize} LIMIT ${input.pageSize} OFFSET ${input.offset}
`), `),
db.execute(sql`SELECT COUNT(*) AS total FROM users WHERE ${where}`), db.execute(sql`SELECT COUNT(*) AS total FROM users WHERE ${where}`),
]); ]);
@@ -187,7 +194,7 @@ export const peopleCommunityAdapters: PeopleCommunityAdapters = {
ORDER BY ${input.sort === "name" ? sql`g.name` : sql`g.id`} ${ ORDER BY ${input.sort === "name" ? sql`g.name` : sql`g.id`} ${
input.order === "desc" ? sql`DESC` : sql`ASC` input.order === "desc" ? sql`DESC` : sql`ASC`
}, g.id ASC }, g.id ASC
LIMIT ${input.offset + input.pageSize} LIMIT ${input.pageSize} OFFSET ${input.offset}
`), `),
db.execute(sql` db.execute(sql`
SELECT COUNT(*) AS total SELECT COUNT(*) AS total
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
@@ -75,11 +75,13 @@ export function createPeopleStaffQuery(
const authorization = authorizeHousekeeping(context, requirement); const authorization = authorizeHousekeeping(context, requirement);
if (!authorization.ok) return authorization; if (!authorization.ok) return authorization;
const correlationId = authorization.correlationId; const correlationId = authorization.correlationId;
const list = normalizePeopleListInput( const allowedSorts =
input.list, input.routeId === "people.staff.applications"
["id", "name", "username", "createdAt"], ? ["id", "username", "createdAt"]
"id", : input.routeId === "people.staff.teams"
); ? ["id", "name"]
: ["id", "username"];
const list = normalizePeopleListInput(input.list, allowedSorts, "id");
try { try {
if (input.routeId === "people.staff.applications") { if (input.routeId === "people.staff.applications") {
@@ -87,13 +89,7 @@ export function createPeopleStaffQuery(
return ok( return ok(
{ {
kind: "applications" as const, kind: "applications" as const,
page: createPeoplePage(result.rows, result.total, list, (row) => page: createPeoplePage(result.rows, result.total, list),
list.sort === "username"
? (row.username ?? "")
: list.sort === "createdAt"
? (row.createdAt ?? "")
: row.id,
),
}, },
correlationId, correlationId,
); );
@@ -104,9 +100,7 @@ export function createPeopleStaffQuery(
return ok( return ok(
{ {
kind: "teams" as const, kind: "teams" as const,
page: createPeoplePage(result.rows, result.total, list, (row) => page: createPeoplePage(result.rows, result.total, list),
list.sort === "name" ? row.name : row.id,
),
}, },
correlationId, correlationId,
); );
@@ -116,9 +110,7 @@ export function createPeopleStaffQuery(
return ok( return ok(
{ {
kind: "moderation-team" as const, kind: "moderation-team" as const,
page: createPeoplePage(result.rows, result.total, list, (row) => page: createPeoplePage(result.rows, result.total, list),
list.sort === "username" ? row.username : row.id,
),
}, },
correlationId, correlationId,
); );
@@ -134,11 +126,13 @@ export function createPeopleStaffQuery(
} }
function resultRows<T>(result: unknown): T[] { function resultRows<T>(result: unknown): T[] {
if (!Array.isArray(result)) return []; if (!Array.isArray(result) || !Array.isArray(result[0])) {
return Array.isArray(result[0]) ? (result[0] as T[]) : []; throw new Error("invalid People driver result");
}
return result[0] as T[];
} }
export const peopleStaffAdapters: PeopleStaffAdapters = { const peopleStaffAdapters: PeopleStaffAdapters = {
async loadApplications(input) { async loadApplications(input) {
const [{ sql }, { db }] = await Promise.all([ const [{ sql }, { db }] = await Promise.all([
import("drizzle-orm"), import("drizzle-orm"),
@@ -158,7 +152,7 @@ export const peopleStaffAdapters: PeopleStaffAdapters = {
ORDER BY ${input.sort === "username" ? sql`u.username` : input.sort === "createdAt" ? sql`a.created_at` : sql`a.id`} ${ ORDER BY ${input.sort === "username" ? sql`u.username` : input.sort === "createdAt" ? sql`a.created_at` : sql`a.id`} ${
input.order === "asc" ? sql`ASC` : sql`DESC` input.order === "asc" ? sql`ASC` : sql`DESC`
}, a.id ASC }, a.id ASC
LIMIT ${input.offset + input.pageSize} LIMIT ${input.pageSize} OFFSET ${input.offset}
`), `),
db.execute(sql` db.execute(sql`
SELECT COUNT(*) AS total SELECT COUNT(*) AS total
@@ -205,7 +199,7 @@ export const peopleStaffAdapters: PeopleStaffAdapters = {
ORDER BY ${input.sort === "name" ? sql`rank_name` : sql`id`} ${ ORDER BY ${input.sort === "name" ? sql`rank_name` : sql`id`} ${
input.order === "desc" ? sql`DESC` : sql`ASC` input.order === "desc" ? sql`DESC` : sql`ASC`
}, id ASC }, id ASC
LIMIT ${input.offset + input.pageSize} LIMIT ${input.pageSize} OFFSET ${input.offset}
`), `),
db.execute(sql`SELECT COUNT(*) AS total FROM website_teams ${where}`), db.execute(sql`SELECT COUNT(*) AS total FROM website_teams ${where}`),
]); ]);
@@ -248,7 +242,7 @@ export const peopleStaffAdapters: PeopleStaffAdapters = {
ORDER BY ${input.sort === "username" ? sql`u.username` : sql`u.id`} ${ ORDER BY ${input.sort === "username" ? sql`u.username` : sql`u.id`} ${
input.order === "desc" ? sql`DESC` : sql`ASC` input.order === "desc" ? sql`DESC` : sql`ASC`
}, u.id ASC }, u.id ASC
LIMIT ${input.offset + input.pageSize} LIMIT ${input.pageSize} OFFSET ${input.offset}
`), `),
db.execute(sql` db.execute(sql`
SELECT COUNT(*) AS total FROM users u SELECT COUNT(*) AS total FROM users u
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
+20 -10
View File
@@ -4,23 +4,33 @@ import { count, eq, ne } from "drizzle-orm";
import { db, WebsiteHelpCenterTickets, WebsiteTicket } from "@/lib/db"; import { db, WebsiteHelpCenterTickets, WebsiteTicket } from "@/lib/db";
/** Open-queue sizes for dual ticket products (CMS desk vs help-center). */ /** Open-queue sizes for dual ticket products (CMS desk vs help-center). */
export async function fetchTicketQueueOpenCounts(): Promise<{ export async function fetchTicketQueueOpenCounts(options?: {
readonly strict?: boolean;
}): Promise<{
cmsOpen: number; cmsOpen: number;
helpOpen: number; helpOpen: number;
}> { }> {
const [cmsOpen, helpOpen] = await Promise.all([ const cms = db
db
.select({ total: count() }) .select({ total: count() })
.from(WebsiteTicket) .from(WebsiteTicket)
.where(ne(WebsiteTicket.status, "closed")) .where(ne(WebsiteTicket.status, "closed"));
.then((rows) => rows[0]?.total ?? 0) const help = db
.catch(() => 0),
db
.select({ total: count() }) .select({ total: count() })
.from(WebsiteHelpCenterTickets) .from(WebsiteHelpCenterTickets)
.where(eq(WebsiteHelpCenterTickets.open, true)) .where(eq(WebsiteHelpCenterTickets.open, true));
.then((rows) => rows[0]?.total ?? 0) const readCount = async (
.catch(() => 0), query: typeof cms | typeof help,
): Promise<number> => {
const rows = await query;
const value = Number(rows[0]?.total);
if (!Number.isSafeInteger(value) || value < 0) {
throw new Error("invalid ticket queue count");
}
return value;
};
const [cmsOpen, helpOpen] = await Promise.all([
options?.strict ? readCount(cms) : readCount(cms).catch(() => 0),
options?.strict ? readCount(help) : readCount(help).catch(() => 0),
]); ]);
return { cmsOpen, helpOpen }; return { cmsOpen, helpOpen };
} }