feat: implement ephemeral 1-click magic link redemption (/pass)
Implements the GET /pass?token=... endpoint for validating session tokens, resolving the correct target application domain dynamically, and routing users seamlessly using ephemeral 1-click magic links. Also updates the Sessions Hub UI hand-off modal to display the 1-Click Magic Link and adds full test coverage. Co-authored-by: mrteye <1945243+mrteye@users.noreply.github.com>
This commit is contained in:
parent
7033c532b2
commit
2d34aa15a7
@ -1,16 +1,21 @@
|
||||
# Dual-Audience Development Guide: Agent-First APIs & Mobile-First SSR UI
|
||||
|
||||
This guide outlines the architectural blueprint and best practices for building modern services within the **Auth-Yes Zero-Trust Ecosystem**.
|
||||
This guide outlines the architectural blueprint and best practices for building
|
||||
modern services within the **Auth-Yes Zero-Trust Ecosystem**.
|
||||
|
||||
---
|
||||
|
||||
## 1. The Core Philosophy: Dual-Audience Architecture
|
||||
|
||||
Every modern application should be built for **two primary audiences**:
|
||||
|
||||
1. **🤖 AI Agents & Workloads (Primary Data Consumer):**
|
||||
- Headless execution via **Model Context Protocol (MCP)**, REST APIs, or ConnectRPC.
|
||||
- Machine-readable, high-density JSON/RPC responses with zero HTML/CSS clutter.
|
||||
- Authentication via **Delegated Bearer Tokens (`ay_sess_...`)** or mTLS SPIFFE workload identities.
|
||||
- Headless execution via **Model Context Protocol (MCP)**, REST APIs, or
|
||||
ConnectRPC.
|
||||
- Machine-readable, high-density JSON/RPC responses with zero HTML/CSS
|
||||
clutter.
|
||||
- Authentication via **Delegated Bearer Tokens (`ay_sess_...`)** or mTLS
|
||||
SPIFFE workload identities.
|
||||
2. **📱 Humans on Mobile Devices (Primary UI Consumer):**
|
||||
- Ultra-fast, zero-friction **Server-Side Rendered (SSR) JSX** touch cards.
|
||||
- Passkey (WebAuthn) biometric authentication.
|
||||
@ -21,26 +26,26 @@ Every modern application should be built for **two primary audiences**:
|
||||
## 2. Architectural Blueprint for Subsidiary Apps (e.g. `ed-droid`)
|
||||
|
||||
```
|
||||
+---------------------------------------+
|
||||
| Traefik Edge Ingress |
|
||||
+---------------------------------------+
|
||||
|
|
||||
ForwardAuth Check | (Injects X-Forwarded-*)
|
||||
+------------------------+------------------------+
|
||||
| |
|
||||
v v
|
||||
+-----------------------+ +-----------------------+
|
||||
| Auth-Yes Service | | Subsidiary App |
|
||||
| (Validates Session / | | (e.g., ed-droid) |
|
||||
| Bearer Token) | +-----------------------+
|
||||
+-----------------------+ |
|
||||
|
|
||||
+----------------------------+----------------------------+
|
||||
| |
|
||||
v v
|
||||
[ /api/* JSON Endpoints ] [ SSR HTML Mobile Cards ]
|
||||
- Served for AI Agents & MCP - Served for Human Browsers
|
||||
- Filtered by Scopes / Roles - Clean, high-contrast UI
|
||||
+---------------------------------------+
|
||||
| Traefik Edge Ingress |
|
||||
+---------------------------------------+
|
||||
|
|
||||
ForwardAuth Check | (Injects X-Forwarded-*)
|
||||
+------------------------+------------------------+
|
||||
| |
|
||||
v v
|
||||
+-----------------------+ +-----------------------+
|
||||
| Auth-Yes Service | | Subsidiary App |
|
||||
| (Validates Session / | | (e.g., ed-droid) |
|
||||
| Bearer Token) | +-----------------------+
|
||||
+-----------------------+ |
|
||||
|
|
||||
+----------------------------+----------------------------+
|
||||
| |
|
||||
v v
|
||||
[ /api/* JSON Endpoints ] [ SSR HTML Mobile Cards ]
|
||||
- Served for AI Agents & MCP - Served for Human Browsers
|
||||
- Filtered by Scopes / Roles - Clean, high-contrast UI
|
||||
```
|
||||
|
||||
---
|
||||
@ -48,22 +53,30 @@ Every modern application should be built for **two primary audiences**:
|
||||
## 3. How to Build an "Agent-First" Subsidiary App
|
||||
|
||||
### Step 1: Ingress Protection via ForwardAuth
|
||||
In your Traefik/Compose configuration, protect your service domain with Auth-Yes ForwardAuth middleware:
|
||||
|
||||
In your Traefik/Compose configuration, protect your service domain with Auth-Yes
|
||||
ForwardAuth middleware:
|
||||
|
||||
```yaml
|
||||
labels:
|
||||
- "traefik.http.routers.ed-droid.middlewares=auth-yes-forwardauth@docker"
|
||||
```
|
||||
|
||||
When requests arrive:
|
||||
* Traefik queries `http://auth-api:8000/api/forward-auth`.
|
||||
* Auth-Yes validates either the browser's `session_id` cookie **or** the incoming `Authorization: Bearer ay_sess_...` token.
|
||||
* If authorized, Traefik injects:
|
||||
|
||||
- Traefik queries `http://auth-api:8000/api/forward-auth`.
|
||||
- Auth-Yes validates either the browser's `session_id` cookie **or** the
|
||||
incoming `Authorization: Bearer ay_sess_...` token.
|
||||
- If authorized, Traefik injects:
|
||||
- `X-Forwarded-User: <username>`
|
||||
- `X-Forwarded-User-Id: <uuid>`
|
||||
- `X-Forwarded-Scopes: <scope1,scope2>`
|
||||
- `X-Forwarded-App-Id: <app_id>`
|
||||
|
||||
### Step 2: Implement Clean JSON API Routes
|
||||
|
||||
Provide standard JSON endpoints for all core operations:
|
||||
|
||||
```typescript
|
||||
import { Hono } from "jsr:@hono/hono";
|
||||
|
||||
@ -78,13 +91,19 @@ app.get("/api/fleet", (c) => {
|
||||
```
|
||||
|
||||
### Step 3: Progressive Content Negotiation (Optional)
|
||||
If a route serves both humans and AI agents without separate `/api` prefixes, inspect the `Accept` header or `?format=json` query:
|
||||
|
||||
If a route serves both humans and AI agents without separate `/api` prefixes,
|
||||
inspect the `Accept` header or `?format=json` query:
|
||||
|
||||
```typescript
|
||||
app.get("/ships/:id", (c) => {
|
||||
const ship = getShip(c.req.param("id"));
|
||||
|
||||
// If requested by an agent or CLI:
|
||||
if (c.req.header("Accept")?.includes("application/json") || c.req.query("format") === "json") {
|
||||
if (
|
||||
c.req.header("Accept")?.includes("application/json") ||
|
||||
c.req.query("format") === "json"
|
||||
) {
|
||||
return c.json(ship);
|
||||
}
|
||||
|
||||
@ -97,11 +116,14 @@ app.get("/ships/:id", (c) => {
|
||||
|
||||
## 4. Connecting AI Agents via Model Context Protocol (MCP)
|
||||
|
||||
To expose your subsidiary applications to AI assistants (Antigravity, Jules, Claude Desktop):
|
||||
To expose your subsidiary applications to AI assistants (Antigravity, Jules,
|
||||
Claude Desktop):
|
||||
|
||||
1. **Mint a Delegated Session in Auth-Yes:**
|
||||
- Go to `https://auth.atyg.org/dashboard/sessions` $\rightarrow$ Click **`+ Delegate Agent Session`**.
|
||||
- Set Label: `"Antigravity Assistant"`, Lifespan: `12 Hours`, Scope: `ed-droid`.
|
||||
- Go to `https://auth.atyg.org/dashboard/sessions` $\rightarrow$ Click
|
||||
**`+ Delegate Agent Session`**.
|
||||
- Set Label: `"Antigravity Assistant"`, Lifespan: `12 Hours`, Scope:
|
||||
`ed-droid`.
|
||||
- Copy the CLI export string.
|
||||
|
||||
2. **Configure the MCP Server:**
|
||||
@ -110,7 +132,11 @@ To expose your subsidiary applications to AI assistants (Antigravity, Jules, Cla
|
||||
"mcpServers": {
|
||||
"ed-droid": {
|
||||
"command": "deno",
|
||||
"args": ["run", "-A", "https://git.atyg.org/tylerg/ed-droid/raw/branch/main/mcp/server.ts"],
|
||||
"args": [
|
||||
"run",
|
||||
"-A",
|
||||
"https://git.atyg.org/tylerg/ed-droid/raw/branch/main/mcp/server.ts"
|
||||
],
|
||||
"env": {
|
||||
"AUTH_YES_TOKEN": "ay_sess_8de186f564d7..."
|
||||
}
|
||||
@ -119,26 +145,33 @@ To expose your subsidiary applications to AI assistants (Antigravity, Jules, Cla
|
||||
}
|
||||
```
|
||||
|
||||
3. **Tool Call Execution:**
|
||||
The MCP server attaches `Authorization: Bearer $AUTH_YES_TOKEN` to all internal fetch calls, gaining instant authorized access to fleet telemetry and data with full Merkle audit attribution!
|
||||
3. **Tool Call Execution:** The MCP server attaches
|
||||
`Authorization: Bearer $AUTH_YES_TOKEN` to all internal fetch calls, gaining
|
||||
instant authorized access to fleet telemetry and data with full Merkle audit
|
||||
attribution!
|
||||
|
||||
---
|
||||
|
||||
## 5. Ephemeral Guest & Support Passes (Magic 1-Click Links)
|
||||
|
||||
In addition to Agent Bearer Tokens, Auth-Yes supports **Ephemeral Magic Passes** for friends and external support technicians:
|
||||
In addition to Agent Bearer Tokens, Auth-Yes supports **Ephemeral Magic Passes**
|
||||
for friends and external support technicians:
|
||||
|
||||
### The Flow:
|
||||
|
||||
1. **Spawn Pass:** Under Sessions, click **`+ Spawn Guest / Support Pass`**:
|
||||
- **Label:** *"Friend Demo - Elite Dangerous Fleet"*
|
||||
- **Lifespan:** *2 Hours* (auto-expires)
|
||||
- **Label:** _"Friend Demo - Elite Dangerous Fleet"_
|
||||
- **Lifespan:** _2 Hours_ (auto-expires)
|
||||
- **Target App:** `ed-droid.atyg.org`
|
||||
2. **Share 1-Click Link:**
|
||||
`https://auth.atyg.org/pass?token=ay_pass_9f8a7b6c...`
|
||||
3. **Instant Redemption:**
|
||||
- When opened in any browser, Auth-Yes automatically sets the `.atyg.org` session cookie with restricted scopes.
|
||||
- When opened in any browser, Auth-Yes automatically sets the `.atyg.org`
|
||||
session cookie with restricted scopes.
|
||||
- The browser is immediately redirected to `https://ed-droid.atyg.org`.
|
||||
- **Zero friction:** No passkeys to register, no passwords, no email confirmation.
|
||||
- **Zero friction:** No passkeys to register, no passwords, no email
|
||||
confirmation.
|
||||
4. **Security & Control:**
|
||||
- The guest only has access to the specified target app.
|
||||
- The pass is visible on your Sessions dashboard in real time with an instant **`[Revoke]`** button.
|
||||
- The pass is visible on your Sessions dashboard in real time with an instant
|
||||
**`[Revoke]`** button.
|
||||
|
||||
@ -1,86 +1,117 @@
|
||||
# Ephemeral Magic Passes & Multi-Claim Event Passes Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use
|
||||
> superpowers:subagent-driven-development (recommended) or
|
||||
> superpowers:executing-plans to implement this plan task-by-task. Steps use
|
||||
> checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Implement 1-on-1 Ephemeral Magic Links, Multi-Claim Event/Workshop Passes (with short codes, PINs, short URLs, and CLI 1-liners), and a real-time Event Cockpit with a master kill switch.
|
||||
**Goal:** Implement 1-on-1 Ephemeral Magic Links, Multi-Claim Event/Workshop
|
||||
Passes (with short codes, PINs, short URLs, and CLI 1-liners), and a real-time
|
||||
Event Cockpit with a master kill switch.
|
||||
|
||||
**Architecture:** Extend the Auth-Yes zero-trust session engine with a dedicated event registry in PostgreSQL and Valkey. Provide a unified 1-click redemption endpoint (`GET /pass`), a short-code/PIN join portal (`GET /join`, `GET /e/:slug`), CLI environment streams (`GET /join/:slug?format=env`), and live seat metrics with sub-millisecond batch revocation via Valkey RESP3 push tracking.
|
||||
**Architecture:** Extend the Auth-Yes zero-trust session engine with a dedicated
|
||||
event registry in PostgreSQL and Valkey. Provide a unified 1-click redemption
|
||||
endpoint (`GET /pass`), a short-code/PIN join portal (`GET /join`,
|
||||
`GET /e/:slug`), CLI environment streams (`GET /join/:slug?format=env`), and
|
||||
live seat metrics with sub-millisecond batch revocation via Valkey RESP3 push
|
||||
tracking.
|
||||
|
||||
**Tech Stack:** Deno 2.x, TypeScript 5.x, Hono SSR JSX (React-free), PostgreSQL 18, Valkey 8, Traefik ForwardAuth.
|
||||
**Tech Stack:** Deno 2.x, TypeScript 5.x, Hono SSR JSX (React-free), PostgreSQL
|
||||
18, Valkey 8, Traefik ForwardAuth.
|
||||
|
||||
**Spec:** [DUAL_AUDIENCE_DEVELOPMENT_GUIDE.md](../../DUAL_AUDIENCE_DEVELOPMENT_GUIDE.md)
|
||||
**Spec:**
|
||||
[DUAL_AUDIENCE_DEVELOPMENT_GUIDE.md](../../DUAL_AUDIENCE_DEVELOPMENT_GUIDE.md)
|
||||
|
||||
## Global Constraints
|
||||
|
||||
- Strictly zero React dependencies; use pure Hono SSR JSX.
|
||||
- Zero-dependency SDK in `sdk/`.
|
||||
- All cookie mutations must delete host-only cookies and set wildcard `.atyg.org` domain cookies.
|
||||
- Every commit and change must be pushed to both GitHub (`origin`) and Gitea (`gitea`).
|
||||
- Quality gates must pass: `deno fmt`, `deno task lint`, `deno task check`, `deno task test`.
|
||||
- All cookie mutations must delete host-only cookies and set wildcard
|
||||
`.atyg.org` domain cookies.
|
||||
- Every commit and change must be pushed to both GitHub (`origin`) and Gitea
|
||||
(`gitea`).
|
||||
- Quality gates must pass: `deno fmt`, `deno task lint`, `deno task check`,
|
||||
`deno task test`.
|
||||
|
||||
---
|
||||
|
||||
### Task 1: 1-on-1 Ephemeral Magic Link Redemption (`/pass`)
|
||||
|
||||
**Files:**
|
||||
|
||||
- Modify: `server/main.ts`
|
||||
- Modify: `ui/components/SessionsPage.tsx`
|
||||
- Test: `server/main.test.ts`
|
||||
|
||||
**Interfaces:**
|
||||
|
||||
- Consumes: `extractAllSessionIds(c)`, `valkey.get()`, `sqlWrapper.sql`
|
||||
- Produces: `GET /pass?token=...` endpoint and updated Hand-Off Modal in `SessionsPage.tsx`
|
||||
- Produces: `GET /pass?token=...` endpoint and updated Hand-Off Modal in
|
||||
`SessionsPage.tsx`
|
||||
|
||||
- [ ] **Step 1: Write the failing test for `GET /pass`**
|
||||
|
||||
```typescript
|
||||
// in server/main.test.ts
|
||||
Deno.test("GET /pass - Ephemeral Magic Link 1-Click Redemption", async (t) => {
|
||||
await t.step("Valid token sets wildcard cookie and redirects to target app", async () => {
|
||||
const valkeyStub = stub(valkey, "get", (key: any) => {
|
||||
if (String(key) === "ay_sess_valid_pass") {
|
||||
return Promise.resolve(
|
||||
JSON.stringify({ uuid: "guest-uuid", username: "guest_user", customScopes: ["app:ed-droid"] }),
|
||||
);
|
||||
}
|
||||
return Promise.resolve(null);
|
||||
});
|
||||
|
||||
const originalSql = sqlWrapper.sql;
|
||||
sqlWrapper.sql = ((_query: any) => {
|
||||
return Promise.resolve([{ domain: "ed-droid.atyg.org" }]);
|
||||
}) as any;
|
||||
|
||||
try {
|
||||
const res = await app.request("/pass?token=ay_sess_valid_pass", {
|
||||
method: "GET",
|
||||
await t.step(
|
||||
"Valid token sets wildcard cookie and redirects to target app",
|
||||
async () => {
|
||||
const valkeyStub = stub(valkey, "get", (key: any) => {
|
||||
if (String(key) === "ay_sess_valid_pass") {
|
||||
return Promise.resolve(
|
||||
JSON.stringify({
|
||||
uuid: "guest-uuid",
|
||||
username: "guest_user",
|
||||
customScopes: ["app:ed-droid"],
|
||||
}),
|
||||
);
|
||||
}
|
||||
return Promise.resolve(null);
|
||||
});
|
||||
assertEquals(res.status, 302);
|
||||
const setCookie = res.headers.get("set-cookie") || "";
|
||||
assert(setCookie.includes("session_id=ay_sess_valid_pass"));
|
||||
assert(res.headers.get("location")?.includes("ed-droid.atyg.org") || res.headers.get("location") === "/dashboard");
|
||||
} finally {
|
||||
sqlWrapper.sql = originalSql;
|
||||
valkeyStub.restore();
|
||||
}
|
||||
});
|
||||
|
||||
const originalSql = sqlWrapper.sql;
|
||||
sqlWrapper.sql = ((_query: any) => {
|
||||
return Promise.resolve([{ domain: "ed-droid.atyg.org" }]);
|
||||
}) as any;
|
||||
|
||||
try {
|
||||
const res = await app.request("/pass?token=ay_sess_valid_pass", {
|
||||
method: "GET",
|
||||
});
|
||||
assertEquals(res.status, 302);
|
||||
const setCookie = res.headers.get("set-cookie") || "";
|
||||
assert(setCookie.includes("session_id=ay_sess_valid_pass"));
|
||||
assert(
|
||||
res.headers.get("location")?.includes("ed-droid.atyg.org") ||
|
||||
res.headers.get("location") === "/dashboard",
|
||||
);
|
||||
} finally {
|
||||
sqlWrapper.sql = originalSql;
|
||||
valkeyStub.restore();
|
||||
}
|
||||
},
|
||||
);
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run test to verify it fails**
|
||||
Run: `deno test server/main.test.ts --filter "Ephemeral Magic Link"`
|
||||
Expected: FAIL (404 Not Found on `/pass`)
|
||||
- [ ] **Step 2: Run test to verify it fails** Run:
|
||||
`deno test server/main.test.ts --filter "Ephemeral Magic Link"` Expected:
|
||||
FAIL (404 Not Found on `/pass`)
|
||||
|
||||
- [ ] **Step 3: Implement `GET /pass` in `server/main.ts`**
|
||||
Extract token from `?token=...`, validate against Valkey/DB, set cookie with `getCookieDomain()`, resolve target app domain if scoped to a specific app, and return `c.redirect(targetUrl)`.
|
||||
- [ ] **Step 3: Implement `GET /pass` in `server/main.ts`** Extract token from
|
||||
`?token=...`, validate against Valkey/DB, set cookie with
|
||||
`getCookieDomain()`, resolve target app domain if scoped to a specific
|
||||
app, and return `c.redirect(targetUrl)`.
|
||||
|
||||
- [ ] **Step 4: Update `ui/components/SessionsPage.tsx`**
|
||||
Add the **"1-Click Magic Link"** copy card tab alongside the CLI and cURL header tabs.
|
||||
- [ ] **Step 4: Update `ui/components/SessionsPage.tsx`** Add the **"1-Click
|
||||
Magic Link"** copy card tab alongside the CLI and cURL header tabs.
|
||||
|
||||
- [ ] **Step 5: Run tests to verify they pass**
|
||||
Run: `deno task test`
|
||||
Expected: PASS (All tests passing)
|
||||
- [ ] **Step 5: Run tests to verify they pass** Run: `deno task test` Expected:
|
||||
PASS (All tests passing)
|
||||
|
||||
- [ ] **Step 6: Commit and Push**
|
||||
|
||||
```bash
|
||||
git add server/main.ts server/main.test.ts ui/components/SessionsPage.tsx
|
||||
git commit -m "feat(pass): implement 1-click ephemeral magic link redemption route"
|
||||
@ -92,6 +123,7 @@ git push origin main && git push gitea main
|
||||
### Task 2: Multi-Claim Event Passes Schema & Join Endpoints (`/join`, `/e/:slug`, CLI 1-Liner)
|
||||
|
||||
**Files:**
|
||||
|
||||
- Modify: `server/db.ts`
|
||||
- Modify: `server/main.ts`
|
||||
- Create: `ui/components/EventJoinPage.tsx`
|
||||
@ -99,13 +131,16 @@ git push origin main && git push gitea main
|
||||
- Test: `server/main.test.ts`
|
||||
|
||||
**Interfaces:**
|
||||
|
||||
- Produces:
|
||||
- Table: `event_passes` in PostgreSQL
|
||||
- Endpoints:
|
||||
- `POST /api/events`: Create event pass (slug, pin_code, name, max_seats, lifespan_hours, app_id, role)
|
||||
- `POST /api/events`: Create event pass (slug, pin_code, name, max_seats,
|
||||
lifespan_hours, app_id, role)
|
||||
- `GET /e/:slug`: Web landing splash with "Enter Workshop" action
|
||||
- `GET /join`: Universal PIN / word-code entry page
|
||||
- `POST /api/join`: Redeems code/PIN and creates isolated `guest_<slug>_<index>` session
|
||||
- `POST /api/join`: Redeems code/PIN and creates isolated
|
||||
`guest_<slug>_<index>` session
|
||||
- `GET /join/:slug`: CLI 1-liner (`?format=env` or `?format=json`)
|
||||
|
||||
- [ ] **Step 1: Write the failing tests for Event creation and redemption**
|
||||
@ -124,23 +159,28 @@ Deno.test("Multi-Claim Event Passes & Join Endpoints", async (t) => {
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run test to verify it fails**
|
||||
Run: `deno test server/main.test.ts --filter "Multi-Claim Event"`
|
||||
Expected: FAIL
|
||||
- [ ] **Step 2: Run test to verify it fails** Run:
|
||||
`deno test server/main.test.ts --filter "Multi-Claim Event"` Expected:
|
||||
FAIL
|
||||
|
||||
- [ ] **Step 3: Add `event_passes` schema in `server/db.ts`**
|
||||
Include columns: `id`, `slug`, `pin_code`, `name`, `app_id`, `role`, `max_seats`, `seats_claimed`, `lifespan_hours`, `created_by`, `is_active`, `expires_at`, `created_at`.
|
||||
- [ ] **Step 3: Add `event_passes` schema in `server/db.ts`** Include columns:
|
||||
`id`, `slug`, `pin_code`, `name`, `app_id`, `role`, `max_seats`,
|
||||
`seats_claimed`, `lifespan_hours`, `created_by`, `is_active`,
|
||||
`expires_at`, `created_at`.
|
||||
|
||||
- [ ] **Step 4: Implement Event API routes in `server/main.ts` and SSR UI Pages**
|
||||
- [ ] **Step 4: Implement Event API routes in `server/main.ts` and SSR UI
|
||||
Pages**
|
||||
- Create `ui/components/EventJoinPage.tsx` for `/join` PIN code entry.
|
||||
- Create `ui/components/EventSplashPage.tsx` for `/e/:slug` 1-click workshop entrance.
|
||||
- Implement `GET /join/:slug` returning `export AUTH_YES_TOKEN="..."` when `?format=env`.
|
||||
- Create `ui/components/EventSplashPage.tsx` for `/e/:slug` 1-click workshop
|
||||
entrance.
|
||||
- Implement `GET /join/:slug` returning `export AUTH_YES_TOKEN="..."` when
|
||||
`?format=env`.
|
||||
|
||||
- [ ] **Step 5: Run tests to verify they pass**
|
||||
Run: `deno task test`
|
||||
Expected: PASS
|
||||
- [ ] **Step 5: Run tests to verify they pass** Run: `deno task test` Expected:
|
||||
PASS
|
||||
|
||||
- [ ] **Step 6: Commit and Push**
|
||||
|
||||
```bash
|
||||
git add server/db.ts server/main.ts ui/components/EventJoinPage.tsx ui/components/EventSplashPage.tsx server/main.test.ts
|
||||
git commit -m "feat(events): implement multi-claim event passes, PIN join portal, and CLI 1-liner"
|
||||
@ -152,41 +192,53 @@ git push origin main && git push gitea main
|
||||
### Task 3: Live Event Cockpit & Master Kill-Switch
|
||||
|
||||
**Files:**
|
||||
|
||||
- Modify: `server/main.ts`
|
||||
- Modify: `ui/components/SessionsPage.tsx`
|
||||
- Modify: `ui/mod.ts`
|
||||
- Test: `server/main.test.ts`
|
||||
|
||||
**Interfaces:**
|
||||
|
||||
- Produces:
|
||||
- `POST /api/events/:id/end`: Closes event and immediately revokes all guest sessions
|
||||
- `POST /api/events/:id/end`: Closes event and immediately revokes all guest
|
||||
sessions
|
||||
- `POST /api/events/:id/extend`: Adds hours to active event
|
||||
- Event Management Deck in `SessionsPage.tsx` with live seat counter (`38 / 50`) and master kill switch
|
||||
- Event Management Deck in `SessionsPage.tsx` with live seat counter
|
||||
(`38 / 50`) and master kill switch
|
||||
|
||||
- [ ] **Step 1: Write failing tests for Event Kill-Switch & Extension**
|
||||
|
||||
```typescript
|
||||
Deno.test("Event Cockpit & Master Kill Switch", async (t) => {
|
||||
await t.step("POST /api/events/:id/end revokes all guest seats instantly", async () => {
|
||||
// verify sessions deleted and Valkey cleared
|
||||
});
|
||||
await t.step(
|
||||
"POST /api/events/:id/end revokes all guest seats instantly",
|
||||
async () => {
|
||||
// verify sessions deleted and Valkey cleared
|
||||
},
|
||||
);
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run test to verify it fails**
|
||||
Run: `deno test server/main.test.ts --filter "Event Cockpit"`
|
||||
Expected: FAIL
|
||||
- [ ] **Step 2: Run test to verify it fails** Run:
|
||||
`deno test server/main.test.ts --filter "Event Cockpit"` Expected: FAIL
|
||||
|
||||
- [ ] **Step 3: Implement `POST /api/events/:id/end` and `POST /api/events/:id/extend` in `server/main.ts`**
|
||||
Fetch all session IDs created under the event pass, delete them from Valkey and PostgreSQL, record in audit ledger, and mark event `is_active = false`.
|
||||
- [ ] **Step 3: Implement `POST /api/events/:id/end` and
|
||||
`POST /api/events/:id/extend` in `server/main.ts`** Fetch all session IDs
|
||||
created under the event pass, delete them from Valkey and PostgreSQL,
|
||||
record in audit ledger, and mark event `is_active = false`.
|
||||
|
||||
- [ ] **Step 4: Update `ui/components/SessionsPage.tsx` with Event Cockpit Deck**
|
||||
Display active events with live progress bar (`Seats Claimed / Capacity`), PIN badge, copy links, `[+1h Extend]` and `[🔴 End Workshop & Revoke All]`.
|
||||
- [ ] **Step 4: Update `ui/components/SessionsPage.tsx` with Event Cockpit
|
||||
Deck** Display active events with live progress bar
|
||||
(`Seats Claimed / Capacity`), PIN badge, copy links, `[+1h Extend]` and
|
||||
`[🔴 End Workshop & Revoke All]`.
|
||||
|
||||
- [ ] **Step 5: Run full quality gates**
|
||||
Run: `deno fmt && deno task lint && deno task check && deno task test`
|
||||
Expected: All pass.
|
||||
- [ ] **Step 5: Run full quality gates** Run:
|
||||
`deno fmt && deno task lint && deno task check && deno task test`
|
||||
Expected: All pass.
|
||||
|
||||
- [ ] **Step 6: Commit and Push**
|
||||
|
||||
```bash
|
||||
git add server/main.ts ui/components/SessionsPage.tsx ui/mod.ts server/main.test.ts
|
||||
git commit -m "feat(cockpit): add live event metrics, seat roster, and master kill-switch"
|
||||
|
||||
@ -71,6 +71,109 @@ Deno.test("Tier 1 & 2: GET /api/forward-auth - Valid session", async () => {
|
||||
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 () => {
|
||||
const req = new Request("http://localhost/api/forward-auth", {
|
||||
headers: { "X-Forwarded-Host": "test.app.local" },
|
||||
|
||||
118
server/main.ts
118
server/main.ts
@ -177,6 +177,124 @@ app.use("/api/admin/*", async (c, next) => {
|
||||
await next();
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------
|
||||
// Ephemeral 1-Click Magic Link Redemption (/pass)
|
||||
// ---------------------------------------------------------
|
||||
|
||||
app.get("/pass", async (c) => {
|
||||
const token = c.req.query("token");
|
||||
if (!token) {
|
||||
return c.redirect("/login?error=invalid_or_expired_pass", 302);
|
||||
}
|
||||
|
||||
// 1. Validate against Valkey, fallback to PostgreSQL
|
||||
let sessionDataStr = null;
|
||||
try {
|
||||
sessionDataStr = await valkey.get(token);
|
||||
} catch (_err) {}
|
||||
|
||||
let sessionInfo = 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 || [];
|
||||
} catch (_err) {
|
||||
return c.redirect("/login?error=invalid_or_expired_pass", 302);
|
||||
}
|
||||
}
|
||||
|
||||
if (!sessionInfo || !expiresAtDate) {
|
||||
return c.redirect("/login?error=invalid_or_expired_pass", 302);
|
||||
}
|
||||
|
||||
// 2. Cookie Scoping
|
||||
deleteCookie(c, "session_id", { path: "/" });
|
||||
|
||||
const cookieDomain = getCookieDomain(rpID);
|
||||
const ttlSeconds = Math.max(
|
||||
1,
|
||||
Math.floor((expiresAtDate.getTime() - Date.now()) / 1000),
|
||||
);
|
||||
setCookie(c, "session_id", token, {
|
||||
path: "/",
|
||||
domain: cookieDomain,
|
||||
httpOnly: true,
|
||||
secure: true,
|
||||
sameSite: "Lax",
|
||||
maxAge: ttlSeconds,
|
||||
});
|
||||
|
||||
// 3. Redirect URL Resolution
|
||||
let targetDomain = null;
|
||||
if (Array.isArray(customScopes)) {
|
||||
const appScope = customScopes.find((s: string) => s.startsWith("app:"));
|
||||
if (appScope) {
|
||||
const appName = appScope.substring(4); // remove 'app:'
|
||||
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);
|
||||
}
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------
|
||||
// Provisioning & Registration (Use Cases 1, 2, 3)
|
||||
// ---------------------------------------------------------
|
||||
|
||||
@ -4,19 +4,26 @@
|
||||
- `server/main.ts`
|
||||
- `ui/components/SessionsPage.tsx`
|
||||
- `server/main.test.ts`
|
||||
- **Core Objective:** Implement `GET /pass?token=...` for 1-click ephemeral session redemption and update the Sessions Hub hand-off UI with copyable magic links.
|
||||
- **Core Objective:** Implement `GET /pass?token=...` for 1-click ephemeral
|
||||
session redemption and update the Sessions Hub hand-off UI with copyable magic
|
||||
links.
|
||||
- **Dependencies:** `server/auth-session.ts`, `server/db.ts`
|
||||
- **Additional Important Notes:** Sets wildcard `.atyg.org` cookie, cleans host-only cookie, and redirects cleanly to target app domain or dashboard.
|
||||
- **Additional Important Notes:** Sets wildcard `.atyg.org` cookie, cleans
|
||||
host-only cookie, and redirects cleanly to target app domain or dashboard.
|
||||
|
||||
---
|
||||
|
||||
### 1. Architectural Considerations & Risks
|
||||
|
||||
- **Security & Cookie Scoping:**
|
||||
- `/pass` must validate that the token is active and not expired before issuing Set-Cookie headers.
|
||||
- Must use `getCookieDomain()` so subdomains (e.g. `ed-droid.atyg.org`) receive the session cookie immediately.
|
||||
- `/pass` must validate that the token is active and not expired before
|
||||
issuing Set-Cookie headers.
|
||||
- Must use `getCookieDomain()` so subdomains (e.g. `ed-droid.atyg.org`)
|
||||
receive the session cookie immediately.
|
||||
- **Target URL Redirection:**
|
||||
- If the session has custom scopes for an application (e.g. `app:ed-droid`), `/pass` looks up the domain of `ed-droid` and redirects directly to `https://ed-droid.atyg.org`.
|
||||
- If the session has custom scopes for an application (e.g. `app:ed-droid`),
|
||||
`/pass` looks up the domain of `ed-droid` and redirects directly to
|
||||
`https://ed-droid.atyg.org`.
|
||||
- If no specific app is scoped, redirects to `/dashboard`.
|
||||
|
||||
---
|
||||
@ -6,35 +6,46 @@
|
||||
- `ui/components/EventJoinPage.tsx`
|
||||
- `ui/components/EventSplashPage.tsx`
|
||||
- `server/main.test.ts`
|
||||
- **Core Objective:** Implement Multi-Claim Event Passes with short vanity URLs (`/e/:slug`), universal PIN join portal (`/join`), and CLI environment 1-liner (`/join/:slug?format=env`).
|
||||
- **Core Objective:** Implement Multi-Claim Event Passes with short vanity URLs
|
||||
(`/e/:slug`), universal PIN join portal (`/join`), and CLI environment 1-liner
|
||||
(`/join/:slug?format=env`).
|
||||
- **Dependencies:** `server/db.ts`, `server/auth-session.ts`
|
||||
- **Additional Important Notes:** Provisions isolated guest seats (`guest_<slug>_<index>`) with individual sessions.
|
||||
- **Additional Important Notes:** Provisions isolated guest seats
|
||||
(`guest_<slug>_<index>`) with individual sessions.
|
||||
|
||||
---
|
||||
|
||||
### 1. Architectural Considerations & Risks
|
||||
|
||||
- **Concurrency & Seat Limits:**
|
||||
- Ensure atomicity when incrementing `seats_claimed` on `event_passes` so events with strict seat limits (e.g. 50 seats) do not oversubscribe.
|
||||
- Ensure atomicity when incrementing `seats_claimed` on `event_passes` so
|
||||
events with strict seat limits (e.g. 50 seats) do not oversubscribe.
|
||||
- **Multi-Channel Accessibility:**
|
||||
- Support web browser UI (`/e/:slug`, `/join`), JSON API (`POST /api/join`), and CLI environment output (`GET /join/:slug?format=env`).
|
||||
- Support web browser UI (`/e/:slug`, `/join`), JSON API (`POST /api/join`),
|
||||
and CLI environment output (`GET /join/:slug?format=env`).
|
||||
- **Session Isolation:**
|
||||
- Each attendee gets their own dedicated guest session and UUID, preventing state collision in shared sandbox apps.
|
||||
- Each attendee gets their own dedicated guest session and UUID, preventing
|
||||
state collision in shared sandbox apps.
|
||||
|
||||
---
|
||||
|
||||
### 2. Proposed Implementation
|
||||
|
||||
1. **Database Schema (`server/db.ts`):**
|
||||
- Create `event_passes` table with columns: `id`, `slug`, `pin_code`, `name`, `app_id`, `role`, `max_seats`, `seats_claimed`, `lifespan_hours`, `created_by`, `is_active`, `expires_at`, `created_at`.
|
||||
- Create `event_passes` table with columns: `id`, `slug`, `pin_code`, `name`,
|
||||
`app_id`, `role`, `max_seats`, `seats_claimed`, `lifespan_hours`,
|
||||
`created_by`, `is_active`, `expires_at`, `created_at`.
|
||||
2. **API Endpoints (`server/main.ts`):**
|
||||
- `POST /api/events`: Create a new event pass.
|
||||
- `GET /e/:slug`: Web landing splash with "Enter Workshop" button.
|
||||
- `GET /join`: Universal PIN / code entry page.
|
||||
- `POST /api/join`: Redeems code or PIN, creates isolated guest user and session, sets cookie or returns JSON.
|
||||
- `GET /join/:slug`: Returns CLI 1-liner (`export AUTH_YES_TOKEN="..."` when `?format=env`).
|
||||
- `POST /api/join`: Redeems code or PIN, creates isolated guest user and
|
||||
session, sets cookie or returns JSON.
|
||||
- `GET /join/:slug`: Returns CLI 1-liner (`export AUTH_YES_TOKEN="..."` when
|
||||
`?format=env`).
|
||||
3. **SSR UI Components:**
|
||||
- `ui/components/EventJoinPage.tsx`
|
||||
- `ui/components/EventSplashPage.tsx`
|
||||
4. **Automated Tests (`server/main.test.ts`):**
|
||||
- Test event creation, PIN redemption, seat limit capping, and CLI output format.
|
||||
- Test event creation, PIN redemption, seat limit capping, and CLI output
|
||||
format.
|
||||
|
||||
@ -5,28 +5,39 @@
|
||||
- `ui/components/SessionsPage.tsx`
|
||||
- `ui/mod.ts`
|
||||
- `server/main.test.ts`
|
||||
- **Core Objective:** Add real-time event cockpit deck to the Sessions page with active seat counters, PIN display, time extension, and 1-tap master kill switch (`/api/events/:id/end`).
|
||||
- **Dependencies:** `tasks/new/2026-0825.02.gem.feat.event-passes.multi-claim-workshops-and-kiosks-0046.md`
|
||||
- **Additional Important Notes:** Sub-millisecond mass revocation of all event guest sessions via Valkey RESP3 push tracking.
|
||||
- **Core Objective:** Add real-time event cockpit deck to the Sessions page with
|
||||
active seat counters, PIN display, time extension, and 1-tap master kill
|
||||
switch (`/api/events/:id/end`).
|
||||
- **Dependencies:**
|
||||
`tasks/new/2026-0825.02.gem.feat.event-passes.multi-claim-workshops-and-kiosks-0046.md`
|
||||
- **Additional Important Notes:** Sub-millisecond mass revocation of all event
|
||||
guest sessions via Valkey RESP3 push tracking.
|
||||
|
||||
---
|
||||
|
||||
### 1. Architectural Considerations & Risks
|
||||
|
||||
- **Mass Revocation Performance:**
|
||||
- When the organizer clicks "End Workshop & Revoke All", the endpoint must delete all associated guest session IDs from both PostgreSQL and Valkey, emitting invalidation events to all connected nodes immediately.
|
||||
- When the organizer clicks "End Workshop & Revoke All", the endpoint must
|
||||
delete all associated guest session IDs from both PostgreSQL and Valkey,
|
||||
emitting invalidation events to all connected nodes immediately.
|
||||
- **Observability:**
|
||||
- Show real-time seat claim progress bar (`38 / 50 claimed`) and event expiration timer.
|
||||
- Show real-time seat claim progress bar (`38 / 50 claimed`) and event
|
||||
expiration timer.
|
||||
|
||||
---
|
||||
|
||||
### 2. Proposed Implementation
|
||||
|
||||
1. **Backend Endpoints (`server/main.ts`):**
|
||||
- `POST /api/events/:id/end`: Closes the event, deletes all guest sessions under the event, and purges Valkey cache.
|
||||
- `POST /api/events/:id/extend`: Adds hours to `expires_at` for the event and all active guest sessions.
|
||||
- `POST /api/events/:id/end`: Closes the event, deletes all guest sessions
|
||||
under the event, and purges Valkey cache.
|
||||
- `POST /api/events/:id/extend`: Adds hours to `expires_at` for the event and
|
||||
all active guest sessions.
|
||||
2. **UI & Data Query (`ui/mod.ts`, `ui/components/SessionsPage.tsx`):**
|
||||
- Query `event_passes` in `/dashboard/sessions` and render the **Event Cockpit Deck**.
|
||||
- Display progress bar, quick copy buttons for PIN and Short URL, `[+1h Extend]` and `[🔴 End Workshop & Revoke All]`.
|
||||
- Query `event_passes` in `/dashboard/sessions` and render the **Event
|
||||
Cockpit Deck**.
|
||||
- Display progress bar, quick copy buttons for PIN and Short URL,
|
||||
`[+1h Extend]` and `[🔴 End Workshop & Revoke All]`.
|
||||
3. **Automated Tests (`server/main.test.ts`):**
|
||||
- Test event extension and master kill-switch mass revocation.
|
||||
|
||||
@ -284,6 +284,28 @@ export const SessionsPage = ({
|
||||
</p>
|
||||
|
||||
<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-outline"
|
||||
style="min-height: 36px; padding: 0 0.85rem; font-size: 0.8rem;"
|
||||
onclick="copyHandoff('magic')"
|
||||
>
|
||||
Copy Link
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 1-Tap Copy CLI */}
|
||||
<div>
|
||||
<label style="display: block; font-size: 0.75rem; font-weight: 700; text-transform: uppercase; color: var(--text-muted); margin-bottom: 0.25rem;">
|
||||
@ -771,6 +793,7 @@ export const SessionsPage = ({
|
||||
const data = await res.json();
|
||||
if (res.ok) {
|
||||
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('handoffCurlText').textContent = '-H "Authorization: Bearer ' + data.token + '"';
|
||||
document.getElementById('handoffModal').style.display = 'block';
|
||||
@ -790,6 +813,7 @@ export const SessionsPage = ({
|
||||
let text = '';
|
||||
if (type === 'cli') text = document.getElementById('handoffCliText').textContent;
|
||||
if (type === 'curl') text = document.getElementById('handoffCurlText').textContent;
|
||||
if (type === 'magic') text = document.getElementById('handoffMagicLinkText').textContent;
|
||||
navigator.clipboard.writeText(text);
|
||||
showNotice('Copied to clipboard: ' + text, false);
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user