Wire OAuth, dashboard, keys, and auth middleware into app

Landing page at / with OAuth buttons (redirects to dashboard
if already logged in). Auth middleware protects /dashboard,
/api/provision, and /api/keys routes. Provision now uses
session username instead of request body. API keys endpoint
for per-user Anthropic/Google key overrides. Logout clears
session from KV and cookie.

Refs #11
This commit is contained in:
marauder-actual
2026-06-12 09:12:59 +02:00
parent d70738165d
commit 1471a58a9b
+120 -9
View File
@@ -10,6 +10,16 @@ import {
deleteDnsRecords,
} from "./cloudflare";
import { generateCloudInit } from "./bootstrap";
import { github } from "./auth/github";
import { google } from "./auth/google";
import {
authMiddleware,
getSessionFromCookie,
deleteSession,
clearSessionCookie,
type Session,
} from "./auth/session";
import { landingPage, dashboardPage } from "./dashboard/page";
const app = new Hono<{ Bindings: Env }>();
@@ -39,17 +49,109 @@ function instanceUrls(username: string) {
};
}
// ─── Health ─────────────────────────────────────────────────────
// ─── Landing page ───────────────────────────────────────────────
app.get("/", (c) => c.json({ service: "madcat-provisioner", status: "ok" }));
app.get("/", async (c) => {
// If already logged in, redirect to dashboard
const session = await getSessionFromCookie(c);
if (session) {
return c.redirect("/dashboard");
}
return c.html(landingPage());
});
// ─── OAuth routes ───────────────────────────────────────────────
app.route("/", github);
app.route("/", google);
// ─── Logout ─────────────────────────────────────────────────────
app.post("/auth/logout", async (c) => {
// Best-effort delete session from KV
const cookie = c.req.header("Cookie");
if (cookie) {
const match = cookie
.split(";")
.map((s) => s.trim())
.find((s) => s.startsWith("madcat_session="));
if (match) {
const value = match.slice("madcat_session=".length);
const dotIdx = value.lastIndexOf(".");
if (dotIdx !== -1) {
const sessionId = value.slice(0, dotIdx);
await deleteSession(c.env.INSTANCES, sessionId);
}
}
}
return new Response(null, {
status: 302,
headers: {
Location: "/",
"Set-Cookie": clearSessionCookie(),
},
});
});
// ─── Auth middleware for protected routes ────────────────────────
app.use("/dashboard/*", authMiddleware);
app.use("/api/provision/*", authMiddleware);
app.use("/api/keys/*", authMiddleware);
// ─── Dashboard ──────────────────────────────────────────────────
app.get("/dashboard", async (c) => {
const user = c.get("user" as never) as unknown as Session;
return c.html(dashboardPage(user));
});
// ─── API Keys ───────────────────────────────────────────────────
function maskKey(key: string): string {
if (key.length <= 8) return "••••••••";
return key.slice(0, 6) + "…" + key.slice(-4);
}
app.get("/api/keys", async (c) => {
const user = c.get("user" as never) as unknown as Session;
const raw = await c.env.INSTANCES.get(`keys:${user.username}`);
if (!raw) return c.json({});
const keys = JSON.parse(raw) as Record<string, string>;
const masked: Record<string, string> = {};
if (keys.anthropic_key) masked.anthropic_key = maskKey(keys.anthropic_key);
if (keys.google_key) masked.google_key = maskKey(keys.google_key);
return c.json(masked);
});
app.put("/api/keys", async (c) => {
const user = c.get("user" as never) as unknown as Session;
const body = await c.req.json<Record<string, string>>();
// Load existing keys
const raw = await c.env.INSTANCES.get(`keys:${user.username}`);
const existing = raw ? (JSON.parse(raw) as Record<string, string>) : {};
if (body.anthropic_key) existing.anthropic_key = body.anthropic_key;
if (body.google_key) existing.google_key = body.google_key;
await c.env.INSTANCES.put(
`keys:${user.username}`,
JSON.stringify(existing),
);
return c.json({ status: "saved" });
});
// ─── POST /api/provision ────────────────────────────────────────
app.post("/api/provision", async (c) => {
const body = await c.req.json<ProvisionRequest>();
const username = validateUsername(body.username);
const user = c.get("user" as never) as unknown as Session;
const username = user.username;
if (!username) {
if (!validateUsername(username)) {
return c.json(
{ error: "Invalid username. Must be 3-20 lowercase alphanumeric characters." },
400,
@@ -76,8 +178,16 @@ app.post("/api/provision", async (c) => {
getSecret(c.env.SECRETS, "HETZNER_SSH_KEY_NAME"),
]);
// Optional secrets
const googleKey = await c.env.SECRETS.get("GOOGLE_GENERATIVE_AI_API_KEY");
// Optional secrets — check user overrides first, then global
const userKeys = await c.env.INSTANCES.get(`keys:${username}`);
const parsedKeys = userKeys
? (JSON.parse(userKeys) as Record<string, string>)
: {};
const finalAnthropicKey = parsedKeys.anthropic_key || anthropicKey;
const googleKey =
parsedKeys.google_key ||
(await c.env.SECRETS.get("GOOGLE_GENERATIVE_AI_API_KEY"));
// 2. Create CF tunnel + get token
const { tunnelId, tunnelToken } = await createTunnel(username, cfToken);
@@ -96,7 +206,7 @@ app.post("/api/provision", async (c) => {
// 5. Generate cloud-init user_data
const userData = generateCloudInit({
username,
anthropicKey,
anthropicKey: finalAnthropicKey,
tunnelToken,
cfApiToken: cfToken,
googleKey: googleKey ?? undefined,
@@ -216,8 +326,9 @@ app.delete("/api/provision/:username", async (c) => {
// 3. Delete CF tunnel
await deleteTunnel(record.tunnel_id, cfToken);
// 4. Remove KV record
// 4. Remove KV records
await c.env.INSTANCES.delete(`instance:${username}`);
await c.env.INSTANCES.delete(`keys:${username}`);
return c.json({ status: "destroyed" });
} catch (err) {