Compare commits

..

23 Commits

Author SHA1 Message Date
Alex Dunmow
742f4434b1 feat(captcha): SetSecret for live HMAC secret rotation
Server now holds the secret in an atomic.Pointer so a running instance can
swap it; outstanding challenge and verification tokens signed with the old
secret immediately fail verification.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-20 14:20:43 +08:00
Alex Dunmow
accb305fa3 docs: core is only for code shared between first-party entities
Purpose sharpened per Captain's direction: core exists solely for code
shared between two or more first-party entities — cms, orchestrator, the
ninja CLI, or future entities. Adds the admission test (single consumer →
that repo, plugin-needed → wasm ABI), the shrink-core direction, and the
templates/bn synced-copy rule (authored in cms, make sync-templates,
check-safety 31, validation.go intentionally divergent).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-07 09:42:00 +08:00
Alex Dunmow
20c67b80ee feat(templates): full bn chrome sync from cms + VersionedAssetURL hook
cms is now the authoring source for the bn chrome (head/toolbar/engagement);
this repo carries a mechanically-synced copy solely for guest-side plugin
templates (cms `make sync-templates`, drift gated by check-safety check 31).
head.templ picks up everything it had drifted behind on (RFG-parity favicon
head, analytics/web-vitals beacons, custom-scripts placement, bnApplyTheme)
and is now SDK-clean: asset versioning goes through the new
bn.VersionedAssetURL hook (identity default; the CMS host wires it to its
internal assets registry). HeadData is shape-compatible with existing plugin
usage; BrandingData/SiteSettingsData internals changed — plugins see that at
compile time on their next core bump.

Spec: cms docs/superpowers/specs/2026-07-07-bn-chrome-single-source-design.md

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-07 03:02:34 +08:00
Alex Dunmow
1ee9c280e2 feat(toolbar): theme-mode tester + sync toolbar.templ from cms
Sync the vendored templates/bn/toolbar.templ with the cms copy (entrance
animation, position picker chips, HidePreviewToggle — and the new per-tab
theme tester button). Port themeInitScript to the override-aware
window.bnApplyTheme so jutsu-rendered pages honour the admin's transient
bn-theme-override (sessionStorage) ahead of the visitor bn-theme preference.

Regenerated *_templ.go with templ v0.3.1020 (button/engagement diffs are
generator-version churn only).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-07 02:20:04 +08:00
Alex Dunmow
1a75edeae9 chore: rename dev host localdev.blockninjacms.com -> blockninja.dev 2026-07-05 20:50:19 +08:00
Alex Dunmow
bc156f2fcb chore(proto): bump proto submodule to 1ca85e3
Converged shared proto: InstallPlugin RPC, platform-backups, DNS and
plugin_registry updates.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-05 17:41:07 +08:00
Alex Dunmow
d7c1abbfb6 style(backup): satisfy errcheck on deferred Close calls
Wrap deferred rc.Close() in closures to explicitly discard the error.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-05 17:41:07 +08:00
Alex Dunmow
81acfac025 chore(deps): bump block/ninjatpl v1.0.1 -> v1.0.2
Pick up ninjatpl perf release (pooled tag-body buffers, lexer escape
fast-path). No API change; templates/pongo builds and behaves identically.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-05 15:39:17 +08:00
Alex Dunmow
d94c979731 chore(templates): migrate pongo2/v6 -> block/ninjatpl v1.0.1
The legacy templates/pongo host runtime now uses the in-house ninjatpl
fork. Mechanical import/ident swap — these files only use NewSet /
TemplateLoader / Context / Must / Template, all identical in ninjatpl;
no custom filters/tags or *Error signatures involved.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-05 12:24:27 +08:00
Alex Dunmow
7089964f6b fix(backup): Extra entry failures warn and continue (cms parity) — never abort the archive
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-05 09:26:43 +08:00
Alex Dunmow
e12160f05c fix(backup): checksum is base64-of-SHA256 to match cms restore verification
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-05 08:59:28 +08:00
Alex Dunmow
62b245d24f feat(backup): shared backup archive format v3 (writer/reader/crypto) for cms + platform backups
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-05 08:57:31 +08:00
Alex Dunmow
5aa52c00ff chore(proto): bump submodule to 06dcc6a (UpdateBlockDefinition RPC for tenant custom definitions)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-05 08:15:13 +08:00
Alex Dunmow
c7cbf69f63 refactor(abi): shed ABI contract + ninja CLI (WO-WZ-023)
The wasm-plugin ABI contract now lives in the cms (block/cms/abi/v1 + docs) and
the ninja CLI moved to its own repo (block/cli). Core keeps abi/ as the guest
SDK until WO-WZ-027. Removes cmd/ninja, the dead orchestrator registry client
(internal/api/orchestrator), the moved docs, and the now-unused ninja/orchclient
deps from go.mod/go.sum.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-04 22:37:24 +08:00
Alex Dunmow
3ce6f9f4a0 feat(bnp): fold manifest.yaml into wasm builds for mixed-form artifacts (WO-WZ-021)
A wasm plugin.build now folds an optional root manifest.yaml into the
DESCRIBE-derived manifest.pb exactly as BuildCodeless does, so a reduced
"mixed" plugin can keep a minimal guest for genuine logic while shipping the
full declarative surface set host-rendered: theme_presets, bundled_fonts,
master_pages, system/page templates, template_overrides, email_wrappers, css,
required_icon_packs (and the referenced root JSON is embedded into manifest.pb).

applyManifestYAML now only overwrites a scalar/bytes key when manifest.yaml
actually declares it, so folding onto a guest-populated manifest supplements
and overrides but never WIPES a guest DESCRIBE field. A repo with no
manifest.yaml is a no-op — existing pure-wasm plugins build byte-for-byte as
before. Codeless builds are unaffected (empty manifest → identical result).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-04 19:50:34 +08:00
Alex Dunmow
2b9a65dcca Revert "feat(pongo): non-panicking Engine compile variants + global 'lines' filter (WO-WZ-021)"
Core-dissolution direction (Captain's ruling, WO-WZ-026/027): host-side
machinery must not grow in core — the engine additions now live in cms
as backend/plugin/pongoengine (absorbed copy). Tag v0.19.1 still points
at the reverted commit; nothing consumes it. The v0.19.0 codeless
BUILDER keys stay: the ninja CLI lives here until WO-WZ-023 moves it
into cms.

This reverts commit 630dfc7dd1cd35b46482d81904f77dbbcb01c885.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-04 14:36:02 +08:00
Alex Dunmow
630dfc72b6 feat(pongo): non-panicking Engine compile variants + global 'lines' filter (WO-WZ-021)
PageTemplate/BlockTemplate/EmailWrapper return errors instead of
panicking so hosts can compile artifact-supplied templates at load time
(codeless themes) without a malformed artifact taking down the process;
Must* variants delegate. The 'lines' filter splits a string into
trimmed non-empty lines — pongo2 string literals cannot express \n, so
multi-line textarea fields (per-line <div> addresses) were previously
inexpressible in templates.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-04 14:31:15 +08:00
Alex Dunmow
87bbf9fe46 feat(bnp): codeless template overrides + email wrappers (WO-WZ-021)
manifest.yaml gains template_overrides [{template, block}] and
email_wrappers [system keys]; sources by convention at
templates/overrides/<template>/<block>.ninjatpl and
templates/email/<system>.ninjatpl (validated at build). Populates the
existing manifest fields BlockTemplateOverrides / EmailWrapperSystemKeys —
no proto change. Unblocks full theme-catalog codeless conversion: every
theme in the fleet registers overrides + an email wrapper.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-04 13:52:32 +08:00
Alex Dunmow
8bd92ea5c4 feat(captcha): single-use verification tokens (replay hardening)
VerifyToken now consumes a redemption token on its first successful verify:
a replayed token within its 5-min TTL is rejected. Mirrors the existing
single-use challenge-nonce store with a per-Server used-token store keyed on
the token's random id, same GC/expiry approach (entries live only for the
remaining TTL). The token is burned only on a successful verify (valid HMAC +
unexpired + unused); forged/expired tokens never touch the store.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-04 12:32:52 +08:00
Alex Dunmow
52d7413aa0 feat(bnp): codeless system/page templates — layouts without code (WO-WZ-020)
manifest.yaml gains system_templates + page_templates declarations; each page
template's .ninjatpl source lives at templates/<system>/<key>.ninjatpl and is
validated at pack time. Verify no longer rejects declared system/page
templates on codeless manifests (guest template_keys stay rejected) — the cms
host registers them source-tracked and renders the files through the same
host pongo pipeline powered blocks use. Blog/system/normal page layouts are
now fully expressible with zero code.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-04 11:50:38 +08:00
Alex Dunmow
4a1d1883cb chore: bump proto submodule — TurnstileConfig/HCaptchaConfig removed (Cap captcha Phase E) 2026-07-04 11:41:48 +08:00
Alex Dunmow
ca9332180d docs: codeless-bnp.md — the declarative/logic split and artifact contract (WO-WZ-020)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-04 11:38:44 +08:00
Alex Dunmow
7a82028618 feat(captcha): remove Turnstile head-script injection (cms Cap captcha Phase E)
Cap is the sole captcha provider in the CMS; the Cloudflare Turnstile
TurnstileSiteKey field, settings read, and turnstileScript CDN injector are
retired from the shared bn head template.
2026-07-04 11:32:54 +08:00
55 changed files with 2226 additions and 12327 deletions

View File

@ -1,9 +1,44 @@
# Core SDK # Core SDK
Go module `git.dev.alexdunmow.com/block/core`. Defines plugin interfaces, template engine, block registry, and shared types for the BlockNinja CMS. Go module `git.dev.alexdunmow.com/block/core`.
**Purpose: core exists ONLY for code that must be shared between first-party
BlockNinja entities** — the **CMS**, the **orchestrator**, the **ninja CLI**,
and any future entity that genuinely needs to share code with the others.
## Admission test
Before adding anything here, ask: **do two or more first-party entities need
this code?**
- Needed by only one entity → it belongs in that entity's repo, not core.
- Needed by plugins → it does NOT belong here (see below); plugins build
against the wasm ABI, whose contract the CMS owns (`cms/backend/abi/`,
`cms/docs/abi/`).
The standing direction (Captain, 2026-07-07) is to **shrink core**: prefer
moving code out to its single consumer over adding more in. When a package's
consumer count drops to one, migrate it out.
## Critical Rules ## Critical Rules
- **NEVER use `replace` directives in go.mod** — not in this repo, not in any consumer. All module resolution goes through the Gitea module proxy. If you need to test local changes, tag and push a version. - **Core is NOT for plugins.** Plugins are standalone wasm artifacts built
- Plugins import from core only — never from the CMS (`blockninja/backend`) or orchestrator. against the wasm ABI, not this module. (Core was previously the plugin SDK;
- All consumers are in-house — no backwards compatibility shims needed. Just change the API and update consumers. that role is gone, and remaining plugin-era surface is legacy to be worked
off, not a precedent.)
- **NEVER use `replace` directives in go.mod** — not in this repo, not in any
consumer. All module resolution goes through the Gitea module proxy. If you
need to test local changes, tag and push a version.
- All consumers are in-house — no backwards compatibility shims needed. Just
change the API and update consumers.
## Special case: `templates/bn/` is a synced copy, do not author here
The bn page chrome (head / toolbar / engagement / asset_hooks) is **authored
in `cms/backend/templates/bn`** and mechanically copied here (`make
sync-templates` in cms) solely so guest-side plugin templates can compile it
into their wasm. check-safety check 31 fails cms commits while the copies
drift. Never edit these files here directly — change them in cms and sync.
`templates/bn/validation.go` is intentionally divergent (cms's version bridges
the SDK ValidationTracker under both context keys) and is NOT part of the sync
set. Spec: cms `docs/superpowers/specs/2026-07-07-bn-chrome-single-source-design.md`.

View File

@ -8,17 +8,10 @@ SDK_DOWNSTREAM_DIRS := \
$(wildcard $(HOME)/src/blockninja/sites/*) \ $(wildcard $(HOME)/src/blockninja/sites/*) \
$(wildcard $(HOME)/src/blockninja/plugins/*) $(wildcard $(HOME)/src/blockninja/plugins/*)
.PHONY: install-ninja # The ninja CLI moved to its own repo (git.dev.alexdunmow.com/block/cli,
install-ninja: # WO-WZ-023, Phase 7 core dissolution). Its plugin-registry orchestrator client
go install ./cmd/ninja # is generated there now, so core no longer carries an `install-ninja` or
# `proto` (plugin_registry) target.
# Regenerate Go bindings from the proto/ submodule. We narrow to
# plugin_registry.proto because other orchestrator/v1 protos (accounts.proto
# etc.) are owned by the orchestrator's generated package; registering them
# from core too would panic at startup with "file ... is already registered".
.PHONY: proto
proto:
buf generate --path proto/orchestrator/v1/plugin_registry.proto
# Lint + regenerate Go bindings for the wasm plugin ABI (repo-local buf # Lint + regenerate Go bindings for the wasm plugin ABI (repo-local buf
# module under abi/ — deliberately not part of the proto/ submodule; see # module under abi/ — deliberately not part of the proto/ submodule; see

View File

@ -1,6 +1,19 @@
# BlockNinja Plugin SDK # BlockNinja Core
Types, interfaces, and utilities for building BlockNinja plugins. Go code shared between first-party BlockNinja entities: the **CMS**, the
**orchestrator**, the **ninja CLI**, and any future entity that genuinely
needs to share code with the others.
> **Scope rule:** core is ONLY for code needed by **two or more** of those
> entities. Code with a single consumer belongs in that consumer's repo. The
> standing direction is to shrink core — when a package's consumer count drops
> to one, it gets migrated out.
> **Not a plugin SDK.** Plugins must **not** treat `block/core` as their SDK —
> in the wasm-plugin era they are standalone artifacts built against the wasm
> ABI (owned by the CMS: `cms/backend/abi/`, `cms/docs/abi/`). Core previously
> served as the plugin SDK; that role is gone, and remaining plugin-era
> surface here is legacy to be worked off, not a precedent.
## Package Structure ## Package Structure
@ -10,7 +23,7 @@ Types, interfaces, and utilities for building BlockNinja plugins.
| `blocks/` | BlockMeta, BlockFunc, BlockRegistry interface, BlockContext | | `blocks/` | BlockMeta, BlockFunc, BlockRegistry interface, BlockContext |
| `blocks/builtin/` | Reusable block implementations (HTMLBlock) | | `blocks/builtin/` | Reusable block implementations (HTMLBlock) |
| `templates/` | TemplateRegistry interface | | `templates/` | TemplateRegistry interface |
| `templates/bn/` | Shared templ components (head, engagement, toolbar) | | `templates/bn/` | Synced copy of the cms-authored bn chrome (see below) |
| `auth/` | Claims types, context extractors | | `auth/` | Claims types, context extractors |
| `content/` | Content access interface | | `content/` | Content access interface |
| `settings/` | Settings access interface | | `settings/` | Settings access interface |
@ -21,8 +34,20 @@ Types, interfaces, and utilities for building BlockNinja plugins.
| `ai/` | AI tool registry interface and types | | `ai/` | AI tool registry interface and types |
| `rbac/` | Role type definition | | `rbac/` | Role type definition |
### `templates/bn/` is a synced copy — do not edit here
The bn page chrome (head / toolbar / engagement / asset_hooks) is authored in
`cms/backend/templates/bn` and copied here via the cms `make sync-templates`
target, solely so guest-side plugin templates can compile it into their wasm.
check-safety (check 31) fails cms commits while the copies drift. Change the
chrome in cms, sync, then commit + tag + push here.
## Usage ## Usage
```go ```go
import "git.dev.alexdunmow.com/ninja/core/plugin" import "git.dev.alexdunmow.com/block/core/blocks"
``` ```
Versioned via git tags through the Gitea module proxy — never `replace`
directives. All consumers are in-house; no backwards-compatibility shims —
change the API and update the consumers.

235
backup/archive.go Normal file
View File

@ -0,0 +1,235 @@
package backup
import (
"archive/zip"
"context"
"crypto/sha256"
"encoding/base64"
"encoding/json"
"fmt"
"io"
"os"
"path/filepath"
"sort"
"time"
)
// Section maps a zip path prefix to an on-disk directory to be archived.
type Section struct {
Prefix string
Dir string
}
// BuildInput is the full set of inputs for BuildArchive.
type BuildInput struct {
Source Source
Dump []byte
Password string
Sections []Section
// Extra holds additional fixed zip entries (zip path -> contents), written
// after database.dump and before section files.
Extra map[string][]byte
}
// backupFile pairs a zip path with its source path on disk.
type backupFile struct {
zipPath string
diskPath string
}
// collectSectionFiles walks a section directory and returns zipPath→diskPath
// pairs. A missing directory is skipped silently (count 0, no warning), matching
// the cms collectSectionFiles semantics. Files that cannot be accessed inside an
// existing directory append warnings and are skipped.
func collectSectionFiles(ctx context.Context, section Section, warnings *[]string) ([]backupFile, error) {
if section.Dir == "" {
return nil, nil
}
if _, err := os.Stat(section.Dir); os.IsNotExist(err) {
return nil, nil
}
var files []backupFile
err := filepath.Walk(section.Dir, func(path string, info os.FileInfo, err error) error {
if ctx.Err() != nil {
return ctx.Err()
}
if err != nil {
*warnings = append(*warnings, fmt.Sprintf("Failed to access %s: %v", path, err))
return nil
}
if info.IsDir() || !info.Mode().IsRegular() {
return nil
}
relPath, err := filepath.Rel(section.Dir, path)
if err != nil {
*warnings = append(*warnings, fmt.Sprintf("Failed to get relative path for %s: %v", path, err))
return nil
}
files = append(files, backupFile{
zipPath: section.Prefix + filepath.ToSlash(relPath),
diskPath: path,
})
return nil
})
if err != nil {
if ctx.Err() != nil {
return nil, ctx.Err()
}
*warnings = append(*warnings, fmt.Sprintf("Failed to walk %s: %v", section.Dir, err))
}
return files, nil
}
// BuildArchive streams a backup zip archive to w: a manifest.json, the
// password-encrypted database dump, any Extra entries, then the files of each
// configured section in order. It returns the manifest and any non-fatal
// warnings encountered while collecting/writing section files.
//
// Zip entry ordering is: manifest.json, database.dump, Extra (sorted by path),
// then section files in section order. Counts for the well-known section
// prefixes (media/, brand/, icons/, styles/, plugins/) are recorded in the
// manifest.
func BuildArchive(ctx context.Context, w io.Writer, in BuildInput) (*Manifest, []string, error) {
var warnings []string
if err := ctx.Err(); err != nil {
return nil, warnings, err
}
// Checksum of the unencrypted dump.
checksum := computeChecksum(in.Dump)
// Encrypt the dump.
encryptedDump, encParams, err := EncryptWithPassword(in.Dump, in.Password)
if err != nil {
return nil, warnings, fmt.Errorf("encryption failed: %w", err)
}
// Collect files for every configured section, preserving section order.
sectionFiles := make([][]backupFile, len(in.Sections))
countsByPrefix := make(map[string]int)
for i, section := range in.Sections {
files, err := collectSectionFiles(ctx, section, &warnings)
if err != nil {
return nil, warnings, err
}
sectionFiles[i] = files
countsByPrefix[section.Prefix] += len(files)
}
manifest := &Manifest{
Version: FormatVersion,
Format: FormatName,
ExportedAt: time.Now().UTC(),
Source: in.Source,
DatabaseSize: int64(len(in.Dump)),
MediaCount: countsByPrefix["media/"],
BrandCount: countsByPrefix["brand/"],
IconsCount: countsByPrefix["icons/"],
StylesCount: countsByPrefix["styles/"],
PluginsCount: countsByPrefix["plugins/"],
Encryption: encParams,
Checksum: checksum,
}
manifestJSON, err := json.MarshalIndent(manifest, "", " ")
if err != nil {
return nil, warnings, fmt.Errorf("manifest marshal failed: %w", err)
}
if err := ctx.Err(); err != nil {
return nil, warnings, err
}
zipWriter := zip.NewWriter(w)
manifestEntry, err := zipWriter.Create("manifest.json")
if err != nil {
return nil, warnings, fmt.Errorf("failed to create manifest.json in zip: %w", err)
}
if _, err := manifestEntry.Write(manifestJSON); err != nil {
return nil, warnings, fmt.Errorf("failed to write manifest.json: %w", err)
}
dumpEntry, err := zipWriter.Create("database.dump")
if err != nil {
return nil, warnings, fmt.Errorf("failed to create database.dump in zip: %w", err)
}
if _, err := dumpEntry.Write(encryptedDump); err != nil {
return nil, warnings, fmt.Errorf("failed to write database.dump: %w", err)
}
// Extra fixed entries, in a stable (sorted) order for reproducibility.
// Failure contract (cms parity — the cssManifestsFile block in
// buildBackupArchive): a failed Create skips the entry silently, a failed
// Write appends a warning. Extra entries are regenerable extras; a problem
// with one must never abort the archive.
if len(in.Extra) > 0 {
names := make([]string, 0, len(in.Extra))
for name := range in.Extra {
names = append(names, name)
}
sort.Strings(names)
for _, name := range names {
entry, err := zipWriter.Create(name)
if err != nil {
continue
}
if _, err := entry.Write(in.Extra[name]); err != nil {
warnings = append(warnings, fmt.Sprintf("Failed to write %s: %v", name, err))
}
}
}
// Section files, with periodic context checks (every 10 files).
written := 0
for _, files := range sectionFiles {
for _, bf := range files {
if written%10 == 0 {
if err := ctx.Err(); err != nil {
return nil, warnings, err
}
}
written++
src, err := os.Open(bf.diskPath)
if err != nil {
warnings = append(warnings, fmt.Sprintf("Failed to read %s for zip: %v", bf.diskPath, err))
continue
}
entry, err := zipWriter.Create(bf.zipPath)
if err != nil {
closeBestEffort(src)
warnings = append(warnings, fmt.Sprintf("Failed to create %s in zip: %v", bf.zipPath, err))
continue
}
if _, err := io.Copy(entry, src); err != nil {
warnings = append(warnings, fmt.Sprintf("Failed to write %s to zip: %v", bf.zipPath, err))
}
closeBestEffort(src)
}
}
if err := zipWriter.Close(); err != nil {
return nil, warnings, fmt.Errorf("failed to close zip: %w", err)
}
return manifest, warnings, nil
}
func closeBestEffort(c io.Closer) {
_ = c.Close()
}
// computeChecksum calculates the SHA-256 hash of data, base64-encoded —
// byte-identical to the cms computeChecksum so the existing cms restore path
// (verifyChecksum) accepts archives built by this package.
func computeChecksum(data []byte) string {
hash := sha256.Sum256(data)
return base64.StdEncoding.EncodeToString(hash[:])
}

171
backup/archive_test.go Normal file
View File

@ -0,0 +1,171 @@
package backup
import (
"archive/zip"
"bytes"
"context"
"crypto/sha256"
"encoding/base64"
"io"
"os"
"path/filepath"
"strings"
"testing"
)
func readZipEntry(t *testing.T, data []byte, name string) []byte {
t.Helper()
zr, err := zip.NewReader(bytes.NewReader(data), int64(len(data)))
if err != nil {
t.Fatalf("zip.NewReader: %v", err)
}
for _, f := range zr.File {
if f.Name == name {
rc, err := f.Open()
if err != nil {
t.Fatalf("open %s: %v", name, err)
}
defer func() { _ = rc.Close() }()
b, err := io.ReadAll(rc)
if err != nil {
t.Fatalf("read %s: %v", name, err)
}
return b
}
}
t.Fatalf("zip entry %q not found", name)
return nil
}
func TestBuildArchiveRoundTrip(t *testing.T) {
dir := t.TempDir()
mediaDir := filepath.Join(dir, "media")
if err := os.MkdirAll(filepath.Join(mediaDir, "sub"), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(mediaDir, "sub", "a.txt"), []byte("hello"), 0o644); err != nil {
t.Fatal(err)
}
dump := []byte("fake pg_dump custom-format payload")
var buf bytes.Buffer
manifest, warnings, err := BuildArchive(context.Background(), &buf, BuildInput{
Source: Source{SiteTitle: "Test Site", Domain: "test.example.com"},
Dump: dump,
Password: "correct horse",
Sections: []Section{
{Prefix: "media/", Dir: mediaDir},
{Prefix: "brand/", Dir: filepath.Join(dir, "does-not-exist")}, // missing dir → warning, not error
},
})
if err != nil {
t.Fatalf("BuildArchive: %v", err)
}
if len(warnings) != 0 {
t.Fatalf("missing section dir must be silently skipped (cms parity), got warnings: %v", warnings)
}
if manifest.Version != FormatVersion || manifest.Format != FormatName {
t.Fatalf("manifest version/format = %d/%q", manifest.Version, manifest.Format)
}
if manifest.MediaCount != 1 || manifest.BrandCount != 0 {
t.Fatalf("counts: media=%d brand=%d", manifest.MediaCount, manifest.BrandCount)
}
wantSum := sha256.Sum256(dump)
if manifest.Checksum != base64.StdEncoding.EncodeToString(wantSum[:]) {
t.Fatalf("checksum mismatch")
}
// Read back: manifest parses, dump decrypts to the original bytes.
ra := bytes.NewReader(buf.Bytes())
got, err := ReadManifest(ra, int64(buf.Len()))
if err != nil {
t.Fatalf("ReadManifest: %v", err)
}
encDump := readZipEntry(t, buf.Bytes(), "database.dump")
plain, err := DecryptWithPassword(encDump, "correct horse", got.Encryption)
if err != nil {
t.Fatalf("DecryptWithPassword: %v", err)
}
if !bytes.Equal(plain, dump) {
t.Fatal("decrypted dump != original")
}
if _, err := DecryptWithPassword(encDump, "wrong password", got.Encryption); err == nil {
t.Fatal("wrong password must fail")
}
if string(readZipEntry(t, buf.Bytes(), "media/sub/a.txt")) != "hello" {
t.Fatal("media file missing or corrupted in archive")
}
}
// TestBuildArchiveExtraEntries locks the happy path of the Extra contract: extra
// entries land in the archive with their exact contents. The failure half of the
// contract (Create failure → skip silently, Write failure → warning, never a
// fatal error — cms cssManifestsFile parity) is documented at the Extra block in
// BuildArchive; zip.Writer failures cannot be injected deterministically without
// error-injection infrastructure, so the failure path is covered for section
// files below where the failure is controllable.
func TestBuildArchiveExtraEntries(t *testing.T) {
var buf bytes.Buffer
_, warnings, err := BuildArchive(context.Background(), &buf, BuildInput{
Source: Source{SiteTitle: "Test Site"},
Dump: []byte("dump"),
Password: "correct horse",
Extra: map[string][]byte{
"state/css-manifests.json": []byte(`{"plugins":{}}`),
},
})
if err != nil {
t.Fatalf("BuildArchive: %v", err)
}
if len(warnings) != 0 {
t.Fatalf("unexpected warnings: %v", warnings)
}
if got := string(readZipEntry(t, buf.Bytes(), "state/css-manifests.json")); got != `{"plugins":{}}` {
t.Fatalf("extra entry content = %q", got)
}
}
// TestBuildArchiveUnreadableFileWarnsAndContinues proves the warning-not-error
// contract: a file that cannot be opened inside an existing section dir appends
// a warning and the archive still completes with every readable file present.
func TestBuildArchiveUnreadableFileWarnsAndContinues(t *testing.T) {
if os.Getuid() == 0 {
t.Skip("running as root: chmod 0 files remain readable")
}
dir := t.TempDir()
mediaDir := filepath.Join(dir, "media")
if err := os.MkdirAll(mediaDir, 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(mediaDir, "good.txt"), []byte("ok"), 0o644); err != nil {
t.Fatal(err)
}
badPath := filepath.Join(mediaDir, "bad.txt")
if err := os.WriteFile(badPath, []byte("nope"), 0o644); err != nil {
t.Fatal(err)
}
if err := os.Chmod(badPath, 0o000); err != nil {
t.Fatal(err)
}
var buf bytes.Buffer
manifest, warnings, err := BuildArchive(context.Background(), &buf, BuildInput{
Source: Source{SiteTitle: "Test Site"},
Dump: []byte("dump"),
Password: "correct horse",
Sections: []Section{{Prefix: "media/", Dir: mediaDir}},
})
if err != nil {
t.Fatalf("unreadable file must not abort the archive, got error: %v", err)
}
if len(warnings) != 1 || !strings.Contains(warnings[0], "bad.txt") {
t.Fatalf("want exactly one warning mentioning bad.txt, got: %v", warnings)
}
// Both files were collected (open fails later, at write time) — cms parity.
if manifest.MediaCount != 2 {
t.Fatalf("media count = %d, want 2", manifest.MediaCount)
}
if got := string(readZipEntry(t, buf.Bytes(), "media/good.txt")); got != "ok" {
t.Fatalf("good.txt content = %q", got)
}
}

120
backup/crypto.go Normal file
View File

@ -0,0 +1,120 @@
package backup
import (
"crypto/aes"
"crypto/cipher"
"crypto/rand"
"encoding/base64"
"fmt"
"io"
"golang.org/x/crypto/argon2"
)
const (
// Argon2id parameters — byte-identical to the cms settings_crypto so that
// archives produced here decrypt through the existing cms restore path.
argon2Time = 3
argon2Memory = 64 * 1024 // 64 MB
argon2Threads = 4
argon2KeyLen = 32 // AES-256
saltLength = 16
)
// EncryptionParams stores the parameters used for key derivation. Its JSON shape
// is wire-identical to the cms EncryptionParams so manifests interoperate.
type EncryptionParams struct {
Algorithm string `json:"algorithm"`
KDF string `json:"kdf"`
Salt string `json:"salt"`
Iterations int `json:"iterations"`
Memory int `json:"memory"`
Parallelism int `json:"parallelism"`
}
// deriveKey derives a 32-byte key from password using Argon2id.
func deriveKey(password string, salt []byte) []byte {
return argon2.IDKey([]byte(password), salt, argon2Time, argon2Memory, argon2Threads, argon2KeyLen)
}
// EncryptWithPassword encrypts data with AES-256-GCM using a password-derived key.
// The nonce is prepended to the returned ciphertext.
func EncryptWithPassword(plaintext []byte, password string) ([]byte, *EncryptionParams, error) {
// Generate random salt
salt := make([]byte, saltLength)
if _, err := io.ReadFull(rand.Reader, salt); err != nil {
return nil, nil, fmt.Errorf("failed to generate salt: %w", err)
}
// Derive key from password
key := deriveKey(password, salt)
// Create AES cipher
block, err := aes.NewCipher(key)
if err != nil {
return nil, nil, fmt.Errorf("failed to create cipher: %w", err)
}
// Create GCM mode
gcm, err := cipher.NewGCM(block)
if err != nil {
return nil, nil, fmt.Errorf("failed to create GCM: %w", err)
}
// Generate nonce
nonce := make([]byte, gcm.NonceSize())
if _, err := io.ReadFull(rand.Reader, nonce); err != nil {
return nil, nil, fmt.Errorf("failed to generate nonce: %w", err)
}
// Encrypt (prepend nonce to ciphertext)
ciphertext := gcm.Seal(nonce, nonce, plaintext, nil)
return ciphertext, &EncryptionParams{
Algorithm: "aes-256-gcm",
KDF: "argon2id",
Salt: base64.StdEncoding.EncodeToString(salt),
Iterations: argon2Time,
Memory: argon2Memory,
Parallelism: argon2Threads,
}, nil
}
// DecryptWithPassword decrypts data encrypted with EncryptWithPassword.
func DecryptWithPassword(ciphertext []byte, password string, params *EncryptionParams) ([]byte, error) {
// Decode salt
salt, err := base64.StdEncoding.DecodeString(params.Salt)
if err != nil {
return nil, fmt.Errorf("failed to decode salt: %w", err)
}
// Derive key from password
key := deriveKey(password, salt)
// Create AES cipher
block, err := aes.NewCipher(key)
if err != nil {
return nil, fmt.Errorf("failed to create cipher: %w", err)
}
// Create GCM mode
gcm, err := cipher.NewGCM(block)
if err != nil {
return nil, fmt.Errorf("failed to create GCM: %w", err)
}
// Extract nonce
nonceSize := gcm.NonceSize()
if len(ciphertext) < nonceSize {
return nil, fmt.Errorf("ciphertext too short")
}
nonce, ciphertext := ciphertext[:nonceSize], ciphertext[nonceSize:]
// Decrypt
plaintext, err := gcm.Open(nil, nonce, ciphertext, nil)
if err != nil {
return nil, fmt.Errorf("decryption failed - invalid password or corrupted data")
}
return plaintext, nil
}

74
backup/manifest.go Normal file
View File

@ -0,0 +1,74 @@
package backup
import (
"archive/zip"
"encoding/json"
"fmt"
"io"
"time"
)
const (
// FormatVersion / FormatName identify the backup archive format. They are
// byte-identical to the cms constants (backupFormatVersion / backupFormatName)
// so archives interoperate with the existing cms restore path.
FormatVersion = 3
FormatName = "blockninja-pgdump-backup"
)
// Source identifies where a backup came from. Field-for-field identical to the
// cms BackupSource JSON shape.
type Source struct {
SiteTitle string `json:"site_title"`
Domain string `json:"domain,omitempty"`
}
// Manifest is the metadata stored in manifest.json. It is a field-for-field copy
// of the cms BackupManifest so archives built here restore through the existing
// cms path unchanged.
type Manifest struct {
Version int `json:"version"`
Format string `json:"format"`
ExportedAt time.Time `json:"exported_at"`
Source Source `json:"source"`
DatabaseSize int64 `json:"database_size"`
MediaCount int `json:"media_count"`
BrandCount int `json:"brand_count,omitempty"`
IconsCount int `json:"icons_count,omitempty"`
StylesCount int `json:"styles_count,omitempty"`
PluginsCount int `json:"plugins_count,omitempty"`
Encryption *EncryptionParams `json:"encryption,omitempty"`
Checksum string `json:"checksum"` // SHA-256 of unencrypted database dump
}
// ReadManifest opens a backup zip archive and parses its manifest.json entry.
func ReadManifest(r io.ReaderAt, size int64) (*Manifest, error) {
zr, err := zip.NewReader(r, size)
if err != nil {
return nil, fmt.Errorf("failed to open backup archive: %w", err)
}
for _, f := range zr.File {
if f.Name != "manifest.json" {
continue
}
rc, err := f.Open()
if err != nil {
return nil, fmt.Errorf("failed to open manifest.json: %w", err)
}
defer func() { _ = rc.Close() }()
data, err := io.ReadAll(rc)
if err != nil {
return nil, fmt.Errorf("failed to read manifest.json: %w", err)
}
var m Manifest
if err := json.Unmarshal(data, &m); err != nil {
return nil, fmt.Errorf("failed to parse manifest.json: %w", err)
}
return &m, nil
}
return nil, fmt.Errorf("manifest.json not found in backup archive")
}

View File

@ -1,6 +1,6 @@
// Code generated by templ - DO NOT EDIT. // Code generated by templ - DO NOT EDIT.
// templ: version: v0.3.1001 // templ: version: v0.3.1020
package shared package shared
//lint:file-ignore SA4006 This context is only used if a nested component is present. //lint:file-ignore SA4006 This context is only used if a nested component is present.
@ -44,7 +44,7 @@ func RenderButton(btn ButtonConfig) templ.Component {
var templ_7745c5c3_Var3 templ.SafeURL var templ_7745c5c3_Var3 templ.SafeURL
templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(btn.URL)) templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(btn.URL))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `button.templ`, Line: 8, Col: 33} return templ.Error{Err: templ_7745c5c3_Err, FileName: `blocks/shared/button.templ`, Line: 8, Col: 33}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var3)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var3))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
@ -55,11 +55,11 @@ func RenderButton(btn ButtonConfig) templ.Component {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
var templ_7745c5c3_Var4 string var templ_7745c5c3_Var4 string
templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinStringErrs(templ.CSSClasses(templ_7745c5c3_Var2).String()) templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.ResolveAttributeValue(templ.CSSClasses(templ_7745c5c3_Var2).String())
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `button.templ`, Line: 1, Col: 0} return templ.Error{Err: templ_7745c5c3_Err, FileName: `blocks/shared/button.templ`, Line: 1, Col: 0}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var4)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var4)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
@ -80,7 +80,7 @@ func RenderButton(btn ButtonConfig) templ.Component {
var templ_7745c5c3_Var5 string var templ_7745c5c3_Var5 string
templ_7745c5c3_Var5, templ_7745c5c3_Err = templruntime.SanitizeStyleAttributeValues(btn.InlineStyle()) templ_7745c5c3_Var5, templ_7745c5c3_Err = templruntime.SanitizeStyleAttributeValues(btn.InlineStyle())
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `button.templ`, Line: 14, Col: 29} return templ.Error{Err: templ_7745c5c3_Err, FileName: `blocks/shared/button.templ`, Line: 14, Col: 29}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var5)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var5))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
@ -93,7 +93,7 @@ func RenderButton(btn ButtonConfig) templ.Component {
var templ_7745c5c3_Var6 string var templ_7745c5c3_Var6 string
templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.JoinStringErrs(btn.Text) templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.JoinStringErrs(btn.Text)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `button.templ`, Line: 16, Col: 14} return templ.Error{Err: templ_7745c5c3_Err, FileName: `blocks/shared/button.templ`, Line: 16, Col: 14}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var6)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var6))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
@ -116,7 +116,7 @@ func RenderButton(btn ButtonConfig) templ.Component {
var templ_7745c5c3_Var8 templ.SafeURL var templ_7745c5c3_Var8 templ.SafeURL
templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(btn.URL)) templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(btn.URL))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `button.templ`, Line: 20, Col: 33} return templ.Error{Err: templ_7745c5c3_Err, FileName: `blocks/shared/button.templ`, Line: 20, Col: 33}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var8)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var8))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
@ -127,11 +127,11 @@ func RenderButton(btn ButtonConfig) templ.Component {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
var templ_7745c5c3_Var9 string var templ_7745c5c3_Var9 string
templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.JoinStringErrs(templ.CSSClasses(templ_7745c5c3_Var7).String()) templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.ResolveAttributeValue(templ.CSSClasses(templ_7745c5c3_Var7).String())
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `button.templ`, Line: 1, Col: 0} return templ.Error{Err: templ_7745c5c3_Err, FileName: `blocks/shared/button.templ`, Line: 1, Col: 0}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var9)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var9)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
@ -152,7 +152,7 @@ func RenderButton(btn ButtonConfig) templ.Component {
var templ_7745c5c3_Var10 string var templ_7745c5c3_Var10 string
templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.JoinStringErrs(btn.Text) templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.JoinStringErrs(btn.Text)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `button.templ`, Line: 27, Col: 14} return templ.Error{Err: templ_7745c5c3_Err, FileName: `blocks/shared/button.templ`, Line: 27, Col: 14}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var10)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var10))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
@ -175,11 +175,11 @@ func RenderButton(btn ButtonConfig) templ.Component {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
var templ_7745c5c3_Var12 string var templ_7745c5c3_Var12 string
templ_7745c5c3_Var12, templ_7745c5c3_Err = templ.JoinStringErrs(templ.CSSClasses(templ_7745c5c3_Var11).String()) templ_7745c5c3_Var12, templ_7745c5c3_Err = templ.ResolveAttributeValue(templ.CSSClasses(templ_7745c5c3_Var11).String())
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `button.templ`, Line: 1, Col: 0} return templ.Error{Err: templ_7745c5c3_Err, FileName: `blocks/shared/button.templ`, Line: 1, Col: 0}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var12)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var12)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
@ -190,7 +190,7 @@ func RenderButton(btn ButtonConfig) templ.Component {
var templ_7745c5c3_Var13 string var templ_7745c5c3_Var13 string
templ_7745c5c3_Var13, templ_7745c5c3_Err = templruntime.SanitizeStyleAttributeValues(btn.InlineStyle()) templ_7745c5c3_Var13, templ_7745c5c3_Err = templruntime.SanitizeStyleAttributeValues(btn.InlineStyle())
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `button.templ`, Line: 32, Col: 74} return templ.Error{Err: templ_7745c5c3_Err, FileName: `blocks/shared/button.templ`, Line: 32, Col: 74}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var13)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var13))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
@ -203,7 +203,7 @@ func RenderButton(btn ButtonConfig) templ.Component {
var templ_7745c5c3_Var14 string var templ_7745c5c3_Var14 string
templ_7745c5c3_Var14, templ_7745c5c3_Err = templ.JoinStringErrs(btn.Text) templ_7745c5c3_Var14, templ_7745c5c3_Err = templ.JoinStringErrs(btn.Text)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `button.templ`, Line: 33, Col: 14} return templ.Error{Err: templ_7745c5c3_Err, FileName: `blocks/shared/button.templ`, Line: 33, Col: 14}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var14)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var14))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
@ -224,11 +224,11 @@ func RenderButton(btn ButtonConfig) templ.Component {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
var templ_7745c5c3_Var16 string var templ_7745c5c3_Var16 string
templ_7745c5c3_Var16, templ_7745c5c3_Err = templ.JoinStringErrs(templ.CSSClasses(templ_7745c5c3_Var15).String()) templ_7745c5c3_Var16, templ_7745c5c3_Err = templ.ResolveAttributeValue(templ.CSSClasses(templ_7745c5c3_Var15).String())
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `button.templ`, Line: 1, Col: 0} return templ.Error{Err: templ_7745c5c3_Err, FileName: `blocks/shared/button.templ`, Line: 1, Col: 0}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var16)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var16)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
@ -239,7 +239,7 @@ func RenderButton(btn ButtonConfig) templ.Component {
var templ_7745c5c3_Var17 string var templ_7745c5c3_Var17 string
templ_7745c5c3_Var17, templ_7745c5c3_Err = templ.JoinStringErrs(btn.Text) templ_7745c5c3_Var17, templ_7745c5c3_Err = templ.JoinStringErrs(btn.Text)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `button.templ`, Line: 37, Col: 14} return templ.Error{Err: templ_7745c5c3_Err, FileName: `blocks/shared/button.templ`, Line: 37, Col: 14}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var17)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var17))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
@ -288,11 +288,11 @@ func RenderSubmitButton(btn ButtonConfig) templ.Component {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
var templ_7745c5c3_Var20 string var templ_7745c5c3_Var20 string
templ_7745c5c3_Var20, templ_7745c5c3_Err = templ.JoinStringErrs(templ.CSSClasses(templ_7745c5c3_Var19).String()) templ_7745c5c3_Var20, templ_7745c5c3_Err = templ.ResolveAttributeValue(templ.CSSClasses(templ_7745c5c3_Var19).String())
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `button.templ`, Line: 1, Col: 0} return templ.Error{Err: templ_7745c5c3_Err, FileName: `blocks/shared/button.templ`, Line: 1, Col: 0}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var20)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var20)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
@ -303,7 +303,7 @@ func RenderSubmitButton(btn ButtonConfig) templ.Component {
var templ_7745c5c3_Var21 string var templ_7745c5c3_Var21 string
templ_7745c5c3_Var21, templ_7745c5c3_Err = templruntime.SanitizeStyleAttributeValues(btn.InlineStyle()) templ_7745c5c3_Var21, templ_7745c5c3_Err = templruntime.SanitizeStyleAttributeValues(btn.InlineStyle())
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `button.templ`, Line: 46, Col: 73} return templ.Error{Err: templ_7745c5c3_Err, FileName: `blocks/shared/button.templ`, Line: 46, Col: 73}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var21)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var21))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
@ -316,7 +316,7 @@ func RenderSubmitButton(btn ButtonConfig) templ.Component {
var templ_7745c5c3_Var22 string var templ_7745c5c3_Var22 string
templ_7745c5c3_Var22, templ_7745c5c3_Err = templ.JoinStringErrs(btn.Text) templ_7745c5c3_Var22, templ_7745c5c3_Err = templ.JoinStringErrs(btn.Text)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `button.templ`, Line: 47, Col: 13} return templ.Error{Err: templ_7745c5c3_Err, FileName: `blocks/shared/button.templ`, Line: 47, Col: 13}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var22)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var22))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
@ -337,11 +337,11 @@ func RenderSubmitButton(btn ButtonConfig) templ.Component {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
var templ_7745c5c3_Var24 string var templ_7745c5c3_Var24 string
templ_7745c5c3_Var24, templ_7745c5c3_Err = templ.JoinStringErrs(templ.CSSClasses(templ_7745c5c3_Var23).String()) templ_7745c5c3_Var24, templ_7745c5c3_Err = templ.ResolveAttributeValue(templ.CSSClasses(templ_7745c5c3_Var23).String())
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `button.templ`, Line: 1, Col: 0} return templ.Error{Err: templ_7745c5c3_Err, FileName: `blocks/shared/button.templ`, Line: 1, Col: 0}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var24)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var24)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
@ -352,7 +352,7 @@ func RenderSubmitButton(btn ButtonConfig) templ.Component {
var templ_7745c5c3_Var25 string var templ_7745c5c3_Var25 string
templ_7745c5c3_Var25, templ_7745c5c3_Err = templ.JoinStringErrs(btn.Text) templ_7745c5c3_Var25, templ_7745c5c3_Err = templ.JoinStringErrs(btn.Text)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `button.templ`, Line: 51, Col: 13} return templ.Error{Err: templ_7745c5c3_Err, FileName: `blocks/shared/button.templ`, Line: 51, Col: 13}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var25)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var25))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {

View File

@ -10,6 +10,7 @@ import (
"encoding/hex" "encoding/hex"
"net/http" "net/http"
"strconv" "strconv"
"sync/atomic"
"time" "time"
) )
@ -34,11 +35,12 @@ type RedeemResponse struct {
} }
type Server struct { type Server struct {
secret []byte secret atomic.Pointer[[]byte]
count, size, d int count, size, d int
challengeTTL time.Duration challengeTTL time.Duration
tokenTTL time.Duration tokenTTL time.Duration
nonces NonceStore nonces NonceStore
usedTokens NonceStore
} }
type Option func(*Server) type Option func(*Server)
@ -50,24 +52,36 @@ func WithChallengeExpiry(d time.Duration) Option { return func(sv *Server) { sv.
func WithTokenExpiry(d time.Duration) Option { return func(sv *Server) { sv.tokenTTL = d } } func WithTokenExpiry(d time.Duration) Option { return func(sv *Server) { sv.tokenTTL = d } }
func WithNonceStore(ns NonceStore) Option { return func(sv *Server) { sv.nonces = ns } } func WithNonceStore(ns NonceStore) Option { return func(sv *Server) { sv.nonces = ns } }
// WithUsedTokenStore injects the single-use store for redemption tokens
// (mirrors WithNonceStore; a durable cross-process store can implement it later).
func WithUsedTokenStore(ns NonceStore) Option { return func(sv *Server) { sv.usedTokens = ns } }
// New builds a Server. Defaults match the Cap.js reference: 50 sub-challenges, // New builds a Server. Defaults match the Cap.js reference: 50 sub-challenges,
// 32-char salts, difficulty 4, 10-min challenge / 5-min token expiry. // 32-char salts, difficulty 4, 10-min challenge / 5-min token expiry.
func New(secret []byte, opts ...Option) *Server { func New(secret []byte, opts ...Option) *Server {
s := &Server{ s := &Server{
secret: secret,
count: 50, count: 50,
size: 32, size: 32,
d: 4, d: 4,
challengeTTL: 10 * time.Minute, challengeTTL: 10 * time.Minute,
tokenTTL: 5 * time.Minute, tokenTTL: 5 * time.Minute,
nonces: NewMemoryNonceStore(), nonces: NewMemoryNonceStore(),
usedTokens: NewMemoryNonceStore(),
} }
s.secret.Store(&secret)
for _, o := range opts { for _, o := range opts {
o(s) o(s)
} }
return s return s
} }
func (s *Server) secretBytes() []byte { return *s.secret.Load() }
// SetSecret swaps the HMAC secret at runtime. Outstanding challenge and
// verification tokens signed with the old secret immediately fail
// verification, so callers can use this to invalidate all issued tokens.
func (s *Server) SetSecret(secret []byte) { s.secret.Store(&secret) }
func (s *Server) CreateChallenge() (ChallengeResponse, error) { func (s *Server) CreateChallenge() (ChallengeResponse, error) {
buf := make([]byte, 25) buf := make([]byte, 25)
if _, err := rand.Read(buf); err != nil { if _, err := rand.Read(buf); err != nil {
@ -75,7 +89,7 @@ func (s *Server) CreateChallenge() (ChallengeResponse, error) {
} }
nonce := hex.EncodeToString(buf) nonce := hex.EncodeToString(buf)
expires := nowMs() + s.challengeTTL.Milliseconds() expires := nowMs() + s.challengeTTL.Milliseconds()
token := makeChallengeToken(s.secret, nonce, expires, s.count, s.size, s.d) token := makeChallengeToken(s.secretBytes(), nonce, expires, s.count, s.size, s.d)
return ChallengeResponse{ return ChallengeResponse{
Challenge: Challenge{C: s.count, S: s.size, D: s.d}, Challenge: Challenge{C: s.count, S: s.size, D: s.d},
Token: token, Token: token,
@ -84,7 +98,7 @@ func (s *Server) CreateChallenge() (ChallengeResponse, error) {
} }
func (s *Server) Redeem(token string, solutions []string) RedeemResponse { func (s *Server) Redeem(token string, solutions []string) RedeemResponse {
claims := verifyChallengeToken(s.secret, token) claims := verifyChallengeToken(s.secretBytes(), token)
if claims == nil { if claims == nil {
return RedeemResponse{Success: false} return RedeemResponse{Success: false}
} }
@ -105,15 +119,34 @@ func (s *Server) Redeem(token string, solutions []string) RedeemResponse {
} }
} }
expires := nowMs() + s.tokenTTL.Milliseconds() expires := nowMs() + s.tokenTTL.Milliseconds()
vt, err := makeVerificationToken(s.secret, expires) vt, err := makeVerificationToken(s.secretBytes(), expires)
if err != nil { if err != nil {
return RedeemResponse{Success: false} return RedeemResponse{Success: false}
} }
return RedeemResponse{Success: true, Token: vt, Expires: expires} return RedeemResponse{Success: true, Token: vt, Expires: expires}
} }
// VerifyToken validates a redemption token and consumes it: a token is
// SINGLE-USE and cannot be replayed within its TTL. The token is burned only on
// a successful verification (valid HMAC + unexpired + not previously used);
// forged/expired tokens never touch the store.
//
// Burn semantics — IMPORTANT for callers: consumption happens here, at the
// captcha layer, BEFORE any downstream business logic runs. So if a caller
// verifies the captcha and THEN rejects the request for a non-captcha reason
// (wrong password, invalid email, form validation error), the token is already
// spent. This is deliberate: it closes the replay window. The Cap widget
// re-issues a fresh token on its next solve, so a re-rendered form/error
// fragment that still carries the widget (with its reset flow) lets the visitor
// re-solve and retry — callers MUST keep the widget present on rejection paths.
func (s *Server) VerifyToken(token string) bool { func (s *Server) VerifyToken(token string) bool {
return verifyVerificationToken(s.secret, token) claims := parseVerificationToken(s.secretBytes(), token)
if claims == nil {
return false
}
ttl := max(time.Duration(claims.ExpiresMs-nowMs())*time.Millisecond, 0)
// MarkUsed returns false if this token id was already consumed → replay.
return s.usedTokens.MarkUsed(claims.ID, ttl)
} }
// VerifyRequest reads the cap-token form field and verifies it. Callers that // VerifyRequest reads the cap-token form field and verifies it. Callers that

View File

@ -42,6 +42,40 @@ func TestChallengeRedeemRoundTrip(t *testing.T) {
} }
} }
func TestVerifyTokenSingleUse(t *testing.T) {
s := New([]byte("secret"), WithChallenge(5, 8, 2))
cr, _ := s.CreateChallenge()
res := s.Redeem(cr.Token, solve(cr.Token, cr.Challenge))
if !res.Success {
t.Fatal("valid redeem rejected")
}
// First verification succeeds.
if !s.VerifyToken(res.Token) {
t.Fatal("first VerifyToken should succeed")
}
// Replaying the same token within its TTL must fail — single-use.
if s.VerifyToken(res.Token) {
t.Error("replayed verification token accepted (should be single-use)")
}
// A third replay stays rejected.
if s.VerifyToken(res.Token) {
t.Error("second replay of verification token accepted")
}
// A forged/garbage token never consumes and never verifies.
if s.VerifyToken("not-a-real-token") {
t.Error("garbage token accepted")
}
// An independently issued token is unaffected by another token's burn.
cr2, _ := s.CreateChallenge()
res2 := s.Redeem(cr2.Token, solve(cr2.Token, cr2.Challenge))
if !res2.Success {
t.Fatal("second redeem rejected")
}
if !s.VerifyToken(res2.Token) {
t.Error("fresh independent token rejected after another token was burned")
}
}
func TestRedeemRejects(t *testing.T) { func TestRedeemRejects(t *testing.T) {
s := New([]byte("secret"), WithChallenge(5, 8, 2)) s := New([]byte("secret"), WithChallenge(5, 8, 2))
cr, _ := s.CreateChallenge() cr, _ := s.CreateChallenge()

View File

@ -72,25 +72,44 @@ func makeVerificationToken(secret []byte, expiresMs int64) (string, error) {
return payload + ":" + sign(secret, payload), nil return payload + ":" + sign(secret, payload), nil
} }
func verifyVerificationToken(secret []byte, token string) bool { // verificationClaims is the parsed, cryptographically-verified content of a
// redemption token. ID is the random per-token identifier (unique per Redeem),
// used as the single-use key in the used-token store.
type verificationClaims struct {
ID string
ExpiresMs int64
}
// parseVerificationToken validates the HMAC + expiry and returns the claims, or
// nil if the token is empty, malformed, forged, or expired. This is a purely
// stateless check — it does NOT consume the token (see Server.VerifyToken for
// the single-use burn).
func parseVerificationToken(secret []byte, token string) *verificationClaims {
if token == "" { if token == "" {
return false return nil
} }
i := strings.LastIndex(token, ":") i := strings.LastIndex(token, ":")
if i < 0 { if i < 0 {
return false return nil
} }
payload, sig := token[:i], token[i+1:] payload, sig := token[:i], token[i+1:]
if !hmac.Equal([]byte(sig), []byte(sign(secret, payload))) { if !hmac.Equal([]byte(sig), []byte(sign(secret, payload))) {
return false return nil
} }
f := strings.Split(payload, ":") f := strings.Split(payload, ":")
if len(f) != 2 { if len(f) != 2 {
return false return nil
} }
expires, err := strconv.ParseInt(f[1], 10, 64) expires, err := strconv.ParseInt(f[1], 10, 64)
if err != nil { if err != nil {
return false return nil
} }
return expires > nowMs() if expires <= nowMs() {
return nil
}
return &verificationClaims{ID: f[0], ExpiresMs: expires}
}
func verifyVerificationToken(secret []byte, token string) bool {
return parseVerificationToken(secret, token) != nil
} }

View File

@ -1,150 +0,0 @@
package cmd
import (
"bufio"
"context"
"fmt"
"strconv"
"strings"
"connectrpc.com/connect"
"github.com/spf13/cobra"
"git.dev.alexdunmow.com/block/core/cmd/ninja/internal/creds"
"git.dev.alexdunmow.com/block/core/cmd/ninja/internal/orchclient"
v1 "git.dev.alexdunmow.com/block/core/internal/api/orchestrator/v1"
)
func newAccountCmd() *cobra.Command {
c := &cobra.Command{
Use: "account",
Short: "Manage which account ninja acts as",
Long: `Account-scoped commands like ` + "`ninja plugins publish --private`" + ` act
against an "active account" the orchestrator-side account whose members
can see and install the plugin. The active account is selected at
` + "`ninja login`" + ` time and persisted in your credentials file.`,
}
c.AddCommand(newAccountListCmd(), newAccountSetCmd(), newAccountShowCmd())
return c
}
func newAccountListCmd() *cobra.Command {
return &cobra.Command{
Use: "list",
Short: "List the accounts the authenticated user belongs to",
RunE: func(c *cobra.Command, _ []string) error {
cli, _, _, hc, err := resolveClient(c)
if err != nil {
return err
}
accts, err := cli.Auth.ListMyAccountsForCLI(context.Background(),
connect.NewRequest(&v1.ListMyAccountsForCLIRequest{}))
if err != nil {
return fmt.Errorf("list accounts: %w", err)
}
if len(accts.Msg.Accounts) == 0 {
fmt.Println("No accounts.")
return nil
}
for _, a := range accts.Msg.Accounts {
marker := " "
if a.Id == hc.ActiveAccountID {
marker = "* "
}
fmt.Printf("%s%s — %s\n", marker, a.Slug, a.Name)
}
return nil
},
}
}
func newAccountSetCmd() *cobra.Command {
return &cobra.Command{
Use: "set <slug>",
Short: "Change the active account",
Args: cobra.ExactArgs(1),
RunE: func(c *cobra.Command, args []string) error {
cli, cr, host, hc, err := resolveClient(c)
if err != nil {
return err
}
slug := strings.TrimPrefix(args[0], "@")
accts, err := cli.Auth.ListMyAccountsForCLI(context.Background(),
connect.NewRequest(&v1.ListMyAccountsForCLIRequest{}))
if err != nil {
return fmt.Errorf("list accounts: %w", err)
}
for _, a := range accts.Msg.Accounts {
if a.Slug == slug {
hc.ActiveAccountID = a.Id
hc.ActiveAccountSlug = a.Slug
cr.Hosts[host] = hc
if err := cr.Save(); err != nil {
return err
}
fmt.Printf("Active account: %s (%s)\n", a.Slug, a.Name)
return nil
}
}
return fmt.Errorf("account %q not found among your memberships; try `ninja account list`", slug)
},
}
}
func newAccountShowCmd() *cobra.Command {
return &cobra.Command{
Use: "show",
Short: "Show the currently active account",
RunE: func(c *cobra.Command, _ []string) error {
_, _, _, hc, err := resolveClient(c)
if err != nil {
return err
}
if hc.ActiveAccountSlug == "" {
fmt.Println("(no active account set; run `ninja login` or `ninja account set <slug>`)")
return nil
}
fmt.Printf("Active account: %s (id=%s)\n", hc.ActiveAccountSlug, hc.ActiveAccountID)
return nil
},
}
}
// resolveClient is a small helper used by every `ninja account` subcommand:
// it loads creds, resolves the host, and returns an authed client plus the
// loaded credentials so the caller can persist changes.
func resolveClient(c *cobra.Command) (*orchclient.Client, *creds.Credentials, string, creds.HostCreds, error) {
host, _ := c.Flags().GetString("host")
cr, err := creds.Load()
if err != nil {
return nil, nil, "", creds.HostCreds{}, err
}
resolvedHost, hc, err := cr.Resolve(host)
if err != nil {
return nil, nil, "", creds.HostCreds{}, err
}
return orchclient.New(resolvedHost, hc.Token), cr, resolvedHost, hc, nil
}
// pickAccountInteractive prompts the user to select an account by number from
// the given list and returns the chosen account. Used by `ninja login` when
// the user belongs to more than one account.
func pickAccountInteractive(scanner *bufio.Scanner, accounts []*v1.MyAccount) (*v1.MyAccount, error) {
if len(accounts) == 0 {
return nil, fmt.Errorf("no accounts available")
}
fmt.Println("Select an account:")
for i, a := range accounts {
fmt.Printf(" %d) %s — %s\n", i+1, a.Slug, a.Name)
}
fmt.Print("> ")
if !scanner.Scan() {
return nil, fmt.Errorf("cancelled")
}
v := strings.TrimSpace(scanner.Text())
n, err := strconv.Atoi(v)
if err != nil || n < 1 || n > len(accounts) {
return nil, fmt.Errorf("invalid selection: %s", v)
}
return accounts[n-1], nil
}

View File

@ -1,150 +0,0 @@
package cmd
import (
"bufio"
"context"
"fmt"
"os"
"time"
"connectrpc.com/connect"
"github.com/spf13/cobra"
"git.dev.alexdunmow.com/block/core/cmd/ninja/internal/creds"
"git.dev.alexdunmow.com/block/core/cmd/ninja/internal/orchclient"
v1 "git.dev.alexdunmow.com/block/core/internal/api/orchestrator/v1"
)
func newLoginCmd() *cobra.Command {
var host string
cmd := &cobra.Command{
Use: "login",
Short: "Authenticate against the orchestrator using device flow",
RunE: func(c *cobra.Command, _ []string) error {
if host == "" {
host, _ = c.Flags().GetString("host")
}
if host == "" {
host = "https://my.blockninjacms.com"
}
cli := orchclient.New(host, "")
ctx := context.Background()
start, err := cli.Auth.StartDevice(ctx, connect.NewRequest(&v1.StartDeviceRequest{
Scopes: []string{"plugin:read", "plugin:publish", "scope:admin"},
}))
if err != nil {
return fmt.Errorf("start device: %w", err)
}
fmt.Printf("Visit %s?user_code=%s to authorize.\n", start.Msg.VerificationUri, start.Msg.UserCode)
interval := time.Duration(start.Msg.IntervalSeconds) * time.Second
deadline := time.Now().Add(time.Duration(start.Msg.ExpiresInSeconds) * time.Second)
for time.Now().Before(deadline) {
time.Sleep(interval)
poll, err := cli.Auth.PollDevice(ctx, connect.NewRequest(&v1.PollDeviceRequest{DeviceCode: start.Msg.DeviceCode}))
if err != nil {
return err
}
switch poll.Msg.Status {
case "pending":
continue
case "approved":
cr, err := creds.Load()
if err != nil {
return err
}
cr.DefaultHost = host
if cr.Hosts == nil {
cr.Hosts = map[string]creds.HostCreds{}
}
hc := creds.HostCreds{Token: poll.Msg.AccessToken}
authed := orchclient.New(host, hc.Token)
if err := selectActiveAccount(ctx, authed, &hc); err != nil {
return err
}
cr.Hosts[host] = hc
if err := cr.Save(); err != nil {
return err
}
fmt.Println("Logged in.")
return nil
case "expired":
return fmt.Errorf("device code expired; try again")
}
}
return fmt.Errorf("login timed out")
},
}
cmd.Flags().StringVar(&host, "host", "", "Orchestrator base URL")
return cmd
}
func newWhoamiCmd() *cobra.Command {
return &cobra.Command{
Use: "whoami",
Short: "Show the currently logged-in user",
RunE: func(c *cobra.Command, _ []string) error {
host, _ := c.Flags().GetString("host")
cr, err := creds.Load()
if err != nil {
return err
}
resolvedHost, hc, err := cr.Resolve(host)
if err != nil {
return err
}
cli := orchclient.New(resolvedHost, hc.Token)
r, err := cli.Auth.Whoami(context.Background(), connect.NewRequest(&v1.WhoamiRequest{}))
if err != nil {
return err
}
fmt.Printf("%s <%s> at %s\n", r.Msg.DisplayName, r.Msg.Email, resolvedHost)
return nil
},
}
}
// selectActiveAccount fetches the user's accounts and writes the active one
// into hc. With 0 accounts it errors (the server contract guarantees every
// user has at least one). With 1 it auto-selects silently. With ≥2 it
// prompts interactively on stdin.
func selectActiveAccount(ctx context.Context, cli *orchclient.Client, hc *creds.HostCreds) error {
resp, err := cli.Auth.ListMyAccountsForCLI(ctx, connect.NewRequest(&v1.ListMyAccountsForCLIRequest{}))
if err != nil {
return fmt.Errorf("list accounts: %w", err)
}
accts := resp.Msg.Accounts
switch len(accts) {
case 0:
return fmt.Errorf("no accounts found for this user; contact support")
case 1:
hc.ActiveAccountID = accts[0].Id
hc.ActiveAccountSlug = accts[0].Slug
fmt.Printf("Active account: %s (%s)\n", accts[0].Slug, accts[0].Name)
return nil
}
chosen, err := pickAccountInteractive(bufio.NewScanner(os.Stdin), accts)
if err != nil {
return err
}
hc.ActiveAccountID = chosen.Id
hc.ActiveAccountSlug = chosen.Slug
fmt.Printf("Active account: %s (%s)\n", chosen.Slug, chosen.Name)
return nil
}
func newLogoutCmd() *cobra.Command {
return &cobra.Command{
Use: "logout",
Short: "Remove stored credentials",
RunE: func(c *cobra.Command, _ []string) error {
host, _ := c.Flags().GetString("host")
cr, err := creds.Load()
if err != nil {
return err
}
resolvedHost, _, _ := cr.Resolve(host)
delete(cr.Hosts, resolvedHost)
return cr.Save()
},
}
}

File diff suppressed because it is too large Load Diff

View File

@ -1,140 +0,0 @@
package cmd
import (
"context"
"fmt"
"strings"
"github.com/spf13/cobra"
"git.dev.alexdunmow.com/block/core/cmd/ninja/internal/bnp"
)
func newPluginBuildCmd() *cobra.Command {
var dir, output string
var codeless bool
cmd := &cobra.Command{
Use: "build",
Short: "Compile a plugin to wasm and pack a .bnp artifact",
Long: `Compile the plugin in --dir to reactor-mode wasip1 wasm, extract its
static manifest by instantiating the module once (HOOK_DESCRIBE), and pack a
.bnp (tar.zst of plugin.wasm, plugin.mod, manifest.pb, plus migrations/,
schemas/, assets/, web/dist when present).
No Docker or podman: the whole pipeline is the local Go toolchain (>= 1.24)
plus wazero. This replaces the in-container .so compile.`,
RunE: func(c *cobra.Command, _ []string) error {
// Classify by repo shape (WO-WZ-020): no Go source → codeless
// (declarative, no wasm); Go source → wasm. --codeless asserts
// the expectation and fails loudly on a mismatch.
isCodeless, err := bnp.IsCodelessRepo(dir)
if err != nil {
return err
}
if codeless && !isCodeless {
return fmt.Errorf("--codeless asserted but %s contains Go source — a codeless plugin has no code (delete the Go or drop the flag)", dir)
}
var res *bnp.BuildResult
if isCodeless {
res, err = bnp.BuildCodeless(context.Background(), bnp.BuildOptions{Dir: dir, Output: output})
} else {
res, err = bnp.Build(context.Background(), bnp.BuildOptions{Dir: dir, Output: output})
}
if err != nil {
return err
}
printBuildSummary(res)
return nil
},
}
cmd.Flags().StringVar(&dir, "dir", ".", "Plugin repo directory")
cmd.Flags().StringVarP(&output, "output", "o", "", "Output .bnp path (default <name>-<version>.bnp)")
cmd.Flags().BoolVar(&codeless, "codeless", false, "Assert the repo builds a codeless (no-wasm) artifact; fail if it contains Go source")
return cmd
}
func newPluginVerifyCmd() *cobra.Command {
cmd := &cobra.Command{
Use: "verify <file.bnp>",
Short: "Validate a .bnp against the loader's layout/name/abi/path/size checks",
Long: `Re-run the CMS reader's checks on a .bnp standalone so CI and the registry
can gate uploads: required members present, path-safety and size caps on
extraction, manifest decodes, abi_version supported, and manifest name matches
plugin.mod. Prints a named reason for each malformed class.`,
Args: cobra.ExactArgs(1),
RunE: func(c *cobra.Command, args []string) error {
res, err := bnp.Verify(args[0])
if err != nil {
return err
}
printVerifySummary(args[0], res)
return nil
},
}
return cmd
}
func printBuildSummary(r *bnp.BuildResult) {
hooks := "(none)"
if len(r.Hooks) > 0 {
hooks = strings.Join(r.Hooks, ", ")
}
dirs := "(none)"
if len(r.IncludedDirs) > 0 {
dirs = strings.Join(r.IncludedDirs, ", ")
}
kind := "wasm"
if r.Codeless {
kind = "codeless"
}
fmt.Printf("Built %s@%s (%s) → %s\n", r.Name, r.Version, kind, r.OutputPath)
fmt.Println(" ┌───────────────────────────────────────────")
fmt.Printf(" │ artifact size %s (%s uncompressed)\n", humanBytes(r.ArtifactBytes), humanBytes(r.UncompBytes))
fmt.Printf(" │ plugin.wasm %s\n", humanBytes(r.WasmBytes))
fmt.Printf(" │ manifest.pb %s\n", humanBytes(r.ManifestBytes))
fmt.Printf(" │ blocks %d\n", r.BlockCount)
fmt.Printf(" │ templates %d\n", r.TemplateCount)
fmt.Printf(" │ admin pages %d\n", r.AdminPages)
fmt.Printf(" │ job types %d\n", r.JobTypes)
fmt.Printf(" │ hooks %s\n", hooks)
fmt.Printf(" │ bundled dirs %s\n", dirs)
fmt.Printf(" │ data_dir grant %t\n", r.DataDir)
fmt.Println(" └───────────────────────────────────────────")
}
func printVerifySummary(path string, r *bnp.VerifyResult) {
var dirs []string
if r.HasMigrations {
dirs = append(dirs, "migrations")
}
if r.HasSchemas {
dirs = append(dirs, "schemas")
}
if r.HasAssets {
dirs = append(dirs, "assets")
}
if r.HasWeb {
dirs = append(dirs, "web")
}
joined := "(none)"
if len(dirs) > 0 {
joined = strings.Join(dirs, ", ")
}
fmt.Printf("OK: %s\n", path)
fmt.Printf(" name=%s version=%s abi_version=%d blocks=%d data_dir=%t dirs=[%s]\n",
r.Name, r.Version, r.ABIVersion, r.BlockCount, r.DataDir, joined)
}
// humanBytes formats a byte count with a binary unit suffix.
func humanBytes(n int64) string {
const unit = 1024
if n < unit {
return fmt.Sprintf("%d B", n)
}
div, exp := int64(unit), 0
for x := n / unit; x >= unit; x /= unit {
div *= unit
exp++
}
return fmt.Sprintf("%.1f %ciB", float64(n)/float64(div), "KMGTPE"[exp])
}

View File

@ -1,279 +0,0 @@
package cmd
import (
"archive/tar"
"context"
"os"
"path/filepath"
"slices"
"strings"
"testing"
abiv1 "git.dev.alexdunmow.com/block/core/abi/v1"
"git.dev.alexdunmow.com/block/core/cmd/ninja/internal/bnp"
"github.com/klauspost/compress/zstd"
"google.golang.org/protobuf/proto"
)
// fixtureDir resolves a testdata plugin fixture in the core module tree.
func fixtureDir(t *testing.T, name string) string {
t.Helper()
dir, err := filepath.Abs(filepath.Join("..", "..", "..", "plugin", "wasmguest", "testdata", name))
if err != nil {
t.Fatalf("resolve fixture %s: %v", name, err)
}
if _, err := os.Stat(filepath.Join(dir, "plugin.mod")); err != nil {
t.Fatalf("fixture %s missing plugin.mod: %v", name, err)
}
return dir
}
// TestPluginBuildAndVerify is the WO-WZ-009 acceptance e2e: build the WZ-002
// fixture repo → a .bnp exists, `verify` passes, and its manifest decodes with
// the expected block keys and the data_dir grant from plugin.mod.
func TestPluginBuildAndVerify(t *testing.T) {
if testing.Short() {
t.Skip("compiles a wasm module; skipped in -short")
}
out := filepath.Join(t.TempDir(), "wasmfixture-0.0.1.bnp")
res, err := bnp.Build(context.Background(), bnp.BuildOptions{
Dir: fixtureDir(t, "fixture"),
Output: out,
})
if err != nil {
t.Fatalf("build: %v", err)
}
if res.Name != "wasmfixture" || res.Version != "0.0.1" {
t.Errorf("identity = %q/%q", res.Name, res.Version)
}
if res.BlockCount != 1 || res.TemplateCount != 1 || res.AdminPages != 1 {
t.Errorf("counts blocks=%d templates=%d admin=%d", res.BlockCount, res.TemplateCount, res.AdminPages)
}
if !res.DataDir {
t.Errorf("data_dir grant not carried from plugin.mod into the manifest")
}
if !slices.Contains(res.Hooks, "load") {
t.Errorf("hooks = %v, want load present", res.Hooks)
}
if _, err := os.Stat(out); err != nil {
t.Fatalf("artifact not written: %v", err)
}
// verify passes on the produced artifact.
vr, err := bnp.Verify(out)
if err != nil {
t.Fatalf("verify: %v", err)
}
if vr.Name != "wasmfixture" || vr.ABIVersion != 1 || !vr.DataDir {
t.Errorf("verify result = %+v", vr)
}
// manifest decodes with expected block keys.
m, err := bnp.ReadManifest(out)
if err != nil {
t.Fatalf("read manifest: %v", err)
}
var keys []string
for _, b := range m.GetBlocks() {
keys = append(keys, b.GetKey())
}
if !slices.Contains(keys, "wasmfixture:greeting") {
t.Errorf("block keys = %v, want wasmfixture:greeting", keys)
}
if !slices.Contains(m.GetTemplateKeys(), "wasmfixture-page") {
t.Errorf("template keys = %v, want wasmfixture-page", m.GetTemplateKeys())
}
if !m.GetDataDir() {
t.Errorf("manifest.data_dir = false, want true")
}
}
// TestPluginBuildDefaultOutputName confirms the default artifact name is
// <name>-<version>.bnp in the working directory.
func TestPluginBuildDefaultOutputName(t *testing.T) {
if testing.Short() {
t.Skip("compiles a wasm module; skipped in -short")
}
dir := fixtureDir(t, "fixture") // resolve to an absolute path before chdir
wd, _ := os.Getwd()
t.Cleanup(func() { _ = os.Chdir(wd) })
tmp := t.TempDir()
if err := os.Chdir(tmp); err != nil {
t.Fatalf("chdir: %v", err)
}
res, err := bnp.Build(context.Background(), bnp.BuildOptions{Dir: dir})
if err != nil {
t.Fatalf("build: %v", err)
}
if res.OutputPath != "wasmfixture-0.0.1.bnp" {
t.Errorf("default output = %q", res.OutputPath)
}
if _, err := os.Stat(filepath.Join(tmp, "wasmfixture-0.0.1.bnp")); err != nil {
t.Errorf("default artifact not in cwd: %v", err)
}
}
// TestPluginBuildCapabilityInRegisterFails proves a plugin that reaches a host
// capability at describe time fails with an actionable error naming the call.
func TestPluginBuildCapabilityInRegisterFails(t *testing.T) {
if testing.Short() {
t.Skip("compiles a wasm module; skipped in -short")
}
out := filepath.Join(t.TempDir(), "capfixture.bnp")
_, err := bnp.Build(context.Background(), bnp.BuildOptions{
Dir: fixtureDir(t, "capfixture"),
Output: out,
})
if err == nil {
t.Fatal("expected build to fail on a describe-time capability call")
}
msg := err.Error()
if !strings.Contains(msg, "db.query") || !strings.Contains(msg, "manifest extraction") {
t.Errorf("error not actionable: %q (want it to name db.query + manifest extraction)", msg)
}
if _, statErr := os.Stat(out); statErr == nil {
t.Errorf("a .bnp must not be written when the build fails")
}
}
// --- verify rejection classes (fast; no wasm compile) ---
type fakeEntry struct {
name string
data []byte
typeflag byte
}
// writeTarZst crafts an arbitrary tar.zst for the malformed-artifact tests.
func writeTarZst(t *testing.T, entries []fakeEntry) string {
t.Helper()
path := filepath.Join(t.TempDir(), "artifact.bnp")
f, err := os.Create(path)
if err != nil {
t.Fatalf("create: %v", err)
}
defer func() { _ = f.Close() }()
enc, _ := zstd.NewWriter(f)
tw := tar.NewWriter(enc)
for _, e := range entries {
typ := e.typeflag
if typ == 0 {
typ = tar.TypeReg
}
hdr := &tar.Header{Name: e.name, Typeflag: typ, Mode: 0o644, Size: int64(len(e.data))}
if typ == tar.TypeSymlink {
hdr.Linkname = string(e.data)
hdr.Size = 0
}
if err := tw.WriteHeader(hdr); err != nil {
t.Fatalf("hdr %q: %v", e.name, err)
}
if typ == tar.TypeReg {
if _, err := tw.Write(e.data); err != nil {
t.Fatalf("write %q: %v", e.name, err)
}
}
}
_ = tw.Close()
_ = enc.Close()
return path
}
func manifestBytes(t *testing.T, name string, abi uint32) []byte {
t.Helper()
b, err := proto.Marshal(&abiv1.PluginManifest{Name: name, Version: "1.0.0", AbiVersion: abi})
if err != nil {
t.Fatalf("marshal manifest: %v", err)
}
return b
}
func TestVerifyRejectsMalformed(t *testing.T) {
validMod := []byte("[plugin]\nname = \"demo\"\nversion = \"1.0.0\"\n")
cases := []struct {
name string
entries []fakeEntry
wantMsg string
}{
{
name: "missing manifest.pb",
entries: []fakeEntry{
{name: "plugin.wasm", data: []byte("\x00asm")},
{name: "plugin.mod", data: validMod},
},
wantMsg: "missing required manifest.pb",
},
{
name: "undecodable manifest",
entries: []fakeEntry{
{name: "plugin.wasm", data: []byte("\x00asm")},
{name: "plugin.mod", data: validMod},
{name: "manifest.pb", data: []byte("not-a-proto\xff\xff")},
},
wantMsg: "decode manifest.pb",
},
{
name: "unsupported abi_version",
entries: []fakeEntry{
{name: "plugin.wasm", data: []byte("\x00asm")},
{name: "plugin.mod", data: validMod},
{name: "manifest.pb", data: manifestBytes(t, "demo", 2)},
},
wantMsg: "unsupported abi_version 2",
},
{
name: "empty manifest name",
entries: []fakeEntry{
{name: "plugin.wasm", data: []byte("\x00asm")},
{name: "plugin.mod", data: validMod},
{name: "manifest.pb", data: manifestBytes(t, "", 1)},
},
wantMsg: "empty name",
},
{
name: "name mismatch",
entries: []fakeEntry{
{name: "plugin.wasm", data: []byte("\x00asm")},
{name: "plugin.mod", data: validMod},
{name: "manifest.pb", data: manifestBytes(t, "other", 1)},
},
wantMsg: "!= plugin.mod name",
},
{
name: "absolute path entry",
entries: []fakeEntry{
{name: "/etc/passwd", data: []byte("x")},
},
wantMsg: "absolute entry path",
},
{
name: "traversal entry",
entries: []fakeEntry{
{name: "../escape.txt", data: []byte("x")},
},
wantMsg: "traversal segment",
},
{
name: "symlink entry",
entries: []fakeEntry{
{name: "link", data: []byte("/etc/passwd"), typeflag: tar.TypeSymlink},
},
wantMsg: "non-regular entry",
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
path := writeTarZst(t, tc.entries)
_, err := bnp.Verify(path)
if err == nil {
t.Fatalf("expected rejection, got nil")
}
if !strings.Contains(err.Error(), tc.wantMsg) {
t.Errorf("error %q does not mention %q", err.Error(), tc.wantMsg)
}
})
}
}

View File

@ -1,175 +0,0 @@
package cmd
import (
"context"
"fmt"
"os"
"slices"
"sort"
"strings"
"connectrpc.com/connect"
"github.com/spf13/cobra"
core "git.dev.alexdunmow.com/block/core/plugin"
"git.dev.alexdunmow.com/block/core/cmd/ninja/internal/creds"
"git.dev.alexdunmow.com/block/core/cmd/ninja/internal/orchclient"
v1 "git.dev.alexdunmow.com/block/core/internal/api/orchestrator/v1"
)
func newPluginTagsCmd() *cobra.Command {
cmd := &cobra.Command{
Use: "tags",
Short: "Show current tags and popular tags from the registry",
RunE: func(c *cobra.Command, _ []string) error {
mod, err := readLocalMod()
if err != nil {
return err
}
if len(mod.Plugin.Tags) == 0 {
fmt.Println("Current tags: (none)")
} else {
fmt.Printf("Current tags: %s\n", strings.Join(mod.Plugin.Tags, ", "))
}
host, _ := c.Flags().GetString("host")
cr, err := creds.Load()
if err != nil {
return nil // not signed in is fine — silent best-effort
}
resolvedHost, hc, err := cr.Resolve(host)
if err != nil {
return nil
}
cli := orchclient.New(resolvedHost, hc.Token)
line := fetchPopularTagsForList(cli, mod.Plugin.Kind)
fmt.Println(line)
return nil
},
}
cmd.AddCommand(&cobra.Command{
Use: "add <tag>...",
Short: "Add tags to the local plugin.mod (union with current)",
Args: cobra.MinimumNArgs(1),
RunE: func(_ *cobra.Command, args []string) error { return mutateTags("add", args) },
})
cmd.AddCommand(&cobra.Command{
Use: "rm <tag>...",
Short: "Remove tags from the local plugin.mod",
Args: cobra.MinimumNArgs(1),
RunE: func(_ *cobra.Command, args []string) error { return mutateTags("rm", args) },
})
cmd.AddCommand(&cobra.Command{
Use: "set <tag>...",
Short: "Replace all tags in the local plugin.mod",
Args: cobra.MinimumNArgs(1),
RunE: func(_ *cobra.Command, args []string) error { return mutateTags("set", args) },
})
cmd.AddCommand(&cobra.Command{
Use: "clear",
Short: "Remove all tags from the local plugin.mod",
Args: cobra.NoArgs,
RunE: func(_ *cobra.Command, _ []string) error { return mutateTags("clear", nil) },
})
return cmd
}
// mutateTags reads plugin.mod, computes the new tag set, normalises, and writes
// it back. Prints the before→after diff and a reminder to publish.
func mutateTags(op string, args []string) error {
mod, err := readLocalMod()
if err != nil {
return err
}
before := append([]string(nil), mod.Plugin.Tags...)
var next []string
switch op {
case "add":
next = append(append([]string(nil), before...), args...)
case "rm":
drop := map[string]struct{}{}
for _, a := range args {
drop[strings.ToLower(strings.TrimSpace(a))] = struct{}{}
}
for _, t := range before {
if _, gone := drop[t]; !gone {
next = append(next, t)
}
}
case "set":
next = append([]string(nil), args...)
case "clear":
next = nil
default:
return fmt.Errorf("unknown tag op: %s", op)
}
normalised, err := core.NormalizeTags(next)
if err != nil {
return err
}
if err := writeLocalModTags(mod, normalised); err != nil {
return err
}
sortedBefore := append([]string(nil), before...)
sortedAfter := append([]string(nil), normalised...)
sort.Strings(sortedBefore)
sort.Strings(sortedAfter)
fmt.Printf("Tags: [%s] → [%s]\n", strings.Join(sortedBefore, ", "), strings.Join(sortedAfter, ", "))
if !slices.Equal(sortedBefore, sortedAfter) {
fmt.Println("Run 'ninja plugin publish' to push to the registry.")
}
return nil
}
func readLocalMod() (*core.ModFile, error) {
b, err := os.ReadFile("plugin.mod")
if err != nil {
return nil, fmt.Errorf("read plugin.mod: %w", err)
}
mod, err := core.ParseModFull(b)
if err != nil {
return nil, fmt.Errorf("parse plugin.mod: %w", err)
}
return mod, nil
}
// writeLocalModTags rewrites plugin.mod with the new tag set, preserving all
// other fields by reusing upsertPluginMod.
func writeLocalModTags(mod *core.ModFile, tags []string) error {
return upsertPluginMod(
mod.Plugin.Scope,
mod.Plugin.Name,
mod.Plugin.DisplayName,
mod.Plugin.Description,
mod.Plugin.Kind,
mod.Plugin.Categories,
tags,
mod.Plugin.Private,
)
}
// fetchPopularTagsForList returns a single user-facing line listing the most-used
// tags for the given kind. Renders "Popular: tag (count), ...", "Popular tags:
// (none yet)" when no tags exist on public plugins yet, or an "(unreachable)"
// notice if the RPC fails.
func fetchPopularTagsForList(cli *orchclient.Client, kind string) string {
resp, err := cli.Reg.ListTags(context.Background(), connect.NewRequest(&v1.ListTagsRequest{Kind: kind, Limit: 20}))
if err != nil {
return "(could not fetch popular tags — orchestrator unreachable)"
}
if len(resp.Msg.Tags) == 0 {
return "Popular tags: (none yet)"
}
parts := make([]string, len(resp.Msg.Tags))
for i, t := range resp.Msg.Tags {
parts[i] = fmt.Sprintf("%s (%d)", t.Tag, t.Count)
}
return "Popular: " + strings.Join(parts, ", ")
}

View File

@ -1,637 +0,0 @@
package cmd
import (
"bytes"
"os"
"os/exec"
"path/filepath"
"strings"
"testing"
core "git.dev.alexdunmow.com/block/core/plugin"
)
func TestCheckRepoHasHEAD_NoCommitsReturnsFriendlyError(t *testing.T) {
dir := t.TempDir()
runGit(t, dir, "init", "-q")
err := checkRepoHasHEAD(dir)
if err == nil {
t.Fatal("expected error for repo with no commits, got nil")
}
if !strings.Contains(err.Error(), "no commits in repository") {
t.Errorf("error %q should mention 'no commits in repository'", err.Error())
}
if !strings.Contains(err.Error(), "git commit") {
t.Errorf("error %q should suggest `git commit`", err.Error())
}
}
func TestCheckRepoHasHEAD_WithCommitReturnsNil(t *testing.T) {
dir := t.TempDir()
runGit(t, dir, "init", "-q")
if err := os.WriteFile(filepath.Join(dir, "f"), []byte("hi"), 0o644); err != nil {
t.Fatal(err)
}
runGit(t, dir, "add", "f")
runGit(t, dir, "commit", "-qm", "init")
if err := checkRepoHasHEAD(dir); err != nil {
t.Errorf("expected nil for repo with a commit, got %v", err)
}
}
func TestAutoCommitPluginMod_CommitsWhenDirty(t *testing.T) {
dir := t.TempDir()
runGit(t, dir, "init", "-q")
if err := os.WriteFile(filepath.Join(dir, "README.md"), []byte("hi"), 0o644); err != nil {
t.Fatal(err)
}
runGit(t, dir, "add", "README.md")
runGit(t, dir, "commit", "-qm", "init")
if err := os.WriteFile(filepath.Join(dir, "plugin.mod"),
[]byte("[plugin]\nname = \"x\"\nscope = \"@s\"\nversion = \"0.1.0\"\n"), 0o644); err != nil {
t.Fatal(err)
}
t.Chdir(dir)
if err := autoCommitPluginMod("Add plugin.mod"); err != nil {
t.Fatalf("autoCommitPluginMod: %v", err)
}
subject := gitLogSubject(t, dir)
if subject != "Add plugin.mod" {
t.Errorf("expected latest commit subject 'Add plugin.mod', got %q", subject)
}
}
func TestAutoCommitPluginMod_NoopWhenClean(t *testing.T) {
dir := t.TempDir()
runGit(t, dir, "init", "-q")
if err := os.WriteFile(filepath.Join(dir, "plugin.mod"),
[]byte("[plugin]\nname = \"x\"\nscope = \"@s\"\nversion = \"0.1.0\"\n"), 0o644); err != nil {
t.Fatal(err)
}
runGit(t, dir, "add", "plugin.mod")
runGit(t, dir, "commit", "-qm", "seed")
beforeSHA := gitHeadSHA(t, dir)
t.Chdir(dir)
if err := autoCommitPluginMod("Add plugin.mod"); err != nil {
t.Fatalf("autoCommitPluginMod: %v", err)
}
afterSHA := gitHeadSHA(t, dir)
if afterSHA != beforeSHA {
t.Errorf("expected no new commit, HEAD moved %s -> %s", beforeSHA, afterSHA)
}
}
func TestAutoCommitPluginMod_WorksOnDetachedHEAD(t *testing.T) {
dir := t.TempDir()
runGit(t, dir, "init", "-q")
if err := os.WriteFile(filepath.Join(dir, "README.md"), []byte("hi"), 0o644); err != nil {
t.Fatal(err)
}
runGit(t, dir, "add", "README.md")
runGit(t, dir, "commit", "-qm", "init")
initialSHA := gitHeadSHA(t, dir)
runGit(t, dir, "checkout", "-q", initialSHA)
if err := os.WriteFile(filepath.Join(dir, "plugin.mod"),
[]byte("[plugin]\nname = \"x\"\nscope = \"@s\"\nversion = \"0.1.0\"\n"), 0o644); err != nil {
t.Fatal(err)
}
t.Chdir(dir)
if err := autoCommitPluginMod("Add plugin.mod"); err != nil {
t.Fatalf("autoCommitPluginMod on detached HEAD: %v", err)
}
afterSHA := gitHeadSHA(t, dir)
if afterSHA == initialSHA {
t.Fatalf("expected new commit on detached HEAD, HEAD still at %s", afterSHA)
}
subject := gitLogSubject(t, dir)
if subject != "Add plugin.mod" {
t.Errorf("expected latest commit subject 'Add plugin.mod', got %q", subject)
}
parentCmd := exec.Command("git", "rev-parse", "HEAD^")
parentCmd.Dir = dir
parentOut, err := parentCmd.CombinedOutput()
if err != nil {
t.Fatalf("git rev-parse HEAD^: %v\n%s", err, parentOut)
}
parentSHA := strings.TrimSpace(string(parentOut))
if parentSHA != initialSHA {
t.Errorf("expected new commit parent to be %s, got %s", initialSHA, parentSHA)
}
}
func TestAutoCommitPluginMod_UsesProvidedMessage(t *testing.T) {
dir := t.TempDir()
runGit(t, dir, "init", "-q")
if err := os.WriteFile(filepath.Join(dir, "README.md"), []byte("hi"), 0o644); err != nil {
t.Fatal(err)
}
runGit(t, dir, "add", "README.md")
runGit(t, dir, "commit", "-qm", "init")
if err := os.WriteFile(filepath.Join(dir, "plugin.mod"),
[]byte("[plugin]\nname = \"x\"\nscope = \"@s\"\nversion = \"0.3.0\"\n"), 0o644); err != nil {
t.Fatal(err)
}
t.Chdir(dir)
if err := autoCommitPluginMod("bump to 0.3.0"); err != nil {
t.Fatalf("autoCommitPluginMod: %v", err)
}
if got := gitLogSubject(t, dir); got != "bump to 0.3.0" {
t.Errorf("expected commit subject 'bump to 0.3.0', got %q", got)
}
}
func TestAutoCommitPluginMod_LeavesOtherStagedPathsAlone(t *testing.T) {
dir := t.TempDir()
runGit(t, dir, "init", "-q")
if err := os.WriteFile(filepath.Join(dir, "README.md"), []byte("hi"), 0o644); err != nil {
t.Fatal(err)
}
runGit(t, dir, "add", "README.md")
runGit(t, dir, "commit", "-qm", "init")
// Stage an unrelated change that publish should NOT sweep up into the
// plugin.mod auto-commit.
if err := os.WriteFile(filepath.Join(dir, "other.txt"), []byte("scratch"), 0o644); err != nil {
t.Fatal(err)
}
runGit(t, dir, "add", "other.txt")
if err := os.WriteFile(filepath.Join(dir, "plugin.mod"),
[]byte("[plugin]\nname = \"x\"\nscope = \"@s\"\nversion = \"0.3.0\"\n"), 0o644); err != nil {
t.Fatal(err)
}
t.Chdir(dir)
if err := autoCommitPluginMod("bump to 0.3.0"); err != nil {
t.Fatalf("autoCommitPluginMod: %v", err)
}
// The new commit should touch plugin.mod only.
filesCmd := exec.Command("git", "show", "--name-only", "--pretty=", "HEAD")
filesCmd.Dir = dir
filesOut, err := filesCmd.CombinedOutput()
if err != nil {
t.Fatalf("git show: %v\n%s", err, filesOut)
}
files := strings.Fields(strings.TrimSpace(string(filesOut)))
if len(files) != 1 || files[0] != "plugin.mod" {
t.Errorf("expected commit to touch only plugin.mod, got %v", files)
}
// other.txt should still be staged (waiting for the developer to deal with).
statusCmd := exec.Command("git", "status", "--porcelain", "other.txt")
statusCmd.Dir = dir
statusOut, _ := statusCmd.Output()
if !strings.HasPrefix(strings.TrimSpace(string(statusOut)), "A ") {
t.Errorf("expected other.txt to remain staged ('A '), got %q", string(statusOut))
}
}
func TestAutoCommitPluginMod_ErrorsWhenGitMissing(t *testing.T) {
dir := t.TempDir()
runGit(t, dir, "init", "-q")
if err := os.WriteFile(filepath.Join(dir, "README.md"), []byte("hi"), 0o644); err != nil {
t.Fatal(err)
}
runGit(t, dir, "add", "README.md")
runGit(t, dir, "commit", "-qm", "init")
if err := os.WriteFile(filepath.Join(dir, "plugin.mod"),
[]byte("[plugin]\nname = \"x\"\nscope = \"@s\"\nversion = \"0.1.0\"\n"), 0o644); err != nil {
t.Fatal(err)
}
t.Chdir(dir)
t.Setenv("PATH", "")
err := autoCommitPluginMod("Add plugin.mod")
if err == nil {
t.Fatal("expected error when git is missing from PATH, got nil")
}
if !strings.Contains(err.Error(), "git") {
t.Errorf("error %q should mention 'git'", err.Error())
}
}
func gitLogSubject(t *testing.T, dir string) string {
t.Helper()
cmd := exec.Command("git", "log", "-1", "--pretty=%s")
cmd.Dir = dir
out, err := cmd.CombinedOutput()
if err != nil {
t.Fatalf("git log: %v\n%s", err, out)
}
return strings.TrimSpace(string(out))
}
func gitHeadSHA(t *testing.T, dir string) string {
t.Helper()
cmd := exec.Command("git", "rev-parse", "HEAD")
cmd.Dir = dir
out, err := cmd.CombinedOutput()
if err != nil {
t.Fatalf("git rev-parse: %v\n%s", err, out)
}
return strings.TrimSpace(string(out))
}
func TestGitignoredTrackedWarning_FiresWhenTrackedFileMatchesGitignore(t *testing.T) {
dir := t.TempDir()
runGit(t, dir, "init", "-q")
if err := os.WriteFile(filepath.Join(dir, "secret.env"), []byte("token=abc"), 0o644); err != nil {
t.Fatal(err)
}
runGit(t, dir, "add", "secret.env")
runGit(t, dir, "commit", "-qm", "init")
if err := os.WriteFile(filepath.Join(dir, ".gitignore"), []byte("*.env\n"), 0o644); err != nil {
t.Fatal(err)
}
runGit(t, dir, "add", ".gitignore")
runGit(t, dir, "commit", "-qm", "ignore")
var buf bytes.Buffer
gitignoredTrackedWarning(dir, &buf)
out := buf.String()
if !strings.Contains(out, "secret.env") {
t.Errorf("warning should list secret.env, got: %q", out)
}
if !strings.Contains(out, "git rm --cached") {
t.Errorf("warning should suggest `git rm --cached`, got: %q", out)
}
if !strings.HasSuffix(out, "\n") {
t.Errorf("warning should end with a newline (Fprintln), got: %q", out)
}
}
func TestGitignoredTrackedWarning_NoopWhenNothingMatches(t *testing.T) {
dir := t.TempDir()
runGit(t, dir, "init", "-q")
if err := os.WriteFile(filepath.Join(dir, "README.md"), []byte("hi"), 0o644); err != nil {
t.Fatal(err)
}
runGit(t, dir, "add", "README.md")
runGit(t, dir, "commit", "-qm", "init")
var buf bytes.Buffer
gitignoredTrackedWarning(dir, &buf)
if buf.Len() != 0 {
t.Errorf("expected empty output for clean repo, got: %q", buf.String())
}
}
func TestUntrackedFilesWarning_FiresWithUntrackedFile(t *testing.T) {
dir := t.TempDir()
runGit(t, dir, "init", "-q")
if err := os.WriteFile(filepath.Join(dir, "README.md"), []byte("hi"), 0o644); err != nil {
t.Fatal(err)
}
runGit(t, dir, "add", "README.md")
runGit(t, dir, "commit", "-qm", "init")
if err := os.WriteFile(filepath.Join(dir, "notes.txt"), []byte("scratch"), 0o644); err != nil {
t.Fatal(err)
}
var buf bytes.Buffer
untrackedFilesWarning(dir, &buf)
out := buf.String()
if !strings.Contains(out, "notes.txt") {
t.Errorf("warning should list notes.txt, got: %q", out)
}
if !strings.Contains(out, "git add") {
t.Errorf("warning should suggest `git add`, got: %q", out)
}
}
func TestUntrackedFilesWarning_NoopWithNoUntracked(t *testing.T) {
dir := t.TempDir()
runGit(t, dir, "init", "-q")
if err := os.WriteFile(filepath.Join(dir, "README.md"), []byte("hi"), 0o644); err != nil {
t.Fatal(err)
}
runGit(t, dir, "add", "README.md")
runGit(t, dir, "commit", "-qm", "init")
var buf bytes.Buffer
untrackedFilesWarning(dir, &buf)
if buf.Len() != 0 {
t.Errorf("expected empty output, got: %q", buf.String())
}
}
func TestSubmoduleWarning_FiresWithGitmodules(t *testing.T) {
dir := t.TempDir()
runGit(t, dir, "init", "-q")
gitmodules := `[submodule "vendor/foo"]
path = vendor/foo
url = https://example.com/foo.git
`
if err := os.WriteFile(filepath.Join(dir, ".gitmodules"), []byte(gitmodules), 0o644); err != nil {
t.Fatal(err)
}
var buf bytes.Buffer
submoduleWarning(dir, &buf)
out := buf.String()
if !strings.Contains(out, "vendor/foo") {
t.Errorf("warning should mention vendor/foo, got: %q", out)
}
if !strings.Contains(out, "submodules") {
t.Errorf("warning should mention submodules, got: %q", out)
}
}
func TestSubmoduleWarning_NoopWithoutGitmodules(t *testing.T) {
dir := t.TempDir()
runGit(t, dir, "init", "-q")
var buf bytes.Buffer
submoduleWarning(dir, &buf)
if buf.Len() != 0 {
t.Errorf("expected empty output, got: %q", buf.String())
}
}
func TestEmitPublishWarnings_WarnsAboutGitignoreTracked(t *testing.T) {
dir := t.TempDir()
runGit(t, dir, "init", "-q")
if err := os.WriteFile(filepath.Join(dir, "secret.env"), []byte("token=abc"), 0o644); err != nil {
t.Fatal(err)
}
runGit(t, dir, "add", "secret.env")
runGit(t, dir, "commit", "-qm", "init")
if err := os.WriteFile(filepath.Join(dir, ".gitignore"), []byte("*.env\n"), 0o644); err != nil {
t.Fatal(err)
}
runGit(t, dir, "add", ".gitignore")
runGit(t, dir, "commit", "-qm", "ignore")
var buf bytes.Buffer
if err := emitPublishWarnings(dir, false, &buf); err != nil {
t.Fatalf("emitPublishWarnings: %v", err)
}
out := buf.String()
if !strings.Contains(out, "secret.env") {
t.Errorf("expected gitignored-tracked warning to mention secret.env, got: %q", out)
}
if !strings.Contains(out, "match .gitignore") {
t.Errorf("expected gitignored-tracked warning fragment, got: %q", out)
}
}
func TestEmitPublishWarnings_WarnsAboutSubmodules(t *testing.T) {
dir := t.TempDir()
runGit(t, dir, "init", "-q")
if err := os.WriteFile(filepath.Join(dir, "README.md"), []byte("hi"), 0o644); err != nil {
t.Fatal(err)
}
runGit(t, dir, "add", "README.md")
runGit(t, dir, "commit", "-qm", "init")
gitmodules := `[submodule "vendor/foo"]
path = vendor/foo
url = https://example.com/foo.git
`
if err := os.WriteFile(filepath.Join(dir, ".gitmodules"), []byte(gitmodules), 0o644); err != nil {
t.Fatal(err)
}
runGit(t, dir, "add", ".gitmodules")
runGit(t, dir, "commit", "-qm", "add submodule decl")
var buf bytes.Buffer
if err := emitPublishWarnings(dir, false, &buf); err != nil {
t.Fatalf("emitPublishWarnings: %v", err)
}
out := buf.String()
if !strings.Contains(out, "vendor/foo") {
t.Errorf("expected submodule warning to mention vendor/foo, got: %q", out)
}
if !strings.Contains(out, "submodules") {
t.Errorf("expected submodule warning fragment, got: %q", out)
}
}
func TestEmitPublishWarnings_WarnsAboutUntrackedWithAllowDirty(t *testing.T) {
dir := t.TempDir()
runGit(t, dir, "init", "-q")
if err := os.WriteFile(filepath.Join(dir, "README.md"), []byte("hi"), 0o644); err != nil {
t.Fatal(err)
}
runGit(t, dir, "add", "README.md")
runGit(t, dir, "commit", "-qm", "init")
if err := os.WriteFile(filepath.Join(dir, "scratch.txt"), []byte("notes"), 0o644); err != nil {
t.Fatal(err)
}
t.Run("allowDirty=true surfaces untracked warning", func(t *testing.T) {
var buf bytes.Buffer
if err := emitPublishWarnings(dir, true, &buf); err != nil {
t.Fatalf("emitPublishWarnings: %v", err)
}
out := buf.String()
if !strings.Contains(out, "scratch.txt") {
t.Errorf("expected untracked-files warning to mention scratch.txt, got: %q", out)
}
if !strings.Contains(out, "NOT be in the archive") {
t.Errorf("expected untracked-files warning fragment, got: %q", out)
}
})
t.Run("allowDirty=false aborts before untracked warning", func(t *testing.T) {
var buf bytes.Buffer
err := emitPublishWarnings(dir, false, &buf)
if err == nil {
t.Fatal("expected dirty-tree error, got nil")
}
if !strings.Contains(err.Error(), "working tree dirty") {
t.Errorf("expected dirty-tree error, got: %v", err)
}
if !strings.Contains(err.Error(), "--strict") {
t.Errorf("expected dirty-tree error to reference --strict, got: %v", err)
}
if strings.Contains(buf.String(), "untracked files will NOT be in the archive") {
t.Errorf("untracked-files warning should not fire on dirty-abort path, got: %q", buf.String())
}
})
}
func TestWriteMod_PrivateTrueSerializes(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "plugin.mod")
m := &core.ModFile{Plugin: core.ModPlugin{
Name: "myplugin",
Scope: "themes",
Version: "0.1.0",
Private: true,
}}
if err := writeMod(path, m); err != nil {
t.Fatalf("writeMod: %v", err)
}
got, err := os.ReadFile(path)
if err != nil {
t.Fatalf("read back: %v", err)
}
if !strings.Contains(string(got), "private = true") {
t.Errorf("expected `private = true` line in plugin.mod, got:\n%s", got)
}
}
func TestWriteMod_PrivateFalseOmitted(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "plugin.mod")
m := &core.ModFile{Plugin: core.ModPlugin{
Name: "publicthing",
Scope: "themes",
Version: "0.1.0",
}}
if err := writeMod(path, m); err != nil {
t.Fatalf("writeMod: %v", err)
}
got, err := os.ReadFile(path)
if err != nil {
t.Fatalf("read back: %v", err)
}
if strings.Contains(string(got), "private") {
t.Errorf("expected no `private` line, got:\n%s", got)
}
}
func TestParsePrivateCoord(t *testing.T) {
cases := []struct {
in string
want string
wantErr bool
}{
{in: "myplugin", want: "myplugin"},
{in: "@private/myplugin", want: "myplugin"},
{in: " myplugin ", want: "myplugin"},
{in: "@themes/myplugin", wantErr: true},
{in: "@private", wantErr: true},
}
for _, c := range cases {
got, err := parsePrivateCoord(c.in)
if c.wantErr {
if err == nil {
t.Errorf("parsePrivateCoord(%q) = %q, want error", c.in, got)
}
continue
}
if err != nil {
t.Errorf("parsePrivateCoord(%q) err: %v", c.in, err)
continue
}
if got != c.want {
t.Errorf("parsePrivateCoord(%q) = %q, want %q", c.in, got, c.want)
}
}
}
func TestMutateTags_AddRmSetClear(t *testing.T) {
dir := t.TempDir()
t.Chdir(dir)
must := func(err error) {
t.Helper()
if err != nil {
t.Fatal(err)
}
}
// Seed plugin.mod with no tags.
must(upsertPluginMod("themes", "darkpro", "Dark Pro", "Sleek dark theme", "theme", []string{}, nil, false))
// add
must(mutateTags("add", []string{"dark", "agency"}))
mod, err := readLocalMod()
must(err)
if len(mod.Plugin.Tags) != 2 || mod.Plugin.Tags[0] != "dark" || mod.Plugin.Tags[1] != "agency" {
t.Errorf("after add: %v", mod.Plugin.Tags)
}
// add (dedupe + normalise)
must(mutateTags("add", []string{"Agency", "Serif"}))
mod, _ = readLocalMod()
if len(mod.Plugin.Tags) != 3 {
t.Errorf("after dedupe add: %v", mod.Plugin.Tags)
}
// rm
must(mutateTags("rm", []string{"dark"}))
mod, _ = readLocalMod()
for _, tag := range mod.Plugin.Tags {
if tag == "dark" {
t.Errorf("after rm: dark still present: %v", mod.Plugin.Tags)
}
}
// set
must(mutateTags("set", []string{"editorial"}))
mod, _ = readLocalMod()
if len(mod.Plugin.Tags) != 1 || mod.Plugin.Tags[0] != "editorial" {
t.Errorf("after set: %v", mod.Plugin.Tags)
}
// clear
must(mutateTags("clear", nil))
mod, _ = readLocalMod()
if len(mod.Plugin.Tags) != 0 {
t.Errorf("after clear: %v", mod.Plugin.Tags)
}
}
func TestMutateTags_RejectsInvalidNoWrite(t *testing.T) {
dir := t.TempDir()
t.Chdir(dir)
if err := upsertPluginMod("themes", "x", "X", "", "theme", nil, []string{"dark"}, false); err != nil {
t.Fatal(err)
}
if err := mutateTags("add", []string{"BAD SPACE"}); err == nil {
t.Fatal("expected validation error")
}
mod, err := readLocalMod()
if err != nil {
t.Fatal(err)
}
if len(mod.Plugin.Tags) != 1 || mod.Plugin.Tags[0] != "dark" {
t.Errorf("tags mutated despite error: %v", mod.Plugin.Tags)
}
}
func runGit(t *testing.T, dir string, args ...string) {
t.Helper()
cmd := exec.Command("git", args...)
cmd.Dir = dir
cmd.Env = append(os.Environ(),
"GIT_AUTHOR_NAME=t",
"GIT_AUTHOR_EMAIL=t@t",
"GIT_COMMITTER_NAME=t",
"GIT_COMMITTER_EMAIL=t@t",
)
out, err := cmd.CombinedOutput()
if err != nil {
t.Fatalf("git %v: %v\n%s", args, err, out)
}
}

View File

@ -1,21 +0,0 @@
package cmd
import "github.com/spf13/cobra"
func NewRoot() *cobra.Command {
root := &cobra.Command{
Use: "ninja",
Short: "BlockNinja developer CLI",
Long: "ninja is the developer-facing CLI for BlockNinja. First subcommand group: plugin.",
}
root.PersistentFlags().String("host", "", "Orchestrator base URL (default: from credentials or https://my.blockninjacms.com)")
root.AddCommand(newVersionCmd())
root.AddCommand(newLoginCmd())
root.AddCommand(newLogoutCmd())
root.AddCommand(newWhoamiCmd())
root.AddCommand(newPluginCmd())
root.AddCommand(newThemeCmd())
root.AddCommand(newScopeCmd())
root.AddCommand(newAccountCmd())
return root
}

View File

@ -1,230 +0,0 @@
package cmd
import (
"bufio"
"context"
"fmt"
"os"
"strconv"
"strings"
"connectrpc.com/connect"
"github.com/spf13/cobra"
"git.dev.alexdunmow.com/block/core/cmd/ninja/internal/creds"
"git.dev.alexdunmow.com/block/core/cmd/ninja/internal/orchclient"
v1 "git.dev.alexdunmow.com/block/core/internal/api/orchestrator/v1"
)
func newScopeCmd() *cobra.Command {
c := &cobra.Command{Use: "scope", Short: "Manage plugin scopes"}
c.AddCommand(newScopeCreateCmd(), newScopeListCmd(), newScopeDefaultCmd())
return c
}
func newScopeCreateCmd() *cobra.Command {
cmd := &cobra.Command{
Use: "create [scope]",
Short: "Create a new scope (organisation namespace for plugins)",
Args: cobra.MaximumNArgs(1),
RunE: func(c *cobra.Command, args []string) error {
host, _ := c.Flags().GetString("host")
cr, err := creds.Load()
if err != nil {
return err
}
resolvedHost, hc, err := cr.Resolve(host)
if err != nil {
return err
}
cli := orchclient.New(resolvedHost, hc.Token)
ctx := context.Background()
scanner := bufio.NewScanner(os.Stdin)
var slug string
if len(args) > 0 {
slug, err = parseScope(args[0])
if err != nil {
return err
}
} else {
slug, err = promptScopeSlug(scanner)
if err != nil {
return err
}
}
fmt.Printf("Display name [%s]: ", scopeAPISlug(slug))
displayName := scopeAPISlug(slug)
if scanner.Scan() {
if v := strings.TrimSpace(scanner.Text()); v != "" {
displayName = v
}
}
_, err = cli.Scope.CreateScope(ctx, connect.NewRequest(&v1.CreateScopeRequest{
Slug: scopeAPISlug(slug),
DisplayName: displayName,
}))
if err != nil {
return err
}
fmt.Printf("Created scope %s\n", slug)
fmt.Printf("Set %s as your default scope? [Y/n]: ", slug)
if scanner.Scan() {
ans := strings.ToLower(strings.TrimSpace(scanner.Text()))
if ans == "" || ans == "y" || ans == "yes" {
hc.DefaultScope = slug
cr.Hosts[resolvedHost] = hc
if err := cr.Save(); err != nil {
fmt.Fprintf(os.Stderr, "warning: could not save default scope: %v\n", err)
} else {
fmt.Println("Default scope saved.")
}
}
}
return nil
},
}
return cmd
}
func promptScopeSlug(scanner *bufio.Scanner) (string, error) {
fmt.Println("A scope is an organisation namespace for your plugins (e.g. @acme).")
fmt.Println("It appears in plugin names like @acme/my-plugin.")
fmt.Println()
fmt.Print("Scope slug (lowercase letters, numbers, dashes): ")
if !scanner.Scan() {
return "", fmt.Errorf("cancelled")
}
return parseScope(scanner.Text())
}
func newScopeDefaultCmd() *cobra.Command {
cmd := &cobra.Command{
Use: "default",
Short: "Show or change the default scope",
RunE: func(c *cobra.Command, _ []string) error {
host, _ := c.Flags().GetString("host")
cr, err := creds.Load()
if err != nil {
return err
}
_, hc, err := cr.Resolve(host)
if err != nil {
return err
}
if hc.DefaultScope == "" {
fmt.Println("No default scope set. Run: ninja scope default set")
} else {
fmt.Println(hc.DefaultScope)
}
return nil
},
}
cmd.AddCommand(newScopeDefaultSetCmd())
return cmd
}
func newScopeDefaultSetCmd() *cobra.Command {
return &cobra.Command{
Use: "set",
Short: "Pick a default scope from your scopes",
RunE: func(c *cobra.Command, _ []string) error {
host, _ := c.Flags().GetString("host")
cr, err := creds.Load()
if err != nil {
return err
}
resolvedHost, hc, err := cr.Resolve(host)
if err != nil {
return err
}
cli := orchclient.New(resolvedHost, hc.Token)
ctx := context.Background()
scopes, err := cli.Scope.ListMyScopes(ctx, connect.NewRequest(&v1.ListMyScopesRequest{}))
if err != nil {
return err
}
if len(scopes.Msg.Scopes) == 0 {
fmt.Println("No scopes yet. Create one with: ninja scope create")
return nil
}
fmt.Println("Your scopes:")
for i, s := range scopes.Msg.Scopes {
marker := ""
if "@"+s.Slug == hc.DefaultScope {
marker = " (current)"
}
fmt.Printf(" %d. @%s — %s%s\n", i+1, s.Slug, s.DisplayName, marker)
}
fmt.Println()
scanner := bufio.NewScanner(os.Stdin)
fmt.Print("Select a scope: ")
if !scanner.Scan() {
return fmt.Errorf("cancelled")
}
input := strings.TrimSpace(scanner.Text())
if input == "" {
return fmt.Errorf("cancelled")
}
var scope string
if n, err := strconv.Atoi(input); err == nil && n >= 1 && n <= len(scopes.Msg.Scopes) {
scope = "@" + scopes.Msg.Scopes[n-1].Slug
} else {
return fmt.Errorf("invalid selection: %s", input)
}
hc.DefaultScope = scope
cr.Hosts[resolvedHost] = hc
if err := cr.Save(); err != nil {
return err
}
fmt.Printf("Default scope set to %s\n", scope)
return nil
},
}
}
func newScopeListCmd() *cobra.Command {
return &cobra.Command{
Use: "list",
Short: "List your scopes",
RunE: func(c *cobra.Command, _ []string) error {
host, _ := c.Flags().GetString("host")
cr, err := creds.Load()
if err != nil {
return err
}
resolvedHost, hc, err := cr.Resolve(host)
if err != nil {
return err
}
cli := orchclient.New(resolvedHost, hc.Token)
ctx := context.Background()
scopes, err := cli.Scope.ListMyScopes(ctx, connect.NewRequest(&v1.ListMyScopesRequest{}))
if err != nil {
return err
}
if len(scopes.Msg.Scopes) == 0 {
fmt.Println("No scopes yet. Create one with: ninja scope create")
return nil
}
for _, s := range scopes.Msg.Scopes {
marker := ""
if "@"+s.Slug == hc.DefaultScope {
marker = " (default)"
}
fmt.Printf("@%s — %s%s\n", s.Slug, s.DisplayName, marker)
}
return nil
},
}
}

View File

@ -1,108 +0,0 @@
package cmd
import (
"context"
"fmt"
"os"
"time"
"github.com/spf13/cobra"
core "git.dev.alexdunmow.com/block/core/plugin"
"git.dev.alexdunmow.com/block/core/cmd/ninja/internal/shot"
)
func newThemeCmd() *cobra.Command {
c := &cobra.Command{Use: "theme", Short: "Theme authoring helpers (preview screenshots)"}
c.AddCommand(newThemeScreenshotCmd())
return c
}
func newThemeScreenshotCmd() *cobra.Command {
var gallery, slug, out, mobileOut, themeName, waitSelector string
var mobile bool
var width, height int
cmd := &cobra.Command{
Use: "screenshot",
Short: "Render this theme's showcase page and write preview.png into the repo",
Long: `screenshot builds the gallery URL for this theme's showcase page
(rendered via the CMS render-only ?preview_template override), drives a headless
Chromium against it, and writes the captured PNG into the theme repo (preview.png,
git-tracked the source of truth a human may later replace).
The theme name defaults to plugin.mod's name in the current directory. Point
--gallery at the gallery CMS site that has this theme's .so loaded and the
showcase content seeded. --host selects the orchestrator (defaults to PROD); the
gallery URL is independent of --host but the flag is accepted for parity with
the rest of the CLI.`,
RunE: func(c *cobra.Command, _ []string) error {
if themeName == "" {
modBytes, err := os.ReadFile("plugin.mod")
if err != nil {
return fmt.Errorf("read plugin.mod (run from the theme repo, or pass --theme): %w", err)
}
mod, err := core.ParseModFull(modBytes)
if err != nil {
return err
}
if mod.Plugin.Kind != "theme" {
return fmt.Errorf("plugin.mod kind = %q, want theme", mod.Plugin.Kind)
}
themeName = mod.Plugin.Name
}
if gallery == "" {
return fmt.Errorf("--gallery is required (the gallery CMS site base, e.g. https://showcase.localdev.blockninjacms.com)")
}
ctx := context.Background()
desktopURL := shot.PreviewURL(gallery, slug, themeName)
fmt.Fprintf(os.Stderr, "capturing desktop: %s\n", desktopURL)
png, err := shot.Capture(ctx, shot.Options{
URL: desktopURL,
Width: width,
Height: height,
WaitSelector: waitSelector,
Timeout: 45 * time.Second,
})
if err != nil {
return err
}
if err := os.WriteFile(out, png, 0o644); err != nil {
return fmt.Errorf("write %s: %w", out, err)
}
fmt.Printf("wrote %s (%d bytes)\n", out, len(png))
if mobile {
mURL := shot.PreviewURL(gallery, slug, themeName)
fmt.Fprintf(os.Stderr, "capturing mobile: %s\n", mURL)
mpng, err := shot.Capture(ctx, shot.Options{
URL: mURL,
Width: 390,
Height: 844,
WaitSelector: waitSelector,
Timeout: 45 * time.Second,
})
if err != nil {
return err
}
if err := os.WriteFile(mobileOut, mpng, 0o644); err != nil {
return fmt.Errorf("write %s: %w", mobileOut, err)
}
fmt.Printf("wrote %s (%d bytes)\n", mobileOut, len(mpng))
}
return nil
},
}
cmd.Flags().StringVar(&gallery, "gallery", "", "Gallery CMS site base URL (has this theme loaded + showcase seeded)")
cmd.Flags().StringVar(&slug, "slug", "/", "Showcase page slug to capture")
cmd.Flags().StringVar(&out, "out", "preview.png", "Output path for the desktop screenshot (git-tracked in the theme repo)")
cmd.Flags().StringVar(&mobileOut, "mobile-out", "preview-mobile.png", "Output path for the mobile screenshot")
cmd.Flags().StringVar(&themeName, "theme", "", "Theme key to preview (default: plugin.mod name)")
cmd.Flags().StringVar(&waitSelector, "wait", "section", "CSS selector to wait for before capturing")
cmd.Flags().BoolVar(&mobile, "mobile", false, "Also capture preview-mobile.png at a phone viewport")
cmd.Flags().IntVar(&width, "width", 1440, "Desktop viewport width")
cmd.Flags().IntVar(&height, "height", 900, "Desktop viewport height")
return cmd
}

View File

@ -1,36 +0,0 @@
package cmd
import (
"strings"
"testing"
)
func TestThemeScreenshotCommandRegistered(t *testing.T) {
root := NewRoot()
theme, _, err := root.Find([]string{"theme", "screenshot"})
if err != nil {
t.Fatalf("find theme screenshot: %v", err)
}
if theme.Name() != "screenshot" {
t.Fatalf("resolved command = %q, want screenshot", theme.Name())
}
}
func TestThemeScreenshotHasGalleryAndMobileFlags(t *testing.T) {
root := NewRoot()
cmd, _, _ := root.Find([]string{"theme", "screenshot"})
for _, name := range []string{"gallery", "mobile", "out", "slug"} {
if cmd.Flags().Lookup(name) == nil {
t.Errorf("missing --%s flag", name)
}
}
}
func TestThemeScreenshotDefaultOutIsPreviewPng(t *testing.T) {
root := NewRoot()
cmd, _, _ := root.Find([]string{"theme", "screenshot"})
out, _ := cmd.Flags().GetString("out")
if !strings.HasSuffix(out, "preview.png") {
t.Fatalf("default --out = %q, want it to end with preview.png", out)
}
}

View File

@ -1,19 +0,0 @@
package cmd
import (
"fmt"
"github.com/spf13/cobra"
)
var Version = "dev"
func newVersionCmd() *cobra.Command {
return &cobra.Command{
Use: "version",
Short: "Print ninja version",
Run: func(_ *cobra.Command, _ []string) {
fmt.Println("ninja", Version)
},
}
}

View File

@ -1,56 +0,0 @@
package archive
import (
"bytes"
"fmt"
"io"
"os/exec"
"strings"
"github.com/klauspost/compress/zstd"
)
// BuildSourceArchive captures the working tree as `tar.zst` bytes.
//
// When the working tree is clean it archives HEAD. When it's dirty
// (modified or staged tracked files), it archives a temporary stash
// object so the dirty state is what ships — callers that want
// HEAD-only behaviour should reject dirty trees before calling.
// Untracked files are never included regardless of state.
func BuildSourceArchive(repoDir string) ([]byte, error) {
stashCmd := exec.Command("git", "stash", "create")
stashCmd.Dir = repoDir
var stashOut, stashErr bytes.Buffer
stashCmd.Stdout = &stashOut
stashCmd.Stderr = &stashErr
if err := stashCmd.Run(); err != nil {
return nil, fmt.Errorf("git stash create: %v: %s", err, stashErr.String())
}
treeish := "HEAD"
if sha := strings.TrimSpace(stashOut.String()); sha != "" {
treeish = sha
}
cmd := exec.Command("git", "archive", "--format=tar", treeish)
cmd.Dir = repoDir
var tarOut, stderr bytes.Buffer
cmd.Stdout = &tarOut
cmd.Stderr = &stderr
if err := cmd.Run(); err != nil {
return nil, fmt.Errorf("git archive: %v: %s", err, stderr.String())
}
var compressed bytes.Buffer
enc, err := zstd.NewWriter(&compressed, zstd.WithEncoderLevel(zstd.SpeedDefault))
if err != nil {
return nil, err
}
if _, err := io.Copy(enc, &tarOut); err != nil {
_ = enc.Close()
return nil, err
}
if err := enc.Close(); err != nil {
return nil, err
}
return compressed.Bytes(), nil
}

View File

@ -1,208 +0,0 @@
package archive
import (
"archive/tar"
"bytes"
"io"
"os"
"os/exec"
"path/filepath"
"strings"
"testing"
"github.com/klauspost/compress/zstd"
)
func TestBuildSourceArchive_RoundTrip(t *testing.T) {
dir := t.TempDir()
run := func(name string, args ...string) {
t.Helper()
cmd := exec.Command(name, args...)
cmd.Dir = dir
cmd.Env = append(os.Environ(),
"GIT_AUTHOR_NAME=t",
"GIT_AUTHOR_EMAIL=t@t",
"GIT_COMMITTER_NAME=t",
"GIT_COMMITTER_EMAIL=t@t",
)
out, err := cmd.CombinedOutput()
if err != nil {
t.Fatalf("%s %v: %v\n%s", name, args, err, out)
}
}
run("git", "init", "-q")
if err := os.WriteFile(filepath.Join(dir, "plugin.mod"),
[]byte("[plugin]\nname=\"x\"\nscope=\"@s\"\nversion=\"0.1.0\"\n"), 0o644); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(dir, "ignored.log"), []byte("nope"), 0o644); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(dir, ".gitignore"), []byte("ignored.log\n"), 0o644); err != nil {
t.Fatal(err)
}
run("git", "add", "plugin.mod", ".gitignore")
run("git", "commit", "-qm", "init")
zstdBytes, err := BuildSourceArchive(dir)
if err != nil {
t.Fatalf("BuildSourceArchive: %v", err)
}
if len(zstdBytes) == 0 {
t.Fatal("empty archive")
}
dec, err := zstd.NewReader(bytes.NewReader(zstdBytes))
if err != nil {
t.Fatal(err)
}
defer dec.Close()
tr := tar.NewReader(dec)
got := map[string]string{}
for {
hdr, err := tr.Next()
if err == io.EOF {
break
}
if err != nil {
t.Fatal(err)
}
buf, err := io.ReadAll(tr)
if err != nil {
t.Fatal(err)
}
got[hdr.Name] = string(buf)
}
if _, ok := got["plugin.mod"]; !ok {
t.Errorf("expected plugin.mod in archive, got %v", keys(got))
}
if _, ok := got["ignored.log"]; ok {
t.Errorf("ignored.log should not be in archive (gitignored + untracked)")
}
}
func keys(m map[string]string) []string {
out := make([]string, 0, len(m))
for k := range m {
out = append(out, k)
}
return out
}
func TestBuildSourceArchive_DirtyTreeShipsWorkingCopy(t *testing.T) {
dir := t.TempDir()
runGitArchive(t, dir, "init", "-q")
modPath := filepath.Join(dir, "plugin.mod")
if err := os.WriteFile(modPath,
[]byte("[plugin]\nname=\"x\"\nscope=\"@s\"\nversion=\"0.1.0\"\n"), 0o644); err != nil {
t.Fatal(err)
}
runGitArchive(t, dir, "add", "plugin.mod")
runGitArchive(t, dir, "commit", "-qm", "init")
dirtyContents := []byte("[plugin]\nname=\"x\"\nscope=\"@s\"\nversion=\"0.1.1\"\n")
if err := os.WriteFile(modPath, dirtyContents, 0o644); err != nil {
t.Fatal(err)
}
zstdBytes, err := BuildSourceArchive(dir)
if err != nil {
t.Fatalf("BuildSourceArchive: %v", err)
}
got := readArchive(t, zstdBytes)
contents, ok := got["plugin.mod"]
if !ok {
t.Fatalf("expected plugin.mod in archive, got %v", keys(got))
}
if !strings.Contains(contents, `version="0.1.1"`) {
t.Errorf("archived plugin.mod should have dirty version 0.1.1, got: %q", contents)
}
if strings.Contains(contents, `version="0.1.0"`) {
t.Errorf("archived plugin.mod should NOT have HEAD version 0.1.0, got: %q", contents)
}
// Working tree should be unchanged after stash-create.
postContents, err := os.ReadFile(modPath)
if err != nil {
t.Fatal(err)
}
if string(postContents) != string(dirtyContents) {
t.Errorf("working tree mutated after BuildSourceArchive\nwant: %q\ngot: %q",
string(dirtyContents), string(postContents))
}
}
func TestBuildSourceArchive_DirtyTreeOmitsUntracked(t *testing.T) {
dir := t.TempDir()
runGitArchive(t, dir, "init", "-q")
modPath := filepath.Join(dir, "plugin.mod")
if err := os.WriteFile(modPath,
[]byte("[plugin]\nname=\"x\"\nscope=\"@s\"\nversion=\"0.1.0\"\n"), 0o644); err != nil {
t.Fatal(err)
}
runGitArchive(t, dir, "add", "plugin.mod")
runGitArchive(t, dir, "commit", "-qm", "init")
// Dirty the tracked file.
if err := os.WriteFile(modPath,
[]byte("[plugin]\nname=\"x\"\nscope=\"@s\"\nversion=\"0.1.1\"\n"), 0o644); err != nil {
t.Fatal(err)
}
// Add an untracked file (no git add).
if err := os.WriteFile(filepath.Join(dir, "extra.txt"), []byte("not tracked"), 0o644); err != nil {
t.Fatal(err)
}
zstdBytes, err := BuildSourceArchive(dir)
if err != nil {
t.Fatalf("BuildSourceArchive: %v", err)
}
got := readArchive(t, zstdBytes)
if _, ok := got["extra.txt"]; ok {
t.Errorf("untracked extra.txt should not be in archive, got %v", keys(got))
}
}
func runGitArchive(t *testing.T, dir string, args ...string) {
t.Helper()
cmd := exec.Command("git", args...)
cmd.Dir = dir
cmd.Env = append(os.Environ(),
"GIT_AUTHOR_NAME=t",
"GIT_AUTHOR_EMAIL=t@t",
"GIT_COMMITTER_NAME=t",
"GIT_COMMITTER_EMAIL=t@t",
)
out, err := cmd.CombinedOutput()
if err != nil {
t.Fatalf("git %v: %v\n%s", args, err, out)
}
}
func readArchive(t *testing.T, zstdBytes []byte) map[string]string {
t.Helper()
dec, err := zstd.NewReader(bytes.NewReader(zstdBytes))
if err != nil {
t.Fatal(err)
}
defer dec.Close()
tr := tar.NewReader(dec)
got := map[string]string{}
for {
hdr, err := tr.Next()
if err == io.EOF {
break
}
if err != nil {
t.Fatal(err)
}
buf, err := io.ReadAll(tr)
if err != nil {
t.Fatal(err)
}
got[hdr.Name] = string(buf)
}
return got
}

View File

@ -1,261 +0,0 @@
package bnp
import (
"bytes"
"context"
"fmt"
"os"
"os/exec"
"path/filepath"
"regexp"
"sort"
"strconv"
abiv1 "git.dev.alexdunmow.com/block/core/abi/v1"
core "git.dev.alexdunmow.com/block/core/plugin"
"google.golang.org/protobuf/proto"
)
// Required top-level artifact members (mirrors the CMS reader).
const (
fileWasm = "plugin.wasm"
fileMod = "plugin.mod"
fileManifest = "manifest.pb"
)
// minGoMajor/minGoMinor is the lowest Go toolchain that can emit reactor-mode
// (`-buildmode=c-shared`) wasip1 modules the guest shim relies on.
const (
minGoMajor = 1
minGoMinor = 24
)
// BuildOptions configures Build.
type BuildOptions struct {
// Dir is the plugin repo root (holds plugin.mod and the main Go package).
Dir string
// Output is the destination .bnp path. Empty → "<name>-<version>.bnp" in
// the current working directory.
Output string
}
// BuildResult summarizes a produced artifact for the CLI summary table.
type BuildResult struct {
OutputPath string
Name string
Version string
// Codeless marks a declarative artifact (no plugin.wasm, WO-WZ-020).
Codeless bool
WasmBytes int64
ManifestBytes int64
ArtifactBytes int64 // on-disk .bnp size
UncompBytes int64 // sum of packed file sizes
BlockCount int
TemplateCount int
AdminPages int
JobTypes int
Hooks []string
DataDir bool
IncludedDirs []string
}
// Build compiles the plugin in opts.Dir to wasm, extracts its manifest, and
// packs a .bnp. No Docker/podman: the whole pipeline is the local Go
// toolchain + wazero + tar.zst.
func Build(ctx context.Context, opts BuildOptions) (*BuildResult, error) {
dir := opts.Dir
if dir == "" {
dir = "."
}
dir, err := filepath.Abs(dir)
if err != nil {
return nil, fmt.Errorf("resolve dir: %w", err)
}
modPath := filepath.Join(dir, fileMod)
modBytes, err := os.ReadFile(modPath)
if err != nil {
return nil, fmt.Errorf("read plugin.mod (is %s a plugin repo?): %w", dir, err)
}
mod, err := core.ParseModFull(modBytes)
if err != nil {
return nil, fmt.Errorf("parse plugin.mod: %w", err)
}
if mod.Plugin.Name == "" || mod.Plugin.Version == "" {
return nil, fmt.Errorf("plugin.mod must set both name and version")
}
if err := checkGoVersion(ctx); err != nil {
return nil, err
}
// 1. Compile to reactor-mode wasip1 c-shared.
tmp, err := os.MkdirTemp("", "ninja-build-*")
if err != nil {
return nil, fmt.Errorf("temp dir: %w", err)
}
defer func() { _ = os.RemoveAll(tmp) }()
wasmPath := filepath.Join(tmp, fileWasm)
if err := buildWasm(ctx, dir, wasmPath); err != nil {
return nil, err
}
// 2. Extract the manifest by driving DESCRIBE.
wasm, err := readWasm(wasmPath)
if err != nil {
return nil, err
}
manifest, err := ExtractManifest(ctx, wasm)
if err != nil {
return nil, fmt.Errorf("extract manifest: %w", err)
}
// 3. Validate against plugin.mod before packing an incoherent artifact.
if manifest.GetName() != mod.Plugin.Name {
return nil, fmt.Errorf("manifest name %q != plugin.mod name %q", manifest.GetName(), mod.Plugin.Name)
}
if v := manifest.GetAbiVersion(); v != hostAbiVersion {
return nil, fmt.Errorf("manifest abi_version %d unsupported (packer speaks %d)", v, hostAbiVersion)
}
// 4. Stamp the data_dir grant from plugin.mod. DESCRIBE cannot see
// plugin.mod (it lives outside guest code), so the packer is where the
// grant crosses from mod → manifest; the loader then reads one source.
manifest.DataDir = mod.Plugin.DataDir
manifestBytes, err := proto.Marshal(manifest)
if err != nil {
return nil, fmt.Errorf("marshal manifest.pb: %w", err)
}
// 5. Assemble artifact entries.
entries := []packEntry{
{ArtifactPath: fileWasm, Source: wasmPath},
{ArtifactPath: fileMod, Source: modPath},
{ArtifactPath: fileManifest, Data: manifestBytes},
}
var includedDirs []string
// dir-name → source subpath. web ships the Module Federation build output
// (web/dist) flattened under web/ so the reader's dirExists("web") fires.
optional := []struct{ artifact, src string }{
{dirBlocks, dirBlocks},
{dirTemplates, dirTemplates},
{dirSeed, dirSeed},
{"migrations", "migrations"},
{"schemas", "schemas"},
{"assets", "assets"},
{"web", filepath.Join("web", "dist")},
}
// Declarative dirs ride along on wasm artifacts too (a reduced plugin may
// mix definition-backed blocks with logic); validate them identically.
if _, err := validateBlocksDir(dir); err != nil {
return nil, err
}
if err := validateSeedDir(dir); err != nil {
return nil, err
}
for _, o := range optional {
dirEntries, has, dErr := collectDir(filepath.Join(dir, o.src), o.artifact)
if dErr != nil {
return nil, dErr
}
if has {
entries = append(entries, dirEntries...)
includedDirs = append(includedDirs, o.artifact)
}
}
// 6. Pack.
outPath := opts.Output
if outPath == "" {
outPath = fmt.Sprintf("%s-%s.bnp", mod.Plugin.Name, mod.Plugin.Version)
}
uncomp, err := packArtifact(outPath, entries)
if err != nil {
return nil, err
}
artInfo, err := os.Stat(outPath)
if err != nil {
return nil, fmt.Errorf("stat artifact: %w", err)
}
wasmInfo, _ := os.Stat(wasmPath)
return &BuildResult{
OutputPath: outPath,
Name: manifest.GetName(),
Version: manifest.GetVersion(),
WasmBytes: wasmInfo.Size(),
ManifestBytes: int64(len(manifestBytes)),
ArtifactBytes: artInfo.Size(),
UncompBytes: uncomp,
BlockCount: len(manifest.GetBlocks()),
TemplateCount: len(manifest.GetTemplateKeys()),
AdminPages: len(manifest.GetAdminPages()),
JobTypes: len(manifest.GetJobTypes()),
Hooks: hooksPresent(manifest),
DataDir: manifest.GetDataDir(),
IncludedDirs: includedDirs,
}, nil
}
// buildWasm runs the reactor-mode wasip1 c-shared build in dir.
func buildWasm(ctx context.Context, dir, outPath string) error {
cmd := exec.CommandContext(ctx, "go", "build", "-buildmode=c-shared", "-o", outPath, ".")
cmd.Dir = dir
cmd.Env = append(os.Environ(), "GOOS=wasip1", "GOARCH=wasm")
var stderr bytes.Buffer
cmd.Stderr = &stderr
if err := cmd.Run(); err != nil {
return fmt.Errorf("go build (GOOS=wasip1 GOARCH=wasm -buildmode=c-shared) failed: %w\n%s", err, stderr.String())
}
return nil
}
var goVersionRe = regexp.MustCompile(`go(\d+)\.(\d+)(?:\.\d+)?`)
// checkGoVersion enforces the minimum toolchain (Go 1.24) with a clear error.
func checkGoVersion(ctx context.Context) error {
out, err := exec.CommandContext(ctx, "go", "version").Output()
if err != nil {
return fmt.Errorf("`go version` failed (is the Go toolchain on PATH?): %w", err)
}
m := goVersionRe.FindStringSubmatch(string(out))
if m == nil {
return fmt.Errorf("could not parse Go version from %q", string(out))
}
major, _ := strconv.Atoi(m[1])
minor, _ := strconv.Atoi(m[2])
if major < minGoMajor || (major == minGoMajor && minor < minGoMinor) {
return fmt.Errorf(
"building reactor-mode wasip1 plugins requires Go %d.%d+; found %d.%d — upgrade your toolchain",
minGoMajor, minGoMinor, major, minor)
}
return nil
}
// hooksPresent returns the sorted set of runtime hooks the manifest declares,
// for the summary table.
func hooksPresent(m *abiv1.PluginManifest) []string {
var hooks []string
if m.GetHasLoadHook() {
hooks = append(hooks, "load")
}
if m.GetHasUnloadHook() {
hooks = append(hooks, "unload")
}
if m.GetHasHttpHandler() {
hooks = append(hooks, "http")
}
if m.GetHasMediaHooks() {
hooks = append(hooks, "media")
}
if len(m.GetJobTypes()) > 0 {
hooks = append(hooks, "job")
}
if len(m.GetRagContentFetcherTypes()) > 0 {
hooks = append(hooks, "rag")
}
sort.Strings(hooks)
return hooks
}

View File

@ -1,429 +0,0 @@
package bnp
import (
"context"
"encoding/json"
"fmt"
"os"
"path/filepath"
"strings"
abiv1 "git.dev.alexdunmow.com/block/core/abi/v1"
core "git.dev.alexdunmow.com/block/core/plugin"
"google.golang.org/protobuf/proto"
"gopkg.in/yaml.v3"
)
// Codeless artifacts (WO-WZ-020): a .bnp with NO plugin.wasm — pure
// declaration the host runs. Classification is by repo shape: a plugin repo
// with no Go source builds codeless; a repo with Go builds wasm as always
// (a converted repo DELETES its Go — that's the point). Both artifact kinds
// may carry blocks/, templates/, and seed/; codeless just has nothing else.
// Declarative artifact dirs shared by both build paths.
const (
dirBlocks = "blocks"
dirTemplates = "templates"
dirSeed = "seed"
)
// manifestYAMLName is the optional root-level declarative manifest source for
// codeless plugins (the counterpart of what DESCRIBE captures from Go code).
const manifestYAMLName = "manifest.yaml"
// manifestYAML mirrors the declarative PluginManifest fields a codeless
// plugin can set. File-valued keys are paths relative to the repo root.
type manifestYAML struct {
ThemePresets string `yaml:"theme_presets"` // JSON file
BundledFonts string `yaml:"bundled_fonts"` // JSON file
SettingsSchema string `yaml:"settings_schema"` // JSON file
MasterPages string `yaml:"master_pages"` // JSON file ([]masterPageJSON)
RequiredIconPacks []string `yaml:"required_icon_packs"`
CSS *struct {
NpmPackages map[string]string `yaml:"npm_packages"`
CSSDirectives []string `yaml:"css_directives"`
InputCSSAppend string `yaml:"input_css_append"`
} `yaml:"css"`
Dependencies []struct {
Plugin string `yaml:"plugin"`
MinVersion string `yaml:"min_version"`
Required bool `yaml:"required"`
} `yaml:"dependencies"`
}
// masterPageJSON mirrors abiv1.MasterPageDefinition for the declarative file.
type masterPageJSON struct {
Key string `json:"key"`
Title string `json:"title"`
PageTemplates []string `json:"page_templates"`
Blocks []struct {
BlockKey string `json:"block_key"`
Title string `json:"title"`
Content map[string]any `json:"content"`
HTMLContent *string `json:"html_content"`
Slot string `json:"slot"`
SortOrder int32 `json:"sort_order"`
} `json:"blocks"`
}
// blocksYAML is the structural mirror of the CMS blocks.yaml manifest
// (cms blocks.LoadManifest) — a deliberate light duplication, same as the
// Verify↔reader pairing: the packer validates shape and file presence; full
// semantic validation (schema/template/provider checks) runs at install.
type blocksYAML struct {
Blocks []struct {
Key string `yaml:"key"`
Title string `yaml:"title"`
Description string `yaml:"description"`
Category string `yaml:"category"`
Schema string `yaml:"schema"`
Template string `yaml:"template"`
SampleData string `yaml:"sample_data"`
Providers []string `yaml:"providers"`
RequiredTags []string `yaml:"required_tags"`
Aliases []string `yaml:"aliases"`
} `yaml:"blocks"`
}
// seedJSON is the declarative seed schema (seed/seed.json). Applied by the
// host at load via the WO-WZ-019 provisioner (idempotent).
type seedJSON struct {
Settings *struct {
Merge map[string]any `json:"merge"`
Override map[string]any `json:"override"`
Ensure map[string]any `json:"ensure"`
} `json:"settings"`
Media []struct {
ID string `json:"id"` // UUID, required (deterministic media key)
File string `json:"file"`
Alt string `json:"alt"`
Folder string `json:"folder"`
} `json:"media"`
Pages []struct {
Slug string `json:"slug"`
ParentSlug string `json:"parent_slug"`
Title string `json:"title"`
TemplateKey string `json:"template_key"`
ReconcileBlocks bool `json:"reconcile_blocks"`
ReconcileTemplate bool `json:"reconcile_template"`
Blocks []struct {
BlockKey string `json:"block_key"`
Title string `json:"title"`
Content map[string]any `json:"content"`
HTMLContent *string `json:"html_content"`
Slot string `json:"slot"`
SortOrder int32 `json:"sort_order"`
} `json:"blocks"`
} `json:"pages"`
MenuItems []struct {
Menu string `json:"menu"`
Label string `json:"label"`
URL string `json:"url"`
PageSlug string `json:"page_slug"`
SortOrder int32 `json:"sort_order"`
} `json:"menu_items"`
}
// IsCodelessRepo reports whether dir is a declarative plugin repo: it has a
// plugin.mod but no Go source at the root (the wasm main package's home).
func IsCodelessRepo(dir string) (bool, error) {
if _, err := os.Stat(filepath.Join(dir, fileMod)); err != nil {
return false, fmt.Errorf("read plugin.mod (is %s a plugin repo?): %w", dir, err)
}
entries, err := os.ReadDir(dir)
if err != nil {
return false, err
}
for _, e := range entries {
if !e.IsDir() && strings.HasSuffix(e.Name(), ".go") {
return false, nil
}
}
return true, nil
}
// BuildCodeless synthesizes manifest.pb from plugin.mod + the declarative
// files and packs a codeless .bnp (no plugin.wasm, no DESCRIBE probe).
func BuildCodeless(_ context.Context, opts BuildOptions) (*BuildResult, error) {
dir := opts.Dir
if dir == "" {
dir = "."
}
dir, err := filepath.Abs(dir)
if err != nil {
return nil, fmt.Errorf("resolve dir: %w", err)
}
modPath := filepath.Join(dir, fileMod)
modBytes, err := os.ReadFile(modPath)
if err != nil {
return nil, fmt.Errorf("read plugin.mod: %w", err)
}
mod, err := core.ParseModFull(modBytes)
if err != nil {
return nil, fmt.Errorf("parse plugin.mod: %w", err)
}
if mod.Plugin.Name == "" || mod.Plugin.Version == "" {
return nil, fmt.Errorf("plugin.mod must set both name and version")
}
if mod.Plugin.DataDir {
return nil, fmt.Errorf("codeless plugin cannot request data_dir — there is no code to use it")
}
manifest := &abiv1.PluginManifest{
AbiVersion: hostAbiVersion,
Name: mod.Plugin.Name,
Version: mod.Plugin.Version,
Codeless: true,
}
blockCount, err := validateBlocksDir(dir)
if err != nil {
return nil, err
}
if err := validateSeedDir(dir); err != nil {
return nil, err
}
if err := applyManifestYAML(dir, manifest); err != nil {
return nil, err
}
manifestBytes, err := proto.Marshal(manifest)
if err != nil {
return nil, fmt.Errorf("marshal manifest.pb: %w", err)
}
entries := []packEntry{
{ArtifactPath: fileMod, Source: modPath},
{ArtifactPath: fileManifest, Data: manifestBytes},
}
var includedDirs []string
optional := []struct{ artifact, src string }{
{dirBlocks, dirBlocks},
{dirTemplates, dirTemplates},
{dirSeed, dirSeed},
{"migrations", "migrations"},
{"schemas", "schemas"},
{"assets", "assets"},
}
for _, o := range optional {
dirEntries, has, dErr := collectDir(filepath.Join(dir, o.src), o.artifact)
if dErr != nil {
return nil, dErr
}
if has {
entries = append(entries, dirEntries...)
includedDirs = append(includedDirs, o.artifact)
}
}
outPath := opts.Output
if outPath == "" {
outPath = fmt.Sprintf("%s-%s.bnp", mod.Plugin.Name, mod.Plugin.Version)
}
uncomp, err := packArtifact(outPath, entries)
if err != nil {
return nil, err
}
artInfo, err := os.Stat(outPath)
if err != nil {
return nil, fmt.Errorf("stat artifact: %w", err)
}
return &BuildResult{
OutputPath: outPath,
Name: manifest.GetName(),
Version: manifest.GetVersion(),
Codeless: true,
ManifestBytes: int64(len(manifestBytes)),
ArtifactBytes: artInfo.Size(),
UncompBytes: uncomp,
BlockCount: blockCount,
IncludedDirs: includedDirs,
}, nil
}
// validateBlocksDir structurally validates blocks/blocks.yaml when present:
// every entry has key/title/schema/template, referenced files exist within
// blocks/, and schemas are valid JSON. Returns the block count (0 when the
// dir is absent).
func validateBlocksDir(dir string) (int, error) {
root := filepath.Join(dir, dirBlocks)
manifestPath := filepath.Join(root, "blocks.yaml")
if _, err := os.Stat(root); os.IsNotExist(err) {
return 0, nil
}
raw, err := os.ReadFile(manifestPath)
if err != nil {
return 0, fmt.Errorf("blocks/ present but blocks.yaml unreadable: %w", err)
}
var bf blocksYAML
if err := yaml.Unmarshal(raw, &bf); err != nil {
return 0, fmt.Errorf("parse blocks/blocks.yaml: %w", err)
}
seen := map[string]bool{}
for i, b := range bf.Blocks {
if b.Key == "" {
return 0, fmt.Errorf("blocks.yaml: entry %d has no key", i)
}
if seen[b.Key] {
return 0, fmt.Errorf("blocks.yaml: duplicate key %q", b.Key)
}
seen[b.Key] = true
if b.Title == "" || b.Schema == "" || b.Template == "" {
return 0, fmt.Errorf("blocks.yaml: %q must declare title, schema, and template", b.Key)
}
for _, f := range []string{b.Schema, b.Template, b.SampleData} {
if f == "" {
continue
}
if err := fileWithin(root, f); err != nil {
return 0, fmt.Errorf("blocks.yaml: %q: %w", b.Key, err)
}
}
schemaRaw, err := os.ReadFile(filepath.Join(root, b.Schema))
if err != nil {
return 0, fmt.Errorf("blocks.yaml: %q schema: %w", b.Key, err)
}
if !json.Valid(schemaRaw) {
return 0, fmt.Errorf("blocks.yaml: %q schema %s is not valid JSON", b.Key, b.Schema)
}
}
return len(bf.Blocks), nil
}
// validateSeedDir validates seed/seed.json when the dir is present: it
// parses, media entries carry an id + an existing file, pages carry slugs.
func validateSeedDir(dir string) error {
root := filepath.Join(dir, dirSeed)
if _, err := os.Stat(root); os.IsNotExist(err) {
return nil
}
raw, err := os.ReadFile(filepath.Join(root, "seed.json"))
if err != nil {
return fmt.Errorf("seed/ present but seed.json unreadable: %w", err)
}
var sf seedJSON
if err := json.Unmarshal(raw, &sf); err != nil {
return fmt.Errorf("parse seed/seed.json: %w", err)
}
for i, m := range sf.Media {
if m.ID == "" || m.File == "" {
return fmt.Errorf("seed.json: media entry %d needs both id and file", i)
}
if err := fileWithin(root, m.File); err != nil {
return fmt.Errorf("seed.json: media %q: %w", m.ID, err)
}
}
for i, p := range sf.Pages {
if p.Slug == "" || p.Title == "" {
return fmt.Errorf("seed.json: page entry %d needs slug and title", i)
}
}
for i, mi := range sf.MenuItems {
if mi.Menu == "" || mi.Label == "" {
return fmt.Errorf("seed.json: menu item %d needs menu and label", i)
}
}
return nil
}
// applyManifestYAML folds the optional root manifest.yaml into the manifest.
func applyManifestYAML(dir string, m *abiv1.PluginManifest) error {
raw, err := os.ReadFile(filepath.Join(dir, manifestYAMLName))
if os.IsNotExist(err) {
return nil
}
if err != nil {
return fmt.Errorf("read %s: %w", manifestYAMLName, err)
}
var my manifestYAML
if err := yaml.Unmarshal(raw, &my); err != nil {
return fmt.Errorf("parse %s: %w", manifestYAMLName, err)
}
readJSONFile := func(rel, what string) ([]byte, error) {
if rel == "" {
return nil, nil
}
if err := fileWithin(dir, rel); err != nil {
return nil, fmt.Errorf("%s: %s: %w", manifestYAMLName, what, err)
}
b, err := os.ReadFile(filepath.Join(dir, rel))
if err != nil {
return nil, fmt.Errorf("%s: %s: %w", manifestYAMLName, what, err)
}
if !json.Valid(b) {
return nil, fmt.Errorf("%s: %s %s is not valid JSON", manifestYAMLName, what, rel)
}
return b, nil
}
if m.ThemePresets, err = readJSONFile(my.ThemePresets, "theme_presets"); err != nil {
return err
}
if m.BundledFonts, err = readJSONFile(my.BundledFonts, "bundled_fonts"); err != nil {
return err
}
if m.SettingsSchema, err = readJSONFile(my.SettingsSchema, "settings_schema"); err != nil {
return err
}
m.RequiredIconPacks = my.RequiredIconPacks
if my.CSS != nil {
m.CssManifest = &abiv1.CssManifest{
NpmPackages: my.CSS.NpmPackages,
CssDirectives: my.CSS.CSSDirectives,
InputCssAppend: my.CSS.InputCSSAppend,
}
}
for _, d := range my.Dependencies {
m.Dependencies = append(m.Dependencies, &abiv1.Dependency{
Plugin: d.Plugin, MinVersion: d.MinVersion, Required: d.Required,
})
}
if my.MasterPages != "" {
mpRaw, err := readJSONFile(my.MasterPages, "master_pages")
if err != nil {
return err
}
var mps []masterPageJSON
if err := json.Unmarshal(mpRaw, &mps); err != nil {
return fmt.Errorf("%s: master_pages %s: %w", manifestYAMLName, my.MasterPages, err)
}
for _, mp := range mps {
def := &abiv1.MasterPageDefinition{
Key: mp.Key, Title: mp.Title, PageTemplates: mp.PageTemplates,
}
for _, b := range mp.Blocks {
contentJSON, err := json.Marshal(b.Content)
if err != nil {
return fmt.Errorf("master page %q block %q: %w", mp.Key, b.BlockKey, err)
}
def.Blocks = append(def.Blocks, &abiv1.MasterPageBlock{
BlockKey: b.BlockKey, Title: b.Title, ContentJson: contentJSON,
HtmlContent: b.HTMLContent, Slot: b.Slot, SortOrder: b.SortOrder,
})
}
m.MasterPages = append(m.MasterPages, def)
}
}
return nil
}
// fileWithin ensures rel names an existing regular file inside root (no
// traversal escapes).
func fileWithin(root, rel string) error {
clean := filepath.Clean(rel)
if filepath.IsAbs(clean) || strings.HasPrefix(clean, "..") {
return fmt.Errorf("path %q escapes the plugin directory", rel)
}
fi, err := os.Stat(filepath.Join(root, clean))
if err != nil {
return fmt.Errorf("referenced file %q: %w", rel, err)
}
if !fi.Mode().IsRegular() {
return fmt.Errorf("referenced path %q is not a regular file", rel)
}
return nil
}

View File

@ -1,152 +0,0 @@
package bnp
import (
"context"
"os"
"path/filepath"
"testing"
abiv1 "git.dev.alexdunmow.com/block/core/abi/v1"
)
// writeFixture lays out a minimal codeless plugin repo.
func writeFixture(t *testing.T, dir string, files map[string]string) {
t.Helper()
for rel, content := range files {
path := filepath.Join(dir, rel)
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(path, []byte(content), 0o644); err != nil {
t.Fatal(err)
}
}
}
func codelessFixture(t *testing.T) string {
t.Helper()
dir := t.TempDir()
writeFixture(t, dir, map[string]string{
"plugin.mod": "[plugin]\nname = \"fixture-theme\"\nversion = \"0.1.0\"\nkind = \"theme\"\n",
"manifest.yaml": "theme_presets: presets.json\nrequired_icon_packs: [lucide]\n" +
"master_pages: master_pages.json\n",
"presets.json": `{"presets":[{"key":"default"}]}`,
"master_pages.json": `[{"key":"landing","title":"Landing","blocks":[{"block_key":"html","title":"Hero","content":{"x":1},"slot":"main","sort_order":1}]}]`,
"blocks/blocks.yaml": "blocks:\n - key: hero\n title: Hero\n category: content\n" +
" schema: hero.schema.json\n template: hero.ninjatpl\n providers: [site]\n",
"blocks/hero.schema.json": `{"type":"object"}`,
"blocks/hero.ninjatpl": `<h1>{{ title }}</h1>`,
"seed/seed.json": `{"settings":{"ensure":{"welcome":true}},` +
`"pages":[{"slug":"/","title":"Home","template_key":"landing"}],` +
`"menu_items":[{"menu":"main","label":"Home","page_slug":"/","sort_order":1}]}`,
"assets/logo.svg": `<svg/>`,
})
return dir
}
func TestCodelessBuildAndVerifyRoundTrip(t *testing.T) {
dir := codelessFixture(t)
isCodeless, err := IsCodelessRepo(dir)
if err != nil || !isCodeless {
t.Fatalf("IsCodelessRepo = %v, %v; want true", isCodeless, err)
}
out := filepath.Join(t.TempDir(), "fixture-theme-0.1.0.bnp")
res, err := BuildCodeless(context.Background(), BuildOptions{Dir: dir, Output: out})
if err != nil {
t.Fatalf("BuildCodeless: %v", err)
}
if !res.Codeless || res.BlockCount != 1 {
t.Errorf("result = %+v; want codeless with 1 block", res)
}
v, err := Verify(out)
if err != nil {
t.Fatalf("Verify: %v", err)
}
if !v.Codeless || !v.HasBlocks || !v.HasSeed || !v.HasAssets || v.Name != "fixture-theme" {
t.Errorf("verify = %+v", v)
}
m, err := ReadManifest(out)
if err != nil {
t.Fatal(err)
}
if !m.GetCodeless() || len(m.GetMasterPages()) != 1 || len(m.GetThemePresets()) == 0 {
t.Errorf("manifest = codeless:%v masters:%d presets:%dB",
m.GetCodeless(), len(m.GetMasterPages()), len(m.GetThemePresets()))
}
if len(m.GetRequiredIconPacks()) != 1 || m.GetRequiredIconPacks()[0] != "lucide" {
t.Errorf("icon packs = %v", m.GetRequiredIconPacks())
}
}
func TestIsCodelessRepoFalseWithGoSource(t *testing.T) {
dir := t.TempDir()
writeFixture(t, dir, map[string]string{
"plugin.mod": "[plugin]\nname = \"x\"\nversion = \"0.1.0\"\n",
"main.go": "package main\nfunc main() {}\n",
})
isCodeless, err := IsCodelessRepo(dir)
if err != nil || isCodeless {
t.Fatalf("IsCodelessRepo = %v, %v; want false", isCodeless, err)
}
}
func TestCodelessBuildRejectsBadDeclarations(t *testing.T) {
cases := []struct {
name string
files map[string]string
}{
{"bad blocks yaml", map[string]string{
"plugin.mod": "[plugin]\nname = \"x\"\nversion = \"0.1.0\"\n",
"blocks/blocks.yaml": "blocks:\n - key: hero\n", // missing title/schema/template
}},
{"missing template file", map[string]string{
"plugin.mod": "[plugin]\nname = \"x\"\nversion = \"0.1.0\"\n",
"blocks/blocks.yaml": "blocks:\n - key: hero\n title: Hero\n" +
" schema: hero.schema.json\n template: missing.ninjatpl\n",
"blocks/hero.schema.json": `{}`,
}},
{"seed media without id", map[string]string{
"plugin.mod": "[plugin]\nname = \"x\"\nversion = \"0.1.0\"\n",
"seed/seed.json": `{"media":[{"file":"a.jpg"}]}`,
"seed/a.jpg": "x",
}},
{"data_dir grant", map[string]string{
"plugin.mod": "[plugin]\nname = \"x\"\nversion = \"0.1.0\"\ndata_dir = true\n",
}},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
dir := t.TempDir()
writeFixture(t, dir, tc.files)
_, err := BuildCodeless(context.Background(), BuildOptions{
Dir: dir, Output: filepath.Join(t.TempDir(), "x.bnp"),
})
if err == nil {
t.Fatal("BuildCodeless succeeded; want error")
}
})
}
}
func TestCodelessHookViolation(t *testing.T) {
ok := &abiv1.PluginManifest{Codeless: true, Name: "x"}
if err := CodelessHookViolation(ok); err != nil {
t.Fatalf("clean manifest flagged: %v", err)
}
bad := []*abiv1.PluginManifest{
{Codeless: true, HasHttpHandler: true},
{Codeless: true, JobTypes: []string{"j"}},
{Codeless: true, DeclaredTags: []string{"t"}},
{Codeless: true, Blocks: []*abiv1.BlockMeta{{Key: "k"}}},
{Codeless: true, DataDir: true},
}
for i, m := range bad {
if err := CodelessHookViolation(m); err == nil {
t.Errorf("case %d not flagged", i)
}
}
}

View File

@ -1,171 +0,0 @@
// Package bnp implements `ninja plugin build` and `ninja plugin verify`: it
// compiles a plugin to reactor-mode wasm, extracts its static manifest by
// instantiating the module once and calling HOOK_DESCRIBE, packs the .bnp
// artifact (tar.zst), and re-runs the CMS reader's layout/name/abi/path/size
// checks standalone.
//
// The verify rules here are a deliberate, documented duplication of the CMS
// reader (cms backend/plugin/bnp/reader.go, WO-WZ-008): core cannot import the
// CMS module, so publishers get the same gate the loader applies. WO-WZ-010's
// integration suite keeps the two in lockstep.
package bnp
import (
"context"
"fmt"
"os"
abiv1 "git.dev.alexdunmow.com/block/core/abi/v1"
"github.com/tetratelabs/wazero"
"github.com/tetratelabs/wazero/api"
"github.com/tetratelabs/wazero/imports/wasi_snapshot_preview1"
"google.golang.org/protobuf/proto"
)
// hostAbiVersion is the ABI major version the packer speaks when driving
// DESCRIBE. It must equal core's guest AbiVersion / the reader's supported
// major (1) or the guest's symmetric version gate rejects the call.
const hostAbiVersion uint32 = 1
// ExtractManifest instantiates a reactor-mode plugin.wasm with wazero, drives
// one HOOK_DESCRIBE round trip, and returns the decoded PluginManifest.
//
// The host functions the guest imports (blockninja.host_call) are stubbed to
// FAIL: DESCRIBE is publish-time and must not need a live host. A plugin that
// reaches a host capability while its manifest is being extracted (e.g. a
// db.* call from Register) gets an actionable error naming the offending
// method rather than a mystery hang or nil deref.
func ExtractManifest(ctx context.Context, wasm []byte) (*abiv1.PluginManifest, error) {
r := wazero.NewRuntime(ctx)
defer func() { _ = r.Close(ctx) }()
if _, err := wasi_snapshot_preview1.Instantiate(ctx, r); err != nil {
return nil, fmt.Errorf("instantiate wasi: %w", err)
}
if err := installFailingHost(ctx, r); err != nil {
return nil, fmt.Errorf("install host stub: %w", err)
}
mod, err := r.InstantiateWithConfig(ctx, wasm,
wazero.NewModuleConfig().WithStartFunctions("_initialize").WithName("plugin"))
if err != nil {
return nil, fmt.Errorf("instantiate module (is it a reactor-mode wasip1 c-shared build?): %w", err)
}
for _, export := range []string{"bn_alloc", "bn_invoke", "bn_free"} {
if mod.ExportedFunction(export) == nil {
return nil, fmt.Errorf("module does not export %s — not a BlockNinja wasm guest (missing wasmguest.Serve boilerplate?)", export)
}
}
resp, err := invokeDescribe(ctx, mod)
if err != nil {
return nil, err
}
if e := resp.GetError(); e != nil {
return nil, fmt.Errorf("DESCRIBE failed (%s): %s", e.GetCode(), e.GetMessage())
}
dr := &abiv1.DescribeResponse{}
if err := proto.Unmarshal(resp.GetPayload(), dr); err != nil {
return nil, fmt.Errorf("decode DescribeResponse: %w", err)
}
m := dr.GetManifest()
if m == nil {
return nil, fmt.Errorf("DESCRIBE returned an empty manifest")
}
return m, nil
}
// invokeDescribe drives one bn_alloc / bn_invoke round trip for HOOK_DESCRIBE
// through guest linear memory, mirroring the calling convention in
// core/docs/wasm-abi.md.
func invokeDescribe(ctx context.Context, mod api.Module) (*abiv1.InvokeResponse, error) {
payload, err := proto.Marshal(&abiv1.DescribeRequest{HostAbiVersion: hostAbiVersion})
if err != nil {
return nil, fmt.Errorf("marshal DescribeRequest: %w", err)
}
req, err := proto.Marshal(&abiv1.InvokeRequest{Hook: abiv1.Hook_HOOK_DESCRIBE, Payload: payload})
if err != nil {
return nil, fmt.Errorf("marshal InvokeRequest: %w", err)
}
res, err := mod.ExportedFunction("bn_alloc").Call(ctx, uint64(len(req)))
if err != nil {
return nil, fmt.Errorf("bn_alloc: %w", err)
}
ptr := uint32(res[0])
if ptr == 0 {
return nil, fmt.Errorf("bn_alloc returned 0")
}
if !mod.Memory().Write(ptr, req) {
return nil, fmt.Errorf("write DESCRIBE request out of range")
}
res, err = mod.ExportedFunction("bn_invoke").Call(ctx,
uint64(abiv1.Hook_HOOK_DESCRIBE), uint64(ptr), uint64(len(req)))
if err != nil {
return nil, fmt.Errorf("bn_invoke(DESCRIBE): %w", err)
}
packed := res[0]
if packed == 0 {
return nil, fmt.Errorf("bn_invoke returned packed 0 (guest could not produce a response envelope)")
}
respBytes, ok := mod.Memory().Read(uint32(packed>>32), uint32(packed))
if !ok {
return nil, fmt.Errorf("read DESCRIBE response out of range")
}
resp := &abiv1.InvokeResponse{}
if err := proto.Unmarshal(respBytes, resp); err != nil {
return nil, fmt.Errorf("decode InvokeResponse: %w", err)
}
_, _ = mod.ExportedFunction("bn_free").Call(ctx, uint64(ptr))
return resp, nil
}
// installFailingHost exports blockninja.host_call so the guest module can be
// instantiated, but answers every capability call with a PERMISSION_DENIED
// AbiError naming the method — DESCRIBE must not depend on the host.
func installFailingHost(ctx context.Context, r wazero.Runtime) error {
_, err := r.NewHostModuleBuilder("blockninja").
NewFunctionBuilder().
WithFunc(func(ctx context.Context, mod api.Module, ptr, size uint32) uint64 {
method := "<unknown>"
if data, ok := mod.Memory().Read(ptr, size); ok {
hcr := &abiv1.HostCallRequest{}
if proto.Unmarshal(data, hcr) == nil && hcr.GetMethod() != "" {
method = hcr.GetMethod()
}
}
out, err := proto.Marshal(&abiv1.HostCallResponse{
Error: &abiv1.AbiError{
Code: abiv1.AbiErrorCode_ABI_ERROR_CODE_PERMISSION_DENIED,
Message: fmt.Sprintf(
"capability %q is unavailable during manifest extraction (DESCRIBE): a plugin must not call host capabilities at register/describe time",
method),
},
})
if err != nil {
return 0
}
res, err := mod.ExportedFunction("bn_alloc").Call(ctx, uint64(len(out)))
if err != nil || len(res) == 0 || res[0] == 0 {
return 0
}
respPtr := uint32(res[0])
if !mod.Memory().Write(respPtr, out) {
return 0
}
return uint64(respPtr)<<32 | uint64(uint32(len(out)))
}).
Export("host_call").
Instantiate(ctx)
return err
}
// readWasm is a tiny helper so callers can pass a path.
func readWasm(path string) ([]byte, error) {
b, err := os.ReadFile(path)
if err != nil {
return nil, fmt.Errorf("read wasm %s: %w", path, err)
}
return b, nil
}

View File

@ -1,138 +0,0 @@
package bnp
import (
"archive/tar"
"fmt"
"os"
"path/filepath"
"sort"
"strings"
"github.com/klauspost/compress/zstd"
)
// packEntry is one file destined for the artifact: its path inside the .bnp
// (always forward-slash relative) and either literal Data or a Source file to
// stream from.
type packEntry struct {
ArtifactPath string
Data []byte
Source string
}
// packArtifact writes entries as a zstd-compressed tar to outPath. Entries are
// sorted by artifact path for a deterministic archive. Directory entries are
// synthesized as needed so the reader's dirExists checks fire for
// migrations/, schemas/, assets/, web/.
func packArtifact(outPath string, entries []packEntry) (int64, error) {
sort.Slice(entries, func(i, j int) bool { return entries[i].ArtifactPath < entries[j].ArtifactPath })
out, err := os.Create(outPath)
if err != nil {
return 0, fmt.Errorf("create %s: %w", outPath, err)
}
defer func() { _ = out.Close() }()
enc, err := zstd.NewWriter(out, zstd.WithEncoderLevel(zstd.SpeedDefault))
if err != nil {
return 0, err
}
tw := tar.NewWriter(enc)
seenDir := map[string]bool{}
writeDir := func(dir string) error {
if dir == "" || dir == "." || seenDir[dir] {
return nil
}
seenDir[dir] = true
return tw.WriteHeader(&tar.Header{
Name: dir + "/",
Typeflag: tar.TypeDir,
Mode: 0o755,
})
}
var total int64
for _, e := range entries {
name := filepath.ToSlash(e.ArtifactPath)
// Ensure parent directories exist as explicit entries.
for _, d := range parentDirs(name) {
if err := writeDir(d); err != nil {
return 0, fmt.Errorf("write dir header %q: %w", d, err)
}
}
var data []byte
if e.Source != "" {
data, err = os.ReadFile(e.Source)
if err != nil {
return 0, fmt.Errorf("read %s: %w", e.Source, err)
}
} else {
data = e.Data
}
if err := tw.WriteHeader(&tar.Header{
Name: name,
Typeflag: tar.TypeReg,
Mode: 0o644,
Size: int64(len(data)),
}); err != nil {
return 0, fmt.Errorf("write header %q: %w", name, err)
}
if _, err := tw.Write(data); err != nil {
return 0, fmt.Errorf("write %q: %w", name, err)
}
total += int64(len(data))
}
if err := tw.Close(); err != nil {
return 0, fmt.Errorf("close tar: %w", err)
}
if err := enc.Close(); err != nil {
return 0, fmt.Errorf("close zstd: %w", err)
}
return total, nil
}
// parentDirs returns the ancestor directories of a forward-slash path, from
// shallowest to deepest ("a/b/c.txt" → ["a", "a/b"]).
func parentDirs(name string) []string {
var dirs []string
parts := strings.Split(name, "/")
for i := 1; i < len(parts); i++ {
dirs = append(dirs, strings.Join(parts[:i], "/"))
}
return dirs
}
// collectDir returns pack entries for every regular file under root, mapped
// under artifactPrefix. Symlinks and irregular files are skipped. Returns
// (entries, hadFiles).
func collectDir(root, artifactPrefix string) ([]packEntry, bool, error) {
info, err := os.Stat(root)
if err != nil || !info.IsDir() {
return nil, false, nil //nolint:nilerr // absent optional dir is not an error
}
var entries []packEntry
walkErr := filepath.WalkDir(root, func(path string, d os.DirEntry, err error) error {
if err != nil {
return err
}
if d.IsDir() || !d.Type().IsRegular() {
return nil
}
rel, err := filepath.Rel(root, path)
if err != nil {
return err
}
entries = append(entries, packEntry{
ArtifactPath: artifactPrefix + "/" + filepath.ToSlash(rel),
Source: path,
})
return nil
})
if walkErr != nil {
return nil, false, fmt.Errorf("walk %s: %w", root, walkErr)
}
return entries, len(entries) > 0, nil
}

View File

@ -1,329 +0,0 @@
package bnp
import (
"archive/tar"
"bufio"
"bytes"
"errors"
"fmt"
"io"
"os"
"path/filepath"
"slices"
"strings"
abiv1 "git.dev.alexdunmow.com/block/core/abi/v1"
"github.com/klauspost/compress/zstd"
"google.golang.org/protobuf/proto"
)
// These constants and the validation flow below are a DELIBERATE duplication
// of the CMS reader (cms backend/plugin/bnp/reader.go, WO-WZ-008). core cannot
// import the CMS module, so `ninja plugin verify` re-implements the same gate a
// publisher's artifact will meet at load time. WO-WZ-010's integration suite
// keeps the two copies in lockstep; change both together.
const (
// supportedABIMajor is the ABI major version a host understands.
supportedABIMajor uint32 = 1
// maxArtifactBytes caps total decompressed size (matches the registry
// publish cap, 1 GiB) to bound a decompression bomb.
maxArtifactBytes int64 = 1 << 30
// maxEntryBytes caps a single extracted file.
maxEntryBytes int64 = 512 * 1024 * 1024
)
// VerifyResult reports what a valid artifact contains.
type VerifyResult struct {
Name string
Version string
ABIVersion uint32
Codeless bool
DataDir bool
BlockCount int
HasMigrations bool
HasSchemas bool
HasAssets bool
HasWeb bool
HasBlocks bool
HasTemplates bool
HasSeed bool
}
// Verify extracts bnpPath into a temp dir and applies the reader's checks:
// path-safety + size caps on extraction, required members present, manifest
// decodes, abi major supported, non-empty name, and manifest name == plugin.mod
// name. The temp dir is always cleaned up. A named error is returned for each
// malformed class.
func Verify(bnpPath string) (*VerifyResult, error) {
destDir, err := os.MkdirTemp("", "ninja-verify-*")
if err != nil {
return nil, fmt.Errorf("bnp: temp dir: %w", err)
}
defer func() { _ = os.RemoveAll(destDir) }()
f, err := os.Open(bnpPath)
if err != nil {
return nil, fmt.Errorf("bnp: open %s: %w", bnpPath, err)
}
defer func() { _ = f.Close() }()
if err := extractTarZst(f, destDir); err != nil {
return nil, err
}
for _, req := range []string{fileMod, fileManifest} {
if !fileRegular(filepath.Join(destDir, req)) {
return nil, fmt.Errorf("bnp: artifact missing required %s", req)
}
}
manifestBytes, err := os.ReadFile(filepath.Join(destDir, fileManifest))
if err != nil {
return nil, fmt.Errorf("bnp: read manifest.pb: %w", err)
}
manifest := &abiv1.PluginManifest{}
if err := proto.Unmarshal(manifestBytes, manifest); err != nil {
return nil, fmt.Errorf("bnp: decode manifest.pb: %w", err)
}
// plugin.wasm is required exactly when the manifest is NOT codeless; a
// codeless artifact must additionally declare no computing hooks
// (WO-WZ-020 — the classifier should have caught this at build).
if manifest.GetCodeless() {
if fileRegular(filepath.Join(destDir, fileWasm)) {
return nil, errors.New("bnp: codeless manifest but plugin.wasm present — rebuild without the wasm or drop codeless")
}
if err := CodelessHookViolation(manifest); err != nil {
return nil, err
}
} else if !fileRegular(filepath.Join(destDir, fileWasm)) {
return nil, fmt.Errorf("bnp: artifact missing required %s", fileWasm)
}
if v := manifest.GetAbiVersion(); v != supportedABIMajor {
return nil, fmt.Errorf("bnp: unsupported abi_version %d (host supports %d)", v, supportedABIMajor)
}
name := manifest.GetName()
if name == "" {
return nil, errors.New("bnp: manifest has empty name")
}
modBytes, err := os.ReadFile(filepath.Join(destDir, fileMod))
if err != nil {
return nil, fmt.Errorf("bnp: read plugin.mod: %w", err)
}
modName := parseModName(modBytes)
if modName == "" {
return nil, errors.New("bnp: plugin.mod has no name")
}
if modName != name {
return nil, fmt.Errorf("bnp: manifest name %q != plugin.mod name %q", name, modName)
}
return &VerifyResult{
Name: name,
Version: manifest.GetVersion(),
ABIVersion: manifest.GetAbiVersion(),
Codeless: manifest.GetCodeless(),
DataDir: manifest.GetDataDir(),
BlockCount: len(manifest.GetBlocks()),
HasMigrations: dirExists(filepath.Join(destDir, "migrations")),
HasSchemas: dirExists(filepath.Join(destDir, "schemas")),
HasAssets: dirExists(filepath.Join(destDir, "assets")),
HasWeb: dirExists(filepath.Join(destDir, "web")),
HasBlocks: dirExists(filepath.Join(destDir, dirBlocks)),
HasTemplates: dirExists(filepath.Join(destDir, dirTemplates)),
HasSeed: dirExists(filepath.Join(destDir, dirSeed)),
}, nil
}
// CodelessHookViolation returns a named error when a codeless manifest
// declares any capability that requires guest code. Mirrored by the CMS
// reader (lockstep duplication, same as the rest of this file).
func CodelessHookViolation(m *abiv1.PluginManifest) error {
viol := func(what string) error {
return fmt.Errorf("bnp: codeless manifest declares %s — that requires guest code; build a wasm plugin instead", what)
}
switch {
case m.GetHasHttpHandler():
return viol("an HTTP handler")
case m.GetHasLoadHook() || m.GetHasUnloadHook():
return viol("load/unload hooks")
case m.GetHasMediaHooks():
return viol("media hooks")
case m.GetHasProvisioner():
return viol("a provisioner hook")
case len(m.GetJobTypes()) > 0:
return viol("job handlers")
case len(m.GetRagContentFetcherTypes()) > 0:
return viol("RAG content fetchers")
case len(m.GetDeclaredTags()) > 0 || len(m.GetDeclaredFilters()) > 0:
return viol("template tags/filters (express pure snippets as template partials)")
case len(m.GetRbacMethodRoles()) > 0 || len(m.GetCoreServiceBindings()) > 0:
return viol("Connect services")
case len(m.GetBlocks()) > 0 || len(m.GetTemplateKeys()) > 0 || len(m.GetSystemTemplates()) > 0 || len(m.GetPageTemplates()) > 0:
return viol("guest-rendered blocks/templates (codeless blocks live in blocks/blocks.yaml)")
case m.GetDirectoryExtensions().GetPanelSectionCount() > 0 || m.GetDirectoryExtensions().GetPinDecoratorCount() > 0:
return viol("directory extension callbacks")
case m.GetDataDir():
return viol("a data_dir grant")
}
return nil
}
// ReadManifest extracts a .bnp into a temp dir (with the same path-safety and
// size caps as Verify) and returns its decoded manifest.pb. It does not enforce
// the name-match / abi checks — use Verify for the full gate.
func ReadManifest(bnpPath string) (*abiv1.PluginManifest, error) {
destDir, err := os.MkdirTemp("", "ninja-manifest-*")
if err != nil {
return nil, fmt.Errorf("bnp: temp dir: %w", err)
}
defer func() { _ = os.RemoveAll(destDir) }()
f, err := os.Open(bnpPath)
if err != nil {
return nil, fmt.Errorf("bnp: open %s: %w", bnpPath, err)
}
defer func() { _ = f.Close() }()
if err := extractTarZst(f, destDir); err != nil {
return nil, err
}
b, err := os.ReadFile(filepath.Join(destDir, fileManifest))
if err != nil {
return nil, fmt.Errorf("bnp: read manifest.pb: %w", err)
}
m := &abiv1.PluginManifest{}
if err := proto.Unmarshal(b, m); err != nil {
return nil, fmt.Errorf("bnp: decode manifest.pb: %w", err)
}
return m, nil
}
// extractTarZst streams a zstd-compressed tar into destDir, rejecting
// non-regular entries, absolute/traversal paths, and enforcing size caps.
func extractTarZst(r io.Reader, destDir string) error {
zr, err := zstd.NewReader(r)
if err != nil {
return fmt.Errorf("bnp: open zstd stream: %w", err)
}
defer zr.Close()
tr := tar.NewReader(zr)
var total int64
root, err := filepath.Abs(destDir)
if err != nil {
return fmt.Errorf("bnp: resolve dest: %w", err)
}
for {
hdr, err := tr.Next()
if errors.Is(err, io.EOF) {
break
}
if err != nil {
return fmt.Errorf("bnp: read tar: %w", err)
}
switch hdr.Typeflag {
case tar.TypeReg, tar.TypeDir:
default:
return fmt.Errorf("bnp: rejected non-regular entry %q (type %d)", hdr.Name, hdr.Typeflag)
}
target, err := safeJoin(root, hdr.Name)
if err != nil {
return err
}
if hdr.Typeflag == tar.TypeDir {
if err := os.MkdirAll(target, 0o755); err != nil {
return fmt.Errorf("bnp: mkdir %q: %w", hdr.Name, err)
}
continue
}
if hdr.Size > maxEntryBytes {
return fmt.Errorf("bnp: entry %q exceeds per-file cap (%d > %d)", hdr.Name, hdr.Size, maxEntryBytes)
}
if total+hdr.Size > maxArtifactBytes {
return fmt.Errorf("bnp: artifact exceeds total size cap (%d)", maxArtifactBytes)
}
if err := os.MkdirAll(filepath.Dir(target), 0o755); err != nil {
return fmt.Errorf("bnp: mkdir for %q: %w", hdr.Name, err)
}
n, err := writeExtractedFile(target, io.LimitReader(tr, maxArtifactBytes-total))
if err != nil {
return fmt.Errorf("bnp: extract %q: %w", hdr.Name, err)
}
total += n
if total > maxArtifactBytes {
return fmt.Errorf("bnp: artifact exceeds total size cap (%d)", maxArtifactBytes)
}
}
return nil
}
// safeJoin joins a relative tar entry onto root, rejecting absolute paths and
// traversal segments.
func safeJoin(root, name string) (string, error) {
norm := strings.ReplaceAll(name, `\`, "/")
if norm == "" || norm == "." {
return "", fmt.Errorf("bnp: empty entry name %q", name)
}
if filepath.IsAbs(name) || strings.HasPrefix(norm, "/") {
return "", fmt.Errorf("bnp: absolute entry path %q rejected", name)
}
if slices.Contains(strings.Split(norm, "/"), "..") {
return "", fmt.Errorf("bnp: entry %q contains a traversal segment", name)
}
target := filepath.Join(root, filepath.Clean(norm))
rel, err := filepath.Rel(root, target)
if err != nil || rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) {
return "", fmt.Errorf("bnp: entry %q escapes artifact root", name)
}
return target, nil
}
func writeExtractedFile(path string, r io.Reader) (int64, error) {
out, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0o644)
if err != nil {
return 0, err
}
n, err := io.Copy(out, r)
closeErr := out.Close()
if err != nil {
return n, err
}
return n, closeErr
}
func fileRegular(p string) bool {
fi, err := os.Stat(p)
return err == nil && fi.Mode().IsRegular()
}
func dirExists(p string) bool {
fi, err := os.Stat(p)
return err == nil && fi.IsDir()
}
// parseModName extracts the `name = "..."` value from a plugin.mod body,
// mirroring the reader's tolerant line scan (works whether or not the key sits
// under a [plugin] table).
func parseModName(data []byte) string {
sc := bufio.NewScanner(bytes.NewReader(data))
for sc.Scan() {
after, ok := strings.CutPrefix(strings.TrimSpace(sc.Text()), "name")
if !ok {
continue
}
val, ok := strings.CutPrefix(strings.TrimSpace(after), "=")
if !ok {
continue
}
val = strings.Trim(strings.TrimSpace(val), `"`)
if val != "" {
return val
}
}
return ""
}

View File

@ -1,88 +0,0 @@
package creds
import (
"encoding/json"
"errors"
"os"
"path/filepath"
)
type Credentials struct {
DefaultHost string `json:"default_host"`
Hosts map[string]HostCreds `json:"hosts"`
}
type HostCreds struct {
Token string `json:"token"`
User string `json:"user,omitempty"`
DefaultScope string `json:"default_scope,omitempty"`
// ActiveAccountID is the orchestrator-side UUID of the account that
// account-scoped commands (notably `ninja plugins publish --private`)
// operate against. Set during `ninja login` (forced selection when the
// user belongs to more than one account) and changeable via
// `ninja account set`.
ActiveAccountID string `json:"active_account_id,omitempty"`
// ActiveAccountSlug mirrors ActiveAccountID in human-readable form for
// display in CLI output. The orchestrator-side slug is authoritative;
// the CLI refreshes it whenever it talks to the server.
ActiveAccountSlug string `json:"active_account_slug,omitempty"`
}
func filePath() (string, error) {
dir, err := os.UserConfigDir()
if err != nil {
return "", err
}
return filepath.Join(dir, "ninja", "credentials.json"), nil
}
func Load() (*Credentials, error) {
p, err := filePath()
if err != nil {
return nil, err
}
b, err := os.ReadFile(p)
if errors.Is(err, os.ErrNotExist) {
return &Credentials{Hosts: map[string]HostCreds{}}, nil
}
if err != nil {
return nil, err
}
c := &Credentials{Hosts: map[string]HostCreds{}}
if err := json.Unmarshal(b, c); err != nil {
return nil, err
}
return c, nil
}
func (c *Credentials) Save() error {
p, err := filePath()
if err != nil {
return err
}
if err := os.MkdirAll(filepath.Dir(p), 0o700); err != nil {
return err
}
b, err := json.MarshalIndent(c, "", " ")
if err != nil {
return err
}
return os.WriteFile(p, b, 0o600)
}
func (c *Credentials) Resolve(host string) (string, HostCreds, error) {
if host == "" {
host = c.DefaultHost
}
if host == "" {
host = "https://my.blockninjacms.com"
}
if t := os.Getenv("NINJA_TOKEN"); t != "" {
return host, HostCreds{Token: t}, nil
}
hc, ok := c.Hosts[host]
if !ok || hc.Token == "" {
return host, HostCreds{}, errors.New("not logged in; run `ninja login`")
}
return host, hc, nil
}

View File

@ -1,45 +0,0 @@
package creds
import (
"encoding/json"
"testing"
)
func TestHostCreds_ActiveAccountRoundTrip(t *testing.T) {
src := HostCreds{
Token: "tok",
ActiveAccountID: "acct-uuid",
ActiveAccountSlug: "acme",
}
b, err := json.Marshal(src)
if err != nil {
t.Fatalf("Marshal: %v", err)
}
var got HostCreds
if err := json.Unmarshal(b, &got); err != nil {
t.Fatalf("Unmarshal: %v", err)
}
if got.ActiveAccountID != "acct-uuid" {
t.Errorf("ActiveAccountID = %q, want acct-uuid", got.ActiveAccountID)
}
if got.ActiveAccountSlug != "acme" {
t.Errorf("ActiveAccountSlug = %q, want acme", got.ActiveAccountSlug)
}
}
func TestHostCreds_LegacyFileLoadsWithoutAccount(t *testing.T) {
// A creds.json from before the active-account fields existed must still
// unmarshal cleanly; the new fields should be zero-valued.
legacy := `{"token":"tok","user":"alice","default_scope":"@themes"}`
var got HostCreds
if err := json.Unmarshal([]byte(legacy), &got); err != nil {
t.Fatalf("Unmarshal legacy: %v", err)
}
if got.Token != "tok" {
t.Errorf("Token = %q", got.Token)
}
if got.ActiveAccountID != "" || got.ActiveAccountSlug != "" {
t.Errorf("ActiveAccount* should be empty for legacy file, got id=%q slug=%q",
got.ActiveAccountID, got.ActiveAccountSlug)
}
}

View File

@ -1,42 +0,0 @@
package orchclient
import (
"context"
"net/http"
"connectrpc.com/connect"
"git.dev.alexdunmow.com/block/core/internal/api/orchestrator/v1/orchestratorv1connect"
)
type Client struct {
Host string
Token string
Auth orchestratorv1connect.PluginAuthServiceClient
Scope orchestratorv1connect.PluginScopeServiceClient
Reg orchestratorv1connect.PluginRegistryServiceClient
Pub orchestratorv1connect.PluginPublishServiceClient
}
func New(host, token string) *Client {
httpClient := &http.Client{}
opts := []connect.ClientOption{
connect.WithInterceptors(bearerInterceptor(token)),
}
c := &Client{Host: host, Token: token}
c.Auth = orchestratorv1connect.NewPluginAuthServiceClient(httpClient, host, opts...)
c.Scope = orchestratorv1connect.NewPluginScopeServiceClient(httpClient, host, opts...)
c.Reg = orchestratorv1connect.NewPluginRegistryServiceClient(httpClient, host, opts...)
c.Pub = orchestratorv1connect.NewPluginPublishServiceClient(httpClient, host, opts...)
return c
}
func bearerInterceptor(token string) connect.Interceptor {
return connect.UnaryInterceptorFunc(func(next connect.UnaryFunc) connect.UnaryFunc {
return func(ctx context.Context, req connect.AnyRequest) (connect.AnyResponse, error) {
if token != "" {
req.Header().Set("Authorization", "Bearer "+token)
}
return next(ctx, req)
}
})
}

View File

@ -1,87 +0,0 @@
// Package shot captures a PNG screenshot of a rendered CMS page using a
// headless Chromium (chromedp / CDP). It is the image-capture half of the
// `ninja theme screenshot` harness; dojo's Playwright runner only ingests
// pass/fail JSON, so capture lives here.
package shot
import (
"context"
"fmt"
"net/url"
"strings"
"time"
"github.com/chromedp/chromedp"
)
// Options configures a single screenshot capture.
type Options struct {
URL string // full page URL to navigate to
Width int // viewport width in px
Height int // viewport height in px
FullPage bool // capture the full scroll height, not just the viewport
WaitSelector string // CSS selector to wait for before capturing (e.g. "section")
Timeout time.Duration // overall navigation+capture timeout
}
// PreviewURL composes the gallery-page URL with the render-only override.
// host is the gallery site base (scheme+host), slug is the page path ("/"),
// theme is the bare theme key written to ?preview_template.
func PreviewURL(host, slug, theme string) string {
host = strings.TrimRight(host, "/")
if slug == "" {
slug = "/"
}
if !strings.HasPrefix(slug, "/") {
slug = "/" + slug
}
return fmt.Sprintf("%s%s?preview_template=%s", host, slug, url.QueryEscape(theme))
}
// Capture navigates to opts.URL in a headless Chromium and returns PNG bytes.
func Capture(ctx context.Context, opts Options) ([]byte, error) {
if opts.Width == 0 {
opts.Width = 1440
}
if opts.Height == 0 {
opts.Height = 900
}
if opts.Timeout == 0 {
opts.Timeout = 30 * time.Second
}
allocCtx, cancelAlloc := chromedp.NewExecAllocator(ctx,
append(chromedp.DefaultExecAllocatorOptions[:],
chromedp.WindowSize(opts.Width, opts.Height),
chromedp.Flag("headless", true),
)...,
)
defer cancelAlloc()
browserCtx, cancelBrowser := chromedp.NewContext(allocCtx)
defer cancelBrowser()
timeoutCtx, cancelTimeout := context.WithTimeout(browserCtx, opts.Timeout)
defer cancelTimeout()
var buf []byte
tasks := chromedp.Tasks{
chromedp.EmulateViewport(int64(opts.Width), int64(opts.Height)),
chromedp.Navigate(opts.URL),
}
if opts.WaitSelector != "" {
tasks = append(tasks, chromedp.WaitVisible(opts.WaitSelector, chromedp.ByQuery))
} else {
tasks = append(tasks, chromedp.WaitReady("body", chromedp.ByQuery))
}
if opts.FullPage {
tasks = append(tasks, chromedp.FullScreenshot(&buf, 90))
} else {
tasks = append(tasks, chromedp.CaptureScreenshot(&buf))
}
if err := chromedp.Run(timeoutCtx, tasks); err != nil {
return nil, fmt.Errorf("capture %s: %w", opts.URL, err)
}
return buf, nil
}

View File

@ -1,27 +0,0 @@
package shot
import "testing"
func TestPreviewURLComposesQuery(t *testing.T) {
got := PreviewURL("https://showcase.localdev.blockninjacms.com", "/", "gotham")
want := "https://showcase.localdev.blockninjacms.com/?preview_template=gotham"
if got != want {
t.Fatalf("PreviewURL = %q, want %q", got, want)
}
}
func TestPreviewURLTrimsTrailingSlashHost(t *testing.T) {
got := PreviewURL("https://showcase.localdev.blockninjacms.com/", "/", "noir")
want := "https://showcase.localdev.blockninjacms.com/?preview_template=noir"
if got != want {
t.Fatalf("PreviewURL = %q, want %q", got, want)
}
}
func TestPreviewURLNonRootSlug(t *testing.T) {
got := PreviewURL("https://x.example.com", "/showcase", "lcars")
want := "https://x.example.com/showcase?preview_template=lcars"
if got != want {
t.Fatalf("PreviewURL = %q, want %q", got, want)
}
}

View File

@ -1,15 +0,0 @@
package main
import (
"fmt"
"os"
"git.dev.alexdunmow.com/block/core/cmd/ninja/cmd"
)
func main() {
if err := cmd.NewRoot().Execute(); err != nil {
fmt.Fprintln(os.Stderr, "Error:", err)
os.Exit(1)
}
}

View File

@ -1,99 +0,0 @@
# ABI Capability Surface (WO-WZ-019)
The canonical map of **every capability a plugin needs from the CMS** to its
ABI mechanism. The rule is *injection, not reliance*: a plugin obtains 100% of
its host interactions by (a) declaring static intent in `manifest.pb`, (b)
calling a `blockninja.host_call` capability method, or (c) implementing a
host-invoked hook. Nothing requires linking the Go `core` module — the Go
guest SDK (`plugin/wasmguest`) is an ergonomic front-end over these
primitives, not part of the model. A plugin in any language that speaks
protobuf over the [wasm ABI](wasm-abi.md) reaches everything below.
Legend — **Mechanism**: `manifest` (static declaration, consumed by the host
loader), `host_call` (dynamic guest→host capability), `hook` (host-invoked
guest logic), `artifact` (a directory in the `.bnp` the host consumes).
**Codeless equivalent**: how a plugin with NO `plugin.wasm` gets the same
effect (the WO-WZ-020 declarative path; rows marked *WZ-020* land there).
## Static contributions (what the plugin IS)
| Capability | Mechanism | Status | Codeless equivalent |
|---|---|---|---|
| Register block types (key/title/category/icon/schema) | `manifest.blocks` (`BlockMeta`) + `schemas/` artifact | ABI-native | Same — pure declaration, zero code |
| Block template overrides | `manifest.block_template_overrides` | ABI-native | Same |
| Templates / system templates / page templates | `manifest.template_keys`, `system_templates`, `page_templates` | ABI-native | Same + `.ninjatpl` files in the artifact (WZ-020) |
| Master pages | `manifest.master_pages` | ABI-native | Same |
| Email wrappers | `manifest.email_wrapper_system_keys` | ABI-native | Same |
| Template tags / filters (computing) | `manifest.declared_tags` / `declared_filters` + `HOOK_RENDER_TAG` / `HOOK_APPLY_FILTER` | ABI-native | N/A — a *computing* tag requires code by definition |
| Template tags (pure snippet/partial, no logic) | today: same as computing (hook) | **WZ-020**: declare as a template partial, host-rendered, no hook | Partial file in `templates/` |
| Powered block with a *declared data provider* (no render hook at all) | today: `HOOK_RENDER_BLOCK` returning `RenderBlockResponse.powered` | **WZ-020**: manifest provider declaration (`posts`, `pages`, `menu`, `datasource:<id>`, …) + `.ninjatpl`; host fetches + renders | The WZ-020 headline feature |
| Settings schema / settings panel / admin pages | `manifest.settings_schema`, `settings_panel`, `admin_pages` | ABI-native | Same |
| Theme presets / bundled fonts / CSS manifest / icon packs | `manifest.theme_presets`, `bundled_fonts`, `css_manifest`, `required_icon_packs` | ABI-native (JSON payloads; no Go types on the wire) | Same. Fonts are **purely declarative** — the host consumes `bundled_fonts` at load; runtime *activation* (choosing a font) is a site setting, writable via `settings.update_site_setting`. |
| AI actions | `manifest.ai_actions` | ABI-native | Same |
| RBAC method roles / core service bindings | `manifest.rbac_method_roles`, `core_service_bindings` | ABI-native | Same (bindings host-mounted) |
| Job types / RAG fetcher types / directory extensions (static fields) | `manifest.job_types`, `rag_content_fetcher_types`, `directory_extensions` | ABI-native | Job/RAG types need their hooks → code |
| Migrations / assets / MF web bundle | `artifact` `migrations/`, `assets/`, `web/` (host-run/served) | ABI-native | Same |
| Persistent data dir | `plugin.mod data_dir``manifest.data_dir` | ABI-native | N/A (no code to need it) |
## Dynamic requests (host_call families)
Every family below is registered in the cms host (`plugin/wasmhost/caps`) and
mirrored 1:1 by a guest SDK stub. Wire types are language-neutral: canonical
UUID strings, JSON bytes for maps, protobuf for structure.
| Capability | Methods | Status |
|---|---|---|
| Content reads | `content.get_author_profile`, `get_page`, `get_post`, `list_posts`, `slugify`, `block_note_to_html`, `generate_excerpt`, `strip_html` | ABI-native |
| **Content authoring** (WZ-019) | `content.create_page`, `set_page_blocks`, `publish_page`, `set_page_seo`, `upsert_post` | ABI-native |
| **Provisioning** (WZ-019) — idempotent seed/ensure, **Load-time** | `provisioner.ensure_data_table`, `merge_site_settings`, `ensure_setting`, `ensure_page`, `override_site_settings`, `ensure_menu_item`, `register_embedding_config`, `ensure_embed`, `ensure_job_schedule`, `update_data_table_row_field`, `disable_orphaned_job_schedules`, `ensure_plugin`, `ensure_custom_color`, `ensure_media` | ABI-native. `RegisterWithProvisioner` is **inert under wasm** (DESCRIBE stubs host functions): call `deps.Provisioner` from `Load` instead. `EnsureEmbed` crosses template-rendered embeds only — `RenderFunc` cannot serialize. |
| Settings | `settings.get_site_settings`, `get_plugin_settings`, `update_site_setting`, **`update_plugin_settings`** (WZ-019; host-pinned to the caller's own plugin) | ABI-native |
| Menus (read) | `menus.get_menu_by_name`, `get_menu_items` | ABI-native. Menu *writes* are seed-shaped → `provisioner.ensure_menu_item`. |
| Media | `media.deposit` (full pipeline, deterministic-ID + `created` flag, folder, alt text, stable `media:<uuid>` ref) + `provisioner.ensure_media` (immutable seed semantics) | ABI-native |
| Gating / crypto / datasources / users / subscriptions | as in [wasm-abi.md](wasm-abi.md) §Capability calls | ABI-native |
| Email | `email.send` | ABI-native |
| AI | `ai.text_call`, `ai.tools.register` (+ execution via `HOOK_AI_TOOL_CALL`) | ABI-native |
| Jobs | `jobs.submit`, **`jobs.progress`** (WZ-019; correlated to the running job via the HOOK_JOB call context — no job ID on the wire) | ABI-native |
| Bridge | `bridge.register_service`, `get_service` (availability), **`bridge.invoke`** (WZ-019; opaque-payload cross-plugin calls → provider's `BridgeInvokable` or `HOOK_BRIDGE_CALL`) | ABI-native. Typed in-process Go values still don't cross — by design; `GetService` stays nil for wasm consumers. |
| Embeddings / RAG / reviews / badges | as in wasm-abi.md | ABI-native |
| Database | `db.query/exec/tx_*` under the per-plugin Postgres role | ABI-native |
## Host-invoked hooks (logic the plugin injects)
| Hook | Contract | Status |
|---|---|---|
| `HOOK_DESCRIBE` | `DescribeRequest/Response` — manifest capture at publish | ABI-native |
| `HOOK_LOAD` / `HOOK_UNLOAD` | `LoadRequest` (host config) / `UnloadRequest` | ABI-native |
| `HOOK_RENDER_BLOCK` | html OR powered `{template, data_json}` | ABI-native |
| `HOOK_RENDER_TEMPLATE` | `RenderTemplateRequest/Response` | ABI-native |
| `HOOK_RENDER_TAG` / `HOOK_APPLY_FILTER` | declared-tag/filter callbacks, re-entrancy-free | ABI-native |
| `HOOK_HANDLE_HTTP` | buffered HTTP + trusted identity headers | ABI-native |
| `HOOK_JOB` | `JobRequest/Response` + `jobs.progress` | ABI-native |
| `HOOK_RAG_FETCH` | content re-index callback | ABI-native |
| `HOOK_MEDIA_HOOK` | media lifecycle events | ABI-native |
| **`HOOK_AI_TOOL_CALL`** (WZ-019) | `AiToolCallRequest{slug, params_json}``{content, error_message}`; executes the guest handler recorded at `ai.tools.register` | ABI-native |
| **`HOOK_BRIDGE_CALL`** (WZ-019) | `BridgeCallRequest{service_name, method, payload}``{payload}`; provider side of `bridge.invoke` | ABI-native |
| **`HOOK_DIRECTORY_PANEL_SECTION`** / **`HOOK_DIRECTORY_PIN_DECORATOR`** (WZ-019) | indexed callbacks over `manifest.directory_extensions` counts; pin decorators return the mutated pin | ABI-native |
**Per-instance rule for callback hooks:** anything the host may call back on
an arbitrary pooled instance (AI tools, bridge services, RAG fetchers) must be
registered in `Register` — which runs on every instance — not in `Load`
(one instance only). Use `wasmguest.HostServices()` for deps at Register time.
## Remaining open items
- **Slot / media / embed resolvers during a guest render**`RenderContext`
still cannot carry them (function values). The powered-block path sidesteps
this: host-side rendering has the real resolvers. Only a plain-HTML guest
block that wants to resolve `media:` refs itself is affected. Tracked for a
render-focused WO; not blocking any shipping plugin.
## Conformance
- Guest↔host wire parity: every capability method has a golden
request/response pair (`plugin/wasmguest/caps/testdata/golden/`, generated
by the guest stubs) that the cms host replays byte-for-byte through its real
handlers (`cms plugin/wasmhost/caps` `TestGoldenParity`). The count check
fails if a registered method lacks a golden.
- Injection-not-reliance: `TestRawProtobufConformance` (cms caps package)
drives representative capabilities with hand-constructed protobuf messages
through the `HostCallRequest` envelope — no guest SDK in the path.

View File

@ -35,7 +35,7 @@ uses; commands assume access via `podman exec blockninja-db psql -U orchestrator
rm -rf /tmp/uat-a1 && mkdir /tmp/uat-a1 && cd /tmp/uat-a1 rm -rf /tmp/uat-a1 && mkdir /tmp/uat-a1 && cd /tmp/uat-a1
git init -q && git commit --allow-empty -qm "initial" git init -q && git commit --allow-empty -qm "initial"
``` ```
- [x] Run: `ninja plugin init --host https://my.localdev.blockninjacms.com --scope @themes --name uat-a1` (answer: kind=plugin, categories=1,2) - [x] Run: `ninja plugin init --host https://my.blockninja.dev --scope @themes --name uat-a1` (answer: kind=plugin, categories=1,2)
- [x] Observe `cat plugin.mod` includes: - [x] Observe `cat plugin.mod` includes:
- `name = "uat-a1"` - `name = "uat-a1"`
- `scope = "@themes"` - `scope = "@themes"`
@ -85,7 +85,7 @@ uses; commands assume access via `podman exec blockninja-db psql -U orchestrator
git tag --list # capture before git tag --list # capture before
git rev-parse HEAD # capture HEAD before git rev-parse HEAD # capture HEAD before
``` ```
- [x] Run `ninja plugin publish --host https://my.localdev.blockninjacms.com`. - [x] Run `ninja plugin publish --host https://my.blockninja.dev`.
- [x] STDOUT contains `Published @themes/uat-a1@0.1.0 (NNN bytes)`. - [x] STDOUT contains `Published @themes/uat-a1@0.1.0 (NNN bytes)`.
- [x] After publish, `git tag --list` output is byte-identical to before. (NO `v0.1.0` tag was created.) - [x] After publish, `git tag --list` output is byte-identical to before. (NO `v0.1.0` tag was created.)
- [x] `git rev-parse HEAD` is unchanged. - [x] `git rev-parse HEAD` is unchanged.
@ -162,7 +162,7 @@ uses; commands assume access via `podman exec blockninja-db psql -U orchestrator
- [x] Run: - [x] Run:
```bash ```bash
curl -sS -X POST \ curl -sS -X POST \
https://my.localdev.blockninjacms.com/orchestrator.v1.PluginRegistryService/ListCategories \ https://my.blockninja.dev/orchestrator.v1.PluginRegistryService/ListCategories \
-H 'Content-Type: application/json' -d '{}' -H 'Content-Type: application/json' -d '{}'
``` ```
- [x] Response includes ALL of: `analytics`, `seo`, `social`, `commerce`, `forms`, `import-export`, `media`, `developer`. - [x] Response includes ALL of: `analytics`, `seo`, `social`, `commerce`, `forms`, `import-export`, `media`, `developer`.
@ -171,9 +171,9 @@ uses; commands assume access via `podman exec blockninja-db psql -U orchestrator
- [x] Setup a fresh repo and start init manually entering a bogus category number — NOT possible via the CLI menu. Instead, hit the RPC directly: - [x] Setup a fresh repo and start init manually entering a bogus category number — NOT possible via the CLI menu. Instead, hit the RPC directly:
```bash ```bash
TOKEN=$(jq -r '.hosts["https://my.localdev.blockninjacms.com"].token' ~/.config/ninja/creds.json) TOKEN=$(jq -r '.hosts["https://my.blockninja.dev"].token' ~/.config/ninja/creds.json)
curl -sS -X POST \ curl -sS -X POST \
https://my.localdev.blockninjacms.com/orchestrator.v1.PluginRegistryService/CreatePlugin \ https://my.blockninja.dev/orchestrator.v1.PluginRegistryService/CreatePlugin \
-H "Authorization: Bearer $TOKEN" \ -H "Authorization: Bearer $TOKEN" \
-H 'Content-Type: application/json' \ -H 'Content-Type: application/json' \
-d '{"scopeSlug":"themes","name":"uat-c2","kind":"plugin","categories":["bogus"]}' -d '{"scopeSlug":"themes","name":"uat-c2","kind":"plugin","categories":["bogus"]}'
@ -185,7 +185,7 @@ uses; commands assume access via `podman exec blockninja-db psql -U orchestrator
- [x] Same setup as C2 but kind=theme with categories=["analytics"]: - [x] Same setup as C2 but kind=theme with categories=["analytics"]:
```bash ```bash
curl -sS -X POST \ curl -sS -X POST \
https://my.localdev.blockninjacms.com/orchestrator.v1.PluginRegistryService/CreatePlugin \ https://my.blockninja.dev/orchestrator.v1.PluginRegistryService/CreatePlugin \
-H "Authorization: Bearer $TOKEN" \ -H "Authorization: Bearer $TOKEN" \
-H 'Content-Type: application/json' \ -H 'Content-Type: application/json' \
-d '{"scopeSlug":"themes","name":"uat-c3","kind":"theme","categories":["analytics"]}' -d '{"scopeSlug":"themes","name":"uat-c3","kind":"theme","categories":["analytics"]}'
@ -213,7 +213,7 @@ uses; commands assume access via `podman exec blockninja-db psql -U orchestrator
- [x] Hit ListPlugins with `kind=plugin`: - [x] Hit ListPlugins with `kind=plugin`:
```bash ```bash
curl -sS -X POST \ curl -sS -X POST \
https://my.localdev.blockninjacms.com/orchestrator.v1.PluginRegistryService/ListPlugins \ https://my.blockninja.dev/orchestrator.v1.PluginRegistryService/ListPlugins \
-H "Authorization: Bearer $TOKEN" \ -H "Authorization: Bearer $TOKEN" \
-H 'Content-Type: application/json' -d '{"kind":"plugin"}' -H 'Content-Type: application/json' -d '{"kind":"plugin"}'
``` ```
@ -236,7 +236,7 @@ uses; commands assume access via `podman exec blockninja-db psql -U orchestrator
- [x] Run: - [x] Run:
```bash ```bash
curl -sS -o /dev/null -w "%{http_code}\n" \ curl -sS -o /dev/null -w "%{http_code}\n" \
https://my.localdev.blockninjacms.com/git/themes/lcars.git/info/refs?service=git-upload-pack https://my.blockninja.dev/git/themes/lcars.git/info/refs?service=git-upload-pack
``` ```
- [x] HTTP code is `404` (not 200, not 403). The route does not exist. - [x] HTTP code is `404` (not 200, not 403). The route does not exist.
@ -283,7 +283,7 @@ uses; commands assume access via `podman exec blockninja-db psql -U orchestrator
- [x] For any published plugin/version (e.g. uat-a1@0.1.0), call ResolveInstall: - [x] For any published plugin/version (e.g. uat-a1@0.1.0), call ResolveInstall:
```bash ```bash
curl -sS -X POST \ curl -sS -X POST \
https://my.localdev.blockninjacms.com/orchestrator.v1.PluginRegistryService/ResolveInstall \ https://my.blockninja.dev/orchestrator.v1.PluginRegistryService/ResolveInstall \
-H 'Content-Type: application/json' \ -H 'Content-Type: application/json' \
-d '{"scopeSlug":"themes","pluginName":"uat-a1","versionOrChannel":"0.1.0"}' -d '{"scopeSlug":"themes","pluginName":"uat-a1","versionOrChannel":"0.1.0"}'
``` ```

View File

@ -1371,8 +1371,8 @@ podman compose up -d orchestrator-backend
mkdir -p /tmp/smokeplugin && cd /tmp/smokeplugin mkdir -p /tmp/smokeplugin && cd /tmp/smokeplugin
git init -q git init -q
git commit --allow-empty -qm "initial" git commit --allow-empty -qm "initial"
ninja login --host https://my.localdev.blockninjacms.com ninja login --host https://my.blockninja.dev
ninja plugin init --host https://my.localdev.blockninjacms.com --scope @themes --name smoke ninja plugin init --host https://my.blockninja.dev --scope @themes --name smoke
git log --oneline git log --oneline
``` ```
Expected: a commit "Add plugin.mod" exists on HEAD; no `ninja` remote (`git remote -v` is empty). Expected: a commit "Add plugin.mod" exists on HEAD; no `ninja` remote (`git remote -v` is empty).
@ -1380,7 +1380,7 @@ Expected: a commit "Add plugin.mod" exists on HEAD; no `ninja` remote (`git remo
- [ ] **Step 3: Publish** - [ ] **Step 3: Publish**
```bash ```bash
ninja plugin publish --host https://my.localdev.blockninjacms.com ninja plugin publish --host https://my.blockninja.dev
``` ```
Expected: `Published @themes/smoke@0.1.0 (NNN bytes)` with no error. Expected: `Published @themes/smoke@0.1.0 (NNN bytes)` with no error.
@ -1399,7 +1399,7 @@ Expected: a row whose `source_archive_key` ends in `source.tar.zst`.
- [ ] **Step 5: Try publishing again — version collision** - [ ] **Step 5: Try publishing again — version collision**
```bash ```bash
ninja plugin publish --host https://my.localdev.blockninjacms.com ninja plugin publish --host https://my.blockninja.dev
``` ```
Expected: error mentioning "version already published". Expected: error mentioning "version already published".
@ -2226,7 +2226,7 @@ podman compose up -d orchestrator-backend
- [ ] **Step 2: Verify ListCategories** - [ ] **Step 2: Verify ListCategories**
```bash ```bash
curl -sS https://my.localdev.blockninjacms.com/orchestrator.v1.PluginRegistryService/ListCategories \ curl -sS https://my.blockninja.dev/orchestrator.v1.PluginRegistryService/ListCategories \
-H 'Content-Type: application/json' -d '{}' -H 'Content-Type: application/json' -d '{}'
``` ```
Expected: JSON listing the 8 seeded categories. Expected: JSON listing the 8 seeded categories.
@ -2236,7 +2236,7 @@ Expected: JSON listing the 8 seeded categories.
```bash ```bash
mkdir -p /tmp/themeplugin && cd /tmp/themeplugin mkdir -p /tmp/themeplugin && cd /tmp/themeplugin
git init -q && git commit --allow-empty -qm "initial" git init -q && git commit --allow-empty -qm "initial"
ninja plugin init --host https://my.localdev.blockninjacms.com --scope @themes --name aurora ninja plugin init --host https://my.blockninja.dev --scope @themes --name aurora
# At the kind prompt, choose 2 (theme). # At the kind prompt, choose 2 (theme).
cat plugin.mod cat plugin.mod
``` ```
@ -2247,7 +2247,7 @@ Expected: `kind = "theme"` set, no categories line.
```bash ```bash
mkdir -p /tmp/normalplugin && cd /tmp/normalplugin mkdir -p /tmp/normalplugin && cd /tmp/normalplugin
git init -q && git commit --allow-empty -qm "initial" git init -q && git commit --allow-empty -qm "initial"
ninja plugin init --host https://my.localdev.blockninjacms.com --scope @themes --name foometrics ninja plugin init --host https://my.blockninja.dev --scope @themes --name foometrics
# kind: 1 (plugin). categories: pick a couple, e.g. "1,2" # kind: 1 (plugin). categories: pick a couple, e.g. "1,2"
cat plugin.mod cat plugin.mod
``` ```
@ -2257,7 +2257,7 @@ Expected: `kind = "plugin"` and `categories = ["analytics", "seo"]`.
```bash ```bash
cd /tmp/normalplugin cd /tmp/normalplugin
ninja plugin publish --host https://my.localdev.blockninjacms.com ninja plugin publish --host https://my.blockninja.dev
``` ```
Expected: success. Expected: success.
@ -2277,7 +2277,7 @@ cd /tmp/normalplugin
sed -i 's/\["analytics", "seo"\]/["analytics", "seo", "bogus"]/' plugin.mod sed -i 's/\["analytics", "seo"\]/["analytics", "seo", "bogus"]/' plugin.mod
git add plugin.mod && git commit -qm "tweak" git add plugin.mod && git commit -qm "tweak"
ninja plugin bump ninja plugin bump
ninja plugin publish --host https://my.localdev.blockninjacms.com ninja plugin publish --host https://my.blockninja.dev
``` ```
Expected: error: "plugin.mod categories do not match registered categories" Expected: error: "plugin.mod categories do not match registered categories"
(the plugin row's categories don't include "bogus"). The mismatch check (the plugin row's categories don't include "bogus"). The mismatch check

View File

@ -1,602 +0,0 @@
# Wasm Plugin ABI (v1)
The host↔guest wire contract for wazero-loaded BlockNinja plugins.
Schema: [`abi/proto/v1/`](../abi/proto/v1/) (buf module [`abi/`](../abi/)) —
generated Go: `git.dev.alexdunmow.com/block/core/abi/v1` (`abiv1`).
Design rationale: the wasm plugin migration design spec in the cms repo
(`docs/superpowers/specs/2026-07-03-wasm-plugin-migration-design.md`).
Regenerate with `make abi` (runs `buf lint` + `buf generate` in `abi/`).
The `abi/` buf module is deliberately separate from the repo-root buf config:
`proto/` is the shared block/proto git submodule (service API contracts),
while this ABI is SDK-internal and versions in lockstep with the guest shim,
so it lives repo-local.
## Versioning — `abi_version`
`PluginManifest.abi_version` (field 1, `manifest.proto`) carries the ABI
**major** version the plugin was built against. Current value: **1**.
- The host **rejects** any manifest whose major version it does not support —
at install/publish time (manifest read) and again at `DESCRIBE`
(`DescribeRequest.host_abi_version` tells the guest who is calling, so a
newer guest shim can refuse an older host symmetrically).
- Within a major version, evolution is protobuf-additive only: new fields,
new `Hook` values, new capability methods. Removing or renaming anything
wire-visible requires a major bump. `buf breaking` (FILE rules, configured
in `abi/buf.yaml`) enforces this against the previous commit.
## Module lifecycle — REACTOR mode (`_initialize`, no `_start`)
Plugins compile as WASI **reactors** (Go ≥ 1.24 toolchain for
`go:wasmexport`; this repo's floor is higher — check `go.mod`):
```
GOOS=wasip1 GOARCH=wasm go build -buildmode=c-shared -o plugin.wasm .
```
with one boilerplate main file next to the untouched Registration:
```go
//go:build wasip1
package main
import "git.dev.alexdunmow.com/block/core/plugin/wasmguest"
func init() { wasmguest.Serve(Registration) }
func main() {} // never called — reactor mode
```
A reactor module exports `_initialize` instead of `_start`. The host MUST
run `_initialize` exactly once per instance **before any `bn_invoke`**
(wazero: `ModuleConfig.WithStartFunctions("_initialize")`); it runs package
init funcs — hence `Serve`, which stores the registration and returns.
`main` exists only to satisfy the linker and is never called.
> Command mode (plain `go build`) does NOT work and must not be used
> (empirically verified, Go 1.26 + wazero v1.12.0, 2026-07-03): `_start`
> runs `main` synchronously, so a blocking `main` (`select{}`) trips the Go
> deadlock detector and traps, while a returning `main` exits and closes
> the module — either way the exports are never callable. The original
> blocking-main design was amended to reactor mode for this reason.
## Building & packing (`ninja plugin build`)
`ninja plugin build` turns a plugin repo into a `.bnp` artifact in one command —
no Docker/podman, just the local Go toolchain (**≥ 1.24**, enforced with a clear
error) plus wazero. It replaces the in-container `.so` compile and the old
`make build-so`. Steps:
1. Parse `plugin.mod` for `name`/`version` (both required).
2. `GOOS=wasip1 GOARCH=wasm go build -buildmode=c-shared -o plugin.wasm .`
(reactor mode).
3. Extract `manifest.pb`: instantiate `plugin.wasm` with wazero and drive one
`HOOK_DESCRIBE`. The host functions are **stubbed to fail** — DESCRIBE must
not need a live host, so a plugin that reaches a capability at
register/describe time (e.g. a `db.*` call from `Register`) gets an
actionable error **naming the offending method** instead of a hang.
4. Stamp `manifest.data_dir` from `plugin.mod` (see below).
5. Pack a `tar.zst`: `plugin.wasm`, `plugin.mod`, `manifest.pb`, plus
`migrations/`, `schemas/`, `assets/`, and `web/dist` (Module Federation
output, flattened under `web/`) when present.
```
ninja plugin build [--dir .] [-o <name>-<version>.bnp]
ninja plugin verify <file.bnp>
```
`ninja plugin verify` re-runs the CMS `.bnp` reader's checks standalone (layout,
required members, path-safety + size caps on extraction, `abi_version` support,
and manifest name == `plugin.mod` name) so CI and the registry can gate uploads.
These rules are a **deliberate duplication** of the reader
(`cms backend/plugin/bnp/reader.go`); core cannot import the CMS module, and
WO-WZ-010's integration suite keeps the two in lockstep.
### Makefile convention — `make build-wasm`
Plugin repos expose a `build-wasm` target (replacing `build-so`) that just calls
the CLI:
```make
.PHONY: build-wasm
build-wasm:
ninja plugin build
```
### `plugin.mod` `data_dir`
`plugin.mod` may set an optional first-class boolean:
```toml
[plugin]
name = "my-plugin"
version = "0.1.0"
data_dir = true # request a persistent per-plugin /data preopen at load
```
`data_dir` is a real field on the mod parser (not an arbitrary key) precisely so
the CLI's mod round-trip cannot silently drop it — `writeMod` reconstructs
`plugin.mod` from known struct fields only. `ninja plugin build` copies it into
`PluginManifest.data_dir`; OFF by default.
## Calling convention (ptr+len, packed u64)
wasm exports can only pass `i32/i64/f32/f64`, so all payloads cross as
protobuf bytes in guest linear memory. The guest exports (via
`go:wasmexport`):
| Export | Signature | Purpose |
|---|---|---|
| `bn_alloc` | `(size: u32) → ptr: u32` | Host asks the guest to allocate `size` bytes in guest memory. The returned region stays valid until the current `bn_invoke` call returns. |
| `bn_invoke` | `(hook_id: u32, ptr: u32, len: u32) → packed: u64` | Host writes a serialized `InvokeRequest` at `(ptr, len)` (memory from `bn_alloc`) and calls with `hook_id = Hook` enum value (duplicated in the envelope for decode sanity). The return packs the response location: `packed = (ptr << 32) | len`, framing a serialized `InvokeResponse` in guest memory, valid until the next `bn_invoke` on this instance. |
Host functions (guest→host capability calls) live in wasm import module
**`blockninja`** and use the same shape in reverse. There is exactly ONE
generic import rather than one symbol per capability family:
```
(blockninja) host_call(ptr: u32, len: u32) → packed: u64
```
The guest passes `(ptr, len)` framing a serialized `HostCallRequest` (whose
`method` string — `"<family>.<snake_method>"`, including the `db.*` driver
methods — already selects the family); the host returns a packed `u64`
framing a `HostCallResponse` that it wrote into guest memory via `bn_alloc`.
The guest shim releases that buffer after decoding, so the host must not
reuse it. Per-family import symbols were considered and rejected (decision,
WO-WZ-002): they would add ~40 declarations on both sides for zero type
safety, since the payloads are opaque protobuf bytes either way.
Buffers MUST come from `bn_alloc` on both paths — the guest rejects a
`bn_invoke` request pointer it did not hand out (`ABI_ERROR_CODE_DECODE`),
and treats an unknown host-call response pointer the same way.
A `packed` value of `0` means the callee could not even produce an envelope
(allocation failure / trap); the caller treats it as
`ABI_ERROR_CODE_INTERNAL` and discards the instance.
> A logically-empty response is **not** packed `0`. A successful hook whose
> response message has no set fields (e.g. `LoadResponse`/`UnloadResponse`)
> proto-marshals to zero bytes; the guest still frames it as `(ptr, 0)` with a
> real pointer so the host reads a valid empty envelope. `bn_invoke` never
> returns packed `0` for a successful call. (Fixed in WO-WZ-003: the earlier
> `len==0 → return 0` shortcut made every successful empty-response hook —
> notably `HOOK_LOAD` — look like an INTERNAL failure and discard the
> instance. The cms host in WO-WZ-006 must likewise not conflate a
> zero-length payload with a missing envelope.)
Instances are single-threaded: one `bn_invoke` at a time per instance;
concurrency comes from the per-plugin instance pool.
### Per-instance state: block pools & `HostServices`
Because concurrency is per-instance, guest state a render path depends on must be
established on **every** pooled instance, not just the one the host runs the
`HOOK_LOAD` hook on. `HOOK_LOAD` fires exactly once per plugin (on one acquired
instance); the deps-receiving entry points (`HTTPHandler`/`JobHandlers` init)
init lazily and only on instances that serve those hooks. A block render
(`HOOK_RENDER_BLOCK`) receives **no** services — only `ctx` + content — so a
DB-backed block cannot get its pool from Load.
The same rule governs the WO-WZ-019 callback hooks: AI tool handlers
(`ai.tools.register`) and bridge service values (`Bridge.RegisterService`)
are recorded per instance, and `HOOK_AI_TOOL_CALL` / `HOOK_BRIDGE_CALL` may
land on ANY pooled instance — so register them in `Register`, not `Load`.
The escape hatch is `wasmguest.HostServices()`: it returns the `CoreServices`
bound at `_initialize` on **every** instance (live `db.*` `Pool` + capability
stubs). A DB-backed block registers its pool from `HostServices().Pool` inside
`Register` (which `newGuest` runs on every instance, after the Pool is bound) —
see symposium's `register.go`. In native/DESCRIBE builds `HostServices()` returns
the zero value (Serve never ran), so callers nil-check `Pool`.
### Instance pool sizing & memory budget (WO-WZ-012)
Defaults (`wasmhost.DefaultConfig`, overridable via `WASM_POOL_MAX_SIZE` /
`WASM_MEMORY_LIMIT_MB`): **pool of 4 live instances per plugin, 512 MiB linear
memory cap per instance**, 30 s call deadline, 5 m idle TTL. The compiled module
(machine code + data segments) is compiled **once** per plugin and shared across
its pool; only each instance's linear memory + Go heap is per-instance.
Measured against the ported **symposium** plugin (45 MiB wasm — the fleet's
largest; representative public block mix = wiki index + course index + community
feed, one sqlc list query each over the `db.*` bridge, real wazero + Postgres):
| Metric | Value |
|---|---|
| Page render (3-block mix) p50 / p95 / p99 | **5.5 ms / 9.4 ms / 13.9 ms** |
| Per-block render (incl. DB round-trip) | ~1.83 ms |
| Process RSS, pool=1 (compile + 1 instance) | ~400500 MiB |
| Process RSS, pool=2 under concurrent load | ~540 MiB |
| Process RSS, pool=4 under concurrent load | ~628 MiB |
| Marginal cost per extra pooled instance | ~50130 MiB |
The ~400 MiB fixed cost is dominated by wazero compiling the 45 MiB module (once,
shared); marginal instances are cheap. Against the default orchestrator container
limit (`CONTAINER_MEMORY_MB` = **4096 MiB**), symposium at pool=4 (~628 MiB) plus
two smaller site-plugin pools + the CMS base fits comfortably. **Decision: keep
pool=4 / 512 MiB.** Memory-constrained deployments (≤1 GiB containers running the
largest plugins) should lower `WASM_POOL_MAX_SIZE` to 2.
Latency gate: the `.so` baseline for the identical block mix is unavailable on
`cms` main (the `.so` loader/builder was deleted in the big-bang cutover,
a04277ee2/da6177e1e), so a direct ≤25% p95 regression comparison cannot be run.
Absolute wasm numbers are recorded above; the wasm boundary overhead is small
relative to the per-block DB round-trip (~sub-ms of the ~3 ms), so a material
regression is not expected — recorded here for **explicit sign-off** per the
adjusted gate. Bench harness: `cms/backend/plugin/wasmintegration/symbench`.
## Hook catalog (`invoke.proto`)
Host→guest calls. `InvokeRequest{hook, payload, deadline_ms}`
`InvokeResponse{payload, error}`; `payload` holds the hook-specific message:
| Hook | Request / Response | Fires |
|---|---|---|
| `HOOK_RENDER_BLOCK` | `RenderBlockRequest` / `RenderBlockResponse` | Public render of one plugin block (`blocks.BlockFunc`). The response carries **either** final `html` **or** a `powered` `{template, data_json}` result — see [Powered blocks](#powered-blocks--render-as-a-host-capability). |
| `HOOK_RENDER_TEMPLATE` | `RenderTemplateRequest` / `RenderTemplateResponse` | Render of one plugin template (`templates.TemplateFunc`). |
| `HOOK_RENDER_TAG` | `RenderTagRequest` / `RenderTagResponse` | Run a plugin-declared template tag (`manifest.declared_tags`) the host engine hit while rendering a powered block (`blocks.RegisterTag`). Re-entrancy-free: the originating RENDER_BLOCK has returned. |
| `HOOK_APPLY_FILTER` | `ApplyFilterRequest` / `ApplyFilterResponse` | Apply a plugin-declared template filter (`manifest.declared_filters`, `blocks.RegisterFilter`). |
| `HOOK_HANDLE_HTTP` | `HttpRequest` / `HttpResponse` | Buffered HTTP/ConnectRPC request forwarded to the guest's internal mux (only when `manifest.has_http_handler`). No streaming/SSE/WebSocket in v1. |
| `HOOK_JOB` | `JobRequest` / `JobResponse` | Background job dispatch for a `manifest.job_types` entry (`plugin.JobHandlerFunc`). |
| `HOOK_LOAD` | `LoadRequest` / `LoadResponse` | Plugin load (`PluginRegistration.Load`); `LoadRequest.host_config` delivers `AppURL`/`MediaPath`. |
| `HOOK_UNLOAD` | `UnloadRequest` / `UnloadResponse` | Plugin unload (`PluginRegistration.Unload`). |
| `HOOK_RAG_FETCH` | `RagFetchRequest` / `RagFetchResponse` | RAG re-index callback for a `manifest.rag_content_fetcher_types` entry (`plugin.ContentFetcher`). |
| `HOOK_MEDIA_HOOK` | `MediaHookRequest` / `MediaHookResponse` | Media lifecycle event (`plugin.MediaHooksProvider`), only when `manifest.has_media_hooks`. |
| `HOOK_DESCRIBE` | `DescribeRequest` / `DescribeResponse` | Publish-time manifest capture; the result is stored as `manifest.pb` in the `.bnp` artifact. Never called on a live instance. |
| `HOOK_AI_TOOL_CALL` | `AiToolCallRequest` / `AiToolCallResponse` | Execute the guest handler of a tool registered via `ai.tools.register`. Register tools in `Register` (every pooled instance runs it), not `Load`. |
| `HOOK_BRIDGE_CALL` | `BridgeCallRequest` / `BridgeCallResponse` | Provider side of `bridge.invoke`: run a method on one of THIS plugin's registered bridge services (`plugin.BridgeInvokable`). Register services in `Register`. |
| `HOOK_DIRECTORY_PANEL_SECTION` | `DirectoryPanelSectionRequest` / `DirectoryPanelSectionResponse` | Render the index-th `DirectoryExtensions.PanelSections` callback. |
| `HOOK_DIRECTORY_PIN_DECORATOR` | `DirectoryPinDecoratorRequest` / `DirectoryPinDecoratorResponse` | Run the index-th pin decorator; the mutated pin map is returned. |
## Capability calls (`capability.proto`, `db.proto`)
Guest→host. Envelope: `HostCallRequest{method, payload}`
`HostCallResponse{payload, error}`. `method` is `"<family>.<snake_method>"`;
each pair mirrors one Go interface method from `CoreServices` 1:1
(UUIDs as canonical strings, `map[string]any`/JSON as bytes):
| Family | Methods | Go surface |
|---|---|---|
| `content` | `get_author_profile`, `get_page`, `get_post`, `list_posts`, `slugify`, `block_note_to_html`, `generate_excerpt`, `strip_html` — plus the WO-WZ-019 writes: `create_page`, `set_page_blocks`, `publish_page`, `set_page_seo`, `upsert_post` | `content.Content`, `content.Author` |
| `settings` | `get_site_settings`, `get_plugin_settings`, `update_site_setting`, `update_plugin_settings` (host-pinned to the caller's own plugin) | `settings.Settings`, `settings.Updater` |
| `gating` | `get_subscriber_tier_level`, `evaluate_access` | `gating.Gating` |
| `crypto` | `encrypt_secret`, `decrypt_secret` | `crypto.Crypto` |
| `menus` | `get_menu_by_name`, `get_menu_items` | `menus.Menus` |
| `datasources` | `resolve_bucket`, `resolve_bucket_by_key` | `datasources.Datasources` |
| `users` | `get_by_username`, `get_by_id` | `auth.PublicUsers` |
| `subscriptions` | `get_user_tier_level`, `get_tier_by_slug`, `list_tiers`, `list_active_plans` | `subscriptions.Subscriptions` |
| `media` | `deposit` | `plugin.Media` |
| `email` | `send` | `plugin.EmailSender` |
| `ai` | `text_call`, `tools.register` | `CoreServices.AITextCall`, `ai.ToolRegistry` |
| `bridge` | `register_service`, `get_service`, `invoke` (opaque-payload cross-plugin calls; provider answers via `HOOK_BRIDGE_CALL` or an in-process `plugin.BridgeInvokable`) | `plugin.PluginBridge` |
| `jobs` | `submit`, `progress` (correlated to the running job via the HOOK_JOB call context) | `plugin.JobRunner` + `JobHandlerFunc`'s progress callback |
| `provisioner` | `ensure_data_table`, `merge_site_settings`, `ensure_setting`, `ensure_page`, `override_site_settings`, `ensure_menu_item`, `register_embedding_config`, `ensure_embed`, `ensure_job_schedule`, `update_data_table_row_field`, `disable_orphaned_job_schedules`, `ensure_plugin`, `ensure_custom_color`, `ensure_media`**Load-time**: call `deps.Provisioner` from `Load`; `RegisterWithProvisioner` is inert under wasm (DESCRIBE stubs host functions). `EnsureEmbed` rejects `RenderFunc`-only embeds (functions cannot cross). | `plugin.Provisioner` |
| `embeddings` | `generate_embedding`, `embed_content`, `is_available` | `plugin.EmbeddingService` |
| `rag` | `query`, `on_content_changed` | `plugin.RAGService` |
| `reviews` | `submit_review` | `plugin.ReviewSubmitter` |
| `badges` | `refresh_badges` | `plugin.BadgeRefresher` |
| `db` | `query`, `exec`, `tx_begin`, `tx_commit`, `tx_rollback` | `CoreServices.Pool` via the guest `database/sql` driver (`db.proto`) |
The guest half of the SDK implements the existing Go interfaces as stubs
marshaling to these calls (`core/plugin/wasmguest/caps/`, WO-WZ-003), so
plugin code compiles unchanged. `caps.NewCoreServices(call)` assembles them;
the wasm shim binds `call` to the real `host_call` transport, tests inject a
fake, and a nil transport (DESCRIBE probes) fails every capability cleanly
instead of nil-panicking.
### Method disposition (every `CoreServices` member)
No silent gaps: each member is either a guest stub or served host-side.
| Member | Disposition |
|---|---|
| `Content` (8 methods) | **stub**`caps/content.go` |
| `ContentAuthor` (5 write methods, WO-WZ-019) | **stub**`caps/author.go` |
| `Provisioner` (14 methods, WO-WZ-019) | **stub**`caps/provisioner.go`; Load-time only (see the `provisioner` family row) |
| `Settings` / `SettingsUpdater` | **stub**`caps/settings.go` (one value, both fields) |
| `Gating` | **stub**`caps/gating.go`; `EvaluateAccess` crosses but falls back to the pure `gating.EvaluateAccess` on transport error |
| `Crypto` | **stub**`caps/crypto.go` |
| `Menus` | **stub**`caps/menus.go` |
| `Datasources` | **stub**`caps/datasources.go` |
| `PublicUsers` | **stub**`caps/users.go` |
| `Subscriptions` | **stub**`caps/subscriptions.go` |
| `Media` | **stub**`caps/media.go` |
| `ToolRegistry` + `AITextCall` | **stub**`caps/ai.go` (`ai.tools.register` + `ai.text_call`); the tool `Handler` stays guest-side, recorded per instance and executed via `HOOK_AI_TOOL_CALL` — register tools in `Register`, not `Load` |
| `EmailSender` | **stub**`caps/email.go` |
| `Bridge` | **stub**`caps/bridge.go`; `RegisterService` forwards the name AND records the value locally for `HOOK_BRIDGE_CALL` dispatch; `GetService` reports availability but returns `nil` (a typed value cannot cross — by design); cross-plugin calls use `Invoke` (`bridge.invoke`, opaque payloads) |
| `ReviewSubmitter` | **stub**`caps/reviews.go` |
| `BadgeRefresher` | **stub**`caps/badges.go` |
| `JobRunner` | **stub**`caps/jobs.go` |
| `EmbeddingService` | **stub**`caps/embeddings.go` |
| `RAGService` | **stub**`caps/rag.go`; `Query`/`OnContentChanged` cross, `RegisterContentFetcher` records guest-side for `HOOK_RAG_FETCH` |
| `Pool` | **host-side** — the `db.*` driver (db.proto), per-plugin Postgres role |
| `Interceptors` | **host-side** — the host builds the connect option chain; RBAC merges from `manifest.rbac_method_roles`. The caller's **verified** identity reaches the guest via **trusted identity headers** (see "Trusted identity headers" below) — never by decoding a client token. |
| `AppURL` / `MediaPath` | **host-side** — delivered once in `LoadRequest.host_config` |
| `CoreServiceBindings` | **host-side** — static `manifest.core_service_bindings`; the host constructs and mounts the `http.Handler` (cannot cross the sandbox), so `caps` provides no stub |
Interface satisfaction is proven at compile time by a `var _ <iface> =
(*stub)(nil)` line per family; a wasip1 build of `testdata/fixture` (whose
`Load` hook calls `deps.Content`/`deps.Settings`/`deps.Bridge` unchanged) plus
the `TestWasmFixtureCapabilityRoundTrip` end-to-end wazero test prove the path
crosses the ABI for real.
Error mapping (`caps/caps.go`): a transport `AbiError` surfaces as a Go error
wrapped with `<family>.<method>` context; an `ABI_ERROR_CODE_DEADLINE_EXCEEDED`
reply is mapped onto `context.DeadlineExceeded` so `errors.Is` keeps working.
Methods without an error channel (`Slugify`, `IsAvailable`, `EvaluateAccess`,
`ToolRegistry.Register`, `Bridge.*`, `RAG.OnContentChanged`, …) degrade to the
zero value / best-effort on transport failure.
`CoreServices` members that do **not** cross as capability calls:
- `Pool` → the `db.*` driver messages (`db.proto`); the host executes under
the per-plugin Postgres role. `DbError.code` carries the SQLSTATE.
Transactions: `tx_begin` returns an opaque `tx_handle` (never 0); `query`/
`exec` with `tx_handle = 0` run autocommit. Handles die with the call
chain's deadline so a guest can never pin a connection. **Guest side
(WO-WZ-004):** `core/plugin/wasmguest/bnwasm` implements this over the
transport as two surfaces — a `database/sql` driver registered as `"bnwasm"`,
and a `plugin.Pool` handing out a `pgx.Tx`-shaped value. The latter is the
primary path: current plugins' sqlc configs use `sql_package: "pgx/v5"`, so
their generated `DBTX` needs `pgconn.CommandTag`/`pgx.Rows`/`pgx.Row` (which
`database/sql` cannot produce), and the `Pool`/`Tx` satisfy it with no source
edits. `DbError` surfaces as `*pgconn.PgError` (SQLSTATE preserved for
`errors.As`). Named args (`pgx.NamedArgs`/`QueryRewriter`) and nested
transactions/savepoints are rejected with clear errors — no fleet plugin uses
either. The DbValue↔Go scan mapping is pinned in the exported
`bnwasm.DbValueFixtures` table, which the WO-WZ-007 host executor mirrors.
**`text[]` NULL-element limit:** `DbValue.text_array` (`abiv1.TextArray`) is a
repeated string with no per-element NULL, so a Postgres `text[]` like
`{a,NULL,b}` cannot round-trip — a NULL element collapses to `""`. The array as
a whole can still be SQL NULL (nil `[]string``DbValue_Null`); only a NULL
*inside* the array is unrepresentable. The host executor must honor this same
limit (encode a NULL element as `""` or reject it), not invent a sentinel.
**`uuid[]` (`DbValue.uuid_array_value`, `abiv1.UuidArray`):** a first-class
variant distinct from `text[]`, so a query keeps native `ANY($1::uuid[])`
(no `::text[]::uuid[]` cast workaround) and a `uuid[]` column scans straight
into `[]uuid.UUID`. Each element is a canonical UUID string (like the scalar
`uuid_value`); nil `[]uuid.UUID``DbValue_Null`, empty stays a non-NULL
empty `uuid[]`. The host binds a native `[]uuid.UUID` parameter and reads a
`UUIDArrayOID` column back into this variant. Pinned by the `uuid_array`
entry in `bnwasm.DbValueFixtures`.
- `Interceptors` (`connect.Option`) → host-side only; RBAC merges from
`manifest.rbac_method_roles`.
- `AppURL` / `MediaPath` → delivered once in `LoadRequest.host_config`.
- `CoreServiceBindings.Bind` → static `manifest.core_service_bindings`
declaration; the host constructs and mounts the handlers.
- `RAGService.RegisterContentFetcher` → static
`manifest.rag_content_fetcher_types` declaration + `HOOK_RAG_FETCH`
callback inversion.
## Trusted identity headers
Context values do **not** cross the ABI, so a guest cannot see the
`auth.Claims` / `auth.PublicClaims` the host's middleware built. A guest that
needs the caller's identity (its Connect RPCs call
`auth.GetUserFromContext` / `GetPublicUserFromContext`) reads it from
**host-set trusted headers**, and **only** from those.
**Trust model (host-enforced, guest-trusting):**
1. Upstream CMS auth middleware verifies the JWT signature and populates
`r.Context()` with the principal. The wasm mount's RBAC guard then runs
`rbac.Authorize` against that **verified** principal (deny-by-default,
live-user validator) *before* the request is forwarded.
2. When forwarding over `HOOK_HANDLE_HTTP`, the host **strips any
client-supplied copy** of the trusted headers from the request and **sets
them itself** from the verified context principal. A guest therefore trusts
them unconditionally — they are not attacker-controllable.
3. The guest reconstructs its context with `auth.TrustedHeaderMiddleware`
(wrap it around your `HTTPHandler`) or `auth.ContextFromTrustedHeaders`.
No token parsing, no signature check — the guest holds no signing secret by
design.
The headers (`core/auth/trustedheaders.go`, canonical MIME form):
| Header | Source |
|---|---|
| `X-Bn-Verified-User-Id` / `X-Bn-Verified-Role` / `X-Bn-Verified-Email` | admin `auth.Claims` |
| `X-Bn-Verified-Public-User-Id` / `X-Bn-Verified-Public-Username` / `X-Bn-Verified-Public-Email` | public `auth.PublicClaims` |
> **SECURITY — do NOT decode a client token in the guest.** The host forwards
> the raw request, so its `Cookie` / `Authorization` headers are
> attacker-controlled across the boundary. Decoding an unsigned `access_token`
> cookie (or Bearer JWT) as identity in the guest is a **privilege-escalation
> bug** (a verified public user can forge an admin JWT the guest would then
> honour on a `RolePublic` method). Identity comes from the trusted headers
> above and nowhere else.
## Error semantics
`AbiError{code, message}` travels in `InvokeResponse.error` and
`HostCallResponse.error`:
| Code | Meaning | Instance consequence |
|---|---|---|
| `ABI_ERROR_CODE_INTERNAL` | Handler ran and failed; `message` is the Go error text. | None (normal error). Guest traps / packed `0` returns are *treated as* INTERNAL by the host and **do** discard the instance. |
| `ABI_ERROR_CODE_DECODE` | Envelope or payload failed to decode. | Instance discarded (protocol desync). |
| `ABI_ERROR_CODE_UNIMPLEMENTED` | Callee does not implement the hook/capability (e.g. host too old for a new capability method). | None. |
| `ABI_ERROR_CODE_DEADLINE_EXCEEDED` | `deadline_ms` elapsed. | Instance considered poisoned, discarded. |
| `ABI_ERROR_CODE_PERMISSION_DENIED` | Caller not entitled to the capability. | None. |
| `ABI_ERROR_CODE_TX_EXPIRED` | A `db.*` call named a transaction handle the host already expired (dropped at the call-chain deadline, WO-WZ-007). | None — retryable. The guest maps it to `bnwasm.ErrTxExpired`; plugin code re-runs the unit of work in a fresh transaction. |
`ABI_ERROR_CODE_TX_EXPIRED` is emitted by the cms `dbexec` side (adopted
separately from this WO) in place of the earlier INTERNAL+message-marker
workaround, so guests can distinguish a retryable tx expiry from a real fault
via `errors.Is(err, bnwasm.ErrTxExpired)`.
Host-function errors surface to plugin code as ordinary Go errors via the
guest SDK. Repeated instance failures trip the existing
`PluginStatusFailed` path + admin notification. DB failures use `DbError`
(SQLSTATE-carrying) inside the `db.*` responses instead of `AbiError`, so
sqlc/pgx error handling keeps working.
## Manifest ↔ `PluginRegistration` mapping
`manifest.pb` (a serialized `PluginManifest`) is produced at publish time via
`HOOK_DESCRIBE` and read by the loader without instantiating the module.
Field-by-field:
| `PluginRegistration` field | Wire counterpart |
|---|---|
| `Name` | `PluginManifest.name` |
| `Version` | `PluginManifest.version` |
| `Dependencies` | `dependencies` (`Dependency`) |
| `Register` | Static effects captured by DESCRIBE: `blocks` (`BlockMeta`), `block_template_overrides`, `template_keys`, `system_templates`, `page_templates`, `email_wrapper_system_keys`, `declared_tags`, `declared_filters` |
| `blocks.RegisterTag` / `blocks.RegisterFilter` (called in `Register`) | `declared_tags` / `declared_filters` + `HOOK_RENDER_TAG` / `HOOK_APPLY_FILTER` |
| `RegisterWithProvisioner` | Same captures + `has_provisioner` (provisioning runs at load, host-side) |
| `Assets` | `.bnp` artifact `assets/` directory (host serves directly; never crosses the boundary) |
| `Schemas` | `.bnp` artifact `schemas/` directory (host loads into the block registry) |
| `SettingsSchema` | `settings_schema` (JSON bytes) |
| `ThemePresets` | `theme_presets` (JSON bytes) |
| `BundledFonts` | `bundled_fonts` (JSON bytes) |
| `MasterPages` | `master_pages` (`MasterPageDefinition`/`MasterPageBlock`) |
| `HTTPHandler` | `has_http_handler` + `HOOK_HANDLE_HTTP` |
| `SettingsPanel` | `settings_panel` |
| `AdminPages` | `admin_pages` (`AdminPage`) |
| `CSSManifest` | `css_manifest` (`CssManifest`) |
| `ServiceHandlers` | `rbac_method_roles` (method → role; the services themselves answer via `HOOK_HANDLE_HTTP`) + `core_service_bindings` |
| `JobHandlers` | `job_types` + `HOOK_JOB` |
| `AIActions` | `ai_actions` (`AiAction`) |
| `DirectoryExtensions` | `directory_extensions` (static fields + callback counts) |
| `MediaHooks` | `has_media_hooks` + `HOOK_MEDIA_HOOK` |
| `Load` | `has_load_hook` + `HOOK_LOAD` |
| `Unload` | `has_unload_hook` + `HOOK_UNLOAD` |
| `Migrations` | `.bnp` artifact `migrations/` directory (Goose runs host-side; never crosses) |
| `RequiredIconPacks` | `required_icon_packs` |
| *(none — `plugin.mod` `data_dir`)* | `data_dir` (bool). Not a `PluginRegistration` field: the grant lives in `plugin.mod`, which the guest code cannot see, so `ninja plugin build` stamps `PluginManifest.data_dir` from `plugin.mod` after DESCRIBE. The `.bnp` reader also reads it straight from `plugin.mod` as a fallback. |
## Render context
`RenderContext` (`render.proto`) is the explicit envelope of every value
blocks read from `ctx` today (`core/blocks/context.go`): request info,
`BlockContext` (the pongo2 data struct, 1:1), current page/post/author/
category/master-page, requested path, injected/expected slots, editor flag,
block + page IDs, human-proof banner, detail row, theme variables JSON, and
locale.
Function-valued context entries cannot serialize; their v1 mapping:
- `GetQueries` → the `db.*` driver (plugin sqlc code, per-plugin role).
- `SlotRenderer` / `MediaResolver` / `EmbedResolver`**open items** (below).
## Powered blocks — render as a host capability
pongo2/ninjatpl is a **host capability**: the template engine stays host-side
(cms) and is **never** compiled into a guest. A guest-reachable core package
(anything under `blocks/` or `plugin/wasmguest/`) MUST NOT import
`github.com/flosch/pongo2/*` — verified by `go list -deps` on the compiled
guest plugin showing zero `flosch/pongo2`. `core/templates/pongo` (the engine)
is core-resident but host-only; it is not in any guest's dependency graph.
Because the guest can't render a template itself, a template-backed block
**defers** rendering to the host. A `blocks.BlockFunc` returns EITHER:
- **plain HTML** — a non-template block returns its final string as always; or
- **a powered result**`blocks.PoweredBlock(template, data)`: the block
builds its data (via capabilities like `Content.ListPosts`) and hands the
host a `{template, data}` pair instead of final HTML.
`PoweredBlock` encodes the `{template, data}` behind a NUL-delimited sentinel
in the `BlockFunc`'s `string` return, so **`BlockFunc`'s signature is
unchanged** (the smallest additive change — no ripple to existing blocks or the
host guest-side). The guest's RENDER_BLOCK handler decodes the sentinel
(`blocks.DecodePoweredBlock`) and fills `RenderBlockResponse.powered`
(`PoweredBlock{template, data_json}`); a plain HTML block fills
`RenderBlockResponse.html` and leaves `powered` nil.
### Flow (re-entrancy-free by construction)
1. Host calls guest `HOOK_RENDER_BLOCK`. A template-backed block returns a
**powered** result `{template, data_json}`; a plain block returns `html`.
2. **After the block-invoke returns**, the host renders the powered `template`
with `data_json` using pongo2, host-side. The guest instance is now free.
3. When the host engine hits a plugin-declared tag (`{% mytag %}`) or filter
(`{{ x|myfilter }}`), it calls back into the *free* guest instance:
`HOOK_RENDER_TAG {tag_name, args_json, render_context} → {html, error}` or
`HOOK_APPLY_FILTER {filter_name, input, args_json} → {output, error}`. Each
callback is a **fresh** `bn_invoke` — never nested inside the still-running
RENDER_BLOCK — so there is no guest re-entrancy.
### Plugin API (`core/blocks`)
Registered in the plugin's `Register` func (alongside blocks), guest-safe (no
pongo2):
```go
// A block returns a powered result instead of final HTML:
func MyBlock(ctx context.Context, content map[string]any) string {
posts := loadPosts(ctx) // via deps/HostServices capabilities
return blocks.PoweredBlock("{% for p in posts %}<li>{{ p.title }}</li>{% endfor %}",
map[string]any{"posts": posts})
}
// Custom tag: args are the parsed tag arguments; rctx is the render context
// (blocks.RenderContext == context.Context — read state via blocks.Get*).
blocks.RegisterTag("mytag", func(args map[string]any, rctx blocks.RenderContext) (string, error) {
return "<span></span>", nil
})
// Custom filter: input is the piped value; args are the filter arguments.
blocks.RegisterFilter("myfilter", func(input string, args map[string]any) (string, error) {
return strings.ToUpper(input), nil
})
```
`RegisterTag`/`RegisterFilter` write a package-level registry (one plugin owns
a wasm module). The guest resets it at the start of each registration's
`Register` pass (`runRegister`), so DESCRIBE captures exactly this plugin's
names into `declared_tags`/`declared_filters` and HOOK dispatch resolves them
via `blocks.LookupTag`/`LookupFilter`. Register tags/filters **in `Register`**
(not package `init`), so DESCRIBE — which runs `Register` — sees them.
A tag/filter fn error surfaces in `RenderTagResponse.error` /
`ApplyFilterResponse.error` (a normal logic failure the host decides how to
render). `AbiError` stays reserved for transport/decode/panic failures; a panic
in a tag/filter recovers to `ABI_ERROR_CODE_INTERNAL` and the instance stays
callable, matching every other hook.
### Host-side contract (the cms phase implements)
The core half (this SDK) defines the wire + guest dispatch. The cms host must:
1. **Render powered blocks host-side.** After `HOOK_RENDER_BLOCK` returns, if
`RenderBlockResponse.powered` is set, render `powered.template` with
`powered.data_json` (JSON → `map[string]any`) through the existing pongo2
engine (`core/templates/pongo`) and use that as the block's HTML. If `html`
is set instead, use it directly. Do the pongo2 render **outside** the
guest-invoke call so the instance is free for callbacks.
2. **Wire per-plugin callback tags/filters.** At load, read
`manifest.declared_tags` / `manifest.declared_filters` and register, in the
pongo2 environment used to render that plugin's powered blocks, one tag per
declared name that invokes `HOOK_RENDER_TAG` (packing the tag args +
current `RenderContext`) and one filter per declared name that invokes
`HOOK_APPLY_FILTER`. Surface a non-empty response `error` as a render error.
3. **Rely on the re-entrancy guarantee.** Because step 1 renders only after the
block-invoke returned, the callbacks in step 2 are fresh invokes on a free
instance — no special re-entrancy handling is needed.
> Migration note: existing blocks that call `blocks.RenderTemplate` inside the
> block (host-side .so world) switch to returning `blocks.PoweredBlock` in the
> wasm world. This SDK ships the mechanism only; per-plugin migration (e.g.
> assumechaos) is a later phase.
## Open items
WO-WZ-019 closed the original set: AI tool execution (`HOOK_AI_TOOL_CALL`),
job progress (`jobs.progress`), cross-plugin bridge invocation
(`bridge.invoke` + `HOOK_BRIDGE_CALL` + `plugin.BridgeInvokable`), directory
extension callbacks (`HOOK_DIRECTORY_PANEL_SECTION` /
`HOOK_DIRECTORY_PIN_DECORATOR`), plugin provisioning (the `provisioner.*`
family), content authoring (`content.*` writes), and the plugin-own settings
write. The full capability→mechanism map lives in
[abi-capability-surface.md](abi-capability-surface.md).
Still open:
- **Slot/media/embed resolvers in render**: container-slot rendering and
media/embed resolution during a guest render need host functions (or host-
side pre-rendering into `RenderContext`). The powered-block path sidesteps
this — host-side rendering has the real resolvers.

16
go.mod
View File

@ -4,32 +4,20 @@ go 1.26.4
require ( require (
connectrpc.com/connect v1.20.0 connectrpc.com/connect v1.20.0
git.dev.alexdunmow.com/block/ninjatpl v1.0.2
github.com/BurntSushi/toml v1.6.0 github.com/BurntSushi/toml v1.6.0
github.com/a-h/templ v0.3.1020 github.com/a-h/templ v0.3.1020
github.com/chromedp/chromedp v0.15.1
github.com/flosch/pongo2/v6 v6.1.0
github.com/google/uuid v1.6.0 github.com/google/uuid v1.6.0
github.com/jackc/pgx/v5 v5.9.2 github.com/jackc/pgx/v5 v5.9.2
github.com/klauspost/compress v1.18.6
github.com/spf13/cobra v1.10.2
github.com/tetratelabs/wazero v1.12.0 github.com/tetratelabs/wazero v1.12.0
golang.org/x/crypto v0.48.0
golang.org/x/mod v0.34.0 golang.org/x/mod v0.34.0
google.golang.org/protobuf v1.36.11 google.golang.org/protobuf v1.36.11
gopkg.in/yaml.v3 v3.0.1
) )
require ( require (
github.com/chromedp/cdproto v0.0.0-20260321001828-e3e3800016bc // indirect
github.com/chromedp/sysutil v1.1.0 // indirect
github.com/go-json-experiment/json v0.0.0-20260214004413-d219187c3433 // indirect
github.com/gobwas/httphead v0.1.0 // indirect
github.com/gobwas/pool v0.2.1 // indirect
github.com/gobwas/ws v1.4.0 // indirect
github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/jackc/pgpassfile v1.0.0 // indirect github.com/jackc/pgpassfile v1.0.0 // indirect
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
github.com/rogpeppe/go-internal v1.15.0 // indirect
github.com/spf13/pflag v1.0.9 // indirect
golang.org/x/sys v0.44.0 // indirect golang.org/x/sys v0.44.0 // indirect
golang.org/x/text v0.36.0 // indirect golang.org/x/text v0.36.0 // indirect
) )

44
go.sum
View File

@ -1,35 +1,18 @@
connectrpc.com/connect v1.20.0 h1:6TNDAB+WeNd2uolWNlYczB5E0KNNaVMNUEx8JEUsPmQ= connectrpc.com/connect v1.20.0 h1:6TNDAB+WeNd2uolWNlYczB5E0KNNaVMNUEx8JEUsPmQ=
connectrpc.com/connect v1.20.0/go.mod h1:A2ygJrukXwWy32vkCAAHNVguZrqZ+jeZ9rGRnGR4dN4= connectrpc.com/connect v1.20.0/go.mod h1:A2ygJrukXwWy32vkCAAHNVguZrqZ+jeZ9rGRnGR4dN4=
git.dev.alexdunmow.com/block/ninjatpl v1.0.2 h1:KQS90HNW35PSzvwDZ3Z9OOwX3OSjX0x62w0AtOYps8s=
git.dev.alexdunmow.com/block/ninjatpl v1.0.2/go.mod h1:WrLz0KysnP5EzJlRCkU2VC1uvazyAXZR99Tn+3Mx1pw=
github.com/BurntSushi/toml v1.6.0 h1:dRaEfpa2VI55EwlIW72hMRHdWouJeRF7TPYhI+AUQjk= github.com/BurntSushi/toml v1.6.0 h1:dRaEfpa2VI55EwlIW72hMRHdWouJeRF7TPYhI+AUQjk=
github.com/BurntSushi/toml v1.6.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho= github.com/BurntSushi/toml v1.6.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho=
github.com/a-h/templ v0.3.1020 h1:ypAT/L5ySWEnZ6Zft/5yfoWXYYkhFNvEFOeeqecg4tw= github.com/a-h/templ v0.3.1020 h1:ypAT/L5ySWEnZ6Zft/5yfoWXYYkhFNvEFOeeqecg4tw=
github.com/a-h/templ v0.3.1020/go.mod h1:A2DlK61v+K+NRoGnhmYbNYVmtYHcFO5/AisMvBdDxTM= github.com/a-h/templ v0.3.1020/go.mod h1:A2DlK61v+K+NRoGnhmYbNYVmtYHcFO5/AisMvBdDxTM=
github.com/chromedp/cdproto v0.0.0-20260321001828-e3e3800016bc h1:wkN/LMi5vc60pBRWx6qpbk/aEvq3/ZVNpnMvsw8PVVU=
github.com/chromedp/cdproto v0.0.0-20260321001828-e3e3800016bc/go.mod h1:cbyjALe67vDvlvdiG9369P8w5U2w6IshwtyD2f2Tvag=
github.com/chromedp/chromedp v0.15.1 h1:EJWiPm7BNqDqjYy6U0lTSL5wNH+iNt9GjC3a4gfjNyQ=
github.com/chromedp/chromedp v0.15.1/go.mod h1:CdTHtUqD/dqaFw/cvFWtTydoEQS44wLBuwbMR9EkOY4=
github.com/chromedp/sysutil v1.1.0 h1:PUFNv5EcprjqXZD9nJb9b/c9ibAbxiYo4exNWZyipwM=
github.com/chromedp/sysutil v1.1.0/go.mod h1:WiThHUdltqCNKGc4gaU50XgYjwjYIhKWoHGPTUfWTJ8=
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/flosch/pongo2/v6 v6.1.0 h1:A/NJbrQJJD2B2mbpw3DRFwBYG0xpCr3vwFlEr46y1HQ=
github.com/flosch/pongo2/v6 v6.1.0/go.mod h1:CuDpFm47R0uGGE7z13/tTlt1Y6zdxvr2RLT5LJhsHEU=
github.com/go-json-experiment/json v0.0.0-20260214004413-d219187c3433 h1:vymEbVwYFP/L05h5TKQxvkXoKxNvTpjxYKdF1Nlwuao=
github.com/go-json-experiment/json v0.0.0-20260214004413-d219187c3433/go.mod h1:tphK2c80bpPhMOI4v6bIc2xWywPfbqi1Z06+RcrMkDg=
github.com/gobwas/httphead v0.1.0 h1:exrUm0f4YX0L7EBwZHuCF4GDp8aJfVeBrlLQrs6NqWU=
github.com/gobwas/httphead v0.1.0/go.mod h1:O/RXo79gxV8G+RqlR/otEwx4Q36zl9rqC5u12GKvMCM=
github.com/gobwas/pool v0.2.1 h1:xfeeEhW7pwmX8nuLVlqbzVc7udMDrwetjEv+TZIz1og=
github.com/gobwas/pool v0.2.1/go.mod h1:q8bcK0KcYlCgd9e7WYLm9LpyS+YeLd8JVDW6WezmKEw=
github.com/gobwas/ws v1.4.0 h1:CTaoG1tojrh4ucGPcoJFiAQUAsEWekEWvLy7GsVNqGs=
github.com/gobwas/ws v1.4.0/go.mod h1:G3gNqMNtPppf5XUz7O4shetPpcZ1VJ7zt18dlUeakrc=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo= github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
@ -38,25 +21,8 @@ github.com/jackc/pgx/v5 v5.9.2 h1:3ZhOzMWnR4yJ+RW1XImIPsD1aNSz4T4fyP7zlQb56hw=
github.com/jackc/pgx/v5 v5.9.2/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4= github.com/jackc/pgx/v5 v5.9.2/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4=
github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo= github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
github.com/klauspost/compress v1.18.6 h1:2jupLlAwFm95+YDR+NwD2MEfFO9d4z4Prjl1XXDjuao=
github.com/klauspost/compress v1.18.6/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ=
github.com/kr/pretty v0.3.0 h1:WgNl7dwNpEZ6jJ9k1snq4pZsg7DOEN8hP9Xw0Tsjwk0=
github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk=
github.com/kr/text v0.1.0 h1:45sCR5RtlFHMR4UwH9sdQ5TC8v0qDQCHnXt+kaKSTVE=
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
github.com/ledongthuc/pdf v0.0.0-20220302134840-0c2507a12d80 h1:6Yzfa6GP0rIo/kULo2bwGEkFvCePZ3qHDDTC3/J9Swo=
github.com/ledongthuc/pdf v0.0.0-20220302134840-0c2507a12d80/go.mod h1:imJHygn/1yfhB7XSJJKlFZKl/J+dCPAknuiaGOshXAs=
github.com/orisano/pixelmatch v0.0.0-20220722002657-fb0b55479cde h1:x0TT0RDC7UhAVbbWWBzr41ElhJx5tXPWkIHA2HWPRuw=
github.com/orisano/pixelmatch v0.0.0-20220722002657-fb0b55479cde/go.mod h1:nZgzbfBr3hhjoZnS66nKrHmduYNpc34ny7RK4z5/HM0=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/rogpeppe/go-internal v1.15.0 h1:D0RCU5rMAp+SpgkiNdrjfJ+LX4J1M32V2NeCY7EJ6hc=
github.com/rogpeppe/go-internal v1.15.0/go.mod h1:DrUVZyrJU+txYW5/1kwtXQSMFio52ZOxX7yM1VHvnxs=
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU=
github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4=
github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY=
github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
@ -64,12 +30,12 @@ github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/tetratelabs/wazero v1.12.0 h1:DuWcpNu/FzgEXgGBDp8J1Spc+CWOvvtvVyjKlaZopYU= github.com/tetratelabs/wazero v1.12.0 h1:DuWcpNu/FzgEXgGBDp8J1Spc+CWOvvtvVyjKlaZopYU=
github.com/tetratelabs/wazero v1.12.0/go.mod h1:LvKtzl2RqO4gyF27BiXU+nKAjcV8f38U+kP/q2vgxh0= github.com/tetratelabs/wazero v1.12.0/go.mod h1:LvKtzl2RqO4gyF27BiXU+nKAjcV8f38U+kP/q2vgxh0=
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts=
golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos=
golang.org/x/mod v0.34.0 h1:xIHgNUUnW6sYkcM5Jleh05DvLOtwc6RitGHbDk4akRI= golang.org/x/mod v0.34.0 h1:xIHgNUUnW6sYkcM5Jleh05DvLOtwc6RitGHbDk4akRI=
golang.org/x/mod v0.34.0/go.mod h1:ykgH52iCZe79kzLLMhyCUzhMci+nQj+0XkbXpNYtVjY= golang.org/x/mod v0.34.0/go.mod h1:ykgH52iCZe79kzLLMhyCUzhMci+nQj+0XkbXpNYtVjY=
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.44.0 h1:ildZl3J4uzeKP07r2F++Op7E9B29JRUy+a27EibtBTQ= golang.org/x/sys v0.44.0 h1:ildZl3J4uzeKP07r2F++Op7E9B29JRUy+a27EibtBTQ=
golang.org/x/sys v0.44.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/sys v0.44.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/text v0.36.0 h1:JfKh3XmcRPqZPKevfXVpI1wXPTqbkE5f7JA92a55Yxg= golang.org/x/text v0.36.0 h1:JfKh3XmcRPqZPKevfXVpI1wXPTqbkE5f7JA92a55Yxg=
@ -77,8 +43,6 @@ golang.org/x/text v0.36.0/go.mod h1:NIdBknypM8iqVmPiuco0Dh6P5Jcdk8lJL0CUebqK164=
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

File diff suppressed because it is too large Load Diff

2
proto

@ -1 +1 @@
Subproject commit 9c45bfd5e1da8bd55edcce8d79cbfbd79ca065d1 Subproject commit 1ca85e3bb64e83a5764637339ab90a86da96ca09

View File

@ -0,0 +1,11 @@
package bn
// VersionedAssetURL resolves an asset URL to a cache-versioned form. The
// default is the identity function; the CMS host wires it to
// internal/assets.VersionedURL at startup so plugin stylesheet links get
// content-hash ?v= params. Guest-side (wasm) renders keep the identity
// default — asset hashing is a host concern.
//
// This file is part of the cms→core template sync set (make sync-templates);
// it must stay SDK-clean (no cms imports).
var VersionedAssetURL = func(url string) string { return url }

View File

@ -1,6 +1,6 @@
// Code generated by templ - DO NOT EDIT. // Code generated by templ - DO NOT EDIT.
// templ: version: v0.3.1001 // templ: version: v0.3.1020
package bn package bn
//lint:file-ignore SA4006 This context is only used if a nested component is present. //lint:file-ignore SA4006 This context is only used if a nested component is present.
@ -60,11 +60,11 @@ func EngagementScript(config EngagementConfig) templ.Component {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
var templ_7745c5c3_Var2 string var templ_7745c5c3_Var2 string
templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.JoinStringErrs(engagementConfigJSON(config)) templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.ResolveAttributeValue(engagementConfigJSON(config))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `engagement.templ`, Line: 30, Col: 63} return templ.Error{Err: templ_7745c5c3_Err, FileName: `engagement.templ`, Line: 30, Col: 63}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var2)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var2)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }

View File

@ -16,22 +16,56 @@ func resolveMediaURL(url string) string {
return url return url
} }
// canonicalURL resolves the canonical link. An admin-set value wins (made absolute
// against the page's own origin when it is root-relative); otherwise the page's own
// absolute URL is used as a self-referencing canonical. Returns "" only when neither
// is available.
func canonicalURL(canonical, pageURL string) string {
if canonical == "" {
return pageURL
}
if strings.HasPrefix(canonical, "http://") || strings.HasPrefix(canonical, "https://") {
return canonical
}
if strings.HasPrefix(canonical, "/") && pageURL != "" {
if i := strings.Index(pageURL, "://"); i >= 0 {
if j := strings.IndexByte(pageURL[i+3:], '/'); j >= 0 {
return pageURL[:i+3+j] + canonical
}
return pageURL + canonical
}
}
return canonical
}
// BrandingData contains generated favicon/icon URLs // BrandingData contains generated favicon/icon URLs
type BrandingData struct { type BrandingData struct {
FaviconICO string // /brand/{id}/favicon.ico FaviconICO string // /.brand/{id}/favicon.ico
Favicon16 string // /brand/{id}/favicon-16x16.png Favicon16 string // /.brand/{id}/favicon-16x16.png
Favicon32 string // /brand/{id}/favicon-32x32.png Favicon32 string // /.brand/{id}/favicon-32x32.png
AppleTouchIcon string // /brand/{id}/apple-touch-icon.png (180x180) Favicon96 string // /.brand/{id}/favicon-96x96.png
Android192 string // /brand/{id}/android-chrome-192x192.png AppleTouchIcon string // /.brand/{id}/apple-touch-icon.png (180x180)
Android512 string // /brand/{id}/android-chrome-512x512.png Android192 string // /.brand/{id}/android-chrome-192x192.png
Maskable512 string // /brand/{id}/maskable-512x512.png Android512 string // /.brand/{id}/android-chrome-512x512.png
Master1024 string // /brand/{id}/icon-1024x1024.png Maskable512 string // /.brand/{id}/maskable-512x512.png
ManifestURL string // /brand/{id}/site.webmanifest Master1024 string // /.brand/{id}/icon-1024x1024.png
ManifestURL string // /.brand/{id}/site.webmanifest
MaskIcon string // /.brand/{id}/mask-icon.svg
MSTile150 string // /.brand/{id}/mstile-150x150.png
BrowserConfig string // /.brand/{id}/browserconfig.xml
ThemeColor string ThemeColor string
SVG string // Optional SVG pass-through SVG string // Optional SVG pass-through
IsGenerated bool IsGenerated bool
} }
// CustomScriptEntry represents a single custom script with placement control
type CustomScriptEntry struct {
Name string
Placement string // "head" or "body"
Code string
Enabled bool
}
// SiteSettingsData contains site-wide settings for head injection // SiteSettingsData contains site-wide settings for head injection
type SiteSettingsData struct { type SiteSettingsData struct {
Title string Title string
@ -41,8 +75,9 @@ type SiteSettingsData struct {
LogoAlt string LogoAlt string
AppleTouchIcon string AppleTouchIcon string
GoogleAnalyticsID string GoogleAnalyticsID string
CustomHeadScripts string CustomScripts []CustomScriptEntry
CustomBodyScripts string GoogleAnalyticsEnabled bool
GoogleAnalyticsExplicitlySet bool // true if the enabled flag was explicitly set in JSON (not nil)
MetaDescription string MetaDescription string
DefaultOGImage string DefaultOGImage string
TwitterHandle string TwitterHandle string
@ -51,7 +86,6 @@ type SiteSettingsData struct {
AdminBypassMode string // "maintenance", "coming_soon", or "" if not bypassing AdminBypassMode string // "maintenance", "coming_soon", or "" if not bypassing
Toolbar ToolbarData Toolbar ToolbarData
LLMsTxtEnabled bool // Whether llms.txt is enabled for AI content discovery LLMsTxtEnabled bool // Whether llms.txt is enabled for AI content discovery
TurnstileSiteKey string // Cloudflare Turnstile site key for bot protection
RSSFeedURL string // URL to the RSS feed (e.g., "/rss"), empty to disable RSSFeedURL string // URL to the RSS feed (e.g., "/rss"), empty to disable
RSSFeedTitle string // Feed title for discovery link RSSFeedTitle string // Feed title for discovery link
} }
@ -68,6 +102,10 @@ type PageMeta struct {
TwitterImage string // Twitter card image URL TwitterImage string // Twitter card image URL
CanonicalURL string // Canonical URL for this page CanonicalURL string // Canonical URL for this page
RobotsDirective string // Robots directive (e.g., "noindex, nofollow") RobotsDirective string // Robots directive (e.g., "noindex, nofollow")
OGType string // Open Graph type ("article" for posts, else "website")
PageURL string // Absolute URL of this page (og:url + auto-canonical)
ArticlePublishedTime string // ISO 8601 published time (articles only)
ArticleAuthor string // Author name (articles only)
} }
// HeadData contains all data needed to render the <head> element // HeadData contains all data needed to render the <head> element
@ -160,15 +198,75 @@ func ParseSiteSettings(doc map[string]any) SiteSettingsData {
} }
// Analytics // Analytics
var legacyHeadScripts, legacyBodyScripts string
if analyticsData, ok := siteData["analytics"].(map[string]any); ok { if analyticsData, ok := siteData["analytics"].(map[string]any); ok {
if v, ok := analyticsData["google_analytics_id"].(string); ok { if v, ok := analyticsData["google_analytics_id"].(string); ok {
settings.GoogleAnalyticsID = v settings.GoogleAnalyticsID = v
} }
// Read GA enabled flag — if present, use it; if absent but ID is set, default to enabled (backward compat)
if v, ok := analyticsData["google_analytics_enabled"].(bool); ok {
settings.GoogleAnalyticsEnabled = v
settings.GoogleAnalyticsExplicitlySet = true
} else if settings.GoogleAnalyticsID != "" {
settings.GoogleAnalyticsEnabled = true
}
// Preserve legacy flat fields for fallback
if v, ok := analyticsData["custom_head_scripts"].(string); ok { if v, ok := analyticsData["custom_head_scripts"].(string); ok {
settings.CustomHeadScripts = v legacyHeadScripts = v
} }
if v, ok := analyticsData["custom_body_scripts"].(string); ok { if v, ok := analyticsData["custom_body_scripts"].(string); ok {
settings.CustomBodyScripts = v legacyBodyScripts = v
}
}
// Structured custom scripts
if scriptsArr, ok := siteData["custom_scripts"].([]any); ok && len(scriptsArr) > 0 {
for _, item := range scriptsArr {
entry, ok := item.(map[string]any)
if !ok {
continue
}
cs := CustomScriptEntry{}
if v, ok := entry["name"].(string); ok {
cs.Name = v
}
// Placement: proto enum stored as float64 (1=head, 2=body) or string
switch p := entry["placement"].(type) {
case float64:
if p == 1 {
cs.Placement = "head"
} else if p == 2 {
cs.Placement = "body"
}
case string:
cs.Placement = p
}
if v, ok := entry["code"].(string); ok {
cs.Code = v
}
if v, ok := entry["enabled"].(bool); ok {
cs.Enabled = v
}
settings.CustomScripts = append(settings.CustomScripts, cs)
}
}
// Fallback: if no structured scripts, migrate old flat fields
if len(settings.CustomScripts) == 0 {
if legacyHeadScripts != "" {
settings.CustomScripts = append(settings.CustomScripts, CustomScriptEntry{
Name: "Legacy Head Scripts",
Placement: "head",
Code: legacyHeadScripts,
Enabled: true,
})
}
if legacyBodyScripts != "" {
settings.CustomScripts = append(settings.CustomScripts, CustomScriptEntry{
Name: "Legacy Body Scripts",
Placement: "body",
Code: legacyBodyScripts,
Enabled: true,
})
} }
} }
@ -210,6 +308,18 @@ func ParseSiteSettings(doc map[string]any) SiteSettingsData {
if v, ok := brandingData["svg"].(string); ok { if v, ok := brandingData["svg"].(string); ok {
settings.Branding.SVG = v settings.Branding.SVG = v
} }
if v, ok := brandingData["favicon_96"].(string); ok {
settings.Branding.Favicon96 = v
}
if v, ok := brandingData["mask_icon"].(string); ok {
settings.Branding.MaskIcon = v
}
if v, ok := brandingData["mstile_150"].(string); ok {
settings.Branding.MSTile150 = v
}
if v, ok := brandingData["browserconfig"].(string); ok {
settings.Branding.BrowserConfig = v
}
} }
// Admin bypass mode (for showing banner when admin bypasses maintenance/coming_soon) // Admin bypass mode (for showing banner when admin bypasses maintenance/coming_soon)
@ -229,16 +339,6 @@ func ParseSiteSettings(doc map[string]any) SiteSettingsData {
} }
} }
// Turnstile bot protection (from site settings)
if turnstileData, ok := siteData["turnstile"].(map[string]any); ok {
// Only set site key if Turnstile is enabled
if enabled, ok := turnstileData["enabled"].(bool); ok && enabled {
if v, ok := turnstileData["site_key"].(string); ok {
settings.TurnstileSiteKey = v
}
}
}
// RSS feed auto-discovery (injected by page handler from system page) // RSS feed auto-discovery (injected by page handler from system page)
if v, ok := siteData["rss_feed_url"].(string); ok { if v, ok := siteData["rss_feed_url"].(string); ok {
settings.RSSFeedURL = v settings.RSSFeedURL = v
@ -287,6 +387,18 @@ func ParsePageMeta(doc map[string]any) PageMeta {
if v, ok := doc["robotsDirective"].(string); ok { if v, ok := doc["robotsDirective"].(string); ok {
meta.RobotsDirective = v meta.RobotsDirective = v
} }
if v, ok := doc["ogType"].(string); ok {
meta.OGType = v
}
if v, ok := doc["pageUrl"].(string); ok {
meta.PageURL = v
}
if v, ok := doc["articlePublishedTime"].(string); ok {
meta.ArticlePublishedTime = v
}
if v, ok := doc["articleAuthor"].(string); ok {
meta.ArticleAuthor = v
}
return meta return meta
} }
@ -321,6 +433,12 @@ func ParseToolbarData(data map[string]any) ToolbarData {
if v, ok := data["preview_mode"].(string); ok { if v, ok := data["preview_mode"].(string); ok {
toolbar.PreviewMode = v toolbar.PreviewMode = v
} }
if v, ok := data["hide_preview_toggle"].(bool); ok {
toolbar.HidePreviewToggle = v
}
if v, ok := data["animate"].(bool); ok {
toolbar.Animate = v
}
if v, ok := data["position"].(string); ok { if v, ok := data["position"].(string); ok {
toolbar.Position = v toolbar.Position = v
} }
@ -401,15 +519,30 @@ templ Head(data HeadData) {
@themeInitScript(data.ThemeMode) @themeInitScript(data.ThemeMode)
<script src="https://unpkg.com/htmx.org@2.0.4"></script> <script src="https://unpkg.com/htmx.org@2.0.4"></script>
if data.Settings.Branding.IsGenerated { if data.Settings.Branding.IsGenerated {
// Use generated brand assets // Use generated brand assets (modern RFG-parity set; SVG first)
<link rel="icon" type="image/x-icon" href={ data.Settings.Branding.FaviconICO }/> if data.Settings.Branding.SVG != "" {
<link rel="icon" type="image/png" sizes="16x16" href={ data.Settings.Branding.Favicon16 }/> <link rel="icon" type="image/svg+xml" href={ data.Settings.Branding.SVG }/>
}
if data.Settings.Branding.Favicon96 != "" {
<link rel="icon" type="image/png" sizes="96x96" href={ data.Settings.Branding.Favicon96 }/>
}
<link rel="icon" type="image/png" sizes="32x32" href={ data.Settings.Branding.Favicon32 }/> <link rel="icon" type="image/png" sizes="32x32" href={ data.Settings.Branding.Favicon32 }/>
<link rel="icon" type="image/png" sizes="16x16" href={ data.Settings.Branding.Favicon16 }/>
<link rel="icon" type="image/x-icon" href={ data.Settings.Branding.FaviconICO }/>
<link rel="apple-touch-icon" sizes="180x180" href={ data.Settings.Branding.AppleTouchIcon }/> <link rel="apple-touch-icon" sizes="180x180" href={ data.Settings.Branding.AppleTouchIcon }/>
if data.Settings.Branding.MaskIcon != "" {
<link rel="mask-icon" href={ data.Settings.Branding.MaskIcon } color={ data.Settings.Branding.ThemeColor }/>
}
<link rel="manifest" href={ data.Settings.Branding.ManifestURL }/> <link rel="manifest" href={ data.Settings.Branding.ManifestURL }/>
if data.Settings.Branding.ThemeColor != "" { if data.Settings.Branding.ThemeColor != "" {
<meta name="theme-color" content={ data.Settings.Branding.ThemeColor }/> <meta name="theme-color" content={ data.Settings.Branding.ThemeColor }/>
} }
if data.Settings.Branding.BrowserConfig != "" {
<meta name="msapplication-config" content={ data.Settings.Branding.BrowserConfig }/>
}
if data.Title != "" {
<meta name="apple-mobile-web-app-title" content={ data.Title }/>
}
} else { } else {
// Legacy fallback - manual favicon uploads // Legacy fallback - manual favicon uploads
if data.Settings.Favicon != "" { if data.Settings.Favicon != "" {
@ -429,9 +562,9 @@ templ Head(data HeadData) {
<meta name="description" content={ data.Settings.MetaDescription }/> <meta name="description" content={ data.Settings.MetaDescription }/>
} }
// Canonical URL (page-level only, no site default) // Canonical URL: admin override (resolved to absolute) → auto self-canonical (M8)
if data.PageMeta.CanonicalURL != "" { if canonical := canonicalURL(data.PageMeta.CanonicalURL, data.PageMeta.PageURL); canonical != "" {
<link rel="canonical" href={ data.PageMeta.CanonicalURL }/> <link rel="canonical" href={ canonical }/>
} }
// Robots directive (page-level only - defaults to index,follow if not set) // Robots directive (page-level only - defaults to index,follow if not set)
@ -470,8 +603,25 @@ templ Head(data HeadData) {
<meta property="og:image" content={ resolveMediaURL(data.Settings.DefaultOGImage) }/> <meta property="og:image" content={ resolveMediaURL(data.Settings.DefaultOGImage) }/>
} }
// og:type - default to website // og:type: article for posts, website otherwise (M5)
if data.PageMeta.OGType != "" {
<meta property="og:type" content={ data.PageMeta.OGType }/>
} else {
<meta property="og:type" content="website"/> <meta property="og:type" content="website"/>
}
// og:url: absolute URL of this page (M6)
if data.PageMeta.PageURL != "" {
<meta property="og:url" content={ data.PageMeta.PageURL }/>
}
// article:* metadata for posts (M5)
if data.PageMeta.OGType == "article" {
if data.PageMeta.ArticlePublishedTime != "" {
<meta property="article:published_time" content={ data.PageMeta.ArticlePublishedTime }/>
}
if data.PageMeta.ArticleAuthor != "" {
<meta property="article:author" content={ data.PageMeta.ArticleAuthor }/>
}
}
// === TWITTER CARD META TAGS === // === TWITTER CARD META TAGS ===
if data.Settings.TwitterHandle != "" { if data.Settings.TwitterHandle != "" {
@ -523,7 +673,7 @@ templ Head(data HeadData) {
<link rel="alternate" type="application/atom+xml" title={ data.Settings.RSSFeedTitle + " (Atom)" } href={ data.Settings.RSSFeedURL + "/atom" }/> <link rel="alternate" type="application/atom+xml" title={ data.Settings.RSSFeedTitle + " (Atom)" } href={ data.Settings.RSSFeedURL + "/atom" }/>
} }
if data.Settings.GoogleAnalyticsID != "" { if data.Settings.GoogleAnalyticsID != "" && (data.Settings.GoogleAnalyticsEnabled || !data.Settings.GoogleAnalyticsExplicitlySet) {
<script async src={ "https://www.googletagmanager.com/gtag/js?id=" + data.Settings.GoogleAnalyticsID }></script> <script async src={ "https://www.googletagmanager.com/gtag/js?id=" + data.Settings.GoogleAnalyticsID }></script>
@googleAnalyticsScript(data.Settings.GoogleAnalyticsID) @googleAnalyticsScript(data.Settings.GoogleAnalyticsID)
} }
@ -532,18 +682,16 @@ templ Head(data HeadData) {
// Blog post engagement tracking (only rendered for posts with engagement config) // Blog post engagement tracking (only rendered for posts with engagement config)
@EngagementScript(data.EngagementConfig) @EngagementScript(data.EngagementConfig)
for _, style := range data.PluginStyles { for _, style := range data.PluginStyles {
<link rel="stylesheet" href={ style }/> <link rel="stylesheet" href={ VersionedAssetURL(style) }/>
} }
// Theme CSS injected AFTER plugin styles to take precedence // Theme CSS injected AFTER plugin styles to take precedence
if data.ThemeCSS != "" { if data.ThemeCSS != "" {
@themeStyle(data.ThemeCSS) @themeStyle(data.ThemeCSS)
} }
if data.Settings.CustomHeadScripts != "" { for _, script := range data.Settings.CustomScripts {
@templ.Raw(data.Settings.CustomHeadScripts) if script.Enabled && script.Placement == "head" {
@templ.Raw(script.Code)
} }
// Cloudflare Turnstile invisible bot protection
if data.Settings.TurnstileSiteKey != "" {
@turnstileScript(data.Settings.TurnstileSiteKey)
} }
if data.StructuredData != "" { if data.StructuredData != "" {
@structuredDataScript(data.StructuredData) @structuredDataScript(data.StructuredData)
@ -557,117 +705,6 @@ func structuredDataScript(jsonLD string) templ.Component {
return templ.Raw(`<script type="application/ld+json">` + jsonLD + `</script>`) return templ.Raw(`<script type="application/ld+json">` + jsonLD + `</script>`)
} }
// turnstileScript renders the Cloudflare Turnstile invisible bot protection
templ turnstileScript(siteKey string) {
<script src="https://challenges.cloudflare.com/turnstile/v0/api.js?render=explicit" async defer></script>
<script data-turnstile-key={ siteKey }>
(function(){
var siteKey = document.currentScript.getAttribute('data-turnstile-key');
if (!siteKey) return;
// Store active widget IDs per form
var formWidgets = new WeakMap();
// Initialize Turnstile when API is ready
function initTurnstile() {
if (typeof turnstile === 'undefined') {
setTimeout(initTurnstile, 100);
return;
}
// Find all forms that POST to /api/* endpoints
function instrumentForm(form) {
// Skip if already instrumented
if (formWidgets.has(form)) return;
var action = form.action || '';
var hxPost = form.getAttribute('hx-post') || '';
// Only protect /api/* POST endpoints
if (!action.includes('/api/') && !hxPost.includes('/api/')) return;
// Skip exempted endpoints
var exempted = ['/api/track', '/api/webhooks/'];
for (var i = 0; i < exempted.length; i++) {
if (action.includes(exempted[i]) || hxPost.includes(exempted[i])) return;
}
// Create container for widget
var container = document.createElement('div');
container.className = 'cf-turnstile-container';
container.style.cssText = 'position:absolute;left:-9999px;';
form.appendChild(container);
// Render invisible widget
var widgetId = turnstile.render(container, {
sitekey: siteKey,
size: 'invisible',
callback: function(token) {
// Store token in hidden field
var input = form.querySelector('input[name="cf-turnstile-response"]');
if (!input) {
input = document.createElement('input');
input.type = 'hidden';
input.name = 'cf-turnstile-response';
form.appendChild(input);
}
input.value = token;
}
});
formWidgets.set(form, widgetId);
}
// Instrument existing forms
document.querySelectorAll('form').forEach(instrumentForm);
// Watch for new forms (dynamic content / HTMX)
var observer = new MutationObserver(function(mutations) {
mutations.forEach(function(mutation) {
mutation.addedNodes.forEach(function(node) {
if (node.nodeName === 'FORM') {
instrumentForm(node);
}
if (node.querySelectorAll) {
node.querySelectorAll('form').forEach(instrumentForm);
}
});
});
});
observer.observe(document.body, { childList: true, subtree: true });
// HTMX integration: add token to request parameters
document.body.addEventListener('htmx:configRequest', function(evt) {
var form = evt.detail.elt.closest('form');
if (!form || !formWidgets.has(form)) return;
var widgetId = formWidgets.get(form);
var token = turnstile.getResponse(widgetId);
if (token) {
evt.detail.parameters['cf-turnstile-response'] = token;
}
});
// Reset widgets after HTMX swap
document.body.addEventListener('htmx:afterSwap', function(evt) {
if (evt.detail.target.querySelectorAll) {
evt.detail.target.querySelectorAll('form').forEach(instrumentForm);
}
});
}
// Start initialization
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', initTurnstile);
} else {
initTurnstile();
}
})();
</script>
}
// AdminBypassBanner renders a banner when admin is bypassing maintenance/coming_soon mode // AdminBypassBanner renders a banner when admin is bypassing maintenance/coming_soon mode
// This should be rendered at the very start of the <body> to push down all content // This should be rendered at the very start of the <body> to push down all content
templ AdminBypassBanner(settings SiteSettingsData) { templ AdminBypassBanner(settings SiteSettingsData) {
@ -696,8 +733,10 @@ templ AdminBypassBanner(settings SiteSettingsData) {
// BodyEnd renders custom scripts and admin toolbar before </body> // BodyEnd renders custom scripts and admin toolbar before </body>
templ BodyEnd(settings SiteSettingsData) { templ BodyEnd(settings SiteSettingsData) {
@recordValidationCall(ctx, "BodyEnd") @recordValidationCall(ctx, "BodyEnd")
if settings.CustomBodyScripts != "" { for _, script := range settings.CustomScripts {
@templ.Raw(settings.CustomBodyScripts) if script.Enabled && script.Placement == "body" {
@templ.Raw(script.Code)
}
} }
// Render admin editor toolbar if enabled (auto-injects for all templates) // Render admin editor toolbar if enabled (auto-injects for all templates)
@AdminEditorToolbar(settings.Toolbar) @AdminEditorToolbar(settings.Toolbar)
@ -901,15 +940,22 @@ func themeInitScript(themeMode string) templ.Component {
default: default:
themeMode = "light" themeMode = "light"
} }
// Precedence: admin toolbar override (per-tab, sessionStorage) → visitor
// preference (bn-theme cookie/localStorage) → site default → system.
// Exposed as window.bnApplyTheme so the toolbar theme tester can re-apply
// after changing the override without duplicating this resolution.
return templ.Raw(`<script> return templ.Raw(`<script>
(function(){ window.bnApplyTheme=function(){
var t=null;try{t=sessionStorage.getItem('bn-theme-override')}catch(e){}
if(!t){
var c=document.cookie.match(/(?:^|; )bn-theme=([^;]*)/); var c=document.cookie.match(/(?:^|; )bn-theme=([^;]*)/);
var t=c?c[1]:localStorage.getItem('bn-theme'); t=c?c[1]:localStorage.getItem('bn-theme');
}
if(!t)t='` + themeMode + `'; if(!t)t='` + themeMode + `';
var h=document.documentElement;
if(t==='system')t=window.matchMedia('(prefers-color-scheme:dark)').matches?'dark':'light'; if(t==='system')t=window.matchMedia('(prefers-color-scheme:dark)').matches?'dark':'light';
if(t==='dark')h.classList.add('dark'); document.documentElement.classList.toggle('dark',t==='dark');
else h.classList.remove('dark'); return t;
})(); };
window.bnApplyTheme();
</script>`) </script>`)
} }

File diff suppressed because one or more lines are too long

View File

@ -17,6 +17,8 @@ type ToolbarData struct {
Status string // "published", "draft", "scheduled" Status string // "published", "draft", "scheduled"
HasUnpublishedChanges bool HasUnpublishedChanges bool
PreviewMode string // "published" or "draft" PreviewMode string // "published" or "draft"
HidePreviewToggle bool
Animate bool // true only on initial full-page render (entrance animation); false on HTMX re-renders
Position string // "tl", "tc", "tr", "bl", "bc", "br" (default: "tr") Position string // "tl", "tc", "tr", "bl", "bc", "br" (default: "tr")
ScheduledAt *time.Time ScheduledAt *time.Time
TemplateName string TemplateName string
@ -135,6 +137,20 @@ func (t ToolbarData) DropdownAlign() string {
return "right" return "right"
} }
// EnterAnimationClass returns the entrance-animation classes for the initial
// full-page render. It slides the pill in from the edge it rests against (up for
// bottom positions, down for top) then gives a brief pulse. Empty on HTMX
// re-renders (Animate=false) so publish/discard/reposition swaps appear instantly.
func (t ToolbarData) EnterAnimationClass() string {
if !t.Animate {
return ""
}
if t.IsBottomPosition() {
return "bn-toolbar-enter bn-toolbar-enter-up"
}
return "bn-toolbar-enter bn-toolbar-enter-down"
}
// PositionClasses returns the positioning classes for the floating pill // PositionClasses returns the positioning classes for the floating pill
func (t ToolbarData) PositionClasses() string { func (t ToolbarData) PositionClasses() string {
switch t.Position { switch t.Position {
@ -188,7 +204,7 @@ templ AdminEditorToolbar(data ToolbarData) {
if data.Enabled { if data.Enabled {
<div <div
id="bn-admin-toolbar" id="bn-admin-toolbar"
class={ "bn-toolbar fixed z-[9999] flex items-center gap-2 px-3 py-2 text-sm font-medium rounded-full backdrop-blur-md transition-all duration-300", data.PositionClasses() } class={ "bn-toolbar fixed z-[9999] flex items-center gap-2 px-3 py-2 text-sm font-medium rounded-full backdrop-blur-md transition-all duration-300", data.PositionClasses(), data.EnterAnimationClass() }
> >
<style> <style>
/* Toolbar: dark by default (for light pages), light when page has .dark class */ /* Toolbar: dark by default (for light pages), light when page has .dark class */
@ -205,6 +221,33 @@ templ AdminEditorToolbar(data ToolbarData) {
border: 1px solid var(--bn-toolbar-border); border: 1px solid var(--bn-toolbar-border);
box-shadow: 0 10px 25px -5px rgba(0, 0, 0, 0.25), 0 8px 10px -6px rgba(0, 0, 0, 0.2); box-shadow: 0 10px 25px -5px rgba(0, 0, 0, 0.25), 0 8px 10px -6px rgba(0, 0, 0, 0.2);
} }
/* Entrance: slide in from the resting edge, then a brief pulse.
Animates `transform` only; Tailwind v4 centres via the separate
`translate` property, so `-translate-x-1/2` is preserved. Runs
~1s after render, 500ms total, initial page load only. */
@keyframes bn-toolbar-slide-up {
0% { opacity: 0; transform: translateY(1.25rem); }
60% { opacity: 1; transform: translateY(0) scale(1); }
80% { transform: translateY(0) scale(1.05); }
100% { opacity: 1; transform: translateY(0) scale(1); }
}
@keyframes bn-toolbar-slide-down {
0% { opacity: 0; transform: translateY(-1.25rem); }
60% { opacity: 1; transform: translateY(0) scale(1); }
80% { transform: translateY(0) scale(1.05); }
100% { opacity: 1; transform: translateY(0) scale(1); }
}
.bn-toolbar-enter {
animation-duration: 500ms;
animation-delay: 900ms;
animation-timing-function: cubic-bezier(0.22, 1, 0.36, 1);
animation-fill-mode: both;
}
.bn-toolbar-enter-up { animation-name: bn-toolbar-slide-up; }
.bn-toolbar-enter-down { animation-name: bn-toolbar-slide-down; }
@media (prefers-reduced-motion: reduce) {
.bn-toolbar-enter { animation: none; }
}
/* Light toolbar when page theme is dark (.dark class on html or body) */ /* Light toolbar when page theme is dark (.dark class on html or body) */
.dark .bn-toolbar, html.dark .bn-toolbar, body.dark .bn-toolbar { .dark .bn-toolbar, html.dark .bn-toolbar, body.dark .bn-toolbar {
--bn-toolbar-bg: rgba(250, 250, 250, 0.95); --bn-toolbar-bg: rgba(250, 250, 250, 0.95);
@ -225,6 +268,13 @@ templ AdminEditorToolbar(data ToolbarData) {
.bn-toolbar .bn-toolbar-primary:hover { .bn-toolbar .bn-toolbar-primary:hover {
background: color-mix(in srgb, var(--bn-toolbar-primary-bg) 90%, black); background: color-mix(in srgb, var(--bn-toolbar-primary-bg) 90%, black);
} }
/* Theme tester: highlighted while a light/dark override is forced.
Uses toolbar vars (not site theme tokens) so it renders on any site. */
.bn-toolbar .bn-theme-tester-active,
.bn-toolbar .bn-theme-tester-active:hover {
background: color-mix(in srgb, var(--bn-toolbar-primary-bg) 20%, transparent);
color: var(--bn-toolbar-primary-bg);
}
/* Dropdown styling - inherits toolbar vars */ /* Dropdown styling - inherits toolbar vars */
.bn-toolbar-dropdown { .bn-toolbar-dropdown {
background: var(--bn-toolbar-bg); background: var(--bn-toolbar-bg);
@ -262,6 +312,7 @@ templ AdminEditorToolbar(data ToolbarData) {
<span class={ "w-2 h-2 rounded-full", templ.KV("bg-success", data.Status == "published"), templ.KV("bg-warning", data.Status == "draft"), templ.KV("bg-info", data.Status == "scheduled"), templ.KV("animate-pulse ring-2 ring-warning/50", data.HasUnpublishedChanges) }></span> <span class={ "w-2 h-2 rounded-full", templ.KV("bg-success", data.Status == "published"), templ.KV("bg-warning", data.Status == "draft"), templ.KV("bg-info", data.Status == "scheduled"), templ.KV("animate-pulse ring-2 ring-warning/50", data.HasUnpublishedChanges) }></span>
<span class="bn-toolbar-muted text-xs hidden sm:inline">{ data.StatusLabel() }</span> <span class="bn-toolbar-muted text-xs hidden sm:inline">{ data.StatusLabel() }</span>
</div> </div>
if !data.HidePreviewToggle {
<!-- Divider --> <!-- Divider -->
<div class="bn-toolbar-divider w-px h-5"></div> <div class="bn-toolbar-divider w-px h-5"></div>
<!-- Preview toggle - compact --> <!-- Preview toggle - compact -->
@ -284,6 +335,97 @@ templ AdminEditorToolbar(data ToolbarData) {
<span class="hidden sm:inline">Live</span> <span class="hidden sm:inline">Live</span>
} }
</button> </button>
}
<!-- Divider -->
<div class="bn-toolbar-divider w-px h-5"></div>
<!-- Theme tester: cycles site default → forced light → forced dark.
Per-tab override (sessionStorage), applied by bnApplyTheme in head.templ.
Never touches the visitor bn-theme preference. -->
<button
type="button"
id="bn-theme-tester"
class="bn-toolbar-hover bn-toolbar-muted inline-flex items-center gap-1 px-2 py-1 rounded-full text-xs transition-colors"
onclick="bnCycleThemeTester()"
title="Theme: site default — click to force light"
>
<span data-tt="auto">
<svg xmlns="http://www.w3.org/2000/svg" class="h-3.5 w-3.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<rect x="2" y="3" width="20" height="14" rx="2" ry="2"></rect>
<line x1="8" y1="21" x2="16" y2="21"></line>
<line x1="12" y1="17" x2="12" y2="21"></line>
</svg>
</span>
<span data-tt="light" style="display:none">
<svg xmlns="http://www.w3.org/2000/svg" class="h-3.5 w-3.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<circle cx="12" cy="12" r="5"></circle>
<line x1="12" y1="1" x2="12" y2="3"></line>
<line x1="12" y1="21" x2="12" y2="23"></line>
<line x1="4.22" y1="4.22" x2="5.64" y2="5.64"></line>
<line x1="18.36" y1="18.36" x2="19.78" y2="19.78"></line>
<line x1="1" y1="12" x2="3" y2="12"></line>
<line x1="21" y1="12" x2="23" y2="12"></line>
<line x1="4.22" y1="19.78" x2="5.64" y2="18.36"></line>
<line x1="18.36" y1="5.64" x2="19.78" y2="4.22"></line>
</svg>
</span>
<span data-tt="dark" style="display:none">
<svg xmlns="http://www.w3.org/2000/svg" class="h-3.5 w-3.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"></path>
</svg>
</span>
<span data-tt-label class="hidden sm:inline" style="display:none"></span>
</button>
<script>
// Theme tester: per-tab light/dark override for admins. Lives inside
// #bn-admin-toolbar so HTMX outerHTML swaps re-run the sync call.
window.bnCycleThemeTester = function() {
var cur = null;
try { cur = sessionStorage.getItem('bn-theme-override'); } catch (e) {}
var next = cur === 'light' ? 'dark' : (cur === 'dark' ? null : 'light');
try {
if (next) sessionStorage.setItem('bn-theme-override', next);
else sessionStorage.removeItem('bn-theme-override');
} catch (e) {}
if (window.bnApplyTheme) {
window.bnApplyTheme();
} else if (next) {
// Fallback for templates without the bn head script
document.documentElement.classList.toggle('dark', next === 'dark');
}
window.bnSyncThemeTester();
// Let darkmode-switcher blocks refresh their icons
var mode = document.documentElement.classList.contains('dark') ? 'dark' : 'light';
document.querySelectorAll('[data-bn-theme-toggle]').forEach(function(el) {
el.dispatchEvent(new CustomEvent('bn:theme-changed', { detail: { mode: mode } }));
});
};
window.bnSyncThemeTester = function() {
var btn = document.getElementById('bn-theme-tester');
if (!btn) return;
var cur = null;
try { cur = sessionStorage.getItem('bn-theme-override'); } catch (e) {}
var state = (cur === 'light' || cur === 'dark') ? cur : 'auto';
btn.querySelectorAll('[data-tt]').forEach(function(s) {
s.style.display = s.getAttribute('data-tt') === state ? '' : 'none';
});
var lbl = btn.querySelector('[data-tt-label]');
if (state === 'auto') {
lbl.style.display = 'none';
lbl.textContent = '';
} else {
lbl.style.display = '';
lbl.textContent = state === 'light' ? 'Light' : 'Dark';
}
btn.classList.toggle('bn-theme-tester-active', state !== 'auto');
btn.classList.toggle('bn-toolbar-muted', state === 'auto');
btn.title = state === 'auto'
? 'Theme: site default — click to force light'
: (state === 'light'
? 'Theme: forced light — click to force dark'
: 'Theme: forced dark — click to reset to site default');
};
window.bnSyncThemeTester();
</script>
<!-- Blog-specific: Reading time --> <!-- Blog-specific: Reading time -->
if data.IsBlogPost() && data.ReadingTime > 0 { if data.IsBlogPost() && data.ReadingTime > 0 {
<div class="bn-toolbar-muted hidden md:flex items-center gap-1 px-2 text-xs" title="Reading time"> <div class="bn-toolbar-muted hidden md:flex items-center gap-1 px-2 text-xs" title="Reading time">
@ -347,6 +489,21 @@ templ AdminEditorToolbar(data ToolbarData) {
// PageInfoDropdown renders the page info and quick actions dropdown // PageInfoDropdown renders the page info and quick actions dropdown
templ PageInfoDropdown(data ToolbarData) { templ PageInfoDropdown(data ToolbarData) {
<div class="bn-toolbar-dropdown rounded-lg py-2 min-w-[260px] max-w-[320px]"> <div class="bn-toolbar-dropdown rounded-lg py-2 min-w-[260px] max-w-[320px]">
<style>
/* Position-picker chips - shipped with the fragment so styling never
desyncs from the separately-rendered toolbar shell. Uses toolbar
vars (resolve via the enclosing .bn-toolbar) to stay visible in
both inverted themes. */
.bn-toolbar-dropdown .bn-dd-poschip { border-color: var(--bn-toolbar-border); }
.bn-toolbar-dropdown .bn-dd-poschip:hover { background: var(--bn-toolbar-hover); }
.bn-toolbar-dropdown .bn-dd-poschip-active,
.bn-toolbar-dropdown .bn-dd-poschip-active:hover {
background: var(--bn-toolbar-primary-bg);
border-color: var(--bn-toolbar-primary-bg);
}
.bn-toolbar-dropdown .bn-dd-dot { background: var(--bn-toolbar-muted); }
.bn-toolbar-dropdown .bn-dd-dot-active { background: var(--bn-toolbar-primary-fg); }
</style>
<!-- Analytics snapshot --> <!-- Analytics snapshot -->
if data.TodayPageviews > 0 || data.PageviewsTrend != "" { if data.TodayPageviews > 0 || data.PageviewsTrend != "" {
<div class="bn-dd-border px-3 py-2 border-b"> <div class="bn-dd-border px-3 py-2 border-b">
@ -482,15 +639,16 @@ templ PageInfoDropdown(data ToolbarData) {
// positionButton renders a position selector button // positionButton renders a position selector button
templ positionButton(pageID uuid.UUID, pos string, currentPos string, label string) { templ positionButton(pageID uuid.UUID, pos string, currentPos string, label string) {
{{ active := pos == currentPos || (currentPos == "" && pos == "tr") }}
<button <button
type="button" type="button"
class={ "w-8 h-6 rounded border transition-colors flex items-center justify-center", templ.KV("bg-info border-info", pos == currentPos || (currentPos == "" && pos == "tr")), templ.KV("bn-dd-border bn-dd-hover", pos != currentPos && !(currentPos == "" && pos == "tr")) } class={ "bn-dd-poschip w-8 h-6 rounded border transition-colors flex items-center justify-center", templ.KV("bn-dd-poschip-active", active) }
hx-post={ fmt.Sprintf("/toolbar/set-position/%s?pos=%s", pageID, pos) } hx-post={ fmt.Sprintf("/toolbar/set-position/%s?pos=%s", pageID, pos) }
hx-target="#bn-admin-toolbar" hx-target="#bn-admin-toolbar"
hx-swap="outerHTML" hx-swap="outerHTML"
title={ label } title={ label }
> >
<span class={ "w-1.5 h-1.5 rounded-full", templ.KV("bg-info-foreground", pos == currentPos || (currentPos == "" && pos == "tr")), templ.KV("bn-dd-muted", pos != currentPos && !(currentPos == "" && pos == "tr")) }></span> <span class={ "w-1.5 h-1.5 rounded-full", templ.KV("bn-dd-dot-active", active), templ.KV("bn-dd-dot", !active) }></span>
</button> </button>
} }

File diff suppressed because one or more lines are too long

View File

@ -4,15 +4,15 @@ import (
"context" "context"
"maps" "maps"
"github.com/flosch/pongo2/v6" "git.dev.alexdunmow.com/block/ninjatpl"
"git.dev.alexdunmow.com/block/core/blocks" "git.dev.alexdunmow.com/block/core/blocks"
"git.dev.alexdunmow.com/block/core/templates/bn" "git.dev.alexdunmow.com/block/core/templates/bn"
) )
// buildPageContext builds a pongo2.Context with pre-rendered head/body HTML // buildPageContext builds a ninjatpl.Context with pre-rendered head/body HTML
// and all page-level variables from the standard doc map. // and all page-level variables from the standard doc map.
func (e *Engine) buildPageContext(ctx context.Context, doc map[string]any) pongo2.Context { func (e *Engine) buildPageContext(ctx context.Context, doc map[string]any) ninjatpl.Context {
title := "Untitled" title := "Untitled"
if t, ok := doc["title"].(string); ok && t != "" { if t, ok := doc["title"].(string); ok && t != "" {
title = t title = t
@ -72,7 +72,7 @@ func (e *Engine) buildPageContext(ctx context.Context, doc map[string]any) pongo
slotsAny[k] = v slotsAny[k] = v
} }
return pongo2.Context{ return ninjatpl.Context{
"head_html": headHTML, "head_html": headHTML,
"body_end_html": bodyEndHTML, "body_end_html": bodyEndHTML,
"admin_banner_html": bannerHTML, "admin_banner_html": bannerHTML,
@ -88,10 +88,10 @@ func (e *Engine) buildPageContext(ctx context.Context, doc map[string]any) pongo
} }
} }
// buildBlockContext builds a pongo2.Context for block rendering. // buildBlockContext builds a ninjatpl.Context for block rendering.
// Content fields are available directly; request context is under "ctx". // Content fields are available directly; request context is under "ctx".
func buildBlockContext(ctx context.Context, content map[string]any) pongo2.Context { func buildBlockContext(ctx context.Context, content map[string]any) ninjatpl.Context {
pongoCtx := make(pongo2.Context, len(content)+1) pongoCtx := make(ninjatpl.Context, len(content)+1)
maps.Copy(pongoCtx, content) maps.Copy(pongoCtx, content)
if bc := blocks.GetBlockContext(ctx); bc != nil { if bc := blocks.GetBlockContext(ctx); bc != nil {
pongoCtx["ctx"] = bc.ToMap() pongoCtx["ctx"] = bc.ToMap()

View File

@ -7,7 +7,7 @@ import (
"io/fs" "io/fs"
"maps" "maps"
"github.com/flosch/pongo2/v6" "git.dev.alexdunmow.com/block/ninjatpl"
"git.dev.alexdunmow.com/block/core/blocks" "git.dev.alexdunmow.com/block/core/blocks"
"git.dev.alexdunmow.com/block/core/templates" "git.dev.alexdunmow.com/block/core/templates"
@ -18,7 +18,7 @@ import (
// MustPageTemplate / MustBlockTemplate to get functions compatible with // MustPageTemplate / MustBlockTemplate to get functions compatible with
// the template and block registries. // the template and block registries.
type Engine struct { type Engine struct {
set *pongo2.TemplateSet set *ninjatpl.TemplateSet
stylePaths []string stylePaths []string
} }
@ -27,19 +27,19 @@ type Engine struct {
// stylePaths are CSS URLs included in the page <head> via bn.Head. // stylePaths are CSS URLs included in the page <head> via bn.Head.
func NewEngine(pluginFS fs.FS, stylePaths ...string) *Engine { func NewEngine(pluginFS fs.FS, stylePaths ...string) *Engine {
loader := &multiLoader{ loader := &multiLoader{
loaders: []pongo2.TemplateLoader{ loaders: []ninjatpl.TemplateLoader{
&fsLoader{fsys: pluginFS}, &fsLoader{fsys: pluginFS},
&fsLoader{fsys: baseFS}, &fsLoader{fsys: baseFS},
}, },
} }
set := pongo2.NewSet("plugin", loader) set := ninjatpl.NewSet("plugin", loader)
return &Engine{set: set, stylePaths: stylePaths} return &Engine{set: set, stylePaths: stylePaths}
} }
// pongoComponent wraps a pongo2 template execution as an HTMLComponent. // pongoComponent wraps a pongo2 template execution as an HTMLComponent.
type pongoComponent struct { type pongoComponent struct {
tpl *pongo2.Template tpl *ninjatpl.Template
ctx pongo2.Context ctx ninjatpl.Context
} }
func (c *pongoComponent) Render(ctx context.Context, w io.Writer) error { func (c *pongoComponent) Render(ctx context.Context, w io.Writer) error {
@ -62,7 +62,7 @@ func (c *pongoComponent) Render(ctx context.Context, w io.Writer) error {
// {{ site_settings }} — bn.SiteSettingsData struct // {{ site_settings }} — bn.SiteSettingsData struct
// {{ page_meta }} — bn.PageMeta struct // {{ page_meta }} — bn.PageMeta struct
func (e *Engine) MustPageTemplate(name string) templates.TemplateFunc { func (e *Engine) MustPageTemplate(name string) templates.TemplateFunc {
tpl := pongo2.Must(e.set.FromFile(name)) tpl := ninjatpl.Must(e.set.FromFile(name))
return func(ctx context.Context, doc map[string]any) templates.HTMLComponent { return func(ctx context.Context, doc map[string]any) templates.HTMLComponent {
pongoCtx := e.buildPageContext(ctx, doc) pongoCtx := e.buildPageContext(ctx, doc)
return &pongoComponent{tpl: tpl, ctx: pongoCtx} return &pongoComponent{tpl: tpl, ctx: pongoCtx}
@ -75,7 +75,7 @@ func (e *Engine) MustPageTemplate(name string) templates.TemplateFunc {
// The content map fields are available directly as template variables. // The content map fields are available directly as template variables.
// Request context is available under {{ ctx.url }}, {{ ctx.isEditor }}, etc. // Request context is available under {{ ctx.url }}, {{ ctx.isEditor }}, etc.
func (e *Engine) MustBlockTemplate(name string) blocks.BlockFunc { func (e *Engine) MustBlockTemplate(name string) blocks.BlockFunc {
tpl := pongo2.Must(e.set.FromFile(name)) tpl := ninjatpl.Must(e.set.FromFile(name))
return func(ctx context.Context, content map[string]any) string { return func(ctx context.Context, content map[string]any) string {
pongoCtx := buildBlockContext(ctx, content) pongoCtx := buildBlockContext(ctx, content)
out, err := tpl.Execute(pongoCtx) out, err := tpl.Execute(pongoCtx)
@ -89,7 +89,7 @@ func (e *Engine) MustBlockTemplate(name string) blocks.BlockFunc {
// MustBlockTemplateWithDefaults is like MustBlockTemplate but merges default // MustBlockTemplateWithDefaults is like MustBlockTemplate but merges default
// values before rendering. Content keys override defaults. // values before rendering. Content keys override defaults.
func (e *Engine) MustBlockTemplateWithDefaults(name string, defaults map[string]any) blocks.BlockFunc { func (e *Engine) MustBlockTemplateWithDefaults(name string, defaults map[string]any) blocks.BlockFunc {
tpl := pongo2.Must(e.set.FromFile(name)) tpl := ninjatpl.Must(e.set.FromFile(name))
return func(ctx context.Context, content map[string]any) string { return func(ctx context.Context, content map[string]any) string {
merged := make(map[string]any, len(defaults)+len(content)) merged := make(map[string]any, len(defaults)+len(content))
maps.Copy(merged, defaults) maps.Copy(merged, defaults)
@ -129,9 +129,9 @@ func (e *Engine) MustTemplateOverride(name string) blocks.BlockFunc {
// {{ colors.muted }} — muted hex color // {{ colors.muted }} — muted hex color
// (and all other EmailColors fields as lowercase keys) // (and all other EmailColors fields as lowercase keys)
func (e *Engine) MustEmailWrapper(name string) templates.EmailWrapperFunc { func (e *Engine) MustEmailWrapper(name string) templates.EmailWrapperFunc {
tpl := pongo2.Must(e.set.FromFile(name)) tpl := ninjatpl.Must(e.set.FromFile(name))
return func(body string, ctx templates.EmailContext) string { return func(body string, ctx templates.EmailContext) string {
pongoCtx := pongo2.Context{ pongoCtx := ninjatpl.Context{
"body": body, "body": body,
"site_name": ctx.SiteSettings.SiteName, "site_name": ctx.SiteSettings.SiteName,
"site_url": ctx.SiteSettings.SiteURL, "site_url": ctx.SiteSettings.SiteURL,

View File

@ -5,7 +5,7 @@ import (
"io" "io"
"io/fs" "io/fs"
"github.com/flosch/pongo2/v6" "git.dev.alexdunmow.com/block/ninjatpl"
) )
// fsLoader adapts an fs.FS to pongo2's TemplateLoader interface. // fsLoader adapts an fs.FS to pongo2's TemplateLoader interface.
@ -25,7 +25,7 @@ func (l *fsLoader) Get(path string) (io.Reader, error) {
// Plugin templates can {% extends "base.html" %} where base.html // Plugin templates can {% extends "base.html" %} where base.html
// lives in the core embedded FS rather than the plugin FS. // lives in the core embedded FS rather than the plugin FS.
type multiLoader struct { type multiLoader struct {
loaders []pongo2.TemplateLoader loaders []ninjatpl.TemplateLoader
} }
func (l *multiLoader) Abs(base, name string) string { func (l *multiLoader) Abs(base, name string) string {