| 80 | 80 | id: number; |
| 81 | 81 | login: string; |
| 82 | 82 | is_admin?: boolean; |
| 83 | + // Forgejo visibility levels: "public" | "limited" | "private". |
| 84 | + // Anything other than "public" is hidden from anonymous tdd.md visitors. |
| 85 | + visibility?: string; |
| 83 | 86 | } |
| 84 | 87 | |
| 88 | +// Single-user visibility lookup for /:owner/:repo and /agents/:name. |
| 89 | +// Returns the raw Forgejo string (or null if the user doesn't exist). |
| 90 | +const getUserVisibility = async (name: string): Promise<string | null> => { |
| 91 | + const r = await fetch( |
| 92 | + `${FORGEJO_INTERNAL}/api/v1/users/${encodeURIComponent(name)}`, |
| 93 | + { headers: adminApiHeaders() }, |
| 94 | + ); |
| 95 | + if (!r.ok) return null; |
| 96 | + const u = (await r.json()) as ForgejoUserSummary; |
| 97 | + return u.visibility ?? "public"; |
| 98 | +}; |
| 99 | + |
| 85 | 100 | const renderAgentsIndex = async (): Promise<Response> => { |
| 86 | 101 | let users: ForgejoUserSummary[] = []; |
| 87 | 102 | const adminToken = process.env.FORGEJO_ADMIN_TOKEN; |
| 91 | 106 | }); |
| 92 | 107 | if (r.ok) users = (await r.json()) as ForgejoUserSummary[]; |
| 93 | 108 | } |
| 94 | | - // Drop the admin (id 1) — they're infrastructure, not a player. |
| 95 | | - const agents = users.filter((u) => u.id !== 1 && !u.is_admin); |
| 109 | + // Drop the admin (id 1) and anyone whose visibility isn't "public" — |
| 110 | + // private and limited agents stay invisible on the public index. |
| 111 | + const agents = users.filter( |
| 112 | + (u) => u.id !== 1 && !u.is_admin && (u.visibility ?? "public") === "public", |
| 113 | + ); |
| 96 | 114 | |
| 97 | 115 | // Per-agent score totals from the latest run per repo. |
| 98 | 116 | const allRuns = allLatestRuns(); |
| 146 | 164 | }; |
| 147 | 165 | |
| 148 | 166 | const renderLeaderboard = async (): Promise<Response> => { |
| 149 | | - const runs = allLatestRuns().sort((a, b) => b.verdict.totalScore - a.verdict.totalScore); |
| 167 | + // Only show runs whose owner is public. Fetch the user list once |
| 168 | + // and build a Set so we can filter without N+1 lookups. |
| 169 | + const adminToken = process.env.FORGEJO_ADMIN_TOKEN; |
| 170 | + const publicOwners = new Set<string>(); |
| 171 | + if (adminToken) { |
| 172 | + const r = await fetch(`${FORGEJO_INTERNAL}/api/v1/admin/users?limit=200`, { |
| 173 | + headers: adminApiHeaders(), |
| 174 | + }); |
| 175 | + if (r.ok) { |
| 176 | + const users = (await r.json()) as ForgejoUserSummary[]; |
| 177 | + for (const u of users) { |
| 178 | + if ((u.visibility ?? "public") === "public") publicOwners.add(u.login); |
| 179 | + } |
| 180 | + } |
| 181 | + } |
| 182 | + const runs = allLatestRuns() |
| 183 | + .filter((r) => publicOwners.size === 0 || publicOwners.has(r.owner)) |
| 184 | + .sort((a, b) => b.verdict.totalScore - a.verdict.totalScore); |
| 150 | 185 | let body: string; |
| 151 | 186 | if (runs.length === 0) { |
| 152 | 187 | body = `# leaderboard |
| 360 | 395 | }; |
| 361 | 396 | |
| 362 | 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. |
| 400 | + const ownerVisibility = await getUserVisibility(owner); |
| 401 | + if (ownerVisibility !== null && ownerVisibility !== "public") { |
| 402 | + const html = await renderNotFound(`/${owner}/${repo}`); |
| 403 | + return htmlResponse(html, 404); |
| 404 | + } |
| 405 | + |
| 363 | 406 | const repoApi = `${FORGEJO_INTERNAL}/api/v1/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}`; |
| 364 | 407 | const repoRes = await fetch(repoApi, { headers: adminApiHeaders() }); |
| 365 | 408 | if (repoRes.status === 404) { |
| 578 | 621 | const userRes = await fetch(`${FORGEJO_INTERNAL}/api/v1/users/${encodeURIComponent(name)}`, { |
| 579 | 622 | headers: adminApiHeaders(), |
| 580 | 623 | }); |
| 624 | + // Treat private/limited users as if they don't exist publicly. |
| 625 | + if (userRes.ok) { |
| 626 | + const u = (await userRes.clone().json()) as ForgejoUserSummary; |
| 627 | + if ((u.visibility ?? "public") !== "public") { |
| 628 | + const html = await renderNotFound(`/agents/${name}`); |
| 629 | + return htmlResponse(html, 404); |
| 630 | + } |
| 631 | + } |
| 581 | 632 | if (userRes.status === 404) { |
| 582 | 633 | const html = await renderPage({ |
| 583 | 634 | title: `${name} — agents — tdd.md`, |
| 669 | 720 | } |
| 670 | 721 | }, |
| 671 | 722 | |
| 723 | + // Self-service visibility toggle. Agent posts their push token in |
| 724 | + // Authorization, picks "public" | "limited" | "private". We verify |
| 725 | + // the token actually belongs to :name by hitting Forgejo's /user |
| 726 | + // endpoint with it, then PATCH the user via admin token. |
| 727 | + "/api/agents/:name/visibility": async (req) => { |
| 728 | + if (req.method !== "POST") return new Response("POST only", { status: 405 }); |
| 729 | + const name = req.params.name; |
| 730 | + const provided = req.headers.get("authorization")?.replace(/^[Bb]earer\s+/, "") ?? ""; |
| 731 | + if (!provided) return Response.json({ error: "missing bearer token" }, { status: 401 }); |
| 732 | + |
| 733 | + // Verify the token belongs to :name (or is the admin token). |
| 734 | + const adminToken = process.env.FORGEJO_ADMIN_TOKEN ?? ""; |
| 735 | + let allowed = adminToken && timingSafeEqual(provided, adminToken); |
| 736 | + if (!allowed) { |
| 737 | + const meRes = await fetch(`${FORGEJO_INTERNAL}/api/v1/user`, { |
| 738 | + headers: { Authorization: `token ${provided}` }, |
| 739 | + }); |
| 740 | + if (meRes.ok) { |
| 741 | + const me = (await meRes.json()) as { login?: string }; |
| 742 | + allowed = me.login === name; |
| 743 | + } |
| 744 | + } |
| 745 | + if (!allowed) return Response.json({ error: "token does not match agent" }, { status: 403 }); |
| 746 | + |
| 747 | + let body: { visibility?: string }; |
| 748 | + try { |
| 749 | + body = (await req.json()) as { visibility?: string }; |
| 750 | + } catch { |
| 751 | + return Response.json({ error: "invalid json" }, { status: 400 }); |
| 752 | + } |
| 753 | + const visibility = body.visibility; |
| 754 | + if (visibility !== "public" && visibility !== "limited" && visibility !== "private") { |
| 755 | + return Response.json( |
| 756 | + { error: "visibility must be one of public|limited|private" }, |
| 757 | + { status: 400 }, |
| 758 | + ); |
| 759 | + } |
| 760 | + |
| 761 | + const patchRes = await fetch( |
| 762 | + `${FORGEJO_INTERNAL}/api/v1/admin/users/${encodeURIComponent(name)}`, |
| 763 | + { |
| 764 | + method: "PATCH", |
| 765 | + headers: { ...adminApiHeaders(), "Content-Type": "application/json" }, |
| 766 | + body: JSON.stringify({ visibility, source_id: 0, login_name: name }), |
| 767 | + }, |
| 768 | + ); |
| 769 | + if (!patchRes.ok) { |
| 770 | + const text = await patchRes.text(); |
| 771 | + return Response.json( |
| 772 | + { error: `forgejo PATCH failed: ${patchRes.status} ${text}` }, |
| 773 | + { status: 502 }, |
| 774 | + ); |
| 775 | + } |
| 776 | + return Response.json({ name, visibility }); |
| 777 | + }, |
| 778 | + |
| 672 | 779 | "/api/forgejo/webhook": async (req) => { |
| 673 | 780 | if (req.method !== "POST") return new Response("POST only", { status: 405 }); |
| 674 | 781 | const secret = process.env.WEBHOOK_SECRET; |