Skip to content
Open
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
4 changes: 1 addition & 3 deletions apps/2026/src/lib/hacker-portal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 });
},
});
Expand Down
14 changes: 13 additions & 1 deletion packages/api/src/hacker-portal/mutations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import {
Hacker,
HackerAttendee,
HackerCheckInPass,
HackerParticipantCommand,
HackerProfile,
} from "@forge/db/schemas/knight-hacks";

Expand Down Expand Up @@ -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",
Expand Down
121 changes: 121 additions & 0 deletions packages/api/src/tests/hackathon-events/check-in.test.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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) => {
Expand Down
22 changes: 18 additions & 4 deletions packages/api/src/tests/integration/hacker-portal-lifecycle.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.");
Expand Down Expand Up @@ -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,
Expand All @@ -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 () => {
Expand Down
32 changes: 29 additions & 3 deletions packages/api/src/utils/hackathon-events/check-in.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand All @@ -253,7 +256,6 @@ export async function performHackathonEventCheckIn({
HackerCheckInPass.tokenHash,
hashOpaqueHackerCheckInPass(opaquePayload),
),
isNull(HackerCheckInPass.revokedAt),
or(
isNull(HackerCheckInPass.expiresAt),
gt(HackerCheckInPass.expiresAt, now),
Expand All @@ -262,6 +264,30 @@ export async function performHackathonEventCheckIn({
)
.limit(1)
: [];
const [activeOpaquePass] =
matchedOpaquePass?.revokedAt === null
? [matchedOpaquePass]
: matchedOpaquePass
Comment thread
coderabbitai[bot] marked this conversation as resolved.
? 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)
Expand Down
Loading