# MuseHub Pre-Launch Checklist > This document governs what must be complete, verified, and signed off before MuseHub > opens to users beyond gabriel. Items are grouped by domain. Nothing ships until every > checkbox is checked. Re-check after any major refactor. --- ## 0. Philosophy The threat model is realistic: we are open source, so attackers can read every route, every query, every auth flow. We assume they will. The bar is not "unbreakable against nation-states." The bar is: **a well-resourced, intelligent adversary with full source access cannot compromise data, impersonate users, or take the service down with commodity tooling.** Normal abuse — scrapers, credential stuffing, path traversal attempts, large payload bombs — should be a non-event. --- ## 1. Authentication & Authorization ### 1.1 Ed25519 / MSign > Auth is Ed25519 per-request signing (MSign). No server secret, > no token expiry, no refresh. The public key registered in the DB is the credential. - [x] No server secret, no `ACCESS_TOKEN_SECRET` — auth is pure Ed25519 key pairs - [x] Per-request Ed25519 signature verified on every protected route (`require_signed_request`) - [x] 30-second replay window enforced (`REPLAY_WINDOW_SECONDS = 30` in `request_signing.py`) - [x] Challenge nonce is single-use — consumed with `.pop()` on first verify; 5-min TTL for GC - [x] `identity.toml` keys are per-host, never shared across environments (muse CLI enforces this) - [x] Key revocation: compromised key deleted via `DELETE /api/auth/keys/{handle}/{key_id}` - [x] Auth endpoints rate-limited at 20 req/min per IP via `slowapi` (`AUTH_LIMIT` in `rate_limits.py`) - [x] Bearer tokens explicitly rejected with 401 (MSign is the only accepted scheme) - [x] `WWW-Authenticate: MSign realm="musehub"` returned on all 401 responses - [x] Failed-auth-specific rate limiting: `musehub/auth/failure_limiter.py` — in-memory per-IP failure counter with exponential backoff. Thresholds: 5→30s, 10→5min, 20→15min. Wired into `POST /api/auth/verify` (check before, record_failure on AuthError, record_success on ok). - [x] No CAPTCHA needed — there is no password or secret the attacker could guess; the private key never leaves the client machine ### 1.2 Authorization (ownership checks) - [x] Every repo-scoped JSON API endpoint asserts `repo.owner == current_user` (or team membership) Destructive/state-changing ops (delete, transfer, close, merge, assign, label, milestone) use `_guard_owner` / `_guard_repo_owner` helpers. Collaborator team membership is future work. - [ ] **SSR UI layer visibility gate** — all 14 repo-scoped `ui_*.py` route handlers must check `repo.visibility` before serving content. **Fixed in issue #90 (task/ssr-visibility-gate).** Previously, private repos returned HTTP 200 to anonymous browser requests. `ui_repo_settings.py` and `ui_sessions.py` additionally require `claims.handle == owner`. - [x] Repo visibility (public/private) is checked before serving any object, blob, or archive All JSON API GET endpoints gate on `optional_token` + `repo.visibility != "public"` check. - [x] Object download (`/archive`, `/blob`, `/object`) cannot be path-traversed to another repo `get_file_at_ref` resolves via snapshot manifest; `get_object_row` filters by `repo_id AND object_id` in SQL — DB is the authority, no path concatenation. - [x] Issue, merge-proposal, and comment endpoints verify the caller owns the parent repo `_guard_repo_owner` added to: close/reopen/update/assign/milestone/labels on issues; merge/request-reviewers/remove-reviewer on proposals; delete-comment. `_guard_write_access` added to: create-issue, create-comment, create-proposal, create-proposal-comment (private repos: owner-only; public repos: any authenticated user). - [ ] Admin-only endpoints (`/api/admin/*`) are gated by a separate `is_admin` claim No `/api/admin/*` routes exist yet. `MSignContext.is_admin` is defined but always False. Low priority — no system-admin operations are needed pre-launch. - [x] There is no "owner" field that a caller can self-assign via POST body `create_repo` sets `owner_user_id` from `claims` (the authenticated caller), never from the request body. The body's `owner` field is a display slug only. PATCH on identities explicitly whitelists allowed fields and does not expose `handle` or `identity_type`. ### 1.3 Key hygiene - [x] Private keys are never logged — auth logs contain only handle, algo, key_id, and fingerprint prefixes; `public_key_b64`, `signature_b64`, and `Authorization` header values are never passed to any logger - [x] MSign signatures are never returned in redirect URLs — all `RedirectResponse` targets are static paths or `/{owner}/{repo}?welcome=1`; no auth material in any Location header - [x] `Authorization: MSign` is the only accepted transport — no `?token=` query param, no `Bearer` fallback path --- ## 2. Input Validation & Injection ### 2.1 Path / traversal - [x] All `owner`, `repo`, `branch`, `path` URL segments are validated against an allowlist regex (`^[a-zA-Z0-9_.-]+$`, length-capped) before touching the filesystem or DB - [x] Constructed file paths are resolved with `Path.resolve()` and checked to be inside the expected root — no `../../` escapes - [x] Objects are fetched from the blob store (R2/MinIO) by `object_id`; no disk paths involved ### 2.2 SQL / ORM - [x] Zero raw SQL string interpolation anywhere in the codebase (`muse content-grep "f\""` audit) - [x] All queries go through the ORM or parameterized `text()` with bound params - [x] Search / filter inputs are sanitized before being passed to `LIKE` or `tsvector` ### 2.3 Payload / content - [x] Request bodies have a hard size cap (e.g., 10 MB for API, 100 MB for object upload) enforced at the ASGI/nginx layer, not just application code (nginx: `client_max_body_size 500m`; ASGI: `ContentSizeLimitMiddleware` — 10 MB API, 500 MB push) - [x] Markdown rendered server-side is sanitized (no raw `