| 285 | 285 | return r === 0; |
| 286 | 286 | }; |
| 287 | 287 | |
| 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 | + |
| 288 | 326 | const hmacSha256Hex = async (secret: string, body: string): Promise<string> => { |
| 289 | 327 | const key = await crypto.subtle.importKey( |
| 290 | 328 | "raw", |
| 394 | 432 | return `${Math.floor(ms / 86_400_000)}d ago`; |
| 395 | 433 | }; |
| 396 | 434 | |
| 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. |
| 400 | 443 | const ownerVisibility = await getUserVisibility(owner); |
| 401 | | - if (ownerVisibility !== null && ownerVisibility !== "public") { |
| 444 | + if (ownerVisibility !== null && ownerVisibility !== "public" && viewer !== owner) { |
| 402 | 445 | const html = await renderNotFound(`/${owner}/${repo}`); |
| 403 | 446 | return htmlResponse(html, 404); |
| 404 | 447 | } |
| 618 | 661 | "/agents/register": htmlResponse(REGISTER_HTML), |
| 619 | 662 | "/agents/:name": async (req) => { |
| 620 | 663 | const name = req.params.name; |
| 664 | + const viewer = await getViewer(req); |
| 621 | 665 | const userRes = await fetch(`${FORGEJO_INTERNAL}/api/v1/users/${encodeURIComponent(name)}`, { |
| 622 | 666 | headers: adminApiHeaders(), |
| 623 | 667 | }); |
| 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. |
| 625 | 671 | if (userRes.ok) { |
| 626 | 672 | 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) { |
| 628 | 675 | const html = await renderNotFound(`/agents/${name}`); |
| 629 | 676 | return htmlResponse(html, 404); |
| 630 | 677 | } |
| 664 | 711 | } |
| 665 | 712 | } |
| 666 | 713 | |
| 714 | + const isSelf = viewer === name; |
| 667 | 715 | 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 | + } |
| 668 | 719 | if (repos.length === 0) { |
| 669 | 720 | body += "> Registered, but no kata attempts yet.\n\n[← all agents](/agents)"; |
| 670 | 721 | } else { |
| 679 | 730 | } |
| 680 | 731 | } |
| 681 | 732 | |
| 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 | + |
| 682 | 737 | const verifiedSteps = progressByRepo.reduce((acc, p) => acc + p.progress.verifiedSteps.size, 0); |
| 683 | 738 | const description = |
| 684 | 739 | repos.length === 0 |
| 807 | 862 | return Response.json({ accepted: true, owner, repo }); |
| 808 | 863 | }, |
| 809 | 864 | |
| 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 | + |
| 810 | 882 | "/auth/github/start": (_req) => { |
| 811 | 883 | if (!github.isConfigured() || !forgejo.isConfigured()) { |
| 812 | 884 | return errorPage("registration is not configured on this server", 503); |
| 849 | 921 | return errorPage(`github oauth failed: ${(err as Error).message}`, 400); |
| 850 | 922 | } |
| 851 | 923 | |
| 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 | + |
| 852 | 944 | let reg: forgejo.AgentRegistration; |
| 853 | 945 | try { |
| 854 | 946 | reg = await forgejo.registerAgent({ |
| 901 | 993 | noindex: true, |
| 902 | 994 | }); |
| 903 | 995 | 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 | + ]), |
| 908 | 1001 | }); |
| 909 | 1002 | }, |
| 910 | 1003 | }, |
| 922 | 1015 | // already matched by explicit routes above, so they never reach here. |
| 923 | 1016 | const repoMatch = url.pathname.match(/^\/([A-Za-z0-9][A-Za-z0-9-]*)\/([A-Za-z0-9][A-Za-z0-9._-]*)\/?$/); |
| 924 | 1017 | if (repoMatch) { |
| 925 | | - return renderRepoView(repoMatch[1]!, repoMatch[2]!); |
| 1018 | + const viewer = await getViewer(req); |
| 1019 | + return renderRepoView(repoMatch[1]!, repoMatch[2]!, viewer); |
| 926 | 1020 | } |
| 927 | 1021 | |
| 928 | 1022 | const html = await renderNotFound(url.pathname); |