diff --git a/apps/2026/src/lib/hacker-portal.tsx b/apps/2026/src/lib/hacker-portal.tsx index 0376ce55f..21368e234 100644 --- a/apps/2026/src/lib/hacker-portal.tsx +++ b/apps/2026/src/lib/hacker-portal.tsx @@ -611,13 +611,11 @@ export function useHackerDashboardFlow( const { client } = useHackerSdkClient(); const confirmKey = useIdempotencyLease("confirm"); const withdrawKey = useIdempotencyLease("withdraw"); - const checkInPassKey = useIdempotencyLease("check-in-pass"); const qrMutation = useMutation({ mutationFn: async () => { const pass = await issuePassMutation.mutateAsync({ - idempotencyKey: checkInPassKey.acquire("issue"), + idempotencyKey: "check-in-pass:v1", }); - checkInPassKey.release(); return QRCode.toDataURL(pass.payload, { margin: 4, width: 640 }); }, }); diff --git a/packages/api/src/hacker-portal/mutations.ts b/packages/api/src/hacker-portal/mutations.ts index 86f3e74cf..0b78bb682 100644 --- a/packages/api/src/hacker-portal/mutations.ts +++ b/packages/api/src/hacker-portal/mutations.ts @@ -17,6 +17,7 @@ import { Hacker, HackerAttendee, HackerCheckInPass, + HackerParticipantCommand, HackerProfile, } from "@forge/db/schemas/knight-hacks"; @@ -803,7 +804,18 @@ export async function withdrawApplication( .set({ lastStatusSendId: sendId, status: "withdrawn" }) .where(eq(HackerAttendee.id, application.attendeeId)); await removeHackerTeamMember(tx, application.attendeeId); - await revokeActivePasses(tx, application.attendeeId, now); + await tx + .delete(HackerCheckInPass) + .where(eq(HackerCheckInPass.attendeeId, application.attendeeId)); + await tx + .delete(HackerParticipantCommand) + .where( + and( + eq(HackerParticipantCommand.userId, ctx.session.userId), + eq(HackerParticipantCommand.hackathonId, hackathon.id), + eq(HackerParticipantCommand.operation, "issue_check_in_pass"), + ), + ); await createAdminAuditEvent( { actionKey: "hacker.application_withdrawn", diff --git a/packages/api/src/tests/hackathon-events/check-in.test.ts b/packages/api/src/tests/hackathon-events/check-in.test.ts index 7c5bc7acb..ec7c630a9 100644 --- a/packages/api/src/tests/hackathon-events/check-in.test.ts +++ b/packages/api/src/tests/hackathon-events/check-in.test.ts @@ -1,3 +1,4 @@ +import { createHash } from "node:crypto"; import { afterAll, beforeAll, beforeEach, describe, expect, it } from "vitest"; import type { db } from "@forge/db/client"; @@ -208,6 +209,7 @@ describe.skipIf(!canRunDatabaseTests())("hackathon event check-in core", () => { await client.delete(knightHacks.HackerDiscordRoleGrant); await client.delete(knightHacks.HackerCheckInAttempt); await client.delete(knightHacks.HackerEventAttendee); + await client.delete(knightHacks.HackerCheckInPass); await client.update(knightHacks.HackerAttendee).set({ checkedInAt: null, checkedInBy: null, @@ -255,6 +257,125 @@ describe.skipIf(!canRunDatabaseTests())("hackathon event check-in core", () => { source: "scanner" as const, }); + it("accepts current and older QRs while the attendee has an active pass", async () => { + const ids = personIds(people.base); + const olderPayload = `fhp1.${"a".repeat(43)}`; + const currentPayload = `fhp1.${"b".repeat(43)}`; + const tokenHash = (payload: string) => + createHash("sha256").update(payload).digest("hex"); + await client.insert(knightHacks.HackerCheckInPass).values([ + { + attendeeId: ids.attendeeId, + hackathonId: HACKATHON_ID, + revokedAt: NOW, + tokenHash: tokenHash(olderPayload), + }, + { + attendeeId: ids.attendeeId, + hackathonId: HACKATHON_ID, + tokenHash: tokenHash(currentPayload), + }, + ]); + + const current = await checkIn({ + actor, + input: { + ...scannerInput(PRIMARY_EVENT_ID, people.base, false), + qrPayload: currentPayload, + }, + now: NOW, + }); + expect(current.result).toMatchObject({ + name: "Hacker1 Fixture", + status: "checked_in", + }); + + const older = await checkIn({ + actor, + input: { + ...scannerInput(PRIMARY_EVENT_ID, people.base, false), + qrPayload: olderPayload, + }, + now: NOW, + }); + expect(older.result).toMatchObject({ + name: "Hacker1 Fixture", + status: "already_checked_in", + }); + + await client + .update(knightHacks.HackerCheckInPass) + .set({ revokedAt: LATER }) + .where(eq(knightHacks.HackerCheckInPass.attendeeId, ids.attendeeId)); + const rejected = await checkIn({ + actor, + input: { + ...scannerInput(PRIMARY_EVENT_ID, people.base, false), + qrPayload: olderPayload, + }, + now: LATER, + }); + expect(rejected.result).toEqual({ status: "invalid_qr" }); + }); + + it("rejects expired current and older QRs", async () => { + const expiredCurrent = `fhp1.${"c".repeat(43)}`; + const expiredOlder = `fhp1.${"d".repeat(43)}`; + const replacement = `fhp1.${"e".repeat(43)}`; + const olderWithExpiredReplacement = `fhp1.${"f".repeat(43)}`; + const expiredReplacement = `fhp1.${"g".repeat(43)}`; + const tokenHash = (payload: string) => + createHash("sha256").update(payload).digest("hex"); + await client.insert(knightHacks.HackerCheckInPass).values([ + { + attendeeId: personIds(people.regular).attendeeId, + expiresAt: NOW, + hackathonId: HACKATHON_ID, + tokenHash: tokenHash(expiredCurrent), + }, + { + attendeeId: personIds(people.vip).attendeeId, + expiresAt: NOW, + hackathonId: HACKATHON_ID, + revokedAt: NOW, + tokenHash: tokenHash(expiredOlder), + }, + { + attendeeId: personIds(people.vip).attendeeId, + hackathonId: HACKATHON_ID, + tokenHash: tokenHash(replacement), + }, + { + attendeeId: personIds(people.allocation).attendeeId, + hackathonId: HACKATHON_ID, + revokedAt: NOW, + tokenHash: tokenHash(olderWithExpiredReplacement), + }, + { + attendeeId: personIds(people.allocation).attendeeId, + expiresAt: NOW, + hackathonId: HACKATHON_ID, + tokenHash: tokenHash(expiredReplacement), + }, + ]); + + for (const [person, qrPayload] of [ + [people.regular, expiredCurrent], + [people.vip, expiredOlder], + [people.allocation, olderWithExpiredReplacement], + ] as const) { + const result = await checkIn({ + actor, + input: { + ...scannerInput(PRIMARY_EVENT_ID, person, false), + qrPayload, + }, + now: NOW, + }); + expect(result.result).toEqual({ status: "invalid_qr" }); + } + }); + it("does not make ordinary event scans wait for team allocation", async () => { const { lockHackerTeams } = await import("../../utils/hacker-teams/teams"); await client.transaction(async (tx) => { diff --git a/packages/api/src/tests/integration/hacker-portal-lifecycle.test.ts b/packages/api/src/tests/integration/hacker-portal-lifecycle.test.ts index 22aa0876f..29c702074 100644 --- a/packages/api/src/tests/integration/hacker-portal-lifecycle.test.ts +++ b/packages/api/src/tests/integration/hacker-portal-lifecycle.test.ts @@ -844,7 +844,7 @@ describe.skipIf(!canRunDatabaseTests())("hacker portal lifecycle", () => { ); }); - it("[TC-LIFE-004/005] makes withdrawal terminal and revokes the active pass", async () => { + it("[TC-LIFE-004/005] makes withdrawal terminal and deletes issued passes", async () => { const hackathon = await seedHackathon(); if (!hackathon.applicationAgreementId) throw new Error("Application agreement was not seeded."); @@ -887,7 +887,7 @@ describe.skipIf(!canRunDatabaseTests())("hacker portal lifecycle", () => { "FORBIDDEN_STATUS", ); - const [application, pass] = await Promise.all([ + const [application, pass, passCommands] = await Promise.all([ client .select({ lastStatusSendId: knightHacks.HackerAttendee.lastStatusSendId, @@ -896,13 +896,27 @@ describe.skipIf(!canRunDatabaseTests())("hacker portal lifecycle", () => { .from(knightHacks.HackerAttendee) .where(eq(knightHacks.HackerAttendee.hackathonId, hackathon.id)), client - .select({ revokedAt: knightHacks.HackerCheckInPass.revokedAt }) + .select({ id: knightHacks.HackerCheckInPass.id }) .from(knightHacks.HackerCheckInPass) .where(eq(knightHacks.HackerCheckInPass.hackathonId, hackathon.id)), + client + .select({ id: knightHacks.HackerParticipantCommand.id }) + .from(knightHacks.HackerParticipantCommand) + .where( + and( + eq(knightHacks.HackerParticipantCommand.userId, userId), + eq(knightHacks.HackerParticipantCommand.hackathonId, hackathon.id), + eq( + knightHacks.HackerParticipantCommand.operation, + "issue_check_in_pass", + ), + ), + ), ]); expect(application[0]?.status).toBe("withdrawn"); expect(typeof application[0]?.lastStatusSendId).toBe("string"); - expect(pass[0]?.revokedAt).toBeInstanceOf(Date); + expect(pass).toEqual([]); + expect(passCommands).toEqual([]); }); it("replays a QR pass without storing plaintext and expires its command record", async () => { diff --git a/packages/api/src/utils/hackathon-events/check-in.ts b/packages/api/src/utils/hackathon-events/check-in.ts index d53238414..10cbc7c4b 100644 --- a/packages/api/src/utils/hackathon-events/check-in.ts +++ b/packages/api/src/utils/hackathon-events/check-in.ts @@ -242,9 +242,12 @@ export async function performHackathonEventCheckIn({ input.source === "scanner" ? parseOpaqueHackerCheckInPass(input.qrPayload) : null; - const [opaquePass] = opaquePayload + const [matchedOpaquePass] = opaquePayload ? await tx - .select({ attendeeId: HackerCheckInPass.attendeeId }) + .select({ + attendeeId: HackerCheckInPass.attendeeId, + revokedAt: HackerCheckInPass.revokedAt, + }) .from(HackerCheckInPass) .where( and( @@ -253,7 +256,6 @@ export async function performHackathonEventCheckIn({ HackerCheckInPass.tokenHash, hashOpaqueHackerCheckInPass(opaquePayload), ), - isNull(HackerCheckInPass.revokedAt), or( isNull(HackerCheckInPass.expiresAt), gt(HackerCheckInPass.expiresAt, now), @@ -262,6 +264,30 @@ export async function performHackathonEventCheckIn({ ) .limit(1) : []; + const [activeOpaquePass] = + matchedOpaquePass?.revokedAt === null + ? [matchedOpaquePass] + : matchedOpaquePass + ? await tx + .select({ attendeeId: HackerCheckInPass.attendeeId }) + .from(HackerCheckInPass) + .where( + and( + eq( + HackerCheckInPass.attendeeId, + matchedOpaquePass.attendeeId, + ), + eq(HackerCheckInPass.hackathonId, input.hackathonId), + isNull(HackerCheckInPass.revokedAt), + or( + isNull(HackerCheckInPass.expiresAt), + gt(HackerCheckInPass.expiresAt, now), + ), + ), + ) + .limit(1) + : []; + const opaquePass = activeOpaquePass ? matchedOpaquePass : null; const resolveBy = input.source === "manual" ? eq(HackerAttendee.id, input.attendeeId)