refactor(server): modularize main.ts into dedicated sub-routers (passes, events, sessions, forward-auth)
This commit is contained in:
parent
40804fd813
commit
39f3b2e3fa
@ -401,3 +401,24 @@ export function isSafeRedirectUrl(
|
||||
}
|
||||
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";
|
||||
}
|
||||
|
||||
771
server/main.ts
771
server/main.ts
@ -21,7 +21,6 @@ import {
|
||||
decodeBase64Url,
|
||||
encodeBase64Url,
|
||||
} from "jsr:@std/encoding@1/base64url";
|
||||
import { encodeHex } from "jsr:@std/encoding@1/hex";
|
||||
import { MetadataService } from "jsr:@simplewebauthn/server@13";
|
||||
import { initDb, sqlWrapper } from "./db.ts";
|
||||
import { pingValkey, valkey } from "./valkey.ts";
|
||||
@ -34,9 +33,12 @@ import {
|
||||
universalServerResponseToFetch,
|
||||
} from "npm:@connectrpc/connect@^1.4.0/protocol";
|
||||
import type { ConnectRouter } from "npm:@connectrpc/connect@^1.4.0";
|
||||
import { computeJwkThumbprint } from "./http_signatures.ts";
|
||||
import { uiApp } from "../ui/mod.ts";
|
||||
import { EventJoinPage } from "../ui/components/EventJoinPage.tsx";
|
||||
import { EventSplashPage } from "../ui/components/EventSplashPage.tsx";
|
||||
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 = {
|
||||
userId: string;
|
||||
@ -46,6 +48,12 @@ export const app: Hono<{ Variables: Variables }> = new Hono<
|
||||
{ Variables: Variables }
|
||||
>();
|
||||
|
||||
// Mount Sub-routers
|
||||
app.route("/pass", passRoutes);
|
||||
app.route("/", eventRoutes);
|
||||
app.route("/", sessionRoutes);
|
||||
app.route("/", forwardAuthRoutes);
|
||||
|
||||
// Mount UI Routes
|
||||
app.route("/", uiApp);
|
||||
|
||||
@ -185,399 +193,6 @@ app.use("/api/admin/*", async (c, next) => {
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------
|
||||
// Ephemeral 1-Click Magic Link Redemption (/pass)
|
||||
// ---------------------------------------------------------
|
||||
|
||||
app.get("/pass", async (c) => {
|
||||
const token = c.req.query("token");
|
||||
if (!token) {
|
||||
return c.redirect("/login?error=invalid_or_expired_pass", 302);
|
||||
}
|
||||
|
||||
// 1. Validate against Valkey, fallback to PostgreSQL
|
||||
let sessionDataStr = null;
|
||||
try {
|
||||
sessionDataStr = await valkey.get(token);
|
||||
} catch (_err) {}
|
||||
|
||||
let sessionInfo: 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 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);
|
||||
}
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------
|
||||
// Multi-Claim Event Passes & Short Code Join Portal
|
||||
// ---------------------------------------------------------
|
||||
|
||||
app.post("/api/events", async (c) => {
|
||||
const user = await getAuthenticatedUser(c);
|
||||
if (!user) return c.json({ error: "Unauthorized" }, 401);
|
||||
|
||||
const body = await c.req.json().catch(() => ({}));
|
||||
const { name, appId, role = "viewer", maxSeats = 50, lifespanHours = 3 } =
|
||||
body;
|
||||
let { slug, pinCode } = body;
|
||||
|
||||
if (!name || typeof name !== "string") {
|
||||
return c.json({ error: "Event name is required" }, 400);
|
||||
}
|
||||
|
||||
if (!slug) {
|
||||
slug =
|
||||
name.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "") +
|
||||
"-" + Math.random().toString(36).substring(2, 6);
|
||||
}
|
||||
if (!pinCode) {
|
||||
const randPin = Math.floor(100000 + Math.random() * 900000).toString();
|
||||
pinCode = randPin.substring(0, 3) + "-" + randPin.substring(3);
|
||||
}
|
||||
|
||||
try {
|
||||
const expiresAt = new Date(
|
||||
Date.now() + Number(lifespanHours) * 3600 * 1000,
|
||||
);
|
||||
const result = await sqlWrapper.sql`
|
||||
INSERT INTO event_passes (slug, pin_code, name, app_id, role, max_seats, lifespan_hours, created_by, expires_at)
|
||||
VALUES (${slug}, ${pinCode}, ${name}, ${appId || null}, ${role}, ${
|
||||
Number(maxSeats)
|
||||
}, ${Number(lifespanHours)}, ${user.userId}, ${expiresAt})
|
||||
RETURNING *
|
||||
`;
|
||||
return c.json({ success: true, event: result[0] });
|
||||
} catch (e: any) {
|
||||
console.error("[Events] Failed to create event pass:", e);
|
||||
return c.json({ error: "Failed to create event pass" }, 500);
|
||||
}
|
||||
});
|
||||
|
||||
app.post("/api/join", async (c) => {
|
||||
let code = "";
|
||||
if (
|
||||
c.req.header("content-type")?.includes("application/x-www-form-urlencoded")
|
||||
) {
|
||||
const fd = await c.req.formData();
|
||||
code = (fd.get("code") as string) || "";
|
||||
} else {
|
||||
const body = await c.req.json().catch(() => ({}));
|
||||
code = body.code || "";
|
||||
}
|
||||
|
||||
if (!code || typeof code !== "string") {
|
||||
return c.json({ error: "Event code or PIN is required" }, 400);
|
||||
}
|
||||
code = code.trim();
|
||||
|
||||
try {
|
||||
const result = await sqlWrapper.sql`
|
||||
UPDATE event_passes
|
||||
SET seats_claimed = seats_claimed + 1
|
||||
WHERE (slug = ${code} OR pin_code = ${code})
|
||||
AND is_active = TRUE
|
||||
AND (expires_at IS NULL OR expires_at > NOW())
|
||||
AND (max_seats = 0 OR seats_claimed < max_seats)
|
||||
RETURNING *
|
||||
`;
|
||||
|
||||
if (!result || result.length === 0) {
|
||||
return c.json({
|
||||
error: "Invalid event code or workshop capacity reached",
|
||||
}, 404);
|
||||
}
|
||||
|
||||
const event = result[0];
|
||||
const guestUuid = crypto.randomUUID();
|
||||
const username = `guest_${event.slug}_${event.seats_claimed}`;
|
||||
|
||||
await sqlWrapper.sql`
|
||||
INSERT INTO users (id, username, display_name, account_status)
|
||||
VALUES (${guestUuid}, ${username}, ${event.name + " Attendee"}, 'guest')
|
||||
ON CONFLICT DO NOTHING
|
||||
`;
|
||||
|
||||
const randomBytes = crypto.getRandomValues(new Uint8Array(32));
|
||||
const sessionId = `ay_sess_${encodeHex(randomBytes)}`;
|
||||
const label = `${event.name} Seat #${event.seats_claimed}`;
|
||||
const ttl = (Number(event.lifespan_hours) || 3) * 3600;
|
||||
|
||||
let customScopes = ["guest", "trial"];
|
||||
let appDomain = "";
|
||||
|
||||
if (event.app_id) {
|
||||
const apps = await sqlWrapper
|
||||
.sql`SELECT name, domain FROM apps WHERE id = ${event.app_id}`;
|
||||
if (apps.length > 0) {
|
||||
customScopes = [`app:${apps[0].name}`, event.role || "viewer"];
|
||||
appDomain = apps[0].domain || "";
|
||||
}
|
||||
}
|
||||
|
||||
const expiresAt = new Date(Date.now() + ttl * 1000);
|
||||
|
||||
await sqlWrapper.sql`
|
||||
INSERT INTO sessions (id, user_id, label, is_agent, custom_scopes, expires_at)
|
||||
VALUES (${sessionId}, ${guestUuid}, ${label}, false, ${customScopes}, ${expiresAt})
|
||||
`;
|
||||
|
||||
await valkey.setex(
|
||||
sessionId,
|
||||
ttl,
|
||||
JSON.stringify({
|
||||
uuid: guestUuid,
|
||||
username,
|
||||
account_status: "guest",
|
||||
customScopes,
|
||||
}),
|
||||
);
|
||||
|
||||
deleteCookie(c, "session_id", { path: "/" });
|
||||
const cookieDomain = getCookieDomain(rpID);
|
||||
|
||||
setCookie(c, "session_id", sessionId, {
|
||||
domain: cookieDomain,
|
||||
path: "/",
|
||||
httpOnly: true,
|
||||
secure: true,
|
||||
sameSite: "Lax",
|
||||
maxAge: ttl,
|
||||
});
|
||||
|
||||
const redirectUrl = appDomain ? `https://${appDomain}` : "/dashboard";
|
||||
|
||||
if (
|
||||
c.req.header("accept")?.includes("text/html") &&
|
||||
!c.req.header("accept")?.includes("application/json")
|
||||
) {
|
||||
return c.redirect(redirectUrl, 302);
|
||||
}
|
||||
|
||||
return c.json({
|
||||
success: true,
|
||||
sessionId,
|
||||
token: sessionId,
|
||||
guestUuid,
|
||||
username,
|
||||
redirectUrl,
|
||||
});
|
||||
} catch (e: any) {
|
||||
console.error("[Events] Failed to join event:", e);
|
||||
return c.json({ error: "Failed to join event" }, 500);
|
||||
}
|
||||
});
|
||||
|
||||
app.get("/join/:slug", async (c) => {
|
||||
const slug = c.req.param("slug");
|
||||
const format = c.req.query("format") || "html";
|
||||
|
||||
try {
|
||||
const result = await sqlWrapper.sql`
|
||||
UPDATE event_passes
|
||||
SET seats_claimed = seats_claimed + 1
|
||||
WHERE slug = ${slug}
|
||||
AND is_active = TRUE
|
||||
AND (expires_at IS NULL OR expires_at > NOW())
|
||||
AND (max_seats = 0 OR seats_claimed < max_seats)
|
||||
RETURNING *
|
||||
`;
|
||||
|
||||
if (!result || result.length === 0) {
|
||||
if (format === "env" || format === "json") {
|
||||
return c.text("Invalid slug or workshop capacity reached", 404);
|
||||
}
|
||||
return c.redirect("/join?error=not_found", 302);
|
||||
}
|
||||
|
||||
const event = result[0];
|
||||
const guestUuid = crypto.randomUUID();
|
||||
const username = `guest_${event.slug}_${event.seats_claimed}`;
|
||||
|
||||
await sqlWrapper.sql`
|
||||
INSERT INTO users (id, username, display_name, account_status)
|
||||
VALUES (${guestUuid}, ${username}, ${event.name + " Attendee"}, 'guest')
|
||||
ON CONFLICT DO NOTHING
|
||||
`;
|
||||
|
||||
const randomBytes = crypto.getRandomValues(new Uint8Array(32));
|
||||
const sessionId = `ay_sess_${encodeHex(randomBytes)}`;
|
||||
const label = `${event.name} Seat #${event.seats_claimed}`;
|
||||
const ttl = (Number(event.lifespan_hours) || 3) * 3600;
|
||||
|
||||
let customScopes = ["guest", "trial"];
|
||||
if (event.app_id) {
|
||||
const apps = await sqlWrapper
|
||||
.sql`SELECT name, domain FROM apps WHERE id = ${event.app_id}`;
|
||||
if (apps.length > 0) {
|
||||
customScopes = [`app:${apps[0].name}`, event.role || "viewer"];
|
||||
}
|
||||
}
|
||||
|
||||
const expiresAt = new Date(Date.now() + ttl * 1000);
|
||||
|
||||
await sqlWrapper.sql`
|
||||
INSERT INTO sessions (id, user_id, label, is_agent, custom_scopes, expires_at)
|
||||
VALUES (${sessionId}, ${guestUuid}, ${label}, false, ${customScopes}, ${expiresAt})
|
||||
`;
|
||||
|
||||
await valkey.setex(
|
||||
sessionId,
|
||||
ttl,
|
||||
JSON.stringify({
|
||||
uuid: guestUuid,
|
||||
username,
|
||||
account_status: "guest",
|
||||
customScopes,
|
||||
}),
|
||||
);
|
||||
|
||||
if (format === "env") {
|
||||
return c.text(
|
||||
`export AUTH_YES_TOKEN="${sessionId}"\nexport AUTH_YES_USER="${username}"\n`,
|
||||
);
|
||||
} else if (format === "json") {
|
||||
return c.json({
|
||||
success: true,
|
||||
token: sessionId,
|
||||
username,
|
||||
expiresAt: expiresAt.toISOString(),
|
||||
});
|
||||
}
|
||||
|
||||
deleteCookie(c, "session_id", { path: "/" });
|
||||
setCookie(c, "session_id", sessionId, {
|
||||
domain: getCookieDomain(rpID),
|
||||
path: "/",
|
||||
httpOnly: true,
|
||||
secure: true,
|
||||
sameSite: "Lax",
|
||||
maxAge: ttl,
|
||||
});
|
||||
|
||||
return c.redirect("/dashboard", 302);
|
||||
} catch (e: any) {
|
||||
console.error("[Events] Failed to execute CLI join:", e);
|
||||
return c.text("Internal Server Error", 500);
|
||||
}
|
||||
});
|
||||
|
||||
app.get("/join", (c) => {
|
||||
return c.html(EventJoinPage());
|
||||
});
|
||||
|
||||
app.get("/e/:slug", async (c) => {
|
||||
const slug = c.req.param("slug");
|
||||
try {
|
||||
const result = await sqlWrapper.sql`
|
||||
SELECT * FROM event_passes WHERE slug = ${slug} AND is_active = TRUE
|
||||
`;
|
||||
if (!result || result.length === 0) {
|
||||
return c.redirect("/join?error=event_not_found", 302);
|
||||
}
|
||||
return c.html(EventSplashPage({ event: result[0] }));
|
||||
} catch (_e) {
|
||||
return c.redirect("/join?error=db_error", 302);
|
||||
}
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------
|
||||
>>>>>>> 0f4b72e (feat(passes): implement ephemeral 1-click magic links, event passes, PIN join portal, and CLI 1-liner)
|
||||
// Provisioning & Registration (Use Cases 1, 2, 3)
|
||||
// ---------------------------------------------------------
|
||||
|
||||
@ -1521,150 +1136,6 @@ app.post("/api/admin/users/:id/profile", async (c) => {
|
||||
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
|
||||
// ---------------------------------------------------------
|
||||
@ -2411,226 +1882,6 @@ app.get("/api/admin/check", async (c) => {
|
||||
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)
|
||||
// ---------------------------------------------------------
|
||||
|
||||
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 });
|
||||
});
|
||||
Loading…
x
Reference in New Issue
Block a user