From 40804fd813cf43e5237d6d0797810402e35cd34e 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 --- server/db.ts | 18 ++ server/main.test.ts | 257 +++++++++++++++ server/main.ts | 294 +++++++++++++++++- ...s.multi-claim-workshops-and-kiosks-0046.md | 0 ui/components/EventJoinPage.tsx | 123 ++++++++ ui/components/EventSplashPage.tsx | 161 ++++++++++ ui/components/SessionsPage.tsx | 7 +- 7 files changed, 850 insertions(+), 10 deletions(-) rename tasks/{new => complete}/2026-0825.02.gem.feat.event-passes.multi-claim-workshops-and-kiosks-0046.md (100%) create mode 100644 ui/components/EventJoinPage.tsx create mode 100644 ui/components/EventSplashPage.tsx 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 6607fa1..3311d59 100644 --- a/server/main.test.ts +++ b/server/main.test.ts @@ -939,3 +939,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 ed6be7b..26ded03 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; @@ -193,7 +200,7 @@ app.get("/pass", async (c) => { sessionDataStr = await valkey.get(token); } catch (_err) {} - let sessionInfo = null; + let sessionInfo: any = null; if (sessionDataStr) { try { sessionInfo = JSON.parse(sessionDataStr); @@ -244,7 +251,7 @@ app.get("/pass", async (c) => { return c.redirect("/login?error=invalid_or_expired_pass", 302); } expiresAtDate = new Date(Date.now() + ttl * 1000); - customScopes = sessionInfo.customScopes || []; + customScopes = sessionInfo.customScopes || sessionInfo.custom_scopes || []; } catch (_err) { return c.redirect("/login?error=invalid_or_expired_pass", 302); } @@ -274,9 +281,11 @@ app.get("/pass", async (c) => { // 3. Redirect URL Resolution let targetDomain = null; if (Array.isArray(customScopes)) { - const appScope = customScopes.find((s: string) => s.startsWith("app:")); + const appScope = customScopes.find((s: string) => + typeof s === "string" && s.startsWith("app:") + ); if (appScope) { - const appName = appScope.substring(4); // remove 'app:' + const appName = appScope.substring(4); try { const appRecord = await sqlWrapper.sql` SELECT domain FROM apps WHERE name = ${appName} @@ -296,6 +305,279 @@ app.get("/pass", async (c) => { }); // --------------------------------------------------------- +// 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); + } +}); + +// --------------------------------------------------------- +>>>>>>> 0f4b72e (feat(passes): implement ephemeral 1-click magic links, event passes, PIN join portal, and CLI 1-liner) // Provisioning & Registration (Use Cases 1, 2, 3) // --------------------------------------------------------- @@ -1143,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.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 100% 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 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. +

+
+ +
+
+ + +
+ +
+ +