diff --git a/tasks/new/2026-0826.01.jul.story.events.phase-1-guest-ingress-1400.ph1.md b/tasks/new/2026-0826.01.jul.story.events.phase-1-guest-ingress-1400.ph1.md index 5e397ea..6e3ed59 100644 --- a/tasks/new/2026-0826.01.jul.story.events.phase-1-guest-ingress-1400.ph1.md +++ b/tasks/new/2026-0826.01.jul.story.events.phase-1-guest-ingress-1400.ph1.md @@ -9,25 +9,25 @@ claims. - **Dependencies:** None. - **Additional Important Notes:** Follow zero-trust default-deny principles for - ForwardAuth. Guest sessions minting needs to correctly log to the Merkle audit + ForwardAuth. Guest session minting must correctly log to the Merkle audit ledger for SIEM visibility. --- -### 2. Architectural Considerations & Risks +## 2. Architectural Considerations & Risks - **Risks:** - **ForwardAuth Security:** Modifying ForwardAuth to accept `guest` accounts risks inadvertently allowing guests to access applications they are not explicitly scoped for. Strict validation of `auth.customScopes` against the requested `appRecord.name` is critical. - - **Launchpad Leakage:** The `getDashboardApps` query modification must be - robust. If the UNION query is not correctly structured, it could leak - visibility of unregistered or unauthorized apps to guests. + - **Launchpad SQL Array Guard:** If a user or guest has no `app:` custom + scopes, passing an empty array into an `IN ()` clause causes invalid SQL. + The query must use `if (appNames.length > 0)` or + `WHERE name = ANY(${appNames}::text[])`. - **Audit Logging Integrity:** Failure to capture the audit events properly during the `join` phase would break SIEM visibility. Using the non-blocking - `auditWrapper.auditLog` is required to avoid impacting user request latency - while ensuring compliance. + `auditWrapper.auditLog` with `getClientIp(c)` is required. - **Alternatives:** - We considered a bypass in `ui/db_queries.ts` where guests completely skip @@ -35,51 +35,55 @@ seamlessly supports both pure guests and regular active users who might hold delegated scoped passes. It provides a unified data retrieval flow. -### 3. Proposed Implementation +--- -#### Phase 1: ForwardAuth Guest Ingress (`server/routes/auth_forward.ts`) +## 3. Proposed Implementation -1. **Permit Guest Accounts:** Update the account status validation to allow - `user.account_status === 'guest'` in addition to `'active'`. +### Phase 1: ForwardAuth Guest Ingress (`server/routes/auth_forward.ts`) + +1. **Permit Guest Accounts:** Update the account status validation (around + line 120) to allow `user.account_status === 'guest'` in addition to + `'active'`. 2. **Custom Scopes Validation:** If the user is a guest (or relying on custom scopes), verify that `auth.customScopes` contains the explicit application - grant (e.g., `app:${appRecord.name}`). If not, return a 403 Forbidden. + grant (e.g., `app:${appRecord.name}` or wildcard `*`). If not, return a + `403 Forbidden`. 3. **Header Injection:** For guest sessions, compute the scopes to inject into `X-Forwarded-Scopes` by taking the comma-joined list of `auth.customScopes` - (`auth.customScopes.join(",")`). This propagates the app scope (e.g., - `app:ed-droid`) and role (e.g., `viewer`) to downstream reverse proxies. + (`auth.customScopes.filter(Boolean).join(",") || "viewer"`). This propagates + the app scope (e.g., `app:ed-droid`) and role (e.g., `viewer`) to downstream + reverse proxies. -#### Phase 2: Launchpad App Query (`ui/db_queries.ts` & `ui/mod.ts`) +### Phase 2: Launchpad App Query (`ui/db_queries.ts` & `ui/mod.ts`) 1. **Function Signature Update:** Update `getDashboardApps(userId: string, isAdmin: boolean, customScopes?: string[])` to accept the session's custom scopes. -2. **UNION Query:** Update the non-admin SQL query to use a `UNION`. - - The first part queries explicitly granted apps via the `grants` table. - - The second part parses the provided `customScopes` array to extract allowed - app names (e.g., stripping the `app:` prefix), querying the `apps` table - for those matching names, and assigning them a pseudo-role such as - `"Guest (Viewer)"` (or extracting the role from the scopes if possible). -3. **UI Integration:** In `ui/mod.ts`, pass `auth.customScopes` from the - resolved session into `getDashboardApps`. +2. **UNION Query with Array Guard:** + - Extract app names from `customScopes` (e.g. scopes starting with `app:`). + - If `appNames.length > 0`, execute a `UNION` with + `SELECT id, name, description, domain, 'Guest (Viewer)' as role FROM apps WHERE domain IS NOT NULL AND name = ANY(${appNames}::text[])`. + - If `appNames.length === 0`, return only the primary `grants` query result. +3. **UI Integration:** In `ui/mod.ts` under `/dashboard`, pass + `auth.customScopes` from the resolved session into `getDashboardApps`. -#### Phase 3: Join Audit Logging (`server/routes/events.ts`) +### Phase 3: Join Audit Logging (`server/routes/events.ts`) -1. **Audit `POST /api/join` (Web):** Immediately after minting the guest - session, invoke +1. **Import `getClientIp`:** Import `getClientIp` from `../middleware.ts`. +2. **Audit `POST /api/join` (Web):** Immediately after minting the guest + session, invoke: `auditWrapper.auditLog(guestUuid, "event_seat_claimed", event.id, { slug: event.slug, name: event.name, seatNumber: event.seats_claimed, method: "web" }, getClientIp(c))`. -2. **Audit `GET /join/:slug` (CLI):** Similarly, wire - `auditWrapper.auditLog(guestUuid, "event_seat_claimed", event.id, { slug: event.slug, name: event.name, seatNumber: event.seats_claimed, method: "cli" }, getClientIp(c))` - into this endpoint before returning the token. +3. **Audit `GET /join/:slug` (CLI):** Similarly, wire: + `auditWrapper.auditLog(guestUuid, "event_seat_claimed", event.id, { slug: event.slug, name: event.name, seatNumber: event.seats_claimed, method: "cli" }, getClientIp(c))`. -#### Phase 4: Unit Testing +### Phase 4: Unit Testing 1. **ForwardAuth Tests (`server/tests/forward_auth.test.ts`):** - - Add tests simulating a guest session with an `app:{name}` scope to ensure a - `200 OK` response with correctly formatted `X-Forwarded-Scopes`. - - Add tests simulating a guest session without the necessary `app:{name}` - scope to ensure a `403 Forbidden` response. + - Add test: + `Tier 1 & 2: GET /api/forward-auth - Guest session with app scope allowed`. + - Add test: + `Tier 1 & 2: GET /api/forward-auth - Guest session without app scope rejected (403)`. 2. **Events Tests (`server/tests/events.test.ts`):** - - Ensure the `POST /api/join` and `GET /join/:slug` assertions capture the - `auditLog` spy calls and verify the exact payload (`event_seat_claimed` - action, `event.id` as resource, correct metadata structure). + - Ensure the `POST /api/join` and `GET /join/:slug` assertions verify the + `auditLog` call payload (`event_seat_claimed` action, `event.id` as + resource, and correct metadata structure). diff --git a/tasks/new/2026-0826.02.jul.story.events.join-reentry-and-limits-2156.ph2.md b/tasks/new/2026-0826.02.jul.story.events.join-reentry-and-limits-2156.ph2.md index d1ead95..7bc9bcd 100644 --- a/tasks/new/2026-0826.02.jul.story.events.join-reentry-and-limits-2156.ph2.md +++ b/tasks/new/2026-0826.02.jul.story.events.join-reentry-and-limits-2156.ph2.md @@ -1,84 +1,101 @@ -### 1. TASK METADATA +# TASK METADATA - **Target Files:** `server/routes/events.ts`, `ui/components/LoginPage.tsx`, - `ui/components/RegisterPage.tsx`, `ui/components/EventJoinPage.tsx` + `ui/components/RegisterPage.tsx`, `ui/components/EventJoinPage.tsx`, + `server/tests/events.test.ts` - **Core Objective:** Implement Phase 2 Event Overhaul: Add PIN discovery links, - normalize input, apply tiered Valkey rate limiting, and implement NAT-safe - idempotent re-entry on join. -- **Dependencies:** `server/middleware.ts` (for getClientIp), + bifurcate slug/PIN input normalization, apply tiered Valkey rate limiting on + failed brute-force attempts, and implement NAT-safe idempotent cookie + re-entry. +- **Dependencies:** `server/middleware.ts` (for `getClientIp`), `server/ratelimit.ts` (for rate limiting logic). -- **Additional Important Notes:** Frontend changes should mirror backend - normalization. Re-entry must seamlessly return an existing active guest - session linked to the correct event without incrementing `seats_claimed`. +- **Additional Important Notes:** Attendee identity must remain 100% IP-agnostic + by relying on browser session cookies (NAT-safe for shared WiFi). IP tracking + is strictly reserved for throttling failed brute-force attacks and forensic + audit logs. --- -### 2. Architectural Considerations & Risks +## 2. Architectural Considerations & Risks - **Risks:** - - If idempotent re-entry is flawed, returning a mismatched event session could - allow access to an event a user didn't register for, or incorrectly redirect - them. We must strictly verify the existing session is a guest session - belonging to the target event. - - Rate limiting logic might accidentally block legitimate NAT'ed traffic if - thresholds are set too low; 5 fail / 3 success per minute per IP should be - monitored. - - Normalization on the backend must be implemented securely using robust - string replacements to prevent SQL injection or unexpected matching - behaviors, especially with PostgreSQL parameterized queries - (`sqlWrapper.sql`). + - **Slug Hyphen Destruction:** Event slugs legitimately contain hyphens (e.g., + `testing-workshop-event-...`). Stripping hyphens globally breaks slug + lookups. Normalization MUST bifurcate: preserve hyphens for slugs + (`LOWER(slug)`), while stripping hyphens only for PIN matching + (`REPLACE(pin_code, '-', '')`). + - **NAT False-Positives:** Never bind attendee identity or successful seats + strictly to an IP. Shared conference/classroom WiFi shares one egress IP. + Idempotent re-entry must inspect the incoming `session_id` cookie. + - **Rate Limiter State Machine:** Pre-check gates at the route entry must + reject exceeded IPs (`429`), while post-execution increments must distinctly + separate failed code attempts (`ratelimit:join:fail:`) from successful + joins. - **Alternatives:** - - For normalization, instead of just runtime stripping, we could normalize - upon creation and store both normalized and display values. However, - stripping at runtime with `REPLACE(pin_code, '-', '')` and `LOWER(slug)` is - an acceptable and low-friction approach for our scale. - - Rate limiting could use sliding window algorithms, but Valkey-backed fixed - windows via the existing `rateLimitWrapper` are more efficient and standard - for our stack. + - Storing pre-normalized columns was evaluated and rejected; runtime + normalization with `LOWER(slug)` and `REPLACE(pin_code, '-', '')` is fast + and zero-migration. -### 3. Proposed Implementation +--- -#### Phase 1: Login & Registration Discovery Links +## 3. Proposed Implementation -- **File:** `ui/components/LoginPage.tsx` - - Add a "Join with PIN" link next to the existing "Register with Invite" in - the footer links section to ensure unauthenticated users can easily discover - the join portal. -- **File:** `ui/components/RegisterPage.tsx` - - Add a similar "Join with PIN" link to the footer for consistency across - entry points. +### Phase 1: Login & Registration Discovery Links -#### Phase 2: Input Normalization (Frontend & Backend) +1. **`ui/components/LoginPage.tsx`:** Add a "Join with PIN" link next to + "Register with Invite" in the footer links section. +2. **`ui/components/RegisterPage.tsx`:** Add a similar "Join with PIN" link to + the footer for consistency across all unauthenticated entry points. -- **File:** `ui/components/EventJoinPage.tsx` - - Enhance the UI script to tolerate input formatting, naturally handling - hyphens, spaces, and case differences during submission, providing immediate - visual feedback without rejecting valid raw input (e.g. `241881` vs - `241-881`). -- **File:** `server/routes/events.ts` (`POST /api/join` & `GET /join/:slug`) - - Strip whitespace, hyphens, and force lowercase on the incoming code on the - backend (`code.trim().replace(/[-\s]/g, '').toLowerCase()`). - - Update the `sqlWrapper` query to match normalized inputs: - `WHERE (LOWER(slug) = ${code} OR REPLACE(pin_code, '-', '') = ${code})`. +### Phase 2: Bifurcated Input Normalization -#### Phase 3: Anti-DoS Rate Limiting +1. **`ui/components/EventJoinPage.tsx`:** Enhance the client-side script to + accept raw 6 digits (`241881`) or hyphenated PINs (`241-881`) and slugs + without throwing client-side validation errors. +2. **`server/routes/events.ts` (`POST /api/join` & `GET /join/:slug`):** + - Create two normalized representations: + - `rawNormalized = code.trim().toLowerCase()` (preserves hyphens for + slugs). + - `pinNormalized = code.trim().replace(/[-\s]/g, '')` (strips + hyphens/spaces for PINs). + - Update SQL query: + `WHERE (LOWER(slug) = ${rawNormalized} OR REPLACE(pin_code, '-', '') = ${pinNormalized})` + `AND is_active = TRUE AND (expires_at IS NULL OR expires_at > NOW())` -- **File:** `server/routes/events.ts` (`POST /api/join`) - - Use `getClientIp(c)` from our middleware to determine the client IP address. - - Mount tiered Valkey rate limits using the keys `ratelimit:join:fail:` - (limit: 5 per 60s) and `ratelimit:join:success:` (limit: 3 per 60s). - - Apply the rate limiting logic prior to executing any database queries to - prevent database DoS on brute-force PIN attempts. +### Phase 3: Anti-DoS Rate Limiting on `POST /api/join` -#### Phase 4: NAT-Safe Idempotent Re-entry +1. **Pre-Check Gate (Top of Route):** + - Resolve IP via `const clientIp = getClientIp(c)`. + - Check if `ratelimit:join:fail:${clientIp}` exceeds 5 attempts per 60s. If + so, return `429 Too Many Requests`. +2. **Post-Execution Increments:** + - If lookup returns 0 rows (invalid code / expired): increment + `ratelimit:join:fail:${clientIp}` (window: 60s) before returning `404`. + - If seat is successfully claimed: optionally increment + `ratelimit:join:success:${clientIp}` (limit: 3 per 60s). -- **File:** `server/routes/events.ts` (`POST /api/join`) - - Before claiming a seat and generating a new session, check for an existing - valid user session using `getAuthenticatedUser(c)`. - - If a valid session exists, query the database to determine if the `user_id` - is linked to a guest account for the target event (e.g., username matches - `guest__%`). - - If the user is already a guest of this event, bypass the `seats_claimed + 1` - update and early-return `{ success: true, sessionId, redirectUrl }`, keeping - the existing session intact. +### Phase 4: NAT-Safe Idempotent Re-entry + +1. **Session Cookie Inspection (`POST /api/join`):** + - Before executing the `seats_claimed + 1` update, check + `getAuthenticatedUser(c)`. + - If a valid session exists, verify if the session is a guest session + belonging to the matched event slug + (`username.startsWith('guest_' + event.slug + '_')`). + - If matched: + - Re-issue the `session_id` cookie with fresh TTL. + - Return + `{ success: true, sessionId: auth.sessionId, redirectUrl, reused: true }` + without incrementing `seats_claimed`. + +### Phase 5: Unit Testing + +1. **`server/tests/events.test.ts`:** + - Add test: + `POST /api/join - normalizes PIN without hyphens (241881 -> 241-881)`. + - Add test: + `POST /api/join - normalizes slug case-insensitively while preserving hyphens`. + - Add test: + `POST /api/join - idempotent re-entry returns existing session without burning seat`. + - Add test: `POST /api/join - enforces 5 failed attempts rate limit per IP`. diff --git a/tasks/new/2026-0826.03.jul.feat.ui.sessions-layout-drawer-machine-2156.ph3.md b/tasks/new/2026-0826.03.jul.feat.ui.sessions-layout-drawer-machine-2156.ph3.md index 7e341f3..c7f11de 100644 --- a/tasks/new/2026-0826.03.jul.feat.ui.sessions-layout-drawer-machine-2156.ph3.md +++ b/tasks/new/2026-0826.03.jul.feat.ui.sessions-layout-drawer-machine-2156.ph3.md @@ -2,84 +2,90 @@ - **Target Files:** `ui/components/SessionsPage.tsx`, `ui/components/sessions/WorkshopDrawer.tsx`, - `ui/components/sessions/SessionsScript.tsx` + `ui/components/sessions/SessionsScript.tsx`, `ui/ui_scripts.test.ts` - **Core Objective:** Overhaul visual hierarchy of `/dashboard/sessions` and enforce strict 2-state mutually exclusive rendering in `WorkshopDrawer.tsx`. - **Dependencies:** None. - **Additional Important Notes:** Must use pure vanilla JavaScript for DOM manipulation (no React/framework state). Ensure copy pills trigger accessible - notifications (aria-live/toast). Fix double emoji bug in JS. Ensure mobile - title wrapping. All changes must pass `ui/ui_scripts.test.ts`. + notifications (aria-live/toast) with explicit `aria-label` tags. Fix double + emoji bug in JS. Ensure mobile title wrapping. All changes must pass + `ui/ui_scripts.test.ts`. --- -## Architectural Considerations & Risks +## 2. Architectural Considerations & Risks - **Risks:** - **DOM Manipulation Regression:** Because this UI operates strictly on vanilla JavaScript, changing the layout and grouping DOM elements within new - containers (like `#eventCreateState` and `#eventHandoffState`) risks - breaking existing DOM ID bindings in `SessionsScript.tsx` if IDs are changed - or misplaced. We must strictly preserve all existing DOM IDs. - - **Test Compatibility:** The client-side scripts are tested via hermetic - `new Function()` execution in `ui/ui_scripts.test.ts` (`validateJsSyntax`). - The script modifications must remain structurally valid and strictly avoid - any unsupported syntax or external framework dependencies. - - **Mobile Layout Breakage:** When fixing the long titles to wrap correctly, - we must ensure CSS properties like `word-break: break-word` and - `overflow-wrap: break-word` don't inadvertently stretch flex containers - horizontally on small screens. + containers (`#eventCreateState` and `#eventHandoffState`) risks breaking + existing DOM ID bindings in `SessionsScript.tsx`. All existing DOM IDs must + be preserved. + - **Test Compatibility:** Client scripts are validated via hermetic execution + in `ui/ui_scripts.test.ts`. Script modifications must remain structurally + valid and strictly avoid any external framework dependencies. + - **Mobile Layout Breakage:** When fixing long titles to wrap correctly, CSS + properties `overflow-wrap: break-word` and `word-break: break-word` must be + paired with bounded max-widths to prevent horizontal container stretching. - **Alternatives:** - - The proposed container-based 2-state machine approach for - `WorkshopDrawer.tsx` is native and robust. It's the most appropriate - solution given our constraints (no client-side frameworks). Using a - class-based toggle could also work, but explicit element display - manipulation is clearer for the specific 2-state requirement. + - The container-based 2-state machine approach for `WorkshopDrawer.tsx` is + native and robust. Using a class-based toggle was considered, but explicit + element display manipulation is clearer for the specific 2-state + requirement. -## Proposed Implementation +--- -### 1. Page Hierarchy Update (`ui/components/SessionsPage.tsx`) +## 3. Proposed Implementation -- Relocate the main header block (containing `

Active Sessions & Passes

` - and the `[ 🔑 Delegate Session ]` button) to be the uppermost visible element - below the `#status-banner`. -- Move the `EventCockpitDeck` component to render below the new header block. -- Keep the `SessionTable` and `SessionDeck` at the bottom. +### Phase 1: Page Hierarchy Update (`ui/components/SessionsPage.tsx`) -### 2. WorkshopDrawer 2-State Machine (`ui/components/sessions/WorkshopDrawer.tsx`) +1. **Header Block Relocation:** Move the main header block + (`

Active Sessions & Passes

`, subtitle, and `[ 🔑 Delegate Session ]` + button) to be the uppermost visible element below the `#status-banner`. +2. **Section Hierarchy:** + - Section 1: `EventCockpitDeck` (`Event Passes` cards). If 0 event passes + exist, render a clean empty state or let the deck cleanly return null + without an orphaned section heading. + - Section 2: `SessionTable` / `SessionDeck` (`Active Sessions`). -- Wrap the initial creation form (`#eventForm`), including inputs, submit - button, and cancel button, into a new container `div` with - `id="eventCreateState"`. Default this container to `display: block`. -- Rename or wrap the `#eventHandoffModal` content into a new container `div` - with `id="eventHandoffState"`. Default this container to `display: none`. -- In `#eventHandoffState`, ensure it contains the 3 cards (PIN + /join, Direct - Link, CLI 1-Liner) and update the final button to be a single "Dismiss" - button. -- Ensure `createdEventTitle` has `overflow-wrap: break-word` and - `word-break: break-word` along with a max-width to allow long titles to wrap - cleanly on mobile screens. +### Phase 2: WorkshopDrawer 2-State Machine (`ui/components/sessions/WorkshopDrawer.tsx`) -### 3. JavaScript Logic Update (`ui/components/sessions/SessionsScript.tsx`) +1. **State 1 (`#eventCreateState`):** Wrap the initial creation form + (`#eventForm`), inputs, submit button, and cancel button in + `
`. +2. **State 2 (`#eventHandoffState`):** Wrap the credential cards in + `