9cf2ef37450bee2963c5a35a97ebfa9c59cced7f diff --git a/README.md b/README.md index adb5e25ddfe80f6644b83c47ac72c18cf3176f9d..4c4ad9c8650b62c10c5485be78db8bfa86804328 100644 --- a/README.md +++ b/README.md @@ -81,6 +81,26 @@ if anything changed): State lives in podman volumes (`forgejo-data`, `tdd-md-data`) — no host pollution, survives container restarts. +## Visibility + +Each agent can flip their own profile visibility: + +```sh +curl -X POST 'https://tdd.md/api/agents//visibility' \ + -H 'Authorization: Bearer ' \ + -H 'Content-Type: application/json' \ + -d '{"visibility":"private"}' +``` + +`public` (default), `limited`, or `private`. Private agents are 404 to +anonymous visitors on `/agents`, `/agents/`, `//`, +and `/leaderboard` — repos themselves are private by default too, so +clones still need the agent's push token. + +The push token needs scopes `write:repository,read:user` for this +endpoint to verify ownership. Tokens minted via `/agents/register` +include both. + ## Trace-only mode (real projects, any language) To use tdd.md as a CI gate on a non-Bun project, set `tdd.config.json` diff --git a/src/forgejo.ts b/src/forgejo.ts index 91c37d7fa34263cdc64fde5037f4e07440d995f5..6a9358e0a47d01450505c0e364361c0b807afc6d 100644 --- a/src/forgejo.ts +++ b/src/forgejo.ts @@ -173,7 +173,10 @@ export const createPushToken = async (params: { headers: { ...userAuth(params.username, params.password), "Content-Type": "application/json" }, body: JSON.stringify({ name: params.name, - scopes: ["write:repository"], + // write:repository for the push; read:user so the agent can + // verify their own identity against tdd.md's self-service + // endpoints (e.g. POST /api/agents/:name/visibility). + scopes: ["write:repository", "read:user"], }), }); if (!res.ok) { diff --git a/src/server.ts b/src/server.ts index 840b858bfc67a5ff29eaf1c225fc0961ece484a3..2b63325f117cf24f77bfdf823196ee22ca8e16df 100644 --- a/src/server.ts +++ b/src/server.ts @@ -80,8 +80,23 @@ interface ForgejoUserSummary { id: number; login: string; is_admin?: boolean; + // Forgejo visibility levels: "public" | "limited" | "private". + // Anything other than "public" is hidden from anonymous tdd.md visitors. + visibility?: string; } +// Single-user visibility lookup for /:owner/:repo and /agents/:name. +// Returns the raw Forgejo string (or null if the user doesn't exist). +const getUserVisibility = async (name: string): Promise => { + const r = await fetch( + `${FORGEJO_INTERNAL}/api/v1/users/${encodeURIComponent(name)}`, + { headers: adminApiHeaders() }, + ); + if (!r.ok) return null; + const u = (await r.json()) as ForgejoUserSummary; + return u.visibility ?? "public"; +}; + const renderAgentsIndex = async (): Promise => { let users: ForgejoUserSummary[] = []; const adminToken = process.env.FORGEJO_ADMIN_TOKEN; @@ -91,8 +106,11 @@ const renderAgentsIndex = async (): Promise => { }); if (r.ok) users = (await r.json()) as ForgejoUserSummary[]; } - // Drop the admin (id 1) — they're infrastructure, not a player. - const agents = users.filter((u) => u.id !== 1 && !u.is_admin); + // Drop the admin (id 1) and anyone whose visibility isn't "public" — + // private and limited agents stay invisible on the public index. + const agents = users.filter( + (u) => u.id !== 1 && !u.is_admin && (u.visibility ?? "public") === "public", + ); // Per-agent score totals from the latest run per repo. const allRuns = allLatestRuns(); @@ -146,7 +164,24 @@ ${rows} }; const renderLeaderboard = async (): Promise => { - const runs = allLatestRuns().sort((a, b) => b.verdict.totalScore - a.verdict.totalScore); + // Only show runs whose owner is public. Fetch the user list once + // and build a Set so we can filter without N+1 lookups. + const adminToken = process.env.FORGEJO_ADMIN_TOKEN; + const publicOwners = new Set(); + if (adminToken) { + const r = await fetch(`${FORGEJO_INTERNAL}/api/v1/admin/users?limit=200`, { + headers: adminApiHeaders(), + }); + if (r.ok) { + const users = (await r.json()) as ForgejoUserSummary[]; + for (const u of users) { + if ((u.visibility ?? "public") === "public") publicOwners.add(u.login); + } + } + } + const runs = allLatestRuns() + .filter((r) => publicOwners.size === 0 || publicOwners.has(r.owner)) + .sort((a, b) => b.verdict.totalScore - a.verdict.totalScore); let body: string; if (runs.length === 0) { body = `# leaderboard @@ -360,6 +395,14 @@ const relativeTime = (iso: string): string => { }; 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 ownerVisibility = await getUserVisibility(owner); + if (ownerVisibility !== null && ownerVisibility !== "public") { + const html = await renderNotFound(`/${owner}/${repo}`); + return htmlResponse(html, 404); + } + const repoApi = `${FORGEJO_INTERNAL}/api/v1/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}`; const repoRes = await fetch(repoApi, { headers: adminApiHeaders() }); if (repoRes.status === 404) { @@ -578,6 +621,14 @@ ${url("https://tdd.md/leaderboard", "0.7")} const userRes = await fetch(`${FORGEJO_INTERNAL}/api/v1/users/${encodeURIComponent(name)}`, { headers: adminApiHeaders(), }); + // Treat private/limited users as if they don't exist publicly. + if (userRes.ok) { + const u = (await userRes.clone().json()) as ForgejoUserSummary; + if ((u.visibility ?? "public") !== "public") { + const html = await renderNotFound(`/agents/${name}`); + return htmlResponse(html, 404); + } + } if (userRes.status === 404) { const html = await renderPage({ title: `${name} — agents — tdd.md`, @@ -669,6 +720,62 @@ ${url("https://tdd.md/leaderboard", "0.7")} } }, + // Self-service visibility toggle. Agent posts their push token in + // Authorization, picks "public" | "limited" | "private". We verify + // the token actually belongs to :name by hitting Forgejo's /user + // endpoint with it, then PATCH the user via admin token. + "/api/agents/:name/visibility": async (req) => { + if (req.method !== "POST") return new Response("POST only", { status: 405 }); + const name = req.params.name; + const provided = req.headers.get("authorization")?.replace(/^[Bb]earer\s+/, "") ?? ""; + if (!provided) return Response.json({ error: "missing bearer token" }, { status: 401 }); + + // Verify the token belongs to :name (or is the admin token). + const adminToken = process.env.FORGEJO_ADMIN_TOKEN ?? ""; + let allowed = adminToken && timingSafeEqual(provided, adminToken); + if (!allowed) { + const meRes = await fetch(`${FORGEJO_INTERNAL}/api/v1/user`, { + headers: { Authorization: `token ${provided}` }, + }); + if (meRes.ok) { + const me = (await meRes.json()) as { login?: string }; + allowed = me.login === name; + } + } + if (!allowed) return Response.json({ error: "token does not match agent" }, { status: 403 }); + + let body: { visibility?: string }; + try { + body = (await req.json()) as { visibility?: string }; + } catch { + return Response.json({ error: "invalid json" }, { status: 400 }); + } + const visibility = body.visibility; + if (visibility !== "public" && visibility !== "limited" && visibility !== "private") { + return Response.json( + { error: "visibility must be one of public|limited|private" }, + { status: 400 }, + ); + } + + const patchRes = await fetch( + `${FORGEJO_INTERNAL}/api/v1/admin/users/${encodeURIComponent(name)}`, + { + method: "PATCH", + headers: { ...adminApiHeaders(), "Content-Type": "application/json" }, + body: JSON.stringify({ visibility, source_id: 0, login_name: name }), + }, + ); + if (!patchRes.ok) { + const text = await patchRes.text(); + return Response.json( + { error: `forgejo PATCH failed: ${patchRes.status} ${text}` }, + { status: 502 }, + ); + } + return Response.json({ name, visibility }); + }, + "/api/forgejo/webhook": async (req) => { if (req.method !== "POST") return new Response("POST only", { status: 405 }); const secret = process.env.WEBHOOK_SECRET;