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(''); - expect(result).not.toContain("onload"); - expect(result).not.toContain("javascript"); - } catch (err) { - expect(err).toBeInstanceOf(TRPCError); - } - }); - - it("should block explicit javascript protocol references", () => { - const payload = 'javascript:alert("hacked")'; - expect(() => sanitizeInput(payload)).toThrowError(TRPCError); - }); - - it("should clean nested script evasion attempts", () => { - const result = sanitizeInput('<'); - expect(result).not.toContain(" { - const result = sanitizeInput( - "This is a text with < than and > than symbols.", - ); - expect(result).toBe( - "This is a text with < than and > than symbols.", + /** + * Dangerous markup is REJECTED, never rewritten: rewriting silently changes + * what somebody wrote, and an HTML parser over prose eats "loss` have to survive untouched. + */ + describe("1. Input sanitization — executable markup", () => { + it("refuses script tags rather than quietly removing them", () => { + expect(() => scrubMarkup('hello')).toThrow( + TRPCError, ); }); - }); - describe("2. Input Sanitization - SQL Injection Protections", () => { - it("should block classic union select injections", () => { - const payload = "1 UNION SELECT username, password FROM users"; - expect(() => sanitizeInput(payload)).toThrowError(TRPCError); + it("refuses a tag carrying an event handler", () => { + expect(() => + scrubMarkup(''), + ).toThrow(TRPCError); + expect(() => scrubMarkup('')).toThrow(TRPCError); }); - it("should block SQL query stacking comments", () => { - const payload = "DROP TABLE hackathons; -- "; - expect(() => sanitizeInput(payload)).toThrowError(TRPCError); - }); - - it("should block block-comment SQL injection style", () => { - const payload = "SELECT * FROM events /* check comments */ WHERE id = 1"; - expect(() => sanitizeInput(payload)).toThrowError(TRPCError); + it("refuses a javascript: URI even as plain text", () => { + // Whoever renders this into an href gets an executable link. + expect(() => scrubMarkup('javascript:alert("hacked")')).toThrow( + TRPCError, + ); }); - it("should block case-insensitive SQL keywords combinations", () => { - const payload = "uNiOn SeLeCt secret FROM credentials"; - expect(() => sanitizeInput(payload)).toThrowError(TRPCError); - }); - }); - - describe("3. Input Sanitization - NoSQL Query Injection Protections", () => { - it("should filter out the where MongoDB operator", () => { - const payload = { $where: 'this.role == "admin"' }; - const result = sanitizeInput(payload) as Record; - expect(result.$where).toBeUndefined(); + it("refuses nested-tag evasion attempts", () => { + expect(() => scrubMarkup('<')).toThrow( + TRPCError, + ); }); - it("should filter out gt and lt MongoDB operators", () => { - const payload = { $gt: "0", $lt: "100", validKey: "data" }; - const result = sanitizeInput(payload) as Record; - expect(result.$gt).toBeUndefined(); - expect(result.$lt).toBeUndefined(); - expect(result.validKey).toBe("data"); + /** + * The case that makes rejection the right design: a hackathon is full of + * people writing comparisons and generics, and none of it may be mangled + * or bounced. + */ + it("passes ordinary prose and code through byte for byte", () => { + const prose = + "picks the class where loss a { - const payload = { $ne: "admin", $eq: "user", username: "guest" }; - const result = sanitizeInput(payload) as Record; - expect(result.$ne).toBeUndefined(); - expect(result.$eq).toBeUndefined(); - expect(result.username).toBe("guest"); + /** + * SQL is NOT guessed at. Parameterised queries already make it a non-issue, + * and pattern-matching prose for keywords rejects "select a track from the + * list" — a sentence somebody will genuinely write in a submission. + */ + it("does not reject prose that happens to read like SQL", () => { + const text = "select a track from the list, then update your project"; + expect(scrubMarkup(text)).toBe(text); }); }); - describe("4. Input Sanitization - Prototype Pollution Protections", () => { - it("should drop proto key assignments", () => { + describe("2. Input sanitization — object shape", () => { + it("drops prototype-polluting keys", () => { const payload = JSON.parse( - '{"__proto__": {"maliciousProperty": "injected"}}', + '{"__proto__": {"maliciousProperty": "injected"}, "name": "ok"}', ); - const result = sanitizeInput(payload) as Record; + const result = scrubMarkup(payload) as Record; expect(Object.prototype.hasOwnProperty.call(result, "__proto__")).toBe( false, ); - expect(({} as any).maliciousProperty).toBeUndefined(); + expect((({}) as Record).maliciousProperty).toBeUndefined(); + expect(result.name).toBe("ok"); }); - it("should drop constructor key assignments", () => { - const payload = JSON.parse( + it("drops constructor and prototype keys", () => { + const withConstructor = JSON.parse( '{"constructor": {"prototype": {"polluted": "yes"}}}', ); - const result = sanitizeInput(payload) as Record; - expect(Object.prototype.hasOwnProperty.call(result, "constructor")).toBe( - false, - ); - expect(({} as any).polluted).toBeUndefined(); - }); - - it("should drop prototype key assignments", () => { - const payload = { prototype: { admin: true }, username: "normal" }; - const result = sanitizeInput(payload) as Record; + expect( + Object.prototype.hasOwnProperty.call( + scrubMarkup(withConstructor) as object, + "constructor", + ), + ).toBe(false); + + const result = scrubMarkup({ + prototype: { admin: true }, + username: "normal", + }) as Record; expect(Object.prototype.hasOwnProperty.call(result, "prototype")).toBe( false, ); expect(result.username).toBe("normal"); }); - }); - describe("5. Input Sanitization - Complexity & Deep Nesting Limits", () => { - const makeNestedObject = (depth: number): any => { - if (depth === 0) return "leaf"; - return { node: makeNestedObject(depth - 1) }; - }; - - it("should allow object nesting level equal to 9", () => { - const payload = makeNestedObject(9); - expect(sanitizeInput(payload)).toBeDefined(); + it("drops keys that are not plain identifiers", () => { + // $where and friends never reach a query builder here, but a key shaped + // like that has no business in a payload either. + const result = scrubMarkup({ + $where: 'this.role == "admin"', + validKey: "data", + }) as Record; + expect(result.$where).toBeUndefined(); + expect(result.validKey).toBe("data"); }); + }); - it("should allow object nesting level equal to 10", () => { - const payload = makeNestedObject(10); - expect(sanitizeInput(payload)).toBeDefined(); - }); + describe("3. Input sanitization — complexity limits", () => { + const makeNestedObject = (depth: number): unknown => + depth === 0 ? "leaf" : { node: makeNestedObject(depth - 1) }; - it("should reject object nesting level equal to 11", () => { - const payload = makeNestedObject(11); - expect(() => sanitizeInput(payload)).toThrowError( + it("allows nesting up to ten levels and refuses eleven", () => { + expect(scrubMarkup(makeNestedObject(9))).toBeDefined(); + expect(scrubMarkup(makeNestedObject(10))).toBeDefined(); + expect(() => scrubMarkup(makeNestedObject(11))).toThrow( "Input too deeply nested", ); }); - it("should allow objects with exactly 50 keys", () => { - const payload: Record = {}; - for (let i = 0; i < 50; i++) { - payload[`key_${i}`] = i; - } - expect(sanitizeInput(payload)).toBeDefined(); - }); + it("allows fifty keys and refuses fifty-one", () => { + const fifty: Record = {}; + for (let i = 0; i < 50; i++) fifty[`key_${i}`] = i; + expect(scrubMarkup(fifty)).toBeDefined(); - it("should reject objects with more than 50 keys", () => { - const payload: Record = {}; - for (let i = 0; i < 51; i++) { - payload[`key_${i}`] = i; - } - expect(() => sanitizeInput(payload)).toThrowError("Object too complex"); + const fiftyOne: Record = { ...fifty, key_50: 50 }; + expect(() => scrubMarkup(fiftyOne)).toThrow("Object too complex"); }); - it("should allow arrays with exactly 500 elements", () => { - const payload = new Array(500).fill("valid"); - expect(sanitizeInput(payload)).toBeDefined(); + /** + * The array cap must stay at or above the largest `.max()` any schema + * declares. It sat at 500 while batchUpdateParticipantStatus allowed 2500, + * which made approving a 2000-person roster impossible in one call — and + * the error named neither the real limit nor the field. + */ + it("allows 2500 array elements and refuses 2501", () => { + expect(scrubMarkup(new Array(2500).fill("valid"))).toBeDefined(); + expect(() => scrubMarkup(new Array(2501).fill("invalid"))).toThrow( + "Array too large", + ); }); - it("should reject arrays with more than 500 elements", () => { - const payload = new Array(501).fill("invalid"); - expect(() => sanitizeInput(payload)).toThrowError("Array too large"); + it("refuses a non-finite number", () => { + expect(() => scrubMarkup(Number.POSITIVE_INFINITY)).toThrow( + "Invalid number", + ); + expect(() => scrubMarkup(Number.NaN)).toThrow("Invalid number"); }); }); @@ -259,6 +230,53 @@ describe("Security and Protection Verification Suite", () => { vi.useRealTimers(); }); + + /** + * Backoff is exponential in the violation count, so without a decay that + * can actually fire, one bad afternoon escalates a legitimate attendee to + * five-minute blocks for the rest of the event. + * + * The old decay compared against lastRefill, which is stamped on every + * request — so it could only fire for somebody who had stopped making + * requests altogether, which is precisely who does not need forgiving. + */ + it("forgives violations one clear period at a time", () => { + vi.useFakeTimers(); + const user = "reformed-user"; + + // Trip it twice: the second violation costs 2s. + rateLimit(user, 1, 0, 1); + expect(rateLimit(user, 1, 0, 1).retryAfter).toBe(1); + vi.advanceTimersByTime(1100); + expect(rateLimit(user, 1, 0, 1).retryAfter).toBe(2); + + // One clear period forgives one step: count 2 -> 1, so the next + // violation is priced at 2^1 rather than 2^2. + vi.advanceTimersByTime(11 * 60 * 1000); + expect(rateLimit(user, 1, 0, 1).retryAfter).toBe(2); + + // A second clear period clears the slate entirely. + vi.advanceTimersByTime(21 * 60 * 1000); + expect(rateLimit(user, 1, 0, 1).retryAfter).toBe(1); + + vi.useRealTimers(); + }); + + // A caller who keeps hammering must not have their count decayed by the + // passage of time alone — the decay is measured from the last violation. + it("keeps escalating a caller who never stops violating", () => { + vi.useFakeTimers(); + const user = "persistent-user"; + + rateLimit(user, 1, 0, 1); + expect(rateLimit(user, 1, 0, 1).retryAfter).toBe(1); + vi.advanceTimersByTime(1100); + expect(rateLimit(user, 1, 0, 1).retryAfter).toBe(2); + vi.advanceTimersByTime(2100); + expect(rateLimit(user, 1, 0, 1).retryAfter).toBe(4); + + vi.useRealTimers(); + }); }); describe("8. DDoS Burst Interception", () => { diff --git a/packages/api/src/middleware/cache.ts b/packages/api/src/middleware/cache.ts index 74f9ffda..24553526 100644 --- a/packages/api/src/middleware/cache.ts +++ b/packages/api/src/middleware/cache.ts @@ -213,11 +213,11 @@ export class CacheService { // Global cache instance export const cache = new CacheService(300, 10000); // 5 minutes default TTL, max 10000 entries -/** - * Cache statistics for metrics/monitoring export - * Access via cache.getStats() or cache.exportStats() +/* + * There is deliberately no exported `cacheStats` snapshot. One existed, taken + * once at module load, so it reported zeroes forever — call cache.getStats() + * for a live reading. */ -export const cacheStats = cache.exportStats(); /** * TTL (seconds) for state an admin can flip mid-event — hackathon status, @@ -232,17 +232,20 @@ export const cacheStats = cache.exportStats(); */ export const VOLATILE_TTL = 5; -// Cache key builders for consistency +/** + * Cache key builders, for the keys that are actually written. + * + * `hackathons()`, `events()`, `user()`, `event()` and `member()` used to be + * here too, and every one described a shape nothing writes — the real keys + * carry a suffix (`events:list:all`, `member:me:`). Builders for keys that + * do not exist are how the invalidation map ended up evicting nothing: they + * read as authoritative and match zero entries. + */ export const CacheKeys = { - user: (userId: string) => `user:${userId}`, userProfile: (userId: string) => `user:${userId}:profile`, admin: (userId: string) => `admin:${userId}`, hackathon: (id: string) => `hackathon:${id}`, - hackathons: () => `hackathons:list`, - event: (id: string) => `event:${id}`, - events: () => `events:list`, judge: (userId: string) => `judge:${userId}`, - member: (userId: string) => `member:${userId}`, projectLeader: (userId: string) => `project-leader:${userId}`, portalContext: (userId: string) => `user:${userId}:portal`, } as const; @@ -276,15 +279,10 @@ export const clearMembershipCaches = (userId: string) => { invalidatePortalContext(userId); }; -// Cache invalidation helpers -export const invalidateUser = (userId: string) => { - cache.deletePattern(`user:${userId}*`); -}; - -export const invalidateHackathons = () => { - cache.deletePattern("hackathon*"); -}; - -export const invalidateEvents = () => { - cache.deletePattern("event*"); -}; +/* + * `invalidateUser`, `invalidateHackathons` and `invalidateEvents` used to live + * here with no callers. Eviction happens through CACHE_INVALIDATION_MAP or by + * id in the resolver that wrote the row — a broad namespace sweep is exactly + * the P5 mistake (`deletePattern("hackathon*")` once wiped every attendee's + * cached registrations on every badge scan). + */ diff --git a/packages/api/src/middleware/http-security.ts b/packages/api/src/middleware/http-security.ts deleted file mode 100644 index 2a4e8a4d..00000000 --- a/packages/api/src/middleware/http-security.ts +++ /dev/null @@ -1,237 +0,0 @@ -/** - * HTTP Security Headers and Utilities - * Provides comprehensive security headers for API responses - */ - -import { resolveClientIp } from "./security"; - -export interface SecurityHeaders { - "X-Content-Type-Options": string; - "X-Frame-Options": string; - "X-XSS-Protection": string; - "Strict-Transport-Security": string; - "Content-Security-Policy": string; - "Referrer-Policy": string; - "Permissions-Policy": string; - "X-Request-Id": string; -} - -export interface CacheHeaders { - "Cache-Control": string; - Vary: string; -} - -export interface RateLimitHeaders { - "X-RateLimit-Limit": string; - "X-RateLimit-Remaining": string; - "X-RateLimit-Reset": string; - "Retry-After"?: string; -} - -/** - * Generate strict security headers for API responses - */ -export async function getSecurityHeaders(): Promise { - const crypto = await import("crypto"); - const generateRequestId = () => crypto.randomUUID(); - - return { - // Prevent MIME type sniffing - "X-Content-Type-Options": "nosniff", - - // Prevent clickjacking - "X-Frame-Options": "DENY", - - // Enable XSS protection (legacy but still useful) - "X-XSS-Protection": "1; mode=block", - - // Force HTTPS for 1 year - "Strict-Transport-Security": "max-age=31536000; includeSubDomains", - - // Content Security Policy - strict for API - // Relaxed to allow API responses (script-src for CSP nonce support) - "Content-Security-Policy": - "default-src 'none'; script-src 'self'; frame-ancestors 'none'", - - // Referrer policy - "Referrer-Policy": "strict-origin-when-cross-origin", - - // Permissions policy - disable all features - "Permissions-Policy": - "geolocation=(), microphone=(), camera=(), usb=(), midi=()", - - // Request ID for observability - "X-Request-Id": generateRequestId(), - }; -} - -/** - * Generate cache control headers based on cacheability - */ -export function getCacheHeaders(options: { - cacheable: boolean; - maxAge?: number; - private?: boolean; - mustRevalidate?: boolean; -}): CacheHeaders { - const { - cacheable, - maxAge = 300, - private: isPrivate = true, - mustRevalidate = true, - } = options; - - if (!cacheable) { - return { - "Cache-Control": "no-store, no-cache, must-revalidate, private", - Vary: "Accept-Encoding, Authorization", - }; - } - - const directives = [isPrivate ? "private" : "public", `max-age=${maxAge}`]; - - if (mustRevalidate) { - directives.push("must-revalidate"); - } - - return { - "Cache-Control": directives.join(", "), - Vary: "Accept-Encoding, Authorization", - }; -} - -/** - * Generate rate limit headers - */ -export function getRateLimitHeaders( - limit: number, - remaining: number, - resetTimestamp: number, - retryAfter?: number, -): RateLimitHeaders { - const headers: RateLimitHeaders = { - "X-RateLimit-Limit": limit.toString(), - "X-RateLimit-Remaining": Math.max(0, remaining).toString(), - "X-RateLimit-Reset": resetTimestamp.toString(), - }; - - if (retryAfter !== undefined) { - headers["Retry-After"] = retryAfter.toString(); - } - - return headers; -} - -/** - * Get client IP address from request, considering proxies - */ -export function getClientIp(request: Request): string { - // Check X-Forwarded-For header (from proxies/load balancers) - const forwardedFor = request.headers.get("x-forwarded-for"); - if (forwardedFor) { - // From the right, not the left: the leading entries are caller-supplied. - return resolveClientIp(forwardedFor); - } - - // Check X-Real-IP header - const realIp = request.headers.get("x-real-ip"); - if (realIp) { - return realIp.trim(); - } - - // Fallback to unknown - return "unknown"; -} - -/** - * Generate a fingerprint for rate limiting - * Combines IP, user agent, and other factors - */ -export function getRequestFingerprint( - request: Request, - userId?: string, -): string { - const ip = getClientIp(request); - const userAgent = request.headers.get("user-agent") || "unknown"; - - // Hash the user agent to keep fingerprint shorter - const uaHash = simpleHash(userAgent); - - if (userId) { - return `user:${userId}:${ip}`; - } - - return `anon:${ip}:${uaHash}`; -} - -/** - * Simple hash function for strings - */ -function simpleHash(str: string): string { - let hash = 0; - for (let i = 0; i < str.length; i++) { - const char = str.charCodeAt(i); - hash = (hash << 5) - hash + char; - hash = hash & hash; // Convert to 32-bit integer - } - return Math.abs(hash).toString(36); -} - -/** - * Apply all headers to a Response object - */ -export async function applySecurityHeaders( - response: Response, - options?: { - cacheable?: boolean; - maxAge?: number; - rateLimit?: { - limit: number; - remaining: number; - reset: number; - retryAfter?: number; - }; - request?: Request; // Optional request for X-Request-Id - }, -): Promise { - const headers = new Headers(response.headers); - - // Apply security headers - const securityHeaders = await getSecurityHeaders(); - Object.entries(securityHeaders).forEach(([key, value]) => { - headers.set(key, value); - }); - - // Apply cache headers - const cacheHeaders = getCacheHeaders({ - cacheable: options?.cacheable ?? false, - maxAge: options?.maxAge, - }); - Object.entries(cacheHeaders).forEach(([key, value]) => { - headers.set(key, value); - }); - - // Apply rate limit headers if provided - if (options?.rateLimit) { - const rateLimitHeaders = getRateLimitHeaders( - options.rateLimit.limit, - options.rateLimit.remaining, - options.rateLimit.reset, - options.rateLimit.retryAfter, - ); - Object.entries(rateLimitHeaders).forEach(([key, value]) => { - if (value !== undefined) { - headers.set(key, value); - } - }); - } - - // Clone and read the body to avoid "body already consumed" errors - const body = await response.clone().arrayBuffer(); - - return new Response(body, { - status: response.status, - statusText: response.statusText, - headers, - }); -} diff --git a/packages/api/src/middleware/security.ts b/packages/api/src/middleware/security.ts index fd443f43..1acb5d03 100644 --- a/packages/api/src/middleware/security.ts +++ b/packages/api/src/middleware/security.ts @@ -1,13 +1,24 @@ -import { TRPCError } from "@trpc/server"; -import sanitizeHtml from "sanitize-html"; interface RateLimitRecord { tokens: number; lastRefill: number; violations: number; blockedUntil: number; + /** + * When this caller last exceeded their bucket. + * + * Separate from lastRefill because that is stamped on every request, so a + * decay measured against it can only fire for somebody who has stopped + * making requests entirely. Backoff is exponential in `violations`, so + * without a decay that actually fires, one bad afternoon escalates a + * legitimate user to five-minute blocks for the rest of the event. + */ + lastViolation: number; } +/** How long a caller must behave for one violation to be forgiven. */ +const VIOLATION_DECAY_MS = 10 * 60 * 1000; + const MAX_RATE_LIMIT_STORE_SIZE = 10000; // Limit rate limit store size to prevent memory bloat const MAX_IP_TRACKING_STORE_SIZE = 50000; // Limit IP tracking store size @@ -39,12 +50,33 @@ const TRUSTED_PROXY_HOPS = Number(process.env.TRUSTED_PROXY_HOPS ?? 1); * pin a bucket to a victim's address to have that victim blocked. Only the * entries our own proxies appended can be trusted, and those are at the end. */ +/** + * Logged once per process, so the hop count can be checked against reality + * instead of assumed. + * + * Getting TRUSTED_PROXY_HOPS wrong is silent in both directions and expensive + * both ways: too few and a CDN address becomes everyone's bucket, so one + * limit covers the entire internet; too many and the value is caller-supplied, + * letting somebody pick their own bucket or pin a block onto a victim. One + * line at startup is enough to confirm which shape the deployment actually + * has, and costs nothing per request. + */ +let loggedForwardedForShape = false; + export const resolveClientIp = (forwardedFor: string | null | undefined) => { const parts = (forwardedFor ?? "") .split(",") .map((part) => part.trim()) .filter(Boolean); + if (!loggedForwardedForShape && parts.length > 0) { + loggedForwardedForShape = true; + // eslint-disable-next-line no-console + console.log( + `[Security] x-forwarded-for has ${parts.length} entr${parts.length === 1 ? "y" : "ies"}; TRUSTED_PROXY_HOPS=${TRUSTED_PROXY_HOPS} selects index ${Math.max(0, parts.length - 1 - TRUSTED_PROXY_HOPS)}. Expect hops = entries - 1.`, + ); + } + if (parts.length === 0) return "unknown"; const index = Math.max(0, parts.length - 1 - TRUSTED_PROXY_HOPS); @@ -83,9 +115,17 @@ const evictOldest = ( const enforceSizeLimit = () => { const now = Date.now(); - // Expired records first, so eviction usually has nothing left to do. + // Idle records first, so eviction usually has nothing left to do. + // + // Both halves of this condition matter. A healthy record carries + // `blockedUntil: 0`, so testing that alone deleted EVERY bucket on every + // tick — the whole token-bucket store was erased once a minute and each + // caller got a fresh full bucket back regardless of how they had behaved, + // which quietly made the limiter little more than a per-minute burst cap. for (const [key, value] of rateLimitStore.entries()) { - if (now > value.blockedUntil) { + const idle = now - value.lastRefill > 30 * 60 * 1000; + const blockElapsed = now > value.blockedUntil; + if (idle && blockElapsed) { rateLimitStore.delete(key); } } @@ -154,6 +194,7 @@ export function rateLimit( lastRefill: now, violations: 0, blockedUntil: 0, + lastViolation: 0, }; rateLimitStore.set(identifier, record); } @@ -170,8 +211,23 @@ export function rateLimit( record.tokens = Math.min(maxTokens, record.tokens + refill); record.lastRefill = now; + // Decay one step per clear period since the last violation, so somebody who + // tripped the limit once and then behaved normally returns to a clean slate + // instead of carrying an escalating backoff for the rest of the weekend. + // Applied before the check below so a caller who has waited out their + // penalty is not immediately re-escalated from the old count. + if (record.violations > 0 && record.lastViolation > 0) { + const clearPeriods = Math.floor( + (now - record.lastViolation) / VIOLATION_DECAY_MS, + ); + if (clearPeriods > 0) { + record.violations = Math.max(0, record.violations - clearPeriods); + } + } + if (record.tokens < tokensToConsume) { record.violations++; + record.lastViolation = now; const backoffSeconds = Math.min(Math.pow(2, record.violations - 1), 300); record.blockedUntil = now + backoffSeconds * 1000; @@ -183,10 +239,6 @@ export function rateLimit( record.tokens -= tokensToConsume; - if (record.violations > 0 && elapsed > 600) { - record.violations = Math.max(0, record.violations - 1); - } - return { allowed: true }; } @@ -217,135 +269,22 @@ export const RATE_LIMITS = { }, } as const; -const SANITIZE_OPTIONS: sanitizeHtml.IOptions = { - allowedTags: [], - allowedAttributes: {}, - disallowedTagsMode: "discard", - nonTextTags: ["style", "script", "textarea", "noscript", "option", "xmp"], -}; - -export function sanitizeInput(input: unknown, depth: number = 0): unknown { - if (depth > 10) { - throw new TRPCError({ - code: "BAD_REQUEST", - message: "Input too deeply nested", - }); - } - - if (input === null || input === undefined) { - return input; - } - - if (typeof input === "string") { - const sanitized = sanitizeHtml(input, SANITIZE_OPTIONS) - .trim() - .slice(0, 10000); - - if (hasInjectionPattern(sanitized)) { - throw new TRPCError({ - code: "BAD_REQUEST", - message: "Invalid input", - }); - } - - return sanitized; - } - - if (typeof input === "number") { - if (!Number.isFinite(input)) { - throw new TRPCError({ - code: "BAD_REQUEST", - message: "Invalid number", - }); - } - return input; - } - - if (typeof input === "boolean") { - return input; - } - - if (Array.isArray(input)) { - if (input.length > 500) { - throw new TRPCError({ - code: "BAD_REQUEST", - message: "Array too large", - }); - } - return input.map((item) => sanitizeInput(item, depth + 1)); - } - - if (typeof input === "object") { - const keys = Object.keys(input as object); - if (keys.length > 50) { - throw new TRPCError({ - code: "BAD_REQUEST", - message: "Object too complex", - }); - } - - const sanitized: Record = {}; - for (const [key, value] of Object.entries(input as object)) { - if (key === "__proto__" || key === "constructor" || key === "prototype") { - continue; - } - if (!/^[\w.-]{1,100}$/.test(key)) { - continue; - } - sanitized[key] = sanitizeInput(value, depth + 1); - } - return sanitized; - } - - throw new TRPCError({ - code: "BAD_REQUEST", - message: "Invalid input type", - }); -} - -function hasInjectionPattern(str: string): boolean { - const patterns = [ - // SQL SELECT/INSERT/UPDATE/DELETE keywords with FROM/INTO/TABLE/DATABASE - /(\b(union|select|insert|update|delete|drop|create|alter|exec|execute)\b.*\b(from|into|table|database)\b)/i, - // SQL comment sequences: "--" must be followed by space or end-of-string (not in URLs like some--repo) - /(--\s|--$)/, - // Block comment: /* only when followed by * content - /\/\*[\s\S]*?\*\//, - // NoSQL injection - /\$where/i, - /\$gt|\$lt|\$ne|\$eq/i, - // XSS - /
+

+ {currentReg.registrationStatus === "approved" + ? "You're accepted — check in at the event before submitting. Find a volunteer and have your badge scanned." + : currentReg.registrationStatus === "pending" + ? "Your registration is still being reviewed. You can form a team once you have been accepted." + : `Your registration for this hackathon is ${currentReg.registrationStatus}.`} +

+
+ )} + {/* The window this form is gated on, said before it is filled in. Without it an attendee wrote a full description and learned it was refused only on submit. */} diff --git a/sites/mainweb/app/api/csp-report/route.ts b/sites/mainweb/app/api/csp-report/route.ts new file mode 100644 index 00000000..f64a612a --- /dev/null +++ b/sites/mainweb/app/api/csp-report/route.ts @@ -0,0 +1,59 @@ +import { NextResponse } from "next/server"; +import type { NextRequest } from "next/server"; + +/** + * Where Content-Security-Policy violations land while the policy is + * report-only. + * + * The point of report-only is to find out what the policy *would* have blocked + * before it blocks it — and a policy nobody can see the reports from tells you + * nothing. Browsers POST here on every violation; the container collects + * stderr, so `[CSP]` lines in the logs are the record of what to fix (or + * allow) before CSP_ENFORCE is turned on. + * + * Deliberately unauthenticated: the browser sends these without credentials, + * and the body is a report about our own pages. It is rate-limited by being + * useless to an attacker — the worst case is noise in a log, so the size cap + * below is the only guard that matters. + */ + +/** Reports are small. Anything larger is not a browser. */ +const MAX_REPORT_BYTES = 8 * 1024; + +export async function POST(request: NextRequest) { + try { + /** + * Checked BEFORE the body is read, not after. + * + * `await request.text()` buffers the whole request first, so a + * length check on the result has already accepted whatever was sent — the + * cap was documentation rather than a limit. An absent or unparseable + * Content-Length is refused too: this endpoint is unauthenticated, and a + * browser sending a violation report always declares one. + */ + const declared = Number(request.headers.get("content-length")); + + if (!Number.isFinite(declared) || declared > MAX_REPORT_BYTES) { + return new NextResponse(null, { status: 413 }); + } + + const body = await request.text(); + + // Content-Length is the sender's claim; the body is the truth. + if (body.length > MAX_REPORT_BYTES) { + return new NextResponse(null, { status: 413 }); + } + + // Two formats in the wild: the legacy `report-uri` shape + // ({"csp-report": {...}}) and the newer Reporting API array. Log whichever + // arrives rather than parsing both into one shape — this is a diagnostic, + // not a data pipeline. + console.warn("[CSP] violation report:", body.slice(0, MAX_REPORT_BYTES)); + } catch (error) { + console.error("[CSP] failed to read a violation report:", error); + } + + // 204 regardless: a failed report must never look like a page error to the + // browser that sent it. + return new NextResponse(null, { status: 204 }); +} diff --git a/sites/mainweb/components/admin/hackathons/AnnouncementsTab.tsx b/sites/mainweb/components/admin/hackathons/AnnouncementsTab.tsx index 1f98078a..e3a7a89b 100644 --- a/sites/mainweb/components/admin/hackathons/AnnouncementsTab.tsx +++ b/sites/mainweb/components/admin/hackathons/AnnouncementsTab.tsx @@ -26,8 +26,28 @@ const AUDIENCES = [ label: "On site", hint: "Checked in at the door", }, + { + id: "not_accepted" as const, + label: "Not accepted", + hint: "Rejected and waitlisted — excluded from every other audience", + }, ]; +/** + * A starting point for the one message that is hard to write, and easy to get + * wrong by sending the wrong tone from a blank box. Entirely editable — it is + * prefilled only when the audience is selected and nothing has been typed yet. + */ +const NOT_ACCEPTED_TEMPLATE = { + subject: "An update on your application", + heading: "An update on your application", + body: [ + "Thank you for applying. We had far more applications than places this year, and we were not able to offer you one.", + "This is not a judgement of you or your work — the numbers simply did not allow it, and turning people down is the part of running this we like least.", + "We would genuinely welcome an application from you next time, and our other events are open to everyone in the meantime.", + ].join("\n\n"), +}; + type Audience = (typeof AUDIENCES)[number]["id"]; export function AnnouncementsTab({ hackathonId }: { hackathonId: string }) { @@ -211,7 +231,21 @@ export function AnnouncementsTab({ hackathonId }: { hackathonId: string }) {