Compare commits
5 Commits
7033c532b2
...
578f3d06ac
| Author | SHA1 | Date | |
|---|---|---|---|
| 578f3d06ac | |||
| 39f3b2e3fa | |||
| 40804fd813 | |||
| 368150c2c0 | |||
|
|
2d34aa15a7 |
@ -1,16 +1,21 @@
|
|||||||
# 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 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
|
## 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 ConnectRPC.
|
- Headless execution via **Model Context Protocol (MCP)**, REST APIs, or
|
||||||
- Machine-readable, high-density JSON/RPC responses with zero HTML/CSS clutter.
|
ConnectRPC.
|
||||||
- Authentication via **Delegated Bearer Tokens (`ay_sess_...`)** or mTLS SPIFFE workload identities.
|
- 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):**
|
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.
|
||||||
@ -21,26 +26,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
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
@ -48,22 +53,30 @@ 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`.
|
|
||||||
* Auth-Yes validates either the browser's `session_id` cookie **or** the incoming `Authorization: Bearer ay_sess_...` token.
|
- Traefik queries `http://auth-api:8000/api/forward-auth`.
|
||||||
* If authorized, Traefik injects:
|
- 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: <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";
|
||||||
|
|
||||||
@ -78,13 +91,19 @@ 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 (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);
|
return c.json(ship);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -97,11 +116,14 @@ 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, Claude Desktop):
|
To expose your subsidiary applications to AI assistants (Antigravity, Jules,
|
||||||
|
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 **`+ Delegate Agent Session`**.
|
- Go to `https://auth.atyg.org/dashboard/sessions` $\rightarrow$ Click
|
||||||
- Set Label: `"Antigravity Assistant"`, Lifespan: `12 Hours`, Scope: `ed-droid`.
|
**`+ Delegate Agent Session`**.
|
||||||
|
- 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:**
|
||||||
@ -110,7 +132,11 @@ To expose your subsidiary applications to AI assistants (Antigravity, Jules, Cla
|
|||||||
"mcpServers": {
|
"mcpServers": {
|
||||||
"ed-droid": {
|
"ed-droid": {
|
||||||
"command": "deno",
|
"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": {
|
"env": {
|
||||||
"AUTH_YES_TOKEN": "ay_sess_8de186f564d7..."
|
"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:**
|
3. **Tool Call Execution:** The MCP server attaches
|
||||||
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!
|
`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)
|
## 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:
|
### 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` 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`.
|
- 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:**
|
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 **`[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
|
# 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
|
## 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 `.atyg.org` domain cookies.
|
- All cookie mutations must delete host-only cookies and set wildcard
|
||||||
- Every commit and change must be pushed to both GitHub (`origin`) and Gitea (`gitea`).
|
`.atyg.org` domain cookies.
|
||||||
- Quality gates must pass: `deno fmt`, `deno task lint`, `deno task check`, `deno task test`.
|
- 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`)
|
### 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 `SessionsPage.tsx`
|
- Produces: `GET /pass?token=...` endpoint and updated Hand-Off Modal in
|
||||||
|
`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("Valid token sets wildcard cookie and redirects to target app", async () => {
|
await t.step(
|
||||||
const valkeyStub = stub(valkey, "get", (key: any) => {
|
"Valid token sets wildcard cookie and redirects to target app",
|
||||||
if (String(key) === "ay_sess_valid_pass") {
|
async () => {
|
||||||
return Promise.resolve(
|
const valkeyStub = stub(valkey, "get", (key: any) => {
|
||||||
JSON.stringify({ uuid: "guest-uuid", username: "guest_user", customScopes: ["app:ed-droid"] }),
|
if (String(key) === "ay_sess_valid_pass") {
|
||||||
);
|
return Promise.resolve(
|
||||||
}
|
JSON.stringify({
|
||||||
return Promise.resolve(null);
|
uuid: "guest-uuid",
|
||||||
});
|
username: "guest_user",
|
||||||
|
customScopes: ["app:ed-droid"],
|
||||||
const originalSql = sqlWrapper.sql;
|
}),
|
||||||
sqlWrapper.sql = ((_query: any) => {
|
);
|
||||||
return Promise.resolve([{ domain: "ed-droid.atyg.org" }]);
|
}
|
||||||
}) as any;
|
return Promise.resolve(null);
|
||||||
|
|
||||||
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") || "";
|
const originalSql = sqlWrapper.sql;
|
||||||
assert(setCookie.includes("session_id=ay_sess_valid_pass"));
|
sqlWrapper.sql = ((_query: any) => {
|
||||||
assert(res.headers.get("location")?.includes("ed-droid.atyg.org") || res.headers.get("location") === "/dashboard");
|
return Promise.resolve([{ domain: "ed-droid.atyg.org" }]);
|
||||||
} finally {
|
}) as any;
|
||||||
sqlWrapper.sql = originalSql;
|
|
||||||
valkeyStub.restore();
|
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**
|
- [ ] **Step 2: Run test to verify it fails** Run:
|
||||||
Run: `deno test server/main.test.ts --filter "Ephemeral Magic Link"`
|
`deno test server/main.test.ts --filter "Ephemeral Magic Link"` Expected:
|
||||||
Expected: FAIL (404 Not Found on `/pass`)
|
FAIL (404 Not Found on `/pass`)
|
||||||
|
|
||||||
- [ ] **Step 3: Implement `GET /pass` in `server/main.ts`**
|
- [ ] **Step 3: Implement `GET /pass` in `server/main.ts`** Extract token from
|
||||||
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)`.
|
`?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`**
|
- [ ] **Step 4: Update `ui/components/SessionsPage.tsx`** Add the **"1-Click
|
||||||
Add the **"1-Click Magic Link"** copy card tab alongside the CLI and cURL header tabs.
|
Magic Link"** copy card tab alongside the CLI and cURL header tabs.
|
||||||
|
|
||||||
- [ ] **Step 5: Run tests to verify they pass**
|
- [ ] **Step 5: Run tests to verify they pass** Run: `deno task test` Expected:
|
||||||
Run: `deno task test`
|
PASS (All tests passing)
|
||||||
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"
|
||||||
@ -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)
|
### 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`
|
||||||
@ -99,13 +131,16 @@ 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, 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 /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 `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`)
|
- `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**
|
||||||
@ -124,23 +159,28 @@ Deno.test("Multi-Claim Event Passes & Join Endpoints", async (t) => {
|
|||||||
});
|
});
|
||||||
```
|
```
|
||||||
|
|
||||||
- [ ] **Step 2: Run test to verify it fails**
|
- [ ] **Step 2: Run test to verify it fails** Run:
|
||||||
Run: `deno test server/main.test.ts --filter "Multi-Claim Event"`
|
`deno test server/main.test.ts --filter "Multi-Claim Event"` Expected:
|
||||||
Expected: FAIL
|
FAIL
|
||||||
|
|
||||||
- [ ] **Step 3: Add `event_passes` schema in `server/db.ts`**
|
- [ ] **Step 3: Add `event_passes` schema in `server/db.ts`** Include columns:
|
||||||
Include columns: `id`, `slug`, `pin_code`, `name`, `app_id`, `role`, `max_seats`, `seats_claimed`, `lifespan_hours`, `created_by`, `is_active`, `expires_at`, `created_at`.
|
`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/EventJoinPage.tsx` for `/join` PIN code entry.
|
||||||
- Create `ui/components/EventSplashPage.tsx` for `/e/:slug` 1-click workshop entrance.
|
- Create `ui/components/EventSplashPage.tsx` for `/e/:slug` 1-click workshop
|
||||||
- Implement `GET /join/:slug` returning `export AUTH_YES_TOKEN="..."` when `?format=env`.
|
entrance.
|
||||||
|
- Implement `GET /join/:slug` returning `export AUTH_YES_TOKEN="..."` when
|
||||||
|
`?format=env`.
|
||||||
|
|
||||||
- [ ] **Step 5: Run tests to verify they pass**
|
- [ ] **Step 5: Run tests to verify they pass** Run: `deno task test` Expected:
|
||||||
Run: `deno task test`
|
PASS
|
||||||
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"
|
||||||
@ -152,41 +192,53 @@ 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 sessions
|
- `POST /api/events/:id/end`: Closes event and immediately revokes all guest
|
||||||
|
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 (`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**
|
- [ ] **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("POST /api/events/:id/end revokes all guest seats instantly", async () => {
|
await t.step(
|
||||||
// verify sessions deleted and Valkey cleared
|
"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**
|
- [ ] **Step 2: Run test to verify it fails** Run:
|
||||||
Run: `deno test server/main.test.ts --filter "Event Cockpit"`
|
`deno test server/main.test.ts --filter "Event Cockpit"` Expected: FAIL
|
||||||
Expected: FAIL
|
|
||||||
|
|
||||||
- [ ] **Step 3: Implement `POST /api/events/:id/end` and `POST /api/events/:id/extend` in `server/main.ts`**
|
- [ ] **Step 3: Implement `POST /api/events/:id/end` and
|
||||||
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`.
|
`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**
|
- [ ] **Step 4: Update `ui/components/SessionsPage.tsx` with Event Cockpit
|
||||||
Display active events with live progress bar (`Seats Claimed / Capacity`), PIN badge, copy links, `[+1h Extend]` and `[🔴 End Workshop & Revoke All]`.
|
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**
|
- [ ] **Step 5: Run full quality gates** Run:
|
||||||
Run: `deno fmt && deno task lint && deno task check && deno task test`
|
`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"
|
||||||
|
|||||||
@ -401,3 +401,24 @@ 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";
|
||||||
|
}
|
||||||
|
|||||||
18
server/db.ts
18
server/db.ts
@ -206,6 +206,24 @@ 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(),
|
||||||
|
|||||||
@ -71,6 +71,109 @@ 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" },
|
||||||
@ -836,3 +939,260 @@ Deno.test("Agent Session Delegation & Scoped Permissions", async (t) => {
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
Deno.test("Ephemeral 1-Click Magic Link Redemption (/pass)", async (t) => {
|
||||||
|
await t.step("GET /pass with missing token redirects to login", async () => {
|
||||||
|
const res = await app.request("/pass", { method: "GET" });
|
||||||
|
assertEquals(res.status, 302);
|
||||||
|
assertEquals(
|
||||||
|
res.headers.get("location"),
|
||||||
|
"/login?error=invalid_or_expired_pass",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
await t.step("GET /pass with invalid token redirects to login", async () => {
|
||||||
|
const valkeyStub = stub(valkey, "get", () => Promise.resolve(null));
|
||||||
|
const originalSql = sqlWrapper.sql;
|
||||||
|
sqlWrapper.sql = (() => Promise.resolve([])) as any;
|
||||||
|
try {
|
||||||
|
const res = await app.request("/pass?token=invalid", { method: "GET" });
|
||||||
|
assertEquals(res.status, 302);
|
||||||
|
assertEquals(
|
||||||
|
res.headers.get("location"),
|
||||||
|
"/login?error=invalid_or_expired_pass",
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
sqlWrapper.sql = originalSql;
|
||||||
|
valkeyStub.restore();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
await t.step(
|
||||||
|
"GET /pass with valid token sets cookies and redirects to app domain",
|
||||||
|
async () => {
|
||||||
|
const sessionToken = "ay_sess_valid_app";
|
||||||
|
const valkeyGetStub = stub(valkey, "get", (key: any) => {
|
||||||
|
if (String(key) === sessionToken) {
|
||||||
|
return Promise.resolve(JSON.stringify({
|
||||||
|
uuid: "user-uuid",
|
||||||
|
username: "testuser",
|
||||||
|
customScopes: ["app:ed-droid"],
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
return Promise.resolve(null);
|
||||||
|
});
|
||||||
|
const valkeyTtlStub = stub(valkey, "ttl", () => Promise.resolve(3600));
|
||||||
|
|
||||||
|
const originalSql = sqlWrapper.sql;
|
||||||
|
sqlWrapper.sql = ((strings: any, ..._values: any[]) => {
|
||||||
|
const query = Array.isArray(strings)
|
||||||
|
? strings.join("?")
|
||||||
|
: String(strings);
|
||||||
|
if (query.includes("SELECT domain FROM apps WHERE name =")) {
|
||||||
|
return Promise.resolve([{ domain: "ed-droid.atyg.org" }]);
|
||||||
|
}
|
||||||
|
return Promise.resolve([]);
|
||||||
|
}) as any;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await app.request(`/pass?token=${sessionToken}`, {
|
||||||
|
method: "GET",
|
||||||
|
});
|
||||||
|
assertEquals(res.status, 302);
|
||||||
|
assertEquals(res.headers.get("location"), "https://ed-droid.atyg.org");
|
||||||
|
|
||||||
|
const cookies = res.headers.get("set-cookie");
|
||||||
|
assertExists(cookies);
|
||||||
|
assert(cookies.includes(`session_id=${sessionToken};`));
|
||||||
|
} finally {
|
||||||
|
sqlWrapper.sql = originalSql;
|
||||||
|
valkeyGetStub.restore();
|
||||||
|
valkeyTtlStub.restore();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
await t.step(
|
||||||
|
"GET /pass with valid token defaults to /dashboard if no app scope",
|
||||||
|
async () => {
|
||||||
|
const sessionToken = "ay_sess_valid_dashboard";
|
||||||
|
const valkeyGetStub = stub(valkey, "get", (key: any) => {
|
||||||
|
if (String(key) === sessionToken) {
|
||||||
|
return Promise.resolve(JSON.stringify({
|
||||||
|
uuid: "user-uuid",
|
||||||
|
username: "testuser",
|
||||||
|
customScopes: ["read:audit"],
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
return Promise.resolve(null);
|
||||||
|
});
|
||||||
|
const valkeyTtlStub = stub(valkey, "ttl", () => Promise.resolve(3600));
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await app.request(`/pass?token=${sessionToken}`, {
|
||||||
|
method: "GET",
|
||||||
|
});
|
||||||
|
assertEquals(res.status, 302);
|
||||||
|
assertEquals(res.headers.get("location"), "/dashboard");
|
||||||
|
} finally {
|
||||||
|
valkeyGetStub.restore();
|
||||||
|
valkeyTtlStub.restore();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
Deno.test("Multi-Claim Event Passes & Join Endpoints", async (t) => {
|
||||||
|
await t.step("POST /api/events creates an event pass", async () => {
|
||||||
|
const valkeyStub = stub(valkey, "get", (key: any) => {
|
||||||
|
if (String(key) === "admin-session") {
|
||||||
|
return Promise.resolve(
|
||||||
|
JSON.stringify({ uuid: "admin-uuid", username: "tylerg" }),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return Promise.resolve(null);
|
||||||
|
});
|
||||||
|
|
||||||
|
const originalSql = sqlWrapper.sql;
|
||||||
|
sqlWrapper.sql = ((strings: any, ..._values: any[]) => {
|
||||||
|
const query = Array.isArray(strings)
|
||||||
|
? strings.join("?")
|
||||||
|
: String(strings);
|
||||||
|
if (query.includes("INSERT INTO event_passes")) {
|
||||||
|
return Promise.resolve([{
|
||||||
|
id: "event-uuid-1",
|
||||||
|
slug: "deno-lab",
|
||||||
|
pin_code: "749-123",
|
||||||
|
name: "Deno Workshop",
|
||||||
|
max_seats: 50,
|
||||||
|
seats_claimed: 0,
|
||||||
|
lifespan_hours: 3,
|
||||||
|
}]);
|
||||||
|
}
|
||||||
|
return Promise.resolve([]);
|
||||||
|
}) as any;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await app.request("/api/events", {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
Authorization: "Bearer admin-session",
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
name: "Deno Workshop",
|
||||||
|
slug: "deno-lab",
|
||||||
|
pinCode: "749-123",
|
||||||
|
maxSeats: 50,
|
||||||
|
lifespanHours: 3,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
assertEquals(res.status, 200);
|
||||||
|
const json = await res.json();
|
||||||
|
assert(json.success === true);
|
||||||
|
assertEquals(json.event.slug, "deno-lab");
|
||||||
|
assertEquals(json.event.pin_code, "749-123");
|
||||||
|
} finally {
|
||||||
|
sqlWrapper.sql = originalSql;
|
||||||
|
valkeyStub.restore();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
await t.step(
|
||||||
|
"POST /api/join redeems PIN / slug and mints guest session",
|
||||||
|
async () => {
|
||||||
|
const originalSql = sqlWrapper.sql;
|
||||||
|
sqlWrapper.sql = ((strings: any, ..._values: any[]) => {
|
||||||
|
const query = Array.isArray(strings)
|
||||||
|
? strings.join("?")
|
||||||
|
: String(strings);
|
||||||
|
if (query.includes("UPDATE event_passes")) {
|
||||||
|
return Promise.resolve([{
|
||||||
|
id: "event-uuid-1",
|
||||||
|
slug: "deno-lab",
|
||||||
|
pin_code: "749-123",
|
||||||
|
name: "Deno Workshop",
|
||||||
|
max_seats: 50,
|
||||||
|
seats_claimed: 1,
|
||||||
|
lifespan_hours: 3,
|
||||||
|
app_id: null,
|
||||||
|
}]);
|
||||||
|
}
|
||||||
|
return Promise.resolve([]);
|
||||||
|
}) as any;
|
||||||
|
|
||||||
|
const valkeySetexStub = stub(
|
||||||
|
valkey,
|
||||||
|
"setex",
|
||||||
|
() => Promise.resolve("OK" as any),
|
||||||
|
);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await app.request("/api/join", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ code: "749-123" }),
|
||||||
|
});
|
||||||
|
|
||||||
|
assertEquals(res.status, 200);
|
||||||
|
const json = await res.json();
|
||||||
|
assert(json.success === true);
|
||||||
|
assert(json.token.startsWith("ay_sess_"));
|
||||||
|
assertEquals(json.username, "guest_deno-lab_1");
|
||||||
|
|
||||||
|
const cookies = res.headers.get("set-cookie");
|
||||||
|
assertExists(cookies);
|
||||||
|
assert(cookies.includes(`session_id=${json.token};`));
|
||||||
|
} finally {
|
||||||
|
sqlWrapper.sql = originalSql;
|
||||||
|
valkeySetexStub.restore();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
await t.step(
|
||||||
|
"GET /join/:slug?format=env returns CLI export string",
|
||||||
|
async () => {
|
||||||
|
const originalSql = sqlWrapper.sql;
|
||||||
|
sqlWrapper.sql = ((strings: any, ..._values: any[]) => {
|
||||||
|
const query = Array.isArray(strings)
|
||||||
|
? strings.join("?")
|
||||||
|
: String(strings);
|
||||||
|
if (query.includes("UPDATE event_passes")) {
|
||||||
|
return Promise.resolve([{
|
||||||
|
id: "event-uuid-1",
|
||||||
|
slug: "deno-lab",
|
||||||
|
pin_code: "749-123",
|
||||||
|
name: "Deno Workshop",
|
||||||
|
max_seats: 50,
|
||||||
|
seats_claimed: 2,
|
||||||
|
lifespan_hours: 3,
|
||||||
|
app_id: null,
|
||||||
|
}]);
|
||||||
|
}
|
||||||
|
return Promise.resolve([]);
|
||||||
|
}) as any;
|
||||||
|
|
||||||
|
const valkeySetexStub = stub(
|
||||||
|
valkey,
|
||||||
|
"setex",
|
||||||
|
() => Promise.resolve("OK" as any),
|
||||||
|
);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await app.request("/join/deno-lab?format=env", {
|
||||||
|
method: "GET",
|
||||||
|
});
|
||||||
|
|
||||||
|
assertEquals(res.status, 200);
|
||||||
|
const text = await res.text();
|
||||||
|
assert(text.includes('export AUTH_YES_TOKEN="ay_sess_'));
|
||||||
|
assert(text.includes('export AUTH_YES_USER="guest_deno-lab_2"'));
|
||||||
|
} finally {
|
||||||
|
sqlWrapper.sql = originalSql;
|
||||||
|
valkeySetexStub.restore();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|||||||
383
server/main.ts
383
server/main.ts
@ -12,7 +12,11 @@ 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 { extractAllSessionIds } from "./auth-session.ts";
|
import {
|
||||||
|
extractAllSessionIds,
|
||||||
|
getAuthenticatedUser,
|
||||||
|
isGlobalAdmin,
|
||||||
|
} from "./auth-session.ts";
|
||||||
import {
|
import {
|
||||||
decodeBase64Url,
|
decodeBase64Url,
|
||||||
encodeBase64Url,
|
encodeBase64Url,
|
||||||
@ -29,7 +33,12 @@ 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;
|
||||||
@ -39,6 +48,12 @@ 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);
|
||||||
|
|
||||||
@ -1025,8 +1040,6 @@ 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)
|
||||||
// ---------------------------------------------------------
|
// ---------------------------------------------------------
|
||||||
@ -1123,150 +1136,6 @@ 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
|
||||||
// ---------------------------------------------------------
|
// ---------------------------------------------------------
|
||||||
@ -2013,226 +1882,6 @@ 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)
|
||||||
// ---------------------------------------------------------
|
// ---------------------------------------------------------
|
||||||
|
|||||||
143
server/routes/auth_forward.ts
Normal file
143
server/routes/auth_forward.ts
Normal file
@ -0,0 +1,143 @@
|
|||||||
|
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);
|
||||||
|
});
|
||||||
285
server/routes/events.ts
Normal file
285
server/routes/events.ts
Normal file
@ -0,0 +1,285 @@
|
|||||||
|
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);
|
||||||
|
}
|
||||||
|
});
|
||||||
129
server/routes/passes_magic.ts
Normal file
129
server/routes/passes_magic.ts
Normal file
@ -0,0 +1,129 @@
|
|||||||
|
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);
|
||||||
|
}
|
||||||
|
});
|
||||||
239
server/routes/sessions.ts
Normal file
239
server/routes/sessions.ts
Normal file
@ -0,0 +1,239 @@
|
|||||||
|
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 });
|
||||||
|
});
|
||||||
@ -4,19 +4,26 @@
|
|||||||
- `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 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`
|
- **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
|
### 1. Architectural Considerations & Risks
|
||||||
|
|
||||||
- **Security & Cookie Scoping:**
|
- **Security & Cookie Scoping:**
|
||||||
- `/pass` must validate that the token is active and not expired before issuing Set-Cookie headers.
|
- `/pass` must validate that the token is active and not expired before
|
||||||
- Must use `getCookieDomain()` so subdomains (e.g. `ed-droid.atyg.org`) receive the session cookie immediately.
|
issuing Set-Cookie headers.
|
||||||
|
- 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`), `/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`.
|
- If no specific app is scoped, redirects to `/dashboard`.
|
||||||
|
|
||||||
---
|
---
|
||||||
@ -6,35 +6,46 @@
|
|||||||
- `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 (`/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`
|
- **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
|
### 1. Architectural Considerations & Risks
|
||||||
|
|
||||||
- **Concurrency & Seat Limits:**
|
- **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:**
|
- **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:**
|
- **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
|
### 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`, `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`):**
|
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 session, sets cookie or returns JSON.
|
- `POST /api/join`: Redeems code or PIN, creates isolated guest user and
|
||||||
- `GET /join/:slug`: Returns CLI 1-liner (`export AUTH_YES_TOKEN="..."` when `?format=env`).
|
session, sets cookie or returns JSON.
|
||||||
|
- `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 format.
|
- Test event creation, PIN redemption, seat limit capping, and CLI output
|
||||||
|
format.
|
||||||
@ -5,28 +5,39 @@
|
|||||||
- `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 active seat counters, PIN display, time extension, and 1-tap master kill switch (`/api/events/:id/end`).
|
- **Core Objective:** Add real-time event cockpit deck to the Sessions page with
|
||||||
- **Dependencies:** `tasks/new/2026-0825.02.gem.feat.event-passes.multi-claim-workshops-and-kiosks-0046.md`
|
active seat counters, PIN display, time extension, and 1-tap master kill
|
||||||
- **Additional Important Notes:** Sub-millisecond mass revocation of all event guest sessions via Valkey RESP3 push tracking.
|
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
|
### 1. Architectural Considerations & Risks
|
||||||
|
|
||||||
- **Mass Revocation Performance:**
|
- **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:**
|
- **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
|
### 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 under the event, and purges Valkey cache.
|
- `POST /api/events/:id/end`: Closes the event, deletes all guest sessions
|
||||||
- `POST /api/events/:id/extend`: Adds hours to `expires_at` for the event and all active 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`):**
|
2. **UI & Data Query (`ui/mod.ts`, `ui/components/SessionsPage.tsx`):**
|
||||||
- Query `event_passes` in `/dashboard/sessions` and render the **Event Cockpit Deck**.
|
- Query `event_passes` in `/dashboard/sessions` and render the **Event
|
||||||
- Display progress bar, quick copy buttons for PIN and Short URL, `[+1h Extend]` and `[🔴 End Workshop & Revoke All]`.
|
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`):**
|
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.
|
||||||
|
|||||||
123
ui/components/EventJoinPage.tsx
Normal file
123
ui/components/EventJoinPage.tsx
Normal file
@ -0,0 +1,123 @@
|
|||||||
|
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>
|
||||||
|
);
|
||||||
|
};
|
||||||
161
ui/components/EventSplashPage.tsx
Normal file
161
ui/components/EventSplashPage.tsx
Normal file
@ -0,0 +1,161 @@
|
|||||||
|
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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@ -284,6 +284,28 @@ 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;">
|
||||||
@ -292,12 +314,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(--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(--text-primary);"
|
||||||
>
|
>
|
||||||
</code>
|
</code>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="btn-primary"
|
class="btn-outline"
|
||||||
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')"
|
||||||
>
|
>
|
||||||
@ -771,6 +793,7 @@ 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';
|
||||||
@ -788,8 +811,10 @@ 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);
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user