Skip to content

Blue-managed extension hub: a curated, hosted catalog with public/internal tabs and self-serve enablement #121

Description

@cubeorgdev

What problem does this solve?

Blue has no catalog of its own. The "curated" list an admin sees under Extensions is package_catalog in that deployment's own deploy/blue.yaml (deploy/blue.yaml:104, read by read_package_catalog at services/control-api/src/lib.rs:6262, served admin-only from GET /admin/package-catalog at lib.rs:1636 / handler lib.rs:6678). Consequences today:

  • Every org hand-authors the same recipes. Adding a popular tool means writing a digest-pinned ManagedPackage block by hand, into a file the deployment operator owns, and reloading config to change it. Nothing is shared between orgs.
  • Nothing useful ships curated. There is no Atlassian, Linear, GitHub MCP, Word/Excel, or common-skills listing anywhere in the repo — the shipped sample catalog is a single demo package.
  • There is no discovery surface. apps/dashboard/app/(dashboard)/configuration/package-manager.tsx is an inventory table of what you already configured, split by component capability (ExtensionTab at :104, tabs at :117). The only provenance signal is a "Curated"/"Custom" badge (:328, :833). There is nothing to browse.
  • Enablement is entirely admin-gated. PUT /admin/governance-extensions (lib.rs:1646) is behind UserPolicy::Adminrequire_admin (lib.rs:3176), the Extensions page calls requireAdminIdentity() (extensions/page.tsx, lib/api.ts:50), and /extensions lives in adminItems in components/dashboard-sidebar.tsx:54-59, which renders only when role === "admin" (:151). A member has no extensions navigation at all and files a ticket to get a skill turned on.

The ask is a Blue-managed extension hub: a curated, hosted, versioned catalog with public-vs-internal browsing and per-user self-serve enablement, so orgs stop re-deriving the same recipes and developers stop filing tickets for a skill.

One scope finding worth stating up front: the marquee listings people ask for (Atlassian, Linear, GitHub MCP) are MCP servers, not packages. McpServer (crates/gh-service/src/schema.rs:518, attached org-wide per harness at HarnessPolicy.mcp, schema.rs:128) has no stable id (it is keyed by name), no digest pinning, and no audience scoping whatsoever — the dashboard hardcodes Audience = "Everyone" for MCP rows (package-manager.tsx:889). Packages, by contrast, already have ids, digests, and an audience mechanism. So self-serve realistically lands package-first, and the MCP half needs a design-review decision rather than an implementation guess. Details in the open questions below.

Proposed solution

Five independently shippable phases. Each is useful alone; none requires the next.

Phase 1 — the catalog lives in this repo and publishes separately.
catalog/extensions/<id>.yaml, one CODEOWNERS-gated file per listing, compiled into a generated catalog/dist/v1/index.json. A new crates/gh-catalog owns the schema so control-api later deserializes the same Rust types rather than a parallel copy that drifts, and ships a small validate | build binary. .github/workflows/catalog.yml validates on PR and re-downloads plus re-hashes every kind: package archive so a listing cannot merge with a digest that does not match its bytes. On merge, publish an immutable v1/<build-id>/index.json with a detached Ed25519 .sig, plus a v1/latest.json pointer. Blast radius is zero: nothing consumes the index yet.

One listing type, a tagged union, with no governance wire-schema change: kind: package carries a ManagedPackage verbatim (schema.rs:141, adapters at :180) and lands in GovernanceConfig.packages; kind: mcp carries an McpServer verbatim and lands in HarnessPolicy.mcp. Shared presentation and trust metadata (name, summary, publisher, categories, harnesses, auth, risk) wraps those structs rather than replacing them.

Phase 2 — control-api consumes the hub.
New control_api.extension_hub.* AppConfig fields; a background refresher gated on the existing run_background_jobs flag; the signature verified against a key pinned in the image (never fetched alongside the index); rollback rejected by a monotonic index_version; the result cached in a new extension_hub_index table. Resolution order is verified fetch → cached row → the deployment's own package_catalog (which keeps airgapped installs working). New GET /admin/extension-hub; GET /admin/package-catalog keeps returning byte-identical output.

Phase 3 — Public vs Internal tabs (admin).
The top-level axis becomes Hub | Internal, and today's capability tabs (ExtensionTab, package-manager.tsx:104) demote to a filter inside Internal. Switch the top level to the URL-synced, deep-linkable pattern already used by members/members-tabs.tsx:24-43, with role gating in the style of gateway/gateway-tabs.tsx. A new hub-gallery.tsx renders a card grid with honest chips ("Remote service", "Executable content", "Sign-in required") and a detail sheet offering Enable / Enable + self-serve. Extract a pure apps/dashboard/lib/extensions-view.ts (resolveExtensionsTab, partitionExtensionsavailable | enabled | self-serve, capabilityCounts) covered by node --test, mirroring lib/members-view.ts — the dashboard has no React test harness, so the testable logic has to live outside the component. Enabling reuses the existing PUT /admin/governance-extensions through saveExtensions (app/actions.ts:178); no new write endpoint.

Phase 4 — self-serve, packages first.
The admin-owned fact becomes a third audience scope: widen the CHECK in governance_package_audiences (services/control-api/migrations/0016_package_audiences.sql) from ('organization','users') to ('organization','users','self_serve'), so both existing enforcement sites — apply_package_audiences (lib.rs:5067) and the re-check in download_package_artifact (lib.rs:7627) — pick the new scope up structurally rather than by a parallel code path.

Per-user opt-ins go in a new, deliberately non-revision-scoped table extension_self_serve_optins (organization_id, user_id, package_id). They must not go in governance_package_audience_users: that table is revision-keyed and copied forward on every admin write by persist_package_audiences (lib.rs:3356), so a member toggling a skill would mint an org governance revision, contend on base_revision, and fan out over SSE to every client. Keying by (org, user, package_id) keeps opt-ins orthogonal to the revision timeline — zero new revisions per toggle. apply_package_audiences gains one predicate and GET /governance-config then returns the correct per-user set with no wire-schema change at all.

New member-facing routes under a new extensions:self-serve scope: GET /extensions/available and PUT /extensions/self-serve (whole-set PUT, 400 on any id the admin has not marked self_serve). Dashboard: move /extensions from adminItems to workspaceItems (dashboard-sidebar.tsx:53-59) and swap requireAdminIdentity() for requireIdentity() plus a role branch, keeping every /admin/* fetch inside the admin branch.

Phase 5 — CLI /extensions.
One entry in COMMANDS (crates/gh-cli/src/supervisor.rs:276-330), after /agent. The control screen rejects slash-command arguments outright (supervisor.rs:3037-3043), so this has to be a chooser, and because enabling is a multi-select it is modeled on ResumeWizard (supervisor.rs:575) rather than the single-select ControlPrompt. gh-service gains list_self_serve_extensions / set_self_serve_extensions shaped like the existing download_package_artifact client call. On commit: PUT, invalidate the governance cache, then call the existing apply_text() so the runtime overlay under ~/.config/blue/runtime/<harness>/ is rewritten.

Be honest about reload here: harnesses read skills, plugins and MCP at launch, so this should follow the /direct pattern (supervisor.rs:2936) — apply now, then offer "Keep current session / Reload agent". It should not claim hot-reload it cannot deliver.

Trust and integrity. Digest pinning stays exactly as it is; the index is signed with a key pinned in the image; listing review is CODEOWNERS on catalog/. MCP is the weak spot and should be treated as such: a remote MCP server has no digest, so require an allowlisted, publisher-verified origin; forbid fetch-at-run stdio MCP commands (npx-style) so helpers must come from a digest-pinned package; and always disclose "remote service — prompts and tool data leave the org" on the listing. Nothing auto-enables, ever.

Compatibility. No contract_version bump is needed (it is 3 at schema.rs:63): self-serve is server-side subtraction from a per-user config, mechanically identical to the already-shipped users scope. Add self_serve_extensions to CAPABILITIES (schema.rs:64). Whether it is advisory or gets pushed into required_capabilities by stamp_version_aware_client_floor (lib.rs:8064, following the unverified_harness_versions precedent at :8098-8102, which 426s older clients) is a core-team call — my recommendation is advisory, because the required form would 426 every older CLI the instant an admin marks one extension self-serve. Contract changes land in deploy/contract/governance.openapi.yaml plus npm run sync:contract.

Open questions for design review (the template routes UI/core features here, and these are the decisions I do not think an implementer should make alone):

  1. Who owns the credential for OAuth remote MCP (Atlassian, Linear)? Harness-native OAuth with Blue governing availability only, or a Blue-brokered per-user secret store? McpServer.env is org-wide plaintext readable by every user, so Blue cannot hold a per-user token today. This blocks the marquee listings.
  2. MCP has no audience scoping and no stable id. Ship package-only self-serve in v1, with Atlassian/Linear as admin-enable-only listings, and defer MCP self-serve until per-user MCP config and MCP identity exist?
  3. Index signing key: baked into the image? What is the rotation story for airgapped deployments that never reach the CDN?
  4. Is self_serve a third mutually-exclusive scope, or an orthogonal boolean ("self-serve, but only offered to these 12 people")? The mutually-exclusive form is much simpler and is what Phase 4 assumes.
  5. Does an opt-in survive an admin removing and re-adding the same package_id? A non-revision-scoped table says yes by default. Is that wanted, or should removal purge opt-ins?
  6. Package ids are constrained to [a-z0-9-] by validate_packages (lib.rs:6276). Widen for publisher/name, or keep flat publisher-name ids?
  7. Hub privacy: a static signed index means Blue learns nothing about which org enabled what. Confirm that is intentional and that no enablement telemetry gets added later by default.
  8. Rate limiting and abuse handling on PUT /extensions/self-serve.

Happy to split this into per-phase issues if that is easier to schedule; Phase 1 is self-contained and has no runtime consumer.

Before submitting

  • I searched existing issues and this isn't a duplicate.
  • I confirmed this doesn't already exist elsewhere in Blue.

Searched all open and closed issues and found nothing on an extension catalog, hub, marketplace, plugin registry, or self-serve enablement. The closest prior art is #44 (closed), which touched custom extension ranges being read-only — related surface, different problem. The closest existing feature is the per-deployment package_catalog in deploy/blue.yaml, which is not it: it is operator-owned static YAML, admin-only, unversioned, unsigned, not hosted by Blue, not shared across orgs, and has no browse surface.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    enhancementNew feature or request

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions