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
+1635 -446

No files matched your search

File diff suppressed because it is too large. Load diff
@@ -41,9 +41,12 @@ describe("People list normalization", () => {
"id",
),
).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(
{ pageSize: 2, offset: 1, sort: "username", order: "asc" },
["id", "username"],
@@ -51,20 +54,17 @@ describe("People list normalization", () => {
);
const page = createPeoplePage(
[
{ id: 4, username: "Bob" },
{ id: 3, username: "alice" },
{ id: 1, username: "Alice" },
{ id: 2, username: "alice" },
],
4,
input,
(row) => row.username,
);
expect(page).toEqual({
items: [
{ id: 2, username: "alice" },
{ id: 3, username: "alice" },
{ id: 1, username: "Alice" },
],
total: 4,
pageSize: 2,
@@ -116,6 +116,11 @@ describe("People canonical models", () => {
accountCreated: toPeopleIsoDate(new Date("2026-08-29T10:20:30.000Z")),
lastLogin: toPeopleIsoDate(1_700_000_000),
sanctions: [],
watched: false,
permission: {
rankName: "Moderator",
ranks: [{ id: 5, name: "Moderator" }],
},
};
expect(JSON.parse(JSON.stringify(detail))).toEqual({
@@ -132,6 +137,11 @@ describe("People canonical models", () => {
accountCreated: "2026-08-29T10:20:30.000Z",
lastLogin: "2023-11-14T22:13:20.000Z",
sanctions: [],
watched: false,
permission: {
rankName: "Moderator",
ranks: [{ id: 5, name: "Moderator" }],
},
});
});
@@ -150,4 +160,16 @@ describe("People canonical models", () => {
"/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,
} from "../../../foundation/contracts";
import {
assertPeopleSerializable,
createPeoplePage,
type ListInput,
normalizePeopleListInput,
@@ -77,9 +78,15 @@ export function createPeopleCommunityQuery(
}
try {
const guild = await adapters.loadGuild(input.id);
return guild === null
? fail("NOT_FOUND", "errors.housekeeping.notFound", correlationId)
: ok({ kind: "guild" as const, guild }, correlationId);
if (guild === null) {
return fail(
"NOT_FOUND",
"errors.housekeeping.notFound",
correlationId,
);
}
assertPeopleSerializable(guild);
return ok({ kind: "guild" as const, guild }, correlationId);
} catch {
return unavailable(correlationId);
}
@@ -87,7 +94,9 @@ export function createPeopleCommunityQuery(
const list = normalizePeopleListInput(
input.list,
["id", "username", "name"],
input.routeId === "people.community.online"
? ["id", "username"]
: ["id", "name"],
"id",
);
try {
@@ -96,9 +105,7 @@ export function createPeopleCommunityQuery(
return ok(
{
kind: "online" as const,
page: createPeoplePage(result.rows, result.total, list, (row) =>
list.sort === "username" ? row.username : row.id,
),
page: createPeoplePage(result.rows, result.total, list),
},
correlationId,
);
@@ -108,9 +115,7 @@ export function createPeopleCommunityQuery(
return ok(
{
kind: "guilds" as const,
page: createPeoplePage(result.rows, result.total, list, (row) =>
list.sort === "name" ? row.name : row.id,
),
page: createPeoplePage(result.rows, result.total, list),
},
correlationId,
);
@@ -122,11 +127,13 @@ export function createPeopleCommunityQuery(
}
function resultRows<T>(result: unknown): T[] {
if (!Array.isArray(result)) return [];
return Array.isArray(result[0]) ? (result[0] as T[]) : [];
if (!Array.isArray(result) || !Array.isArray(result[0])) {
throw new Error("invalid People driver result");
}
return result[0] as T[];
}
export const peopleCommunityAdapters: PeopleCommunityAdapters = {
const peopleCommunityAdapters: PeopleCommunityAdapters = {
async loadOnline(input) {
const [{ sql }, { db }] = await Promise.all([
import("drizzle-orm"),
@@ -144,7 +151,7 @@ export const peopleCommunityAdapters: PeopleCommunityAdapters = {
ORDER BY ${input.sort === "username" ? sql`username` : sql`id`} ${
input.order === "desc" ? sql`DESC` : sql`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}`),
]);
@@ -187,7 +194,7 @@ export const peopleCommunityAdapters: PeopleCommunityAdapters = {
ORDER BY ${input.sort === "name" ? sql`g.name` : sql`g.id`} ${
input.order === "desc" ? sql`DESC` : sql`ASC`
}, g.id ASC
LIMIT ${input.offset + input.pageSize}
LIMIT ${input.pageSize} OFFSET ${input.offset}
`),
db.execute(sql`
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);
if (!authorization.ok) return authorization;
const correlationId = authorization.correlationId;
const list = normalizePeopleListInput(
input.list,
["id", "name", "username", "createdAt"],
"id",
);
const allowedSorts =
input.routeId === "people.staff.applications"
? ["id", "username", "createdAt"]
: input.routeId === "people.staff.teams"
? ["id", "name"]
: ["id", "username"];
const list = normalizePeopleListInput(input.list, allowedSorts, "id");
try {
if (input.routeId === "people.staff.applications") {
@@ -87,13 +89,7 @@ export function createPeopleStaffQuery(
return ok(
{
kind: "applications" as const,
page: createPeoplePage(result.rows, result.total, list, (row) =>
list.sort === "username"
? (row.username ?? "")
: list.sort === "createdAt"
? (row.createdAt ?? "")
: row.id,
),
page: createPeoplePage(result.rows, result.total, list),
},
correlationId,
);
@@ -104,9 +100,7 @@ export function createPeopleStaffQuery(
return ok(
{
kind: "teams" as const,
page: createPeoplePage(result.rows, result.total, list, (row) =>
list.sort === "name" ? row.name : row.id,
),
page: createPeoplePage(result.rows, result.total, list),
},
correlationId,
);
@@ -116,9 +110,7 @@ export function createPeopleStaffQuery(
return ok(
{
kind: "moderation-team" as const,
page: createPeoplePage(result.rows, result.total, list, (row) =>
list.sort === "username" ? row.username : row.id,
),
page: createPeoplePage(result.rows, result.total, list),
},
correlationId,
);
@@ -134,11 +126,13 @@ export function createPeopleStaffQuery(
}
function resultRows<T>(result: unknown): T[] {
if (!Array.isArray(result)) return [];
return Array.isArray(result[0]) ? (result[0] as T[]) : [];
if (!Array.isArray(result) || !Array.isArray(result[0])) {
throw new Error("invalid People driver result");
}
return result[0] as T[];
}
export const peopleStaffAdapters: PeopleStaffAdapters = {
const peopleStaffAdapters: PeopleStaffAdapters = {
async loadApplications(input) {
const [{ sql }, { db }] = await Promise.all([
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`} ${
input.order === "asc" ? sql`ASC` : sql`DESC`
}, a.id ASC
LIMIT ${input.offset + input.pageSize}
LIMIT ${input.pageSize} OFFSET ${input.offset}
`),
db.execute(sql`
SELECT COUNT(*) AS total
@@ -205,7 +199,7 @@ export const peopleStaffAdapters: PeopleStaffAdapters = {
ORDER BY ${input.sort === "name" ? sql`rank_name` : sql`id`} ${
input.order === "desc" ? sql`DESC` : sql`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}`),
]);
@@ -248,7 +242,7 @@ export const peopleStaffAdapters: PeopleStaffAdapters = {
ORDER BY ${input.sort === "username" ? sql`u.username` : sql`u.id`} ${
input.order === "desc" ? sql`DESC` : sql`ASC`
}, u.id ASC
LIMIT ${input.offset + input.pageSize}
LIMIT ${input.pageSize} OFFSET ${input.offset}
`),
db.execute(sql`
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
+23 -13
View File
@@ -4,23 +4,33 @@ import { count, eq, ne } from "drizzle-orm";
import { db, WebsiteHelpCenterTickets, WebsiteTicket } from "@/lib/db";
/** 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;
helpOpen: number;
}> {
const cms = db
.select({ total: count() })
.from(WebsiteTicket)
.where(ne(WebsiteTicket.status, "closed"));
const help = db
.select({ total: count() })
.from(WebsiteHelpCenterTickets)
.where(eq(WebsiteHelpCenterTickets.open, true));
const readCount = async (
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([
db
.select({ total: count() })
.from(WebsiteTicket)
.where(ne(WebsiteTicket.status, "closed"))
.then((rows) => rows[0]?.total ?? 0)
.catch(() => 0),
db
.select({ total: count() })
.from(WebsiteHelpCenterTickets)
.where(eq(WebsiteHelpCenterTickets.open, true))
.then((rows) => rows[0]?.total ?? 0)
.catch(() => 0),
options?.strict ? readCount(cms) : readCount(cms).catch(() => 0),
options?.strict ? readCount(help) : readCount(help).catch(() => 0),
]);
return { cmsOpen, helpOpen };
}