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:
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user