diff --git a/server/routes/events.ts b/server/routes/events.ts index feacf7f..aebe12b 100644 --- a/server/routes/events.ts +++ b/server/routes/events.ts @@ -31,36 +31,50 @@ eventRoutes.post("/api/events/:id/rotate-pin", async (c) => { const eventId = c.req.param("id"); - // Generate new PIN - const randPin = Math.floor(100000 + Math.random() * 900000).toString(); - const newPinCode = randPin.substring(0, 3) + "-" + randPin.substring(3); - try { - const eventResult = await sqlWrapper.sql` - UPDATE event_passes - SET pin_code = ${newPinCode} + const event = await sqlWrapper.sql` + SELECT slug, name FROM event_passes WHERE id = ${eventId} AND (created_by = ${user.userId} OR ${await isGlobalAdmin(user.userId)}) AND is_active = TRUE - RETURNING pin_code `; - if (!eventResult || eventResult.length === 0) { + if (!event || event.length === 0) { return c.json( { error: "Event not found, inactive, or unauthorized" }, 404, ); } + // Generate new PIN + const randPin = Math.floor(100000 + Math.random() * 900000).toString(); + const newPinCode = randPin.substring(0, 3) + "-" + randPin.substring(3); + + // Generate new Slug + const baseSlug = event[0].slug.replace(/-[a-z0-9]{4}$/, ""); + const newSuffix = Math.random().toString(36).substring(2, 6); + const newSlug = `${baseSlug}-${newSuffix}`; + + const updateResult = await sqlWrapper.sql` + UPDATE event_passes + SET pin_code = ${newPinCode}, slug = ${newSlug} + WHERE id = ${eventId} + RETURNING pin_code, slug + `; + auditWrapper.auditLog( user.userId, - "event_pin_rotated", + "event_ingress_rotated", eventId, {}, getClientIp(c), ); - return c.json({ success: true, pinCode: eventResult[0].pin_code }); + return c.json({ + success: true, + pinCode: updateResult[0].pin_code, + slug: updateResult[0].slug, + }); } catch (e: any) { console.error("[Events] Failed to rotate event PIN:", e); return c.json({ error: "Failed to rotate PIN" }, 500); @@ -206,7 +220,7 @@ eventRoutes.post("/api/events/:id/extend", async (c) => { try { const eventResult = await sqlWrapper.sql` UPDATE event_passes - SET expires_at = expires_at + interval '${extendHours} hours' + SET expires_at = GREATEST(expires_at, NOW()) + interval '${extendHours} hours' WHERE id = ${eventId} AND (created_by = ${user.userId} OR ${await isGlobalAdmin( user.userId, )}) AND is_active = TRUE diff --git a/server/tests/events.test.ts b/server/tests/events.test.ts index 79b6351..4b70fd1 100644 --- a/server/tests/events.test.ts +++ b/server/tests/events.test.ts @@ -232,6 +232,59 @@ Deno.test("Multi-Claim Event Passes & Join Endpoints", async (t) => { }, ); + await t.step( + "POST /api/events/:id/rotate-pin rotates both pin and slug", + async () => { + const valkeyGetStub = stub(valkey, "get", (key: any) => { + if (String(key) === "admin-session") { + return Promise.resolve( + JSON.stringify({ uuid: "admin-uuid", username: "tylerg" }), + ); + } + return Promise.resolve(null); + }); + + const originalSql = sqlWrapper.sql; + let updateEventCalled = false; + let newSlug = ""; + + sqlWrapper.sql = ((strings: any, ..._values: any[]) => { + const query = Array.isArray(strings) + ? strings.join("?") + : String(strings); + if (query.includes("SELECT slug, name FROM event_passes")) { + return Promise.resolve([{ slug: "deno-lab-1a2b", name: "Deno Lab" }]); + } + if (query.includes("UPDATE event_passes") && query.includes("slug =")) { + updateEventCalled = true; + newSlug = _values[1]; // second bound parameter is the slug + return Promise.resolve([{ pin_code: _values[0], slug: newSlug }]); + } + return Promise.resolve([]); + }) as any; + + try { + const res = await app.request("/api/events/evt-123/rotate-pin", { + method: "POST", + headers: { + Authorization: "Bearer admin-session", + }, + }); + + assertEquals(res.status, 200); + const json = await res.json(); + assert(json.success === true); + assert(updateEventCalled); + assert(json.slug.startsWith("deno-lab-")); + assert(json.slug !== "deno-lab-1a2b"); + assertEquals(json.slug, newSlug); + } finally { + sqlWrapper.sql = originalSql; + valkeyGetStub.restore(); + } + }, + ); + await t.step( "POST /api/join enforces 5 failed attempts rate limit per IP", async () => { @@ -491,7 +544,7 @@ Deno.test("Multi-Claim Event Passes & Join Endpoints", async (t) => { : String(strings); if ( query.includes("UPDATE event_passes") && - query.includes("expires_at = expires_at + interval") + query.includes("expires_at = GREATEST(expires_at, NOW()) + interval") ) { updateEventCalled = true; return Promise.resolve([{ diff --git a/tasks/new/2026-0826.06.gem.feat.ui.sessions-and-events-final-polish-2305.ph6.md b/tasks/new/2026-0826.06.gem.feat.ui.sessions-and-events-final-polish-2305.ph6.md index 0c8b084..ca26dd0 100644 --- a/tasks/new/2026-0826.06.gem.feat.ui.sessions-and-events-final-polish-2305.ph6.md +++ b/tasks/new/2026-0826.06.gem.feat.ui.sessions-and-events-final-polish-2305.ph6.md @@ -12,28 +12,57 @@ - `server/tests/events.test.ts` - `ui/ui_scripts.test.ts` - **Core Objective:** Implement Phase 6 Final Polish & Hardening: - 1. Unify nomenclature & IA across pages and drawers (`Sessions & Events`, `Delegate Session`, `Events`, `Sessions`). - 2. Implement universal ingress credential rotation (rotates both 6-digit PIN and event slug suffix simultaneously). - 3. Fix expired event extend math bug using `GREATEST(expires_at, NOW()) + interval`. - 4. Replace broken compact mode with strictly bounded, 2-Row Compact Cards with inline copy pills and zero text/border collisions. - 5. Implement standardized natural `ExpiryBadge` format (`[Date] Β· [Time] Β· [Urgency Badge]`) and symmetrical inverted start-time telemetry in the Guest Drawer. - 6. Upgrade segmented view mode toggles and WAI-ARIA delegation tabs with high-contrast active states. - 7. Reset drawer state machine on open and replace verbose/mock buttons with clean `[ OK ]`. + 1. Unify nomenclature & IA across pages and drawers (`Sessions & Events`, + `Delegate Session`, `Events`, `Sessions`). + 2. Implement universal ingress credential rotation (rotates both 6-digit PIN + and event slug suffix simultaneously). + 3. Fix expired event extend math bug using + `GREATEST(expires_at, NOW()) + interval`. + 4. Replace broken compact mode with strictly bounded, 2-Row Compact Cards with + inline copy pills and zero text/border collisions. + 5. Implement standardized natural `ExpiryBadge` format + (`[Date] Β· [Time] Β· [Urgency Badge]`) and symmetrical inverted start-time + telemetry in the Guest Drawer. + 6. Upgrade segmented view mode toggles and WAI-ARIA delegation tabs with + high-contrast active states. + 7. Reset drawer state machine on open and replace verbose/mock buttons with + clean `[ OK ]`. - **Dependencies:** None. -- **Additional Important Notes:** Must remain 100% pure React-free Hono SSR JSX. All client interactions in `SessionsScript.tsx` must use native vanilla JavaScript DOM APIs. +- **Additional Important Notes:** Must remain 100% pure React-free Hono SSR JSX. + All client interactions in `SessionsScript.tsx` must use native vanilla + JavaScript DOM APIs. --- ## 2. Architectural Considerations & Risks - **Risks:** - - **Ingress Rotation Leakage & Active Session Disruption:** When rotating event ingress credentials (`pin_code` and `slug`), existing active guest sessions must remain valid and uninterrupted. Only subsequent unauthenticated join attempts using the old PIN, old Direct Link, or old CLI command must be rejected (404 / Invalid Code). - - **Expired Event Extending Edge Cases:** When an organizer extends an event that expired in the past, `expires_at + interval '1 hour'` would leave the expiry in the past. Using `GREATEST(expires_at, NOW()) + interval '${extendHours} hours'` guarantees the new expiration time is set relative to the current moment. - - **DOM Boundary & Overflow Bleed:** The previous compact mode forced elements into a single unconstrained horizontal flex line, causing text collisions and input boxes bleeding off the desktop screen. The new 2-Row Compact Card must enforce strict CSS bounding (`box-sizing: border-box; max-width: 100%; overflow: hidden; text-overflow: ellipsis;`). - - **State Machine Staleness:** Reopening the delegation drawer must unconditionally reset the form back to State 1 (clean creation form with `Single Session` default tab) rather than leaving the stale success screen visible. + - **Ingress Rotation Leakage & Active Session Disruption:** When rotating + event ingress credentials (`pin_code` and `slug`), existing active guest + sessions must remain valid and uninterrupted. Only subsequent + unauthenticated join attempts using the old PIN, old Direct Link, or old CLI + command must be rejected (404 / Invalid Code). + - **Expired Event Extending Edge Cases:** When an organizer extends an event + that expired in the past, `expires_at + interval '1 hour'` would leave the + expiry in the past. Using + `GREATEST(expires_at, NOW()) + interval '${extendHours} hours'` guarantees + the new expiration time is set relative to the current moment. + - **DOM Boundary & Overflow Bleed:** The previous compact mode forced elements + into a single unconstrained horizontal flex line, causing text collisions + and input boxes bleeding off the desktop screen. The new 2-Row Compact Card + must enforce strict CSS bounding + (`box-sizing: border-box; max-width: 100%; overflow: hidden; text-overflow: ellipsis;`). + - **State Machine Staleness:** Reopening the delegation drawer must + unconditionally reset the form back to State 1 (clean creation form with + `Single Session` default tab) rather than leaving the stale success screen + visible. - **Alternatives:** - - *Single-Line vs. 2-Row Compact Cards:* Single-line cards inevitably drop essential copy actions or clip text on viewports under 1200px. A structured 2-Row Compact Card (~70px height) preserves 100% of copy handoffs (PIN, Link, CLI) and key metrics (Seats, Status, Expiry) while preventing all layout collisions. + - _Single-Line vs. 2-Row Compact Cards:_ Single-line cards inevitably drop + essential copy actions or clip text on viewports under 1200px. A structured + 2-Row Compact Card (~70px height) preserves 100% of copy handoffs (PIN, + Link, CLI) and key metrics (Seats, Status, Expiry) while preventing all + layout collisions. --- @@ -46,17 +75,20 @@ ```typescript const randPin = Math.floor(100000 + Math.random() * 900000).toString(); const newPinCode = randPin.substring(0, 3) + "-" + randPin.substring(3); - + // Extract base slug prefix and generate fresh random 4-char suffix - const event = await sqlWrapper.sql`SELECT slug, name FROM event_passes WHERE id = ${eventId}...`; + const event = await sqlWrapper + .sql`SELECT slug, name FROM event_passes WHERE id = ${eventId}...`; const baseSlug = event[0].slug.replace(/-[a-z0-9]{4}$/, ""); const newSuffix = Math.random().toString(36).substring(2, 6); const newSlug = `${baseSlug}-${newSuffix}`; ``` - - Update database: `UPDATE event_passes SET pin_code = ${newPinCode}, slug = ${newSlug} WHERE id = ${eventId}...` + - Update database: + `UPDATE event_passes SET pin_code = ${newPinCode}, slug = ${newSlug} WHERE id = ${eventId}...` - Audit log `event_ingress_rotated`. - Return `{ success: true, pinCode: newPinCode, slug: newSlug }`. - - Note: Existing active attendee sessions (`username LIKE 'guest_...'`) authenticate via session cookies/Valkey tokens and are unaffected. + - Note: Existing active attendee sessions (`username LIKE 'guest_...'`) + authenticate via session cookies/Valkey tokens and are unaffected. 2. **Resilient Event Extension Math (`POST /api/events/:id/extend`):** - Fix SQL to calculate new expiry from `GREATEST(expires_at, NOW())`: @@ -68,18 +100,24 @@ ``` 3. **Guest Attendee Telemetry (`GET /api/events/:id/attendees`):** - - Ensure query returns `s.id, s.label, s.is_paused, s.created_at, s.expires_at, s.last_activity_at, s.last_activity_action, u.username, u.display_name`. + - Ensure query returns + `s.id, s.label, s.is_paused, s.created_at, s.expires_at, s.last_activity_at, s.last_activity_action, u.username, u.display_name`. --- ### Phase 2: Page Hierarchy, Nomenclature & Heading Cleanup (`SessionsPage.tsx`) 1. **Page Title:** - - Set top `
- Manage logins, mint 1:1 ephemeral links & CLI tokens, or launch - multi-claim workshop event passes. + Manage logins, mint 1:1 delegated tokens, or launch multi-claim + workshop events.
+ Mint a 1:1 delegated token or launch a multi-seat workshop event. +
-