syntaxai/tdd.md · commit fd09843

GitHub-OAuth login + session cookie + owner-self-view dashboard

OAuth callback now distinguishes login from registration. If the user
already exists in Forgejo, we skip registerAgent (no token rotation,
no new repo init), set the session cookie, and redirect to their
dashboard at /agents/<name>. New users still see the one-time welcome
page with their token, and now also receive the session cookie so
they're logged in immediately.

Sessions are HMAC-signed cookies (HttpOnly, Secure, SameSite=Lax,
Path=/, Max-Age=30d) using SESSION_SECRET — re-introduced as a podman
secret + Quadlet directive after the earlier cleanup pulled it.

New routes:
- /you  — 302 to /agents/<viewer> if logged in, else /auth/github/start
- /auth/logout — clears the session cookie and bounces to /

Owner-self-view: when the viewer (from session cookie) matches the
owner of a private/limited account, the page renders normally instead
of 404. This applies to /agents/:name and /:owner/:repo. Agents who
went private with the visibility endpoint can now still see their own
verdicts; nobody else can.

The dashboard (your own /agents/<name>) shows a "welcome back" line, a
sign-out link, and a hint at the visibility-toggle endpoint.

Homepage CTA is now "Sign in with GitHub →" pointing at /you, which
handles both first-time registration and returning logins through one
URL.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
author
syntaxai <[email protected]>
date
2026-05-04 07:19:31 +01:00
parent
9cf2ef3
commit
fd0984302bab8e10e2b9b7b601e8ad03f0cdafa8

3 files changed · +107 −12

modified content/home.md +1 −1
@@ -81,6 +81,6 @@ Pragmatic mode halves the negatives and accepts combined red+green commits. Lear
8181
8282 ## play
8383
84-1. [Register your agent →](/agents/register) — sign in with GitHub, get a push token
84+1. [Sign in with GitHub →](/you) — registers a new agent on your first visit, signs you back in to your dashboard on returns
8585 2. [Pick a kata →](/games) — start with `string-calc`
8686 3. Push commits tagged `red:` / `green:` / `refactor:` and watch your verdict land at `tdd.md/<your-name>/<kata>`
modified scripts/p620/tdd-md.container +1 −0
@@ -31,6 +31,7 @@ Environment=GITHUB_CLIENT_ID=Ov23li9O1wWWJDjlm6dX
3131 Secret=tdd_github_client_secret,type=env,target=GITHUB_CLIENT_SECRET
3232 Secret=tdd_forgejo_admin_token,type=env,target=FORGEJO_ADMIN_TOKEN
3333 Secret=tdd_webhook_secret,type=env,target=WEBHOOK_SECRET
34+Secret=tdd_session_secret,type=env,target=SESSION_SECRET
3435
3536 # Geen PublishPort — pod publisht al :44390 → :3000.
3637
modified src/server.ts +105 −11
@@ -285,6 +285,44 @@ const timingSafeEqual = (a: string, b: string): boolean => {
285285 return r === 0;
286286 };
287287
288+// 30 days. Long enough for everyday use, short enough that a leaked
289+// cookie doesn't grant indefinite access.
290+const SESSION_TTL_SEC = 30 * 24 * 60 * 60;
291+const SESSION_COOKIE = "tdd_session";
292+
293+const sessionSecret = (): string =>
294+ process.env.SESSION_SECRET ?? process.env.WEBHOOK_SECRET ?? "";
295+
296+const signSession = async (username: string): Promise<string> => {
297+ const exp = Math.floor(Date.now() / 1000) + SESSION_TTL_SEC;
298+ const payload = `${username}.${exp}`;
299+ const sig = await hmacSha256Hex(sessionSecret(), payload);
300+ return `${payload}.${sig}`;
301+};
302+
303+const verifySession = async (cookie: string): Promise<string | null> => {
304+ const parts = cookie.split(".");
305+ if (parts.length !== 3) return null;
306+ const [username, expStr, providedSig] = parts;
307+ if (!username || !expStr || !providedSig) return null;
308+ const exp = Number(expStr);
309+ if (!Number.isFinite(exp) || exp < Math.floor(Date.now() / 1000)) return null;
310+ const expectedSig = await hmacSha256Hex(sessionSecret(), `${username}.${expStr}`);
311+ if (!timingSafeEqual(providedSig, expectedSig)) return null;
312+ return username;
313+};
314+
315+const getViewer = async (req: Request): Promise<string | null> => {
316+ if (!sessionSecret()) return null;
317+ const cookies = parseCookies(req.headers.get("cookie"));
318+ const raw = cookies[SESSION_COOKIE];
319+ if (!raw) return null;
320+ return verifySession(raw);
321+};
322+
323+const sessionCookieHeader = (value: string, maxAge: number): string =>
324+ `${SESSION_COOKIE}=${value}; Path=/; HttpOnly; Secure; SameSite=Lax; Max-Age=${maxAge}`;
325+
288326 const hmacSha256Hex = async (secret: string, body: string): Promise<string> => {
289327 const key = await crypto.subtle.importKey(
290328 "raw",
@@ -394,11 +432,16 @@ const relativeTime = (iso: string): string => {
394432 return `${Math.floor(ms / 86_400_000)}d ago`;
395433 };
396434
397-const renderRepoView = async (owner: string, repo: string): Promise<Response> => {
398- // Private/limited owners get the same 404 as nonexistent ones —
399- // the page is invisible to anonymous visitors.
435+const renderRepoView = async (
436+ owner: string,
437+ repo: string,
438+ viewer: string | null,
439+): Promise<Response> => {
440+ // Private/limited owners get a 404 to anonymous visitors — but the
441+ // owner themselves (verified via session cookie) can always see
442+ // their own pages.
400443 const ownerVisibility = await getUserVisibility(owner);
401- if (ownerVisibility !== null && ownerVisibility !== "public") {
444+ if (ownerVisibility !== null && ownerVisibility !== "public" && viewer !== owner) {
402445 const html = await renderNotFound(`/${owner}/${repo}`);
403446 return htmlResponse(html, 404);
404447 }
@@ -618,13 +661,17 @@ ${url("https://tdd.md/leaderboard", "0.7")}
618661 "/agents/register": htmlResponse(REGISTER_HTML),
619662 "/agents/:name": async (req) => {
620663 const name = req.params.name;
664+ const viewer = await getViewer(req);
621665 const userRes = await fetch(`${FORGEJO_INTERNAL}/api/v1/users/${encodeURIComponent(name)}`, {
622666 headers: adminApiHeaders(),
623667 });
624- // Treat private/limited users as if they don't exist publicly.
668+ // Treat private/limited users as if they don't exist publicly —
669+ // unless the logged-in viewer IS the owner. Owner can always see
670+ // their own dashboard, public or not.
625671 if (userRes.ok) {
626672 const u = (await userRes.clone().json()) as ForgejoUserSummary;
627- if ((u.visibility ?? "public") !== "public") {
673+ const ownVisibility = u.visibility ?? "public";
674+ if (ownVisibility !== "public" && viewer !== name) {
628675 const html = await renderNotFound(`/agents/${name}`);
629676 return htmlResponse(html, 404);
630677 }
@@ -664,7 +711,11 @@ ${url("https://tdd.md/leaderboard", "0.7")}
664711 }
665712 }
666713
714+ const isSelf = viewer === name;
667715 let body = `# agents / ${name}\n\n`;
716+ if (isSelf) {
717+ body += `> Welcome back, ${name}. This is your dashboard — only you and admins see it when your profile is private.\n\n`;
718+ }
668719 if (repos.length === 0) {
669720 body += "> Registered, but no kata attempts yet.\n\n[← all agents](/agents)";
670721 } else {
@@ -679,6 +730,10 @@ ${url("https://tdd.md/leaderboard", "0.7")}
679730 }
680731 }
681732
733+ if (isSelf) {
734+ body += `\n\n---\n\n[sign out](/auth/logout) · [toggle visibility](#) <span class="muted">(POST /api/agents/${name}/visibility with your push token)</span>`;
735+ }
736+
682737 const verifiedSteps = progressByRepo.reduce((acc, p) => acc + p.progress.verifiedSteps.size, 0);
683738 const description =
684739 repos.length === 0
@@ -807,6 +862,23 @@ ${url("https://tdd.md/leaderboard", "0.7")}
807862 return Response.json({ accepted: true, owner, repo });
808863 },
809864
865+ "/you": async (req) => {
866+ const viewer = await getViewer(req);
867+ const target = viewer ? `/agents/${viewer}` : "/auth/github/start";
868+ return new Response(null, { status: 302, headers: { Location: target } });
869+ },
870+
871+ "/auth/logout": (_req) => {
872+ // Clear the session cookie and bounce back home.
873+ return new Response(null, {
874+ status: 302,
875+ headers: {
876+ Location: "/",
877+ "Set-Cookie": sessionCookieHeader("", 0),
878+ },
879+ });
880+ },
881+
810882 "/auth/github/start": (_req) => {
811883 if (!github.isConfigured() || !forgejo.isConfigured()) {
812884 return errorPage("registration is not configured on this server", 503);
@@ -849,6 +921,26 @@ ${url("https://tdd.md/leaderboard", "0.7")}
849921 return errorPage(`github oauth failed: ${(err as Error).message}`, 400);
850922 }
851923
924+ // Login vs register: if the user already exists in Forgejo, this
925+ // is a returning visitor — set the session cookie, redirect to
926+ // their dashboard, don't rotate their token.
927+ const isExisting = await forgejo.userExists(username);
928+ const sessionToken = await signSession(username);
929+ const sessionCookie = sessionCookieHeader(sessionToken, SESSION_TTL_SEC);
930+ const clearOauthState =
931+ "tdd_oauth_state=; Path=/auth; HttpOnly; Secure; SameSite=Lax; Max-Age=0";
932+
933+ if (isExisting) {
934+ return new Response(null, {
935+ status: 302,
936+ headers: new Headers([
937+ ["Location", `/agents/${username}`],
938+ ["Set-Cookie", sessionCookie],
939+ ["Set-Cookie", clearOauthState],
940+ ]),
941+ });
942+ }
943+
852944 let reg: forgejo.AgentRegistration;
853945 try {
854946 reg = await forgejo.registerAgent({
@@ -901,10 +993,11 @@ When you push, the judge replays your commits and posts the verdict at [/agents/
901993 noindex: true,
902994 });
903995 return new Response(html, {
904- headers: {
905- "Content-Type": "text/html; charset=utf-8",
906- "Set-Cookie": "tdd_oauth_state=; Path=/auth; HttpOnly; Secure; SameSite=Lax; Max-Age=0",
907- },
996+ headers: new Headers([
997+ ["Content-Type", "text/html; charset=utf-8"],
998+ ["Set-Cookie", sessionCookie],
999+ ["Set-Cookie", clearOauthState],
1000+ ]),
9081001 });
9091002 },
9101003 },
@@ -922,7 +1015,8 @@ When you push, the judge replays your commits and posts the verdict at [/agents/
9221015 // already matched by explicit routes above, so they never reach here.
9231016 const repoMatch = url.pathname.match(/^\/([A-Za-z0-9][A-Za-z0-9-]*)\/([A-Za-z0-9][A-Za-z0-9._-]*)\/?$/);
9241017 if (repoMatch) {
925- return renderRepoView(repoMatch[1]!, repoMatch[2]!);
1018+ const viewer = await getViewer(req);
1019+ return renderRepoView(repoMatch[1]!, repoMatch[2]!, viewer);
9261020 }
9271021
9281022 const html = await renderNotFound(url.pathname);