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:
parent
455d130f74
commit
edbceae0fb
@ -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 = {
|
||||
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;
|
||||
},
|
||||
};
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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 () => {
|
||||
|
||||
@ -115,7 +115,9 @@ export const LoginPage = () => {
|
||||
</details>
|
||||
|
||||
<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>
|
||||
|
||||
|
||||
@ -116,7 +116,8 @@ export const RegisterPage = (
|
||||
<div id="statusMessage"></div>
|
||||
|
||||
<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>
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user