gabriel / musehub public
custom-domain-registration.md markdown
519 lines 30.9 KB
Raw
sha256:34035d72cef530c1ab9d6a6f53be18d803dde705fc3157617d70352a96d0747b Merge branch 'fix/wire-push-external-parent-manifest' into dev Human 8 days ago

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 existsMusehubDomain / 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: commit graph + symbol graph + code-dimension heatmap + agent activity) or a DAW-style track viewer (midi-demo: 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/<domain>/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. Donetest_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. Donetest_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). DoneTestDomainRegistryURLConsistency (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_05viewer_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_06capabilities 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). DoneDOMAIN_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)

  • [x] 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. Donesrc/ts/domain-palette.ts + src/ts/domain-viewers.ts, hand-rolled SVG (no new dependency — the same zero-dependency pattern already used by src/ts/pages/timeline.ts and the symbols.ts sparkline).
  • [x] 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. Done — the UI route's page_json was extended to carry viewer_type/dimensions through to the client; verified end-to-end with a real published domain.
  • [x] 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. Donesrc/ts/pages/user-profile.ts's inline copies replaced with imports from the shared domain-palette.ts module.

Scope decision made with gabriel before implementing: the exit gate as originally written says "renders... on its repo page," but the repo↔domain linking mechanism (DOM_12) didn't exist yet — that's Phase 3's explicit job. Rather than pull Phase 3 forward or silently under-deliver, the real, data-driven viewers landed on the existing domain detail page (/domains/@author/slug) now; Phase 3 wires a repo to a registered domain and surfaces the same viewer components there.

Exit gate — met, for the corrected scope, verified live against real staging: published @gabriel/phase2-piano-verify with viewer_type: "piano_roll" and two real dimensions (drums, bass). Confirmed the domain detail page's page_json carries the real viewerType and dimensions data, the #dd-viewer-preview container renders, and the deployed app.js bundle contains the actual renderPianoRollViewer/ renderSymbolGraphViewer functions (grepped the live bundle for their distinguishing output strings). 20 new vitest tests (domain-palette.test.ts, domain-viewers.test.ts) plus one new Python E2E test for the page_json wiring — 43/43 vitest, 77/77 test_domains.py, 9/9 test_repo_card_e2e.py, zero regressions.

Known follow-up, not blocking: a third verification-only catalog entry (@gabriel/phase2-piano-verify) is now live on staging alongside the two from Phases 0/1 — same no-delete-path limitation.

Phase 3 — Wire a repo to an actual registered domain

  • [x] 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. Done, but not the mechanism as originally described — investigation found MusehubDomainInstall is actually a user↔domain adoption record (unique on user_id+domain_id, used for profile notifications), not a repo↔domain link at all. And MusehubRepo.domain_id — despite its name and docstring claiming "FK to musehub_domains" — is in active practice a plain VCS-plugin category string ("code"/"midi"/ "mist") load-bearing for the profile heatmap; musehub_profile.py even has guard code treating a real marketplace ID landing in that column as a bug to normalize away, from a past incident. Neither mechanism could safely carry a marketplace link. Added a genuinely new, separate column instead: MusehubRepo.marketplace_domain_id (migration 0074), wired through PATCH /api/repos/{id}/settings (404 if the target domain doesn't exist, "" clears the link, owner/admin-guarded) and into repo_nav.html's domain badge — which already had real, dormant rendering logic for exactly this, just never populated by any route.
  • [x] DOM_13install_count on MusehubDomain (already in the model) actually increments/decrements correctly as repos link/unlink. Donerecord_domain_install existed but had zero real callers anywhere in the app (dead code) and no uninstall counterpart at all; added record_domain_uninstall, wired both into the settings patch so linking/unlinking/re-linking/clearing all correctly increment, decrement (floored at 0), and never double-count a no-op re-send of the same value.

Exit gate — met, verified live against real staging: created a real repo (phase3-verify), linked it to the Phase 2 domain (@gabriel/phase2-piano-verify) via a signed PATCH .../settings request, and confirmed the repo's actual home page (https://staging.musehub.ai/gabriel/phase3-verify) renders a real domain badge (<a class="domain-badge nav-domain-badge" href="/domains/@gabriel/phase2-piano-verify">) linking through to that domain's page — which, from Phase 2, already renders the real piano_roll viewer driven by the registered manifest. aaronrene's knowtation domain, once published, can now go through this exact same path. 19 new tests (install/uninstall lifecycle, settings-patch link/404/clear/idempotent, repo-home badge E2E); 171 passed across test_domains.py/test_musehub_repos.py/test_musehub_ui_repo_home_ssr.py, 49/49 test_migrations.py (full up/down/re-up cycle), 6/6 test_musehub_alembic.py, zero regressions.

Phase 4 — Docs and CLI polish

  • [x] DOM_14muse domains publish --help and any example commands in docs use the corrected URL and a working end-to-end example. Done — audited every --viewer-type/--capabilities/"Required keys" occurrence in muse/cli/commands/domains.py (the "Example::" docstring, the runtime print statement, both TypedDict docstrings, and the argparse quickstart/help text) against the real Phase 1 server enum (generic/symbol_graph/piano_roll, merge_semantics: "ot"|"crdt"|"three_way") — every stale --viewer-type genome/spatial example and the false "Required keys" claim (all capability keys are actually optional) fixed. docs_muse_domains.html itself needed no changes — grepped musehub/templates/ for every muse domains publish occurrence and confirmed the only one (the GET /api/domains callout) was already corrected back in Phase 0.
  • [x] 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. Done — implemented as --dry-run on publish (muse domains validate was already a different, unrelated subcommand — local plugin protocol-compliance checking — so reusing that name would have been confusing). _validate_manifest_locally() mirrors the server's Pydantic schema (viewer_type enum, merge_semantics enum, 50-dimension cap, 16 KB manifest cap), deliberately duplicated rather than imported since muse and musehub are separately versioned/deployed packages. Needs neither a network call nor a signing identity. 5 new tests (test_dry_run_valid_manifest_exits_zero, test_dry_run_invalid_viewer_type_exits_one, test_dry_run_invalid_merge_semantics_exits_one, test_dry_run_too_many_dimensions_exits_one, test_dry_run_requires_no_signing_identity) in tests/test_domains_publish.py.

Exit gate — met, verified live against real staging: republished the muse CLI tarball (same 0.2.0rc15 version, updated contents; bash deploy/publish_muse_release.sh — 20/20 smoke checks passed) and installed it fresh into a throwaway venv from https://staging.musehub.ai/releases/muse-0.2.0rc15.tar.gz. Ran the exact documented "Agent quickstart" example from --help verbatim — it worked on the first try. Verified --dry-run catches a bad viewer_type, a bad merge_semantics, and an oversized dimensions list, all with no network call (urlopen never invoked in tests; confirmed no real HTTP round trip live) and no signing identity required. 251/251 tests passing (test_domains_publish.py, test_stress_domains_publish.py, test_cmd_domains_hardening.py), zero regressions. mypy --strict run against the file: confirmed (by diffing against the pre-Phase-4 committed version) that all 13 reported errors are pre-existing, unrelated debt (run_info/_SchemaInfoJson typing gaps, get_signing_identity's loose object | None return type) — none introduced by this phase's changes; additionally resolved a latent SigningIdentity forward-reference gap under TYPE_CHECKING so mypy reports the real underlying type mismatches instead of masking them as an undefined name.

Phase 5 — Full CRUD parity for the domains entity

/api/domains has Create (POST) and Read (GET list + GET detail), but no Update or Delete — every other MuseHub entity with an owner (repos, issues, proposals, labels, releases) has all four. This was silently papered over across Phases 0–4's own "Known follow-up, not blocking" notes: four verification-only catalog entries (test-domain-verify, phase1-good-verify, phase2-piano-verify, dryrun-real-verify) are still live on staging with no way to remove them, because Delete was never built.

  • [x] DOM_16PATCH /api/domains/@{author_slug}/{slug} — partial update of display_name, description, capabilities, viewer_type, version. Owner-only (author_user_id must match the caller's handle, same default-deny as DOM_02 — no admin bypass). Recompute manifest_hash when capabilities changes. Reuses the existing DomainCapabilities Pydantic model for validation, so a malformed update gets the same specific 422 as a malformed create (Phase 1). Mirrors labels.py::update_label's partial-update pattern (only fields present in the body are changed). Doneupdate_domain() service function + PATCH route in musehub/api/routes/musehub/domains.py.
  • [x] DOM_17DELETE /api/domains/@{author_slug}/{slug} — owner-only, sets the existing is_deprecated flag rather than a hard row delete. A hard delete would orphan MusehubRepo.marketplace_domain_id (Phase 3) and MusehubDomainInstall.domain_id rows with no cascade story; is_deprecated already exists on the model for exactly this and list_domains already unconditionally excludes deprecated rows (WHERE is_deprecated IS FALSE, no query flag needed) — so a deprecated domain disappears from /domains browsing immediately but stays individually fetchable by scoped ID (matches npm's "deprecated package" semantics, and mirrors repos.py::delete_repo's own soft-delete-via-flag convention — that endpoint is also a flag flip, not a row delete). 204 on success, 404 if already gone, 403 if not owner. Doneset_domain_deprecated() service function + DELETE route.
  • [x] DOM_18 — CLI parity: muse domains update and muse domains delete, alongside the existing publish (Create), so the new server routes are actually reachable — an API-only CRUD half is a half-finished feature. Same --dry-run-style local validation reuse as DOM_15 where applicable (update shares the same schema constraints as publish). Donerun_update/run_delete in muse/cli/commands/domains.py, with new _patch_json/ _delete_request HTTP helpers mirroring _post_json.
  • [x] DOM_19 — Use the new muse domains delete against real staging to clean up the four orphaned verification-only catalog entries left by Phases 0–4, closing out that long-standing "known follow-up" instead of leaving it open indefinitely. Done — all four (test-domain-verify, phase1-good-verify, phase2-piano-verify, dryrun-real-verify) deprecated; GET /api/domains on staging now returns {"domains": [], "total": 0}.

Exit gate — met, verified live against real staging: deployed musehub (0.2.0rc16) and republished the muse CLI tarball (0.2.0rc16, 20/20 smoke checks passed). Ran the full CRUD cycle against real https://staging.musehub.ai from a fresh throwaway-venv install: muse domains publishmuse domains update --description "..." → confirmed the change via a real GET /api/domains/@gabriel/...muse domains delete (204) → confirmed the domain no longer appears in GET /api/domains?q=... (total: 0) but still resolves via direct GET /api/domains/@author/slug with is_deprecated: true → a second muse domains delete on the same domain correctly 404s (not a silent success). Also verified muse domains update against a nonexistent domain 404s. 20 new server-side tests (integration + E2E: owner-only 403, not-found 404, malformed-update 422, deprecated-exclusion-from-list, idempotent-delete 404) and 16 new CLI tests — 287/287 passing across test_domains.py (musehub) and test_domains_publish.py + test_stress_domains_publish.py + test_cmd_domains_hardening.py (muse), zero regressions. mypy --strict: confirmed zero new errors introduced (baseline stayed at the same 13 pre-existing, unrelated errors documented in Phase 4).

Post-Phase-5 wrap-up audit

Before closing this issue, did a full deliverable-by-deliverable re-verification of every phase against the current committed state (not just what each phase's own exit-gate note claimed) — reading the actual code, re-running every test file (567 tests across both repos, plus 20 vitest + 15 UI E2E tests), and re-checking live staging behavior. All DOM_01DOM_19 deliverables confirmed present, correct, and tested; dev/main confirmed identical in both repos; no dangling task branches.

The audit did surface one gap outside the originally-scoped DOM_* items: DOM_05/DOM_09 dropped sequence_viewer from the enum and DOM_14 fixed the muse CLI's stale --viewer-type examples, but the MCP tool surface (musehub/mcp/tools/musehub.py, musehub/mcp/resources.py) — a third interface agents use alongside the CLI and raw REST API — was never audited by any phase and still listed sequence_viewer as valid, plus invalid worked-example values ('midi', 'code', 'spatial', 'genome', 'sequence', 'code_graph') for viewer_type, including in musehub_publish_domain's copy-pasteable example — an agent following it verbatim would get a real 422. Fixed all instances; no test pinned the stale strings, 181/181 MCP tests still pass. Deployed to staging.

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.
File History 3 commits
sha256:34035d72cef530c1ab9d6a6f53be18d803dde705fc3157617d70352a96d0747b Merge branch 'fix/wire-push-external-parent-manifest' into dev Human 8 days ago
sha256:fc04e4cae9e1774d6a21b65c45daeed0e6787eb581d13aa1b03bfe9384a34226 Merge branch 'fix/two-column-scroll-layout' into dev Human 8 days ago
sha256:408916fc5973ba59c6e4eebaa80ebdcc801c0a63205651e25009d11548f79454 chore: bump version to 0.2.0.dev2 — nightly.2, matching muse Sonnet 4.6 patch 11 days ago