feat(events): implement bifurcated join inputs and NAT-safe idempotency

- Adds UI links for joining with PIN in Login and Register pages.
- Normalizes event slugs to lowercase (preserving hyphens) and event PINs to strip all hyphens/spaces to handle raw inputs.
- Implements a pre-check rate limit pattern (`isRateLimited`) to safely enforce a max of 5 failed attempts per IP window (60s) without rate-limiting successful authentications.
- Achieves NAT-safe idempotency in `POST /api/join` by extracting and reusing active event guest sessions instead of blindly incrementing claimed seats on every request.
- Integrates complete test suite coverage for these new constraints.

Co-authored-by: mrteye <1945243+mrteye@users.noreply.github.com>
This commit is contained in:
google-labs-jules[bot] 2026-08-26 23:57:13 +00:00
parent 455d130f74
commit edbceae0fb
6 changed files with 347 additions and 26 deletions

View File

@ -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<boolean> {
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 = { export const rateLimitWrapper = {
get checkRateLimit() { get checkRateLimit() {
return checkRateLimit; return checkRateLimit;
@ -64,4 +99,10 @@ export const rateLimitWrapper = {
set checkRateLimit(val: any) { set checkRateLimit(val: any) {
checkRateLimit = val; checkRateLimit = val;
}, },
get isRateLimited() {
return isRateLimited;
},
set isRateLimited(val: any) {
isRateLimited = val;
},
}; };

View File

@ -10,6 +10,7 @@ import {
} from "../auth-session.ts"; } from "../auth-session.ts";
import { getClientIp } from "../middleware.ts"; import { getClientIp } from "../middleware.ts";
import { auditWrapper } from "../audit.ts"; import { auditWrapper } from "../audit.ts";
import { rateLimitWrapper } from "../ratelimit.ts";
import { EventJoinPage } from "../../ui/components/EventJoinPage.tsx"; import { EventJoinPage } from "../../ui/components/EventJoinPage.tsx";
import { EventSplashPage } from "../../ui/components/EventSplashPage.tsx"; import { EventSplashPage } from "../../ui/components/EventSplashPage.tsx";
@ -180,49 +181,122 @@ eventRoutes.post("/api/join", async (c) => {
if (!code || typeof code !== "string") { if (!code || typeof code !== "string") {
return c.json({ error: "Event code or PIN is required" }, 400); 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 { try {
const result = await sqlWrapper.sql` const eventLookup = await sqlWrapper.sql`
UPDATE event_passes SELECT * FROM event_passes
SET seats_claimed = seats_claimed + 1 WHERE (LOWER(slug) = ${rawNormalized} OR REPLACE(pin_code, '-', '') = ${pinNormalized})
WHERE (slug = ${code} OR pin_code = ${code})
AND is_active = TRUE AND is_active = TRUE
AND (expires_at IS NULL OR expires_at > NOW()) 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( return c.json(
{ error: "Invalid event code or workshop capacity reached" }, { error: "Invalid event code or workshop capacity reached" },
404, 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 guestUuid = crypto.randomUUID();
const username = `guest_${event.slug}_${event.seats_claimed}`; const username = `guest_${updatedEvent.slug}_${updatedEvent.seats_claimed}`;
await sqlWrapper.sql` await sqlWrapper.sql`
INSERT INTO users (id, username, display_name, account_status) 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 ON CONFLICT DO NOTHING
`; `;
const randomBytes = crypto.getRandomValues(new Uint8Array(32)); const randomBytes = crypto.getRandomValues(new Uint8Array(32));
const sessionId = `ay_sess_${encodeHex(randomBytes)}`; const sessionId = `ay_sess_${encodeHex(randomBytes)}`;
const label = `${event.name} Seat #${event.seats_claimed}`; const label = `${updatedEvent.name} Seat #${updatedEvent.seats_claimed}`;
const ttl = (Number(event.lifespan_hours) || 3) * 3600; const ttl = (Number(updatedEvent.lifespan_hours) || 3) * 3600;
let customScopes = ["guest", "trial"]; let customScopes = ["guest", "trial"];
let appDomain = ""; let appDomain = "";
if (event.app_id) { if (updatedEvent.app_id) {
const apps = await sqlWrapper 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) { if (apps.length > 0) {
customScopes = [`app:${apps[0].name}`, event.role || "viewer"]; customScopes = [`app:${apps[0].name}`, updatedEvent.role || "viewer"];
appDomain = apps[0].domain || ""; appDomain = apps[0].domain || "";
} }
} }
@ -245,10 +319,10 @@ eventRoutes.post("/api/join", async (c) => {
}), }),
); );
auditWrapper.auditLog(guestUuid, "event_seat_claimed", event.id, { auditWrapper.auditLog(guestUuid, "event_seat_claimed", updatedEvent.id, {
slug: event.slug, slug: updatedEvent.slug,
name: event.name, name: updatedEvent.name,
seatNumber: event.seats_claimed, seatNumber: updatedEvent.seats_claimed,
method: "web", method: "web",
}, getClientIp(c)); }, getClientIp(c));
@ -289,14 +363,17 @@ eventRoutes.post("/api/join", async (c) => {
}); });
eventRoutes.get("/join/:slug", 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 format = c.req.query("format") || "html";
const rawNormalized = slug.trim().toLowerCase();
const pinNormalized = slug.trim().replace(/[-\s]/g, "");
try { try {
const result = await sqlWrapper.sql` const result = await sqlWrapper.sql`
UPDATE event_passes UPDATE event_passes
SET seats_claimed = seats_claimed + 1 SET seats_claimed = seats_claimed + 1
WHERE slug = ${slug} WHERE (LOWER(slug) = ${rawNormalized} OR REPLACE(pin_code, '-', '') = ${pinNormalized})
AND is_active = TRUE AND is_active = TRUE
AND (expires_at IS NULL OR expires_at > NOW()) AND (expires_at IS NULL OR expires_at > NOW())
AND (max_seats = 0 OR seats_claimed < max_seats) AND (max_seats = 0 OR seats_claimed < max_seats)

View File

@ -65,14 +65,32 @@ Deno.test("Multi-Claim Event Passes & Join Endpoints", async (t) => {
}); });
await t.step( 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 () => { async () => {
const originalSql = sqlWrapper.sql; 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) const query = Array.isArray(strings)
? strings.join("?") ? strings.join("?")
: String(strings); : 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")) { if (query.includes("UPDATE event_passes")) {
updateCalled = true;
return Promise.resolve([{ return Promise.resolve([{
id: "event-uuid-1", id: "event-uuid-1",
slug: "deno-lab", slug: "deno-lab",
@ -127,6 +145,8 @@ Deno.test("Multi-Claim Event Passes & Join Endpoints", async (t) => {
assertExists(cookies); assertExists(cookies);
assert(cookies.includes(`session_id=${json.token};`)); assert(cookies.includes(`session_id=${json.token};`));
assert(selectCalled);
assert(updateCalled);
assert(auditCalled); assert(auditCalled);
assertEquals(auditPayload.resource, "event-uuid-1"); assertEquals(auditPayload.resource, "event-uuid-1");
assertEquals(auditPayload.metadata.slug, "deno-lab"); 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( await t.step(
"GET /join/:slug?format=env returns CLI export string", "GET /join/:slug?format=env returns CLI export string",
async () => { async () => {

View File

@ -115,7 +115,9 @@ export const LoginPage = () => {
</details> </details>
<div class="links"> <div class="links">
Don't have an account? <a href="/register">Register with Invite</a> Don't have an account? <a href="/register">Register with Invite</a> |
{" "}
<a href="/join">Join with PIN</a>
</div> </div>
</div> </div>

View File

@ -116,7 +116,8 @@ export const RegisterPage = (
<div id="statusMessage"></div> <div id="statusMessage"></div>
<div class="links"> <div class="links">
Already registered? <a href="/login">Sign in</a> Already registered? <a href="/login">Sign in</a> |{" "}
<a href="/join">Join with PIN</a>
</div> </div>
</div> </div>