fix(sessions): resolve attendees drawer TypeError and PostgreSQL interval syntax error

This commit is contained in:
Tyler Gillispie 2026-08-27 13:30:03 -07:00
parent 0a3c147880
commit f6b5dd3992
11 changed files with 811 additions and 21 deletions

View File

@ -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}
)

View File

@ -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();
}
},
);
});

View File

@ -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`.

View File

@ -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 ~1618 leading
zero bits (~2040ms 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`.

View File

@ -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 `<details>` 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 `<details>` 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 `<details>` 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`.

View File

@ -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`.

View File

@ -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 `<summary>` 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 `<details><summary>` elements
automatically render disclosure triangles in modern browsers.
- _Mitigation:_ Purge all explicit `▸` characters from `<summary>` 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
`<div id="delegateCreateState" style="display: block;">`.
2. Update the submit button:
- Label: **`[ Create Session ]`** (replace `"Mint & Delegate Session"`).
- Standardize styling to match `btn-primary`.
3. In the Accordion `<summary>`:
- 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 `<summary>`:
- 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 `<details>` block:
```html
<details
style="margin-top: 1.5rem; background: var(--surface-card); border: 1px solid var(--border-subtle); border-radius: var(--radius-md); padding: 0.75rem 1rem;">
<summary style="font-weight: 600; font-size: 0.95rem; cursor: pointer; color: var(--text-secondary);">
📁 Expired Events ({expiredEvents.length})
</summary>
...
</details>
```
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`.

53
tasks/ui-audit-4.md Normal file
View File

@ -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:**<br>1. **Button Copy:** Handoff button says `"Done (Session is Active)"` instead of standardized **`[ OK ]`**.<br>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`.<br>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.<br>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:**<br>1. Native `<details><summary>` already renders a disclosure triangle. Hardcoding `▸` in the text produces a broken double arrow (`> >`).<br>2. Clean up accordion text while explicitly retaining the `(Optional)` tag. | **Action Item:**<br>• Remove hardcoded `▸` character from all `<summary>` blocks.<br>• 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:**<br>1. Power users creating multiple sessions or workshop passes need an instant way to mint a second token without closing and reopening the drawer.<br>2. Switching tabs while in a handoff state reveals stale inputs from the other mode. | **Action Item (Dual Actions + Handoff Tab Lock):**<br>• 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).<br>• 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:**<br>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".<br>2. The Guest Drawer header does not dynamically update its countdown timer or expiration badge when an event is extended. | **Action Item:**<br>• Wrap `res.json()` defensively and surface true backend error payloads.<br>• 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 `<details>` 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 `<details>`
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.

View File

@ -32,7 +32,8 @@ export const EventGuestsDrawer = () => {
>
Event Pass · <span id="guestDrawerClaimed">0</span> /{" "}
<span id="guestDrawerMax">0</span> Claimed Seats ·{" "}
<span id="guestDrawerCountdown"> 0h 0m left</span>
<span id="guestDrawerCountdown"> 0h 0m left</span>{" "}
· (<span id="guestDrawerExpiresAt">--:--</span>)
</div>
</div>

View File

@ -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 = '<div style="padding: 1rem; text-align: center; color: var(--text-muted);">No attendees currently active.</div>';
if (contentElem) {
contentElem.innerHTML = '<div style="padding: 1rem; text-align: center; color: var(--text-muted);">No attendees currently active.</div>';
}
return;
}
@ -528,10 +546,17 @@ export const SessionsScript = () => {
});
} else {
document.getElementById('attendeesDrawerContent').innerHTML = '<div style="padding: 1rem; color: var(--danger-text);">Failed to load attendees: ' + (data.error || 'Unknown error') + '</div>';
const contentElem = document.getElementById('attendeesDrawerContent');
if (contentElem) {
contentElem.innerHTML = '<div style="padding: 1rem; color: var(--danger-text);">Failed to load attendees: ' + (data.error || 'HTTP ' + res.status) + '</div>';
}
}
} catch (err) {
document.getElementById('attendeesDrawerContent').innerHTML = '<div style="padding: 1rem; color: var(--danger-text);">Network error loading attendees.</div>';
console.error('[Attendees Drawer Error]', err);
const contentElem = document.getElementById('attendeesDrawerContent');
if (contentElem) {
contentElem.innerHTML = '<div style="padding: 1rem; color: var(--danger-text);">Error loading attendees: ' + (err && err.message ? err.message : 'Network failure') + '</div>';
}
}
}
@ -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) {