Public repositories: viewable without an account; repos get public (default) / private visibility enforced across listings, git, and the UI #345

Closed
opened 2026-09-11 18:26:53 +00:00 by crueber · 3 comments
Owner

What's requested

Public repositories must be viewable by anyone (including signed-out visitors). That requires repositories to have a public/private distinction:

  • Default: public. Private is the opt-in way to hide a repo.
  • Private = only the owner / the org's members can see it. (A fine-grained org permissions system comes later — this ticket is the coarse owner/org boundary only.)
  • Public repos must be reachable without an account.

What already exists (important — this is mostly wiring, not new machinery)

The visibility model and its enforcement already ship — the gap is that a global switch overrides it, and the surfaces never expose or filter it:

  • access.json already carries visibility: AccessDoc{Version, Visibility, RoleBindings, UpdatedAt} (internal/identity/access.go:24-30), with VisibilityPublic/VisibilityPrivate, normalizeAccess validation (:61-86), and public-by-default at creation (:98).
  • Read resolution already honors it: CheckRead grants RoleRead to an anonymous principal when doc.Visibility == VisibilityPublic (internal/identity/access.go:246-248), otherwise falls back to bindings / org-owner.
  • Repo-scoped API reads already consult it: the dispatch layer calls e.Access.CheckRead(...) for every AuthRead non-NonRepo route (internal/api/routes.go:226-231).
  • Creation accepts a visibility: POST body {visibility: "public"|"private"} validated (internal/api/placeholder.go:381-382), EnsureRepoAccess(...) materializes it (:201-237); the SDK already types it (web/sdk/src/types.js:85, web/sdk/src/access.js:18, create.js:82).
  • An access API exists (SDK access.js — {version, visibility, role_bindings[]}, triage+).

The gaps that make public repos unviewable today

  1. The global anonymous_read flag preempts per-repo visibility. Env.gate returns 401 for anonymous on AuthRead whenever server.auth.anonymous_read is false (internal/api/env.go:741-745), and the same check gates the SPA shell, git smart-HTTP (smart.go:118/266/510), LFS (lfs.go:65) and health (health.go:232). On hub.packden.us (OIDC, anonymous_read=false) every public repo is invisible to signed-out users — the exact symptom motivating this ticket. Visibility must become the authority for repo reads: "public ⇒ readable without authentication", with anonymous_read deciding only what unauthenticated non-repo surfaces do (or being deprecated for repo reads — decision below).
  2. Listings do not filter by visibility — and must. repoRegistry.Owners/Repos (cmd/walhub/serve.go:498-547) and the /api/v1/owners* listings return every manifest-backed repo with no visibility consult (verified: no visibility/access reference in the listing path). Today the flag hides them by accident (anonymous gets nothing at all); once step 1 lands, private repos would leak into public listings unless listing filters by the caller's role. This is the privacy-critical half of the change.
  3. No UI to see or set visibility — not on the repo page (no public/private badge), not in repo settings (no control), not in the create flows (the API accepts it; web/src never sends it). Nothing in Settings.jsx mentions visibility.
  4. Private is not surfaced in the API view — summaryBody (internal/api/summary.go:12) has no visibility field, so the UI cannot render a badge or gate affordances even if it wanted to.

Proposed design

  1. Make visibility the read authority for repo-scoped surfaces. In the gate chain, repo reads (API, git smart-HTTP, LFS, SPA repo pages) resolve access via Access.CheckRead first; anonymous_read=false no longer short-circuits a public repo. Keep the flag's meaning for non-repo surfaces (owners list, instance info, setup) or narrow it explicitly — state the chosen semantic in the PR and update docs/go/06_server_http.md/07_api.md (it is a spec-level change to a documented gate).
  2. Filter listings by the caller's read role. Owners/Repos/repos/detailed and the /explore feed must omit repos the caller cannot read (anonymous ⇒ public only; authenticated ⇒ public + their own/org repos). This is where the visibility check must sit — one helper consulted per row, using the existing access LRU (internal/identity/access.go:283+) so it is not a per-repo store round trip; note the cost in the PR (liveRepos already fans out per-repo manifest reads).
  3. Expose visibility in summaryBody (and the detailed listing rows) so the UI can badge and gate; note the cached-summary ETag trap (visibility change with no ref move would serve stale — same class as #235/#247; cover it or serve the field no-cache).
  4. UI: a public/private badge on the repo header next to the mirror badge (mirror pattern, #240/#281), and a visibility control in repo settings (General tab per #235) — triage/admin-gated, matching the access API's auth. Private repos should also mark fork/import-created repos sensibly (inherit or default public — pick and document).
  5. Private semantics (v1, coarse): readable by role bindings (user:/team:), org owners, and global admins; nobody else — including no anonymous. Enumerate the matrix in the PR: author, org owner, team-bound user, other authenticated user, anonymous × public/private × read/write surfaces.
  6. Defaults and migration: repos without access.json (or with an empty/invalid visibility) resolve public — matching the model's existing default (access.go:98) so existing repos don't silently become private. Verify the pre-existing-repo path materializes access on first write or treats missing as public (the AccessBoot.EnsureRepoAccess seam) and state it.

Acceptance criteria

  • A signed-out visitor can browse, clone (HTTP + SSH-if-applicable), and read the API of a public repo on an instance with anonymous_read = false; private repos return 401/404 to them (pick one and be consistent — 404 avoids confirming existence; document the choice).
  • Ownership boundary enforced: private repos are visible to the owner, org owners/members per bindings, and admins — nobody else, on both the HTML and JSON surfaces.
  • /explore, /api/v1/owners, /owners/{owner}/repos, and repos/detailed omit repos the caller cannot read (anonymous ⇒ public only) — no private repo name leaks in any listing, count, or activity/size aggregate (#247/#248 rollups included).
  • New repos default to public; existing repos without access metadata resolve public.
  • Visibility is settable from the UI (repo settings) and readable as a badge on the repo header; the API's existing visibility accept-path is wired to the UI.
  • summaryBody/listing rows carry visibility; the cached-response ETag covers it (or the field is served no-cache) — no stale badge after a visibility flip.
  • Permission matrix documented in the PR (surface × visibility × principal class), with tests per cell for the read paths (API, git smart-HTTP, LFS, listings).
  • Any change to the documented gate semantics (anonymous_read interaction) is stated explicitly as a spec amendment, not slipped in.

Out of scope (explicitly)

Fine-grained org permission model, per-branch/per-path restrictions, and private-repo discovery UX (e.g. "request access"). This ticket is the coarse public/private boundary with the owner/org rule the user specified.

## What's requested Public repositories must be **viewable by anyone** (including signed-out visitors). That requires repositories to have a **public/private** distinction: - **Default: public.** Private is the opt-in way to hide a repo. - **Private** = only the owner / the org's members can see it. (A fine-grained org permissions system comes later — this ticket is the coarse owner/org boundary only.) - Public repos must be reachable without an account. ## What already exists (important — this is mostly wiring, not new machinery) **The visibility model and its enforcement already ship** — the gap is that a *global* switch overrides it, and the surfaces never expose or filter it: - `access.json` already carries visibility: `AccessDoc{Version, Visibility, RoleBindings, UpdatedAt}` (`internal/identity/access.go:24-30`), with `VisibilityPublic`/`VisibilityPrivate`, `normalizeAccess` validation (`:61-86`), and **public-by-default at creation** (`:98`). - Read resolution already honors it: `CheckRead` grants `RoleRead` to an anonymous principal when `doc.Visibility == VisibilityPublic` (`internal/identity/access.go:246-248`), otherwise falls back to bindings / org-owner. - Repo-scoped API reads already consult it: the dispatch layer calls `e.Access.CheckRead(...)` for every `AuthRead` non-NonRepo route (`internal/api/routes.go:226-231`). - Creation accepts a visibility: `POST` body `{visibility: "public"|"private"}` validated (`internal/api/placeholder.go:381-382`), `EnsureRepoAccess(...)` materializes it (`:201-237`); the SDK already types it (`web/sdk/src/types.js:85`, `web/sdk/src/access.js:18`, `create.js:82`). - An access API exists (SDK `access.js` — `{version, visibility, role_bindings[]}`, triage+). ## The gaps that make public repos unviewable today 1. **The global `anonymous_read` flag preempts per-repo visibility.** `Env.gate` returns 401 for anonymous on `AuthRead` whenever `server.auth.anonymous_read` is false (`internal/api/env.go:741-745`), and the same check gates the SPA shell, git smart-HTTP (`smart.go:118/266/510`), LFS (`lfs.go:65`) and health (`health.go:232`). On hub.packden.us (OIDC, anonymous_read=false) **every public repo is invisible to signed-out users** — the exact symptom motivating this ticket. Visibility must become the authority for repo reads: "public ⇒ readable without authentication", with `anonymous_read` deciding only what *unauthenticated non-repo* surfaces do (or being deprecated for repo reads — decision below). 2. **Listings do not filter by visibility — and must.** `repoRegistry.Owners/Repos` (`cmd/walhub/serve.go:498-547`) and the `/api/v1/owners*` listings return every manifest-backed repo with no visibility consult (verified: no `visibility`/`access` reference in the listing path). Today the flag hides them by accident (anonymous gets nothing at all); once step 1 lands, **private repos would leak into public listings** unless listing filters by the caller's role. This is the privacy-critical half of the change. 3. **No UI to see or set visibility** — not on the repo page (no public/private badge), not in repo settings (no control), not in the create flows (the API accepts it; `web/src` never sends it). Nothing in `Settings.jsx` mentions visibility. 4. **Private is not surfaced in the API view** — `summaryBody` (`internal/api/summary.go:12`) has no visibility field, so the UI cannot render a badge or gate affordances even if it wanted to. ## Proposed design 1. **Make visibility the read authority for repo-scoped surfaces.** In the gate chain, repo reads (API, git smart-HTTP, LFS, SPA repo pages) resolve access via `Access.CheckRead` first; `anonymous_read=false` no longer short-circuits a *public* repo. Keep the flag's meaning for non-repo surfaces (owners list, instance info, setup) or narrow it explicitly — state the chosen semantic in the PR and update `docs/go/06_server_http.md`/`07_api.md` (it is a spec-level change to a documented gate). 2. **Filter listings by the caller's read role.** `Owners`/`Repos`/`repos/detailed` and the `/explore` feed must omit repos the caller cannot read (anonymous ⇒ public only; authenticated ⇒ public + their own/org repos). This is where the visibility check must sit — one helper consulted per row, using the existing access LRU (`internal/identity/access.go:283+`) so it is not a per-repo store round trip; note the cost in the PR (`liveRepos` already fans out per-repo manifest reads). 3. **Expose visibility** in `summaryBody` (and the detailed listing rows) so the UI can badge and gate; note the **cached-summary ETag trap** (visibility change with no ref move would serve stale — same class as #235/#247; cover it or serve the field `no-cache`). 4. **UI**: a `public`/`private` badge on the repo header next to the mirror badge (mirror pattern, #240/#281), and a visibility control in repo settings (General tab per #235) — triage/admin-gated, matching the access API's auth. Private repos should also mark fork/import-created repos sensibly (inherit or default public — pick and document). 5. **Private semantics (v1, coarse)**: readable by role bindings (`user:`/`team:`), org owners, and global admins; nobody else — including no anonymous. Enumerate the matrix in the PR: author, org owner, team-bound user, other authenticated user, anonymous × public/private × read/write surfaces. 6. **Defaults and migration**: repos without `access.json` (or with an empty/invalid visibility) resolve **public** — matching the model's existing default (`access.go:98`) so existing repos don't silently become private. Verify the pre-existing-repo path materializes access on first write or treats missing as public (the `AccessBoot.EnsureRepoAccess` seam) and state it. ## Acceptance criteria - [ ] A signed-out visitor can browse, clone (HTTP + SSH-if-applicable), and read the API of a **public** repo on an instance with `anonymous_read = false`; private repos return 401/404 to them (pick one and be consistent — 404 avoids confirming existence; document the choice). - [ ] Ownership boundary enforced: private repos are visible to the owner, org owners/members per bindings, and admins — nobody else, on both the HTML and JSON surfaces. - [ ] `/explore`, `/api/v1/owners`, `/owners/{owner}/repos`, and `repos/detailed` omit repos the caller cannot read (anonymous ⇒ public only) — no private repo name leaks in any listing, count, or activity/size aggregate (#247/#248 rollups included). - [ ] New repos **default to public**; existing repos without access metadata resolve public. - [ ] Visibility is settable from the UI (repo settings) and readable as a badge on the repo header; the API's existing `visibility` accept-path is wired to the UI. - [ ] `summaryBody`/listing rows carry visibility; the cached-response ETag covers it (or the field is served no-cache) — no stale badge after a visibility flip. - [ ] Permission matrix documented in the PR (surface × visibility × principal class), with tests per cell for the read paths (API, git smart-HTTP, LFS, listings). - [ ] Any change to the documented gate semantics (`anonymous_read` interaction) is stated explicitly as a spec amendment, not slipped in. ## Out of scope (explicitly) Fine-grained org permission model, per-branch/per-path restrictions, and private-repo *discovery* UX (e.g. "request access"). This ticket is the coarse public/private boundary with the owner/org rule the user specified.
crueber added this to the v1 milestone 2026-09-11 18:26:53 +00:00
Author
Owner

PR #353 (fix/issue-345) implements this: visibility is the read authority for repo surfaces (API, smart-HTTP, LFS, bundles, SSH fetch, repo SPA shells) with anonymous_read keeping only its non-repo meaning — spec amendment stated in 06 §8.3 / 07 §13 with the full permission matrix in the PR description. Listings (owners/repos/both detaileds//explore) filter by caller role behind the access LRU with aggregates folded post-filter; summary + rows carry visibility under the ~v ETag suffix; UI badges + General-tab control wired (create already sent it; fork/import default public). Denials stay 401/403 by choice (law 9 + #344), documented. Tests per matrix cell, -race/e2e/cover green; no new deps. Pre-existing env gaps noted in the PR (web build, :8080 smoke, browser).

PR #353 (fix/issue-345) implements this: visibility is the read authority for repo surfaces (API, smart-HTTP, LFS, bundles, SSH fetch, repo SPA shells) with anonymous_read keeping only its non-repo meaning — spec amendment stated in 06 §8.3 / 07 §13 with the full permission matrix in the PR description. Listings (owners/repos/both detaileds//explore) filter by caller role behind the access LRU with aggregates folded post-filter; summary + rows carry visibility under the ~v ETag suffix; UI badges + General-tab control wired (create already sent it; fork/import default public). Denials stay 401/403 by choice (law 9 + #344), documented. Tests per matrix cell, -race/e2e/cover green; no new deps. Pre-existing env gaps noted in the PR (web build, :8080 smoke, browser).
Author
Owner

REVIEW PR #353 (fix/issue-345, commit 1ce5869) — adversarial privacy pass, verified in scratch worktrees (since removed). No browser run per task instructions (tests + reasoning only).

LEAK HUNT (all clear): every repo-listing surface filters via VisibleRepos/VisibleOwners/VisibleReposByOwner (internal/api/visibility.go): owners, ownerRepos, ownersDetailed, ownerReposDetailed, /explore text twin (bind_api.go Owners -> VisibleOwners). Grep confirms no remaining direct Repos.Owners/Repos.Repos/OwnerRepoCounts calls on request paths outside the helpers (only registry impl, fakes, unfiltered legacy branch). No search/sitemap/feed surfaces exist. Aggregates fold AFTER filtering: ownerRollups takes the allow map + filterCatalog strips private rows before the maxima (names, counts, activity all derive from the visible set). 401/403-never-404 is deliberate + documented (git credential-erase law 9, #344 login-page keying); existence protection comes from filtered listings. All 401s carry Bearer (writePlain/mapAccessErr).

GATE ORDER (clear): dispatch (routes.go) + open (refs.go) consult CheckRead BEFORE the flag for repo AuthRead; shared gateRepoRead covers smart-HTTP x3, LFS, bundles; SSHUploadPack takes the principal and read-gates first (17_ssh.md 17.6); repoPageGated serves public shells to anon with flag off, private falls through to gated() (401/#344 page/307). e.gate bypass loses nothing (AuthRead flag-check only). Nil gate -> legacy everywhere. identity/http.go flag uses are all non-repo user/org/team surfaces — correctly retained.

SEMANTICS (clear): CheckRead matrix tested per cell incl. flag-off (gate_test.go: bound writer, org owner, stranger 403, anon 401, anon/stranger public allow, synthesized-default allow). Missing/corrupt/invalid access.json -> public in BOTH gate (Resolve fallback) and projection (RepoVisibility) — agree, migration-safe. Team: bindings flow through the untouched Resolve path (edge_test coverage pre-existing).

ETAG/UI (clear): summary + RepoSizeRow carry visibility; ~v suffix only when wired (unwired byte-identical). Header + row badges render from the same payload; Settings General-tab PUT re-reads CAS version and preserves bindings (button only renders when the access doc loaded, so no binding-wipe path); New.jsx already sent visibility; fork/import default-public documented in 07 Decisions.

#344 (clear): no session/oauth/trio files touched; private anon curl 401, anon browser #344 login page, authed 200, nil-gate 401 — all asserted in TestRepoPageGatedVisibility (passes). Bearer/write paths unaffected (writes never consult the read gate — asserted).

COST/COVERAGE (clear): filters ride the access LRU (conditional GET, version-hit no body); admins/writers +0 probes; budgets untouched. Cover: api 95.3%, identity 97.2%, server 98.5%, sshd 96.5%. -race clean (identity/api/sshd/cmd/server). gofmt/vet clean. go build clean. e2e green. Dependents green (repoimport/issues/pulls/review/checks/social/notify/releases/tags/sizecatalog). node visibility.test.js 3/3; full unit suite shows only the 12 pre-existing env failures identical on the parent commit.

ENV GAPS (pre-existing, confirmed on parent ceb7331): shell/concepts/live-server tests fail in scratch without make web (no pnpm) — the PR discloses these. After copying main's built dist into scratch (read-only copy, scratch-only), the full server package passes except TestUIAssetConcepts (needs concepts GIFs from a real make web; PR touches no concepts files).

ONE OBSERVATION (not a blocker): anon listings with anonymous_read=false stay 401 (flag's kept non-repo meaning, locked in TestDispatchVisibilityMatrix) rather than serving the public-only slice the acceptance criterion's letter suggests. Stricter, not leakier; stated + tested + documented as the chosen semantic — flagging only so the criterion checkbox records the deviation consciously.

No fixes needed; nothing pushed. MERGE RECOMMENDATION: ready to merge.

REVIEW PR #353 (fix/issue-345, commit 1ce5869) — adversarial privacy pass, verified in scratch worktrees (since removed). No browser run per task instructions (tests + reasoning only). LEAK HUNT (all clear): every repo-listing surface filters via VisibleRepos/VisibleOwners/VisibleReposByOwner (internal/api/visibility.go): owners, ownerRepos, ownersDetailed, ownerReposDetailed, /explore text twin (bind_api.go Owners -> VisibleOwners). Grep confirms no remaining direct Repos.Owners/Repos.Repos/OwnerRepoCounts calls on request paths outside the helpers (only registry impl, fakes, unfiltered legacy branch). No search/sitemap/feed surfaces exist. Aggregates fold AFTER filtering: ownerRollups takes the allow map + filterCatalog strips private rows before the maxima (names, counts, activity all derive from the visible set). 401/403-never-404 is deliberate + documented (git credential-erase law 9, #344 login-page keying); existence protection comes from filtered listings. All 401s carry Bearer (writePlain/mapAccessErr). GATE ORDER (clear): dispatch (routes.go) + open (refs.go) consult CheckRead BEFORE the flag for repo AuthRead; shared gateRepoRead covers smart-HTTP x3, LFS, bundles; SSHUploadPack takes the principal and read-gates first (17_ssh.md 17.6); repoPageGated serves public shells to anon with flag off, private falls through to gated() (401/#344 page/307). e.gate bypass loses nothing (AuthRead flag-check only). Nil gate -> legacy everywhere. identity/http.go flag uses are all non-repo user/org/team surfaces — correctly retained. SEMANTICS (clear): CheckRead matrix tested per cell incl. flag-off (gate_test.go: bound writer, org owner, stranger 403, anon 401, anon/stranger public allow, synthesized-default allow). Missing/corrupt/invalid access.json -> public in BOTH gate (Resolve fallback) and projection (RepoVisibility) — agree, migration-safe. Team: bindings flow through the untouched Resolve path (edge_test coverage pre-existing). ETAG/UI (clear): summary + RepoSizeRow carry visibility; ~v suffix only when wired (unwired byte-identical). Header + row badges render from the same payload; Settings General-tab PUT re-reads CAS version and preserves bindings (button only renders when the access doc loaded, so no binding-wipe path); New.jsx already sent visibility; fork/import default-public documented in 07 Decisions. #344 (clear): no session/oauth/trio files touched; private anon curl 401, anon browser #344 login page, authed 200, nil-gate 401 — all asserted in TestRepoPageGatedVisibility (passes). Bearer/write paths unaffected (writes never consult the read gate — asserted). COST/COVERAGE (clear): filters ride the access LRU (conditional GET, version-hit no body); admins/writers +0 probes; budgets untouched. Cover: api 95.3%, identity 97.2%, server 98.5%, sshd 96.5%. -race clean (identity/api/sshd/cmd/server). gofmt/vet clean. go build clean. e2e green. Dependents green (repoimport/issues/pulls/review/checks/social/notify/releases/tags/sizecatalog). node visibility.test.js 3/3; full unit suite shows only the 12 pre-existing env failures identical on the parent commit. ENV GAPS (pre-existing, confirmed on parent ceb7331): shell/concepts/live-server tests fail in scratch without make web (no pnpm) — the PR discloses these. After copying main's built dist into scratch (read-only copy, scratch-only), the full server package passes except TestUIAssetConcepts (needs concepts GIFs from a real make web; PR touches no concepts files). ONE OBSERVATION (not a blocker): anon listings with anonymous_read=false stay 401 (flag's kept non-repo meaning, locked in TestDispatchVisibilityMatrix) rather than serving the public-only slice the acceptance criterion's letter suggests. Stricter, not leakier; stated + tested + documented as the chosen semantic — flagging only so the criterion checkbox records the deviation consciously. No fixes needed; nothing pushed. MERGE RECOMMENDATION: ready to merge.
Author
Owner

Fixed by PR #353 (review clean — adversarial leak hunt clear, gate order, ETag, #344 no-regress verified), merged. Closing.

Fixed by PR #353 (review clean — adversarial leak hunt clear, gate order, ETag, #344 no-regress verified), merged. Closing.
Sign in to join this conversation.
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
crueber/walhub#345
No description provided.