From 0f4b72e813577adced1416afbc6809254239abbf Mon Sep 17 00:00:00 2001 From: Tyler Gillispie Date: Tue, 25 Aug 2026 08:01:44 -0700 Subject: [PATCH] feat(passes): implement ephemeral 1-click magic links, event passes, PIN join portal, and CLI 1-liner --- docs/DUAL_AUDIENCE_DEVELOPMENT_GUIDE.md | 121 ++++-- ...ephemeral-passes-and-multi-claim-events.md | 200 +++++---- server/db.ts | 18 + server/main.test.ts | 257 +++++++++++ server/main.ts | 404 +++++++++++++++++- ...sses.ephemeral-1-click-guest-links-0045.md | 17 +- ...s.multi-claim-workshops-and-kiosks-0046.md | 29 +- ...live-metrics-and-master-killswitch-0047.md | 29 +- ui/components/EventJoinPage.tsx | 123 ++++++ ui/components/EventSplashPage.tsx | 161 +++++++ ui/components/SessionsPage.tsx | 29 +- 11 files changed, 1242 insertions(+), 146 deletions(-) rename tasks/{new => complete}/2026-0825.01.gem.feat.magic-passes.ephemeral-1-click-guest-links-0045.md (75%) rename tasks/{new => complete}/2026-0825.02.gem.feat.event-passes.multi-claim-workshops-and-kiosks-0046.md (67%) create mode 100644 ui/components/EventJoinPage.tsx create mode 100644 ui/components/EventSplashPage.tsx diff --git a/docs/DUAL_AUDIENCE_DEVELOPMENT_GUIDE.md b/docs/DUAL_AUDIENCE_DEVELOPMENT_GUIDE.md index b6f58b7..f1ccf2c 100644 --- a/docs/DUAL_AUDIENCE_DEVELOPMENT_GUIDE.md +++ b/docs/DUAL_AUDIENCE_DEVELOPMENT_GUIDE.md @@ -1,16 +1,21 @@ # Dual-Audience Development Guide: Agent-First APIs & Mobile-First SSR UI -This guide outlines the architectural blueprint and best practices for building modern services within the **Auth-Yes Zero-Trust Ecosystem**. +This guide outlines the architectural blueprint and best practices for building +modern services within the **Auth-Yes Zero-Trust Ecosystem**. --- ## 1. The Core Philosophy: Dual-Audience Architecture Every modern application should be built for **two primary audiences**: + 1. **🤖 AI Agents & Workloads (Primary Data Consumer):** - - Headless execution via **Model Context Protocol (MCP)**, REST APIs, or ConnectRPC. - - Machine-readable, high-density JSON/RPC responses with zero HTML/CSS clutter. - - Authentication via **Delegated Bearer Tokens (`ay_sess_...`)** or mTLS SPIFFE workload identities. + - Headless execution via **Model Context Protocol (MCP)**, REST APIs, or + ConnectRPC. + - Machine-readable, high-density JSON/RPC responses with zero HTML/CSS + clutter. + - Authentication via **Delegated Bearer Tokens (`ay_sess_...`)** or mTLS + SPIFFE workload identities. 2. **📱 Humans on Mobile Devices (Primary UI Consumer):** - Ultra-fast, zero-friction **Server-Side Rendered (SSR) JSX** touch cards. - Passkey (WebAuthn) biometric authentication. @@ -21,26 +26,26 @@ Every modern application should be built for **two primary audiences**: ## 2. Architectural Blueprint for Subsidiary Apps (e.g. `ed-droid`) ``` - +---------------------------------------+ - | Traefik Edge Ingress | - +---------------------------------------+ - | - ForwardAuth Check | (Injects X-Forwarded-*) - +------------------------+------------------------+ - | | - v v - +-----------------------+ +-----------------------+ - | Auth-Yes Service | | Subsidiary App | - | (Validates Session / | | (e.g., ed-droid) | - | Bearer Token) | +-----------------------+ - +-----------------------+ | - | - +----------------------------+----------------------------+ - | | - v v - [ /api/* JSON Endpoints ] [ SSR HTML Mobile Cards ] - - Served for AI Agents & MCP - Served for Human Browsers - - Filtered by Scopes / Roles - Clean, high-contrast UI + +---------------------------------------+ + | Traefik Edge Ingress | + +---------------------------------------+ + | + ForwardAuth Check | (Injects X-Forwarded-*) + +------------------------+------------------------+ + | | + v v ++-----------------------+ +-----------------------+ +| Auth-Yes Service | | Subsidiary App | +| (Validates Session / | | (e.g., ed-droid) | +| Bearer Token) | +-----------------------+ ++-----------------------+ | + | + +----------------------------+----------------------------+ + | | + v v + [ /api/* JSON Endpoints ] [ SSR HTML Mobile Cards ] + - Served for AI Agents & MCP - Served for Human Browsers + - Filtered by Scopes / Roles - Clean, high-contrast UI ``` --- @@ -48,22 +53,30 @@ Every modern application should be built for **two primary audiences**: ## 3. How to Build an "Agent-First" Subsidiary App ### Step 1: Ingress Protection via ForwardAuth -In your Traefik/Compose configuration, protect your service domain with Auth-Yes ForwardAuth middleware: + +In your Traefik/Compose configuration, protect your service domain with Auth-Yes +ForwardAuth middleware: + ```yaml labels: - "traefik.http.routers.ed-droid.middlewares=auth-yes-forwardauth@docker" ``` + When requests arrive: -* Traefik queries `http://auth-api:8000/api/forward-auth`. -* Auth-Yes validates either the browser's `session_id` cookie **or** the incoming `Authorization: Bearer ay_sess_...` token. -* If authorized, Traefik injects: + +- Traefik queries `http://auth-api:8000/api/forward-auth`. +- Auth-Yes validates either the browser's `session_id` cookie **or** the + incoming `Authorization: Bearer ay_sess_...` token. +- If authorized, Traefik injects: - `X-Forwarded-User: ` - `X-Forwarded-User-Id: ` - `X-Forwarded-Scopes: ` - `X-Forwarded-App-Id: ` ### Step 2: Implement Clean JSON API Routes + Provide standard JSON endpoints for all core operations: + ```typescript import { Hono } from "jsr:@hono/hono"; @@ -78,16 +91,22 @@ app.get("/api/fleet", (c) => { ``` ### Step 3: Progressive Content Negotiation (Optional) -If a route serves both humans and AI agents without separate `/api` prefixes, inspect the `Accept` header or `?format=json` query: + +If a route serves both humans and AI agents without separate `/api` prefixes, +inspect the `Accept` header or `?format=json` query: + ```typescript app.get("/ships/:id", (c) => { const ship = getShip(c.req.param("id")); - + // If requested by an agent or CLI: - if (c.req.header("Accept")?.includes("application/json") || c.req.query("format") === "json") { + if ( + c.req.header("Accept")?.includes("application/json") || + c.req.query("format") === "json" + ) { return c.json(ship); } - + // If requested by a human browser: return c.html(ShipCardView({ ship })); }); @@ -97,11 +116,14 @@ app.get("/ships/:id", (c) => { ## 4. Connecting AI Agents via Model Context Protocol (MCP) -To expose your subsidiary applications to AI assistants (Antigravity, Jules, Claude Desktop): +To expose your subsidiary applications to AI assistants (Antigravity, Jules, +Claude Desktop): 1. **Mint a Delegated Session in Auth-Yes:** - - Go to `https://auth.atyg.org/dashboard/sessions` $\rightarrow$ Click **`+ Delegate Agent Session`**. - - Set Label: `"Antigravity Assistant"`, Lifespan: `12 Hours`, Scope: `ed-droid`. + - Go to `https://auth.atyg.org/dashboard/sessions` $\rightarrow$ Click + **`+ Delegate Agent Session`**. + - Set Label: `"Antigravity Assistant"`, Lifespan: `12 Hours`, Scope: + `ed-droid`. - Copy the CLI export string. 2. **Configure the MCP Server:** @@ -110,7 +132,11 @@ To expose your subsidiary applications to AI assistants (Antigravity, Jules, Cla "mcpServers": { "ed-droid": { "command": "deno", - "args": ["run", "-A", "https://git.atyg.org/tylerg/ed-droid/raw/branch/main/mcp/server.ts"], + "args": [ + "run", + "-A", + "https://git.atyg.org/tylerg/ed-droid/raw/branch/main/mcp/server.ts" + ], "env": { "AUTH_YES_TOKEN": "ay_sess_8de186f564d7..." } @@ -119,26 +145,33 @@ To expose your subsidiary applications to AI assistants (Antigravity, Jules, Cla } ``` -3. **Tool Call Execution:** - The MCP server attaches `Authorization: Bearer $AUTH_YES_TOKEN` to all internal fetch calls, gaining instant authorized access to fleet telemetry and data with full Merkle audit attribution! +3. **Tool Call Execution:** The MCP server attaches + `Authorization: Bearer $AUTH_YES_TOKEN` to all internal fetch calls, gaining + instant authorized access to fleet telemetry and data with full Merkle audit + attribution! --- ## 5. Ephemeral Guest & Support Passes (Magic 1-Click Links) -In addition to Agent Bearer Tokens, Auth-Yes supports **Ephemeral Magic Passes** for friends and external support technicians: +In addition to Agent Bearer Tokens, Auth-Yes supports **Ephemeral Magic Passes** +for friends and external support technicians: ### The Flow: + 1. **Spawn Pass:** Under Sessions, click **`+ Spawn Guest / Support Pass`**: - - **Label:** *"Friend Demo - Elite Dangerous Fleet"* - - **Lifespan:** *2 Hours* (auto-expires) + - **Label:** _"Friend Demo - Elite Dangerous Fleet"_ + - **Lifespan:** _2 Hours_ (auto-expires) - **Target App:** `ed-droid.atyg.org` 2. **Share 1-Click Link:** `https://auth.atyg.org/pass?token=ay_pass_9f8a7b6c...` 3. **Instant Redemption:** - - When opened in any browser, Auth-Yes automatically sets the `.atyg.org` session cookie with restricted scopes. + - When opened in any browser, Auth-Yes automatically sets the `.atyg.org` + session cookie with restricted scopes. - The browser is immediately redirected to `https://ed-droid.atyg.org`. - - **Zero friction:** No passkeys to register, no passwords, no email confirmation. + - **Zero friction:** No passkeys to register, no passwords, no email + confirmation. 4. **Security & Control:** - The guest only has access to the specified target app. - - The pass is visible on your Sessions dashboard in real time with an instant **`[Revoke]`** button. + - The pass is visible on your Sessions dashboard in real time with an instant + **`[Revoke]`** button. diff --git a/docs/superpowers/plans/2026-08-25-ephemeral-passes-and-multi-claim-events.md b/docs/superpowers/plans/2026-08-25-ephemeral-passes-and-multi-claim-events.md index b54f59f..85eed37 100644 --- a/docs/superpowers/plans/2026-08-25-ephemeral-passes-and-multi-claim-events.md +++ b/docs/superpowers/plans/2026-08-25-ephemeral-passes-and-multi-claim-events.md @@ -1,86 +1,117 @@ # Ephemeral Magic Passes & Multi-Claim Event Passes Implementation Plan -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. +> **For agentic workers:** REQUIRED SUB-SKILL: Use +> superpowers:subagent-driven-development (recommended) or +> superpowers:executing-plans to implement this plan task-by-task. Steps use +> checkbox (`- [ ]`) syntax for tracking. -**Goal:** Implement 1-on-1 Ephemeral Magic Links, Multi-Claim Event/Workshop Passes (with short codes, PINs, short URLs, and CLI 1-liners), and a real-time Event Cockpit with a master kill switch. +**Goal:** Implement 1-on-1 Ephemeral Magic Links, Multi-Claim Event/Workshop +Passes (with short codes, PINs, short URLs, and CLI 1-liners), and a real-time +Event Cockpit with a master kill switch. -**Architecture:** Extend the Auth-Yes zero-trust session engine with a dedicated event registry in PostgreSQL and Valkey. Provide a unified 1-click redemption endpoint (`GET /pass`), a short-code/PIN join portal (`GET /join`, `GET /e/:slug`), CLI environment streams (`GET /join/:slug?format=env`), and live seat metrics with sub-millisecond batch revocation via Valkey RESP3 push tracking. +**Architecture:** Extend the Auth-Yes zero-trust session engine with a dedicated +event registry in PostgreSQL and Valkey. Provide a unified 1-click redemption +endpoint (`GET /pass`), a short-code/PIN join portal (`GET /join`, +`GET /e/:slug`), CLI environment streams (`GET /join/:slug?format=env`), and +live seat metrics with sub-millisecond batch revocation via Valkey RESP3 push +tracking. -**Tech Stack:** Deno 2.x, TypeScript 5.x, Hono SSR JSX (React-free), PostgreSQL 18, Valkey 8, Traefik ForwardAuth. +**Tech Stack:** Deno 2.x, TypeScript 5.x, Hono SSR JSX (React-free), PostgreSQL +18, Valkey 8, Traefik ForwardAuth. -**Spec:** [DUAL_AUDIENCE_DEVELOPMENT_GUIDE.md](../../DUAL_AUDIENCE_DEVELOPMENT_GUIDE.md) +**Spec:** +[DUAL_AUDIENCE_DEVELOPMENT_GUIDE.md](../../DUAL_AUDIENCE_DEVELOPMENT_GUIDE.md) ## Global Constraints + - Strictly zero React dependencies; use pure Hono SSR JSX. - Zero-dependency SDK in `sdk/`. -- All cookie mutations must delete host-only cookies and set wildcard `.atyg.org` domain cookies. -- Every commit and change must be pushed to both GitHub (`origin`) and Gitea (`gitea`). -- Quality gates must pass: `deno fmt`, `deno task lint`, `deno task check`, `deno task test`. +- All cookie mutations must delete host-only cookies and set wildcard + `.atyg.org` domain cookies. +- Every commit and change must be pushed to both GitHub (`origin`) and Gitea + (`gitea`). +- Quality gates must pass: `deno fmt`, `deno task lint`, `deno task check`, + `deno task test`. --- ### Task 1: 1-on-1 Ephemeral Magic Link Redemption (`/pass`) **Files:** + - Modify: `server/main.ts` - Modify: `ui/components/SessionsPage.tsx` - Test: `server/main.test.ts` **Interfaces:** + - Consumes: `extractAllSessionIds(c)`, `valkey.get()`, `sqlWrapper.sql` -- Produces: `GET /pass?token=...` endpoint and updated Hand-Off Modal in `SessionsPage.tsx` +- Produces: `GET /pass?token=...` endpoint and updated Hand-Off Modal in + `SessionsPage.tsx` - [ ] **Step 1: Write the failing test for `GET /pass`** ```typescript // in server/main.test.ts Deno.test("GET /pass - Ephemeral Magic Link 1-Click Redemption", async (t) => { - await t.step("Valid token sets wildcard cookie and redirects to target app", async () => { - const valkeyStub = stub(valkey, "get", (key: any) => { - if (String(key) === "ay_sess_valid_pass") { - return Promise.resolve( - JSON.stringify({ uuid: "guest-uuid", username: "guest_user", customScopes: ["app:ed-droid"] }), - ); - } - return Promise.resolve(null); - }); - - const originalSql = sqlWrapper.sql; - sqlWrapper.sql = ((_query: any) => { - return Promise.resolve([{ domain: "ed-droid.atyg.org" }]); - }) as any; - - try { - const res = await app.request("/pass?token=ay_sess_valid_pass", { - method: "GET", + await t.step( + "Valid token sets wildcard cookie and redirects to target app", + async () => { + const valkeyStub = stub(valkey, "get", (key: any) => { + if (String(key) === "ay_sess_valid_pass") { + return Promise.resolve( + JSON.stringify({ + uuid: "guest-uuid", + username: "guest_user", + customScopes: ["app:ed-droid"], + }), + ); + } + return Promise.resolve(null); }); - assertEquals(res.status, 302); - const setCookie = res.headers.get("set-cookie") || ""; - assert(setCookie.includes("session_id=ay_sess_valid_pass")); - assert(res.headers.get("location")?.includes("ed-droid.atyg.org") || res.headers.get("location") === "/dashboard"); - } finally { - sqlWrapper.sql = originalSql; - valkeyStub.restore(); - } - }); + + const originalSql = sqlWrapper.sql; + sqlWrapper.sql = ((_query: any) => { + return Promise.resolve([{ domain: "ed-droid.atyg.org" }]); + }) as any; + + try { + const res = await app.request("/pass?token=ay_sess_valid_pass", { + method: "GET", + }); + assertEquals(res.status, 302); + const setCookie = res.headers.get("set-cookie") || ""; + assert(setCookie.includes("session_id=ay_sess_valid_pass")); + assert( + res.headers.get("location")?.includes("ed-droid.atyg.org") || + res.headers.get("location") === "/dashboard", + ); + } finally { + sqlWrapper.sql = originalSql; + valkeyStub.restore(); + } + }, + ); }); ``` -- [ ] **Step 2: Run test to verify it fails** -Run: `deno test server/main.test.ts --filter "Ephemeral Magic Link"` -Expected: FAIL (404 Not Found on `/pass`) +- [ ] **Step 2: Run test to verify it fails** Run: + `deno test server/main.test.ts --filter "Ephemeral Magic Link"` Expected: + FAIL (404 Not Found on `/pass`) -- [ ] **Step 3: Implement `GET /pass` in `server/main.ts`** -Extract token from `?token=...`, validate against Valkey/DB, set cookie with `getCookieDomain()`, resolve target app domain if scoped to a specific app, and return `c.redirect(targetUrl)`. +- [ ] **Step 3: Implement `GET /pass` in `server/main.ts`** Extract token from + `?token=...`, validate against Valkey/DB, set cookie with + `getCookieDomain()`, resolve target app domain if scoped to a specific + app, and return `c.redirect(targetUrl)`. -- [ ] **Step 4: Update `ui/components/SessionsPage.tsx`** -Add the **"1-Click Magic Link"** copy card tab alongside the CLI and cURL header tabs. +- [ ] **Step 4: Update `ui/components/SessionsPage.tsx`** Add the **"1-Click + Magic Link"** copy card tab alongside the CLI and cURL header tabs. -- [ ] **Step 5: Run tests to verify they pass** -Run: `deno task test` -Expected: PASS (All tests passing) +- [ ] **Step 5: Run tests to verify they pass** Run: `deno task test` Expected: + PASS (All tests passing) - [ ] **Step 6: Commit and Push** + ```bash git add server/main.ts server/main.test.ts ui/components/SessionsPage.tsx git commit -m "feat(pass): implement 1-click ephemeral magic link redemption route" @@ -92,6 +123,7 @@ git push origin main && git push gitea main ### Task 2: Multi-Claim Event Passes Schema & Join Endpoints (`/join`, `/e/:slug`, CLI 1-Liner) **Files:** + - Modify: `server/db.ts` - Modify: `server/main.ts` - Create: `ui/components/EventJoinPage.tsx` @@ -99,13 +131,16 @@ git push origin main && git push gitea main - Test: `server/main.test.ts` **Interfaces:** + - Produces: - Table: `event_passes` in PostgreSQL - Endpoints: - - `POST /api/events`: Create event pass (slug, pin_code, name, max_seats, lifespan_hours, app_id, role) + - `POST /api/events`: Create event pass (slug, pin_code, name, max_seats, + lifespan_hours, app_id, role) - `GET /e/:slug`: Web landing splash with "Enter Workshop" action - `GET /join`: Universal PIN / word-code entry page - - `POST /api/join`: Redeems code/PIN and creates isolated `guest__` session + - `POST /api/join`: Redeems code/PIN and creates isolated + `guest__` session - `GET /join/:slug`: CLI 1-liner (`?format=env` or `?format=json`) - [ ] **Step 1: Write the failing tests for Event creation and redemption** @@ -124,23 +159,28 @@ Deno.test("Multi-Claim Event Passes & Join Endpoints", async (t) => { }); ``` -- [ ] **Step 2: Run test to verify it fails** -Run: `deno test server/main.test.ts --filter "Multi-Claim Event"` -Expected: FAIL +- [ ] **Step 2: Run test to verify it fails** Run: + `deno test server/main.test.ts --filter "Multi-Claim Event"` Expected: + FAIL -- [ ] **Step 3: Add `event_passes` schema in `server/db.ts`** -Include columns: `id`, `slug`, `pin_code`, `name`, `app_id`, `role`, `max_seats`, `seats_claimed`, `lifespan_hours`, `created_by`, `is_active`, `expires_at`, `created_at`. +- [ ] **Step 3: Add `event_passes` schema in `server/db.ts`** Include columns: + `id`, `slug`, `pin_code`, `name`, `app_id`, `role`, `max_seats`, + `seats_claimed`, `lifespan_hours`, `created_by`, `is_active`, + `expires_at`, `created_at`. -- [ ] **Step 4: Implement Event API routes in `server/main.ts` and SSR UI Pages** +- [ ] **Step 4: Implement Event API routes in `server/main.ts` and SSR UI + Pages** - Create `ui/components/EventJoinPage.tsx` for `/join` PIN code entry. -- Create `ui/components/EventSplashPage.tsx` for `/e/:slug` 1-click workshop entrance. -- Implement `GET /join/:slug` returning `export AUTH_YES_TOKEN="..."` when `?format=env`. +- Create `ui/components/EventSplashPage.tsx` for `/e/:slug` 1-click workshop + entrance. +- Implement `GET /join/:slug` returning `export AUTH_YES_TOKEN="..."` when + `?format=env`. -- [ ] **Step 5: Run tests to verify they pass** -Run: `deno task test` -Expected: PASS +- [ ] **Step 5: Run tests to verify they pass** Run: `deno task test` Expected: + PASS - [ ] **Step 6: Commit and Push** + ```bash git add server/db.ts server/main.ts ui/components/EventJoinPage.tsx ui/components/EventSplashPage.tsx server/main.test.ts git commit -m "feat(events): implement multi-claim event passes, PIN join portal, and CLI 1-liner" @@ -152,41 +192,53 @@ git push origin main && git push gitea main ### Task 3: Live Event Cockpit & Master Kill-Switch **Files:** + - Modify: `server/main.ts` - Modify: `ui/components/SessionsPage.tsx` - Modify: `ui/mod.ts` - Test: `server/main.test.ts` **Interfaces:** + - Produces: - - `POST /api/events/:id/end`: Closes event and immediately revokes all guest sessions + - `POST /api/events/:id/end`: Closes event and immediately revokes all guest + sessions - `POST /api/events/:id/extend`: Adds hours to active event - - Event Management Deck in `SessionsPage.tsx` with live seat counter (`38 / 50`) and master kill switch + - Event Management Deck in `SessionsPage.tsx` with live seat counter + (`38 / 50`) and master kill switch - [ ] **Step 1: Write failing tests for Event Kill-Switch & Extension** + ```typescript Deno.test("Event Cockpit & Master Kill Switch", async (t) => { - await t.step("POST /api/events/:id/end revokes all guest seats instantly", async () => { - // verify sessions deleted and Valkey cleared - }); + await t.step( + "POST /api/events/:id/end revokes all guest seats instantly", + async () => { + // verify sessions deleted and Valkey cleared + }, + ); }); ``` -- [ ] **Step 2: Run test to verify it fails** -Run: `deno test server/main.test.ts --filter "Event Cockpit"` -Expected: FAIL +- [ ] **Step 2: Run test to verify it fails** Run: + `deno test server/main.test.ts --filter "Event Cockpit"` Expected: FAIL -- [ ] **Step 3: Implement `POST /api/events/:id/end` and `POST /api/events/:id/extend` in `server/main.ts`** -Fetch all session IDs created under the event pass, delete them from Valkey and PostgreSQL, record in audit ledger, and mark event `is_active = false`. +- [ ] **Step 3: Implement `POST /api/events/:id/end` and + `POST /api/events/:id/extend` in `server/main.ts`** Fetch all session IDs + created under the event pass, delete them from Valkey and PostgreSQL, + record in audit ledger, and mark event `is_active = false`. -- [ ] **Step 4: Update `ui/components/SessionsPage.tsx` with Event Cockpit Deck** -Display active events with live progress bar (`Seats Claimed / Capacity`), PIN badge, copy links, `[+1h Extend]` and `[🔴 End Workshop & Revoke All]`. +- [ ] **Step 4: Update `ui/components/SessionsPage.tsx` with Event Cockpit + Deck** Display active events with live progress bar + (`Seats Claimed / Capacity`), PIN badge, copy links, `[+1h Extend]` and + `[🔴 End Workshop & Revoke All]`. -- [ ] **Step 5: Run full quality gates** -Run: `deno fmt && deno task lint && deno task check && deno task test` -Expected: All pass. +- [ ] **Step 5: Run full quality gates** Run: + `deno fmt && deno task lint && deno task check && deno task test` + Expected: All pass. - [ ] **Step 6: Commit and Push** + ```bash git add server/main.ts ui/components/SessionsPage.tsx ui/mod.ts server/main.test.ts git commit -m "feat(cockpit): add live event metrics, seat roster, and master kill-switch" diff --git a/server/db.ts b/server/db.ts index b96c3d3..db79d54 100644 --- a/server/db.ts +++ b/server/db.ts @@ -206,6 +206,24 @@ export async function initDb(): Promise { // Ignore migration column exists } + await sql` + CREATE TABLE IF NOT EXISTS event_passes ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + slug TEXT UNIQUE NOT NULL, + pin_code TEXT UNIQUE, + name TEXT NOT NULL, + app_id UUID REFERENCES apps(id) ON DELETE SET NULL, + role TEXT DEFAULT 'viewer', + max_seats INT DEFAULT 50, + seats_claimed INT DEFAULT 0, + lifespan_hours INT DEFAULT 3, + created_by UUID REFERENCES users(id) ON DELETE SET NULL, + is_active BOOLEAN DEFAULT TRUE, + expires_at TIMESTAMP WITH TIME ZONE, + created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW() + ); + `; + await sql` CREATE TABLE IF NOT EXISTS audit_sths ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), diff --git a/server/main.test.ts b/server/main.test.ts index a4545d4..e49e54e 100644 --- a/server/main.test.ts +++ b/server/main.test.ts @@ -836,3 +836,260 @@ Deno.test("Agent Session Delegation & Scoped Permissions", async (t) => { }, ); }); + +Deno.test("Ephemeral 1-Click Magic Link Redemption (/pass)", async (t) => { + await t.step("GET /pass with missing token redirects to login", async () => { + const res = await app.request("/pass", { method: "GET" }); + assertEquals(res.status, 302); + assertEquals( + res.headers.get("location"), + "/login?error=invalid_or_expired_pass", + ); + }); + + await t.step("GET /pass with invalid token redirects to login", async () => { + const valkeyStub = stub(valkey, "get", () => Promise.resolve(null)); + const originalSql = sqlWrapper.sql; + sqlWrapper.sql = (() => Promise.resolve([])) as any; + try { + const res = await app.request("/pass?token=invalid", { method: "GET" }); + assertEquals(res.status, 302); + assertEquals( + res.headers.get("location"), + "/login?error=invalid_or_expired_pass", + ); + } finally { + sqlWrapper.sql = originalSql; + valkeyStub.restore(); + } + }); + + await t.step( + "GET /pass with valid token sets cookies and redirects to app domain", + async () => { + const sessionToken = "ay_sess_valid_app"; + const valkeyGetStub = stub(valkey, "get", (key: any) => { + if (String(key) === sessionToken) { + return Promise.resolve(JSON.stringify({ + uuid: "user-uuid", + username: "testuser", + customScopes: ["app:ed-droid"], + })); + } + return Promise.resolve(null); + }); + const valkeyTtlStub = stub(valkey, "ttl", () => Promise.resolve(3600)); + + const originalSql = sqlWrapper.sql; + sqlWrapper.sql = ((strings: any, ..._values: any[]) => { + const query = Array.isArray(strings) + ? strings.join("?") + : String(strings); + if (query.includes("SELECT domain FROM apps WHERE name =")) { + return Promise.resolve([{ domain: "ed-droid.atyg.org" }]); + } + return Promise.resolve([]); + }) as any; + + try { + const res = await app.request(`/pass?token=${sessionToken}`, { + method: "GET", + }); + assertEquals(res.status, 302); + assertEquals(res.headers.get("location"), "https://ed-droid.atyg.org"); + + const cookies = res.headers.get("set-cookie"); + assertExists(cookies); + assert(cookies.includes(`session_id=${sessionToken};`)); + } finally { + sqlWrapper.sql = originalSql; + valkeyGetStub.restore(); + valkeyTtlStub.restore(); + } + }, + ); + + await t.step( + "GET /pass with valid token defaults to /dashboard if no app scope", + async () => { + const sessionToken = "ay_sess_valid_dashboard"; + const valkeyGetStub = stub(valkey, "get", (key: any) => { + if (String(key) === sessionToken) { + return Promise.resolve(JSON.stringify({ + uuid: "user-uuid", + username: "testuser", + customScopes: ["read:audit"], + })); + } + return Promise.resolve(null); + }); + const valkeyTtlStub = stub(valkey, "ttl", () => Promise.resolve(3600)); + + try { + const res = await app.request(`/pass?token=${sessionToken}`, { + method: "GET", + }); + assertEquals(res.status, 302); + assertEquals(res.headers.get("location"), "/dashboard"); + } finally { + valkeyGetStub.restore(); + valkeyTtlStub.restore(); + } + }, + ); +}); + +Deno.test("Multi-Claim Event Passes & Join Endpoints", async (t) => { + await t.step("POST /api/events creates an event pass", async () => { + const valkeyStub = 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 = ((strings: any, ..._values: any[]) => { + const query = Array.isArray(strings) + ? strings.join("?") + : String(strings); + if (query.includes("INSERT INTO event_passes")) { + return Promise.resolve([{ + id: "event-uuid-1", + slug: "deno-lab", + pin_code: "749-123", + name: "Deno Workshop", + max_seats: 50, + seats_claimed: 0, + lifespan_hours: 3, + }]); + } + return Promise.resolve([]); + }) as any; + + try { + const res = await app.request("/api/events", { + method: "POST", + headers: { + Authorization: "Bearer admin-session", + "Content-Type": "application/json", + }, + body: JSON.stringify({ + name: "Deno Workshop", + slug: "deno-lab", + pinCode: "749-123", + maxSeats: 50, + lifespanHours: 3, + }), + }); + + assertEquals(res.status, 200); + const json = await res.json(); + assert(json.success === true); + assertEquals(json.event.slug, "deno-lab"); + assertEquals(json.event.pin_code, "749-123"); + } finally { + sqlWrapper.sql = originalSql; + valkeyStub.restore(); + } + }); + + await t.step( + "POST /api/join redeems PIN / slug and mints guest session", + async () => { + const originalSql = sqlWrapper.sql; + sqlWrapper.sql = ((strings: any, ..._values: any[]) => { + const query = Array.isArray(strings) + ? strings.join("?") + : String(strings); + if (query.includes("UPDATE event_passes")) { + return Promise.resolve([{ + id: "event-uuid-1", + slug: "deno-lab", + pin_code: "749-123", + name: "Deno Workshop", + max_seats: 50, + seats_claimed: 1, + lifespan_hours: 3, + app_id: null, + }]); + } + return Promise.resolve([]); + }) as any; + + const valkeySetexStub = stub( + valkey, + "setex", + () => Promise.resolve("OK" as any), + ); + + try { + const res = await app.request("/api/join", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ code: "749-123" }), + }); + + assertEquals(res.status, 200); + const json = await res.json(); + assert(json.success === true); + assert(json.token.startsWith("ay_sess_")); + assertEquals(json.username, "guest_deno-lab_1"); + + const cookies = res.headers.get("set-cookie"); + assertExists(cookies); + assert(cookies.includes(`session_id=${json.token};`)); + } finally { + sqlWrapper.sql = originalSql; + valkeySetexStub.restore(); + } + }, + ); + + await t.step( + "GET /join/:slug?format=env returns CLI export string", + async () => { + const originalSql = sqlWrapper.sql; + sqlWrapper.sql = ((strings: any, ..._values: any[]) => { + const query = Array.isArray(strings) + ? strings.join("?") + : String(strings); + if (query.includes("UPDATE event_passes")) { + return Promise.resolve([{ + id: "event-uuid-1", + slug: "deno-lab", + pin_code: "749-123", + name: "Deno Workshop", + max_seats: 50, + seats_claimed: 2, + lifespan_hours: 3, + app_id: null, + }]); + } + return Promise.resolve([]); + }) as any; + + const valkeySetexStub = stub( + valkey, + "setex", + () => Promise.resolve("OK" as any), + ); + + try { + const res = await app.request("/join/deno-lab?format=env", { + method: "GET", + }); + + assertEquals(res.status, 200); + const text = await res.text(); + assert(text.includes('export AUTH_YES_TOKEN="ay_sess_')); + assert(text.includes('export AUTH_YES_USER="guest_deno-lab_2"')); + } finally { + sqlWrapper.sql = originalSql; + valkeySetexStub.restore(); + } + }, + ); +}); diff --git a/server/main.ts b/server/main.ts index 2b7d67b..3a6d0b3 100644 --- a/server/main.ts +++ b/server/main.ts @@ -12,11 +12,16 @@ import type { RegistrationResponseJSON, } from "jsr:@simplewebauthn/server@13"; import { deleteCookie, getCookie, setCookie } from "jsr:@hono/hono@4/cookie"; -import { extractAllSessionIds } from "./auth-session.ts"; +import { + extractAllSessionIds, + getAuthenticatedUser, + isGlobalAdmin, +} from "./auth-session.ts"; import { decodeBase64Url, encodeBase64Url, } from "jsr:@std/encoding@1/base64url"; +import { encodeHex } from "jsr:@std/encoding@1/hex"; import { MetadataService } from "jsr:@simplewebauthn/server@13"; import { initDb, sqlWrapper } from "./db.ts"; import { pingValkey, valkey } from "./valkey.ts"; @@ -30,6 +35,8 @@ import { } from "npm:@connectrpc/connect@^1.4.0/protocol"; import type { ConnectRouter } from "npm:@connectrpc/connect@^1.4.0"; import { uiApp } from "../ui/mod.ts"; +import { EventJoinPage } from "../ui/components/EventJoinPage.tsx"; +import { EventSplashPage } from "../ui/components/EventSplashPage.tsx"; type Variables = { userId: string; @@ -177,6 +184,399 @@ app.use("/api/admin/*", async (c, next) => { await next(); }); +// --------------------------------------------------------- +// Ephemeral 1-Click Magic Link Redemption (/pass) +// --------------------------------------------------------- + +app.get("/pass", async (c) => { + const token = c.req.query("token"); + if (!token) { + return c.redirect("/login?error=invalid_or_expired_pass", 302); + } + + // 1. Validate against Valkey, fallback to PostgreSQL + let sessionDataStr = null; + try { + sessionDataStr = await valkey.get(token); + } catch (_err) {} + + let sessionInfo: any = null; + if (sessionDataStr) { + try { + sessionInfo = JSON.parse(sessionDataStr); + } catch (_err) {} + } + + let expiresAtDate: Date | null = null; + let customScopes: string[] = []; + + if (!sessionInfo || !sessionInfo.uuid) { + try { + const nowIso = new Date().toISOString(); + const session = await sqlWrapper.sql` + SELECT s.user_id, s.expires_at, s.label, s.is_agent, s.custom_scopes, u.username + FROM sessions s + JOIN users u ON s.user_id = u.id + WHERE s.id = ${token} AND s.expires_at > ${nowIso} + `.then((res: any) => res[0]); + + if (!session) { + return c.redirect("/login?error=invalid_or_expired_pass", 302); + } + + sessionInfo = { + uuid: session.user_id, + username: session.username, + label: session.label, + isAgent: session.is_agent, + customScopes: session.custom_scopes, + }; + expiresAtDate = new Date(session.expires_at); + customScopes = session.custom_scopes || []; + + try { + const ttlSeconds = Math.max( + 1, + Math.floor((expiresAtDate.getTime() - Date.now()) / 1000), + ); + await valkey.setex(token, ttlSeconds, JSON.stringify(sessionInfo)); + } catch (_e) {} + } catch (_err) { + return c.redirect("/login?error=invalid_or_expired_pass", 302); + } + } else { + try { + const ttl = await valkey.ttl(token); + if (ttl <= 0) { + return c.redirect("/login?error=invalid_or_expired_pass", 302); + } + expiresAtDate = new Date(Date.now() + ttl * 1000); + customScopes = sessionInfo.customScopes || sessionInfo.custom_scopes || + []; + } catch (_err) { + return c.redirect("/login?error=invalid_or_expired_pass", 302); + } + } + + if (!sessionInfo || !expiresAtDate) { + return c.redirect("/login?error=invalid_or_expired_pass", 302); + } + + // 2. Cookie Scoping + deleteCookie(c, "session_id", { path: "/" }); + + const cookieDomain = getCookieDomain(rpID); + const ttlSeconds = Math.max( + 1, + Math.floor((expiresAtDate.getTime() - Date.now()) / 1000), + ); + setCookie(c, "session_id", token, { + path: "/", + domain: cookieDomain, + httpOnly: true, + secure: true, + sameSite: "Lax", + maxAge: ttlSeconds, + }); + + // 3. Redirect URL Resolution + let targetDomain = null; + if (Array.isArray(customScopes)) { + const appScope = customScopes.find((s: string) => + typeof s === "string" && s.startsWith("app:") + ); + if (appScope) { + const appName = appScope.substring(4); + try { + const appRecord = await sqlWrapper.sql` + SELECT domain FROM apps WHERE name = ${appName} + `.then((res: any) => res[0]); + if (appRecord && appRecord.domain) { + targetDomain = appRecord.domain; + } + } catch (_err) {} + } + } + + if (targetDomain) { + return c.redirect(`https://${targetDomain}`, 302); + } else { + return c.redirect("/dashboard", 302); + } +}); + +// --------------------------------------------------------- +// Multi-Claim Event Passes & Short Code Join Portal +// --------------------------------------------------------- + +app.post("/api/events", async (c) => { + const user = await getAuthenticatedUser(c); + if (!user) return c.json({ error: "Unauthorized" }, 401); + + const body = await c.req.json().catch(() => ({})); + const { name, appId, role = "viewer", maxSeats = 50, lifespanHours = 3 } = + body; + let { slug, pinCode } = body; + + if (!name || typeof name !== "string") { + return c.json({ error: "Event name is required" }, 400); + } + + if (!slug) { + slug = + name.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "") + + "-" + Math.random().toString(36).substring(2, 6); + } + if (!pinCode) { + const randPin = Math.floor(100000 + Math.random() * 900000).toString(); + pinCode = randPin.substring(0, 3) + "-" + randPin.substring(3); + } + + try { + const expiresAt = new Date( + Date.now() + Number(lifespanHours) * 3600 * 1000, + ); + const result = await sqlWrapper.sql` + INSERT INTO event_passes (slug, pin_code, name, app_id, role, max_seats, lifespan_hours, created_by, expires_at) + VALUES (${slug}, ${pinCode}, ${name}, ${appId || null}, ${role}, ${ + Number(maxSeats) + }, ${Number(lifespanHours)}, ${user.userId}, ${expiresAt}) + RETURNING * + `; + return c.json({ success: true, event: result[0] }); + } catch (e: any) { + console.error("[Events] Failed to create event pass:", e); + return c.json({ error: "Failed to create event pass" }, 500); + } +}); + +app.post("/api/join", async (c) => { + let code = ""; + if ( + c.req.header("content-type")?.includes("application/x-www-form-urlencoded") + ) { + const fd = await c.req.formData(); + code = (fd.get("code") as string) || ""; + } else { + const body = await c.req.json().catch(() => ({})); + code = body.code || ""; + } + + if (!code || typeof code !== "string") { + return c.json({ error: "Event code or PIN is required" }, 400); + } + code = code.trim(); + + try { + const result = await sqlWrapper.sql` + UPDATE event_passes + SET seats_claimed = seats_claimed + 1 + WHERE (slug = ${code} OR pin_code = ${code}) + AND is_active = TRUE + AND (expires_at IS NULL OR expires_at > NOW()) + AND (max_seats = 0 OR seats_claimed < max_seats) + RETURNING * + `; + + if (!result || result.length === 0) { + return c.json({ + error: "Invalid event code or workshop capacity reached", + }, 404); + } + + const event = result[0]; + const guestUuid = crypto.randomUUID(); + const username = `guest_${event.slug}_${event.seats_claimed}`; + + await sqlWrapper.sql` + INSERT INTO users (id, username, display_name, account_status) + VALUES (${guestUuid}, ${username}, ${event.name + " Attendee"}, 'guest') + ON CONFLICT DO NOTHING + `; + + const randomBytes = crypto.getRandomValues(new Uint8Array(32)); + const sessionId = `ay_sess_${encodeHex(randomBytes)}`; + const label = `${event.name} Seat #${event.seats_claimed}`; + const ttl = (Number(event.lifespan_hours) || 3) * 3600; + + let customScopes = ["guest", "trial"]; + let appDomain = ""; + + if (event.app_id) { + const apps = await sqlWrapper + .sql`SELECT name, domain FROM apps WHERE id = ${event.app_id}`; + if (apps.length > 0) { + customScopes = [`app:${apps[0].name}`, event.role || "viewer"]; + appDomain = apps[0].domain || ""; + } + } + + const expiresAt = new Date(Date.now() + ttl * 1000); + + await sqlWrapper.sql` + INSERT INTO sessions (id, user_id, label, is_agent, custom_scopes, expires_at) + VALUES (${sessionId}, ${guestUuid}, ${label}, false, ${customScopes}, ${expiresAt}) + `; + + await valkey.setex( + sessionId, + ttl, + JSON.stringify({ + uuid: guestUuid, + username, + account_status: "guest", + customScopes, + }), + ); + + deleteCookie(c, "session_id", { path: "/" }); + const cookieDomain = getCookieDomain(rpID); + + setCookie(c, "session_id", sessionId, { + domain: cookieDomain, + path: "/", + httpOnly: true, + secure: true, + sameSite: "Lax", + maxAge: ttl, + }); + + const redirectUrl = appDomain ? `https://${appDomain}` : "/dashboard"; + + if ( + c.req.header("accept")?.includes("text/html") && + !c.req.header("accept")?.includes("application/json") + ) { + return c.redirect(redirectUrl, 302); + } + + return c.json({ + success: true, + sessionId, + token: sessionId, + guestUuid, + username, + redirectUrl, + }); + } catch (e: any) { + console.error("[Events] Failed to join event:", e); + return c.json({ error: "Failed to join event" }, 500); + } +}); + +app.get("/join/:slug", async (c) => { + const slug = c.req.param("slug"); + const format = c.req.query("format") || "html"; + + try { + const result = await sqlWrapper.sql` + UPDATE event_passes + SET seats_claimed = seats_claimed + 1 + WHERE slug = ${slug} + AND is_active = TRUE + AND (expires_at IS NULL OR expires_at > NOW()) + AND (max_seats = 0 OR seats_claimed < max_seats) + RETURNING * + `; + + if (!result || result.length === 0) { + if (format === "env" || format === "json") { + return c.text("Invalid slug or workshop capacity reached", 404); + } + return c.redirect("/join?error=not_found", 302); + } + + const event = result[0]; + const guestUuid = crypto.randomUUID(); + const username = `guest_${event.slug}_${event.seats_claimed}`; + + await sqlWrapper.sql` + INSERT INTO users (id, username, display_name, account_status) + VALUES (${guestUuid}, ${username}, ${event.name + " Attendee"}, 'guest') + ON CONFLICT DO NOTHING + `; + + const randomBytes = crypto.getRandomValues(new Uint8Array(32)); + const sessionId = `ay_sess_${encodeHex(randomBytes)}`; + const label = `${event.name} Seat #${event.seats_claimed}`; + const ttl = (Number(event.lifespan_hours) || 3) * 3600; + + let customScopes = ["guest", "trial"]; + if (event.app_id) { + const apps = await sqlWrapper + .sql`SELECT name, domain FROM apps WHERE id = ${event.app_id}`; + if (apps.length > 0) { + customScopes = [`app:${apps[0].name}`, event.role || "viewer"]; + } + } + + const expiresAt = new Date(Date.now() + ttl * 1000); + + await sqlWrapper.sql` + INSERT INTO sessions (id, user_id, label, is_agent, custom_scopes, expires_at) + VALUES (${sessionId}, ${guestUuid}, ${label}, false, ${customScopes}, ${expiresAt}) + `; + + await valkey.setex( + sessionId, + ttl, + JSON.stringify({ + uuid: guestUuid, + username, + account_status: "guest", + customScopes, + }), + ); + + if (format === "env") { + return c.text( + `export AUTH_YES_TOKEN="${sessionId}"\nexport AUTH_YES_USER="${username}"\n`, + ); + } else if (format === "json") { + return c.json({ + success: true, + token: sessionId, + username, + expiresAt: expiresAt.toISOString(), + }); + } + + deleteCookie(c, "session_id", { path: "/" }); + setCookie(c, "session_id", sessionId, { + domain: getCookieDomain(rpID), + path: "/", + httpOnly: true, + secure: true, + sameSite: "Lax", + maxAge: ttl, + }); + + return c.redirect("/dashboard", 302); + } catch (e: any) { + console.error("[Events] Failed to execute CLI join:", e); + return c.text("Internal Server Error", 500); + } +}); + +app.get("/join", (c) => { + return c.html(EventJoinPage()); +}); + +app.get("/e/:slug", async (c) => { + const slug = c.req.param("slug"); + try { + const result = await sqlWrapper.sql` + SELECT * FROM event_passes WHERE slug = ${slug} AND is_active = TRUE + `; + if (!result || result.length === 0) { + return c.redirect("/join?error=event_not_found", 302); + } + return c.html(EventSplashPage({ event: result[0] })); + } catch (_e) { + return c.redirect("/join?error=db_error", 302); + } +}); + // --------------------------------------------------------- // Provisioning & Registration (Use Cases 1, 2, 3) // --------------------------------------------------------- @@ -1025,8 +1425,6 @@ app.all("/auth.v1.AuthService/*", async (c) => { // Session & Credential Management (Authenticated APIs) // --------------------------------------------------------- -import { getAuthenticatedUser, isGlobalAdmin } from "./auth-session.ts"; - // --------------------------------------------------------- // Global Admin APIs (For the Management Console) // --------------------------------------------------------- diff --git a/tasks/new/2026-0825.01.gem.feat.magic-passes.ephemeral-1-click-guest-links-0045.md b/tasks/complete/2026-0825.01.gem.feat.magic-passes.ephemeral-1-click-guest-links-0045.md similarity index 75% rename from tasks/new/2026-0825.01.gem.feat.magic-passes.ephemeral-1-click-guest-links-0045.md rename to tasks/complete/2026-0825.01.gem.feat.magic-passes.ephemeral-1-click-guest-links-0045.md index 3c4ee97..e705044 100644 --- a/tasks/new/2026-0825.01.gem.feat.magic-passes.ephemeral-1-click-guest-links-0045.md +++ b/tasks/complete/2026-0825.01.gem.feat.magic-passes.ephemeral-1-click-guest-links-0045.md @@ -4,19 +4,26 @@ - `server/main.ts` - `ui/components/SessionsPage.tsx` - `server/main.test.ts` -- **Core Objective:** Implement `GET /pass?token=...` for 1-click ephemeral session redemption and update the Sessions Hub hand-off UI with copyable magic links. +- **Core Objective:** Implement `GET /pass?token=...` for 1-click ephemeral + session redemption and update the Sessions Hub hand-off UI with copyable magic + links. - **Dependencies:** `server/auth-session.ts`, `server/db.ts` -- **Additional Important Notes:** Sets wildcard `.atyg.org` cookie, cleans host-only cookie, and redirects cleanly to target app domain or dashboard. +- **Additional Important Notes:** Sets wildcard `.atyg.org` cookie, cleans + host-only cookie, and redirects cleanly to target app domain or dashboard. --- ### 1. Architectural Considerations & Risks - **Security & Cookie Scoping:** - - `/pass` must validate that the token is active and not expired before issuing Set-Cookie headers. - - Must use `getCookieDomain()` so subdomains (e.g. `ed-droid.atyg.org`) receive the session cookie immediately. + - `/pass` must validate that the token is active and not expired before + issuing Set-Cookie headers. + - Must use `getCookieDomain()` so subdomains (e.g. `ed-droid.atyg.org`) + receive the session cookie immediately. - **Target URL Redirection:** - - If the session has custom scopes for an application (e.g. `app:ed-droid`), `/pass` looks up the domain of `ed-droid` and redirects directly to `https://ed-droid.atyg.org`. + - If the session has custom scopes for an application (e.g. `app:ed-droid`), + `/pass` looks up the domain of `ed-droid` and redirects directly to + `https://ed-droid.atyg.org`. - If no specific app is scoped, redirects to `/dashboard`. --- diff --git a/tasks/new/2026-0825.02.gem.feat.event-passes.multi-claim-workshops-and-kiosks-0046.md b/tasks/complete/2026-0825.02.gem.feat.event-passes.multi-claim-workshops-and-kiosks-0046.md similarity index 67% rename from tasks/new/2026-0825.02.gem.feat.event-passes.multi-claim-workshops-and-kiosks-0046.md rename to tasks/complete/2026-0825.02.gem.feat.event-passes.multi-claim-workshops-and-kiosks-0046.md index e3b6139..fdf2ba9 100644 --- a/tasks/new/2026-0825.02.gem.feat.event-passes.multi-claim-workshops-and-kiosks-0046.md +++ b/tasks/complete/2026-0825.02.gem.feat.event-passes.multi-claim-workshops-and-kiosks-0046.md @@ -6,35 +6,46 @@ - `ui/components/EventJoinPage.tsx` - `ui/components/EventSplashPage.tsx` - `server/main.test.ts` -- **Core Objective:** Implement Multi-Claim Event Passes with short vanity URLs (`/e/:slug`), universal PIN join portal (`/join`), and CLI environment 1-liner (`/join/:slug?format=env`). +- **Core Objective:** Implement Multi-Claim Event Passes with short vanity URLs + (`/e/:slug`), universal PIN join portal (`/join`), and CLI environment 1-liner + (`/join/:slug?format=env`). - **Dependencies:** `server/db.ts`, `server/auth-session.ts` -- **Additional Important Notes:** Provisions isolated guest seats (`guest__`) with individual sessions. +- **Additional Important Notes:** Provisions isolated guest seats + (`guest__`) with individual sessions. --- ### 1. Architectural Considerations & Risks - **Concurrency & Seat Limits:** - - Ensure atomicity when incrementing `seats_claimed` on `event_passes` so events with strict seat limits (e.g. 50 seats) do not oversubscribe. + - Ensure atomicity when incrementing `seats_claimed` on `event_passes` so + events with strict seat limits (e.g. 50 seats) do not oversubscribe. - **Multi-Channel Accessibility:** - - Support web browser UI (`/e/:slug`, `/join`), JSON API (`POST /api/join`), and CLI environment output (`GET /join/:slug?format=env`). + - Support web browser UI (`/e/:slug`, `/join`), JSON API (`POST /api/join`), + and CLI environment output (`GET /join/:slug?format=env`). - **Session Isolation:** - - Each attendee gets their own dedicated guest session and UUID, preventing state collision in shared sandbox apps. + - Each attendee gets their own dedicated guest session and UUID, preventing + state collision in shared sandbox apps. --- ### 2. Proposed Implementation 1. **Database Schema (`server/db.ts`):** - - Create `event_passes` table with columns: `id`, `slug`, `pin_code`, `name`, `app_id`, `role`, `max_seats`, `seats_claimed`, `lifespan_hours`, `created_by`, `is_active`, `expires_at`, `created_at`. + - Create `event_passes` table with columns: `id`, `slug`, `pin_code`, `name`, + `app_id`, `role`, `max_seats`, `seats_claimed`, `lifespan_hours`, + `created_by`, `is_active`, `expires_at`, `created_at`. 2. **API Endpoints (`server/main.ts`):** - `POST /api/events`: Create a new event pass. - `GET /e/:slug`: Web landing splash with "Enter Workshop" button. - `GET /join`: Universal PIN / code entry page. - - `POST /api/join`: Redeems code or PIN, creates isolated guest user and session, sets cookie or returns JSON. - - `GET /join/:slug`: Returns CLI 1-liner (`export AUTH_YES_TOKEN="..."` when `?format=env`). + - `POST /api/join`: Redeems code or PIN, creates isolated guest user and + session, sets cookie or returns JSON. + - `GET /join/:slug`: Returns CLI 1-liner (`export AUTH_YES_TOKEN="..."` when + `?format=env`). 3. **SSR UI Components:** - `ui/components/EventJoinPage.tsx` - `ui/components/EventSplashPage.tsx` 4. **Automated Tests (`server/main.test.ts`):** - - Test event creation, PIN redemption, seat limit capping, and CLI output format. + - Test event creation, PIN redemption, seat limit capping, and CLI output + format. diff --git a/tasks/new/2026-0825.03.gem.feat.event-cockpit.live-metrics-and-master-killswitch-0047.md b/tasks/new/2026-0825.03.gem.feat.event-cockpit.live-metrics-and-master-killswitch-0047.md index 49bd2c2..8680a6a 100644 --- a/tasks/new/2026-0825.03.gem.feat.event-cockpit.live-metrics-and-master-killswitch-0047.md +++ b/tasks/new/2026-0825.03.gem.feat.event-cockpit.live-metrics-and-master-killswitch-0047.md @@ -5,28 +5,39 @@ - `ui/components/SessionsPage.tsx` - `ui/mod.ts` - `server/main.test.ts` -- **Core Objective:** Add real-time event cockpit deck to the Sessions page with active seat counters, PIN display, time extension, and 1-tap master kill switch (`/api/events/:id/end`). -- **Dependencies:** `tasks/new/2026-0825.02.gem.feat.event-passes.multi-claim-workshops-and-kiosks-0046.md` -- **Additional Important Notes:** Sub-millisecond mass revocation of all event guest sessions via Valkey RESP3 push tracking. +- **Core Objective:** Add real-time event cockpit deck to the Sessions page with + active seat counters, PIN display, time extension, and 1-tap master kill + switch (`/api/events/:id/end`). +- **Dependencies:** + `tasks/new/2026-0825.02.gem.feat.event-passes.multi-claim-workshops-and-kiosks-0046.md` +- **Additional Important Notes:** Sub-millisecond mass revocation of all event + guest sessions via Valkey RESP3 push tracking. --- ### 1. Architectural Considerations & Risks - **Mass Revocation Performance:** - - When the organizer clicks "End Workshop & Revoke All", the endpoint must delete all associated guest session IDs from both PostgreSQL and Valkey, emitting invalidation events to all connected nodes immediately. + - When the organizer clicks "End Workshop & Revoke All", the endpoint must + delete all associated guest session IDs from both PostgreSQL and Valkey, + emitting invalidation events to all connected nodes immediately. - **Observability:** - - Show real-time seat claim progress bar (`38 / 50 claimed`) and event expiration timer. + - Show real-time seat claim progress bar (`38 / 50 claimed`) and event + expiration timer. --- ### 2. Proposed Implementation 1. **Backend Endpoints (`server/main.ts`):** - - `POST /api/events/:id/end`: Closes the event, deletes all guest sessions under the event, and purges Valkey cache. - - `POST /api/events/:id/extend`: Adds hours to `expires_at` for the event and all active guest sessions. + - `POST /api/events/:id/end`: Closes the event, deletes all guest sessions + under the event, and purges Valkey cache. + - `POST /api/events/:id/extend`: Adds hours to `expires_at` for the event and + all active guest sessions. 2. **UI & Data Query (`ui/mod.ts`, `ui/components/SessionsPage.tsx`):** - - Query `event_passes` in `/dashboard/sessions` and render the **Event Cockpit Deck**. - - Display progress bar, quick copy buttons for PIN and Short URL, `[+1h Extend]` and `[🔴 End Workshop & Revoke All]`. + - Query `event_passes` in `/dashboard/sessions` and render the **Event + Cockpit Deck**. + - Display progress bar, quick copy buttons for PIN and Short URL, + `[+1h Extend]` and `[🔴 End Workshop & Revoke All]`. 3. **Automated Tests (`server/main.test.ts`):** - Test event extension and master kill-switch mass revocation. diff --git a/ui/components/EventJoinPage.tsx b/ui/components/EventJoinPage.tsx new file mode 100644 index 0000000..aa0cc9e --- /dev/null +++ b/ui/components/EventJoinPage.tsx @@ -0,0 +1,123 @@ +import { Layout } from "./Layout.tsx"; + +export const EventJoinPage = () => { + return ( + +
+
+ +

Join Event or Workshop

+

+ Enter your event PIN code or slug to claim an instant sandbox seat. +

+
+ +
+
+ + +
+ +
+ +