diff --git a/src/core/audit.ts b/src/core/audit.ts new file mode 100644 index 0000000..27e1542 --- /dev/null +++ b/src/core/audit.ts @@ -0,0 +1,187 @@ +import { encodeBase64 } from "jsr:@std/encoding@1/base64"; +import { encodeHex } from "jsr:@std/encoding@1/hex"; +import { sqlWrapper } from "./db.ts"; +import { buildMerkleTree, leafHash } from "./audit_merkle.ts"; +import { fetchSpiffeIdentity } from "./spire_ffi.ts"; +import { valkey } from "./valkey.ts"; + +let batcherIntervalId: number | null = null; +let isFlushing = false; + +/** + * SIDE EFFECT: Asynchronously logs an audit record to the database. + * Does not block the main execution thread. Errors are logged but swallowed + * to prevent failing the core request due to a logging issue. + */ +export let auditLog = function auditLog( + userId: string | null, + action: string, + resource: string | null, + details: Record | null, + ipAddress: string, +): void { + // Fire and forget + (async () => { + try { + const entryObj = { + userId, + action, + resource, + details, + ipAddress, + timestamp: Date.now(), + }; + const entryStr = JSON.stringify(entryObj); + const entryBytes = new TextEncoder().encode(entryStr); + const hashBuffer = await leafHash(entryBytes); + const leafHashHex = encodeHex(hashBuffer); + + await sqlWrapper.sql` + INSERT INTO audit_records (user_id, action, resource, details, ip_address, leaf_hash) + VALUES (${userId}, ${action}, ${resource}, ${ + details ? JSON.stringify(details) : null + }, ${ipAddress}, ${leafHashHex}) + `; + } catch (error: any) { + const msg = error?.code || error?.message || String(error); + console.error(`[Audit Logger] Failed to insert audit record: ${msg}`); + } + })(); +}; + +export async function flush(): Promise { + if (isFlushing) return; + isFlushing = true; + + try { + const sthResult = await sqlWrapper.sql` + SELECT tree_size FROM audit_sths ORDER BY tree_size DESC LIMIT 1 + `; + const lastTreeSize = sthResult.length > 0 + ? parseInt(sthResult[0].tree_size, 10) + : 0; + + const allRecordsResult = await sqlWrapper.sql` + SELECT leaf_hash FROM audit_records ORDER BY created_at ASC, id ASC + `; + + const currentTreeSize = allRecordsResult.length; + + if (currentTreeSize > lastTreeSize) { + const leafHashes = allRecordsResult.map((row: any) => { + const hex = row.leaf_hash; + const bytes = new Uint8Array(hex.length / 2); + for (let i = 0; i < hex.length; i += 2) { + bytes[i / 2] = parseInt(hex.substring(i, i + 2), 16); + } + return bytes; + }); + + const rootHashBytes = await buildMerkleTree(leafHashes); + const rootHashHex = encodeHex(rootHashBytes); + + let svidData; + try { + svidData = await fetchSpiffeIdentity(); + } catch (_e) { + svidData = { x509_svid_key: new Uint8Array() }; + } + + let signatureBase64 = ""; + if (svidData.x509_svid_key && svidData.x509_svid_key.length > 0) { + try { + const keyBuffer = svidData.x509_svid_key.buffer as ArrayBuffer; + const privateKey = await crypto.subtle.importKey( + "pkcs8", + keyBuffer, + { name: "ECDSA", namedCurve: "P-256" }, + true, + ["sign"], + ).catch(() => + crypto.subtle.importKey( + "pkcs8", + keyBuffer, + { name: "Ed25519" }, + true, + ["sign"], + ) + ); + + const payloadBytes = new TextEncoder().encode( + JSON.stringify({ + tree_size: currentTreeSize, + root_hash: rootHashHex, + }), + ); + + let signAlgo: any = { name: "ECDSA", hash: "SHA-256" }; + if (privateKey.algorithm.name === "Ed25519") { + signAlgo = { name: "Ed25519" }; + } + const signatureBytes = await crypto.subtle.sign( + signAlgo, + privateKey, + payloadBytes, + ); + signatureBase64 = encodeBase64(new Uint8Array(signatureBytes)); + } catch (e) { + console.warn( + "[Audit Batcher] Failed to import key or sign, using empty signature.", + e, + ); + } + } + + await sqlWrapper.sql` + INSERT INTO audit_sths (tree_size, root_hash, signature) + VALUES (${currentTreeSize}, ${rootHashHex}, ${signatureBase64}) + `; + + const payload = { + tree_size: currentTreeSize, + root_hash: rootHashHex, + signature: signatureBase64, + created_at: new Date().toISOString(), + }; + + const payloadStr = JSON.stringify(payload); + + try { + if (typeof valkey.set === "function") { + await valkey.set("auth:audit:latest_sth", payloadStr); + } + if (typeof valkey.publish === "function") { + await valkey.publish("auth:audit:sth", payloadStr); + } + } catch (e) { + console.warn("[Audit Batcher] Valkey broadcast failed", e); + } + } + } catch (error) { + console.error("[Audit Batcher] Error during flush:", error); + } finally { + isFlushing = false; + } +} + +export function startMicroBatcher(): void { + if (batcherIntervalId === null) { + batcherIntervalId = setInterval(flush, 1000) as unknown as number; + } +} + +export function stopMicroBatcher(): void { + if (batcherIntervalId !== null) { + clearInterval(batcherIntervalId); + batcherIntervalId = null; + } +} + +export const auditWrapper = { + get auditLog() { + return auditLog; + }, + set auditLog(val: any) { + auditLog = val; + }, +}; diff --git a/src/core/audit_merkle.ts b/src/core/audit_merkle.ts new file mode 100644 index 0000000..d1020cc --- /dev/null +++ b/src/core/audit_merkle.ts @@ -0,0 +1,72 @@ +export async function leafHash(entry_bytes: Uint8Array): Promise { + const data = new Uint8Array(1 + entry_bytes.length); + data[0] = 0x00; + data.set(entry_bytes, 1); + const hashBuffer = await crypto.subtle.digest("SHA-256", data); + return new Uint8Array(hashBuffer); +} + +export async function nodeHash( + left: Uint8Array, + right: Uint8Array, +): Promise { + const data = new Uint8Array(1 + left.length + right.length); + data[0] = 0x01; + data.set(left, 1); + data.set(right, 1 + left.length); + const hashBuffer = await crypto.subtle.digest("SHA-256", data); + return new Uint8Array(hashBuffer); +} + +export async function buildMerkleTree( + leaves: Uint8Array[], +): Promise { + if (leaves.length === 0) { + const hashBuffer = await crypto.subtle.digest("SHA-256", new Uint8Array(0)); + return new Uint8Array(hashBuffer); + } + + if (leaves.length === 1) { + return leaves[0]; + } + + const k = Math.pow(2, Math.floor(Math.log2(leaves.length - 1))); + const leftHash = await buildMerkleTree(leaves.slice(0, k)); + const rightHash = await buildMerkleTree(leaves.slice(k)); + + return await nodeHash(leftHash, rightHash); +} + +export async function verifyInclusionProof( + leaf: Uint8Array, + proof: Uint8Array[], + index: number, + treeSize: number, + expectedRoot: Uint8Array, +): Promise { + let currentHash = leaf; + let currentIndex = index; + let right = treeSize - 1; + + for (const siblingHash of proof) { + if (currentIndex % 2 === 1) { + currentHash = await nodeHash(siblingHash, currentHash); + } else { + if (currentIndex === right) { + currentHash = await nodeHash(siblingHash, currentHash); + } else { + currentHash = await nodeHash(currentHash, siblingHash); + } + } + currentIndex = Math.floor(currentIndex / 2); + right = Math.floor(right / 2); + } + + const currentHashHex = Array.from(currentHash).map((b) => + b.toString(16).padStart(2, "0") + ).join(""); + const expectedRootHex = Array.from(expectedRoot).map((b) => + b.toString(16).padStart(2, "0") + ).join(""); + return currentHashHex === expectedRootHex; +} diff --git a/src/core/middleware.ts b/src/core/middleware.ts new file mode 100644 index 0000000..ad859ed --- /dev/null +++ b/src/core/middleware.ts @@ -0,0 +1,84 @@ +import type { Context } from "jsr:@hono/hono@4"; +import { getAuthenticatedUser } from "./session.ts"; +import { valkey } from "./valkey.ts"; + +/** + * Extracts client IP from headers, checking X-Real-IP and X-Forwarded-For. + */ +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 "unknown-ip"; +} + +export async function checkRateLimit( + key: string, + limit: number, + windowMs: number, +): Promise { + try { + const current = await valkey.incr(key); + if (current === 1) { + await valkey.pexpire(key, windowMs); + } + return current <= limit; + } catch (err) { + console.warn("[Rate Limiter] Valkey error:", err); + return true; // Fail open on cache error + } +} + +export const rateLimitWrapper = { + checkRateLimit, + async isRateLimited( + key: string, + limit: number, + windowMs: number, + ): Promise { + const allowed = await checkRateLimit(key, limit, windowMs); + return !allowed; + }, +}; + +export const publicRateLimiter = async ( + c: Context, + next: () => Promise, +) => { + const ip = getClientIp(c); + const key = `ratelimit:public:${ip}`; + const allowed = await checkRateLimit(key, 10, 60000); + if (!allowed) { + return c.json({ error: "Too Many Requests" }, 429); + } + await next(); +}; + +export const adminRateLimiter = async ( + c: Context, + next: () => Promise, +) => { + const auth = await getAuthenticatedUser(c); + if (!auth) { + return c.json({ error: "Missing or invalid session" }, 401); + } + + const key = `ratelimit:admin:${auth.userId}`; + const allowed = await checkRateLimit(key, 60, 60000); + if (!allowed) { + return c.json({ error: "Too Many Requests" }, 429); + } + + c.set("userId", auth.userId); + await next(); +}; diff --git a/src/core/session.ts b/src/core/session.ts new file mode 100644 index 0000000..cc02b88 --- /dev/null +++ b/src/core/session.ts @@ -0,0 +1,233 @@ +import type { Context } from "jsr:@hono/hono@4"; +import { deleteCookie } from "jsr:@hono/hono@4/cookie"; +import { sqlWrapper } from "./db.ts"; +import { valkey } from "./valkey.ts"; + +export interface AuthenticatedUser { + userId: string; + sessionId: string; + username: string; + label?: string; + isAgent?: boolean; + isPaused?: boolean; + customScopes?: string[]; +} + +export interface AppRecord { + id: string; + name: string; + domain?: string; + is_public?: boolean; + bypass_paths?: string[]; + allowed_cidrs?: string[]; +} + +/** + * Extracts all session_id tokens from the Cookie and Authorization headers. + */ +export function extractAllSessionIds(c: Context): string[] { + const candidates: string[] = []; + + const authHeader = c.req.header("authorization") || ""; + if (authHeader.startsWith("Bearer ")) { + const bearerToken = authHeader.substring(7).trim(); + if (bearerToken) candidates.push(bearerToken); + } + + const cookieHeader = c.req.header("cookie") || ""; + if (cookieHeader) { + const cookieMatches = [ + ...cookieHeader.matchAll(/(?:^|;\s*)session_id=([^;]+)/g), + ] + .map((m) => decodeURIComponent(m[1].trim())) + .filter(Boolean); + candidates.push(...cookieMatches); + } + + return candidates; +} + +/** + * Resolves the authenticated user from Valkey cache or PostgreSQL sessions table. + */ +export async function getAuthenticatedUser( + c: Context, +): Promise { + const sessionMatches = extractAllSessionIds(c); + if (sessionMatches.length === 0) return null; + + for (let i = 0; i < sessionMatches.length; i++) { + const candidateId = sessionMatches[i]; + + // 1. Try Valkey cache + try { + const sessionDataStr = await valkey.get(candidateId); + if (sessionDataStr) { + const sessionData = JSON.parse(sessionDataStr); + if (sessionData && sessionData.uuid) { + if (i > 0) { + deleteCookie(c, "session_id", { path: "/" }); + } + + return { + userId: sessionData.uuid, + sessionId: candidateId, + username: sessionData.username || "", + label: sessionData.label, + isAgent: sessionData.isAgent, + isPaused: sessionData.is_paused, + customScopes: sessionData.customScopes, + }; + } + } + } catch (_err) { + // Valkey cache miss or connection hiccup - fallback to DB + } + + // 2. Fallback to PostgreSQL sessions table + try { + const nowIso = new Date().toISOString(); + const session = await sqlWrapper.sql` + SELECT s.user_id, s.expires_at, s.label, s.is_agent, s.is_paused, s.custom_scopes, u.username + FROM sessions s + JOIN users u ON s.user_id = u.id + WHERE s.id = ${candidateId} AND s.expires_at > ${nowIso} + `.then((res: any) => res[0]); + + if (session) { + const username = session.username || ""; + try { + const ttlSeconds = Math.max( + 1, + Math.floor( + (new Date(session.expires_at).getTime() - Date.now()) / 1000, + ), + ); + await valkey.setex( + candidateId, + ttlSeconds, + JSON.stringify({ + uuid: session.user_id, + username, + label: session.label, + isAgent: session.is_agent, + is_paused: session.is_paused, + customScopes: session.custom_scopes, + }), + ); + } catch (_e) {} + + if (i > 0) { + deleteCookie(c, "session_id", { path: "/" }); + } + + return { + userId: session.user_id, + sessionId: candidateId, + username, + label: session.label, + isAgent: session.is_agent, + isPaused: session.is_paused, + customScopes: session.custom_scopes, + }; + } + } catch (_err) { + // Continue to next candidate + } + } + + return null; +} + +/** + * Checks if a user has a global admin role. + */ +export async function isGlobalAdmin(userId: string): Promise { + const result = await sqlWrapper.sql` + SELECT r.name FROM user_roles ur + JOIN roles r ON ur.role_id = r.id + WHERE ur.user_id = ${userId} AND r.name = 'admin' + `; + return result.length > 0; +} + +/** + * Evaluates if the current user's capabilities satisfy the required scope. + */ +export function hasScope( + auth: AuthenticatedUser, + requiredScope: string, +): boolean { + if (!auth.isAgent) return true; + if (!Array.isArray(auth.customScopes)) return false; + return auth.customScopes.includes("*") || + auth.customScopes.includes(requiredScope); +} + +/** + * Helper to check if the session itself is authorized as an admin. + */ +export async function isSessionAdmin( + auth: AuthenticatedUser, +): Promise { + const globalAdmin = await isGlobalAdmin(auth.userId); + if (!globalAdmin) return false; + if (!auth.isAgent) return true; + return Array.isArray(auth.customScopes) && auth.customScopes.includes("*"); +} + +/** + * Computes root cookie domain from RP_ID or host. + */ +export function getCookieDomain(customRpId?: string): string { + const rpId = customRpId || Deno.env.get("RP_ID"); + if (rpId) { + if (rpId.includes("localhost") || rpId.includes("127.0.0.1")) { + return rpId; + } + return `.${rpId}`; + } + return ""; +} + +/** + * Hono Middleware: Blocks access if the session is a delegated agent session. + */ +export async function requirePrimarySession( + c: Context, + next: () => Promise, +) { + const auth = await getAuthenticatedUser(c); + if (!auth) return c.json({ error: "Unauthorized" }, 401); + if (auth.isAgent) { + return c.json({ error: "Forbidden: Primary session required" }, 403); + } + await next(); +} + +/** + * Hono Middleware Factory: Requires a specific scope. + */ +export function requireScope(scope: string) { + return async (c: Context, next: () => Promise) => { + const auth = await getAuthenticatedUser(c); + if (!auth) return c.json({ error: "Unauthorized" }, 401); + if (!hasScope(auth, scope)) { + return c.json({ error: "Forbidden: Insufficient scopes" }, 403); + } + await next(); + }; +} + +/** + * Hono Middleware: Blocks access unless the session is an admin session. + */ +export async function requireAdmin(c: Context, next: () => Promise) { + const auth = await getAuthenticatedUser(c); + if (!auth) return c.json({ error: "Unauthorized" }, 401); + const isAdmin = await isSessionAdmin(auth); + if (!isAdmin) { + return c.json({ error: "Forbidden: Global admin access required" }, 403); + } + await next(); +} diff --git a/src/features/admin/routes.tsx b/src/features/admin/routes.tsx index 2540ce3..524af86 100644 --- a/src/features/admin/routes.tsx +++ b/src/features/admin/routes.tsx @@ -1,13 +1,10 @@ import { Hono } from "jsr:@hono/hono@4"; import { encodeBase64Url } from "jsr:@std/encoding@1/base64url"; -import { adminRateLimiter, getClientIp } from "../../../server/middleware.ts"; -import { - getAuthenticatedUser, - requireAdmin, -} from "../../../server/auth-session.ts"; +import { adminRateLimiter, getClientIp } from "../../core/middleware.ts"; +import { getAuthenticatedUser, requireAdmin } from "../../core/session.ts"; import { valkey } from "../../core/valkey.ts"; -import { auditWrapper } from "../../../server/audit.ts"; +import { auditWrapper } from "../../core/audit.ts"; import { assignUserGrant, diff --git a/src/features/auth/login_routes.ts b/src/features/auth/login_routes.ts index 47b3aac..e5d9f49 100644 --- a/src/features/auth/login_routes.ts +++ b/src/features/auth/login_routes.ts @@ -8,9 +8,9 @@ import { import type { AuthenticationResponseJSON } from "jsr:@simplewebauthn/server@13"; import { valkey } from "../../core/valkey.ts"; -import { getClientIp, publicRateLimiter } from "../../../server/middleware.ts"; -import { extractAllSessionIds } from "../../../server/auth-session.ts"; -import { auditWrapper } from "../../../server/audit.ts"; +import { getClientIp, publicRateLimiter } from "../../core/middleware.ts"; +import { extractAllSessionIds } from "../../core/session.ts"; +import { auditWrapper } from "../../core/audit.ts"; import { createSession, diff --git a/src/features/auth/recovery_routes.ts b/src/features/auth/recovery_routes.ts index 0546384..47f3b11 100644 --- a/src/features/auth/recovery_routes.ts +++ b/src/features/auth/recovery_routes.ts @@ -7,8 +7,8 @@ import { } from "jsr:@simplewebauthn/server@13"; import type { RegistrationResponseJSON } from "jsr:@simplewebauthn/server@13"; -import { getClientIp, publicRateLimiter } from "../../../server/middleware.ts"; -import { auditWrapper } from "../../../server/audit.ts"; +import { getClientIp, publicRateLimiter } from "../../core/middleware.ts"; +import { auditWrapper } from "../../core/audit.ts"; import { bindPasskey, diff --git a/src/features/auth/register_routes.ts b/src/features/auth/register_routes.ts index b1f39e8..032ba37 100644 --- a/src/features/auth/register_routes.ts +++ b/src/features/auth/register_routes.ts @@ -8,8 +8,8 @@ import { import type { RegistrationResponseJSON } from "jsr:@simplewebauthn/server@13"; import { valkey } from "../../core/valkey.ts"; -import { getClientIp, publicRateLimiter } from "../../../server/middleware.ts"; -import { auditWrapper } from "../../../server/audit.ts"; +import { getClientIp, publicRateLimiter } from "../../core/middleware.ts"; +import { auditWrapper } from "../../core/audit.ts"; import { createPasskey, diff --git a/src/features/events/routes.tsx b/src/features/events/routes.tsx index 8aa34e0..03f0b33 100644 --- a/src/features/events/routes.tsx +++ b/src/features/events/routes.tsx @@ -4,13 +4,12 @@ import { getCookieDomain, hasScope, isGlobalAdmin, -} from "../../../server/auth-session.ts"; +} from "../../core/session.ts"; import { deleteCookie, setCookie } from "jsr:@hono/hono@4/cookie"; import { encodeHex } from "jsr:@std/encoding@1/hex"; -import { getClientIp } from "../../../server/middleware.ts"; +import { getClientIp, rateLimitWrapper } from "../../core/middleware.ts"; import { valkey } from "../../core/valkey.ts"; -import { auditWrapper } from "../../../server/audit.ts"; -import { rateLimitWrapper } from "../../../server/ratelimit.ts"; +import { auditWrapper } from "../../core/audit.ts"; import { streamDatastar } from "../../core/sse_adapter.ts"; import { renderErrorToastFragment } from "../../core/error_fragments.tsx"; import * as Queries from "./queries.ts"; diff --git a/src/features/sessions/actions_routes.ts b/src/features/sessions/actions_routes.ts index 8efa0fb..b3f5fec 100644 --- a/src/features/sessions/actions_routes.ts +++ b/src/features/sessions/actions_routes.ts @@ -1,12 +1,9 @@ import { Hono } from "jsr:@hono/hono@4"; import { valkey } from "../../core/valkey.ts"; -import { auditWrapper } from "../../../server/audit.ts"; -import { - getAuthenticatedUser, - getClientIp, - hasScope, -} from "../../../server/auth-session.ts"; +import { auditWrapper } from "../../core/audit.ts"; +import { getClientIp } from "../../core/middleware.ts"; +import { getAuthenticatedUser, hasScope } from "../../core/session.ts"; import { delegateSession, diff --git a/src/features/sessions/routes.tsx b/src/features/sessions/routes.tsx index ba58c33..62f1d81 100644 --- a/src/features/sessions/routes.tsx +++ b/src/features/sessions/routes.tsx @@ -5,10 +5,7 @@ import { renderErrorToastFragment } from "../../core/error_fragments.tsx"; import { AuthenticatedLayoutFragment, } from "../../shared/ui/layout_fragments.tsx"; -import { - getAuthenticatedUser, - hasScope, -} from "../../../server/auth-session.ts"; +import { getAuthenticatedUser, hasScope } from "../../core/session.ts"; import { getAllApps } from "../admin/queries.ts"; import { getActiveSessions } from "./queries.ts"; diff --git a/tasks/audits/2026-0827-audit-2-phase-3.md b/tasks/audits/2026-0827-audit-2-phase-3.md index 768c476..ff6059a 100644 --- a/tasks/audits/2026-0827-audit-2-phase-3.md +++ b/tasks/audits/2026-0827-audit-2-phase-3.md @@ -2,36 +2,62 @@ ## 1. Test Suite & Verification -- **`deno fmt`**: Passed (All newly created fragments, scripts, queries, routes, and styles formatted). -- **`deno task lint`**: Passed (`deno lint` and `scripts/lint_arch.ts` passed with 0 errors; all files strictly $\le 400$ lines with no banned DOM API regressions). -- **`deno task check`**: Passed across all workspace modules (`server/`, `sdk/`, `ui/`, `infra/`, `src/`). -- **`deno test -A --no-check`**: Passed (80 tests across 30 steps with 0 failures). +- **`deno fmt`**: Passed (All newly created fragments, scripts, queries, routes, + and styles formatted). +- **`deno task lint`**: Passed (`deno lint` and `scripts/lint_arch.ts` passed + with 0 errors; all files strictly $\le 400$ lines with no banned DOM API + regressions). +- **`deno task check`**: Passed across all workspace modules (`server/`, `sdk/`, + `ui/`, `infra/`, `src/`). +- **`deno test -A --no-check`**: Passed (80 tests across 30 steps with 0 + failures). ## 2. Scope Implemented & Verified 1. **Events Vertical Slice (`src/features/events/`):** - - `cockpit_fragments.tsx`: `EventCockpitDeckFragment` supporting responsive Grid and Compact view modes. - - `cockpit_styles.ts`: Isolated CSS styling tokens to enforce SRP and keep component files under the 400-line limit. - - `drawer_fragments.tsx`: `WorkshopPassDrawerFragment` for minting event passes with 2-state handoff UI. - - `attendees_fragments.tsx`: `GuestDrawerAttendeesFragment` desktop slide-over and mobile bottom sheet for live attendee telemetry and session controls. - - `join_fragments.tsx`: `EventJoinPageFragment` for universal PIN and slug redemption. - - `queries.ts`: Pure PostgreSQL SQL queries for event retrieval, creation, expansion, PIN rotation, and live seat counts. - - `routes.tsx`: Complete Datastar SSE endpoint (`/api/events/:id/stream`) and interactive event actions (`/api/events/:id/rotate-pin`, `/api/events/:id/expand`, `/api/events/:id/end`, `/api/join`). + - `cockpit_fragments.tsx`: `EventCockpitDeckFragment` supporting responsive + Grid and Compact view modes. + - `cockpit_styles.ts`: Isolated CSS styling tokens to enforce SRP and keep + component files under the 400-line limit. + - `drawer_fragments.tsx`: `WorkshopPassDrawerFragment` for minting event + passes with 2-state handoff UI. + - `attendees_fragments.tsx`: `GuestDrawerAttendeesFragment` desktop + slide-over and mobile bottom sheet for live attendee telemetry and session + controls. + - `join_fragments.tsx`: `EventJoinPageFragment` for universal PIN and slug + redemption. + - `queries.ts`: Pure PostgreSQL SQL queries for event retrieval, creation, + expansion, PIN rotation, and live seat counts. + - `routes.tsx`: Complete Datastar SSE endpoint (`/api/events/:id/stream`) and + interactive event actions (`/api/events/:id/rotate-pin`, + `/api/events/:id/expand`, `/api/events/:id/end`, `/api/join`). - `events.test.ts`: Verified public join route and error handling fragments. 2. **Sessions Vertical Slice (`src/features/sessions/`):** - - `table_fragments.tsx`: `SessionTableFragment` desktop table with Datastar reactive action triggers. - - `deck_fragments.tsx`: `SessionDeckFragment` responsive touch card deck for mobile viewports. - - `drawer_fragments.tsx`: `DirectPassDrawerFragment` for minting scoped child agent passes. - - `modal_fragments.tsx`: `ScopeModalFragment` for interactive permissions editing. - - `queries.ts`: SQL queries for active sessions, delegation, scope updating, pause/resume, and expiration extension. - - `actions_routes.ts`: Sub-router handling session delegation, scope modification, pausing, extension, and revocation. - - `routes.tsx`: UI route (`/dashboard/sessions`) and live telemetry SSE stream (`/api/sessions/stream`) with Valkey Pub/Sub revocation broadcasting. - - `sessions.test.tsx`: Verified route protection, table, deck, drawer, and modal rendering. + - `table_fragments.tsx`: `SessionTableFragment` desktop table with Datastar + reactive action triggers. + - `deck_fragments.tsx`: `SessionDeckFragment` responsive touch card deck for + mobile viewports. + - `drawer_fragments.tsx`: `DirectPassDrawerFragment` for minting scoped child + agent passes. + - `modal_fragments.tsx`: `ScopeModalFragment` for interactive permissions + editing. + - `queries.ts`: SQL queries for active sessions, delegation, scope updating, + pause/resume, and expiration extension. + - `actions_routes.ts`: Sub-router handling session delegation, scope + modification, pausing, extension, and revocation. + - `routes.tsx`: UI route (`/dashboard/sessions`) and live telemetry SSE + stream (`/api/sessions/stream`) with Valkey Pub/Sub revocation + broadcasting. + - `sessions.test.tsx`: Verified route protection, table, deck, drawer, and + modal rendering. 3. **Client Assets & Compatibility:** - - `public/sessions-scripts.js`: Pure vanilla JavaScript module handling drawer animations, copy clipboard actions, and scope modification modals with `globalThis` scoping. + - `public/sessions-scripts.js`: Pure vanilla JavaScript module handling + drawer animations, copy clipboard actions, and scope modification modals + with `globalThis` scoping. 4. **App Wiring & Dual Remote Distribution:** - Mounted `eventsRoutes` and `sessionRoutes` in `src/main.ts`. - - Committed on `feat/phase-3-realtime-slices-and-scripts-removal` and synchronized across GitHub (`origin`) and Gitea (`gitea`). + - Committed on `feat/phase-3-realtime-slices-and-scripts-removal` and + synchronized across GitHub (`origin`) and Gitea (`gitea`). - Created GitHub PR #58. ## 3. Decision