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
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
- **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.
- Plugins import from core only — never from the CMS (`blockninja/backend`) or orchestrator.
- All consumers are in-house — no backwards compatibility shims needed. Just change the API and update consumers.
- **Core is NOT for plugins.** Plugins are standalone wasm artifacts built
against the wasm ABI, not this module. (Core was previously the plugin SDK;
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/plugins/*)
.PHONY: install-ninja
install-ninja:
go install ./cmd/ninja
# 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
# The ninja CLI moved to its own repo (git.dev.alexdunmow.com/block/cli,
# WO-WZ-023, Phase 7 core dissolution). Its plugin-registry orchestrator client
# is generated there now, so core no longer carries an `install-ninja` or
# `proto` (plugin_registry) target.
# Lint + regenerate Go bindings for the wasm plugin ABI (repo-local buf
# 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
@ -10,7 +23,7 @@ Types, interfaces, and utilities for building BlockNinja plugins.
| `blocks/` | BlockMeta, BlockFunc, BlockRegistry interface, BlockContext |
| `blocks/builtin/` | Reusable block implementations (HTMLBlock) |
| `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 |
| `content/` | Content 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 |
| `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
```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.
// templ: version: v0.3.1001
// templ: version: v0.3.1020
package shared
//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
templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(btn.URL))
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))
if templ_7745c5c3_Err != nil {
@ -55,11 +55,11 @@ func RenderButton(btn ButtonConfig) templ.Component {
return templ_7745c5c3_Err
}
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 {
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 {
return templ_7745c5c3_Err
}
@ -80,7 +80,7 @@ func RenderButton(btn ButtonConfig) templ.Component {
var templ_7745c5c3_Var5 string
templ_7745c5c3_Var5, templ_7745c5c3_Err = templruntime.SanitizeStyleAttributeValues(btn.InlineStyle())
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))
if templ_7745c5c3_Err != nil {
@ -93,7 +93,7 @@ func RenderButton(btn ButtonConfig) templ.Component {
var templ_7745c5c3_Var6 string
templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.JoinStringErrs(btn.Text)
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))
if templ_7745c5c3_Err != nil {
@ -116,7 +116,7 @@ func RenderButton(btn ButtonConfig) templ.Component {
var templ_7745c5c3_Var8 templ.SafeURL
templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(btn.URL))
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))
if templ_7745c5c3_Err != nil {
@ -127,11 +127,11 @@ func RenderButton(btn ButtonConfig) templ.Component {
return templ_7745c5c3_Err
}
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 {
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 {
return templ_7745c5c3_Err
}
@ -152,7 +152,7 @@ func RenderButton(btn ButtonConfig) templ.Component {
var templ_7745c5c3_Var10 string
templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.JoinStringErrs(btn.Text)
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))
if templ_7745c5c3_Err != nil {
@ -175,11 +175,11 @@ func RenderButton(btn ButtonConfig) templ.Component {
return templ_7745c5c3_Err
}
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 {
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 {
return templ_7745c5c3_Err
}
@ -190,7 +190,7 @@ func RenderButton(btn ButtonConfig) templ.Component {
var templ_7745c5c3_Var13 string
templ_7745c5c3_Var13, templ_7745c5c3_Err = templruntime.SanitizeStyleAttributeValues(btn.InlineStyle())
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))
if templ_7745c5c3_Err != nil {
@ -203,7 +203,7 @@ func RenderButton(btn ButtonConfig) templ.Component {
var templ_7745c5c3_Var14 string
templ_7745c5c3_Var14, templ_7745c5c3_Err = templ.JoinStringErrs(btn.Text)
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))
if templ_7745c5c3_Err != nil {
@ -224,11 +224,11 @@ func RenderButton(btn ButtonConfig) templ.Component {
return templ_7745c5c3_Err
}
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 {
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 {
return templ_7745c5c3_Err
}
@ -239,7 +239,7 @@ func RenderButton(btn ButtonConfig) templ.Component {
var templ_7745c5c3_Var17 string
templ_7745c5c3_Var17, templ_7745c5c3_Err = templ.JoinStringErrs(btn.Text)
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))
if templ_7745c5c3_Err != nil {
@ -288,11 +288,11 @@ func RenderSubmitButton(btn ButtonConfig) templ.Component {
return templ_7745c5c3_Err
}
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 {
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 {
return templ_7745c5c3_Err
}
@ -303,7 +303,7 @@ func RenderSubmitButton(btn ButtonConfig) templ.Component {
var templ_7745c5c3_Var21 string
templ_7745c5c3_Var21, templ_7745c5c3_Err = templruntime.SanitizeStyleAttributeValues(btn.InlineStyle())
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))
if templ_7745c5c3_Err != nil {
@ -316,7 +316,7 @@ func RenderSubmitButton(btn ButtonConfig) templ.Component {
var templ_7745c5c3_Var22 string
templ_7745c5c3_Var22, templ_7745c5c3_Err = templ.JoinStringErrs(btn.Text)
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))
if templ_7745c5c3_Err != nil {
@ -337,11 +337,11 @@ func RenderSubmitButton(btn ButtonConfig) templ.Component {
return templ_7745c5c3_Err
}
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 {
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 {
return templ_7745c5c3_Err
}
@ -352,7 +352,7 @@ func RenderSubmitButton(btn ButtonConfig) templ.Component {
var templ_7745c5c3_Var25 string
templ_7745c5c3_Var25, templ_7745c5c3_Err = templ.JoinStringErrs(btn.Text)
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))
if templ_7745c5c3_Err != nil {

View File

@ -10,6 +10,7 @@ import (
"encoding/hex"
"net/http"
"strconv"
"sync/atomic"
"time"
)
@ -34,11 +35,12 @@ type RedeemResponse struct {
}
type Server struct {
secret []byte
secret atomic.Pointer[[]byte]
count, size, d int
challengeTTL time.Duration
tokenTTL time.Duration
nonces NonceStore
usedTokens NonceStore
}
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 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,
// 32-char salts, difficulty 4, 10-min challenge / 5-min token expiry.
func New(secret []byte, opts ...Option) *Server {
s := &Server{
secret: secret,
count: 50,
size: 32,
d: 4,
challengeTTL: 10 * time.Minute,
tokenTTL: 5 * time.Minute,
nonces: NewMemoryNonceStore(),
usedTokens: NewMemoryNonceStore(),
}
s.secret.Store(&secret)
for _, o := range opts {
o(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) {
buf := make([]byte, 25)
if _, err := rand.Read(buf); err != nil {
@ -75,7 +89,7 @@ func (s *Server) CreateChallenge() (ChallengeResponse, error) {
}
nonce := hex.EncodeToString(buf)
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{
Challenge: Challenge{C: s.count, S: s.size, D: s.d},
Token: token,
@ -84,7 +98,7 @@ func (s *Server) CreateChallenge() (ChallengeResponse, error) {
}
func (s *Server) Redeem(token string, solutions []string) RedeemResponse {
claims := verifyChallengeToken(s.secret, token)
claims := verifyChallengeToken(s.secretBytes(), token)
if claims == nil {
return RedeemResponse{Success: false}
}
@ -105,15 +119,34 @@ func (s *Server) Redeem(token string, solutions []string) RedeemResponse {
}
}
expires := nowMs() + s.tokenTTL.Milliseconds()
vt, err := makeVerificationToken(s.secret, expires)
vt, err := makeVerificationToken(s.secretBytes(), expires)
if err != nil {
return RedeemResponse{Success: false}
}
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 {
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

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) {
s := New([]byte("secret"), WithChallenge(5, 8, 2))
cr, _ := s.CreateChallenge()

View File

@ -72,25 +72,44 @@ func makeVerificationToken(secret []byte, expiresMs int64) (string, error) {
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 == "" {
return false
return nil
}
i := strings.LastIndex(token, ":")
if i < 0 {
return false
return nil
}
payload, sig := token[:i], token[i+1:]
if !hmac.Equal([]byte(sig), []byte(sign(secret, payload))) {
return false
return nil
}
f := strings.Split(payload, ":")
if len(f) != 2 {
return false
return nil
}
expires, err := strconv.ParseInt(f[1], 10, 64)
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
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:
- `name = "uat-a1"`
- `scope = "@themes"`
@ -85,7 +85,7 @@ uses; commands assume access via `podman exec blockninja-db psql -U orchestrator
git tag --list # capture 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] 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.
@ -162,7 +162,7 @@ uses; commands assume access via `podman exec blockninja-db psql -U orchestrator
- [x] Run:
```bash
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 '{}'
```
- [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:
```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 \
https://my.localdev.blockninjacms.com/orchestrator.v1.PluginRegistryService/CreatePlugin \
https://my.blockninja.dev/orchestrator.v1.PluginRegistryService/CreatePlugin \
-H "Authorization: Bearer $TOKEN" \
-H 'Content-Type: application/json' \
-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"]:
```bash
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 'Content-Type: application/json' \
-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`:
```bash
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 '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:
```bash
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.
@ -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:
```bash
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' \
-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
git init -q
git commit --allow-empty -qm "initial"
ninja login --host https://my.localdev.blockninjacms.com
ninja plugin init --host https://my.localdev.blockninjacms.com --scope @themes --name smoke
ninja login --host https://my.blockninja.dev
ninja plugin init --host https://my.blockninja.dev --scope @themes --name smoke
git log --oneline
```
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**
```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.
@ -1399,7 +1399,7 @@ Expected: a row whose `source_archive_key` ends in `source.tar.zst`.
- [ ] **Step 5: Try publishing again — version collision**
```bash
ninja plugin publish --host https://my.localdev.blockninjacms.com
ninja plugin publish --host https://my.blockninja.dev
```
Expected: error mentioning "version already published".
@ -2226,7 +2226,7 @@ podman compose up -d orchestrator-backend
- [ ] **Step 2: Verify ListCategories**
```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 '{}'
```
Expected: JSON listing the 8 seeded categories.
@ -2236,7 +2236,7 @@ Expected: JSON listing the 8 seeded categories.
```bash
mkdir -p /tmp/themeplugin && cd /tmp/themeplugin
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).
cat plugin.mod
```
@ -2247,7 +2247,7 @@ Expected: `kind = "theme"` set, no categories line.
```bash
mkdir -p /tmp/normalplugin && cd /tmp/normalplugin
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"
cat plugin.mod
```
@ -2257,7 +2257,7 @@ Expected: `kind = "plugin"` and `categories = ["analytics", "seo"]`.
```bash
cd /tmp/normalplugin
ninja plugin publish --host https://my.localdev.blockninjacms.com
ninja plugin publish --host https://my.blockninja.dev
```
Expected: success.
@ -2277,7 +2277,7 @@ cd /tmp/normalplugin
sed -i 's/\["analytics", "seo"\]/["analytics", "seo", "bogus"]/' plugin.mod
git add plugin.mod && git commit -qm "tweak"
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"
(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 (
connectrpc.com/connect v1.20.0
git.dev.alexdunmow.com/block/ninjatpl v1.0.2
github.com/BurntSushi/toml v1.6.0
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/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
golang.org/x/crypto v0.48.0
golang.org/x/mod v0.34.0
google.golang.org/protobuf v1.36.11
gopkg.in/yaml.v3 v3.0.1
)
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/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/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/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/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/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.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
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/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
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/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/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
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/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/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/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/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
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/tetratelabs/wazero v1.12.0 h1:DuWcpNu/FzgEXgGBDp8J1Spc+CWOvvtvVyjKlaZopYU=
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/go.mod h1:ykgH52iCZe79kzLLMhyCUzhMci+nQj+0XkbXpNYtVjY=
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/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/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
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/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
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.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
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.
// templ: version: v0.3.1001
// templ: version: v0.3.1020
package bn
//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
}
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 {
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 {
return templ_7745c5c3_Err
}

View File

@ -16,34 +16,69 @@ func resolveMediaURL(url string) string {
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
type BrandingData struct {
FaviconICO string // /brand/{id}/favicon.ico
Favicon16 string // /brand/{id}/favicon-16x16.png
Favicon32 string // /brand/{id}/favicon-32x32.png
AppleTouchIcon string // /brand/{id}/apple-touch-icon.png (180x180)
Android192 string // /brand/{id}/android-chrome-192x192.png
Android512 string // /brand/{id}/android-chrome-512x512.png
Maskable512 string // /brand/{id}/maskable-512x512.png
Master1024 string // /brand/{id}/icon-1024x1024.png
ManifestURL string // /brand/{id}/site.webmanifest
FaviconICO string // /.brand/{id}/favicon.ico
Favicon16 string // /.brand/{id}/favicon-16x16.png
Favicon32 string // /.brand/{id}/favicon-32x32.png
Favicon96 string // /.brand/{id}/favicon-96x96.png
AppleTouchIcon string // /.brand/{id}/apple-touch-icon.png (180x180)
Android192 string // /.brand/{id}/android-chrome-192x192.png
Android512 string // /.brand/{id}/android-chrome-512x512.png
Maskable512 string // /.brand/{id}/maskable-512x512.png
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
SVG string // Optional SVG pass-through
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
type SiteSettingsData struct {
Title string
Description string
Favicon string
Logo string
LogoAlt string
AppleTouchIcon string
GoogleAnalyticsID string
CustomHeadScripts string
CustomBodyScripts string
MetaDescription string
Title string
Description string
Favicon string
Logo string
LogoAlt string
AppleTouchIcon string
GoogleAnalyticsID string
CustomScripts []CustomScriptEntry
GoogleAnalyticsEnabled bool
GoogleAnalyticsExplicitlySet bool // true if the enabled flag was explicitly set in JSON (not nil)
MetaDescription string
DefaultOGImage string
TwitterHandle string
OGSiteName string
@ -51,7 +86,6 @@ type SiteSettingsData struct {
AdminBypassMode string // "maintenance", "coming_soon", or "" if not bypassing
Toolbar ToolbarData
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
RSSFeedTitle string // Feed title for discovery link
}
@ -68,6 +102,10 @@ type PageMeta struct {
TwitterImage string // Twitter card image URL
CanonicalURL string // Canonical URL for this page
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
@ -160,15 +198,75 @@ func ParseSiteSettings(doc map[string]any) SiteSettingsData {
}
// Analytics
var legacyHeadScripts, legacyBodyScripts string
if analyticsData, ok := siteData["analytics"].(map[string]any); ok {
if v, ok := analyticsData["google_analytics_id"].(string); ok {
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 {
settings.CustomHeadScripts = v
legacyHeadScripts = v
}
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 {
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)
@ -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)
if v, ok := siteData["rss_feed_url"].(string); ok {
settings.RSSFeedURL = v
@ -287,6 +387,18 @@ func ParsePageMeta(doc map[string]any) PageMeta {
if v, ok := doc["robotsDirective"].(string); ok {
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
}
@ -321,6 +433,12 @@ func ParseToolbarData(data map[string]any) ToolbarData {
if v, ok := data["preview_mode"].(string); ok {
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 {
toolbar.Position = v
}
@ -401,15 +519,30 @@ templ Head(data HeadData) {
@themeInitScript(data.ThemeMode)
<script src="https://unpkg.com/htmx.org@2.0.4"></script>
if data.Settings.Branding.IsGenerated {
// Use generated brand assets
<link rel="icon" type="image/x-icon" href={ data.Settings.Branding.FaviconICO }/>
<link rel="icon" type="image/png" sizes="16x16" href={ data.Settings.Branding.Favicon16 }/>
// Use generated brand assets (modern RFG-parity set; SVG first)
if data.Settings.Branding.SVG != "" {
<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="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 }/>
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 }/>
if 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 {
// Legacy fallback - manual favicon uploads
if data.Settings.Favicon != "" {
@ -429,9 +562,9 @@ templ Head(data HeadData) {
<meta name="description" content={ data.Settings.MetaDescription }/>
}
// Canonical URL (page-level only, no site default)
if data.PageMeta.CanonicalURL != "" {
<link rel="canonical" href={ data.PageMeta.CanonicalURL }/>
// Canonical URL: admin override (resolved to absolute) → auto self-canonical (M8)
if canonical := canonicalURL(data.PageMeta.CanonicalURL, data.PageMeta.PageURL); canonical != "" {
<link rel="canonical" href={ canonical }/>
}
// 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) }/>
}
// og:type - default to website
<meta property="og:type" content="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"/>
}
// 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 ===
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" }/>
}
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>
@googleAnalyticsScript(data.Settings.GoogleAnalyticsID)
}
@ -532,18 +682,16 @@ templ Head(data HeadData) {
// Blog post engagement tracking (only rendered for posts with engagement config)
@EngagementScript(data.EngagementConfig)
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
if data.ThemeCSS != "" {
@themeStyle(data.ThemeCSS)
}
if data.Settings.CustomHeadScripts != "" {
@templ.Raw(data.Settings.CustomHeadScripts)
}
// Cloudflare Turnstile invisible bot protection
if data.Settings.TurnstileSiteKey != "" {
@turnstileScript(data.Settings.TurnstileSiteKey)
for _, script := range data.Settings.CustomScripts {
if script.Enabled && script.Placement == "head" {
@templ.Raw(script.Code)
}
}
if 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>`)
}
// 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
// This should be rendered at the very start of the <body> to push down all content
templ AdminBypassBanner(settings SiteSettingsData) {
@ -696,8 +733,10 @@ templ AdminBypassBanner(settings SiteSettingsData) {
// BodyEnd renders custom scripts and admin toolbar before </body>
templ BodyEnd(settings SiteSettingsData) {
@recordValidationCall(ctx, "BodyEnd")
if settings.CustomBodyScripts != "" {
@templ.Raw(settings.CustomBodyScripts)
for _, script := range settings.CustomScripts {
if script.Enabled && script.Placement == "body" {
@templ.Raw(script.Code)
}
}
// Render admin editor toolbar if enabled (auto-injects for all templates)
@AdminEditorToolbar(settings.Toolbar)
@ -901,15 +940,22 @@ func themeInitScript(themeMode string) templ.Component {
default:
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>
(function(){
var c=document.cookie.match(/(?:^|; )bn-theme=([^;]*)/);
var t=c?c[1]:localStorage.getItem('bn-theme');
window.bnApplyTheme=function(){
var t=null;try{t=sessionStorage.getItem('bn-theme-override')}catch(e){}
if(!t){
var c=document.cookie.match(/(?:^|; )bn-theme=([^;]*)/);
t=c?c[1]:localStorage.getItem('bn-theme');
}
if(!t)t='` + themeMode + `';
var h=document.documentElement;
if(t==='system')t=window.matchMedia('(prefers-color-scheme:dark)').matches?'dark':'light';
if(t==='dark')h.classList.add('dark');
else h.classList.remove('dark');
})();
document.documentElement.classList.toggle('dark',t==='dark');
return t;
};
window.bnApplyTheme();
</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"
HasUnpublishedChanges bool
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")
ScheduledAt *time.Time
TemplateName string
@ -135,6 +137,20 @@ func (t ToolbarData) DropdownAlign() string {
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
func (t ToolbarData) PositionClasses() string {
switch t.Position {
@ -188,7 +204,7 @@ templ AdminEditorToolbar(data ToolbarData) {
if data.Enabled {
<div
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>
/* 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);
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) */
.dark .bn-toolbar, html.dark .bn-toolbar, body.dark .bn-toolbar {
--bn-toolbar-bg: rgba(250, 250, 250, 0.95);
@ -225,6 +268,13 @@ templ AdminEditorToolbar(data ToolbarData) {
.bn-toolbar .bn-toolbar-primary:hover {
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 */
.bn-toolbar-dropdown {
background: var(--bn-toolbar-bg);
@ -262,28 +312,120 @@ 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="bn-toolbar-muted text-xs hidden sm:inline">{ data.StatusLabel() }</span>
</div>
if !data.HidePreviewToggle {
<!-- Divider -->
<div class="bn-toolbar-divider w-px h-5"></div>
<!-- Preview toggle - compact -->
<button
type="button"
class={ "bn-toolbar-hover inline-flex items-center gap-1 px-2 py-1 rounded-full text-xs transition-colors", templ.KV("!bg-info/20 text-info", data.IsPreviewMode()), templ.KV("bn-toolbar-muted", !data.IsPreviewMode()) }
onclick={ togglePreviewMode(data.IsPreviewMode()) }
title={ func() string { if data.IsPreviewMode() { return "Viewing draft - click to view published" } else { return "Viewing published - click to preview draft" } }() }
>
if data.IsPreviewMode() {
<svg xmlns="http://www.w3.org/2000/svg" class="h-3.5 w-3.5" viewBox="0 0 20 20" fill="currentColor">
<path d="M10 12a2 2 0 100-4 2 2 0 000 4z"></path>
<path fill-rule="evenodd" d="M.458 10C1.732 5.943 5.522 3 10 3s8.268 2.943 9.542 7c-1.274 4.057-5.064 7-9.542 7S1.732 14.057.458 10zM14 10a4 4 0 11-8 0 4 4 0 018 0z" clip-rule="evenodd"></path>
</svg>
<span class="hidden sm:inline">Draft</span>
} else {
<svg xmlns="http://www.w3.org/2000/svg" class="h-3.5 w-3.5" viewBox="0 0 20 20" fill="currentColor">
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clip-rule="evenodd"></path>
</svg>
<span class="hidden sm:inline">Live</span>
}
</button>
}
<!-- Divider -->
<div class="bn-toolbar-divider w-px h-5"></div>
<!-- Preview toggle - compact -->
<!-- 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"
class={ "bn-toolbar-hover inline-flex items-center gap-1 px-2 py-1 rounded-full text-xs transition-colors", templ.KV("!bg-info/20 text-info", data.IsPreviewMode()), templ.KV("bn-toolbar-muted", !data.IsPreviewMode()) }
onclick={ togglePreviewMode(data.IsPreviewMode()) }
title={ func() string { if data.IsPreviewMode() { return "Viewing draft - click to view published" } else { return "Viewing published - click to preview draft" } }() }
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"
>
if data.IsPreviewMode() {
<svg xmlns="http://www.w3.org/2000/svg" class="h-3.5 w-3.5" viewBox="0 0 20 20" fill="currentColor">
<path d="M10 12a2 2 0 100-4 2 2 0 000 4z"></path>
<path fill-rule="evenodd" d="M.458 10C1.732 5.943 5.522 3 10 3s8.268 2.943 9.542 7c-1.274 4.057-5.064 7-9.542 7S1.732 14.057.458 10zM14 10a4 4 0 11-8 0 4 4 0 018 0z" clip-rule="evenodd"></path>
<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 class="hidden sm:inline">Draft</span>
} else {
<svg xmlns="http://www.w3.org/2000/svg" class="h-3.5 w-3.5" viewBox="0 0 20 20" fill="currentColor">
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clip-rule="evenodd"></path>
</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 class="hidden sm:inline">Live</span>
}
</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 -->
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">
@ -347,6 +489,21 @@ templ AdminEditorToolbar(data ToolbarData) {
// PageInfoDropdown renders the page info and quick actions dropdown
templ PageInfoDropdown(data ToolbarData) {
<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 -->
if data.TodayPageviews > 0 || data.PageviewsTrend != "" {
<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
templ positionButton(pageID uuid.UUID, pos string, currentPos string, label string) {
{{ active := pos == currentPos || (currentPos == "" && pos == "tr") }}
<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-target="#bn-admin-toolbar"
hx-swap="outerHTML"
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>
}

File diff suppressed because one or more lines are too long

View File

@ -4,15 +4,15 @@ import (
"context"
"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/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.
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"
if t, ok := doc["title"].(string); ok && t != "" {
title = t
@ -72,7 +72,7 @@ func (e *Engine) buildPageContext(ctx context.Context, doc map[string]any) pongo
slotsAny[k] = v
}
return pongo2.Context{
return ninjatpl.Context{
"head_html": headHTML,
"body_end_html": bodyEndHTML,
"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".
func buildBlockContext(ctx context.Context, content map[string]any) pongo2.Context {
pongoCtx := make(pongo2.Context, len(content)+1)
func buildBlockContext(ctx context.Context, content map[string]any) ninjatpl.Context {
pongoCtx := make(ninjatpl.Context, len(content)+1)
maps.Copy(pongoCtx, content)
if bc := blocks.GetBlockContext(ctx); bc != nil {
pongoCtx["ctx"] = bc.ToMap()

View File

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

View File

@ -5,7 +5,7 @@ import (
"io"
"io/fs"
"github.com/flosch/pongo2/v6"
"git.dev.alexdunmow.com/block/ninjatpl"
)
// 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
// lives in the core embedded FS rather than the plugin FS.
type multiLoader struct {
loaders []pongo2.TemplateLoader
loaders []ninjatpl.TemplateLoader
}
func (l *multiLoader) Abs(base, name string) string {