From f6b5dd399261e7d2023566f984a3e5b01d8520d6 Mon Sep 17 00:00:00 2001 From: Tyler Gillispie Date: Thu, 27 Aug 2026 13:30:03 -0700 Subject: [PATCH] fix(sessions): resolve attendees drawer TypeError and PostgreSQL interval syntax error --- server/routes/events.ts | 4 +- server/tests/events.test.ts | 74 +++++++- ...ssions-and-events-final-polish-2305.ph6.md | 0 ...rity.p2p-vouching-and-web-of-trust-1040.md | 149 ++++++++++++++++ ...curity.client-proof-of-work-shield-1041.md | 122 +++++++++++++ ...events-archive-and-guest-telemetry-1042.md | 109 ++++++++++++ ...h.spiffe-workload-api-live-harness-1043.md | 91 ++++++++++ ...sessions-docking-and-creation-flow-1130.md | 165 ++++++++++++++++++ tasks/ui-audit-4.md | 53 ++++++ ui/components/sessions/EventGuestsDrawer.tsx | 3 +- ui/components/sessions/SessionsScript.tsx | 62 +++++-- 11 files changed, 811 insertions(+), 21 deletions(-) rename tasks/{new => complete}/2026-0826.06.gem.feat.ui.sessions-and-events-final-polish-2305.ph6.md (100%) create mode 100644 tasks/new/2026-0827.02.gem.feat.security.p2p-vouching-and-web-of-trust-1040.md create mode 100644 tasks/new/2026-0827.03.gem.feat.security.client-proof-of-work-shield-1041.md create mode 100644 tasks/new/2026-0827.04.gem.feat.ui.expired-events-archive-and-guest-telemetry-1042.md create mode 100644 tasks/new/2026-0827.05.gem.feat.mesh.spiffe-workload-api-live-harness-1043.md create mode 100644 tasks/new/2026-0827.06.gem.feat.ui.sessions-docking-and-creation-flow-1130.md create mode 100644 tasks/ui-audit-4.md diff --git a/server/routes/events.ts b/server/routes/events.ts index b45b66d..1a9cec5 100644 --- a/server/routes/events.ts +++ b/server/routes/events.ts @@ -224,7 +224,7 @@ eventRoutes.post("/api/events/:id/extend", async (c) => { try { const eventResult = await sqlWrapper.sql` UPDATE event_passes - SET expires_at = GREATEST(expires_at, NOW()) + interval '${extendHours} hours' + SET expires_at = GREATEST(expires_at, NOW()) + INTERVAL '1 hour' * ${extendHours} WHERE id = ${eventId} AND (created_by = ${user.userId} OR ${await isGlobalAdmin( user.userId, )}) AND is_active = TRUE @@ -242,7 +242,7 @@ eventRoutes.post("/api/events/:id/extend", async (c) => { const sessionResult = await sqlWrapper.sql` UPDATE sessions - SET expires_at = expires_at + interval '${extendHours} hours' + SET expires_at = expires_at + INTERVAL '1 hour' * ${extendHours} WHERE user_id IN ( SELECT id FROM users WHERE event_pass_id = ${eventId} ) diff --git a/server/tests/events.test.ts b/server/tests/events.test.ts index 5f29858..5153779 100644 --- a/server/tests/events.test.ts +++ b/server/tests/events.test.ts @@ -544,7 +544,9 @@ Deno.test("Multi-Claim Event Passes & Join Endpoints", async (t) => { : String(strings); if ( query.includes("UPDATE event_passes") && - query.includes("expires_at = GREATEST(expires_at, NOW()) + interval") + query.includes( + "expires_at = GREATEST(expires_at, NOW()) + INTERVAL '1 hour'", + ) ) { updateEventCalled = true; return Promise.resolve([{ @@ -554,7 +556,7 @@ Deno.test("Multi-Claim Event Passes & Join Endpoints", async (t) => { } if ( query.includes("UPDATE sessions") && - query.includes("expires_at = expires_at + interval") + query.includes("expires_at = expires_at + INTERVAL '1 hour'") ) { updateSessionsCalled = true; return Promise.resolve([{ id: "sess-1" }, { id: "sess-2" }]); @@ -663,4 +665,72 @@ Deno.test("Multi-Claim Event Passes & Join Endpoints", async (t) => { } }, ); + + await t.step( + "GET /api/events/:id/attendees handles 404 for non-existent or unauthorized event", + 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; + sqlWrapper.sql = (() => Promise.resolve([])) as any; + + try { + const res = await app.request("/api/events/non-existent-id/attendees", { + method: "GET", + headers: { + Authorization: "Bearer admin-session", + }, + }); + + assertEquals(res.status, 404); + const json = await res.json(); + assertEquals(json.error, "Event not found or unauthorized"); + } finally { + sqlWrapper.sql = originalSql; + valkeyGetStub.restore(); + } + }, + ); + + await t.step( + "POST /api/events/:id/extend handles 404 for non-existent or inactive event", + 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; + sqlWrapper.sql = (() => Promise.resolve([])) as any; + + try { + const res = await app.request("/api/events/non-existent-id/extend", { + method: "POST", + headers: { + Authorization: "Bearer admin-session", + "Content-Type": "application/json", + }, + body: JSON.stringify({ extendHours: 1 }), + }); + + assertEquals(res.status, 404); + const json = await res.json(); + assertEquals(json.error, "Event not found, inactive, or unauthorized"); + } finally { + sqlWrapper.sql = originalSql; + valkeyGetStub.restore(); + } + }, + ); }); diff --git a/tasks/new/2026-0826.06.gem.feat.ui.sessions-and-events-final-polish-2305.ph6.md b/tasks/complete/2026-0826.06.gem.feat.ui.sessions-and-events-final-polish-2305.ph6.md similarity index 100% rename from tasks/new/2026-0826.06.gem.feat.ui.sessions-and-events-final-polish-2305.ph6.md rename to tasks/complete/2026-0826.06.gem.feat.ui.sessions-and-events-final-polish-2305.ph6.md diff --git a/tasks/new/2026-0827.02.gem.feat.security.p2p-vouching-and-web-of-trust-1040.md b/tasks/new/2026-0827.02.gem.feat.security.p2p-vouching-and-web-of-trust-1040.md new file mode 100644 index 0000000..44bd61d --- /dev/null +++ b/tasks/new/2026-0827.02.gem.feat.security.p2p-vouching-and-web-of-trust-1040.md @@ -0,0 +1,149 @@ +# TASK METADATA + +- **Target Files:** + - `server/db.ts` + - `server/routes/events.ts` + - `server/routes/sessions.ts` + - `server/routes/forward_auth.ts` + - `ui/components/events/VouchingModal.tsx` + - `ui/components/sessions/EventGuestsDrawer.tsx` + - `ui/components/sessions/SessionsScript.tsx` + - `server/tests/vouching.test.ts` +- **Core Objective:** Implement a decentralized Peer-to-Peer (P2P) Vouching Web + of Trust for high-security events, where joining guests enter a quarantined + read-only state until verified by a peer or host via QR/Emoji pairing, with + recursive cascade revocation. +- **Dependencies:** + - Existing `event_passes` schema and `users.event_pass_id` relational linking. + - Valkey L1/L2 cache for real-time session state propagation. +- **Additional Important Notes:** + - Must remain opt-in per event via `require_vouching BOOLEAN DEFAULT FALSE`. + - UI must remain 100% React-free Hono SSR JSX with vanilla JavaScript DOM + state manipulation. + +--- + +## 2. Architectural Considerations & Risks + +### Risks + +1. **Vouching Graph Cycles & Endless Loops:** If attendee A vouches for B, B + vouches for C, and C attempts to vouch for A, cyclical graphs could corrupt + hierarchy metrics or create infinite loops during cascade revocation. + - _Mitigation:_ Enforce an acyclic tree constraint. A user can only be + vouched for once (setting an immutable `vouched_by UUID` pointing to their + verified parent). Vouchers must already be in an `active` (non-quarantined) + state. +2. **Cascade Revocation Overhead & Blast Radius:** If an organizer revokes a + rogue voucher at the root of a large subtree, revoking dozens of downstream + guest sessions could block the event loop or leave orphaned cache records. + - _Mitigation:_ Use a PostgreSQL Recursive Common Table Expression (CTE) to + fetch all downstream descendant session IDs in a single atomic query + (`WITH RECURSIVE subordinates AS (...)`), followed by bulk atomic cache + eviction in Valkey using pipelined `UNLINK`. +3. **UX Friction in Standard Workshops:** Mandating vouching for open public + demos or casual workshops adds unnecessary friction. + - _Mitigation:_ Make P2P vouching an opt-in toggle (`require_vouching`) in + the `[ Delegate Session ]` creation drawer. + +### Alternatives + +- **Centralized Host-Only Approval Queue:** Instead of P2P vouching, all + attendees could wait in a lobby for the host to click "Approve". While + simpler, this creates an operational bottleneck for workshops with 50+ + attendees. P2P vouching allows any already-admitted participant to onboard + their neighbor, enabling rapid, distributed verification. +- **WebRTC / Bluetooth Proximity:** We considered WebRTC or WebBluetooth for + physical co-location verification. However, browser support and permission + prompts make this fragile. A 3-emoji visual sequence + 1-click QR code camera + scan provides instant, high-friction-to-bots physical co-presence verification + with zero native permission dependencies. + +--- + +## 3. Proposed Implementation + +### Phase 1: Database Schema & Entity Relationships (`server/db.ts`) + +1. Add vouching metadata columns to `event_passes`: + - `require_vouching BOOLEAN DEFAULT FALSE` + - `vouch_depth_limit INT DEFAULT 2` (0 = host-only, 1 = direct guests only, + 2+ = transitive) +2. Add verification tracking to `users`: + - `is_quarantined BOOLEAN DEFAULT FALSE` + - `vouched_by UUID REFERENCES users(id) ON DELETE SET NULL` + - `vouched_at TIMESTAMP WITH TIME ZONE` +3. Add ephemeral vouching challenge table `vouch_challenges`: + - `id UUID PRIMARY KEY DEFAULT gen_random_uuid()` + - `target_user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE` + - `event_id UUID NOT NULL REFERENCES event_passes(id) ON DELETE CASCADE` + - `emoji_sequence TEXT NOT NULL` (e.g. `πŸš€-🦊-⚑`) + - `pairing_code TEXT UNIQUE NOT NULL` (6-char alphanumeric code or QR token) + - `created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()` + - `expires_at TIMESTAMP WITH TIME ZONE NOT NULL` + +### Phase 2: Ingress Quarantine & Verification API (`server/routes/events.ts`) + +1. **Quarantined Ingress:** + - In `POST /api/join` and `GET /join/:slug`, if + `event.require_vouching = TRUE`, set `users.is_quarantined = TRUE` and tag + the session with custom scope `['guest:quarantined']`. + - Issue a `vouch_challenge` returning the 3-emoji sequence, QR payload, and + pairing code. +2. **ForwardAuth Guard:** + - In `server/routes/forward_auth.ts`, check if the session contains + `guest:quarantined`. If quarantined, block access to internal apps (return + 403 or redirect to `/vouch/pending`). +3. **Vouching Endpoints:** + - `POST /api/events/:id/vouch/verify`: + - Authenticated voucher submits the 3-emoji sequence or scans the QR + pairing token. + - Verify the voucher is an active (non-quarantined) participant of the same + event. + - Check `vouch_depth_limit`. + - Atomically update `users.is_quarantined = FALSE`, set + `users.vouched_by = voucher.id`, delete the challenge, and broadcast + session upgrade to Valkey. + +### Phase 3: Recursive Cascade Revocation (`server/routes/sessions.ts`) + +1. Refactor `DELETE /api/sessions/:id`: + - If the target session belongs to a guest user who has vouched for others, + execute a recursive CTE query: + ```sql + WITH RECURSIVE tree AS ( + SELECT id, event_pass_id FROM users WHERE id = ${targetUserId} + UNION ALL + SELECT u.id, u.event_pass_id FROM users u + INNER JOIN tree t ON u.vouched_by = t.id + ) + SELECT s.id as session_id, t.id as user_id FROM sessions s + JOIN tree t ON s.user_id = t.id; + ``` + - Delete all discovered sessions in PostgreSQL and evict all matching keys + from Valkey. + +### Phase 4: UI & Mobile Pairing Interface + +1. **Attendee Waiting Screen (`ui/components/events/QuarantineLobby.tsx`):** + - Render large high-contrast 3-emoji sequence and QR code: "Show this to the + workshop host or an admitted attendee to unlock full access". + - Poll or listen for activation event via SSE/fetch. +2. **Voucher Action Modal (`ui/components/events/VouchingModal.tsx`):** + - Admitted attendees see a `[ 🀝 Vouch for Peer ]` button in their top bar or + Guest Drawer. + - Opens simple camera QR scanner or 3-emoji selector grid to confirm the peer + in front of them. +3. **Guest Drawer Web of Trust Tree:** + - In `EventGuestsDrawer.tsx`, show vouching tree indentation / badge + (`Vouched by Tyler G`). + +### Phase 5: Quality Gates & Integration Testing + +1. Authored unit and integration tests in `server/tests/vouching.test.ts`: + - Verify unvouched attendees cannot access ForwardAuth-protected endpoints. + - Verify valid emoji/QR verification lifts quarantine immediately. + - Verify cascading revocation cleans up all child and grandchild sessions + recursively. +2. Run `deno fmt`, `deno task lint`, `deno task check`, and + `deno test -A --no-check`. diff --git a/tasks/new/2026-0827.03.gem.feat.security.client-proof-of-work-shield-1041.md b/tasks/new/2026-0827.03.gem.feat.security.client-proof-of-work-shield-1041.md new file mode 100644 index 0000000..68d504e --- /dev/null +++ b/tasks/new/2026-0827.03.gem.feat.security.client-proof-of-work-shield-1041.md @@ -0,0 +1,122 @@ +# TASK METADATA + +- **Target Files:** + - `server/pow.ts` + - `server/routes/events.ts` + - `server/valkey.ts` + - `ui/components/events/JoinPage.tsx` + - `ui/components/events/JoinScript.tsx` + - `ui/components/events/EventLandingPage.tsx` + - `server/tests/pow.test.ts` +- **Core Objective:** Implement a transparent, sub-50ms client-side WebCrypto + (`crypto.subtle`) SHA-256 Proof-of-Work (PoW) ingress shield to protect + 6-digit workshop PIN codes and event slugs against distributed botnet + brute-forcing and denial-of-service. +- **Dependencies:** + - Valkey 8 for atomic replay cache (`SET ... NX EX 60`). + - Standard WebCrypto APIs supported across modern browsers and Deno. +- **Additional Important Notes:** + - PoW computation must be entirely transparent to human users (solved in + background Web Workers or async microtasks during page load/typing). + - Headless CLI clients (`curl ... | source`) must be provided with a + streamlined solver or signed temporary ingress token. + +--- + +## 2. Architectural Considerations & Risks + +### Risks + +1. **Client-Side CPU Exhaustion & Mobile Battery Drain:** If the cryptographic + difficulty target is set too high, mobile devices or older laptops might + freeze the main thread or experience noticeable latency before submitting a + PIN. + - _Mitigation:_ Calibrate the default baseline difficulty to ~16–18 leading + zero bits (~20–40ms CPU time on standard mobile silicon). Run computation + inside a Web Worker or async `requestAnimationFrame` chunks to guarantee + zero UI frame drops. +2. **Replay Attacks on Solved Nonces:** An attacker could precompute a single + valid PoW solution and reuse it across thousands of simultaneous requests to + brute-force the 6-digit PIN. + - _Mitigation:_ Bind challenges cryptographically to a short-lived + timestamped seed + (`HMAC-SHA256(secret, client_ip + timestamp_minute + event_id)`). Record + solved nonces in Valkey with a strict 60-second TTL via + `SET key nonce NX EX 60` to enforce single-use execution. +3. **Dynamic Threat Scaling:** Static difficulty is either too easy for large + botnets or too heavy for low-powered mobile devices. + - _Mitigation:_ Implement adaptive difficulty scaling in `server/pow.ts`: + Normal traffic requires 16 bits; if an IP or specific event encounters $>3$ + failed attempts within 60s, automatically scale difficulty to 22 bits for + that subnet/event. + +### Alternatives + +- **Cloudflare Turnstile / Google reCAPTCHA:** Heavy external dependency + requiring network egress, proprietary script tags, and tracking cookies. + Rejected. A self-contained, zero-dependency SHA-256 PoW preserves Auth-Yes's + 100% sovereign, zero-external-dependency IAM architecture. +- **Strict IP Rate Limiting Only:** In distributed botnet attacks, attackers + rotate thousands of residential proxies, making single-IP rate limiting + ineffective. PoW shifts the economic and compute cost onto the attacker. + +--- + +## 3. Proposed Implementation + +### Phase 1: PoW Challenge Engine (`server/pow.ts`) + +1. Implement challenge minting: + - `generateChallenge(clientIp: string, eventId?: string, difficultyBits = 16)`: + - Mint challenge payload: + `{ salt: string, timestamp: number, difficulty: number, signature: string }`. + - Sign payload using internal HMAC key. +2. Implement validation: + - `verifyProofOfWork(challenge: PoWChallenge, nonce: number, clientIp: string)`: + - Verify HMAC signature on salt/timestamp (ensure timestamp within 120s + window). + - Compute `SHA-256(salt + nonce)`. + - Check that the leading $N$ bits of the digest are zero. + - Atomically store `nonce` in Valkey + (`SET pow:${salt}:${nonce} 1 NX EX 120`); if key already exists, reject + as replayed. + +### Phase 2: Ingress Route Protection (`server/routes/events.ts`) + +1. Add challenge endpoint: + - `GET /api/join/challenge`: Returns + `{ salt, timestamp, difficulty, signature }`. +2. Update `POST /api/join`: + - Require `pow_salt`, `pow_nonce`, `pow_timestamp`, `pow_signature` in JSON + body. + - Validate PoW _before_ checking the PIN code or touching PostgreSQL. + - If invalid or missing, immediately return 400 + (`Bad Request: Invalid Proof-of-Work Challenge`) without consuming DB + connections. +3. Adaptive Difficulty Escalation: + - Track failed attempts per IP and per event in Valkey. Increase `difficulty` + dynamically if failure rate spikes. + +### Phase 3: Client-Side WebCrypto Solver (`ui/components/events/JoinScript.tsx`) + +1. **Background Web Worker Solver:** + - When the user lands on `/join` or `/e/:slug`, automatically fetch + `/api/join/challenge` in the background. + - Spawn a lightweight Web Worker executing WebCrypto + `crypto.subtle.digest('SHA-256', ...)` in a tight loop to find the nonce + while the user types their 6-digit PIN. + - Attach solved nonce payload seamlessly to the form submit handler. +2. **Headless CLI Compatibility:** + - For CLI joins (`/join/:slug?format=env`), embed a micro-solver script in + the piped shell payload or provide a signed 1-click token for terminal + workflows. + +### Phase 4: Quality Gates & Benchmarks + +1. Comprehensive test suite in `server/tests/pow.test.ts`: + - Unit tests for challenge generation, difficulty checks, expiration, and + replay prevention. + - Performance benchmark asserting solver verification on server takes + $<0.5\text{ms}$. +2. Run `deno fmt`, `deno task lint`, `deno task check`, and + `deno test -A --no-check`. diff --git a/tasks/new/2026-0827.04.gem.feat.ui.expired-events-archive-and-guest-telemetry-1042.md b/tasks/new/2026-0827.04.gem.feat.ui.expired-events-archive-and-guest-telemetry-1042.md new file mode 100644 index 0000000..881b5a2 --- /dev/null +++ b/tasks/new/2026-0827.04.gem.feat.ui.expired-events-archive-and-guest-telemetry-1042.md @@ -0,0 +1,109 @@ +# TASK METADATA + +- **Target Files:** + - `server/routes/events.ts` + - `ui/components/sessions/EventCockpitDeck.tsx` + - `ui/components/sessions/EventGuestsDrawer.tsx` + - `ui/components/sessions/SessionsScript.tsx` + - `ui/components/SessionsPage.tsx` + - `server/tests/events.test.ts` + - `ui/ui_scripts.test.ts` +- **Core Objective:** Clean up the Sessions & Events dashboard by archiving + expired passes into a collapsible `
` accordion with a 1-click + `[ πŸ”„ Reopen (+1h) ]` action, and enrich the Guest Drawer with real-time + attendee telemetry and activity logs. +- **Dependencies:** None. +- **Additional Important Notes:** + - Must remain 100% pure React-free Hono SSR JSX. + - Distinguish between naturally expired events (`expires_at < NOW()`, + `is_active = TRUE`, reopenable) vs manually terminated events + (`is_active = FALSE`, permanent archive). + +--- + +## 2. Architectural Considerations & Risks + +### Risks + +1. **Dashboard Clutter & Information Overload:** As organizers host dozens of + workshops over weeks, active and dead events pile up on the main screen, + pushing active management tools below the fold. + - _Mitigation:_ Partition the SQL query and SSR view into two decks: + `activePasses` (top priority grid/compact cards) and `expiredPasses` + (collapsed under a clean `
` drawer showing count + `[ πŸ“ Expired Passes (N) ]`). +2. **Re-opening State Ambiguity:** If an organizer re-opens an event that + expired 3 days ago, adding 1 hour via `expires_at + interval '1h'` leaves it + in the past. + - _Mitigation:_ Use + `SET expires_at = GREATEST(expires_at, NOW()) + interval '1 hour'` in + PostgreSQL to guarantee the event opens immediately for 60 minutes from the + moment clicked. +3. **Telemetry Performance in Large Guest Drawers:** Querying full session + activity logs for 100+ attendees could slow down + `GET /api/events/:id/attendees`. + - _Mitigation:_ Select only `last_activity_action`, `last_activity_at`, and + `is_agent` joined directly on `sessions` indexed by `user_id`. + +### Alternatives + +- **Permanent Deletion of Expired Events:** We considered automatically dropping + expired events from the database. However, organizers frequently need to audit + past workshop attendees or re-open events that ran over schedule. Keeping + expired passes in an archived state maintains audit integrity. + +--- + +## 3. Proposed Implementation + +### Phase 1: Split Active vs. Expired Events Query (`ui/components/SessionsPage.tsx`) + +1. Update `event_passes` query in `SessionsPage.tsx`: + - Split passes into + `activeEvents = eventPasses.filter(e => e.is_active && (!e.expires_at || new Date(e.expires_at) > new Date()))`. + - `expiredEvents = eventPasses.filter(e => !e.is_active || (e.expires_at && new Date(e.expires_at) <= new Date()))`. +2. Pass both arrays into `EventCockpitDeck.tsx`. + +### Phase 2: Collapsible Expired Deck & 1-Click Reopen (`ui/components/sessions/EventCockpitDeck.tsx`) + +1. **Active Deck:** + - Render active events in standard Grid or 2-Row Compact view. +2. **Archived Deck (`[ πŸ“ Expired Passes (N) ]`):** + - Render a native `
` element below the active deck. + - For each expired pass with `is_active = TRUE`, display an amber/gray card + with an instant `[ πŸ”„ Reopen (+1h) ]` button. + - For manually ended events (`is_active = FALSE`), show a muted + `[ Terminated ]` badge without a reopen button. +3. **Client Script Handler (`SessionsScript.tsx`):** + - Add `reopenEvent(eventId)` calling `POST /api/events/:id/extend` with + `{ extendHours: 1 }`. + - Dynamically transition the card or reload to place it back into the active + deck. + +### Phase 3: Rich Attendee Telemetry in Guest Drawer (`ui/components/sessions/EventGuestsDrawer.tsx`) + +1. **Backend Payload Enrichment (`server/routes/events.ts`):** + - In `GET /api/events/:id/attendees`, select: + - `s.last_activity_at` + - `s.last_activity_action` (e.g. `ForwardAuth Ingress (ed-droid)`, + `API Token Read`) + - `s.is_agent` +2. **UI Card Formatting:** + - On each attendee card in the drawer, render: + - **Header:** `Seat #[N]` + `🟒 Active / ⏸️ Paused` badge + inline + `[ πŸ—‘οΈ Revoke ]`. + - **Line 1 (Inverted Join Time):** + `Joined Today Β· 2:15 PM Β· [ Active 1h 45m ]`. + - **Line 2 (Action Telemetry):** + `Last Action: ForwardAuth (ed-droid) Β· 2m ago Β· πŸ’» Web` (with device icon + or agent robot emoji if `is_agent = true`). + +### Phase 4: Quality Gates & Verification + +1. Unit tests in `server/tests/events.test.ts`: + - Test reopening naturally expired events with + `GREATEST(expires_at, NOW()) + 1h`. + - Test telemetry fields in `/attendees` endpoint. +2. UI syntax tests in `ui/ui_scripts.test.ts`. +3. Run `deno fmt`, `deno task lint`, `deno task check`, and + `deno test -A --no-check`. diff --git a/tasks/new/2026-0827.05.gem.feat.mesh.spiffe-workload-api-live-harness-1043.md b/tasks/new/2026-0827.05.gem.feat.mesh.spiffe-workload-api-live-harness-1043.md new file mode 100644 index 0000000..c832cda --- /dev/null +++ b/tasks/new/2026-0827.05.gem.feat.mesh.spiffe-workload-api-live-harness-1043.md @@ -0,0 +1,91 @@ +# TASK METADATA + +- **Target Files:** + - `spire_ffi/src/lib.rs` + - `spire_ffi/Cargo.toml` + - `server/spire.ts` + - `server/routes/rpc.ts` + - `server/tests/spire_workload.test.ts` + - `infra/spire/docker-compose.test.yml` +- **Core Objective:** Establish a comprehensive integration harness and + automated testing pipeline for the Rust SPIFFE/mTLS FFI crate (`spire_ffi/`), + enabling live X.509 SVID validation and ConnectRPC mutual TLS attestation + against a containerized SPIRE agent. +- **Dependencies:** + - Rust toolchain (`cargo`, `rustc`). + - Deno FFI (`--allow-ffi`). + - Containerized SPIRE Server & Agent in `infra/spire/`. +- **Additional Important Notes:** + - Must provide a seamless mock fallback for standard development environments + when `libspire_ffi.so` is not built, while enforcing strict validation in + mesh-enabled environments. + +--- + +## 2. Architectural Considerations & Risks + +### Risks + +1. **Native Dynamic Library Portability:** C dynamic libraries (`.so`, `.dylib`, + `.dll`) compiled on one architecture/OS cannot run on others without + cross-compilation. + - _Mitigation:_ Keep `server/spire.ts` resilient with an explicit graceful + fallback mock layer when `libspire_ffi` is missing. Provide a dedicated + task script (`deno task build:ffi`) that compiles `spire_ffi` locally using + `cargo build --release`. +2. **Workload API Unix Domain Socket Timeouts:** If the SPIRE agent daemon + restarts or socket permissions change, FFI calls to fetch X.509 SVIDs could + block or hang worker threads. + - _Mitigation:_ Enforce non-blocking socket reads with strict timeouts (e.g. + 500ms) inside Rust FFI functions before returning to Deno. + +### Alternatives + +- **Pure TypeScript gRPC Client for SPIRE Workload API:** We considered writing + a pure TypeScript gRPC client. However, SPIRE's Workload API communicates over + Unix Domain Sockets with strict OS-level credential passing (`SO_PEERCRED`), + which is far more performant and natively handled via the official + `spire-api-sdk` crate in Rust. + +--- + +## 3. Proposed Implementation + +### Phase 1: Rust FFI Hardening (`spire_ffi/src/lib.rs`) + +1. Enhance `spire_ffi`: + - Implement + `fetch_x509_svid(socket_path: *const c_char, timeout_ms: u32) -> FfiResult`. + - Implement + `validate_spiffe_id(client_cert_der: *const u8, cert_len: usize, expected_spiffe_id: *const c_char) -> bool`. + - Ensure all string buffers and error structures are safely allocated and + freed across the FFI boundary (`free_ffi_string`). + +### Phase 2: Deno FFI Binding & Fallback Layer (`server/spire.ts`) + +1. Load dynamic library: + - Search `./libspire_ffi.so`, `./spire_ffi/target/release/libspire_ffi.so`, + and system library paths. + - If missing, log a warning and activate the hermetic mock provider for local + unit testing. +2. Expose high-level TypeScript API: + - `getWorkloadSvid(socketPath?: string): Promise<{ spiffeId: string, certChain: Uint8Array, privateKey: Uint8Array }>` + - `verifyClientSpiffeId(cert: Uint8Array, expectedId: string): boolean` + +### Phase 3: ConnectRPC Integration & Test Harness + +1. In `server/routes/rpc.ts`: + - Bind incoming ConnectRPC service requests to `verifyClientSpiffeId` when + running behind mTLS proxies. +2. In `infra/spire/docker-compose.test.yml`: + - Add minimal SPIRE Server & Agent test configuration with a registered test + workload entry (`spiffe://system.local/auth-yes-tester`). +3. In `server/tests/spire_workload.test.ts`: + - Test live X.509 SVID acquisition, parsing, and ConnectRPC authorization + against the running test SPIRE container. + +### Phase 4: Quality Gates + +1. Run `cargo test` in `spire_ffi/`. +2. Run `deno fmt`, `deno task lint`, `deno task check`, and + `deno test -A --no-check`. diff --git a/tasks/new/2026-0827.06.gem.feat.ui.sessions-docking-and-creation-flow-1130.md b/tasks/new/2026-0827.06.gem.feat.ui.sessions-docking-and-creation-flow-1130.md new file mode 100644 index 0000000..1795ab6 --- /dev/null +++ b/tasks/new/2026-0827.06.gem.feat.ui.sessions-docking-and-creation-flow-1130.md @@ -0,0 +1,165 @@ +# TASK METADATA + +- **Target Files:** + - `ui/components/SessionsPage.tsx` + - `ui/components/sessions/DirectPassDrawer.tsx` + - `ui/components/sessions/WorkshopDrawer.tsx` + - `ui/components/sessions/SessionsScript.tsx` + - `ui/ui_scripts.test.ts` +- **Core Objective:** Resolve Round 4 UI defects by docking the sticky + sub-header beneath the main app top bar, standardizing single session creation + to full parity with workshop passes, eliminating `` double arrows + while preserving `(Optional)` labels, and implementing a rapid creation flow + (`[ Create Another ]` + `[ OK ]`) with tab-locking during handoff states. +- **Dependencies:** None. +- **Additional Important Notes:** Must remain 100% pure React-free Hono SSR JSX. + All state transitions in `SessionsScript.tsx` must use native vanilla + JavaScript DOM APIs. + +--- + +## 2. Architectural Considerations & Risks + +### Risks + +1. **Navbar Collision on Mobile Viewports:** On narrow screens, the top app bar + height might vary depending on wrapped elements. + - _Mitigation:_ Use `top: var(--top-bar-height, 57px); z-index: 30;` with a + solid background (`var(--surface-bg)`) to prevent cards from scrolling + through transparent gaps or overlapping the fixed `.top-bar` (which sits at + `z-index: 40`). +2. **State Leakage Across Tabs:** If a user mints a Workshop Pass, the handoff + screen appears. If they click the "Single Pass" tab while the event handoff + is still visible, the form displays a dirty or stale state. + - _Mitigation:_ Lock the tab switcher (`pointer-events: none; opacity: 0.5;` + or visually disable the buttons) while in either `handoffModal` or + `eventHandoffState`. Tab navigation is automatically re-enabled when the + user clicks `[ Create Another ]` or closes the drawer. +3. **Double Arrow Rendering:** Native `
` elements + automatically render disclosure triangles in modern browsers. + - _Mitigation:_ Purge all explicit `β–Έ` characters from `` text + strings across both drawer templates. + +### Alternatives + +- **Closing the Drawer Immediately on Creation:** We considered closing the + drawer right after minting and letting the user copy links from the main page + deck. However, showing the 1-click link, PIN, and CLI export immediately upon + creation in a dedicated handoff card provides essential visual confirmation + and zero-friction copying before returning to the dashboard. + +--- + +## 3. Proposed Implementation + +### Phase 1: Viewport Docking (`ui/components/SessionsPage.tsx`) + +1. Locate the `Sessions & Events` sub-header block in `SessionsPage.tsx`. +2. Update the sticky styling from `top: 0; z-index: 40;` to: + ```css + position: sticky; + top: var(--top-bar-height, 57px); + z-index: 30; + background: var(--surface-bg, #0f172a); + padding: 0.75rem 0; + margin-bottom: 1.5rem; + border-bottom: 1px solid var(--border-subtle); + ``` + +### Phase 2: Single Pass Drawer Parity & Form Hiding (`ui/components/sessions/DirectPassDrawer.tsx`) + +1. Wrap the Single Pass creation form in + `
`. +2. Update the submit button: + - Label: **`[ Create Session ]`** (replace `"Mint & Delegate Session"`). + - Standardize styling to match `btn-primary`. +3. In the Accordion ``: + - Remove the `β–Έ` character. + - Label as: **`"Customize App Permissions & Scopes (Optional)"`**. +4. In `#handoffModal`: + - Change the 1-Click Link copy button from `btn-primary` to uniform + `btn-outline`. + - Update footer actions: + - Replace the single `"Done (Session is Active)"` button with a dual-action + flex container: + - **`[ Create Another ]`** + (`type="button" class="btn-outline" onclick="createAnotherSession()"`) + - **`[ OK ]`** + (`type="button" class="btn-primary" onclick="closeDelegateDrawer()"`) + +### Phase 3: Workshop Drawer Parity & Accordion Cleanliness (`ui/components/sessions/WorkshopDrawer.tsx`) + +1. Update the submit button: + - Label: **`[ Create Event ]`** (replace `"🎟️ Launch Workshop Pass"`). +2. In the Custom Vanity Slug & PIN ``: + - Remove the `β–Έ` character. + - Label as: **`"Custom Vanity Slug & PIN Code (Optional)"`**. +3. In `#eventHandoffState`: + - Update footer actions to match Single Pass: + - **`[ Create Another ]`** + (`type="button" class="btn-outline" onclick="createAnotherEvent()"`) + - **`[ OK ]`** + (`type="button" class="btn-primary" onclick="closeDelegateDrawer()"`) + +### Phase 4: Client State Machine & Tab Locking (`ui/components/sessions/SessionsScript.tsx`) + +1. **Single Session Submit Handler (`handleDelegateSession`):** + - On success, set `#delegateCreateState.style.display = 'none'` and + `#handoffModal.style.display = 'block'`. + - Call `lockDelegationTabs(true)` to prevent switching tabs while viewing the + handoff token. +2. **Event Creation Submit Handler (`handleCreateEvent`):** + - On success, set `#eventCreateState.style.display = 'none'` and + `#eventHandoffState.style.display = 'block'`. + - Call `lockDelegationTabs(true)`. +3. **Tab Locking Helpers:** + - `lockDelegationTabs(locked)`: Toggles `disabled` state and + `opacity: 0.5; pointer-events: none;` on `#tabBtnDirectPass` and + `#tabBtnWorkshopPass`. +4. **"Create Another" Handlers:** + - `createAnotherSession()`: + - Reset `#delegateForm` input values (default back to 1 Hour and + Read-Only). + - `#handoffModal.style.display = 'none'`. + - `#delegateCreateState.style.display = 'block'`. + - `lockDelegationTabs(false)`. + - Focus `#delegateLabel`. + - `createAnotherEvent()`: + - Reset `#eventForm` input values (default back to 50 seats and 3 hours). + - `#eventHandoffState.style.display = 'none'`. + - `#eventCreateState.style.display = 'block'`. + - `lockDelegationTabs(false)`. + - Focus `#eventName`. +5. **Drawer Open/Close Reset:** + - When calling `openDelegateDrawer()`, ensure `lockDelegationTabs(false)` is + invoked and both form states are reset to visible State 1. + +### Phase 5: Expired Events Accordion & 1-Click Reopen (`ui/components/sessions/EventCockpitDeck.tsx`) + +1. **Active vs. Expired Separation:** + - Filter `eventPasses` into `activeEvents` and `expiredEvents` + (`expires_at <= NOW()` or `is_active = FALSE`). +2. **Collapsible Accordion:** + - Below the active deck, render a native `
` block: + ```html +
+ + πŸ“ Expired Events ({expiredEvents.length}) + + ... +
+ ``` +3. **1-Click Reopen Action:** + - For naturally expired events (`is_active = TRUE`), render a dedicated + **`[ πŸ”„ Reopen (+1h) ]`** button calling `POST /api/events/:id/extend` with + `extendHours: 1`. + +### Phase 6: Quality Gates & Network/Error Mock Testing + +1. Run `deno test -A --no-check server/tests/events.test.ts` to assert all + extend, attendees, and 404/403 failure paths pass. +2. Run `deno test -A --no-check ui/ui_scripts.test.ts` to verify zero JavaScript + syntax or parsing errors. +3. Run `deno fmt`, `deno task lint`, `deno task check`, and + `deno test -A --no-check`. diff --git a/tasks/ui-audit-4.md b/tasks/ui-audit-4.md new file mode 100644 index 0000000..2c0610a --- /dev/null +++ b/tasks/ui-audit-4.md @@ -0,0 +1,53 @@ +# UI & UX Live Audit Log β€” Round 4 (`ui-audit-4.md`) + +**Date:** 2026-08-27 (Session: 10:45)\ +**Target Environments:** `https://auth.atyg.org` | `https://ed-droid.atyg.org`\ +**Scope:** Live verification of Phase 7 & 8 Deliverables (Attendee Relational +Decoupling, Sticky Action Bar, Dynamic Credential Rotation DOM Sync, Guest +Drawer Event Meta Payload, Legacy DB Migration Backfills) and discovery/triage +of new Round 4 defects. + +--- + +## 1. Core Verification Focus Areas + +| # | Feature / Flow | Target Behavior | Status | +| :---- | :-------------------------------- | :---------------------------------------------------------------------------------------------------------------------------------------- | :----------------- | +| **1** | **Sticky Action Bar** | Pinned `Sessions & Events` header and `[ Delegate Session ]` trigger stick cleanly to top viewport on deep scroll without clipping cards. | ⏳ Ready for Audit | +| **2** | **Live Credential Rotation Sync** | Clicking `[ πŸ”„ Rotate ]` hot-swaps the numeric PIN, Slug, Direct Link, and CLI command in DOM (Grid & Compact) preserving proxy origin. | ⏳ Ready for Audit | +| **3** | **Guest Drawer Live Meta** | Guest Drawer renders live countdown (`⏳ Xh Ym left`) and accurate seat fraction (`N / Max Seats`) with zero `0 / 0` placeholders. | ⏳ Ready for Audit | +| **4** | **Relational Session Revocation** | Revoking or ending an event pass cleanly terminates only matching attendee sessions without string `LIKE` leakage or orphan records. | ⏳ Ready for Audit | +| **5** | **Legacy Pass Compatibility** | Pre-existing guest accounts seamlessly bind to `event_pass_id` via the backfill migration so older passes can be revoked/extended. | ⏳ Ready for Audit | + +--- + +## 2. Live Observation & Findings Log + +_Record live observations, visual feedback, quirks, UI anomalies, and proposed +action items here._ + +| Timestamp | Scenario / Screen | Component / Flow | Observation / Finding | Resolution / Action Item | +| :-------- | :---------------------------------------- | :------------------------------------------------ | :------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | :----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `10:45` | Setup | System | Opened `ui-audit-4.md` log for Round 4 live verification and defect triage. | Ready for live observation inputs. | +| `11:17` | Layout & Viewport Collision | `SessionsPage.tsx` vs `AuthenticatedLayout.tsx` | **Sticky Sub-Header Overwrites Main Navigation:** The `Sessions & Events` sub-header has `position: sticky; top: 0; z-index: 40;`, which collides directly with the fixed `.top-bar` navbar (also at `top: 0`), overwriting the brand badge, nav links, and user menu on scroll. | **Action Item:** Offset sub-header to `top: var(--top-bar-height, 57px); z-index: 30;` so it docks cleanly right beneath the main top bar without clipping or obscuring navigation. | +| `11:20` | Information Architecture & Parity | `DirectPassDrawer.tsx` vs `WorkshopDrawer.tsx` | **Single Session Minting Lacks Phase 6/8 UX Parity:**
1. **Button Copy:** Handoff button says `"Done (Session is Active)"` instead of standardized **`[ OK ]`**.
2. **Copy Button Styling:** 1-Click link copy button is solid blue `btn-primary` while CLI/cURL are `btn-outline`. Standardize all to uniform `btn-outline`.
3. **Form Does Not Hide on Mint:** Creating a single session keeps `#delegateForm` visible above the handoff card, leaving the stale `[ Cancel ]` button visible after creation.
4. **Action Button Phrasing:** Use explicit, confidence-building submit labels: **`[ Create Session ]`** for Single Pass and **`[ Create Event ]`** for Multi-User Workshop. | **Action Item:** Bring `DirectPassDrawer.tsx` to 100% parity with `WorkshopDrawer.tsx` by hiding `#delegateForm` upon generation, displaying a dedicated handoff card with uniform `btn-outline` copy buttons, and standardized `[ Create Session ]` / `[ OK ]` triggers. | +| `11:23` | Visual Glitch & Redundant Copy | `DirectPassDrawer.tsx` & `WorkshopDrawer.tsx` | **Double Arrow (`> >`) & Wordy Accordion Titles:**
1. Native `
` already renders a disclosure triangle. Hardcoding `β–Έ` in the text produces a broken double arrow (`> >`).
2. Clean up accordion text while explicitly retaining the `(Optional)` tag. | **Action Item:**
β€’ Remove hardcoded `β–Έ` character from all `` blocks.
β€’ Label as **`"Customize App Permissions & Scopes (Optional)"`** in Single Pass and **`"Custom Vanity Slug & PIN Code (Optional)"`** in Workshop Pass. | +| `11:26` | Workflow Architecture & State Transitions | `SessionsScript.tsx` & Drawers | **Rapid Creation Workflow & Tab Locking in Handoff:**
1. Power users creating multiple sessions or workshop passes need an instant way to mint a second token without closing and reopening the drawer.
2. Switching tabs while in a handoff state reveals stale inputs from the other mode. | **Action Item (Dual Actions + Handoff Tab Lock):**
β€’ In both single and event handoff screens, provide two text buttons: **`[ Create Another ]`** (`btn-outline`, resets form to State 1 in 1 click) and **`[ OK ]`** (`btn-primary`, closes drawer).
β€’ Disable/lock the top tab switcher while in the Handoff state so users cannot switch tabs until they either click `[ Create Another ]` or close the drawer. | +| `13:11` | Backend SQL Bug & 500 Error | `server/routes/events.ts` (`/extend`) | **SQL Syntax Error on `+1h Extend`:** In `server/routes/events.ts:227` and `245`, `interval '${extendHours} hours'` inside tagged SQL literals evaluates to invalid parameterized SQL (`$1` inside quotes), throwing a Postgres syntax error and returning 500 ("Network error extending event"). | **Action Item:** Replace `interval '${extendHours} hours'` with valid parameterized PostgreSQL interval multiplication: `INTERVAL '1 hour' * ${extendHours}`. | +| `13:13` | Telemetry & Ingress API | `server/routes/events.ts` (`/attendees`) & Script | **"Network Error Loading Attendees" & Stale Drawer Expiration:**
1. In `openAttendeesDrawer()`, if the endpoint returns an error or non-JSON payload, `res.json()` throws a SyntaxError which surfaces as a misleading "Network error".
2. The Guest Drawer header does not dynamically update its countdown timer or expiration badge when an event is extended. | **Action Item:**
β€’ Wrap `res.json()` defensively and surface true backend error payloads.
β€’ Update `guestDrawerExpiresAt` and trigger `updateAllCountdowns()` when extending an event. | +| `13:15` | IA & Lifecycle Completeness | `EventCockpitDeck.tsx` & Queries | **Missing Expired Events Accordion:** Expired event passes currently remain mixed or hidden rather than cleanly archived in a dedicated collapsible `
` section with a 1-click `[ πŸ”„ Reopen (+1h) ]` button. | **Action Item:** Integrate the `[ πŸ“ Expired Passes (N) ]` collapsible accordion into `EventCockpitDeck.tsx` with instant 1-click reopen capability (`GREATEST(expires_at, NOW()) + 1h`). | +| `13:17` | Architecture & Data Model | `SessionsPage.tsx` | **Events vs. Sessions List Separation:** Multi-claim event passes live in the top `Events` Cockpit deck, while individual 1:1 sessions (passkeys, device logins, agent tokens) live in the bottom `Sessions` table/deck. Guest attendee sessions live inside each event's `[ πŸ‘₯ Manage Guests ]` drawer. | **Status / Clarification:** Clarified architectural separation between Event pass factories (top deck) vs individual session tokens (bottom table). | + +--- + +## 3. Retained Action & Security Backlog + +- [ ] **πŸ›‘οΈ Peer-to-Peer Vouching (Web of Trust):** Opt-in quarantine state for + event attendees with QR/3-Emoji neighbor verification and cascade + revocation. +- [ ] **⚑ Transparent Client Proof-of-Work (PoW):** Background WebCrypto + SHA-256 challenge on `/api/join` to block botnet PIN brute-forcing. +- [ ] **πŸ“ Expired Events Archive & 1-Click Reopen:** Collapsible `
` + section for expired passes with instant `[ πŸ”„ Reopen (+1h) ]` button. +- [ ] **πŸ“Š Rich Attendee Telemetry:** Display last activity action, relative + time, and device/agent badge in guest drawer cards. diff --git a/ui/components/sessions/EventGuestsDrawer.tsx b/ui/components/sessions/EventGuestsDrawer.tsx index 6cbdd2f..c296ae0 100644 --- a/ui/components/sessions/EventGuestsDrawer.tsx +++ b/ui/components/sessions/EventGuestsDrawer.tsx @@ -32,7 +32,8 @@ export const EventGuestsDrawer = () => { > Event Pass Β· 0 /{" "} 0 Claimed Seats Β·{" "} - ⏳ 0h 0m left + ⏳ 0h 0m left{" "} + Β· (--:--)
diff --git a/ui/components/sessions/SessionsScript.tsx b/ui/components/sessions/SessionsScript.tsx index 533427f..4a15f99 100644 --- a/ui/components/sessions/SessionsScript.tsx +++ b/ui/components/sessions/SessionsScript.tsx @@ -427,26 +427,44 @@ export const SessionsScript = () => { try { const res = await fetch('/api/events/' + eventId + '/attendees'); - const data = await res.json(); + let data = {}; + try { + data = await res.json(); + } catch (_) { + // Non-JSON payload + } if (res.ok && data.success) { - const attendees = data.attendees; + const attendees = data.attendees || []; + + // Update Context Header with null safety + const claimedElem = document.getElementById('guestDrawerClaimed'); + if (claimedElem) claimedElem.textContent = attendees.length; - // Update Context Header - // In a real app we would get the true max seats and expires time from the event payload. - // For this UI, we can derive it or keep it simple. - document.getElementById('guestDrawerClaimed').textContent = attendees.length; if (data.event) { - document.getElementById('guestDrawerMax').textContent = data.event.max_seats; - const expDate = new Date(data.event.expires_at); - document.getElementById('guestDrawerExpiresAt').textContent = expDate.toLocaleTimeString([], {hour: '2-digit', minute:'2-digit'}); - document.getElementById('guestDrawerCountdown').setAttribute('data-expires-at', data.event.expires_at); + const maxElem = document.getElementById('guestDrawerMax'); + if (maxElem) maxElem.textContent = data.event.max_seats; + + const expiresAtElem = document.getElementById('guestDrawerExpiresAt'); + if (expiresAtElem && data.event.expires_at) { + const expDate = new Date(data.event.expires_at); + expiresAtElem.textContent = expDate.toLocaleTimeString([], {hour: '2-digit', minute:'2-digit'}); + } + + const countdownElem = document.getElementById('guestDrawerCountdown'); + if (countdownElem && data.event.expires_at) { + countdownElem.setAttribute('data-expires-at', data.event.expires_at); + } + // Trigger countdown update if (typeof updateAllCountdowns === 'function') updateAllCountdowns(); } + const contentElem = document.getElementById('attendeesDrawerContent'); if (attendees.length === 0) { - document.getElementById('attendeesDrawerContent').innerHTML = '
No attendees currently active.
'; + if (contentElem) { + contentElem.innerHTML = '
No attendees currently active.
'; + } return; } @@ -528,10 +546,17 @@ export const SessionsScript = () => { }); } else { - document.getElementById('attendeesDrawerContent').innerHTML = '
Failed to load attendees: ' + (data.error || 'Unknown error') + '
'; + const contentElem = document.getElementById('attendeesDrawerContent'); + if (contentElem) { + contentElem.innerHTML = '
Failed to load attendees: ' + (data.error || 'HTTP ' + res.status) + '
'; + } } } catch (err) { - document.getElementById('attendeesDrawerContent').innerHTML = '
Network error loading attendees.
'; + console.error('[Attendees Drawer Error]', err); + const contentElem = document.getElementById('attendeesDrawerContent'); + if (contentElem) { + contentElem.innerHTML = '
Error loading attendees: ' + (err && err.message ? err.message : 'Network failure') + '
'; + } } } @@ -565,12 +590,17 @@ export const SessionsScript = () => { headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ extendHours: 1 }) }); - if (res.ok) { + let data = {}; + try { + data = await res.json(); + } catch (_) { + // Non-JSON payload + } + if (res.ok && data.success) { showNotice('Event extended by 1 hour!', false); setTimeout(() => window.location.reload(), 600); } else { - const data = await res.json(); - showNotice(data.error || 'Failed to extend event', true); + showNotice(data.error || 'Failed to extend event (HTTP ' + res.status + ')', true); e.currentTarget.disabled = false; } } catch (err) {