# Custom domain registration: let third parties publish a domain and get a repo tab, safely ## Background ### The proximate trigger aaronrene wants to publish `@aaronrene/knowtation` — a catalog listing for his `knowtation` domain — so it's discoverable at `/domains`. This is metadata registration, distinct from `aaronrene/knowtation` (his product repo) and `aaronrene/gabriel-muse` (his plugin source). He hit: ``` muse domains publish --author aaronrene --slug knowtation ... --hub https://staging.musehub.ai → HTTP 405 on POST /api/v1/domains GET /api/domains returns empty. ``` ### What this issue found, going deeper (root causes, not just the symptom) A full read of both the CLI (`muse/cli/commands/domains.py`) and the server (`musehub/api/routes/musehub/domains.py`, `musehub/services/musehub_domains.py`, `musehub/db/musehub_domain_models.py`) surfaced that **the marketplace feature mostly already exists** — `MusehubDomain` / `MusehubDomainInstall` are real, tested DB models; `GET/POST /api/domains`, detail, repos, and verify routes are all implemented and covered by `tests/test_domains.py`. aaronrene's blocker is not "the feature doesn't exist" — it's a URL mismatch plus a set of hardening gaps that make it unsafe to open up broadly as-is: 1. **The CLI targets the wrong URL.** `muse/cli/commands/domains.py` (~line 858, `run_publish`) POSTs to `f"{resolved_hub}/api/v1/domains"`. No `/api/v1/*` namespace exists anywhere in musehub — the real, working, tested route is `/api/domains` (mounted via `app.include_router(musehub_domains_routes.router, prefix="/api", ...)` in `musehub/main.py:455`, router itself prefixed `/domains`). This is why `GET /api/domains` looks "empty" to aaronrene too — nothing has ever successfully published, because publish always 404/405s first. 2. **The public docs disagree with both of the above.** `docs_muse_domains.html` tells developers the registry lives at `GET /api/musehub/domains` — a third, also-wrong URL. Three different URLs are floating around one feature: CLI targets `/api/v1/domains` (wrong), docs claim `/api/musehub/domains` (wrong), reality is `/api/domains` (correct, tested). This needs to converge on one documented, correct URL. 3. **No author-identity check — a real impersonation hole.** `RegisterDomainRequest.author_slug` is a client-supplied string. `register_domain()` correctly resolves `author_user_id = claims.handle` from the authenticated caller, but `musehub_domains.create_domain()` (`musehub/services/musehub_domains.py:227`) stores `author_slug` from the request body **verbatim, with no check that it matches the caller's real handle**. Today, any authenticated user can register `@aaronrene/knowtation` or `@gabriel/anything` while being neither. This must be fixed before this endpoint is something we publicize or open up further — it's a namesquatting/impersonation vector, not a hypothetical. 4. **`viewer_type` is unvalidated free text.** The Pydantic model's docstring promises an enum — `'piano_roll' | 'symbol_graph' | 'sequence_viewer' | 'generic'` — but the field is typed as plain `str` with no `Literal`/enum enforcement server-side. Worse: the frontend (`app.js`, functions `Re`/ `lt`/`ke`) only special-cases 2 of those 4 documented values (`symbol_graph`, `piano_roll`) — `sequence_viewer` has no real rendering at all, and anything else (or a typo) silently falls back to a generic gray diamond icon with no error. There is currently **no real extensible "palette" of GUI widgets**, despite that being the documented promise and exactly what gabriel's vision below needs. 5. **`capabilities` is unschema'd arbitrary JSON** (`dict[str, PydanticJson]`, no shape validation, no size cap). The docstring lists an intended shape (`dimensions`, `viewer_type`, `artifact_types`, `merge_semantics: "ot"|"crdt"|"three_way"`, `supported_commands`) but none of it is enforced. 6. **No rate limiting** on `POST /domains` — combined with #5's unbounded JSON body, this is an open spam/DoS surface with zero cost to an attacker. 7. **The deepest architectural gap — muse-core domain *plugins* (the VCS-level diff/merge/symbol-extraction logic) are not extensible by third parties at all today**, and conflating this with the marketplace *catalog* metadata is exactly the "shoot ourselves in the foot" risk this issue exists to prevent. `muse/plugins/registry.py`'s `_REGISTRY` is a hardcoded Python dict — the *only* documented way to add a domain (`docs_muse_domains.html`'s own example) is `from my_domain.plugin import JsonDocPlugin` directly into that file, i.e. **you must patch muse's own source and ship a new muse release.** That's fine for muse-team-owned domains (code, midi, mist, ...); it is fundamentally incompatible with "aaronrene self-publishes a domain today." If we ever let third-party Python plugin code execute on every user's `muse diff`/`muse merge`/`muse clone` invocation, that's unreviewed arbitrary code execution on every machine that installs it — a supply-chain RCE risk, not a UI nicety. **The safe design is to keep these two layers explicitly separate** (see Design below). ### gabriel's vision (the shape to build toward) MuseHub's repo page should have a domain-specific tab. The domain's registered manifest picks from a **pre-built, MuseHub-curated list of GUI elements** (gabriel's own framing) — e.g. a DAG/commit-graph view ([code-demo](https://cgcardona.github.io/muse/code-demo.html): commit graph + symbol graph + code-dimension heatmap + agent activity) or a DAW-style track viewer ([midi-demo](https://cgcardona.github.io/muse/midi-demo.html): transport controls, per-dimension track list, commit DAG, dimension-activity heatmap). The registrant does **not** ship executable rendering code — they select a viewer type and supply declarative configuration (field/dimension names, labels) that parameterizes a viewer MuseHub already built and reviewed. ## Design — the load-bearing decision this issue is built around **Two layers, kept explicitly separate, never conflated:** | Layer | What it is | Who can create one | How | |---|---|---|---| | **Marketplace catalog** (`MusehubDomain`) | Declarative metadata: display name, description, a `viewer_type` selected from a fixed built-in enum, and a schema-validated `capabilities` manifest (field names/labels/dimensions) | **Self-service, any authenticated user, for their own `author_slug` namespace** | `POST /api/domains` — data only, no executable code, ever | | **VCS domain plugin** (`muse/plugins//plugin.py`) | Real Python code: diff/merge algorithms, symbol extraction, snapshot semantics | **muse-team reviewed and merged into muse core, v1** | Patch `muse/plugins/registry.py`, ship a muse release — exactly as documented today | A domain manifest can exist in the marketplace *without* a matching muse-core plugin (aaronrene's `knowtation` domain likely runs on top of an *existing* plugin — probably `generic` or `code` — and just wants its own catalog listing + a nicer viewer). A repo's actual diff/merge behavior is still governed entirely by whatever plugin `.muse/repo.json`'s `"domain"` key resolves to via the existing local registry — this issue does not change that resolution at all. **Explicitly out of scope for this issue:** letting third parties ship *executable* rendering code (arbitrary JS run in MuseHub's page) or *executable* diff/merge plugin code (arbitrary Python run in `muse`). Both are real, valuable future asks — genomics, 3D scenes, financial models will eventually want custom semantics — but both need a proper sandboxing story (WASM for browser-side, subprocess/capability isolation for CLI-side) and a review/verification pipeline first. Building that alongside the "let aaronrene publish a catalog entry today" fix would block a two-line URL fix behind a much larger, riskier effort. Tracked as explicit future work at the end of this doc, not attempted here. ## Goal — definition of done 1. aaronrene (or anyone) can run `muse domains publish` against real staging and see `@aaronrene/knowtation` appear at `/domains` today. 2. Nobody can register a domain under an `author_slug` that isn't their own authenticated handle. 3. `viewer_type` and `capabilities` are schema-validated at publish time — bad input is rejected with a clear error, not silently accepted. 4. A real, extensible, MuseHub-curated viewer palette exists (starting with at least `symbol_graph`/DAG-style and `piano_roll`/DAW-track-style, per the two demo pages) that a repo can actually render from, driven by the registered manifest — not just 2 hardcoded icon/color special-cases with no real distinct view. 5. One canonical, correct URL for this API, matching what the CLI sends, what the docs claim, and what the server serves. 6. Every deliverable below is TDD'd: red test written from the manifest in this doc, then made green. ## Phases Ordered by load-bearing dependency — Phase 0 is both aaronrene's literal blocker and a live security hole, so it gates everything else regardless of how the rest of this plan is sequenced or reprioritized later. ### Phase 0 — Unblock aaronrene + close the impersonation hole The smallest possible change that (a) lets real publishing happen and (b) closes the identity-spoofing gap before more traffic touches this endpoint. - [x] `DOM_01` — Fix `muse/cli/commands/domains.py::run_publish` to target `/api/domains` (not `/api/v1/domains`). Test: mock transport asserts the exact endpoint URL called. **Done** — `test_publish_targets_api_domains_not_v1` (muse repo), red before the fix, green after; 246/246 in the domain-publish test files, no regressions. - [x] `DOM_02` — Server-side: `register_domain()` must reject (403) any request where `body.author_slug` does not resolve to the caller's own authenticated handle (`claims.handle`), unless the caller has an explicit admin/org-delegation capability (design that check narrowly — default deny). Test: authenticated as `gabriel`, attempt to register `author_slug="aaronrene"` → 403, not 201. **Done** — `test_author_slug_must_match_caller_handle`, red (201, the actual vulnerability) before the fix, 403 after; 67/67 in `test_domains.py`. - [x] `DOM_03` — Fix `docs_muse_domains.html`'s `/api/musehub/domains` reference to the real `/api/domains`. **Done.** - [x] `DOM_04` — Regression test asserting the CLI's target URL, the docs literal string, and the actual mounted route all agree (fails loudly if any one drifts from the other two again). **Done** — `TestDomainRegistryURLConsistency` (musehub repo) introspects the real `app.routes` entry for `register_domain` as the source of truth (not a third hardcoded string) and checks the docs page against it; paired with the muse-side CLI-target test from DOM_01. **Exit gate — met, verified live against real staging (not just unit tests):** - Deployed the fix to staging and republished the muse CLI tarball (same `0.2.0rc15` version, updated contents) so `install.sh` actually serves the fix. - `muse domains publish --author gabriel --slug test-domain-verify ...` against real `https://staging.musehub.ai` → succeeded, and `GET /api/domains?q=test-domain-verify` confirmed it live and discoverable. - `muse domains publish --author someone-else-entirely ...` while authenticated as `gabriel` → real `403`, `{"detail":"author_slug must match your own handle ('gabriel')."}` — the impersonation guard verified live, not just in the test suite. - aaronrene has not yet run the real command himself for `@aaronrene/knowtation` — the blocker is confirmed cleared, but that last step is his to do. **Known follow-up, not blocking:** the `test-domain-verify` catalog entry created for this verification is still live on staging — there is no `muse domains` delete/deprecate subcommand yet (marketplace management is out of scope for Phase 0). Harmless catalog metadata, but flagging so it isn't mistaken for a real domain later. ### Phase 1 — Schema hardening on the publish path - [x] `DOM_05` — `viewer_type` becomes a real enum (`Literal["symbol_graph", "piano_roll", "generic"]` — see Phase 2 for why `sequence_viewer` is dropped rather than kept undefined) validated by Pydantic; invalid values rejected with 422, not silently stored. **Done** — verified live: `viewer_type=bogus_viewer` → real 422 with the enum listed in the error against staging. - [x] `DOM_06` — `capabilities` gets a real Pydantic sub-model: `dimensions: list[{name, description}]`, `artifact_types: list[str]`, `merge_semantics: Literal["ot", "crdt", "three_way"]`, `supported_commands: list[str]`. Reject malformed manifests with a specific, actionable 422 message (not a generic 500). **Done** — all fields default to safe empty values so the pre-existing `capabilities={}` minimal-manifest case (used throughout the test suite and real callers not yet ready to declare full capabilities) stays valid; only genuinely malformed input (bad enum, wrong shape) is rejected. - [x] `DOM_07` — Size cap on the `capabilities` payload (e.g. 16 KB) and a max `dimensions` count, to bound the unschema'd-JSON DoS surface. **Done** — 16 KB serialized cap, 50-dimension cap. - [x] `DOM_08` — Rate limit `POST /domains` (mirror the existing `@limiter.limit(...)` pattern already used elsewhere in `wire.py`, e.g. `WIRE_PUSH_LIMIT`). **Done** — `DOMAIN_REGISTER_LIMIT = "10/minute"` in `musehub/rate_limits.py`, same decorator pattern. **Exit gate — met.** 10 new tests added (`TestDomainSchemaHardening`, `TestDomainRegistrationRateLimit` in `tests/test_domains.py`) throwing malformed/oversized/wrong-enum payloads at `POST /domains` — every one gets a specific 422, never a 500, never a silent accept; sanity checks confirm legitimate minimal and fully-specified manifests still succeed (201). 76/76 in `test_domains.py`, 60/60 in `test_rate_limiting.py`. Deployed to staging and verified live: invalid `viewer_type` → real 422 with the correct enum message; a fully-specified valid manifest → real 201. **Known follow-up, not blocking:** a second verification-only catalog entry (`@gabriel/phase1-good-verify`) is now also live on staging, same situation as Phase 0's `test-domain-verify` — no delete/deprecate path exists yet. ### Phase 2 — Build the real viewer palette (the actual "pre-built GUI elements" list) - [ ] `DOM_09` — Define the v1 palette as a small, explicit, versioned set — recommend starting with exactly two, matching the two demo pages already built: `symbol_graph` (DAG/commit-graph + symbol-graph style, per code-demo.html) and `piano_roll` (DAW track-viewer + transport + dimension-heatmap style, per midi-demo.html), plus the existing `generic` fallback. Drop `sequence_viewer` from the enum until a real viewer backs it — an enum value with no implementation is worse than no value. - [ ] `DOM_10` — Each palette viewer takes its per-domain configuration from the manifest's `dimensions` list (already schema'd in Phase 1) — e.g. `piano_roll` maps `dimensions` to track names/colors; no per-domain executable code anywhere in this path. - [ ] `DOM_11` — Frontend: replace the two-case inline ternaries in `app.js` (`Re`/`lt`/`ke`) with a real lookup against the palette, driven by data from `/api/domains/@{author}/{slug}` — same safety property (a fixed, reviewed set of components), just made real instead of two hardcoded special cases plus a silent generic fallback for everything else. **Exit gate:** a domain registered with `viewer_type: "piano_roll"` and a real `dimensions` manifest renders an actual DAW-style track list on its repo page — not a gray fallback icon. ### Phase 3 — Wire a repo to an actual registered domain - [ ] `DOM_12` — Confirm/extend `MusehubDomainInstall`'s repo↔domain link so a repo can select a *marketplace-registered* domain (not just the 4 cases hardcoded today in `profile.html`) and have `repo_tabs.html`/ `repo_nav.html` render the domain tab from that link. - [ ] `DOM_13` — `install_count` on `MusehubDomain` (already in the model) actually increments/decrements correctly as repos link/unlink. **Exit gate:** aaronrene's `knowtation` domain, once published, can be selected by a real repo and that repo's page shows the domain tab driven by the registered manifest. ### Phase 4 — Docs and CLI polish - [ ] `DOM_14` — `muse domains publish --help` and any example commands in docs use the corrected URL and a working end-to-end example. - [ ] `DOM_15` — Add `muse domains validate` (or equivalent `--dry-run` on `publish`) that checks a manifest against the Phase 1 schema locally, before hitting the network — catches malformed manifests before a round trip. **Exit gate:** a fresh read of `docs_muse_domains.html` + `muse domains publish --help` produces a working publish on the first try, with no tribal knowledge needed. ## Acceptance criteria (whole-issue gate) - `muse domains publish` works end-to-end against real staging, verified live (not just unit-tested), mirroring this workspace's TDD + live-verify convention. - Identity-spoofing attempt is rejected; test asserting this is part of the permanent regression suite, not a one-off manual check. - Exactly one documented, correct URL for this feature across CLI, docs, and server. - At least one non-`generic` viewer type renders a real, distinct view driven by a published manifest. - `@aaronrene/knowtation` is live at `/domains` on staging. ## Out of scope (explicit, for future issues) - Third-party **executable** rendering code (arbitrary JS/WASM run in MuseHub's page context). - Third-party **executable** VCS plugin code (arbitrary Python run inside `muse` for diff/merge/symbol-extraction) — muse-core domains remain team-reviewed-and-shipped in v1, per the Design section above. - Monetization/billing for marketplace domains. - Org-level delegated publishing (a team publishing under a shared `author_slug`) — Phase 0's identity check is intentionally narrow (caller's own handle only) until a real delegation model is designed.