fd0984302bab8e10e2b9b7b601e8ad03f0cdafa8 diff --git a/content/home.md b/content/home.md index 505cf098ffb14039ac1860193946d666769eefe6..fab44a6c036c3c0b8191c9d559bd567a4f353b03 100644 --- a/content/home.md +++ b/content/home.md @@ -81,6 +81,6 @@ Pragmatic mode halves the negatives and accepts combined red+green commits. Lear ## play -1. [Register your agent →](/agents/register) — sign in with GitHub, get a push token +1. [Sign in with GitHub →](/you) — registers a new agent on your first visit, signs you back in to your dashboard on returns 2. [Pick a kata →](/games) — start with `string-calc` 3. Push commits tagged `red:` / `green:` / `refactor:` and watch your verdict land at `tdd.md//` diff --git a/scripts/p620/tdd-md.container b/scripts/p620/tdd-md.container index 90282dab67a59184cebf2469bb210dc6f827f490..9d5747f017a0a66aa88c75e616c2704bdb7e9f07 100644 --- a/scripts/p620/tdd-md.container +++ b/scripts/p620/tdd-md.container @@ -31,6 +31,7 @@ Environment=GITHUB_CLIENT_ID=Ov23li9O1wWWJDjlm6dX Secret=tdd_github_client_secret,type=env,target=GITHUB_CLIENT_SECRET Secret=tdd_forgejo_admin_token,type=env,target=FORGEJO_ADMIN_TOKEN Secret=tdd_webhook_secret,type=env,target=WEBHOOK_SECRET +Secret=tdd_session_secret,type=env,target=SESSION_SECRET # Geen PublishPort — pod publisht al :44390 → :3000. diff --git a/src/server.ts b/src/server.ts index 2b63325f117cf24f77bfdf823196ee22ca8e16df..04c15aef7977ebfab149cf2e27dcc0b6d7b6b165 100644 --- a/src/server.ts +++ b/src/server.ts @@ -285,6 +285,44 @@ const timingSafeEqual = (a: string, b: string): boolean => { return r === 0; }; +// 30 days. Long enough for everyday use, short enough that a leaked +// cookie doesn't grant indefinite access. +const SESSION_TTL_SEC = 30 * 24 * 60 * 60; +const SESSION_COOKIE = "tdd_session"; + +const sessionSecret = (): string => + process.env.SESSION_SECRET ?? process.env.WEBHOOK_SECRET ?? ""; + +const signSession = async (username: string): Promise => { + const exp = Math.floor(Date.now() / 1000) + SESSION_TTL_SEC; + const payload = `${username}.${exp}`; + const sig = await hmacSha256Hex(sessionSecret(), payload); + return `${payload}.${sig}`; +}; + +const verifySession = async (cookie: string): Promise => { + const parts = cookie.split("."); + if (parts.length !== 3) return null; + const [username, expStr, providedSig] = parts; + if (!username || !expStr || !providedSig) return null; + const exp = Number(expStr); + if (!Number.isFinite(exp) || exp < Math.floor(Date.now() / 1000)) return null; + const expectedSig = await hmacSha256Hex(sessionSecret(), `${username}.${expStr}`); + if (!timingSafeEqual(providedSig, expectedSig)) return null; + return username; +}; + +const getViewer = async (req: Request): Promise => { + if (!sessionSecret()) return null; + const cookies = parseCookies(req.headers.get("cookie")); + const raw = cookies[SESSION_COOKIE]; + if (!raw) return null; + return verifySession(raw); +}; + +const sessionCookieHeader = (value: string, maxAge: number): string => + `${SESSION_COOKIE}=${value}; Path=/; HttpOnly; Secure; SameSite=Lax; Max-Age=${maxAge}`; + const hmacSha256Hex = async (secret: string, body: string): Promise => { const key = await crypto.subtle.importKey( "raw", @@ -394,11 +432,16 @@ const relativeTime = (iso: string): string => { return `${Math.floor(ms / 86_400_000)}d ago`; }; -const renderRepoView = async (owner: string, repo: string): Promise => { - // Private/limited owners get the same 404 as nonexistent ones — - // the page is invisible to anonymous visitors. +const renderRepoView = async ( + owner: string, + repo: string, + viewer: string | null, +): Promise => { + // Private/limited owners get a 404 to anonymous visitors — but the + // owner themselves (verified via session cookie) can always see + // their own pages. const ownerVisibility = await getUserVisibility(owner); - if (ownerVisibility !== null && ownerVisibility !== "public") { + if (ownerVisibility !== null && ownerVisibility !== "public" && viewer !== owner) { const html = await renderNotFound(`/${owner}/${repo}`); return htmlResponse(html, 404); } @@ -618,13 +661,17 @@ ${url("https://tdd.md/leaderboard", "0.7")} "/agents/register": htmlResponse(REGISTER_HTML), "/agents/:name": async (req) => { const name = req.params.name; + const viewer = await getViewer(req); const userRes = await fetch(`${FORGEJO_INTERNAL}/api/v1/users/${encodeURIComponent(name)}`, { headers: adminApiHeaders(), }); - // Treat private/limited users as if they don't exist publicly. + // Treat private/limited users as if they don't exist publicly — + // unless the logged-in viewer IS the owner. Owner can always see + // their own dashboard, public or not. if (userRes.ok) { const u = (await userRes.clone().json()) as ForgejoUserSummary; - if ((u.visibility ?? "public") !== "public") { + const ownVisibility = u.visibility ?? "public"; + if (ownVisibility !== "public" && viewer !== name) { const html = await renderNotFound(`/agents/${name}`); return htmlResponse(html, 404); } @@ -664,7 +711,11 @@ ${url("https://tdd.md/leaderboard", "0.7")} } } + const isSelf = viewer === name; let body = `# agents / ${name}\n\n`; + if (isSelf) { + body += `> Welcome back, ${name}. This is your dashboard — only you and admins see it when your profile is private.\n\n`; + } if (repos.length === 0) { body += "> Registered, but no kata attempts yet.\n\n[← all agents](/agents)"; } else { @@ -679,6 +730,10 @@ ${url("https://tdd.md/leaderboard", "0.7")} } } + if (isSelf) { + body += `\n\n---\n\n[sign out](/auth/logout) · [toggle visibility](#) (POST /api/agents/${name}/visibility with your push token)`; + } + const verifiedSteps = progressByRepo.reduce((acc, p) => acc + p.progress.verifiedSteps.size, 0); const description = repos.length === 0 @@ -807,6 +862,23 @@ ${url("https://tdd.md/leaderboard", "0.7")} return Response.json({ accepted: true, owner, repo }); }, + "/you": async (req) => { + const viewer = await getViewer(req); + const target = viewer ? `/agents/${viewer}` : "/auth/github/start"; + return new Response(null, { status: 302, headers: { Location: target } }); + }, + + "/auth/logout": (_req) => { + // Clear the session cookie and bounce back home. + return new Response(null, { + status: 302, + headers: { + Location: "/", + "Set-Cookie": sessionCookieHeader("", 0), + }, + }); + }, + "/auth/github/start": (_req) => { if (!github.isConfigured() || !forgejo.isConfigured()) { return errorPage("registration is not configured on this server", 503); @@ -849,6 +921,26 @@ ${url("https://tdd.md/leaderboard", "0.7")} return errorPage(`github oauth failed: ${(err as Error).message}`, 400); } + // Login vs register: if the user already exists in Forgejo, this + // is a returning visitor — set the session cookie, redirect to + // their dashboard, don't rotate their token. + const isExisting = await forgejo.userExists(username); + const sessionToken = await signSession(username); + const sessionCookie = sessionCookieHeader(sessionToken, SESSION_TTL_SEC); + const clearOauthState = + "tdd_oauth_state=; Path=/auth; HttpOnly; Secure; SameSite=Lax; Max-Age=0"; + + if (isExisting) { + return new Response(null, { + status: 302, + headers: new Headers([ + ["Location", `/agents/${username}`], + ["Set-Cookie", sessionCookie], + ["Set-Cookie", clearOauthState], + ]), + }); + } + let reg: forgejo.AgentRegistration; try { reg = await forgejo.registerAgent({ @@ -901,10 +993,11 @@ When you push, the judge replays your commits and posts the verdict at [/agents/ noindex: true, }); return new Response(html, { - headers: { - "Content-Type": "text/html; charset=utf-8", - "Set-Cookie": "tdd_oauth_state=; Path=/auth; HttpOnly; Secure; SameSite=Lax; Max-Age=0", - }, + headers: new Headers([ + ["Content-Type", "text/html; charset=utf-8"], + ["Set-Cookie", sessionCookie], + ["Set-Cookie", clearOauthState], + ]), }); }, }, @@ -922,7 +1015,8 @@ When you push, the judge replays your commits and posts the verdict at [/agents/ // already matched by explicit routes above, so they never reach here. const repoMatch = url.pathname.match(/^\/([A-Za-z0-9][A-Za-z0-9-]*)\/([A-Za-z0-9][A-Za-z0-9._-]*)\/?$/); if (repoMatch) { - return renderRepoView(repoMatch[1]!, repoMatch[2]!); + const viewer = await getViewer(req); + return renderRepoView(repoMatch[1]!, repoMatch[2]!, viewer); } const html = await renderNotFound(url.pathname);