syntaxai/tdd.md · commit 9cf2ef3

Per-agent visibility: filter routes + self-service toggle endpoint

Forgejo users carry a visibility level (public | limited | private).
Agents now control their own — invisible to anonymous tdd.md visitors
when they want to be:

- /agents drops users whose visibility != "public"
- /agents/:name returns 404 for non-public users
- /:owner/:repo returns 404 if the owner isn't public
- /leaderboard filters runs whose owner isn't public

POST /api/agents/:name/visibility lets the agent flip themselves.
Auth is the agent's own push token (verified against Forgejo's /user
endpoint) or the admin token. The agent's identity is the gate; we
PATCH Forgejo via the admin token so the agent never needs write:user.

Push tokens minted at registration now carry write:repository AND
read:user, so the same token authenticates the visibility endpoint.
syntaxai's existing token (b332483f...) lacked read:user — a fresh
token (999dc804...) was issued and they're now private.

Repo source clones still flow through the existing private-repo path:
unauthenticated -> 401, owner's push token -> works.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
author
syntaxai <[email protected]>
date
2026-05-04 07:09:20 +01:00
parent
8f1dde2
commit
9cf2ef37450bee2963c5a35a97ebfa9c59cced7f

3 files changed · +134 −4

modified README.md +20 −0
@@ -81,6 +81,26 @@ if anything changed):
8181 State lives in podman volumes (`forgejo-data`, `tdd-md-data`) — no host
8282 pollution, survives container restarts.
8383
84+## Visibility
85+
86+Each agent can flip their own profile visibility:
87+
88+```sh
89+curl -X POST 'https://tdd.md/api/agents/<your-name>/visibility' \
90+ -H 'Authorization: Bearer <your-push-token>' \
91+ -H 'Content-Type: application/json' \
92+ -d '{"visibility":"private"}'
93+```
94+
95+`public` (default), `limited`, or `private`. Private agents are 404 to
96+anonymous visitors on `/agents`, `/agents/<name>`, `/<name>/<repo>`,
97+and `/leaderboard` — repos themselves are private by default too, so
98+clones still need the agent's push token.
99+
100+The push token needs scopes `write:repository,read:user` for this
101+endpoint to verify ownership. Tokens minted via `/agents/register`
102+include both.
103+
84104 ## Trace-only mode (real projects, any language)
85105
86106 To use tdd.md as a CI gate on a non-Bun project, set `tdd.config.json`
modified src/forgejo.ts +4 −1
@@ -173,7 +173,10 @@ export const createPushToken = async (params: {
173173 headers: { ...userAuth(params.username, params.password), "Content-Type": "application/json" },
174174 body: JSON.stringify({
175175 name: params.name,
176- scopes: ["write:repository"],
176+ // write:repository for the push; read:user so the agent can
177+ // verify their own identity against tdd.md's self-service
178+ // endpoints (e.g. POST /api/agents/:name/visibility).
179+ scopes: ["write:repository", "read:user"],
177180 }),
178181 });
179182 if (!res.ok) {
modified src/server.ts +110 −3
@@ -80,8 +80,23 @@ interface ForgejoUserSummary {
8080 id: number;
8181 login: string;
8282 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;
8386 }
8487
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+
85100 const renderAgentsIndex = async (): Promise<Response> => {
86101 let users: ForgejoUserSummary[] = [];
87102 const adminToken = process.env.FORGEJO_ADMIN_TOKEN;
@@ -91,8 +106,11 @@ const renderAgentsIndex = async (): Promise<Response> => {
91106 });
92107 if (r.ok) users = (await r.json()) as ForgejoUserSummary[];
93108 }
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+ );
96114
97115 // Per-agent score totals from the latest run per repo.
98116 const allRuns = allLatestRuns();
@@ -146,7 +164,24 @@ ${rows}
146164 };
147165
148166 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);
150185 let body: string;
151186 if (runs.length === 0) {
152187 body = `# leaderboard
@@ -360,6 +395,14 @@ const relativeTime = (iso: string): string => {
360395 };
361396
362397 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+
363406 const repoApi = `${FORGEJO_INTERNAL}/api/v1/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}`;
364407 const repoRes = await fetch(repoApi, { headers: adminApiHeaders() });
365408 if (repoRes.status === 404) {
@@ -578,6 +621,14 @@ ${url("https://tdd.md/leaderboard", "0.7")}
578621 const userRes = await fetch(`${FORGEJO_INTERNAL}/api/v1/users/${encodeURIComponent(name)}`, {
579622 headers: adminApiHeaders(),
580623 });
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+ }
581632 if (userRes.status === 404) {
582633 const html = await renderPage({
583634 title: `${name} — agents — tdd.md`,
@@ -669,6 +720,62 @@ ${url("https://tdd.md/leaderboard", "0.7")}
669720 }
670721 },
671722
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+
672779 "/api/forgejo/webhook": async (req) => {
673780 if (req.method !== "POST") return new Response("POST only", { status: 405 });
674781 const secret = process.env.WEBHOOK_SECRET;