core/docs/superpowers/specs/2026-06-07-private-plugins-design.md
Alex Dunmow f4e579ad7a refactor: simplify pongo template context and engine, add private plugins spec
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-06-08 20:49:18 +08:00

18 KiB

Private plugins — design

Date: 2026-06-07 Status: Draft (core + orchestrator backend landed; CMS UI + publisher dashboard TODO)

Motivation

ninja plugin publish currently lands every plugin in the shared public registry under a developer-chosen scope (@themes/foo, @myorg/bar). There is no way to publish a plugin that is restricted to a single account — useful for in-house themes, internal blocks, or per-customer customisations that should never appear in public listings.

We want a "private plugin" lane that:

  1. The developer opts into with ninja plugin publish --private.
  2. Is scoped to the publisher's active account (not a global namespace) so anyone in that account can install it, and no one outside can see it.
  3. Has CLI ergonomics, a publisher dashboard ("manage what I've published"), and an "easy installer" in the CMS admin so site operators can browse and install their account's private plugins in two clicks.

Greenfield — the registry currently has no live private plugins. We can change shapes freely and the only existing rows are public.

Design decisions

These were locked in during brainstorming, in order of how foundational they are:

  1. Scope slug = sentinel @private. Every private plugin lives under one global @private namespace. Identity is (owner_account_id, name), not the scope slug, so two accounts can each have an @private/myplugin without colliding. Coords are only meaningful inside an account.
  2. Account membership is the access boundary. Any user in account_users WHERE account_id = plugin.owner_account_id can publish, install, list, and delete that account's privates. No roles, no per-site grants in v1.
  3. scope = in plugin.mod is ignored when private = true. Leave the value alone in the file, emit a warning at publish.
  4. private = true lives in plugin.mod. First --private rewrites the mod file; subsequent ninja plugin publish (no flag) continues to publish private. Re-publishing without private = true against an existing private plugin is blocked in v1 (no graduation).
  5. Active account, gcloud-style. After ninja login, the server returns the user's accounts. The CLI forces a selection (auto-picks if only one), persists it in creds.json, and ninja account set <slug> changes it later.
  6. CMS installer = a new "Private" tab on /admin/plugins listing the plugins owned by the CMS site's account.
  7. Publisher dashboard = my.blockninjacms.com (account portal), not the CMS admin. Lists plugins + versions, shows "installed on N sites" with a drilldown, supports deleting a version or the whole plugin (delete-plugin is disabled while any site has it installed).
  8. CLI parity for delete. ninja plugin delete @private/<name> and ninja plugin delete-version <name> --version=<v>.
  9. Same channel model as public (--channel=latest|beta|...).
  10. No tier gating, no quotas, no graduation in v1.
  11. ninja plugin init --private skips the scope prompt and writes private = true.
  12. ninja plugin list shows a Public section then a Private — account: <name> section.

Architecture

Four layers, four deliverables. Two are landed, two are still TODO.

Layer Repo Status Deliverable
Shared proto block/proto plugin_registry.proto carries the union of all surfaces
Registry core block/core + orchestrator/backend Migration, RPC handlers, auth, sqlc
Developer CLI core/cmd/ninja --private, account subcommand, list/delete
CMS site admin cms/web (/admin/plugins) TODO New "Private" tab + backend route
Publisher portal my.blockninjacms.com TODO /private-plugins dashboard

Data flow for the two operations that exist today:

publish --private
  ninja --(gRPC, bearer user token)--> orchestrator.PluginRegistryService.CreatePlugin
                                       { scope_slug:"private", visibility:PRIVATE,
                                         active_account_id }
                                       authz: requireAccountMembership
                                       row: registry_plugins
                                            scope_id = @private sentinel
                                            owner_account_id = X
  ninja --(gRPC)--> orchestrator.PluginPublishService.PublishVersion(plugin_id, …)

CMS install (Private tab)  (TODO)
  cms/web --(cms API)--> cms/backend
  cms/backend --(X-CMS-Secret, instance_id)--> orchestrator.PluginRegistryService
                                               .ResolveInstall(scope_slug="private",
                                                               plugin_name,
                                                               version_or_channel,
                                                               active_account_id)
                                               authz: requireAccountMembership

Cross-repo proto consolidation (incidental but load-bearing)

Building this revealed that core and orchestrator were independently forking plugin_registry.proto and drifting. As part of this work both repos now consume the canonical block/proto repo as a git submodule:

  • core/proto/ is now a submodule of block/proto.
  • core only generates plugin_registry.proto via buf generate --path proto/orchestrator/v1/plugin_registry.proto. Generating the rest of orchestrator/v1 (accounts, instances, …) would register proto descriptors that the orchestrator's own bindings also register, and panic at startup with "file ... is already registered".
  • core/plugin/mod.go no longer imports the generated proto package. The VisibilityLabel helper that previously lived there was the load-bearing leak (every consumer of core/plugin transitively pulled in core's bindings). It now lives in core/cmd/ninja/cmd/plugin.go as visibilityLabel.
  • The CLI's ListMyAccountsForCLI RPC + MyAccount message intentionally duplicate AccountService.ListMyAccounts in accounts.proto. They look like a duplicate but they're not: this one stays in plugin_registry.proto so core can resolve account membership without needing accounts.proto generated. They carry the minimum the CLI cares about (id, slug, name). Naming is *ForCLI to avoid the message-name collision with AccountService.

Data model

Migration backend/sql/migrations/00059_private_plugins.sql:

  1. ALTER TABLE registry_scopes ALTER COLUMN owner_user_id DROP NOT NULL so the @private sentinel scope can exist without a user owner.
  2. Seed the sentinel scope row: INSERT INTO registry_scopes (id, slug, display_name, owner_user_id) VALUES ('00000000-0000-0000-0000-000000000001', 'private', 'Private', NULL). Fixed UUID lets handlers reference it without a lookup.
  3. ALTER TABLE registry_plugins ADD COLUMN owner_account_id UUID REFERENCES accounts(id) ON DELETE RESTRICT. NULL for public plugins.
  4. Drop the table-wide UNIQUE (scope_id, name). Replace with two partial uniques: (scope_id, name) WHERE owner_account_id IS NULL for publics, and (owner_account_id, name) WHERE owner_account_id IS NOT NULL for privates. This is what lets two different accounts each have an @private/myplugin.
  5. CHECK constraint registry_plugins_visibility_consistency: a row is either public-scope-owned or account-owned, never both.

Knock-on effect: OwnerUserID in sqlc-generated params is now uuid.NullUUID. The scope-create code wraps claims.UserID accordingly.

Proto

In proto/orchestrator/v1/plugin_registry.proto (in the shared repo, all changes landed except the last one which is still local — see Open issues):

  • enum PluginVisibilityUNSPECIFIED, PRIVATE, UNDER_REVIEW, PUBLIC, REJECTED, TAKEN_DOWN.
  • Plugin.visibility converted from string to PluginVisibility (breaking wire change; greenfield so we accepted it).
  • Plugin.owner_account_id (string, field 12) — empty for publics.
  • CreatePluginRequest gains PluginVisibility visibility and string active_account_id.
  • GetPluginRequest gains string active_account_id (resolved when scope_slug = "private").
  • ResolveInstallRequest gains string active_account_id (same).
  • New on PluginRegistryService:
    • ListPrivatePlugins(account_id) -> [PrivatePluginSummary]
    • DeletePrivatePlugin(account_id, plugin_name)
    • DeletePrivatePluginVersion(account_id, plugin_name, version)
    • ListPrivatePluginInstallSites(account_id, plugin_name) -> [PrivatePluginInstallSite]
  • New on PluginAuthService:
    • ListMyAccountsForCLI() -> [MyAccount] (see the proto-consolidation section above for why this is intentionally not in accounts.proto).
  • New message MyAccount { id, slug, name }.
  • New messages PrivatePluginSummary { plugin, channel_versions, installed_site_count } and PrivatePluginInstallSite { instance_id, site_display_name, installed_version, pinned_channel }.

Also folded into the shared proto from the orchestrator's old local-only fork: PluginScopeService.ListMyPlugins, PluginRegistryService.SubmitForReview, and the full PluginModerationService (ListPendingReviews, ApproveSubmission, RejectSubmission, RequestChanges).

ninja CLI (core/cmd/ninja)

  • cmd/account.go (new) — ninja account list / set <slug> / show.
  • cmd/login.go — after device-flow approval, calls ListMyAccountsForCLI: 0 errors, 1 auto-selects, ≥2 prompts via pickAccountInteractive. Persists to creds.json.
  • internal/creds/creds.goHostCreds gains ActiveAccountID and ActiveAccountSlug. Legacy files load unchanged (creds_test.go covers both round-trip and legacy).
  • plugin/mod.goModPlugin.Private bool field; Coords() returns @private/<name>@<version> when Private=true regardless of Scope. Exported PrivateScopeSlug constant. Tests in plugin/mod_test.go.
  • cmd/plugin.go:
    • newPluginInitCmd: --private flag; skips scope prompt, calls CreatePlugin(visibility=PRIVATE, active_account_id=…), writes private = true to plugin.mod, omits the scope line. Inherits private = true from existing mod so re-running init can't silently downgrade.
    • newPluginPublishCmd: --private flag is sticky — first use rewrites the mod file. Warns when both scope and private = true are present. Calls GetPlugin(scope_slug="private", active_account_id=…) then PublishVersion.
    • newPluginListCmd: sectioned output (Public via ListMyScopes+GetScope, Private — account: <slug> via ListPrivatePlugins). --public-only / --private-only filters.
    • newPluginDeleteCmd / newPluginDeleteVersionCmd: --yes confirm. parsePrivateCoord accepts either bare name or @private/<name> and rejects any other scope so a slip can't delete a public plugin.

Orchestrator backend

backend/internal/registry/api/private_plugins.go (new) implements:

  • requireAccountMembership(ctx, accountID) — single authorisation chokepoint. Returns 401 on unauthenticated, 400 on unparseable UUID, 404 (deliberately, hides existence) when caller isn't a member.
  • ListPrivatePlugins — fans out to ListPrivatePluginChannelVersions and per-plugin CountPrivatePluginInstalledSites to compose summaries.
  • DeletePrivatePlugin — blocks while install count > 0 (CountPrivatePluginInstalledSites).
  • DeletePrivatePluginVersion.
  • ListPrivatePluginInstallSites — drilldown via the registry_install_eventsinstances join. (Caveat: install events are append-only and there's no uninstall event, so "installed on N sites" is the conservative "ever installed" approximation. Documented in the SQL query.)

backend/internal/registry/api/plugin.go gains branches in:

  • CreatePluginslug=="private" or visibility=PRIVATECreatePrivateRegistryPlugin under the sentinel scope.
  • GetPluginslug=="private"GetPrivateRegistryPluginByAccountAndName.
  • ResolveInstall — same.

backend/internal/registry/api/converters.govisibilityToProto(string) maps the DB text column to the new PluginVisibility enum; unknown values fall through to UNSPECIFIED.

backend/internal/registry/auth/device.goListMyAccountsForCLI returns MyAccount rows from a new sqlc query ListAccountsForUser.

backend/internal/rbac/interceptor.go — RBAC entries added: every new private RPC is RoleUser (handlers do the per-account membership check on top). ListMyAccountsForCLI is also RoleUser.

sqlc queries added to backend/sql/queries/registry_plugins.sql:

  • CreatePrivateRegistryPlugin, GetPrivateRegistryPluginByAccountAndName, ListPrivateRegistryPluginsForAccount,
  • DeletePrivateRegistryPlugin, DeletePrivateRegistryPluginVersion,
  • ListPrivatePluginChannelVersions,
  • CountPrivatePluginInstalledSites,
  • ListPrivatePluginInstalledSites.

And to backend/sql/queries/account_users.sql:

  • ListAccountsForUser — accounts the user is an accepted member of, with role.

CMS site admin UI (TODO)

cms/web/routes/admin/plugins.tsx — add a "Private" tab. The tab fetches via a new cms/backend route that calls orchestrator.PluginRegistryService .ListPrivatePlugins(account_id=<site's account>) with X-CMS-Secret. Same card layout as Browse, with an Install button that calls installFromRegistry with scope_slug:"private" and the site's active_account_id.

Note: ResolveInstall already supports the private branch; the CMS-side plumbing is what's missing.

Publisher dashboard (TODO)

my.blockninjacms.com/account/<slug>/private-plugins. Auth: existing user JWT; gate on IsAccountMember. Sections:

  • List of plugins (display name, version count, "installed on N sites").
  • Per-plugin drawer with versions per channel, "installed on" drilldown (ListPrivatePluginInstallSites), delete-version buttons.
  • Plugin-level delete button, disabled with tooltip when installs > 0.

Out of scope for v1

  • Graduating a private plugin to public (or vice versa). Hard error if attempted.
  • Per-site grants inside an account (every site in the account sees every private plugin owned by the account).
  • Subscription/tier gating; quotas. A private_plugin_limit column on plans is left as a placeholder for later.
  • Editing display_name / description / categories without a version bump.
  • Audit log of who published / installed / deleted.
  • Private plugin "homepage_url" UI.
  • Server-rendered registry browse page for privates (CLI + CMS + publisher dashboard cover all consumption surfaces).

Verification

Build/test gates (all green):

  • core: go build ./... && go test ./... clean. Plugin mod and CLI tests: TestParseModFull_PrivateField, TestParseModFull_PrivateDefaultsFalse, TestCoords_PrivateOverridesScope, TestCoords_PrivateNoScope, TestWriteMod_PrivateTrueSerializes, TestWriteMod_PrivateFalseOmitted, TestParsePrivateCoord, TestHostCreds_ActiveAccountRoundTrip, TestHostCreds_LegacyFileLoadsWithoutAccount.
  • orchestrator/backend: go build ./... && go test ./... clean — 22/22 packages including internal/registry/api, internal/registry/auth, internal/server, internal/rbac.

End-to-end manual flow once CMS + dashboard land:

  1. Account selection. ninja login against a user with two accounts → picker fires, choice persists. ninja account show confirms. ninja account set <slug> switches.
  2. Publish. ninja plugin init --private in a fresh dir → scope prompt skipped, plugin.mod has private = true. ninja plugin publish --private → coord @private/myplugin@0.1.0. Same name re-published from a different account succeeds (unique key is (account_id, name)).
  3. CMS install. Private tab shows the plugin on the publisher's account's site; empty on a site in a different account. Direct API attempt with another account's X-CMS-Secret → 403.
  4. Dashboard. Lists plugin, shows "installed on 1 site", drilldown lists that site + pinned version. Delete-plugin disabled until site uninstalls. Delete-version on a non-installed version succeeds; CLI plugins list reflects it.
  5. CLI parity. ninja plugin list shows sections; delete-version and delete work with --yes; server returns FAILED_PRECONDITION when install count > 0.

Open issues / followups

  • ResolveInstallRequest.active_account_id is uncommitted on the proto side. The shared block/proto working tree currently sits on feat/plugin-latest-version with a separate WIP commit, plus many go_package renames (block/ninjablock/cms) across the whole tree. Once that branch lands, we cycle: push the proto change → tag core v0.12.x → bump orchestrator's submodule and core pin → push.
  • Submodule pointer drift. Both core and orchestrator have the shared proto as a submodule; pinning is independent. Distribution via make release + make distribute-sdk in core already covers go.mod bumps; submodule pointer updates are still manual.
  • InstallFromRegistry (cms/backend → orchestrator). This is the CMS-side entry point that delegates to ResolveInstall. It already exists for the public path; needs the private branch (pass scope_slug="private" + active_account_id from the instance's account) when the CMS Private tab ships.
  • Install-tracking is approximate. registry_install_events is append- only, so the "N sites" count and dashboard drilldown count ever-installed-and-still-existing sites, not currently-installed sites. A v2 could add an instance_plugins.registry_version_id FK so we have a precise current-install signal.