SSR layer enforces no repo visibility — all 14 repo-scoped page routes expose private repos
Background
Issue #87 reported that the SSR repo home page returns 200 to unauthenticated requests on private repos. An audit of the full SSR layer reveals this is not a one-off bug — it is a systematic gap: no SSR route handler enforces repo visibility, and the pre-launch checklist entry that claimed this was done (docs/pre-launch-checklist.md:42) is false.
What is checked ✓ and what is not ✗
The JSON API layer (musehub/api/routes/musehub/*.py) enforces visibility correctly throughout:
# Pattern used in proposals.py, releases.py, objects.py, blame.py, issues.py, …
if repo.visibility != "public" and claims is None:
raise HTTPException(status_code=404, detail="Repo not found.")
The SSR layer (musehub/api/routes/musehub/ui_*.py) has zero uses of optional_token, require_valid_token, or any visibility check in any repo-scoped handler. Every SSR page for a private repo returns HTTP 200 with full content to any anonymous request.
Why the web UI cannot "sign in" today
MSign is a per-request Ed25519 signing scheme. The browser has no private key and no session mechanism — there is no "signed-in" state in the browser until magic links are built. This is intentional for now. It also means the correct short-term behaviour is explicit: private repos are inaccessible via the web UI to any anonymous browser. Any browser request to a private repo should receive a 404 (not a 403, which would confirm the repo exists).
Scope — unprotected SSR route files
All 14 files below call _resolve_repo or _resolve_repo_full from _ui_helpers.py and pass zero auth context:
| File | Handler count | Visibility check | Notes |
|---|---|---|---|
ui_repo.py |
1 | ✗ | Repo home page (#87) |
ui_commits.py |
3 | ✗ | Commit list, diff, single commit |
ui_tree.py |
2 | ✗ | Directory tree browse |
ui_blob.py |
~3 | ✗ | File content viewer |
ui_blame.py |
1 | ✗ | Blame view |
ui_issues.py |
3 | ✗ | Issue list, issue detail |
ui_proposals.py |
4+ | ✗ | Proposal list, proposal detail |
ui_releases.py |
3 | ✗ | Release list, release detail |
ui_intel.py |
~20 | ✗ | All intelligence strip fragments |
ui_symbols.py |
2 | ✗ | Symbol browser |
ui_agents.py |
5 | ✗ | Agent activity pages |
ui_sessions.py |
2 | ✗ | Session list — should be owner-only |
ui_repo_settings.py |
2 | ✗ | Settings — must be owner-only |
ui_view.py |
varies | ✗ | Generic view fragments |
Additionally:
ui_mists.py— usesmist.visibilityfor mist-level gating but does not gate the parent repomusehub/api/routes/musehub/__init__.py— has 2 auth uses; needs review
Root cause
_resolve_repo and _resolve_repo_full in _ui_helpers.py perform exactly one job: look up the repo by owner/slug and raise 404 if it doesn't exist. Neither accepts a claims argument nor checks repo.visibility. Every SSR handler calls one of these helpers and then proceeds with full access.
Goal
Every SSR route that renders repo-scoped content must enforce the same visibility contract already used in the JSON API:
- Public repo, any request → proceed.
- Private repo, no MSign token → 404 (repo "not found" — does not confirm existence).
- Private repo, valid MSign token → proceed if the token's handle is the owner (or a future collaborator).
- Repo settings / sessions pages → always require a valid MSign token and the token's handle must equal the repo owner. These are not just visibility-gated; they are owner-only.
Design
Central fix — _resolve_repo and _resolve_repo_full
Both helpers receive a new optional claims: MSignContext | None = None parameter. The visibility gate is added after the DB lookup:
# _ui_helpers.py — _resolve_repo (abbreviated)
async def _resolve_repo(
owner: str,
repo_slug: str,
db: AsyncSession,
claims: MSignContext | None = None,
) -> tuple[str, str, dict]:
row = await musehub_repository.get_repo_by_owner_slug_row(db, owner, repo_slug)
if row is None:
raise HTTPException(status_code=404, detail="Repo not found.")
if row.visibility != "public" and claims is None:
raise HTTPException(status_code=404, detail="Repo not found.")
...
# _ui_helpers.py — _resolve_repo_full (abbreviated)
async def _resolve_repo_full(
owner: str,
repo_slug: str,
db: AsyncSession,
claims: MSignContext | None = None,
) -> tuple[RepoResponse, str]:
repo = await musehub_repository.get_repo_by_owner_slug(db, owner, repo_slug)
if repo is None:
raise HTTPException(status_code=404, detail="Repo not found.")
if repo.visibility != "public" and claims is None:
raise HTTPException(status_code=404, detail="Repo not found.")
return repo, _base_url(owner, repo_slug)
Thread optional_token into every SSR handler
Every handler that calls _resolve_repo or _resolve_repo_full adds:
from musehub.auth.dependencies import optional_token, TokenClaims
# In the handler signature:
claims: TokenClaims | None = Depends(optional_token),
# Pass through:
repo_id, base_url, nav_ctx = await _resolve_repo(owner, repo_slug, db, claims)
Owner-only gate for settings and sessions
ui_repo_settings.py and ui_sessions.py need stricter enforcement — not just "has a token" but "token handle == repo owner":
from musehub.auth.dependencies import require_valid_token, TokenClaims
from fastapi import HTTPException, status
# In the handler signature:
claims: TokenClaims = Depends(require_valid_token), # 401 if no token
# After resolving the repo:
if claims.handle != owner:
raise HTTPException(status_code=403, detail="Repo owner access required.")
Phases
Phase 1 — Central visibility gate in _ui_helpers.py
Deliverables:
AV_01— Addclaims: MSignContext | None = Noneparameter to_resolve_repo. Add visibility check:if row.visibility != "public" and claims is None: raise HTTPException(404).AV_02— Same for_resolve_repo_full.AV_03— Default isNoneso existing callers that don't passclaimsget safe behavior: private repos 404. No call-site changes required for Phase 1.
Phase 2 — Thread optional_token into all repo-scoped SSR handlers
One change per file: add Depends(optional_token) to every handler signature, pass claims to the resolver call.
Deliverables:
AV_04—ui_repo.py—repo_pageAV_05—ui_commits.py— all handlersAV_06—ui_tree.py— all handlersAV_07—ui_blob.py— all handlersAV_08—ui_blame.py— all handlersAV_09—ui_issues.py— all handlersAV_10—ui_proposals.py— all handlersAV_11—ui_releases.py— all handlersAV_12—ui_intel.py— all handlers (large file; ~20 handlers)AV_13—ui_symbols.py— all handlersAV_14—ui_agents.py— all handlersAV_15—ui_view.py— all handlers
Phase 3 — Owner-only gate for settings and sessions
AV_16—ui_repo_settings.py— swapoptional_tokenforrequire_valid_token; addclaims.handle != owner → 403after resolver.AV_17—ui_sessions.py— same owner-only gate.AV_18— Correctdocs/pre-launch-checklist.md:42— mark as[ ]with a note that SSR layer was not covered.
Phase 4 — Tests
One new test file: tests/test_ssr_visibility.py.
Deliverables:
AV_19— Anonymous GET on a private repo's home page (/{owner}/{slug}) returns 404.AV_20— Anonymous GET on a private repo's commits page returns 404.AV_21— Anonymous GET on a private repo's issues page returns 404.AV_22— Anonymous GET on a public repo's home page returns 200.AV_23— Authenticated GET (valid MSign token, owner handle) on a private repo returns 200.AV_24— Unauthenticated GET on/{owner}/{slug}/settingsreturns 401 (not 200 or 404).AV_25— Authenticated non-owner GET on/{owner}/{slug}/settingsreturns 403.AV_26— Authenticated owner GET on/{owner}/{slug}/settingsreturns 200.
Acceptance criteria
- Anonymous HTTP GET on any page of a private repo returns 404 — not 200, not 403.
- Authenticated GET (valid MSign token, owner handle) on a private repo returns 200.
- Public repos remain accessible without any token.
- Repo settings and sessions pages require a valid token from the repo owner; non-owner returns 403; no token returns 401.
- AV_19–AV_26 all green.
- No regression to existing JSON API auth tests.
Out of scope
- Collaborator access to private repos (future: when team membership is implemented, the visibility check expands to
owner OR collaborator; for now, owner-only is correct). - Browser "signed-in" state / magic links — this issue gates only on MSign token presence, which is CLI/API-only for now. Web UI users cannot access private repos until magic links exist.
- Changes to MSign verification logic or token format.
- Rate limiting or audit logging of 404 responses on private repos.
All 26 deliverables complete. Tests AV_19–AV_26 live in
tests/test_ssr_visibility.py— 8/8 green. Also fixedtest_musehub_ui_settings.pyandtest_musehub_ui_settings_ssr.pywhich were broken by the Phase 3require_valid_tokengate (22 tests updated to useauth_headers+owner=testuser). Merged to dev and pushed.