Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
81 changes: 81 additions & 0 deletions packages/api/src/.internal-tests/hackathon-admin-edge.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,7 @@ vi.mock("@query/db", () => {
stripePayments: table("stripePayments"),
userAccountLinks: table("userAccountLinks"),
auditLogs: table("auditLogs"),
hackathonBans: table("hackathonBans"),
},
insert: (...insertArgs: any[]) => ({
values: (...valArgs: any[]) => {
Expand Down Expand Up @@ -124,6 +125,12 @@ vi.mock("@query/db", () => {
role: "role",
},
users: { _t: "users", id: "id", email: "email" },
hackathonBans: {
_t: "hackathonBans",
id: "id",
email: "email",
createdAt: "created_at",
},
userProfiles: { _t: "userProfiles", userId: "user_id" },
hackathons: {
_t: "hackathons",
Expand Down Expand Up @@ -500,6 +507,80 @@ describe("Hackathon admin management edge cases", () => {
});
});

// =====================================================================
describe("Hackathon bans", () => {
const BANNED = "Banned.Person@Example.com";

// A signed-in participant whose email comes from their user row, the
// path taken when the session carries no email.
const participant = (banned: boolean) => {
mockFindFirst.mockImplementation((table: string) => {
if (table === "users") return { email: BANNED };
if (table === "hackathonBans") return banned ? { id: "ban-1" } : undefined;
return undefined;
});
return appRouter.createCaller(createMockCtx("participant-user"));
};

it("refuses a banned person every way of taking part", async () => {
const caller = participant(true);
const refused = /can't take part in Hacklytics/;

await expect(
caller.hackathon.registerInterest({ hackathonId: HACK_A }),
).rejects.toThrow(refused);
await expect(
caller.team.joinTeam({ inviteCode: "ABCDEF" } as never),
).rejects.toThrow(refused);
await expect(
caller.team.createTeam({ hackathonId: HACK_A, name: "Team" } as never),
).rejects.toThrow(refused);
});

it("lets everyone else through the ban gate", async () => {
const caller = participant(false);
const result = await caller.hackathon
.registerInterest({ hackathonId: HACK_A })
.catch((e: Error) => e);
expect(String(result)).not.toMatch(/can't take part/);
});

it("stores the ban by lowercased email and audits it", async () => {
const caller = adminCaller({}, "admin");
mockInsert.mockReturnValue([{ id: "ban-1" }]);

await caller.hackathon.banFromHackathons({
email: BANNED,
reason: "Harassment at Hacklytics 2026",
});

const values = mockInsert.mock.calls.map((c) => c[2]?.[0]);
expect(values).toContainEqual(
expect.objectContaining({ email: "banned.person@example.com" }),
);
expect(values).toContainEqual(
expect.objectContaining({ action: "hackathon.ban" }),
);
});

it("refuses a bug tester banning or lifting", async () => {
const caller = adminCaller({}, "bug_tester");

await expect(
caller.hackathon.banFromHackathons({ email: BANNED, reason: "Test" }),
).rejects.toThrow(/read-only/);
await expect(
caller.hackathon.liftHackathonBan({ email: BANNED }),
).rejects.toThrow(/read-only/);
});

it("refuses a non-staff account the ban list", async () => {
await expect(participant(false).hackathon.listBans()).rejects.toThrow(
/Admin access required/,
);
});
});

const liveHackathon = (overrides: Record<string, unknown> = {}) => ({
id: HACK_A,
name: "Hacklytics 2027",
Expand Down
50 changes: 50 additions & 0 deletions packages/api/src/middleware/procedures.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@ import { TRPCError } from "@trpc/server";
import { protectedProcedure } from "../trpc";
import {
admins,
hackathonBans,
users,
judges,
judgingProjects,
judgeQueue,
Expand Down Expand Up @@ -261,3 +263,51 @@ export const isJudge = protectedProcedure.use(async ({ ctx, next, getRawInput })
return next({ ctx: { ...ctx, judge } });
},
);

/** Cache key for a hackathon-ban lookup. Shared with the ban/unban mutations. */
export const hackathonBanCacheKey = (email: string) =>
`hackathon-ban:${email.trim().toLowerCase()}`;

// Refuses people banned from hackathons. Goes on the procedures where someone
// takes part — registering, the interest list, teams, submitting, applying to
// judge — and not on reads or on the ways out (withdraw, leave), so a banned
// person can still see and undo what they already did. The ban is by email,
// so it holds across providers and new accounts on the same address. Cached
// 60s, the negative answer too; ban and unban clear the key.
export const notHackathonBanned = protectedProcedure.use(
async ({ ctx, next }) => {
const db = ctx.db as NonNullable<typeof ctx.db>;

let email = ctx.session?.user?.email ?? null;
if (!email) {
const user = await db.query.users.findFirst({
where: eq(users.id, ctx.userId as string),
columns: { email: true },
});
email = user?.email ?? null;
}

if (email) {
const key = hackathonBanCacheKey(email);
let banned = ctx.cache.get<boolean>(key);
if (banned === null) {
const ban = await db.query.hackathonBans.findFirst({
where: eq(hackathonBans.email, email.trim().toLowerCase()),
columns: { id: true },
});
banned = !!ban;
ctx.cache.set(key, banned, 60);
}

if (banned) {
throw new TRPCError({
code: "FORBIDDEN",
message:
"You can't take part in Hacklytics events. If you think this is a mistake, email hello@hacklytics.io.",
});
}
}

return next({ ctx });
},
);
117 changes: 117 additions & 0 deletions packages/api/src/routers/hackathon/bans.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
import { z } from "zod";
import { desc, eq, inArray } from "drizzle-orm";
import { hackathonBans, users } from "@query/db";
import type { DrizzleDB } from "@query/db";
import { createTRPCRouter } from "../../trpc";
import { hackathonBanCacheKey, isAdmin } from "../../middleware/procedures";
import { recordAdminAction } from "../../middleware/audit";

const normalize = (email: string) => email.trim().toLowerCase();

/**
* Hackathon bans. Staff can bar someone from taking part in hackathons
* (registering, the interest list, teams, submitting, judging) without
* touching their club membership. Enforcement is notHackathonBanned in
* middleware/procedures.ts; this router only manages the list. Bug testers
* can read it and nothing else, like every isAdmin mutation.
*/
export const hackathonBansRouter = createTRPCRouter({
listBans: isAdmin.query(async ({ ctx }) => {
const db = ctx.db as DrizzleDB;
const bans = await db.query.hackathonBans.findMany({
orderBy: [desc(hackathonBans.createdAt)],
});

// Names for the people involved, in one read rather than one per row.
const emails = bans.map((b) => b.email);
const staffIds = bans
.map((b) => b.bannedBy)
.filter((id): id is string => !!id);
const [accounts, staff] = await Promise.all([
emails.length
? db.query.users.findMany({
where: inArray(users.email, emails),
columns: { email: true, name: true },
})

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ban list misses mixed-case accounts

Low Severity

listBans matches accounts with a case-sensitive inArray on users.email against already-lowercased ban emails. OAuth still stores the provider's original casing, so those people show as having no account and lose their name even though the ban itself is in force.

Additional Locations (1)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 5a67e1e. Configure here.

: [],
staffIds.length
? db.query.users.findMany({
where: inArray(users.id, staffIds),
columns: { id: true, name: true, email: true },
})
: [],
]);

return bans.map((ban) => {
const account = accounts.find((a) => normalize(a.email) === ban.email);
const by = staff.find((s) => s.id === ban.bannedBy);
return {
id: ban.id,
email: ban.email,
name: account?.name ?? null,
hasAccount: !!account,
reason: ban.reason,
bannedBy: by?.name ?? by?.email ?? null,
createdAt: ban.createdAt,
};
});
}),

banFromHackathons: isAdmin
.input(
z.object({
email: z.string().trim().email().max(320),
reason: z.string().trim().min(3).max(500),
}),
)
.mutation(async ({ ctx, input }) => {
const db = ctx.db as DrizzleDB;
const email = normalize(input.email);

// Re-banning updates the reason instead of failing on the unique email.
const [ban] = await db
.insert(hackathonBans)
.values({ email, reason: input.reason, bannedBy: ctx.userId })
.onConflictDoUpdate({
target: hackathonBans.email,
set: { reason: input.reason, bannedBy: ctx.userId },
})
.returning();

ctx.cache.delete(hackathonBanCacheKey(email));

await recordAdminAction(db, {
userId: ctx.userId,
action: "hackathon.ban",
resourceId: email,
severity: "warn",
metadata: { reason: input.reason },
});

return ban;
}),

liftHackathonBan: isAdmin
.input(z.object({ email: z.string().trim().email().max(320) }))
.mutation(async ({ ctx, input }) => {
const db = ctx.db as DrizzleDB;
const email = normalize(input.email);

const removed = await db
.delete(hackathonBans)
.where(eq(hackathonBans.email, email))
.returning({ id: hackathonBans.id });

ctx.cache.delete(hackathonBanCacheKey(email));

if (removed.length) {
await recordAdminAction(db, {
userId: ctx.userId,
action: "hackathon.unban",
resourceId: email,
});
}

return { lifted: removed.length > 0 };
}),
});
2 changes: 2 additions & 0 deletions packages/api/src/routers/hackathon/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { hackathonEventsRouter } from "./events";
import { hackathonContentRouter } from "./content";
import { hackathonInterestRouter } from "./interest";
import { hackathonAnnounceRouter } from "./announce";
import { hackathonBansRouter } from "./bans";

export const hackathonRouter = mergeRouters(
hackathonCrudRouter,
Expand All @@ -15,4 +16,5 @@ export const hackathonRouter = mergeRouters(
hackathonContentRouter,
hackathonInterestRouter,
hackathonAnnounceRouter,
hackathonBansRouter,
);
4 changes: 2 additions & 2 deletions packages/api/src/routers/hackathon/interest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ import {
protectedProcedure,
publicProcedure,
} from "../../trpc";
import { isAdmin } from "../../middleware/procedures";
import { isAdmin, notHackathonBanned } from "../../middleware/procedures";
import { rateLimit } from "../../middleware/security";
import { VOLATILE_TTL } from "../../middleware/cache";

Expand Down Expand Up @@ -154,7 +154,7 @@ export const hackathonInterestRouter = createTRPCRouter({
// Upserted, so submitting twice edits one entry rather than failing on the
// unique index or quietly creating a second. Somebody correcting their
// graduation year should not have to find a delete button.
registerInterest: protectedProcedure
registerInterest: notHackathonBanned
.input(interestInput)
.mutation(async ({ ctx, input }) => {
const db = ctx.db as DrizzleDB;
Expand Down
3 changes: 2 additions & 1 deletion packages/api/src/routers/hackathon/registration.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { z } from "zod";
import { TRPCError } from "@trpc/server";
import { createTRPCRouter, protectedProcedure, publicProcedure } from "../../trpc";
import { notHackathonBanned } from "../../middleware/procedures";
import { CacheKeys } from "../../middleware/cache";
import {
hackathons,
Expand Down Expand Up @@ -37,7 +38,7 @@ const isDuplicateRegistration = (error: unknown) => {
};

export const hackathonRegistrationRouter = createTRPCRouter({
register: protectedProcedure
register: notHackathonBanned
.input(
z.object({
hackathonId: z.string().uuid("Invalid hackathon ID"),
Expand Down
10 changes: 7 additions & 3 deletions packages/api/src/routers/judge/admin.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { z } from "zod";
import { TRPCError } from "@trpc/server";
import { createTRPCRouter, protectedProcedure } from "../../trpc";
import { createTRPCRouter } from "../../trpc";
import {
judges,
judgeAssignments,
Expand All @@ -13,7 +13,11 @@ import {
hackathonParticipants,
} from "@query/db";
import { eq, and, asc, sql, inArray, isNull } from "drizzle-orm";
import { isAdmin, isSuperAdmin } from "../../middleware/procedures";
import {
isAdmin,
isSuperAdmin,
notHackathonBanned,
} from "../../middleware/procedures";
import { CacheKeys, invalidatePortalContext } from "../../middleware/cache";
import type { DrizzleDB } from "@query/db";
import { isLive, loadGroups, loadPool } from "./dispatch";
Expand Down Expand Up @@ -911,7 +915,7 @@ export const judgeAdminRouter = createTRPCRouter({
return result;
}),

register: protectedProcedure
register: notHackathonBanned
.input(
z.object({
hackathonId: z.string().uuid(),
Expand Down
7 changes: 4 additions & 3 deletions packages/api/src/routers/team.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { z } from "zod";
import { TRPCError } from "@trpc/server";
import { createTRPCRouter, protectedProcedure } from "../trpc";
import { notHackathonBanned } from "../middleware/procedures";
import {
hackathonTeams,
hackathonParticipants,
Expand Down Expand Up @@ -211,7 +212,7 @@ async function assertNoLiveSoloSubmission(
}

export const teamRouter = createTRPCRouter({
createTeam: protectedProcedure
createTeam: notHackathonBanned
.input(
z.object({
hackathonId: z.string().uuid("Invalid hackathon ID"),
Expand Down Expand Up @@ -305,7 +306,7 @@ export const teamRouter = createTRPCRouter({
}
}),

joinTeam: protectedProcedure
joinTeam: notHackathonBanned
.input(
z.object({
hackathonId: z.string().uuid("Invalid hackathon ID"),
Expand Down Expand Up @@ -663,7 +664,7 @@ export const teamRouter = createTRPCRouter({
}
}),

submitProject: protectedProcedure
submitProject: notHackathonBanned
.input(
z.object({
hackathonId: z.string().uuid(),
Expand Down
Loading
Loading