# 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 ` 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/` and `ninja plugin delete-version --version=`. 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: ` 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 PluginVisibility` — `UNSPECIFIED`, `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 / 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.go`** — `HostCreds` gains `ActiveAccountID` and `ActiveAccountSlug`. Legacy files load unchanged (`creds_test.go` covers both round-trip and legacy). - **`plugin/mod.go`** — `ModPlugin.Private bool` field; `Coords()` returns `@private/@` 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: ` via `ListPrivatePlugins`). `--public-only` / `--private-only` filters. - `newPluginDeleteCmd` / `newPluginDeleteVersionCmd`: `--yes` confirm. `parsePrivateCoord` accepts either bare name or `@private/` 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_events` ⨝ `instances` 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: - `CreatePlugin` — `slug=="private"` or `visibility=PRIVATE` → `CreatePrivateRegistryPlugin` under 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=)` 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//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 ` 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/ninja` → `block/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.