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

341 lines
18 KiB
Markdown

# 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 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 <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.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/<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_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=<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/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.