From f4e579ad7a27289851534a20cd89bd7bc5db770e Mon Sep 17 00:00:00 2001 From: Alex Dunmow Date: Mon, 8 Jun 2026 20:49:18 +0800 Subject: [PATCH] refactor: simplify pongo template context and engine, add private plugins spec Co-Authored-By: Claude Opus 4.6 --- .../2026-06-07-private-plugins-design.md | 340 ++++++++++++++++++ templates/pongo/context.go | 5 +- templates/pongo/engine.go | 9 +- 3 files changed, 345 insertions(+), 9 deletions(-) create mode 100644 docs/superpowers/specs/2026-06-07-private-plugins-design.md diff --git a/docs/superpowers/specs/2026-06-07-private-plugins-design.md b/docs/superpowers/specs/2026-06-07-private-plugins-design.md new file mode 100644 index 0000000..2b18a8f --- /dev/null +++ b/docs/superpowers/specs/2026-06-07-private-plugins-design.md @@ -0,0 +1,340 @@ +# 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. diff --git a/templates/pongo/context.go b/templates/pongo/context.go index 377ea7e..1b27ac9 100644 --- a/templates/pongo/context.go +++ b/templates/pongo/context.go @@ -2,6 +2,7 @@ package pongo import ( "context" + "maps" "github.com/flosch/pongo2/v6" @@ -91,9 +92,7 @@ func (e *Engine) buildPageContext(ctx context.Context, doc map[string]any) pongo // Content fields are available directly; request context is under "ctx". func buildBlockContext(ctx context.Context, content map[string]any) pongo2.Context { pongoCtx := make(pongo2.Context, len(content)+1) - for k, v := range content { - pongoCtx[k] = v - } + maps.Copy(pongoCtx, content) if bc := blocks.GetBlockContext(ctx); bc != nil { pongoCtx["ctx"] = bc.ToMap() } diff --git a/templates/pongo/engine.go b/templates/pongo/engine.go index dbcb9d9..bf71a23 100644 --- a/templates/pongo/engine.go +++ b/templates/pongo/engine.go @@ -5,6 +5,7 @@ import ( "context" "io" "io/fs" + "maps" "github.com/flosch/pongo2/v6" @@ -91,12 +92,8 @@ func (e *Engine) MustBlockTemplateWithDefaults(name string, defaults map[string] tpl := pongo2.Must(e.set.FromFile(name)) return func(ctx context.Context, content map[string]any) string { merged := make(map[string]any, len(defaults)+len(content)) - for k, v := range defaults { - merged[k] = v - } - for k, v := range content { - merged[k] = v - } + maps.Copy(merged, defaults) + maps.Copy(merged, content) pongoCtx := buildBlockContext(ctx, merged) out, err := tpl.Execute(pongoCtx) if err != nil {