Compare commits

..

No commits in common. "578f3d06ac480bb9f2a625af2d1c8816a95b3413" and "7033c532b2dd5a64755c7d706f253ea627e944f3" have entirely different histories.

16 changed files with 509 additions and 1776 deletions

View File

@ -1,21 +1,16 @@
# Dual-Audience Development Guide: Agent-First APIs & Mobile-First SSR UI # Dual-Audience Development Guide: Agent-First APIs & Mobile-First SSR UI
This guide outlines the architectural blueprint and best practices for building This guide outlines the architectural blueprint and best practices for building modern services within the **Auth-Yes Zero-Trust Ecosystem**.
modern services within the **Auth-Yes Zero-Trust Ecosystem**.
--- ---
## 1. The Core Philosophy: Dual-Audience Architecture ## 1. The Core Philosophy: Dual-Audience Architecture
Every modern application should be built for **two primary audiences**: Every modern application should be built for **two primary audiences**:
1. **🤖 AI Agents & Workloads (Primary Data Consumer):** 1. **🤖 AI Agents & Workloads (Primary Data Consumer):**
- Headless execution via **Model Context Protocol (MCP)**, REST APIs, or - Headless execution via **Model Context Protocol (MCP)**, REST APIs, or ConnectRPC.
ConnectRPC. - Machine-readable, high-density JSON/RPC responses with zero HTML/CSS clutter.
- Machine-readable, high-density JSON/RPC responses with zero HTML/CSS - Authentication via **Delegated Bearer Tokens (`ay_sess_...`)** or mTLS SPIFFE workload identities.
clutter.
- Authentication via **Delegated Bearer Tokens (`ay_sess_...`)** or mTLS
SPIFFE workload identities.
2. **📱 Humans on Mobile Devices (Primary UI Consumer):** 2. **📱 Humans on Mobile Devices (Primary UI Consumer):**
- Ultra-fast, zero-friction **Server-Side Rendered (SSR) JSX** touch cards. - Ultra-fast, zero-friction **Server-Side Rendered (SSR) JSX** touch cards.
- Passkey (WebAuthn) biometric authentication. - Passkey (WebAuthn) biometric authentication.
@ -26,26 +21,26 @@ Every modern application should be built for **two primary audiences**:
## 2. Architectural Blueprint for Subsidiary Apps (e.g. `ed-droid`) ## 2. Architectural Blueprint for Subsidiary Apps (e.g. `ed-droid`)
``` ```
+---------------------------------------+ +---------------------------------------+
| Traefik Edge Ingress | | Traefik Edge Ingress |
+---------------------------------------+ +---------------------------------------+
| |
ForwardAuth Check | (Injects X-Forwarded-*) ForwardAuth Check | (Injects X-Forwarded-*)
+------------------------+------------------------+ +------------------------+------------------------+
| | | |
v v v v
+-----------------------+ +-----------------------+ +-----------------------+ +-----------------------+
| Auth-Yes Service | | Subsidiary App | | Auth-Yes Service | | Subsidiary App |
| (Validates Session / | | (e.g., ed-droid) | | (Validates Session / | | (e.g., ed-droid) |
| Bearer Token) | +-----------------------+ | Bearer Token) | +-----------------------+
+-----------------------+ | +-----------------------+ |
| |
+----------------------------+----------------------------+ +----------------------------+----------------------------+
| | | |
v v v v
[ /api/* JSON Endpoints ] [ SSR HTML Mobile Cards ] [ /api/* JSON Endpoints ] [ SSR HTML Mobile Cards ]
- Served for AI Agents & MCP - Served for Human Browsers - Served for AI Agents & MCP - Served for Human Browsers
- Filtered by Scopes / Roles - Clean, high-contrast UI - Filtered by Scopes / Roles - Clean, high-contrast UI
``` ```
--- ---
@ -53,30 +48,22 @@ Every modern application should be built for **two primary audiences**:
## 3. How to Build an "Agent-First" Subsidiary App ## 3. How to Build an "Agent-First" Subsidiary App
### Step 1: Ingress Protection via ForwardAuth ### 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 ```yaml
labels: labels:
- "traefik.http.routers.ed-droid.middlewares=auth-yes-forwardauth@docker" - "traefik.http.routers.ed-droid.middlewares=auth-yes-forwardauth@docker"
``` ```
When requests arrive: When requests arrive:
* Traefik queries `http://auth-api:8000/api/forward-auth`.
- 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.
- Auth-Yes validates either the browser's `session_id` cookie **or** the * If authorized, Traefik injects:
incoming `Authorization: Bearer ay_sess_...` token.
- If authorized, Traefik injects:
- `X-Forwarded-User: <username>` - `X-Forwarded-User: <username>`
- `X-Forwarded-User-Id: <uuid>` - `X-Forwarded-User-Id: <uuid>`
- `X-Forwarded-Scopes: <scope1,scope2>` - `X-Forwarded-Scopes: <scope1,scope2>`
- `X-Forwarded-App-Id: <app_id>` - `X-Forwarded-App-Id: <app_id>`
### Step 2: Implement Clean JSON API Routes ### Step 2: Implement Clean JSON API Routes
Provide standard JSON endpoints for all core operations: Provide standard JSON endpoints for all core operations:
```typescript ```typescript
import { Hono } from "jsr:@hono/hono"; import { Hono } from "jsr:@hono/hono";
@ -91,19 +78,13 @@ app.get("/api/fleet", (c) => {
``` ```
### Step 3: Progressive Content Negotiation (Optional) ### 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 ```typescript
app.get("/ships/:id", (c) => { app.get("/ships/:id", (c) => {
const ship = getShip(c.req.param("id")); const ship = getShip(c.req.param("id"));
// If requested by an agent or CLI: // If requested by an agent or CLI:
if ( if (c.req.header("Accept")?.includes("application/json") || c.req.query("format") === "json") {
c.req.header("Accept")?.includes("application/json") ||
c.req.query("format") === "json"
) {
return c.json(ship); return c.json(ship);
} }
@ -116,14 +97,11 @@ app.get("/ships/:id", (c) => {
## 4. Connecting AI Agents via Model Context Protocol (MCP) ## 4. Connecting AI Agents via Model Context Protocol (MCP)
To expose your subsidiary applications to AI assistants (Antigravity, Jules, To expose your subsidiary applications to AI assistants (Antigravity, Jules, Claude Desktop):
Claude Desktop):
1. **Mint a Delegated Session in Auth-Yes:** 1. **Mint a Delegated Session in Auth-Yes:**
- Go to `https://auth.atyg.org/dashboard/sessions` $\rightarrow$ Click - Go to `https://auth.atyg.org/dashboard/sessions` $\rightarrow$ Click **`+ Delegate Agent Session`**.
**`+ Delegate Agent Session`**. - Set Label: `"Antigravity Assistant"`, Lifespan: `12 Hours`, Scope: `ed-droid`.
- Set Label: `"Antigravity Assistant"`, Lifespan: `12 Hours`, Scope:
`ed-droid`.
- Copy the CLI export string. - Copy the CLI export string.
2. **Configure the MCP Server:** 2. **Configure the MCP Server:**
@ -132,11 +110,7 @@ Claude Desktop):
"mcpServers": { "mcpServers": {
"ed-droid": { "ed-droid": {
"command": "deno", "command": "deno",
"args": [ "args": ["run", "-A", "https://git.atyg.org/tylerg/ed-droid/raw/branch/main/mcp/server.ts"],
"run",
"-A",
"https://git.atyg.org/tylerg/ed-droid/raw/branch/main/mcp/server.ts"
],
"env": { "env": {
"AUTH_YES_TOKEN": "ay_sess_8de186f564d7..." "AUTH_YES_TOKEN": "ay_sess_8de186f564d7..."
} }
@ -145,33 +119,26 @@ Claude Desktop):
} }
``` ```
3. **Tool Call Execution:** The MCP server attaches 3. **Tool Call Execution:**
`Authorization: Bearer $AUTH_YES_TOKEN` to all internal fetch calls, gaining 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!
instant authorized access to fleet telemetry and data with full Merkle audit
attribution!
--- ---
## 5. Ephemeral Guest & Support Passes (Magic 1-Click Links) ## 5. Ephemeral Guest & Support Passes (Magic 1-Click Links)
In addition to Agent Bearer Tokens, Auth-Yes supports **Ephemeral Magic Passes** In addition to Agent Bearer Tokens, Auth-Yes supports **Ephemeral Magic Passes** for friends and external support technicians:
for friends and external support technicians:
### The Flow: ### The Flow:
1. **Spawn Pass:** Under Sessions, click **`+ Spawn Guest / Support Pass`**: 1. **Spawn Pass:** Under Sessions, click **`+ Spawn Guest / Support Pass`**:
- **Label:** _"Friend Demo - Elite Dangerous Fleet"_ - **Label:** *"Friend Demo - Elite Dangerous Fleet"*
- **Lifespan:** _2 Hours_ (auto-expires) - **Lifespan:** *2 Hours* (auto-expires)
- **Target App:** `ed-droid.atyg.org` - **Target App:** `ed-droid.atyg.org`
2. **Share 1-Click Link:** 2. **Share 1-Click Link:**
`https://auth.atyg.org/pass?token=ay_pass_9f8a7b6c...` `https://auth.atyg.org/pass?token=ay_pass_9f8a7b6c...`
3. **Instant Redemption:** 3. **Instant Redemption:**
- When opened in any browser, Auth-Yes automatically sets the `.atyg.org` - When opened in any browser, Auth-Yes automatically sets the `.atyg.org` session cookie with restricted scopes.
session cookie with restricted scopes.
- The browser is immediately redirected to `https://ed-droid.atyg.org`. - The browser is immediately redirected to `https://ed-droid.atyg.org`.
- **Zero friction:** No passkeys to register, no passwords, no email - **Zero friction:** No passkeys to register, no passwords, no email confirmation.
confirmation.
4. **Security & Control:** 4. **Security & Control:**
- The guest only has access to the specified target app. - The guest only has access to the specified target app.
- The pass is visible on your Sessions dashboard in real time with an instant - The pass is visible on your Sessions dashboard in real time with an instant **`[Revoke]`** button.
**`[Revoke]`** button.

View File

@ -1,117 +1,86 @@
# Ephemeral Magic Passes & Multi-Claim Event Passes Implementation Plan # Ephemeral Magic Passes & Multi-Claim Event Passes Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use > **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.
> 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 **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.
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 **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.
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 **Tech Stack:** Deno 2.x, TypeScript 5.x, Hono SSR JSX (React-free), PostgreSQL 18, Valkey 8, Traefik ForwardAuth.
18, Valkey 8, Traefik ForwardAuth.
**Spec:** **Spec:** [DUAL_AUDIENCE_DEVELOPMENT_GUIDE.md](../../DUAL_AUDIENCE_DEVELOPMENT_GUIDE.md)
[DUAL_AUDIENCE_DEVELOPMENT_GUIDE.md](../../DUAL_AUDIENCE_DEVELOPMENT_GUIDE.md)
## Global Constraints ## Global Constraints
- Strictly zero React dependencies; use pure Hono SSR JSX. - Strictly zero React dependencies; use pure Hono SSR JSX.
- Zero-dependency SDK in `sdk/`. - Zero-dependency SDK in `sdk/`.
- All cookie mutations must delete host-only cookies and set wildcard - All cookie mutations must delete host-only cookies and set wildcard `.atyg.org` domain cookies.
`.atyg.org` domain cookies. - Every commit and change must be pushed to both GitHub (`origin`) and Gitea (`gitea`).
- Every commit and change must be pushed to both GitHub (`origin`) and Gitea - Quality gates must pass: `deno fmt`, `deno task lint`, `deno task check`, `deno task test`.
(`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`) ### Task 1: 1-on-1 Ephemeral Magic Link Redemption (`/pass`)
**Files:** **Files:**
- Modify: `server/main.ts` - Modify: `server/main.ts`
- Modify: `ui/components/SessionsPage.tsx` - Modify: `ui/components/SessionsPage.tsx`
- Test: `server/main.test.ts` - Test: `server/main.test.ts`
**Interfaces:** **Interfaces:**
- Consumes: `extractAllSessionIds(c)`, `valkey.get()`, `sqlWrapper.sql` - Consumes: `extractAllSessionIds(c)`, `valkey.get()`, `sqlWrapper.sql`
- Produces: `GET /pass?token=...` endpoint and updated Hand-Off Modal in - Produces: `GET /pass?token=...` endpoint and updated Hand-Off Modal in `SessionsPage.tsx`
`SessionsPage.tsx`
- [ ] **Step 1: Write the failing test for `GET /pass`** - [ ] **Step 1: Write the failing test for `GET /pass`**
```typescript ```typescript
// in server/main.test.ts // in server/main.test.ts
Deno.test("GET /pass - Ephemeral Magic Link 1-Click Redemption", async (t) => { Deno.test("GET /pass - Ephemeral Magic Link 1-Click Redemption", async (t) => {
await t.step( await t.step("Valid token sets wildcard cookie and redirects to target app", async () => {
"Valid token sets wildcard cookie and redirects to target app", const valkeyStub = stub(valkey, "get", (key: any) => {
async () => { if (String(key) === "ay_sess_valid_pass") {
const valkeyStub = stub(valkey, "get", (key: any) => { return Promise.resolve(
if (String(key) === "ay_sess_valid_pass") { JSON.stringify({ uuid: "guest-uuid", username: "guest_user", customScopes: ["app:ed-droid"] }),
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",
});
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();
} }
}, 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",
});
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: - [ ] **Step 2: Run test to verify it fails**
`deno test server/main.test.ts --filter "Ephemeral Magic Link"` Expected: Run: `deno test server/main.test.ts --filter "Ephemeral Magic Link"`
FAIL (404 Not Found on `/pass`) Expected: FAIL (404 Not Found on `/pass`)
- [ ] **Step 3: Implement `GET /pass` in `server/main.ts`** Extract token from - [ ] **Step 3: Implement `GET /pass` in `server/main.ts`**
`?token=...`, validate against Valkey/DB, set cookie with 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)`.
`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 - [ ] **Step 4: Update `ui/components/SessionsPage.tsx`**
Magic Link"** copy card tab alongside the CLI and cURL header tabs. 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: - [ ] **Step 5: Run tests to verify they pass**
PASS (All tests passing) Run: `deno task test`
Expected: PASS (All tests passing)
- [ ] **Step 6: Commit and Push** - [ ] **Step 6: Commit and Push**
```bash ```bash
git add server/main.ts server/main.test.ts ui/components/SessionsPage.tsx 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" git commit -m "feat(pass): implement 1-click ephemeral magic link redemption route"
@ -123,7 +92,6 @@ git push origin main && git push gitea main
### Task 2: Multi-Claim Event Passes Schema & Join Endpoints (`/join`, `/e/:slug`, CLI 1-Liner) ### Task 2: Multi-Claim Event Passes Schema & Join Endpoints (`/join`, `/e/:slug`, CLI 1-Liner)
**Files:** **Files:**
- Modify: `server/db.ts` - Modify: `server/db.ts`
- Modify: `server/main.ts` - Modify: `server/main.ts`
- Create: `ui/components/EventJoinPage.tsx` - Create: `ui/components/EventJoinPage.tsx`
@ -131,16 +99,13 @@ git push origin main && git push gitea main
- Test: `server/main.test.ts` - Test: `server/main.test.ts`
**Interfaces:** **Interfaces:**
- Produces: - Produces:
- Table: `event_passes` in PostgreSQL - Table: `event_passes` in PostgreSQL
- Endpoints: - Endpoints:
- `POST /api/events`: Create event pass (slug, pin_code, name, max_seats, - `POST /api/events`: Create event pass (slug, pin_code, name, max_seats, lifespan_hours, app_id, role)
lifespan_hours, app_id, role)
- `GET /e/:slug`: Web landing splash with "Enter Workshop" action - `GET /e/:slug`: Web landing splash with "Enter Workshop" action
- `GET /join`: Universal PIN / word-code entry page - `GET /join`: Universal PIN / word-code entry page
- `POST /api/join`: Redeems code/PIN and creates isolated - `POST /api/join`: Redeems code/PIN and creates isolated `guest_<slug>_<index>` session
`guest_<slug>_<index>` session
- `GET /join/:slug`: CLI 1-liner (`?format=env` or `?format=json`) - `GET /join/:slug`: CLI 1-liner (`?format=env` or `?format=json`)
- [ ] **Step 1: Write the failing tests for Event creation and redemption** - [ ] **Step 1: Write the failing tests for Event creation and redemption**
@ -159,28 +124,23 @@ Deno.test("Multi-Claim Event Passes & Join Endpoints", async (t) => {
}); });
``` ```
- [ ] **Step 2: Run test to verify it fails** Run: - [ ] **Step 2: Run test to verify it fails**
`deno test server/main.test.ts --filter "Multi-Claim Event"` Expected: Run: `deno test server/main.test.ts --filter "Multi-Claim Event"`
FAIL Expected: FAIL
- [ ] **Step 3: Add `event_passes` schema in `server/db.ts`** Include columns: - [ ] **Step 3: Add `event_passes` schema in `server/db.ts`**
`id`, `slug`, `pin_code`, `name`, `app_id`, `role`, `max_seats`, Include columns: `id`, `slug`, `pin_code`, `name`, `app_id`, `role`, `max_seats`, `seats_claimed`, `lifespan_hours`, `created_by`, `is_active`, `expires_at`, `created_at`.
`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 - [ ] **Step 4: Implement Event API routes in `server/main.ts` and SSR UI Pages**
Pages**
- Create `ui/components/EventJoinPage.tsx` for `/join` PIN code entry. - Create `ui/components/EventJoinPage.tsx` for `/join` PIN code entry.
- Create `ui/components/EventSplashPage.tsx` for `/e/:slug` 1-click workshop - Create `ui/components/EventSplashPage.tsx` for `/e/:slug` 1-click workshop entrance.
entrance. - Implement `GET /join/:slug` returning `export AUTH_YES_TOKEN="..."` when `?format=env`.
- 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: - [ ] **Step 5: Run tests to verify they pass**
PASS Run: `deno task test`
Expected: PASS
- [ ] **Step 6: Commit and Push** - [ ] **Step 6: Commit and Push**
```bash ```bash
git add server/db.ts server/main.ts ui/components/EventJoinPage.tsx ui/components/EventSplashPage.tsx server/main.test.ts 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" git commit -m "feat(events): implement multi-claim event passes, PIN join portal, and CLI 1-liner"
@ -192,53 +152,41 @@ git push origin main && git push gitea main
### Task 3: Live Event Cockpit & Master Kill-Switch ### Task 3: Live Event Cockpit & Master Kill-Switch
**Files:** **Files:**
- Modify: `server/main.ts` - Modify: `server/main.ts`
- Modify: `ui/components/SessionsPage.tsx` - Modify: `ui/components/SessionsPage.tsx`
- Modify: `ui/mod.ts` - Modify: `ui/mod.ts`
- Test: `server/main.test.ts` - Test: `server/main.test.ts`
**Interfaces:** **Interfaces:**
- Produces: - Produces:
- `POST /api/events/:id/end`: Closes event and immediately revokes all guest - `POST /api/events/:id/end`: Closes event and immediately revokes all guest sessions
sessions
- `POST /api/events/:id/extend`: Adds hours to active event - `POST /api/events/:id/extend`: Adds hours to active event
- Event Management Deck in `SessionsPage.tsx` with live seat counter - Event Management Deck in `SessionsPage.tsx` with live seat counter (`38 / 50`) and master kill switch
(`38 / 50`) and master kill switch
- [ ] **Step 1: Write failing tests for Event Kill-Switch & Extension** - [ ] **Step 1: Write failing tests for Event Kill-Switch & Extension**
```typescript ```typescript
Deno.test("Event Cockpit & Master Kill Switch", async (t) => { Deno.test("Event Cockpit & Master Kill Switch", async (t) => {
await t.step( await t.step("POST /api/events/:id/end revokes all guest seats instantly", async () => {
"POST /api/events/:id/end revokes all guest seats instantly", // verify sessions deleted and Valkey cleared
async () => { });
// verify sessions deleted and Valkey cleared
},
);
}); });
``` ```
- [ ] **Step 2: Run test to verify it fails** Run: - [ ] **Step 2: Run test to verify it fails**
`deno test server/main.test.ts --filter "Event Cockpit"` Expected: FAIL Run: `deno test server/main.test.ts --filter "Event Cockpit"`
Expected: FAIL
- [ ] **Step 3: Implement `POST /api/events/:id/end` and - [ ] **Step 3: Implement `POST /api/events/:id/end` and `POST /api/events/:id/extend` in `server/main.ts`**
`POST /api/events/:id/extend` in `server/main.ts`** Fetch all session IDs 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`.
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 - [ ] **Step 4: Update `ui/components/SessionsPage.tsx` with Event Cockpit Deck**
Deck** Display active events with live progress bar Display active events with live progress bar (`Seats Claimed / Capacity`), PIN badge, copy links, `[+1h Extend]` and `[🔴 End Workshop & Revoke All]`.
(`Seats Claimed / Capacity`), PIN badge, copy links, `[+1h Extend]` and
`[🔴 End Workshop & Revoke All]`.
- [ ] **Step 5: Run full quality gates** Run: - [ ] **Step 5: Run full quality gates**
`deno fmt && deno task lint && deno task check && deno task test` Run: `deno fmt && deno task lint && deno task check && deno task test`
Expected: All pass. Expected: All pass.
- [ ] **Step 6: Commit and Push** - [ ] **Step 6: Commit and Push**
```bash ```bash
git add server/main.ts ui/components/SessionsPage.tsx ui/mod.ts server/main.test.ts 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" git commit -m "feat(cockpit): add live event metrics, seat roster, and master kill-switch"

View File

@ -401,24 +401,3 @@ export function isSafeRedirectUrl(
} }
return false; return false;
} }
/**
* Extracts the real client IP from X-Real-IP or X-Forwarded-For headers.
*/
export function getClientIp(c: Context): string {
const realIp = c.req.header("x-real-ip");
if (realIp) {
return realIp.trim();
}
let forwardedFor = c.req.header("x-forwarded-for");
if (forwardedFor) {
if (forwardedFor.length > 256) {
forwardedFor = forwardedFor.substring(0, 256);
}
const parts = forwardedFor.split(",");
return parts[parts.length - 1].trim();
}
return "127.0.0.1";
}

View File

@ -206,24 +206,6 @@ export async function initDb(): Promise<void> {
// Ignore migration column exists // 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` await sql`
CREATE TABLE IF NOT EXISTS audit_sths ( CREATE TABLE IF NOT EXISTS audit_sths (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(), id UUID PRIMARY KEY DEFAULT gen_random_uuid(),

View File

@ -71,109 +71,6 @@ Deno.test("Tier 1 & 2: GET /api/forward-auth - Valid session", async () => {
valkeyStub.restore(); valkeyStub.restore();
}); });
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([]);
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:test-app"],
}));
}
return Promise.resolve(null);
});
const valkeyTtlStub = stub(valkey, "ttl", () => Promise.resolve(3600));
const originalSql = sqlWrapper.sql;
sqlWrapper.sql = (strings: any, ..._values: any[]) => {
const query = strings.join("?");
if (query.includes("SELECT domain FROM apps WHERE name =")) {
return Promise.resolve([{ domain: "test-app.atyg.org" }]);
}
return Promise.resolve([]);
};
try {
const res = await app.request(`/pass?token=${sessionToken}`, {
method: "GET",
});
assertEquals(res.status, 302);
assertEquals(res.headers.get("location"), "https://test-app.atyg.org");
// Verify cookies
const cookies = res.headers.get("set-cookie");
assertExists(cookies);
assert(cookies.includes("session_id=;")); // clear host-only cookie
assert(cookies.includes(`session_id=${sessionToken};`)); // set wildcard cookie
// In tests, process.env.COOKIE_DOMAIN or getCookieDomain fallback doesn't output .atyg.org because rpID env variables may not be explicitly set in Deno tests. Wait, if it fails, let's just make sure it sets domain
} 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("Tier 1 & 2: GET /api/forward-auth - Missing session (API request)", async () => { Deno.test("Tier 1 & 2: GET /api/forward-auth - Missing session (API request)", async () => {
const req = new Request("http://localhost/api/forward-auth", { const req = new Request("http://localhost/api/forward-auth", {
headers: { "X-Forwarded-Host": "test.app.local" }, headers: { "X-Forwarded-Host": "test.app.local" },
@ -939,260 +836,3 @@ 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();
}
},
);
});

View File

@ -12,11 +12,7 @@ import type {
RegistrationResponseJSON, RegistrationResponseJSON,
} from "jsr:@simplewebauthn/server@13"; } from "jsr:@simplewebauthn/server@13";
import { deleteCookie, getCookie, setCookie } from "jsr:@hono/hono@4/cookie"; import { deleteCookie, getCookie, setCookie } from "jsr:@hono/hono@4/cookie";
import { import { extractAllSessionIds } from "./auth-session.ts";
extractAllSessionIds,
getAuthenticatedUser,
isGlobalAdmin,
} from "./auth-session.ts";
import { import {
decodeBase64Url, decodeBase64Url,
encodeBase64Url, encodeBase64Url,
@ -33,12 +29,7 @@ import {
universalServerResponseToFetch, universalServerResponseToFetch,
} from "npm:@connectrpc/connect@^1.4.0/protocol"; } from "npm:@connectrpc/connect@^1.4.0/protocol";
import type { ConnectRouter } from "npm:@connectrpc/connect@^1.4.0"; import type { ConnectRouter } from "npm:@connectrpc/connect@^1.4.0";
import { computeJwkThumbprint } from "./http_signatures.ts";
import { uiApp } from "../ui/mod.ts"; import { uiApp } from "../ui/mod.ts";
import { passRoutes } from "./routes/passes_magic.ts";
import { eventRoutes } from "./routes/events.ts";
import { sessionRoutes } from "./routes/sessions.ts";
import { forwardAuthRoutes } from "./routes/auth_forward.ts";
type Variables = { type Variables = {
userId: string; userId: string;
@ -48,12 +39,6 @@ export const app: Hono<{ Variables: Variables }> = new Hono<
{ Variables: Variables } { Variables: Variables }
>(); >();
// Mount Sub-routers
app.route("/pass", passRoutes);
app.route("/", eventRoutes);
app.route("/", sessionRoutes);
app.route("/", forwardAuthRoutes);
// Mount UI Routes // Mount UI Routes
app.route("/", uiApp); app.route("/", uiApp);
@ -1040,6 +1025,8 @@ app.all("/auth.v1.AuthService/*", async (c) => {
// Session & Credential Management (Authenticated APIs) // Session & Credential Management (Authenticated APIs)
// --------------------------------------------------------- // ---------------------------------------------------------
import { getAuthenticatedUser, isGlobalAdmin } from "./auth-session.ts";
// --------------------------------------------------------- // ---------------------------------------------------------
// Global Admin APIs (For the Management Console) // Global Admin APIs (For the Management Console)
// --------------------------------------------------------- // ---------------------------------------------------------
@ -1136,6 +1123,150 @@ app.post("/api/admin/users/:id/profile", async (c) => {
return c.json({ success: true, user: targetUser }); return c.json({ success: true, user: targetUser });
}); });
// ---------------------------------------------------------
// Traefik ForwardAuth Edge Proxy Route (Tier 2)
// ---------------------------------------------------------
import {
getAppByHost,
getUserGrant,
isIpAllowed,
isPathBypassed,
} from "./auth-session.ts";
import {
computeJwkThumbprint,
verifyHttpSignature,
} from "./http_signatures.ts";
app.get("/api/forward-auth", async (c) => {
const host = c.req.header("X-Forwarded-Host");
if (!host) {
return c.text("Bad Request: Missing X-Forwarded-Host header", 400);
}
// 1. Resolve Target App (Valkey -> DB)
const appRecord = await getAppByHost(host);
if (!appRecord) {
const accept = c.req.header("Accept") || "";
// If a browser is requesting a webpage on an unregistered domain, seamlessly redirect to unregistered error view
if (accept.includes("text/html")) {
const loginDomain = rpID || "auth.atyg.org";
c.header(
"Cache-Control",
"no-store, no-cache, must-revalidate, max-age=0",
);
return c.redirect(
`https://${loginDomain}/errors/unregistered?host=${
encodeURIComponent(host)
}`,
302,
);
}
// Default-Deny if app is not registered (API requests)
return c.json({ error: "Application not registered" }, 403);
}
// 1.5 Dynamic Bypass Check
const uri = c.req.header("X-Forwarded-Uri") || "/";
const clientIp = c.req.header("X-Forwarded-For") || "127.0.0.1";
const requestPath = new URL(uri, `http://${host}`).pathname;
if (
appRecord.is_public === true ||
isPathBypassed(requestPath, appRecord.bypass_paths) ||
isIpAllowed(clientIp, appRecord.allowed_cidrs)
) {
// Append standard headers even on bypass for downstream context if needed
c.header("X-Forwarded-App-Id", appRecord.id);
return c.text("OK", 200);
}
// 2. Validate Session OR HTTP Signature
const signatureInput = c.req.header("Signature-Input");
const signature = c.req.header("Signature");
if (signatureInput && signature) {
// Headless Edge Node Path (RFC 9421)
try {
const fingerprint = await verifyHttpSignature(c.req.raw);
// Look up key name from postgres if needed, but fingerprint string manipulation is fast enough
const serviceName = `service-node:${fingerprint.substring(0, 8)}`;
const serviceId = fingerprint;
const scopes = "edge-node,daemon";
c.header("X-Forwarded-User", serviceName);
c.header("X-Forwarded-User-Id", serviceId);
c.header("X-Forwarded-Scopes", scopes);
c.header("X-Forwarded-App-Id", appRecord.id);
return c.text("OK", 200);
} catch (err: any) {
return c.text(`Unauthorized: ${err.message}`, 401);
}
}
// Standard User Session Path
const auth = await getAuthenticatedUser(c);
if (!auth) {
const accept = c.req.header("Accept") || "";
const proto = c.req.header("X-Forwarded-Proto") || "https";
const uri = c.req.header("X-Forwarded-Uri") || "/";
const originalUrl = `${proto}://${host}${uri}`;
// If a browser is requesting a webpage, seamlessly redirect to login
if (accept.includes("text/html")) {
const loginDomain = rpID || "auth.atyg.org";
c.header(
"Cache-Control",
"no-store, no-cache, must-revalidate, max-age=0",
);
return c.redirect(
`https://${loginDomain}/login?redirect=${
encodeURIComponent(originalUrl)
}`,
302,
);
}
return c.text("Unauthorized", 401);
}
// Cache lookup for user status can be added later; hitting DB to be safe for now,
// but let's just make sure account is active.
const user = await sqlWrapper.sql`
SELECT id, username, account_status
FROM users
WHERE id = ${auth.userId}
`.then((res: any) => res[0]);
if (!user || user.account_status !== "active") {
return c.text("Forbidden: Account inactive", 403);
}
// 3. Resolve Grants and Roles
const globalAdmin = await isGlobalAdmin(auth.userId);
const grantRole = await getUserGrant(auth.userId, appRecord.id);
if (!globalAdmin && !grantRole) {
// Enforce Default-Deny if no app-specific grants and not global admin
return c.text("Forbidden: Access denied to this application", 403);
}
// Combine scopes, ensuring no duplicates and formatting as comma-separated string
const scopes = [
...new Set([grantRole, globalAdmin ? "admin" : null].filter(Boolean)),
].join(",");
// 4. Inject Headers
c.header("X-Forwarded-User", user.username);
c.header("X-Forwarded-User-Id", user.id);
c.header("X-Forwarded-Scopes", scopes);
c.header("X-Forwarded-App-Id", appRecord.id);
return c.text("OK", 200);
});
// --------------------------------------------------------- // ---------------------------------------------------------
// Admin Application Registry // Admin Application Registry
// --------------------------------------------------------- // ---------------------------------------------------------
@ -1882,6 +2013,226 @@ app.get("/api/admin/check", async (c) => {
return c.json({ isAdmin }); return c.json({ isAdmin });
}); });
// Get user's active sessions
app.get("/api/sessions", async (c) => {
const auth = await getAuthenticatedUser(c);
if (!auth) return c.json({ error: "Unauthorized" }, 401);
const sessions = await sqlWrapper.sql`
SELECT id, label, is_agent, custom_scopes, last_activity_at, last_activity_action, created_at, expires_at
FROM sessions
WHERE user_id = ${auth.userId} AND expires_at > NOW()
ORDER BY created_at DESC
`;
return c.json({ sessions, currentSessionId: auth.sessionId });
});
// Delegate a child agent session with custom lifespan and scopes
app.post("/api/sessions/delegate", async (c) => {
const auth = await getAuthenticatedUser(c);
if (!auth) return c.json({ error: "Unauthorized" }, 401);
const {
label,
lifespanHours = 1,
mode = "read_only",
customScopes = [],
} = await c.req.json();
const cleanLabel = (label && typeof label === "string" && label.trim())
? label.trim()
: "AI Agent Session";
const hours = Math.min(Math.max(Number(lifespanHours) || 1, 1), 720); // Max 30 days
const expiresAt = new Date(Date.now() + hours * 3600 * 1000);
let effectiveScopes: string[] = [];
if (mode === "read_only") {
effectiveScopes = [
"read:audit",
"read:users",
"read:apps",
"read:roles",
"read:sessions",
];
} else if (mode === "operator") {
effectiveScopes = ["operator", "read:audit", "read:users", "read:apps"];
} else if (mode === "admin") {
effectiveScopes = ["*"];
} else if (mode === "custom" && Array.isArray(customScopes)) {
effectiveScopes = customScopes.map((s: string) => String(s).trim()).filter(
Boolean,
);
}
const rawBytes = new Uint8Array(32);
crypto.getRandomValues(rawBytes);
const tokenHex = Array.from(rawBytes).map((b) =>
b.toString(16).padStart(2, "0")
).join("");
const sessionId = `ay_sess_${tokenHex}`;
await sqlWrapper.sql`
INSERT INTO sessions (id, user_id, label, is_agent, custom_scopes, expires_at, created_at)
VALUES (${sessionId}, ${auth.userId}, ${cleanLabel}, true, ${effectiveScopes}, ${expiresAt.toISOString()}, NOW())
`;
try {
const sessionData = {
uuid: auth.userId,
username: auth.username,
label: cleanLabel,
isAgent: true,
customScopes: effectiveScopes,
};
await valkey.set(
sessionId,
JSON.stringify(sessionData),
"EX",
Math.floor(hours * 3600),
);
} catch (err) {
console.error("[Valkey] Failed to cache delegated session:", err);
}
auditWrapper.auditLog(auth.userId, "session_delegated", sessionId, {
label: cleanLabel,
lifespan_hours: hours,
mode,
scopes: effectiveScopes,
}, getClientIp(c));
return c.json({
success: true,
sessionId,
token: sessionId,
label: cleanLabel,
expiresAt: expiresAt.toISOString(),
scopes: effectiveScopes,
});
});
// Update permissions on an active session
app.put("/api/sessions/:id/scopes", async (c) => {
const auth = await getAuthenticatedUser(c);
if (!auth) return c.json({ error: "Unauthorized" }, 401);
const targetSessionId = c.req.param("id");
const { customScopes = [] } = await c.req.json();
const session = await sqlWrapper.sql`
SELECT id, is_agent FROM sessions WHERE id = ${targetSessionId} AND user_id = ${auth.userId}
`.then((res: any) => res[0]);
if (!session) {
return c.json({ error: "Session not found or access denied" }, 404);
}
const effectiveScopes = Array.isArray(customScopes)
? customScopes.map((s: string) => String(s).trim()).filter(Boolean)
: [];
await sqlWrapper.sql`
UPDATE sessions SET custom_scopes = ${effectiveScopes} WHERE id = ${targetSessionId}
`;
try {
const existingCached = await valkey.get(targetSessionId);
if (existingCached) {
const parsed = JSON.parse(existingCached);
parsed.customScopes = effectiveScopes;
await valkey.set(targetSessionId, JSON.stringify(parsed));
}
} catch (_e) {}
auditWrapper.auditLog(
auth.userId,
"session_scopes_updated",
targetSessionId,
{
scopes: effectiveScopes,
},
getClientIp(c),
);
return c.json({ success: true, scopes: effectiveScopes });
});
// Extend session TTL
app.post("/api/sessions/:id/extend", async (c) => {
const auth = await getAuthenticatedUser(c);
if (!auth) return c.json({ error: "Unauthorized" }, 401);
const targetSessionId = c.req.param("id");
const { extendHours = 1 } = await c.req.json();
const additionalHours = Math.max(Number(extendHours) || 1, 1);
const session = await sqlWrapper.sql`
SELECT id, expires_at FROM sessions WHERE id = ${targetSessionId} AND user_id = ${auth.userId}
`.then((res: any) => res[0]);
if (!session) {
return c.json({ error: "Session not found or access denied" }, 404);
}
const currentExpiry = new Date(session.expires_at).getTime();
const newExpiry = new Date(
Math.max(Date.now(), currentExpiry) + additionalHours * 3600 * 1000,
);
await sqlWrapper.sql`
UPDATE sessions SET expires_at = ${newExpiry.toISOString()} WHERE id = ${targetSessionId}
`;
try {
const ttlSeconds = Math.max(
1,
Math.floor((newExpiry.getTime() - Date.now()) / 1000),
);
await valkey.expire(targetSessionId, ttlSeconds);
} catch (_e) {}
auditWrapper.auditLog(auth.userId, "session_extended", targetSessionId, {
extended_by_hours: additionalHours,
new_expires_at: newExpiry.toISOString(),
}, getClientIp(c));
return c.json({ success: true, newExpiresAt: newExpiry.toISOString() });
});
// Revoke a specific session
app.delete("/api/sessions/:id", async (c) => {
const auth = await getAuthenticatedUser(c);
if (!auth) return c.json({ error: "Unauthorized" }, 401);
const targetSessionId = c.req.param("id");
// Verify the session belongs to the user
const session = await sqlWrapper.sql`
SELECT id FROM sessions WHERE id = ${targetSessionId} AND user_id = ${auth.userId}
`.then((res: any) => res[0]);
if (!session) {
return c.json({ error: "Session not found or access denied" }, 404);
}
// Remove from Valkey
try {
await valkey.del(targetSessionId);
} catch (err) {
console.error("Failed to delete session from cache:", err);
}
// Remove from DB (or expire it immediately)
await sqlWrapper.sql`DELETE FROM sessions WHERE id = ${targetSessionId}`;
auditWrapper.auditLog(auth.userId, "session_revoked", null, {
revoked_session_id: targetSessionId,
}, getClientIp(c));
return c.json({ success: true });
});
// --------------------------------------------------------- // ---------------------------------------------------------
// Authenticated Passkey Registration (Adding a new device) // Authenticated Passkey Registration (Adding a new device)
// --------------------------------------------------------- // ---------------------------------------------------------

View File

@ -1,143 +0,0 @@
import { Hono } from "jsr:@hono/hono@4";
import { sqlWrapper } from "../db.ts";
import {
getAppByHost,
getAuthenticatedUser,
getUserGrant,
isGlobalAdmin,
isIpAllowed,
isPathBypassed,
} from "../auth-session.ts";
import { verifyHttpSignature } from "../http_signatures.ts";
export const forwardAuthRoutes = new Hono();
// ---------------------------------------------------------
// ForwardAuth Ingress Check (Traefik Ingress Middleware)
// ---------------------------------------------------------
forwardAuthRoutes.get("/api/forward-auth", async (c) => {
const host = c.req.header("X-Forwarded-Host");
if (!host) {
return c.text("Bad Request: Missing X-Forwarded-Host header", 400);
}
// 1. Resolve Target App (Valkey -> DB)
const appRecord = await getAppByHost(host);
if (!appRecord) {
const accept = c.req.header("Accept") || "";
// If a browser is requesting a webpage on an unregistered domain, seamlessly redirect to unregistered error view
if (accept.includes("text/html")) {
const rpID = Deno.env.get("RP_ID");
const loginDomain = rpID || "auth.atyg.org";
c.header(
"Cache-Control",
"no-store, no-cache, must-revalidate, max-age=0",
);
return c.redirect(
`https://${loginDomain}/errors/unregistered?host=${
encodeURIComponent(host)
}`,
302,
);
}
// Default-Deny if app is not registered (API requests)
return c.json({ error: "Application not registered" }, 403);
}
// 1.5 Dynamic Bypass Check
const uri = c.req.header("X-Forwarded-Uri") || "/";
const clientIp = c.req.header("X-Forwarded-For") || "127.0.0.1";
const requestPath = new URL(uri, `http://${host}`).pathname;
if (
appRecord.is_public === true ||
isPathBypassed(requestPath, appRecord.bypass_paths) ||
isIpAllowed(clientIp, appRecord.allowed_cidrs)
) {
// Append standard headers even on bypass for downstream context if needed
c.header("X-Forwarded-App-Id", appRecord.id);
return c.text("OK", 200);
}
// 2. Validate Session OR HTTP Signature
const signatureInput = c.req.header("Signature-Input");
const signature = c.req.header("Signature");
if (signatureInput && signature) {
// Headless Edge Node Path (RFC 9421)
try {
const fingerprint = await verifyHttpSignature(c.req.raw);
const serviceName = `service-node:${fingerprint.substring(0, 8)}`;
const serviceId = fingerprint;
const scopes = "edge-node,daemon";
c.header("X-Forwarded-User", serviceName);
c.header("X-Forwarded-User-Id", serviceId);
c.header("X-Forwarded-Scopes", scopes);
c.header("X-Forwarded-App-Id", appRecord.id);
return c.text("OK", 200);
} catch (err: any) {
return c.text(`Unauthorized: ${err.message}`, 401);
}
}
// Standard User Session Path
const auth = await getAuthenticatedUser(c);
if (!auth) {
const accept = c.req.header("Accept") || "";
const proto = c.req.header("X-Forwarded-Proto") || "https";
const uri = c.req.header("X-Forwarded-Uri") || "/";
const originalUrl = `${proto}://${host}${uri}`;
// If a browser is requesting a webpage, seamlessly redirect to login
if (accept.includes("text/html")) {
const rpID = Deno.env.get("RP_ID");
const loginDomain = rpID || "auth.atyg.org";
c.header(
"Cache-Control",
"no-store, no-cache, must-revalidate, max-age=0",
);
return c.redirect(
`https://${loginDomain}/login?redirect=${
encodeURIComponent(originalUrl)
}`,
302,
);
}
return c.text("Unauthorized", 401);
}
const user = await sqlWrapper.sql`
SELECT id, username, account_status
FROM users
WHERE id = ${auth.userId}
`.then((res: any) => res[0]);
if (!user || user.account_status !== "active") {
return c.text("Forbidden: Account inactive", 403);
}
// 3. Resolve Grants and Roles
const globalAdmin = await isGlobalAdmin(auth.userId);
const grantRole = await getUserGrant(auth.userId, appRecord.id);
if (!globalAdmin && !grantRole) {
return c.text("Forbidden: Access denied to this application", 403);
}
const scopes = [
...new Set([grantRole, globalAdmin ? "admin" : null].filter(Boolean)),
].join(",");
// 4. Inject Headers
c.header("X-Forwarded-User", user.username);
c.header("X-Forwarded-User-Id", user.id);
c.header("X-Forwarded-Scopes", scopes);
c.header("X-Forwarded-App-Id", appRecord.id);
return c.text("OK", 200);
});

View File

@ -1,285 +0,0 @@
import { Hono } from "jsr:@hono/hono@4";
import { deleteCookie, setCookie } from "jsr:@hono/hono@4/cookie";
import { encodeHex } from "jsr:@std/encoding@1/hex";
import { sqlWrapper } from "../db.ts";
import { valkey } from "../valkey.ts";
import { getAuthenticatedUser, getCookieDomain } from "../auth-session.ts";
import { EventJoinPage } from "../../ui/components/EventJoinPage.tsx";
import { EventSplashPage } from "../../ui/components/EventSplashPage.tsx";
export const eventRoutes = new Hono();
// ---------------------------------------------------------
// Multi-Claim Event Passes & Short Code Join Portal
// ---------------------------------------------------------
eventRoutes.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);
}
});
eventRoutes.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 rpID = Deno.env.get("RP_ID");
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);
}
});
eventRoutes.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: "/" });
const rpID = Deno.env.get("RP_ID");
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);
}
});
eventRoutes.get("/join", (c) => {
return c.html(EventJoinPage());
});
eventRoutes.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);
}
});

View File

@ -1,129 +0,0 @@
import { Hono } from "jsr:@hono/hono@4";
import { deleteCookie, setCookie } from "jsr:@hono/hono@4/cookie";
import { valkey } from "../valkey.ts";
import { sqlWrapper } from "../db.ts";
import { getCookieDomain } from "../auth-session.ts";
export const passRoutes = new Hono();
// ---------------------------------------------------------
// Ephemeral 1-Click Magic Link Redemption (/pass)
// ---------------------------------------------------------
passRoutes.get("/", 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 rpID = Deno.env.get("RP_ID");
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);
}
});

View File

@ -1,239 +0,0 @@
import { Hono } from "jsr:@hono/hono@4";
import { sqlWrapper } from "../db.ts";
import { valkey } from "../valkey.ts";
import { auditWrapper } from "../audit.ts";
import { getAuthenticatedUser, getClientIp } from "../auth-session.ts";
export const sessionRoutes = new Hono();
// ---------------------------------------------------------
// Active Sessions Listing
// ---------------------------------------------------------
sessionRoutes.get("/api/sessions", async (c) => {
const auth = await getAuthenticatedUser(c);
if (!auth) return c.json({ error: "Unauthorized" }, 401);
const sessions = await sqlWrapper.sql`
SELECT id, label, is_agent, custom_scopes, last_activity_at, last_activity_action, created_at, expires_at
FROM sessions
WHERE user_id = ${auth.userId} AND expires_at > NOW()
ORDER BY created_at DESC
`;
return c.json({ sessions, currentSessionId: auth.sessionId });
});
// ---------------------------------------------------------
// Delegate a child agent session with custom lifespan and scopes
// ---------------------------------------------------------
sessionRoutes.post("/api/sessions/delegate", async (c) => {
const auth = await getAuthenticatedUser(c);
if (!auth) return c.json({ error: "Unauthorized" }, 401);
const {
label,
lifespanHours = 1,
mode = "read_only",
customScopes = [],
} = await c.req.json();
const cleanLabel = (label && typeof label === "string" && label.trim())
? label.trim()
: "AI Agent Session";
const hours = Math.min(Math.max(Number(lifespanHours) || 1, 1), 720); // Max 30 days
const expiresAt = new Date(Date.now() + hours * 3600 * 1000);
let effectiveScopes: string[] = [];
if (mode === "read_only") {
effectiveScopes = [
"read:audit",
"read:users",
"read:apps",
"read:roles",
"read:sessions",
];
} else if (mode === "operator") {
effectiveScopes = ["operator", "read:audit", "read:users", "read:apps"];
} else if (mode === "admin") {
effectiveScopes = ["*"];
} else if (mode === "custom" && Array.isArray(customScopes)) {
effectiveScopes = customScopes.map((s: string) => String(s).trim()).filter(
Boolean,
);
}
const rawBytes = new Uint8Array(32);
crypto.getRandomValues(rawBytes);
const tokenHex = Array.from(rawBytes).map((b) =>
b.toString(16).padStart(2, "0")
).join("");
const sessionId = `ay_sess_${tokenHex}`;
await sqlWrapper.sql`
INSERT INTO sessions (id, user_id, label, is_agent, custom_scopes, expires_at, created_at)
VALUES (${sessionId}, ${auth.userId}, ${cleanLabel}, true, ${effectiveScopes}, ${expiresAt.toISOString()}, NOW())
`;
try {
const sessionData = {
uuid: auth.userId,
username: auth.username,
label: cleanLabel,
isAgent: true,
customScopes: effectiveScopes,
};
await valkey.set(
sessionId,
JSON.stringify(sessionData),
"EX",
Math.floor(hours * 3600),
);
} catch (err) {
console.error("[Valkey] Failed to cache delegated session:", err);
}
auditWrapper.auditLog(auth.userId, "session_delegated", sessionId, {
label: cleanLabel,
lifespan_hours: hours,
mode,
scopes: effectiveScopes,
}, getClientIp(c));
return c.json({
success: true,
sessionId,
token: sessionId,
label: cleanLabel,
expiresAt: expiresAt.toISOString(),
scopes: effectiveScopes,
});
});
// ---------------------------------------------------------
// Update permissions on an active session
// ---------------------------------------------------------
sessionRoutes.put("/api/sessions/:id/scopes", async (c) => {
const auth = await getAuthenticatedUser(c);
if (!auth) return c.json({ error: "Unauthorized" }, 401);
const targetSessionId = c.req.param("id");
const { customScopes = [] } = await c.req.json();
const session = await sqlWrapper.sql`
SELECT id, is_agent FROM sessions WHERE id = ${targetSessionId} AND user_id = ${auth.userId}
`.then((res: any) => res[0]);
if (!session) {
return c.json({ error: "Session not found or access denied" }, 404);
}
const effectiveScopes = Array.isArray(customScopes)
? customScopes.map((s: string) => String(s).trim()).filter(Boolean)
: [];
await sqlWrapper.sql`
UPDATE sessions SET custom_scopes = ${effectiveScopes} WHERE id = ${targetSessionId}
`;
try {
const existingCached = await valkey.get(targetSessionId);
if (existingCached) {
const parsed = JSON.parse(existingCached);
parsed.customScopes = effectiveScopes;
await valkey.set(targetSessionId, JSON.stringify(parsed));
}
} catch (_e) {}
auditWrapper.auditLog(
auth.userId,
"session_scopes_updated",
targetSessionId,
{
scopes: effectiveScopes,
},
getClientIp(c),
);
return c.json({ success: true, scopes: effectiveScopes });
});
// ---------------------------------------------------------
// Extend session TTL
// ---------------------------------------------------------
sessionRoutes.post("/api/sessions/:id/extend", async (c) => {
const auth = await getAuthenticatedUser(c);
if (!auth) return c.json({ error: "Unauthorized" }, 401);
const targetSessionId = c.req.param("id");
const { extendHours = 1 } = await c.req.json();
const additionalHours = Math.max(Number(extendHours) || 1, 1);
const session = await sqlWrapper.sql`
SELECT id, expires_at FROM sessions WHERE id = ${targetSessionId} AND user_id = ${auth.userId}
`.then((res: any) => res[0]);
if (!session) {
return c.json({ error: "Session not found or access denied" }, 404);
}
const currentExpiry = new Date(session.expires_at).getTime();
const newExpiry = new Date(
Math.max(Date.now(), currentExpiry) + additionalHours * 3600 * 1000,
);
await sqlWrapper.sql`
UPDATE sessions SET expires_at = ${newExpiry.toISOString()} WHERE id = ${targetSessionId}
`;
try {
const ttlSeconds = Math.max(
1,
Math.floor((newExpiry.getTime() - Date.now()) / 1000),
);
await valkey.expire(targetSessionId, ttlSeconds);
} catch (_e) {}
auditWrapper.auditLog(auth.userId, "session_extended", targetSessionId, {
extended_by_hours: additionalHours,
new_expires_at: newExpiry.toISOString(),
}, getClientIp(c));
return c.json({ success: true, newExpiresAt: newExpiry.toISOString() });
});
// ---------------------------------------------------------
// Revoke a specific session
// ---------------------------------------------------------
sessionRoutes.delete("/api/sessions/:id", async (c) => {
const auth = await getAuthenticatedUser(c);
if (!auth) return c.json({ error: "Unauthorized" }, 401);
const targetSessionId = c.req.param("id");
const session = await sqlWrapper.sql`
SELECT id FROM sessions WHERE id = ${targetSessionId} AND user_id = ${auth.userId}
`.then((res: any) => res[0]);
if (!session) {
return c.json({ error: "Session not found or access denied" }, 404);
}
try {
await valkey.del(targetSessionId);
} catch (err) {
console.error("Failed to delete session from cache:", err);
}
await sqlWrapper.sql`DELETE FROM sessions WHERE id = ${targetSessionId}`;
auditWrapper.auditLog(auth.userId, "session_revoked", null, {
revoked_session_id: targetSessionId,
}, getClientIp(c));
return c.json({ success: true });
});

View File

@ -4,26 +4,19 @@
- `server/main.ts` - `server/main.ts`
- `ui/components/SessionsPage.tsx` - `ui/components/SessionsPage.tsx`
- `server/main.test.ts` - `server/main.test.ts`
- **Core Objective:** Implement `GET /pass?token=...` for 1-click ephemeral - **Core Objective:** Implement `GET /pass?token=...` for 1-click ephemeral session redemption and update the Sessions Hub hand-off UI with copyable magic links.
session redemption and update the Sessions Hub hand-off UI with copyable magic
links.
- **Dependencies:** `server/auth-session.ts`, `server/db.ts` - **Dependencies:** `server/auth-session.ts`, `server/db.ts`
- **Additional Important Notes:** Sets wildcard `.atyg.org` cookie, cleans - **Additional Important Notes:** Sets wildcard `.atyg.org` cookie, cleans host-only cookie, and redirects cleanly to target app domain or dashboard.
host-only cookie, and redirects cleanly to target app domain or dashboard.
--- ---
### 1. Architectural Considerations & Risks ### 1. Architectural Considerations & Risks
- **Security & Cookie Scoping:** - **Security & Cookie Scoping:**
- `/pass` must validate that the token is active and not expired before - `/pass` must validate that the token is active and not expired before issuing Set-Cookie headers.
issuing Set-Cookie headers. - Must use `getCookieDomain()` so subdomains (e.g. `ed-droid.atyg.org`) receive the session cookie immediately.
- Must use `getCookieDomain()` so subdomains (e.g. `ed-droid.atyg.org`)
receive the session cookie immediately.
- **Target URL Redirection:** - **Target URL Redirection:**
- If the session has custom scopes for an application (e.g. `app:ed-droid`), - 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`.
`/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`. - If no specific app is scoped, redirects to `/dashboard`.
--- ---

View File

@ -6,46 +6,35 @@
- `ui/components/EventJoinPage.tsx` - `ui/components/EventJoinPage.tsx`
- `ui/components/EventSplashPage.tsx` - `ui/components/EventSplashPage.tsx`
- `server/main.test.ts` - `server/main.test.ts`
- **Core Objective:** Implement Multi-Claim Event Passes with short vanity URLs - **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`).
(`/e/:slug`), universal PIN join portal (`/join`), and CLI environment 1-liner
(`/join/:slug?format=env`).
- **Dependencies:** `server/db.ts`, `server/auth-session.ts` - **Dependencies:** `server/db.ts`, `server/auth-session.ts`
- **Additional Important Notes:** Provisions isolated guest seats - **Additional Important Notes:** Provisions isolated guest seats (`guest_<slug>_<index>`) with individual sessions.
(`guest_<slug>_<index>`) with individual sessions.
--- ---
### 1. Architectural Considerations & Risks ### 1. Architectural Considerations & Risks
- **Concurrency & Seat Limits:** - **Concurrency & Seat Limits:**
- Ensure atomicity when incrementing `seats_claimed` on `event_passes` so - Ensure atomicity when incrementing `seats_claimed` on `event_passes` so events with strict seat limits (e.g. 50 seats) do not oversubscribe.
events with strict seat limits (e.g. 50 seats) do not oversubscribe.
- **Multi-Channel Accessibility:** - **Multi-Channel Accessibility:**
- Support web browser UI (`/e/:slug`, `/join`), JSON API (`POST /api/join`), - Support web browser UI (`/e/:slug`, `/join`), JSON API (`POST /api/join`), and CLI environment output (`GET /join/:slug?format=env`).
and CLI environment output (`GET /join/:slug?format=env`).
- **Session Isolation:** - **Session Isolation:**
- Each attendee gets their own dedicated guest session and UUID, preventing - Each attendee gets their own dedicated guest session and UUID, preventing state collision in shared sandbox apps.
state collision in shared sandbox apps.
--- ---
### 2. Proposed Implementation ### 2. Proposed Implementation
1. **Database Schema (`server/db.ts`):** 1. **Database Schema (`server/db.ts`):**
- Create `event_passes` table with columns: `id`, `slug`, `pin_code`, `name`, - 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`.
`app_id`, `role`, `max_seats`, `seats_claimed`, `lifespan_hours`,
`created_by`, `is_active`, `expires_at`, `created_at`.
2. **API Endpoints (`server/main.ts`):** 2. **API Endpoints (`server/main.ts`):**
- `POST /api/events`: Create a new event pass. - `POST /api/events`: Create a new event pass.
- `GET /e/:slug`: Web landing splash with "Enter Workshop" button. - `GET /e/:slug`: Web landing splash with "Enter Workshop" button.
- `GET /join`: Universal PIN / code entry page. - `GET /join`: Universal PIN / code entry page.
- `POST /api/join`: Redeems code or PIN, creates isolated guest user and - `POST /api/join`: Redeems code or PIN, creates isolated guest user and session, sets cookie or returns JSON.
session, sets cookie or returns JSON. - `GET /join/:slug`: Returns CLI 1-liner (`export AUTH_YES_TOKEN="..."` when `?format=env`).
- `GET /join/:slug`: Returns CLI 1-liner (`export AUTH_YES_TOKEN="..."` when
`?format=env`).
3. **SSR UI Components:** 3. **SSR UI Components:**
- `ui/components/EventJoinPage.tsx` - `ui/components/EventJoinPage.tsx`
- `ui/components/EventSplashPage.tsx` - `ui/components/EventSplashPage.tsx`
4. **Automated Tests (`server/main.test.ts`):** 4. **Automated Tests (`server/main.test.ts`):**
- Test event creation, PIN redemption, seat limit capping, and CLI output - Test event creation, PIN redemption, seat limit capping, and CLI output format.
format.

View File

@ -5,39 +5,28 @@
- `ui/components/SessionsPage.tsx` - `ui/components/SessionsPage.tsx`
- `ui/mod.ts` - `ui/mod.ts`
- `server/main.test.ts` - `server/main.test.ts`
- **Core Objective:** Add real-time event cockpit deck to the Sessions page with - **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`).
active seat counters, PIN display, time extension, and 1-tap master kill - **Dependencies:** `tasks/new/2026-0825.02.gem.feat.event-passes.multi-claim-workshops-and-kiosks-0046.md`
switch (`/api/events/:id/end`). - **Additional Important Notes:** Sub-millisecond mass revocation of all event guest sessions via Valkey RESP3 push tracking.
- **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 ### 1. Architectural Considerations & Risks
- **Mass Revocation Performance:** - **Mass Revocation Performance:**
- When the organizer clicks "End Workshop & Revoke All", the endpoint must - 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.
delete all associated guest session IDs from both PostgreSQL and Valkey,
emitting invalidation events to all connected nodes immediately.
- **Observability:** - **Observability:**
- Show real-time seat claim progress bar (`38 / 50 claimed`) and event - Show real-time seat claim progress bar (`38 / 50 claimed`) and event expiration timer.
expiration timer.
--- ---
### 2. Proposed Implementation ### 2. Proposed Implementation
1. **Backend Endpoints (`server/main.ts`):** 1. **Backend Endpoints (`server/main.ts`):**
- `POST /api/events/:id/end`: Closes the event, deletes all guest sessions - `POST /api/events/:id/end`: Closes the event, deletes all guest sessions under the event, and purges Valkey cache.
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/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`):** 2. **UI & Data Query (`ui/mod.ts`, `ui/components/SessionsPage.tsx`):**
- Query `event_passes` in `/dashboard/sessions` and render the **Event - Query `event_passes` in `/dashboard/sessions` and render the **Event Cockpit Deck**.
Cockpit Deck**. - Display progress bar, quick copy buttons for PIN and Short URL, `[+1h Extend]` and `[🔴 End Workshop & Revoke All]`.
- 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`):** 3. **Automated Tests (`server/main.test.ts`):**
- Test event extension and master kill-switch mass revocation. - Test event extension and master kill-switch mass revocation.

View File

@ -1,123 +0,0 @@
import { Layout } from "./Layout.tsx";
export const EventJoinPage = () => {
return (
<Layout title="Join Event & Workshop">
<div>
<div class="brand-header">
<div
class="brand-logo"
style="background: var(--primary-light); color: var(--primary);"
>
<svg
width="26"
height="26"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2.5"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M2 9a3 3 0 0 1 0 6v2a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2v-2a3 3 0 0 1 0-6V7a2 2 0 0 0-2-2H4a2 2 0 0 0-2 2Z">
</path>
<path d="M13 5v2"></path>
<path d="M13 17v2"></path>
<path d="M13 11v2"></path>
</svg>
</div>
<h1>Join Event or Workshop</h1>
<p class="subtitle">
Enter your event PIN code or slug to claim an instant sandbox seat.
</p>
</div>
<form id="joinForm" onsubmit="handleJoin(event)">
<div style="margin-bottom: 1.25rem;">
<label style="display: block; font-weight: 600; margin-bottom: 0.35rem; font-size: 0.875rem; color: var(--text-secondary);">
Event PIN or Slug Code
</label>
<input
type="text"
id="eventCode"
placeholder="e.g. 749-123 or deno-lab"
required
autofocus
style="width: 100%; font-size: 1.1rem; text-align: center; letter-spacing: 0.05em; font-weight: 600; min-height: 48px;"
/>
</div>
<div
id="joinNotice"
style="display: none; margin-bottom: 1rem; padding: 0.75rem 1rem; border-radius: var(--radius-md); font-size: 0.9rem;"
/>
<button
type="submit"
id="joinBtn"
class="btn-primary"
style="width: 100%; min-height: 48px; font-size: 1rem;"
>
Enter Workshop
</button>
</form>
<div style="margin-top: 1.5rem; text-align: center; font-size: 0.85rem; color: var(--text-muted);">
Looking for standard sign in?{" "}
<a
href="/login"
style="color: var(--primary); text-decoration: none; font-weight: 600;"
>
Sign in with Passkey
</a>
</div>
</div>
<script
dangerouslySetInnerHTML={{
__html: `
async function handleJoin(e) {
e.preventDefault();
const code = document.getElementById('eventCode').value.trim();
if (!code) return;
const btn = document.getElementById('joinBtn');
const notice = document.getElementById('joinNotice');
btn.disabled = true;
btn.textContent = 'Claiming Seat...';
notice.style.display = 'none';
try {
const res = await fetch('/api/join', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ code }),
});
const data = await res.json();
if (res.ok) {
window.location.href = data.redirectUrl || '/dashboard';
} else {
notice.textContent = data.error || 'Invalid event code or workshop is full';
notice.style.display = 'block';
notice.style.background = 'var(--danger-bg)';
notice.style.color = 'var(--danger-text)';
notice.style.border = '1px solid var(--danger-border)';
btn.disabled = false;
btn.textContent = '⚡ Enter Workshop';
}
} catch (err) {
notice.textContent = 'Network error connecting to event';
notice.style.display = 'block';
notice.style.background = 'var(--danger-bg)';
notice.style.color = 'var(--danger-text)';
notice.style.border = '1px solid var(--danger-border)';
btn.disabled = false;
btn.textContent = '⚡ Enter Workshop';
}
}
`,
}}
/>
</Layout>
);
};

View File

@ -1,161 +0,0 @@
import { Layout } from "./Layout.tsx";
export const EventSplashPage = ({ event }: { event: any }) => {
const maxSeats = Number(event.max_seats) || 0;
const seatsClaimed = Number(event.seats_claimed) || 0;
const isFull = maxSeats > 0 && seatsClaimed >= maxSeats;
const seatsRemaining = maxSeats > 0
? Math.max(0, maxSeats - seatsClaimed)
: null;
return (
<Layout title={`Join ${event.name}`}>
<div>
<div class="brand-header">
<div
class="brand-logo"
style="background: var(--primary-light); color: var(--primary);"
>
<svg
width="26"
height="26"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2.5"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M2 9a3 3 0 0 1 0 6v2a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2v-2a3 3 0 0 1 0-6V7a2 2 0 0 0-2-2H4a2 2 0 0 0-2 2Z">
</path>
<path d="M13 5v2"></path>
<path d="M13 17v2"></path>
<path d="M13 11v2"></path>
</svg>
</div>
<h1>{event.name}</h1>
<p class="subtitle">
You've been invited to join this event sandbox session.
</p>
</div>
<div
class="card"
style="margin-bottom: 1.5rem; text-align: center; border: 1px solid var(--border-subtle); background: var(--surface-muted);"
>
<div style="display: flex; justify-content: center; gap: 0.5rem; margin-bottom: 0.75rem; flex-wrap: wrap;">
{isFull
? (
<span class="badge badge-danger">
Workshop Full ({seatsClaimed}/{maxSeats})
</span>
)
: seatsRemaining !== null
? (
<span class="badge badge-success">
{seatsRemaining} seats remaining ({seatsClaimed}/{maxSeats})
</span>
)
: (
<span class="badge badge-success">
{seatsClaimed} attendees active (Open Access)
</span>
)}
<span class="badge badge-info">
{event.lifespan_hours || 3}h Session
</span>
</div>
{event.pin_code && (
<div style="font-size: 0.85rem; color: var(--text-secondary); margin-top: 0.5rem;">
Event PIN:{" "}
<code style="font-weight: 700; color: var(--primary); font-size: 0.95rem;">
{event.pin_code}
</code>
</div>
)}
</div>
<div
id="splashNotice"
style="display: none; margin-bottom: 1rem; padding: 0.75rem 1rem; border-radius: var(--radius-md); font-size: 0.9rem;"
/>
{!isFull
? (
<button
type="button"
id="joinSplashBtn"
class="btn-primary"
style="width: 100%; min-height: 52px; font-size: 1.05rem; box-shadow: var(--shadow-sm);"
onclick={`handleSplashJoin('${event.slug}')`}
>
Enter Workshop & Claim Seat
</button>
)
: (
<button
type="button"
class="btn-outline"
disabled
style="width: 100%; min-height: 52px; opacity: 0.6; cursor: not-allowed;"
>
Workshop at Capacity
</button>
)}
<div style="margin-top: 1.5rem; text-align: center; font-size: 0.85rem; color: var(--text-muted);">
Standard account login?{" "}
<a
href="/login"
style="color: var(--primary); text-decoration: none; font-weight: 600;"
>
Sign in with Passkey
</a>
</div>
</div>
<script
dangerouslySetInnerHTML={{
__html: `
async function handleSplashJoin(slug) {
const btn = document.getElementById('joinSplashBtn');
const notice = document.getElementById('splashNotice');
btn.disabled = true;
btn.textContent = 'Claiming Seat...';
notice.style.display = 'none';
try {
const res = await fetch('/api/join', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ code: slug }),
});
const data = await res.json();
if (res.ok) {
window.location.href = data.redirectUrl || '/dashboard';
} else {
notice.textContent = data.error || 'Failed to join event';
notice.style.display = 'block';
notice.style.background = 'var(--danger-bg)';
notice.style.color = 'var(--danger-text)';
notice.style.border = '1px solid var(--danger-border)';
btn.disabled = false;
btn.textContent = '⚡ Enter Workshop & Claim Seat';
}
} catch (err) {
notice.textContent = 'Network error connecting to event';
notice.style.display = 'block';
notice.style.background = 'var(--danger-bg)';
notice.style.color = 'var(--danger-text)';
notice.style.border = '1px solid var(--danger-border)';
btn.disabled = false;
btn.textContent = '⚡ Enter Workshop & Claim Seat';
}
}
`,
}}
/>
</Layout>
);
};

View File

@ -284,28 +284,6 @@ export const SessionsPage = ({
</p> </p>
<div style="display: flex; flex-direction: column; gap: 0.75rem; margin-bottom: 1rem;"> <div style="display: flex; flex-direction: column; gap: 0.75rem; margin-bottom: 1rem;">
{/* 1-Click Magic Link */}
<div>
<label style="display: block; font-size: 0.75rem; font-weight: 700; text-transform: uppercase; color: var(--text-muted); margin-bottom: 0.25rem;">
1-Click Magic Link (Web / Friend / Interview)
</label>
<div style="display: flex; gap: 0.5rem;">
<code
id="handoffMagicLinkText"
style="flex: 1; padding: 0.5rem 0.75rem; background: var(--surface-muted); border: 1px solid var(--border-subtle); border-radius: var(--radius-sm); font-family: monospace; font-size: 0.85rem; overflow-x: auto; white-space: nowrap; color: var(--success);"
>
</code>
<button
type="button"
class="btn-primary"
style="min-height: 36px; padding: 0 0.85rem; font-size: 0.8rem;"
onclick="copyHandoff('magic')"
>
Copy Link
</button>
</div>
</div>
{/* 1-Tap Copy CLI */} {/* 1-Tap Copy CLI */}
<div> <div>
<label style="display: block; font-size: 0.75rem; font-weight: 700; text-transform: uppercase; color: var(--text-muted); margin-bottom: 0.25rem;"> <label style="display: block; font-size: 0.75rem; font-weight: 700; text-transform: uppercase; color: var(--text-muted); margin-bottom: 0.25rem;">
@ -314,12 +292,12 @@ export const SessionsPage = ({
<div style="display: flex; gap: 0.5rem;"> <div style="display: flex; gap: 0.5rem;">
<code <code
id="handoffCliText" id="handoffCliText"
style="flex: 1; padding: 0.5rem 0.75rem; background: var(--surface-muted); border: 1px solid var(--border-subtle); border-radius: var(--radius-sm); font-family: monospace; font-size: 0.85rem; overflow-x: auto; white-space: nowrap; color: var(--text-primary);" style="flex: 1; padding: 0.5rem 0.75rem; background: var(--surface-muted); border: 1px solid var(--border-subtle); border-radius: var(--radius-sm); font-family: monospace; font-size: 0.85rem; overflow-x: auto; white-space: nowrap; color: var(--primary);"
> >
</code> </code>
<button <button
type="button" type="button"
class="btn-outline" class="btn-primary"
style="min-height: 36px; padding: 0 0.85rem; font-size: 0.8rem;" style="min-height: 36px; padding: 0 0.85rem; font-size: 0.8rem;"
onclick="copyHandoff('cli')" onclick="copyHandoff('cli')"
> >
@ -793,7 +771,6 @@ export const SessionsPage = ({
const data = await res.json(); const data = await res.json();
if (res.ok) { if (res.ok) {
lastMintedToken = data.token; lastMintedToken = data.token;
document.getElementById('handoffMagicLinkText').textContent = window.location.origin + '/pass?token=' + data.token;
document.getElementById('handoffCliText').textContent = 'export AUTH_YES_TOKEN="' + data.token + '"'; document.getElementById('handoffCliText').textContent = 'export AUTH_YES_TOKEN="' + data.token + '"';
document.getElementById('handoffCurlText').textContent = '-H "Authorization: Bearer ' + data.token + '"'; document.getElementById('handoffCurlText').textContent = '-H "Authorization: Bearer ' + data.token + '"';
document.getElementById('handoffModal').style.display = 'block'; document.getElementById('handoffModal').style.display = 'block';
@ -811,10 +788,8 @@ export const SessionsPage = ({
function copyHandoff(type) { function copyHandoff(type) {
let text = ''; let text = '';
if (type === 'link') text = document.getElementById('handoffLinkText').textContent;
if (type === 'cli') text = document.getElementById('handoffCliText').textContent; if (type === 'cli') text = document.getElementById('handoffCliText').textContent;
if (type === 'curl') text = document.getElementById('handoffCurlText').textContent; if (type === 'curl') text = document.getElementById('handoffCurlText').textContent;
if (type === 'magic') text = document.getElementById('handoffMagicLinkText').textContent;
navigator.clipboard.writeText(text); navigator.clipboard.writeText(text);
showNotice('Copied to clipboard: ' + text, false); showNotice('Copied to clipboard: ' + text, false);
} }