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:
- The developer opts into with
ninja plugin publish --private. - 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.
- 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:
- Scope slug = sentinel
@private. Every private plugin lives under one global@privatenamespace. Identity is(owner_account_id, name), not the scope slug, so two accounts can each have an@private/mypluginwithout colliding. Coords are only meaningful inside an account. - Account membership is the access boundary. Any user in
account_users WHERE account_id = plugin.owner_account_idcan publish, install, list, and delete that account's privates. No roles, no per-site grants in v1. scope =inplugin.modis ignored whenprivate = true. Leave the value alone in the file, emit a warning at publish.private = truelives inplugin.mod. First--privaterewrites the mod file; subsequentninja plugin publish(no flag) continues to publish private. Re-publishing withoutprivate = trueagainst an existing private plugin is blocked in v1 (no graduation).- 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 increds.json, andninja account set <slug>changes it later. - CMS installer = a new "Private" tab on
/admin/pluginslisting the plugins owned by the CMS site's account. - 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).
- CLI parity for delete.
ninja plugin delete @private/<name>andninja plugin delete-version <name> --version=<v>. - Same channel model as public (
--channel=latest|beta|...). - No tier gating, no quotas, no graduation in v1.
ninja plugin init --privateskips the scope prompt and writesprivate = true.ninja plugin listshows aPublicsection then aPrivate — 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 ofblock/proto.coreonly generatesplugin_registry.protoviabuf generate --path proto/orchestrator/v1/plugin_registry.proto. Generating the rest oforchestrator/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.gono longer imports the generated proto package. TheVisibilityLabelhelper that previously lived there was the load-bearing leak (every consumer ofcore/plugintransitively pulled in core's bindings). It now lives incore/cmd/ninja/cmd/plugin.goasvisibilityLabel.- The CLI's
ListMyAccountsForCLIRPC +MyAccountmessage intentionally duplicateAccountService.ListMyAccountsinaccounts.proto. They look like a duplicate but they're not: this one stays inplugin_registry.protoso core can resolve account membership without needingaccounts.protogenerated. They carry the minimum the CLI cares about (id,slug,name). Naming is*ForCLIto avoid the message-name collision withAccountService.
Data model
Migration backend/sql/migrations/00059_private_plugins.sql:
ALTER TABLE registry_scopes ALTER COLUMN owner_user_id DROP NOT NULLso the@privatesentinel scope can exist without a user owner.- 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. ALTER TABLE registry_plugins ADD COLUMN owner_account_id UUID REFERENCES accounts(id) ON DELETE RESTRICT. NULL for public plugins.- Drop the table-wide
UNIQUE (scope_id, name). Replace with two partial uniques:(scope_id, name) WHERE owner_account_id IS NULLfor publics, and(owner_account_id, name) WHERE owner_account_id IS NOT NULLfor privates. This is what lets two different accounts each have an@private/myplugin. - 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 PluginVisibility—UNSPECIFIED,PRIVATE,UNDER_REVIEW,PUBLIC,REJECTED,TAKEN_DOWN.Plugin.visibilityconverted fromstringtoPluginVisibility(breaking wire change; greenfield so we accepted it).Plugin.owner_account_id(string, field 12) — empty for publics.CreatePluginRequestgainsPluginVisibility visibilityandstring active_account_id.GetPluginRequestgainsstring active_account_id(resolved whenscope_slug = "private").ResolveInstallRequestgainsstring 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 inaccounts.proto).
- New message
MyAccount { id, slug, name }. - New messages
PrivatePluginSummary { plugin, channel_versions, installed_site_count }andPrivatePluginInstallSite { 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, callsListMyAccountsForCLI: 0 errors, 1 auto-selects, ≥2 prompts viapickAccountInteractive. Persists tocreds.json.internal/creds/creds.go—HostCredsgainsActiveAccountIDandActiveAccountSlug. Legacy files load unchanged (creds_test.gocovers both round-trip and legacy).plugin/mod.go—ModPlugin.Private boolfield;Coords()returns@private/<name>@<version>whenPrivate=trueregardless ofScope. ExportedPrivateScopeSlugconstant. Tests inplugin/mod_test.go.cmd/plugin.go:newPluginInitCmd:--privateflag; skips scope prompt, callsCreatePlugin(visibility=PRIVATE, active_account_id=…), writesprivate = truetoplugin.mod, omits thescopeline. Inheritsprivate = truefrom existing mod so re-runninginitcan't silently downgrade.newPluginPublishCmd:--privateflag is sticky — first use rewrites the mod file. Warns when bothscopeandprivate = trueare present. CallsGetPlugin(scope_slug="private", active_account_id=…)thenPublishVersion.newPluginListCmd: sectioned output (PublicviaListMyScopes+GetScope,Private — account: <slug>viaListPrivatePlugins).--public-only/--private-onlyfilters.newPluginDeleteCmd/newPluginDeleteVersionCmd:--yesconfirm.parsePrivateCoordaccepts 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 toListPrivatePluginChannelVersionsand per-pluginCountPrivatePluginInstalledSitesto compose summaries.DeletePrivatePlugin— blocks while install count > 0 (CountPrivatePluginInstalledSites).DeletePrivatePluginVersion.ListPrivatePluginInstallSites— drilldown via theregistry_install_events⨝instancesjoin. (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:
CreatePlugin—slug=="private"orvisibility=PRIVATE→CreatePrivateRegistryPluginunder the sentinel scope.GetPlugin—slug=="private"→GetPrivateRegistryPluginByAccountAndName.ResolveInstall— same.
backend/internal/registry/api/converters.go — visibilityToProto(string)
maps the DB text column to the new PluginVisibility enum; unknown values
fall through to UNSPECIFIED.
backend/internal/registry/auth/device.go — ListMyAccountsForCLI 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_limitcolumn 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 includinginternal/registry/api,internal/registry/auth,internal/server,internal/rbac.
End-to-end manual flow once CMS + dashboard land:
- Account selection.
ninja loginagainst a user with two accounts → picker fires, choice persists.ninja account showconfirms.ninja account set <slug>switches. - Publish.
ninja plugin init --privatein a fresh dir → scope prompt skipped,plugin.modhasprivate = 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)). - 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. - 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 listreflects it. - CLI parity.
ninja plugin listshows sections;delete-versionanddeletework with--yes; server returnsFAILED_PRECONDITIONwhen install count > 0.
Open issues / followups
ResolveInstallRequest.active_account_idis uncommitted on the proto side. The sharedblock/protoworking tree currently sits onfeat/plugin-latest-versionwith a separate WIP commit, plus manygo_packagerenames (block/ninja→block/cms) across the whole tree. Once that branch lands, we cycle: push the proto change → tagcorev0.12.x → bumporchestrator's submodule and core pin → push.- Submodule pointer drift. Both
coreandorchestratorhave the shared proto as a submodule; pinning is independent. Distribution viamake release+make distribute-sdkin 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 toResolveInstall. It already exists for the public path; needs the private branch (passscope_slug="private"+active_account_idfrom the instance's account) when the CMS Private tab ships.- Install-tracking is approximate.
registry_install_eventsis 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 aninstance_plugins.registry_version_idFK so we have a precise current-install signal.