diff --git a/apphosting.yaml b/apphosting.yaml
index 9d2fad38..6d427961 100644
--- a/apphosting.yaml
+++ b/apphosting.yaml
@@ -1,4 +1,5 @@
-# Monorepo - app is in sites/portal
+# Monorepo — the deployed app is sites/mainweb (workspace name: web).
+# There is no sites/portal; the portal is a route group inside mainweb.
scripts:
buildCommand: pnpm install && pnpm turbo run build --filter=web && mkdir -p sites/mainweb/.next/standalone/sites/mainweb/.next && cp -r sites/mainweb/.next/static sites/mainweb/.next/standalone/sites/mainweb/.next/static && (cp -r sites/mainweb/public sites/mainweb/.next/standalone/sites/mainweb/public || true)
@@ -27,6 +28,15 @@ env:
secret: AUTH_GOOGLE_ID
- variable: GOOGLE_CLIENT_SECRET
secret: AUTH_GOOGLE_SECRET
+ # GitHub sign-in. The provider is only registered when BOTH of these are
+ # present (packages/auth/src/config.ts), so until the secrets exist the
+ # login page hides the button rather than offering one that cannot work.
+ # Callback URL to register on the GitHub OAuth app:
+ # https://datasciencegt.org/api/auth/callback/github
+ - variable: GITHUB_CLIENT_ID
+ secret: AUTH_GITHUB_ID
+ - variable: GITHUB_CLIENT_SECRET
+ secret: AUTH_GITHUB_SECRET
- variable: AUTH_URL
value: https://datasciencegt.org
- variable: NEXTAUTH_URL
@@ -49,8 +59,6 @@ env:
secret: projects/672446353769/secrets/EMAIL_SERVER_PASSWORD
- variable: EMAIL_FROM
value: datascience.gt@gmail.com
- - variable: CRON_SECRET
- secret: CRON_SECRET
# Flood-protection thresholds, sized per instance for a full venue.
# These are the ceiling for one signed-in person, not for the building —
# the limiter keys on user id when somebody is signed in. The short block
@@ -64,3 +72,11 @@ env:
value: "14000"
- variable: DDOS_BLOCK_DURATION_MS
value: "30000"
+ # Proxies between the client and this process that append to
+ # X-Forwarded-For. Set explicitly rather than left to the code default so
+ # the value is reviewable here. Cloud Run behind Google's load balancer is
+ # 1; if a CDN is added in front, this becomes 2 — and getting it wrong is
+ # silent. The process logs the observed header shape once at startup
+ # ("[Security] x-forwarded-for has N entries"); expect hops = entries - 1.
+ - variable: TRUSTED_PROXY_HOPS
+ value: "1"
diff --git a/firebase.json b/firebase.json
index 2f353be7..2bd09047 100644
--- a/firebase.json
+++ b/firebase.json
@@ -1,19 +1,5 @@
{
"hosting": [
- {
- "site": "dsgt-portal",
- "public": "sites/portal/public",
- "cleanUrls": true,
- "rewrites": [
- {
- "source": "**",
- "run": {
- "serviceId": "portal",
- "region": "us-central1"
- }
- }
- ]
- },
{
"site": "dsgt-website",
"public": "sites/mainweb/out",
diff --git a/packages/api/src/.internal-tests/announcements.test.ts b/packages/api/src/.internal-tests/announcements.test.ts
index c6250d01..566fbcce 100644
--- a/packages/api/src/.internal-tests/announcements.test.ts
+++ b/packages/api/src/.internal-tests/announcements.test.ts
@@ -224,6 +224,43 @@ describe("Announcements", () => {
).rejects.toMatchObject({ code: "FORBIDDEN" });
});
+ /**
+ * Rejected and waitlisted applicants are excluded from every other
+ * audience, which left nothing in the product able to reach them at all.
+ * This audience exists to be chosen deliberately, for a message written
+ * for it — so it must be selectable, and must not leak into the others.
+ */
+ it("accepts the not_accepted audience", async () => {
+ asAdmin((table) => (table === "hackathons" ? { id: HACK } : undefined));
+ mockSelectRows.mockReturnValue([
+ { userId: "u1", email: "turned.down@example.com" },
+ ]);
+
+ const res = await callerFor(ADMIN).hackathon.createAnnouncement({
+ ...compose,
+ audience: "not_accepted",
+ subject: "An update on your application",
+ });
+
+ expect(res.totalRecipients).toBe(1);
+ const announcementRow = mockInsert.mock.calls
+ .map((c) => c[2]?.[0])
+ .find((row) => row && !Array.isArray(row) && "audience" in row);
+ expect(announcementRow.audience).toBe("not_accepted");
+ });
+
+ it("refuses an audience name that does not exist", async () => {
+ asAdmin((table) => (table === "hackathons" ? { id: HACK } : undefined));
+
+ await expect(
+ callerFor(ADMIN).hackathon.createAnnouncement({
+ ...compose,
+ // @ts-expect-error deliberately not one of the five audiences
+ audience: "everyone",
+ }),
+ ).rejects.toBeDefined();
+ });
+
});
describe("Sending in batches", () => {
diff --git a/packages/api/src/.internal-tests/hackathon-admin-edge.test.ts b/packages/api/src/.internal-tests/hackathon-admin-edge.test.ts
index 1e22f039..4e8dc5c5 100644
--- a/packages/api/src/.internal-tests/hackathon-admin-edge.test.ts
+++ b/packages/api/src/.internal-tests/hackathon-admin-edge.test.ts
@@ -626,6 +626,75 @@ describe("Hackathon admin management edge cases", () => {
expect(participantWrite?.[2][0].checkedInAt).toBeInstanceOf(Date);
});
+ /**
+ * Reported by review on #325.
+ *
+ * Submitting a project now requires `checked_in`, which makes it an
+ * authorisation state rather than a note about who turned up. Setting it on
+ * somebody still pending would hand them the whole event with no review
+ * having happened — and the attendees screen offers "Select all N
+ * matching", so one wrong click could do it to every applicant at once.
+ */
+ it("refuses to check in an applicant who has not been accepted", async () => {
+ const caller = adminCaller({
+ hackathonParticipants: {
+ id: PART_A1,
+ hackathonId: HACK_A,
+ registrationStatus: "pending",
+ },
+ });
+
+ await expect(
+ caller.hackathon.updateParticipantStatus({
+ hackathonId: HACK_A,
+ participantId: PART_A1,
+ status: "checked_in",
+ }),
+ ).rejects.toMatchObject({ code: "CONFLICT" });
+
+ expect(
+ mockUpdate.mock.calls.find((c) => c[1][0] === hackathonParticipants),
+ ).toBeUndefined();
+ });
+
+ it("names the remedy rather than just refusing", async () => {
+ const caller = adminCaller({
+ hackathonParticipants: {
+ id: PART_A1,
+ hackathonId: HACK_A,
+ registrationStatus: "rejected",
+ },
+ });
+
+ await expect(
+ caller.hackathon.updateParticipantStatus({
+ hackathonId: HACK_A,
+ participantId: PART_A1,
+ status: "checked_in",
+ }),
+ ).rejects.toThrow(/Accept them first/i);
+ });
+
+ // Re-scanning somebody already inside is ordinary, not a transition.
+ it("allows checking in somebody already checked in", async () => {
+ const caller = adminCaller({
+ hackathonParticipants: {
+ id: PART_A1,
+ hackathonId: HACK_A,
+ registrationStatus: "checked_in",
+ checkedInAt: new Date(),
+ },
+ });
+
+ await expect(
+ caller.hackathon.updateParticipantStatus({
+ hackathonId: HACK_A,
+ participantId: PART_A1,
+ status: "checked_in",
+ }),
+ ).resolves.toMatchObject({ success: true });
+ });
+
// BUG: hackathons.currentParticipants is incremented on register and never
// decremented, so rejected applicants permanently consume capacity.
it("frees a seat when an admin rejects a registration", async () => {
diff --git a/packages/api/src/.internal-tests/hackathon-flow.test.ts b/packages/api/src/.internal-tests/hackathon-flow.test.ts
index 9467217c..fea28497 100644
--- a/packages/api/src/.internal-tests/hackathon-flow.test.ts
+++ b/packages/api/src/.internal-tests/hackathon-flow.test.ts
@@ -557,7 +557,13 @@ describe("Hackathon end-to-end flow", () => {
mockFindFirst.mockImplementation((table) => {
if (table === "hackathons") return hackathonStartedHoursAgo(hoursIn);
if (table === "hackathonParticipants")
- return participant ?? { id: PARTICIPANT, teamId: null };
+ return (
+ participant ?? {
+ id: PARTICIPANT,
+ teamId: null,
+ registrationStatus: "checked_in",
+ }
+ );
if (table === "hackathonTeams")
return {
id: "team_1",
@@ -653,7 +659,13 @@ describe("Hackathon end-to-end flow", () => {
hackingStartTime: new Date(Date.now() - 20 * HOUR),
};
if (table === "hackathonParticipants")
- return opts.participant ?? { id: PARTICIPANT, teamId: null };
+ return (
+ opts.participant ?? {
+ id: PARTICIPANT,
+ teamId: null,
+ registrationStatus: "approved",
+ }
+ );
if (table === "hackathonTeams")
return (
opts.team ?? {
@@ -680,7 +692,11 @@ describe("Hackathon end-to-end flow", () => {
it("refuses to let someone join two teams", async () => {
const caller = joinCaller({
- participant: { id: PARTICIPANT, teamId: "team_existing" },
+ participant: {
+ id: PARTICIPANT,
+ teamId: "team_existing",
+ registrationStatus: "approved",
+ },
});
await expect(caller.team.joinTeam(join)).rejects.toThrow(
@@ -749,7 +765,12 @@ describe("Hackathon end-to-end flow", () => {
hackingStartTime: new Date(Date.now() - hoursIn * HOUR),
};
if (table === "hackathonParticipants")
- return { id: PARTICIPANT, teamId: "team_1" };
+ // Submitting requires the badge scan — see checkCheckedIn.
+ return {
+ id: PARTICIPANT,
+ teamId: "team_1",
+ registrationStatus: "checked_in",
+ };
if (table === "hackathonProjects")
return existingProject ? { id: "project_1" } : undefined;
return undefined;
diff --git a/packages/api/src/.internal-tests/participant-edge.test.ts b/packages/api/src/.internal-tests/participant-edge.test.ts
index aaae7b0a..7389fbcc 100644
--- a/packages/api/src/.internal-tests/participant-edge.test.ts
+++ b/packages/api/src/.internal-tests/participant-edge.test.ts
@@ -319,7 +319,10 @@ describe("Participant edge cases", () => {
hackathonId: HACK_A,
userId: "user_a",
teamId: TEAM_A,
- registrationStatus: opts.status ?? "approved",
+ // Submitting requires the badge scan, so "checked in" is the
+ // default state for these tests — they are about ownership and the
+ // clock, not about admission.
+ registrationStatus: opts.status ?? "checked_in",
team: { id: TEAM_A, captainId: opts.captainId ?? "captain_user" },
};
if (table === "hackathonTeams")
@@ -442,7 +445,15 @@ describe("Participant edge cases", () => {
hackingStartTime: new Date(FIXED.getTime() - offsetMs),
};
if (table === "hackathonParticipants")
- return participant ?? { id: PARTICIPANT_A, teamId: null };
+ // Checked in by default: submitting requires it and forming a team
+ // requires acceptance; these tests are about the clock.
+ return (
+ participant ?? {
+ id: PARTICIPANT_A,
+ teamId: null,
+ registrationStatus: "checked_in",
+ }
+ );
if (table === "hackathonTeams")
return {
id: TEAM_A,
@@ -654,6 +665,62 @@ describe("Participant edge cases", () => {
).rejects.toThrow(new RegExp(status));
},
);
+
+ /**
+ * Registration is an application, not admission.
+ *
+ * Every participant is created `pending`, and `pending` used to pass — so
+ * teams formed and projects were submitted with no review having happened,
+ * while organisers were shown an approve/reject screen that decided nothing
+ * but an email.
+ */
+ it("keeps a pending applicant out until they are accepted", async () => {
+ const caller = rejectedApplicant("pending");
+
+ await expect(
+ caller.team.createTeam({
+ hackathonId: HACK_A,
+ name: "meow",
+ maxMembers: 4,
+ }),
+ ).rejects.toThrow(/still being reviewed/i);
+ await expect(
+ caller.team.joinTeam({ hackathonId: HACK_A, teamId: TEAM_A }),
+ ).rejects.toThrow(/still being reviewed/i);
+ });
+
+ it("lets an accepted applicant form a team", async () => {
+ const caller = rejectedApplicant("approved");
+
+ await expect(
+ caller.team.createTeam({
+ hackathonId: HACK_A,
+ name: "meow",
+ maxMembers: 4,
+ }),
+ ).resolves.toMatchObject({ id: TEAM_A });
+ });
+
+ /**
+ * Acceptance is a decision made weeks earlier; it says nothing about
+ * whether somebody turned up. Judging is in person against a table number,
+ * so a submission has to come from a team that is actually in the building.
+ */
+ it("refuses a submission from someone accepted but not checked in", async () => {
+ const caller = rejectedApplicant("approved");
+
+ await expect(caller.team.submitProject(projectInput())).rejects.toThrow(
+ /check in at the event/i,
+ );
+ });
+
+ it("accepts a submission once they have been scanned in", async () => {
+ const caller = rejectedApplicant("checked_in");
+
+ await expect(
+ caller.team.submitProject(projectInput()),
+ ).resolves.toBeDefined();
+ });
});
// =====================================================================
diff --git a/packages/api/src/.internal-tests/resilience.test.ts b/packages/api/src/.internal-tests/resilience.test.ts
index cd8676ac..145accd4 100644
--- a/packages/api/src/.internal-tests/resilience.test.ts
+++ b/packages/api/src/.internal-tests/resilience.test.ts
@@ -1,5 +1,8 @@
import { describe, it, expect, vi } from "vitest";
-import { sanitizeInput } from "../middleware/security";
+// The sanitizer the request path actually runs — see the note in
+// security.test.ts about the second, uncalled implementation these used to
+// target.
+import { scrubMarkup } from "../trpc";
vi.mock("@query/db", () => ({
db: {},
@@ -33,22 +36,28 @@ describe("Resilience and Domain Edge Cases Verification Suite", () => {
const zalgo =
"H\u033d\u0310\u0355e\u033d\u0310\u0355l\u033d\u0310\u0355l\u033d\u0310\u0355o\u033d\u0310\u0355";
- expect(typeof sanitizeInput(zalgo)).toBe("string");
+ expect(typeof scrubMarkup(zalgo)).toBe("string");
// Backtracking on this input would take seconds, not 250ms.
- expect(fastestRun(() => sanitizeInput(zalgo))).toBeLessThan(250);
+ expect(fastestRun(() => scrubMarkup(zalgo))).toBeLessThan(250);
});
it("should handle massive combined character strings efficiently", () => {
const hugeZalgo = "A" + "\u0301".repeat(5000);
- expect(typeof sanitizeInput(hugeZalgo)).toBe("string");
- expect(fastestRun(() => sanitizeInput(hugeZalgo))).toBeLessThan(500);
+ expect(typeof scrubMarkup(hugeZalgo)).toBe("string");
+ expect(fastestRun(() => scrubMarkup(hugeZalgo))).toBeLessThan(500);
});
- it("should handle long plain strings up to the maximum slice length", () => {
+ /**
+ * Long input is passed through, not truncated. Silently cutting a 15,000
+ * character project description at 10,000 is the same class of bug as
+ * rewriting markup: the author is never told, and the loss is permanent.
+ * Length limits belong in each procedure's own schema, where the error can
+ * name the field.
+ */
+ it("passes a long plain string through untouched", () => {
const normalLongString = "b".repeat(15000);
- const result = sanitizeInput(normalLongString) as string;
- expect(result.length).toBe(10000); // Truncation limit
+ expect(scrubMarkup(normalLongString)).toBe(normalLongString);
});
});
@@ -70,7 +79,7 @@ describe("Resilience and Domain Edge Cases Verification Suite", () => {
it("should handle double file extensions safely without modifications", () => {
const name = "document.pdf.png";
- const result = sanitizeInput(name);
+ const result = scrubMarkup(name);
expect(result).toBe("document.pdf.png");
});
});
diff --git a/packages/api/src/.internal-tests/routers.test.ts b/packages/api/src/.internal-tests/routers.test.ts
index 5af01b77..7588017e 100644
--- a/packages/api/src/.internal-tests/routers.test.ts
+++ b/packages/api/src/.internal-tests/routers.test.ts
@@ -5,7 +5,7 @@ import { TRPCError } from "@trpc/server";
import { cache } from "../middleware/cache";
import { db } from "@query/db";
import { errorFormatter } from "../trpc";
-import { sanitizeInput } from "../middleware/security";
+import { scrubMarkup } from "../trpc";
// Fully mock the DB at the file level
const mockFindFirst = vi.fn();
@@ -644,12 +644,11 @@ describe("Router Integration and Access Control Verification Suite", () => {
});
it("should ensure backslash escapes in sql queries are checked securely", () => {
- // Drizzle handles parameterization automatically, so raw inputs are never
- // interpolated directly. We test that inputs containing backslashes are
- // sanitized/passed as single literals.
+ // Drizzle parameterises every query, so raw input is never interpolated.
+ // The sanitizer therefore passes this through byte for byte rather than
+ // guessing at SQL — guessing rejects ordinary prose.
const dangerousValue = "value\\' OR \\'1\\'=\\'1";
- const cleanValue = sanitizeInput(dangerousValue);
- expect(typeof cleanValue).toBe("string");
+ expect(scrubMarkup(dangerousValue)).toBe(dangerousValue);
});
});
@@ -706,6 +705,9 @@ describe("Router Integration and Access Control Verification Suite", () => {
userId: "captain_user_id",
hackathonId,
teamId,
+ // Submitting requires the badge scan; these tests are about the
+ // window, so admission is deliberately out of the way.
+ registrationStatus: "checked_in",
};
}
if (table === "hackathons") {
@@ -748,6 +750,9 @@ describe("Router Integration and Access Control Verification Suite", () => {
userId: "captain_user_id",
hackathonId,
teamId,
+ // Submitting requires the badge scan; these tests are about the
+ // window, so admission is deliberately out of the way.
+ registrationStatus: "checked_in",
};
}
if (table === "hackathons") {
@@ -799,6 +804,9 @@ describe("Router Integration and Access Control Verification Suite", () => {
userId: "captain_user_id",
hackathonId,
teamId,
+ // Submitting requires the badge scan; these tests are about the
+ // window, so admission is deliberately out of the way.
+ registrationStatus: "checked_in",
};
}
if (table === "hackathons") {
diff --git a/packages/api/src/.internal-tests/security.test.ts b/packages/api/src/.internal-tests/security.test.ts
index c2068ec3..aaaeab61 100644
--- a/packages/api/src/.internal-tests/security.test.ts
+++ b/packages/api/src/.internal-tests/security.test.ts
@@ -1,10 +1,14 @@
import { describe, it, expect, vi } from "vitest";
import {
- sanitizeInput,
validateRequestSize,
rateLimit,
ddosProtection,
} from "../middleware/security";
+// The sanitizer the request path actually runs. These tests used to target
+// `sanitizeInput` in middleware/security.ts — a second implementation with
+// different semantics (it stripped markup rather than refusing it) and no
+// caller anywhere, so the suite described behaviour the product did not have.
+import { scrubMarkup } from "../trpc";
import { TRPCError } from "@trpc/server";
vi.mock("@query/db", () => ({
@@ -13,180 +17,147 @@ vi.mock("@query/db", () => ({
}));
describe("Security and Protection Verification Suite", () => {
- describe("1. Input Sanitization - XSS Vulnerability Protections", () => {
- it("should drop script tags completely", () => {
- const result = sanitizeInput('hello');
- expect(result).toBe("hello");
- });
-
- it("should sanitize image tag onerror events", () => {
- try {
- const result = sanitizeInput(
- '
',
- );
- expect(result).not.toContain("onerror");
- } catch (err) {
- expect(err).toBeInstanceOf(TRPCError);
- }
- });
-
- it("should sanitize svg onload actions", () => {
- try {
- const result = sanitizeInput('