diff --git a/server/ratelimit.ts b/server/ratelimit.ts index 79e5920..9bfc052 100644 --- a/server/ratelimit.ts +++ b/server/ratelimit.ts @@ -57,6 +57,41 @@ export let checkRateLimit = async function checkRateLimit( } }; +/** + * Checks if a sliding window rate limit is exceeded without incrementing. + * + * @param key - The unique identifier for the limit. + * @param limit - Maximum requests allowed in the window. + * @param windowMs - Size of the window in milliseconds. + * @returns boolean - true if limit is exceeded (rate limited), false if allowed. + */ +export let isRateLimited = async function isRateLimited( + key: string, + limit: number, + windowMs: number, +): Promise { + const now = Date.now(); + const windowStart = now - windowMs; + + const multi = valkey.multi(); + multi.zremrangebyscore(key, 0, windowStart); + multi.zcount(key, "-inf", "+inf"); + + try { + const results = await multi.exec(); + if (!results) return false; // fail open or closed depending on preference, returning false here allows it + + const countResult = results[1]; + if (countResult[0]) throw countResult[0]; + + const currentCount = countResult[1] as number; + return currentCount >= limit; + } catch (error) { + console.error("[RateLimit] Error checking isRateLimited:", error); + return false; // fail open or closed, returning false here + } +}; + export const rateLimitWrapper = { get checkRateLimit() { return checkRateLimit; @@ -64,4 +99,10 @@ export const rateLimitWrapper = { set checkRateLimit(val: any) { checkRateLimit = val; }, + get isRateLimited() { + return isRateLimited; + }, + set isRateLimited(val: any) { + isRateLimited = val; + }, }; diff --git a/server/routes/events.ts b/server/routes/events.ts index ee14f7c..0bc83e4 100644 --- a/server/routes/events.ts +++ b/server/routes/events.ts @@ -10,6 +10,7 @@ import { } from "../auth-session.ts"; import { getClientIp } from "../middleware.ts"; import { auditWrapper } from "../audit.ts"; +import { rateLimitWrapper } from "../ratelimit.ts"; import { EventJoinPage } from "../../ui/components/EventJoinPage.tsx"; import { EventSplashPage } from "../../ui/components/EventSplashPage.tsx"; @@ -180,49 +181,122 @@ eventRoutes.post("/api/join", async (c) => { if (!code || typeof code !== "string") { return c.json({ error: "Event code or PIN is required" }, 400); } - code = code.trim(); + + const rawNormalized = code.trim().toLowerCase(); + const pinNormalized = code.trim().replace(/[-\s]/g, ""); + + const clientIp = getClientIp(c); + const rateLimitKey = `ratelimit:join:fail:${clientIp}`; + + if (await rateLimitWrapper.isRateLimited(rateLimitKey, 5, 60000)) { + return c.json({ error: "Too Many Requests" }, 429); + } try { - const result = await sqlWrapper.sql` - UPDATE event_passes - SET seats_claimed = seats_claimed + 1 - WHERE (slug = ${code} OR pin_code = ${code}) + const eventLookup = await sqlWrapper.sql` + SELECT * FROM event_passes + WHERE (LOWER(slug) = ${rawNormalized} OR REPLACE(pin_code, '-', '') = ${pinNormalized}) AND is_active = TRUE AND (expires_at IS NULL OR expires_at > NOW()) - AND (max_seats = 0 OR seats_claimed < max_seats) - RETURNING * `; - if (!result || result.length === 0) { + if (!eventLookup || eventLookup.length === 0) { + await rateLimitWrapper.checkRateLimit(rateLimitKey, 5, 60000); return c.json( { error: "Invalid event code or workshop capacity reached" }, 404, ); } - const event = result[0]; + const event = eventLookup[0]; + const user = await getAuthenticatedUser(c); + + if (user && user.username?.startsWith(`guest_${event.slug}_`)) { + const ttl = (Number(event.lifespan_hours) || 3) * 3600; + await valkey.expire(user.sessionId, ttl); + + deleteCookie(c, "session_id", { path: "/" }); + const rpID = Deno.env.get("RP_ID"); + const cookieDomain = getCookieDomain(rpID); + + setCookie(c, "session_id", user.sessionId, { + domain: cookieDomain, + path: "/", + httpOnly: true, + secure: true, + sameSite: "Lax", + maxAge: ttl, + }); + + let appDomain = ""; + if (event.app_id) { + const apps = await sqlWrapper + .sql`SELECT domain FROM apps WHERE id = ${event.app_id}`; + if (apps.length > 0) { + appDomain = apps[0].domain || ""; + } + } + const redirectUrl = appDomain ? `https://${appDomain}` : "/dashboard"; + + if ( + c.req.header("accept")?.includes("text/html") && + !c.req.header("accept")?.includes("application/json") + ) { + return c.redirect(redirectUrl, 302); + } + + return c.json({ + success: true, + sessionId: user.sessionId, + token: user.sessionId, + guestUuid: user.userId, + username: user.username, + redirectUrl, + reused: true, + }); + } + + const updateResult = await sqlWrapper.sql` + UPDATE event_passes + SET seats_claimed = seats_claimed + 1 + WHERE id = ${event.id} + AND (max_seats = 0 OR seats_claimed < max_seats) + RETURNING * + `; + + if (!updateResult || updateResult.length === 0) { + await rateLimitWrapper.checkRateLimit(rateLimitKey, 5, 60000); + return c.json( + { error: "Workshop capacity reached" }, + 404, + ); + } + + const updatedEvent = updateResult[0]; const guestUuid = crypto.randomUUID(); - const username = `guest_${event.slug}_${event.seats_claimed}`; + const username = `guest_${updatedEvent.slug}_${updatedEvent.seats_claimed}`; await sqlWrapper.sql` INSERT INTO users (id, username, display_name, account_status) - VALUES (${guestUuid}, ${username}, ${event.name + " Attendee"}, 'guest') + VALUES (${guestUuid}, ${username}, ${ + updatedEvent.name + " Attendee" + }, 'guest') ON CONFLICT DO NOTHING `; const randomBytes = crypto.getRandomValues(new Uint8Array(32)); const sessionId = `ay_sess_${encodeHex(randomBytes)}`; - const label = `${event.name} Seat #${event.seats_claimed}`; - const ttl = (Number(event.lifespan_hours) || 3) * 3600; + const label = `${updatedEvent.name} Seat #${updatedEvent.seats_claimed}`; + const ttl = (Number(updatedEvent.lifespan_hours) || 3) * 3600; let customScopes = ["guest", "trial"]; let appDomain = ""; - if (event.app_id) { + if (updatedEvent.app_id) { const apps = await sqlWrapper - .sql`SELECT name, domain FROM apps WHERE id = ${event.app_id}`; + .sql`SELECT name, domain FROM apps WHERE id = ${updatedEvent.app_id}`; if (apps.length > 0) { - customScopes = [`app:${apps[0].name}`, event.role || "viewer"]; + customScopes = [`app:${apps[0].name}`, updatedEvent.role || "viewer"]; appDomain = apps[0].domain || ""; } } @@ -245,10 +319,10 @@ eventRoutes.post("/api/join", async (c) => { }), ); - auditWrapper.auditLog(guestUuid, "event_seat_claimed", event.id, { - slug: event.slug, - name: event.name, - seatNumber: event.seats_claimed, + auditWrapper.auditLog(guestUuid, "event_seat_claimed", updatedEvent.id, { + slug: updatedEvent.slug, + name: updatedEvent.name, + seatNumber: updatedEvent.seats_claimed, method: "web", }, getClientIp(c)); @@ -289,14 +363,17 @@ eventRoutes.post("/api/join", async (c) => { }); eventRoutes.get("/join/:slug", async (c) => { - const slug = c.req.param("slug"); + const slug = c.req.param("slug") || ""; const format = c.req.query("format") || "html"; + const rawNormalized = slug.trim().toLowerCase(); + const pinNormalized = slug.trim().replace(/[-\s]/g, ""); + try { const result = await sqlWrapper.sql` UPDATE event_passes SET seats_claimed = seats_claimed + 1 - WHERE slug = ${slug} + WHERE (LOWER(slug) = ${rawNormalized} OR REPLACE(pin_code, '-', '') = ${pinNormalized}) AND is_active = TRUE AND (expires_at IS NULL OR expires_at > NOW()) AND (max_seats = 0 OR seats_claimed < max_seats) diff --git a/server/tests/events.test.ts b/server/tests/events.test.ts index b7fc62d..1366b89 100644 --- a/server/tests/events.test.ts +++ b/server/tests/events.test.ts @@ -65,14 +65,32 @@ Deno.test("Multi-Claim Event Passes & Join Endpoints", async (t) => { }); await t.step( - "POST /api/join redeems PIN / slug and mints guest session", + "POST /api/join normalizes PIN without hyphens and mints guest session", async () => { const originalSql = sqlWrapper.sql; - sqlWrapper.sql = ((strings: any, ..._values: any[]) => { + let updateCalled = false; + let selectCalled = false; + sqlWrapper.sql = ((strings: any, ...values: any[]) => { const query = Array.isArray(strings) ? strings.join("?") : String(strings); + if (query.includes("SELECT * FROM event_passes")) { + selectCalled = true; + // Verify pinNormalized parameter value + assert(values.includes("749123")); + return Promise.resolve([{ + id: "event-uuid-1", + slug: "deno-lab", + pin_code: "749-123", + name: "Deno Workshop", + max_seats: 50, + seats_claimed: 0, + lifespan_hours: 3, + app_id: null, + }]); + } if (query.includes("UPDATE event_passes")) { + updateCalled = true; return Promise.resolve([{ id: "event-uuid-1", slug: "deno-lab", @@ -127,6 +145,8 @@ Deno.test("Multi-Claim Event Passes & Join Endpoints", async (t) => { assertExists(cookies); assert(cookies.includes(`session_id=${json.token};`)); + assert(selectCalled); + assert(updateCalled); assert(auditCalled); assertEquals(auditPayload.resource, "event-uuid-1"); assertEquals(auditPayload.metadata.slug, "deno-lab"); @@ -139,6 +159,186 @@ Deno.test("Multi-Claim Event Passes & Join Endpoints", async (t) => { }, ); + await t.step( + "POST /api/join idempotent re-entry returns existing session without burning a seat", + async () => { + const valkeyGetStub = stub(valkey, "get", (key: any) => { + if (String(key) === "existing-guest-session") { + return Promise.resolve( + JSON.stringify({ + uuid: "guest-uuid", + username: "guest_deno-lab_1", + sessionId: "existing-guest-session", + }), + ); + } + return Promise.resolve(null); + }); + + const originalSql = sqlWrapper.sql; + let updateCalled = false; + sqlWrapper.sql = ((strings: any, ..._values: any[]) => { + const query = Array.isArray(strings) + ? strings.join("?") + : String(strings); + if (query.includes("SELECT * FROM event_passes")) { + return Promise.resolve([{ + id: "event-uuid-1", + slug: "deno-lab", + pin_code: "749-123", + name: "Deno Workshop", + max_seats: 50, + seats_claimed: 1, + lifespan_hours: 3, + app_id: null, + }]); + } + if (query.includes("UPDATE event_passes")) { + updateCalled = true; + return Promise.resolve([]); + } + return Promise.resolve([]); + }) as any; + + const valkeyExpireStub = stub( + valkey, + "expire", + () => Promise.resolve(1 as any), + ); + + try { + const res = await app.request("/api/join", { + method: "POST", + headers: { + "Content-Type": "application/json", + "X-Forwarded-For": "192.168.1.1", + "Cookie": "session_id=existing-guest-session", + }, + body: JSON.stringify({ code: "deno-lab" }), + }); + + assertEquals(res.status, 200); + const json = await res.json(); + assert(json.success === true); + assert(json.reused === true); + assertEquals(json.sessionId, "existing-guest-session"); + assert(!updateCalled); // verify seat was not burned + } finally { + sqlWrapper.sql = originalSql; + valkeyGetStub.restore(); + valkeyExpireStub.restore(); + } + }, + ); + + await t.step( + "POST /api/join enforces 5 failed attempts rate limit per IP", + async () => { + let isRateLimitedCalled = false; + let checkRateLimitCalled = false; + + rateLimitWrapper.isRateLimited = ( + key: string, + limit: number, + _window: number, + ) => { + if (key === "ratelimit:join:fail:192.168.1.99" && limit === 5) { + isRateLimitedCalled = true; + return Promise.resolve(true); + } + return Promise.resolve(false); + }; + + rateLimitWrapper.checkRateLimit = () => { + checkRateLimitCalled = true; + return Promise.resolve(false); + }; + + try { + const res = await app.request("/api/join", { + method: "POST", + headers: { + "Content-Type": "application/json", + "X-Forwarded-For": "192.168.1.99", + }, + body: JSON.stringify({ code: "invalid-code" }), + }); + + assertEquals(res.status, 429); + const json = await res.json(); + assertEquals(json.error, "Too Many Requests"); + assert(isRateLimitedCalled); + assert(!checkRateLimitCalled); // didn't reach failure branch + } finally { + rateLimitWrapper.isRateLimited = () => Promise.resolve(false); + rateLimitWrapper.checkRateLimit = () => Promise.resolve(true); + } + }, + ); + + await t.step( + "POST /api/join normalizes slug case-insensitively while preserving hyphens", + async () => { + const originalSql = sqlWrapper.sql; + sqlWrapper.sql = ((strings: any, ...values: any[]) => { + const query = Array.isArray(strings) + ? strings.join("?") + : String(strings); + if (query.includes("SELECT * FROM event_passes")) { + assert(values.includes("deno-lab-workshop")); + return Promise.resolve([{ + id: "event-uuid-2", + slug: "deno-lab-workshop", + pin_code: "111-222", + name: "Deno Workshop 2", + max_seats: 50, + seats_claimed: 0, + lifespan_hours: 3, + app_id: null, + }]); + } + if (query.includes("UPDATE event_passes")) { + return Promise.resolve([{ + id: "event-uuid-2", + slug: "deno-lab-workshop", + pin_code: "111-222", + name: "Deno Workshop 2", + max_seats: 50, + seats_claimed: 1, + lifespan_hours: 3, + app_id: null, + }]); + } + return Promise.resolve([]); + }) as any; + + const valkeySetexStub = stub( + valkey, + "setex", + () => Promise.resolve("OK" as any), + ); + + try { + const res = await app.request("/api/join", { + method: "POST", + headers: { + "Content-Type": "application/json", + "X-Forwarded-For": "192.168.1.1", + }, + body: JSON.stringify({ code: " Deno-Lab-Workshop " }), + }); + + assertEquals(res.status, 200); + const json = await res.json(); + assert(json.success === true); + assertEquals(json.username, "guest_deno-lab-workshop_1"); + } finally { + sqlWrapper.sql = originalSql; + valkeySetexStub.restore(); + } + }, + ); + await t.step( "GET /join/:slug?format=env returns CLI export string", async () => { diff --git a/tasks/new/2026-0826.02.jul.story.events.join-reentry-and-limits-2156.ph2.md b/tasks/complete/2026-0826.02.jul.story.events.join-reentry-and-limits-2156.ph2.md similarity index 100% rename from tasks/new/2026-0826.02.jul.story.events.join-reentry-and-limits-2156.ph2.md rename to tasks/complete/2026-0826.02.jul.story.events.join-reentry-and-limits-2156.ph2.md diff --git a/ui/components/LoginPage.tsx b/ui/components/LoginPage.tsx index 69face7..060a34e 100644 --- a/ui/components/LoginPage.tsx +++ b/ui/components/LoginPage.tsx @@ -115,7 +115,9 @@ export const LoginPage = () => { diff --git a/ui/components/RegisterPage.tsx b/ui/components/RegisterPage.tsx index 8d1c8f2..5d367e4 100644 --- a/ui/components/RegisterPage.tsx +++ b/ui/components/RegisterPage.tsx @@ -116,7 +116,8 @@ export const RegisterPage = (