docs(10.1): research runtime admin extension point
This commit is contained in:
@@ -0,0 +1,704 @@
|
|||||||
|
# Phase 10.1: Runtime admin extension point - Research
|
||||||
|
|
||||||
|
**Researched:** 2026-09-28
|
||||||
|
**Domain:** Go admin schema pipeline (cabana), embedded asset serving (boardwalk), Vue 3 SPA extension seams, html/template partials, custom elements
|
||||||
|
**Confidence:** HIGH for codebase facts and the Go/OpenAPI toolchain (read or probed this session); MEDIUM for browser-behaviour claims that can't be exercised in happy-dom
|
||||||
|
|
||||||
|
<user_constraints>
|
||||||
|
## User Constraints (from CONTEXT.md)
|
||||||
|
|
||||||
|
### Locked Decisions
|
||||||
|
|
||||||
|
#### Acceptance
|
||||||
|
- **D-01:** Prove the phase on Płytarium Albums, not a toy-only milestone: stats strip on the Albums list, Discogs widget on the Albums form, one extra Albums toolbar button. A nameless fixture plugin in `summercms.go` still covers the framework contract (no Płytarium names in the framework repo).
|
||||||
|
- **D-02:** The Discogs widget button is enabled in this phase. Click hits a stub endpoint that returns a clear not-implemented or fixture payload. Phase 14 replaces the stub. Do not implement the Discogs client here.
|
||||||
|
- **D-03:** The statistics strip is list chrome above `DataTable`, not `type: partial` in `fields.yaml`. Form `type: partial` is still in this phase (boot must allow it); Płytarium may not have a form partial yet — the fixture plugin (or a tiny unused field) proves that path.
|
||||||
|
|
||||||
|
#### Widget contract
|
||||||
|
- **D-04:** Plugins ship plain JS custom elements. No Vue SFC, no Vue compiler, no import of admin SPA modules. App developers never need Node (Phase 10 D-04 stands).
|
||||||
|
- **D-05:** The SPA owns HTTP. The widget dispatches a `CustomEvent`; `FieldRenderer` POSTs to the YAML-declared action with the existing cookie and `X-Requested-With` CSRF header. Widget JS must not `fetch` the admin API and must not read the JWT cookie. — **Reversibility:** costly — every widget and the CSRF matrix assume this split.
|
||||||
|
- **D-06:** `fields.yaml` uses Winter-shaped `type: widget` plus a small key set: custom-element tag/path, POST `action`, and `fill: [keys]` for write-back. Unknown keys fail boot (`DisallowUnknownField`). — **Reversibility:** costly — every ported `fields.yaml` that uses a widget writes this shape.
|
||||||
|
- **D-07:** On success the SPA patches only the `fill` keys onto the form model. Phase 10.1 may return a fixture payload so the save path is real; Phase 14 returns Discogs values into the same keys.
|
||||||
|
- **D-08:** The SPA mounts the custom element with `record-id`, field name, locale, and a snapshot of the current `fill` values. No Vue instance, no token, no cookie on the element.
|
||||||
|
|
||||||
|
#### Partials and toolbar
|
||||||
|
- **D-09:** Both surfaces ship: list-header partials and form `type: partial`. Phase 9’s boot error for `type: partial` is lifted for a supported partial contract. `type: widget` is added to the allowed form types.
|
||||||
|
- **D-10:** Partials render with `html/template` against a **curated view model** the controller supplies. The template must not receive a raw GORM model or the request. `html/template` escaping stays on; record fields are not trusted HTML.
|
||||||
|
- **D-11:** The Albums stats strip is declared in `config_list.yaml` (e.g. `headerPartial:` naming the template). The controller implements the view-model method. Missing template or unknown YAML key fails boot.
|
||||||
|
- **D-12:** Custom toolbar actions extend the D-14 string list: `toolbar.buttons: [create, delete, discogsSync]`. Unknown names fail boot unless the controller registers that action (label, permission, POST path). Click: SPA POSTs with CSRF, stub returns, toast. `create`/`delete` behavior is unchanged. — **Reversibility:** costly — list YAML and the toolbar compiler grow a registration table.
|
||||||
|
|
||||||
|
#### Assets, serving, CSP
|
||||||
|
- **D-13:** Controllers declare JS/CSS with a Go method (Winter `addJs`/`addCss`). `pact.AdminAssets` remains the YAML `embed.FS` — the JS/CSS interface gets a different name (planner). Files live in the plugin embed tree.
|
||||||
|
- **D-14:** Assets load when that controller opens (list or form). Other plugins’ admin JS stay unloaded.
|
||||||
|
- **D-15:** Production and dev both serve from `embed.FS`. No disk-override switch in v1. `air` / `summer watch` rebuilds the binary. — **Reversibility:** reversible
|
||||||
|
- **D-16:** Files are served under the admin prefix, same origin as the SPA, e.g. `{backend.uri}/assets/{vendor}/{plugin}/…`. CSP stays `script-src 'self'`: no `unsafe-inline`, no extra script hosts. Cookie stays HttpOnly, Secure, SameSite=Strict (Phase 10 D-19). — **Reversibility:** costly — CSP hygiene gate and cookie threat model (T-10-01, T-10-16) assume this.
|
||||||
|
|
||||||
|
#### Hygiene constraint (partial HTML in the SPA)
|
||||||
|
- **D-17:** Phase 10 forbids unsanitized `v-html` / `innerHTML` (T-10-16, `--hygiene`). Partial HTML still has to appear in the list/form. Researcher/planner must pick a host that does not reopen that threat (dedicated sanitized slot, iframe under the admin prefix, or equivalent). Record data must not become executable HTML.
|
||||||
|
|
||||||
|
### Claude's Discretion
|
||||||
|
- Exact YAML key names (`headerPartial` vs another spelling, widget `path` vs `tag`, action path convention).
|
||||||
|
- JS/CSS capability interface name (must not reuse `pact.AdminAssets`).
|
||||||
|
- Stub JSON envelope, toast copy, and the fixture `fill` payload for the Discogs widget.
|
||||||
|
- Which numbers the Albums stats strip shows (counts the controller already has vs a small new query).
|
||||||
|
- How the form `type: partial` proof is wired if fonoteka has no real form partial (fixture plugin field vs a harmless unused Albums field).
|
||||||
|
- Custom-element tag naming and `customElements.define` timing relative to asset load.
|
||||||
|
- Whether the Discogs widget renders on create, update, or both (record-id may be empty on create).
|
||||||
|
- Exact asset URL layout under `{backend.uri}/assets/`.
|
||||||
|
- Partial-host implementation that satisfies D-17.
|
||||||
|
- Whether the third toolbar action is named `discogsSync` or something else; label/permission strings.
|
||||||
|
|
||||||
|
### Deferred Ideas (OUT OF SCOPE)
|
||||||
|
- **Phase 14:** Real Discogs client, jobs, and non-stub widget/toolbar payloads.
|
||||||
|
- **Dev-mode disk override** for plugin JS/CSS — considered and rejected for v1 (embed only).
|
||||||
|
- **WASM sandboxed extension API** — FW-06 / v2; not a substitute for this compiled-plugin extension point.
|
||||||
|
- Phase 10 leftovers, still out of scope: Ctrl+K command palette, badge/icon column type, Playwright admin e2e, user/media admin navigation.
|
||||||
|
- Reviewed todo not folded: backend admin personal API tokens (deferred Apparatus PersonalApiToken).
|
||||||
|
</user_constraints>
|
||||||
|
|
||||||
|
<phase_requirements>
|
||||||
|
## Phase Requirements
|
||||||
|
|
||||||
|
No requirement ID is mapped yet (ROADMAP says `Requirements: TBD`). CONTEXT asks for a new ID; ADMIN-06 is complete and must not be reused.
|
||||||
|
|
||||||
|
**Proposed REQUIREMENTS.md entry (Admin section, after ADMIN-06):**
|
||||||
|
|
||||||
|
> - [ ] **ADMIN-07**: A plugin extends the compiled admin SPA without a Node rebuild: controller-declared JS/CSS is served from the plugin's embedded files under `{backend.uri}/assets/` and loaded when that controller opens (CSP `script-src 'self'`); `type: widget` fields mount plugin custom elements whose actions the SPA posts with the admin cookie and CSRF header, patching only the declared `fill` fields; `type: partial` form fields and a `config_list.yaml` `headerPartial` render server-side with `html/template` from a controller view model and display without any raw-HTML sink; and controllers register named toolbar actions. Unknown YAML keys, missing templates and unregistered actions fail boot.
|
||||||
|
|
||||||
|
**Traceability row:** `| ADMIN-07 | Phase 10.1 | Pending |`
|
||||||
|
|
||||||
|
**ROADMAP §Phase 10.1 `Requirements:`** `ADMIN-07`
|
||||||
|
|
||||||
|
| ID | Description | Research Support |
|
||||||
|
|----|-------------|------------------|
|
||||||
|
| ADMIN-07 (proposed) | Runtime admin extension point (assets, widgets, partials, toolbar actions) | Patterns 1-7, Don't Hand-Roll, Pitfalls 1-14, Validation Architecture, Security Domain |
|
||||||
|
</phase_requirements>
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
The Phase 9/10 pipeline already has all the seams this phase needs. They are closed shut in three places. `cabana/form_schema.go` explicitly rejects `type: partial` and has no `widget`. `cabana/list_schema.go` checks toolbar names against a static `{create, delete}` map *during YAML decode*, before any controller is visible. Surf refuses any plugin route under the admin prefix (`TestPhase10AdminPrefixCollision`). The last point is the decisive design constraint. A plugin **cannot** own the POST path for a widget or a toolbar action, so "controller registers an action" has to mean a Go handler function that cabana dispatches from cabana-owned routes: `POST …/{controller}/widgets/{field}` and `POST …/{controller}/toolbar/{action}`. That way `requireAjax`, `protect()`, permission checks and record scoping come from the framework for free. `TestPhase10CSRF` auto-walks every unsafe route `service.mount` registers, so the new routes are CSRF-tested without new test code.
|
||||||
|
|
||||||
|
For D-17, the recommended partial host is a **server-sanitized node tree**. cabana executes the `html/template` partial and parses the output with `golang.org/x/net/html.ParseFragment`. It walks that tree through a tag/attribute allowlist and returns JSON nodes (`{tag, attrs, children}` / `{text}`). The SPA builds DOM with Vue `h()`, re-checking the same allowlist, so no HTML string ever reaches a browser parser. That satisfies the existing `--hygiene` grep (`v-html|innerHTML|outerHTML|insertAdjacentHTML`) without an exemption, adds no npm package (T-10-SC), keeps the admin theme and dark mode, and is fully unit-testable in Go. The sandboxed iframe, the sanitized `v-html` slot and declarative shadow DOM were all evaluated and rejected (see Pattern 4). I verified end to end this session that swag v1.16.6, the repo's `swagger2openapi` and openapi-typescript 7.13.0 handle the recursive `PartialNode` type.
|
||||||
|
|
||||||
|
Assets are served by a cabana-mounted `GET {prefix}/assets/{vendor}/{plugin}/{file...}` route. It looks each file up in a boot-built **exact allowlist** of declared files. The route never exposes the plugin FS, so there is no path traversal and no YAML or template disclosure. Each response carries an explicit JS/CSS MIME type, `nosniff`, `no-cache` + ETag and a `?v=<hash>` URL. The URL namespace is shared with Vite's flat `dist/assets/*`, so an allowlist miss must fall through to the SPA handler. The SPA loads assets idempotently: a module-level `Map<url, Promise>`, `<script type="module">`, and `<link rel=stylesheet>` toggled `disabled` per active controller. Widgets are created imperatively and wait on `customElements.whenDefined`.
|
||||||
|
|
||||||
|
**Primary recommendation:** Four lean plans: (1) framework Go (pact interfaces, cabana YAML/boot rules, action/partial/asset routes, sanitizer, OpenAPI regen, acme fixture); (2) framework SPA (loader, WidgetField, PartialHost, list-header slot, custom toolbar buttons, dist rebuild); (3) fonoteka.go Albums (stats strip, Discogs widget stub, `discogsSync` stub, assets, lang, fixed existing tests); (4) unit tests plus `scripts/check-phase10.1.sh`, security review and validation evidence (last plan, per CLAUDE.md rule 3). Present this count at the plan-count checkpoint (CLAUDE.md rule 2).
|
||||||
|
|
||||||
|
## Architectural Responsibility Map
|
||||||
|
|
||||||
|
| Capability | Primary Tier | Secondary Tier | Rationale |
|
||||||
|
|------------|-------------|----------------|-----------|
|
||||||
|
| YAML parsing/validation of widget, partial, headerPartial, toolbar names | API / Backend (cabana boot) | — | Fail-loud boot is an established pattern; the SPA only renders what it receives (T-10-18) |
|
||||||
|
| Action dispatch (widget + toolbar POST), permission, scoping, CSRF | API / Backend (cabana routes) | Plugin Go handler (business logic) | Plugins cannot mount routes under the prefix; cabana owns auth/CSRF/scope |
|
||||||
|
| Fill-key filtering | API / Backend | Browser (defence in depth) | Server drops non-declared keys; SPA patches only `field.fill` keys |
|
||||||
|
| Partial rendering + sanitization | API / Backend (html/template + x/net/html allowlist) | Browser (h() allowlist re-check) | Keeps HTML strings away from the DOM parser (D-17) |
|
||||||
|
| Partial view model | Plugin controller (Go) | — | D-10 curated struct; cabana supplies the scoped record |
|
||||||
|
| Static JS/CSS serving | Frontend server (cabana route, boardwalk-style headers) | — | Same origin, `script-src 'self'` (D-16) |
|
||||||
|
| Asset loading, custom-element mounting, event → POST | Browser (admin SPA) | — | D-05: SPA owns HTTP |
|
||||||
|
| Widget UI (button, busy state) | Browser (plugin custom element) | — | D-04: plain JS custom elements |
|
||||||
|
| Stats numbers | Database via plugin controller | — | Must reuse the collection scope (`scopeAlbums`) |
|
||||||
|
|
||||||
|
## Project Constraints (from CLAUDE.md)
|
||||||
|
|
||||||
|
- Lean planning: few, large plans. Before writing PLAN.md files, present the plan count with a one-line scope each and **wait for confirmation**.
|
||||||
|
- Unit tests are always the **last plan** of the phase; earlier plans may carry smoke tests.
|
||||||
|
- Go: stdlib first (`net/http` ServeMux, `html/template`, `encoding/json`). Add a dependency only when the research doc or a phase decision names it. **This document names `golang.org/x/net/html`** (already `golang.org/x/net v0.58.0 // indirect` in `go.mod`); the planner should record it as a phase decision.
|
||||||
|
- `go vet ./...` and `go test ./...` green at every commit, in both repos.
|
||||||
|
- Compiled plugins only; no runtime plugin loading (stdlib `plugin`, yaegi).
|
||||||
|
- Two repositories: `summercms.go` is framework-only and must contain no Płytarium names (hygiene-enforced). `fonoteka.go` holds the Albums work. Planning docs stay in `summercms.go/.planning`.
|
||||||
|
- Commits: no co-author tags; one logical change per commit; planning docs and code in separate commits.
|
||||||
|
- Core plugin contracts (user, blog, pages, payment) are not changed. This phase touches none of them.
|
||||||
|
- API parity is the acceptance test for Płytarium *API* routes. Admin routes are framework-owned and not part of the Nuxt parity contract.
|
||||||
|
- Adding a permission code to fonoteka diverges from the Winter `registerPermissions()` catalog (`admin_permissions.go` "preserves the plugin's Winter registerPermissions() catalog"). **Reuse `golem15.fonoteka.access_albums`** for both stub actions.
|
||||||
|
|
||||||
|
## Standard Stack
|
||||||
|
|
||||||
|
### Core (all already in the tree; nothing new to install except promoting one indirect Go module)
|
||||||
|
| Library | Version | Purpose | Why Standard |
|
||||||
|
|---------|---------|---------|--------------|
|
||||||
|
| `html/template` (stdlib) | Go 1.27.0 | Partial rendering with contextual autoescaping | Locked by D-10; `Clone()` + `Funcs()` binds per-request `trans` [VERIFIED: `go doc html/template Template.Clone` — "It returns an error if t has already been executed." / `Template.Funcs` — "Funcs may be called more than once, including after parsing (for example, after Template.Clone), to replace a function of the same name"] |
|
||||||
|
| `golang.org/x/net/html` | v0.58.0 (already `// indirect` in go.mod) | Parse partial output into a node tree for the allowlist walk | HTML5-spec tokenizer from the Go team. Promoting it adds no new module to go.sum [VERIFIED: summercms.go/go.mod `golang.org/x/net v0.58.0 // indirect`; `go doc golang.org/x/net/html ParseFragment` resolves from the module cache] |
|
||||||
|
| `github.com/goccy/go-yaml` | v1.19.2 | fields.yaml / config_list.yaml decode (existing) | Existing; `DisallowUnknownField` path in `decodeStrict` |
|
||||||
|
| swag (via `go run`) | v1.16.6 | Admin OpenAPI annotations | Existing pipeline. **Recursive struct probed OK this session** (self `$ref` emitted) |
|
||||||
|
| openapi-typescript | 7.13.0 (admin devDependency, exact pin) | TS types | Existing. Probed this session: emits `children?: components["schemas"]["…PartialNode"][]` |
|
||||||
|
| Vue | 3.5.35 (exact pin) | `h()` render of the node tree, `provide/inject` for form values | Existing |
|
||||||
|
| happy-dom | 20.11.6 | Vitest DOM | Probed this session: supports `customElements.define/whenDefined`, bubbling/composed `CustomEvent`, and fires `error` on an unreachable `<script src>` |
|
||||||
|
|
||||||
|
### Supporting
|
||||||
|
| Library | Version | Purpose | When to Use |
|
||||||
|
|---------|---------|---------|-------------|
|
||||||
|
| `crypto/sha256` (stdlib) | — | Asset ETag and `?v=` cache-buster | At boot, per declared asset |
|
||||||
|
| `testing/fstest`, `os.DirFS` | — | acme fixture plugin trees | Framework contract tests |
|
||||||
|
| testcontainers-go | v0.44.0 (existing) | Postgres for conformance and fonoteka acceptance | `--postgres`-style tests |
|
||||||
|
|
||||||
|
### Alternatives Considered
|
||||||
|
| Instead of | Could Use | Tradeoff |
|
||||||
|
|------------|-----------|----------|
|
||||||
|
| `x/net/html` | stdlib `encoding/xml` (`Strict=false`, `AutoClose=xml.HTMLAutoClose`, `Entity=xml.HTMLEntity`) | Zero dependency change, but it misparses ordinary HTML (unclosed `<li>`/`<p>`, void elements outside the list). Security is unaffected because we render our own tree, but plugin authors get surprising layouts. Use only if the user refuses to promote x/net |
|
||||||
|
| Server node tree | sandboxed iframe / DOMPurify + `v-html` / declarative shadow DOM | All rejected; see Pattern 4 |
|
||||||
|
| Two cabana action routes | one `…/actions/{action}` route | One route cannot tell which widget's `fill` list applies, and cannot stop a toolbar-only action being called as a widget (or the reverse) |
|
||||||
|
|
||||||
|
**Installation:** none. `go mod tidy` promotes `golang.org/x/net` to a direct requirement once `modules/cabana` imports `golang.org/x/net/html`. **No npm changes** (T-10-SC exact-pin gate stays untouched).
|
||||||
|
|
||||||
|
## Package Legitimacy Audit
|
||||||
|
|
||||||
|
| Package | Registry | Age | Downloads | Source Repo | Verdict | Disposition |
|
||||||
|
|---------|----------|-----|-----------|-------------|---------|-------------|
|
||||||
|
| golang.org/x/net (html subpackage) | Go module proxy | 10+ yrs | n/a (Go team module, already in go.sum as indirect) | go.googlesource.com/net | OK (not run through the seam: it supports npm/pypi/crates only; module already pinned in go.sum) | Approved; record as a phase decision |
|
||||||
|
|
||||||
|
**Packages removed due to [SLOP] verdict:** none
|
||||||
|
**Packages flagged as suspicious [SUS]:** none
|
||||||
|
**npm packages added:** none (DOMPurify explicitly rejected)
|
||||||
|
|
||||||
|
## Architecture Patterns
|
||||||
|
|
||||||
|
### System Architecture Diagram
|
||||||
|
|
||||||
|
```
|
||||||
|
Browser (admin SPA, origin = app, CSP script-src 'self')
|
||||||
|
┌───────────────────────────────────────────────────────────────────────────────────────┐
|
||||||
|
│ ListView / FormView │
|
||||||
|
│ │ GET schema/list | schema/form ──► schema.assets {scripts[], styles[]} │
|
||||||
|
│ ▼ │
|
||||||
|
│ pluginAssets.load(assets) ──(URL must start with runtime.base+'/assets/')──┐ │
|
||||||
|
│ │ Map<url,Promise>; <script type=module>; <link rel=stylesheet> │ │
|
||||||
|
│ ▼ │ │
|
||||||
|
│ WidgetField (type: widget) │ │
|
||||||
|
│ whenDefined(tag, timeout) → createElement(tag) → setAttribute(record-id, │ │
|
||||||
|
│ field-name, locale, fill-values JSON) → listen 'summer-action' │ │
|
||||||
|
│ │ event │ │
|
||||||
|
│ ▼ │ │
|
||||||
|
│ api.POST …/widgets/{field} {record_id, values} ─────────────────────┐ │ │
|
||||||
|
│ ◄── {data:{message, fill}} → patch ONLY field.fill keys → toast │ │ │
|
||||||
|
│ ListToolbar custom button → api.POST …/toolbar/{action} → toast │ │ │
|
||||||
|
│ PartialHost (headerPartial | type: partial) │ │ │
|
||||||
|
│ api.GET …/partials/{name}?id= → nodes[] → h() with allowlist │ │ │
|
||||||
|
└────────────────────────────────────────────────────────────────────────┼───┼──────────┘
|
||||||
|
│ │ GET /backend/assets/{v}/{p}/{file}?v=hash
|
||||||
|
Go binary ──────────────────────────────────────────────────────────────┼───┼───────────
|
||||||
|
surf ServeMux (plugins refused under prefix) │ ▼
|
||||||
|
├─ cabana API group (backend guard) ◄───────────────────────────────────┘ cabana asset route (public)
|
||||||
|
│ requireAjax (unsafe) → protect(controller perms) → action perms exact allowlist map lookup
|
||||||
|
│ → scoped record load (FormExtendQuery) → 404 if out of scope hit: MIME + nosniff + ETag + no-cache
|
||||||
|
│ → pact action Run(ctx, input) ──► plugin Go (stub now, Phase 14 real) miss: fall through to boardwalk SPA
|
||||||
|
│ → filter result.fill ⊆ field.fill → D-10 envelope
|
||||||
|
│ partials: PartialData(ctx,name,record) → view model
|
||||||
|
│ → html/template Clone+Funcs(trans) → Execute (size cap)
|
||||||
|
│ → x/net/html ParseFragment → allowlist walk (node/depth caps) → JSON nodes
|
||||||
|
└─ boardwalk SPA shell + dist/assets/* (immutable) plugin AdminFS (embed.FS)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Recommended Project Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
summercms.go/
|
||||||
|
├── modules/pact/capabilities.go # + AdminClientAssets, AdminAction(+Input/Result), HasAdminActions, AdminPartialData
|
||||||
|
├── modules/cabana/
|
||||||
|
│ ├── form_schema.go # widget/partial types + keys (widget, action, fill, path)
|
||||||
|
│ ├── list_schema.go # headerPartial; toolbar validation moved after decode
|
||||||
|
│ ├── extension.go # NEW: boot validation of widgets/partials/actions/assets (needs Writable)
|
||||||
|
│ ├── partial_render.go # NEW: template compile, render, x/net/html allowlist → []PartialNode
|
||||||
|
│ ├── plugin_assets.go # NEW: allowlist build + GET handler
|
||||||
|
│ ├── actions.go # NEW: widget + toolbar POST handlers
|
||||||
|
│ ├── http.go # mount the 3 API routes + asset route
|
||||||
|
│ ├── admin_openapi.go # annotations for the 3 API routes + new types
|
||||||
|
│ └── testdata/extension/ # NEW acme fixture tree (yaml, _*.htm, assets/js, assets/css)
|
||||||
|
├── modules/phrasebook/backend/lang/{en,pl}/lang.yaml # widget/partial failure strings
|
||||||
|
├── admin/src/app/pluginAssets.ts # NEW loader
|
||||||
|
├── admin/src/components/form/fields/WidgetField.vue # NEW
|
||||||
|
├── admin/src/components/form/fields/PartialField.vue # NEW (wraps PartialHost)
|
||||||
|
├── admin/src/components/form/formContext.ts # NEW provide/inject keys (values + patch)
|
||||||
|
├── admin/src/components/partial/PartialHost.vue # NEW h()-renderer + allowlist
|
||||||
|
├── admin/src/components/list/ListToolbar.vue # custom action buttons
|
||||||
|
├── admin/src/views/ListView.vue / FormView.vue # header slot, asset load, provide
|
||||||
|
├── admin/vite.config.ts # proxy `${devPrefix}/assets` in dev
|
||||||
|
└── scripts/check-phase10.1.sh # NEW gate (last plan)
|
||||||
|
|
||||||
|
fonoteka.go/plugins/golem15/fonoteka/
|
||||||
|
├── assets/js/discogs-lookup.js # <golem15-fonoteka-discogs-lookup>
|
||||||
|
├── assets/css/albums.css # scoped under the tag / .golem15-fonoteka-* classes
|
||||||
|
├── controllers/albums/_stats.htm # header partial
|
||||||
|
├── controllers/albums/config_list.yaml # headerPartial: stats; buttons: [create, delete, discogsSync]
|
||||||
|
├── models/album/fields.yaml # discogs: {type: widget, …}
|
||||||
|
├── controllers/albums_admin_controller.go# AdminClientAssets, AdminActions, PartialData
|
||||||
|
├── admin.go # extend //go:embed list
|
||||||
|
└── lang/{en,pl}/lang.yaml # action label, toast copy, stats labels
|
||||||
|
```
|
||||||
|
|
||||||
|
### Pattern 1: Controller capability interfaces (pact)
|
||||||
|
|
||||||
|
**What:** Three optional interfaces, type-asserted by cabana like every Phase 9/10 hook. None reuses `AdminAssets` (D-13).
|
||||||
|
|
||||||
|
```go
|
||||||
|
// pact/capabilities.go (proposed)
|
||||||
|
|
||||||
|
// AdminClientAssets is Winter's addJs/addCss for one admin controller (D-13).
|
||||||
|
// Paths are relative to the owning plugin's AdminFS and must live under assets/.
|
||||||
|
type AdminClientAssets interface {
|
||||||
|
AdminJS() []string
|
||||||
|
AdminCSS() []string
|
||||||
|
}
|
||||||
|
|
||||||
|
// AdminAction is one controller action a toolbar button or a widget runs (D-12, D-05).
|
||||||
|
type AdminAction struct {
|
||||||
|
Name string // identifier; toolbar.buttons entry or a widget's action:
|
||||||
|
Label string // phrase key; toolbar button text
|
||||||
|
Permissions []string // checked in addition to the controller's RequiredPermissions
|
||||||
|
Run func(ctx context.Context, in AdminActionInput) (AdminActionResult, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
type AdminActionInput struct {
|
||||||
|
Field string // widget field name; "" for a toolbar action
|
||||||
|
RecordID *uint64 // nil on create and for toolbar actions
|
||||||
|
Record any // record loaded through FormExtendQuery; nil when RecordID is nil
|
||||||
|
Values map[string]any // widget fill snapshot, already filtered to the field's fill keys
|
||||||
|
}
|
||||||
|
|
||||||
|
type AdminActionResult struct {
|
||||||
|
Message string // phrase key or text; localized by cabana
|
||||||
|
Fill map[string]any // widget write-back; keys outside the field's fill are dropped
|
||||||
|
}
|
||||||
|
|
||||||
|
type HasAdminActions interface{ AdminActions() []AdminAction }
|
||||||
|
|
||||||
|
// AdminPartialData supplies the curated view model a controller partial renders (D-10).
|
||||||
|
// record is the scoped record for a form partial on an existing record, else nil.
|
||||||
|
type AdminPartialData interface {
|
||||||
|
PartialData(ctx context.Context, name string, record any) (any, error)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`pact.SettingsItem` already carries func fields (`NewModel func() any`), so a func field in a pact struct has precedent [VERIFIED: modules/pact/capabilities.go:162-175 — `Form string \`json:"-"\`` / `NewModel func() any \`json:"-"\``]. **Do not add `IDs []uint64` for toolbar selections in v1.** Unscoped ids would be an IDOR footgun, and adding them later is additive.
|
||||||
|
|
||||||
|
### Pattern 2: YAML contract (discretion resolved)
|
||||||
|
|
||||||
|
**fields.yaml widget** (Winter-shaped `type: widget` + `widget:`):
|
||||||
|
```yaml
|
||||||
|
discogs:
|
||||||
|
label: golem15.fonoteka::lang.discogs.lookup_label
|
||||||
|
type: widget
|
||||||
|
widget: golem15-fonoteka-discogs-lookup # custom-element tag
|
||||||
|
action: discogsLookup # a registered AdminAction name
|
||||||
|
fill: [year, format] # writable scalar fields only
|
||||||
|
span: right
|
||||||
|
```
|
||||||
|
**fields.yaml partial** (Winter `path:`, identifier only):
|
||||||
|
```yaml
|
||||||
|
summary:
|
||||||
|
type: partial
|
||||||
|
path: summary # → {ConfigDir}/_summary.htm
|
||||||
|
```
|
||||||
|
**config_list.yaml:**
|
||||||
|
```yaml
|
||||||
|
headerPartial: stats # → {ConfigDir}/_stats.htm
|
||||||
|
toolbar:
|
||||||
|
buttons: [create, delete, discogsSync]
|
||||||
|
```
|
||||||
|
|
||||||
|
Boot rules (all `bootErr`, all fail-closed):
|
||||||
|
1. `widget`, `action`, `fill` are allowed only on `type: widget`, and `path` only on `type: partial`. A key on the wrong type is an error.
|
||||||
|
2. The `widget` tag matches `^[a-z][a-z0-9]*(-[a-z0-9]+)+$`, **starts with `{vendor}-{plugin}-`** of the owning plugin, and is not a reserved name. Per MDN, reserved names are "annotation-xml", "color-profile", "font-face", "font-face-src", "font-face-uri", "font-face-format", "font-face-name", "missing-glyph" [CITED: developer.mozilla.org/en-US/docs/Web/API/CustomElementRegistry/define]. The prefix rule also makes cross-plugin collisions (`NotSupportedError` on define) impossible.
|
||||||
|
3. `action` names an entry in the controller's `AdminActions()`.
|
||||||
|
4. Every `fill` key is a field of the same form with a scalar type **and** present in `cc.Writable` (so not protected, and a model column). Validate after `BindWritableFields`.
|
||||||
|
5. A form with any widget requires the controller to implement `AdminClientAssets` with at least one JS file.
|
||||||
|
6. `path` / `headerPartial` pass `identifier()` and resolve to `{ConfigDir}/_{name}.htm`. The file must exist and parse, and the controller must implement `AdminPartialData`. Winter's `$/…` and `~/…` forms are a boot error with a hint (no free-form paths means no traversal surface).
|
||||||
|
7. `toolbar.buttons` names other than `create`/`delete` must be registered `AdminActions`. Duplicates stay an error.
|
||||||
|
8. Action `Permissions` are validated like relation permissions (`reg.validatePermissions`), and action `Label` keys go through `validateMessageKeys`.
|
||||||
|
9. Settings forms (`compileSetting`, which reuses `decodeFields`) reject `widget` and `partial`. A settings form has no controller for actions or view models.
|
||||||
|
|
||||||
|
### Pattern 3: Action routes (cabana-owned)
|
||||||
|
|
||||||
|
Plugins cannot mount routes under the prefix [VERIFIED: modules/surf/admin_prefix_test.go — `TestPhase10AdminPrefixCollision` "fails boot when a plugin other than cabana registers a route at or under the admin prefix"]. Mount inside the existing `r.GroupRaw(api, []string{"backend"}, …)`:
|
||||||
|
|
||||||
|
```go
|
||||||
|
g.Post("/{vendor}/{plugin}/{controller}/widgets/{field}", requireAjax(s.widgetAction))
|
||||||
|
constrainController(g); g.Where("field", "[A-Za-z_][A-Za-z0-9_]*")
|
||||||
|
g.Post("/{vendor}/{plugin}/{controller}/toolbar/{action}", requireAjax(s.toolbarAction))
|
||||||
|
constrainController(g); g.Where("action", "[A-Za-z_][A-Za-z0-9_]*")
|
||||||
|
g.Get("/{vendor}/{plugin}/{controller}/partials/{name}", s.partial) // ?id= optional
|
||||||
|
constrainController(g); g.Where("name", "[A-Za-z_][A-Za-z0-9_]*")
|
||||||
|
```
|
||||||
|
ServeMux has no conflict: the 5-segment POSTs have distinct literals (`widgets`, `toolbar`) and no other 5-segment POST exists; the 5-segment GET literal `partials` differs from `schema`. Handler order: `protect` (controller lookup, principal, controller perms) → the name must be declared in *this* surface (widget field / toolbar list / declared partial, else 404) → action perms (403) → decode the body with `DisallowUnknownFields` and trailing-token check (copy `decodeRelationMutation`) → if `record_id` is set, load through the `FormExtendQuery` scope **without** `FOR UPDATE` (`loadRecord` locks and is meant for write txs) → 404 if missing → `Run` → drop fill keys not in `field.fill` → `translateKey(message)` → `WriteData(200, AdminActionResult)`. Errors go through `writeCRUDError`, so `*cabana.ValidationError` maps to 422.
|
||||||
|
|
||||||
|
### Pattern 4: Partial host (D-17). Recommendation: server-sanitized node tree
|
||||||
|
|
||||||
|
| Option | Verdict | Evidence |
|
||||||
|
|--------|---------|----------|
|
||||||
|
| **Server node tree + Vue `h()`** | **Use** | No HTML string reaches a browser parser; passes the existing hygiene grep unchanged; theme/dark mode inherited; Go-testable; recursive type verified through swag → swagger2openapi → openapi-typescript this session |
|
||||||
|
| Sandboxed same-origin iframe | Reject | Every SPA response sets `X-Frame-Options: DENY` and `frame-ancestors 'none'` [VERIFIED: modules/boardwalk/boardwalk.go:32 `const contentSecurityPolicy = "frame-ancestors 'none'; base-uri 'none'; object-src 'none'; script-src 'self'"`; :164 `h.Set("X-Frame-Options", "DENY")`], so it needs a carve-out from T-10-04. `sandbox=""` gives an opaque origin, so the parent cannot auto-size it (fixed-height stats strip), the iframe doesn't get the `.dark` class or theme vars, and its subresource requests are cross-site (SameSite=Strict cookie not sent) [ASSUMED] |
|
||||||
|
| Sanitized slot (DOMPurify + `v-html`) | Reject | New npm dep breaks the T-10-SC exact-pin posture; `v-html` fails `--hygiene` [VERIFIED: scripts/check-phase10.sh:319 `grep -rnE 'v-html|innerHTML|outerHTML|insertAdjacentHTML' admin/src`] |
|
||||||
|
| Declarative shadow DOM | Reject | In an SPA, a client-side parser (`setHTMLUnsafe`/`DOMParser`/`innerHTML`) is needed to attach it, which reopens T-10-16. Shadow DOM encapsulates style but is not a script boundary [ASSUMED] |
|
||||||
|
|
||||||
|
**Server allowlist (mirror it in `PartialHost.vue`):**
|
||||||
|
- Tags: `div span p strong em b i u s small mark code pre br hr ul ol li dl dt dd h2 h3 h4 h5 h6 table thead tbody tfoot tr th td caption section header footer figure figcaption blockquote q abbr time data meter progress sup sub a img`.
|
||||||
|
- Global attrs: `class title lang dir role aria-* data-*`. Per-tag: `a[href]` only if it starts with exactly one `/` (not `//` or `/\`) or `#` (same rule as `safeRedirect`, T-10-17); `img[src]` same-origin `/` path only, plus `alt width height`; `td/th[colspan rowspan scope]`; `time[datetime]`; `data[value]`; `meter[value min max low high optimum]`; `progress[value max]`.
|
||||||
|
- Drop `id` (DOM clobbering and collisions), `style` (keeps a future `style-src` possible; plugins use `AdminCSS`) and every `on*`.
|
||||||
|
- Drop with subtree: `script style template iframe object embed noscript textarea title xmp svg math form input button select link meta base`. Any other unknown element is unwrapped (children kept).
|
||||||
|
- Caps: 64 KiB template output, 2 000 nodes, depth 32. Exceeding a cap is a 500 with a server log, never a partial render.
|
||||||
|
|
||||||
|
The template gets a framework root `{{ .Data }}` plus a `trans` func bound per request. Parse the pristine template once at boot with a placeholder `trans` (Funcs must exist before Parse); per request `Clone()` the never-executed pristine copy and `Funcs({"trans": bound})`. Guard D-10: if `reflect.TypeOf(vm)` equals the controller model type (`NewRecord()` or its elem), refuse with 500.
|
||||||
|
|
||||||
|
Hygiene extension (new gate, not an edit to the Phase 10 regex): also refuse `setHTML|setHTMLUnsafe|createContextualFragment|DOMParser|srcdoc|document\.write` in `admin/src`.
|
||||||
|
|
||||||
|
### Pattern 5: Asset serving (D-13..D-16)
|
||||||
|
|
||||||
|
- **URL layout:** plugin file `assets/js/lookup.js` → `{prefix}/assets/{vendor}/{plugin}/js/lookup.js?v={sha256[:12]}`. Files must live under the plugin's `assets/` directory; that prefix is stripped in the URL. `assets` is already a reserved vendor segment [VERIFIED: modules/cabana/registry.go:40 `var reservedVendorSegments = map[string]bool{"api": true, "assets": true, "login": true, "settings": true}`], so no controller can collide.
|
||||||
|
- **Boot:** for each controller implementing `AdminClientAssets`, validate each path (clean, starts with `assets/`, no `..`, extension `.js`/`.mjs`/`.css`), read it from that plugin's `AdminFS()` (the file must be in the plugin's `//go:embed` list), hash it, and store `map["vendor/plugin/js/lookup.js"] → {body, contentType, etag}`. A missing file fails boot.
|
||||||
|
- **Route:** `g.Get("/assets/{vendor}/{plugin}/{file...}", s.pluginAsset)` inside the existing `GroupRaw(s.adminPrefix(), nil, …)`, which is public like the SPA shell. Winter plugin assets are public too; they are static code with no data. Lookup is an **exact map key**. On a miss, delegate to `s.spa` so Vite's flat `dist/assets/*` is never shadowed.
|
||||||
|
- **Headers:** `Content-Type: text/javascript; charset=utf-8` or `text/css; charset=utf-8` (module scripts and nosniff'd stylesheets require them), `X-Content-Type-Options: nosniff`, the same CSP string, `Cross-Origin-Resource-Policy: same-origin`, `Referrer-Policy: same-origin`, `Cache-Control: no-cache`, `ETag: "<sha256>"`. Serve through `http.ServeContent` (If-None-Match, HEAD). **Do not** reuse boardwalk's `immutable` rule: it keys on the `assets/` prefix [VERIFIED: modules/boardwalk/boardwalk.go:142-146 `if strings.HasPrefix(name, "assets/") { w.Header().Set("Cache-Control", "public, max-age=31536000, immutable") }`] and plugin files are not content-hashed. Export boardwalk's `contentType`/`setSecurityHeaders` (e.g. `boardwalk.ContentType`, `boardwalk.SetSecurityHeaders`) rather than duplicating them.
|
||||||
|
- **Schema exposure:** add `Assets ControllerAssets` (`{"scripts":[...],"styles":[...]}`, always arrays) to both `ListSchema` and `FormView`, with full absolute URLs computed from the prefix.
|
||||||
|
|
||||||
|
### Pattern 6: SPA loader, widget mounting, event contract
|
||||||
|
|
||||||
|
- `pluginAssets.ts`: a module-level `Map<string, Promise<void>>`. Refuse any URL not starting with `${runtime.base}/assets/`. Scripts are `<script type="module" src>` appended to `document.head`; the promise resolves on `load` and rejects on `error`, and a rejected entry is deleted so a later navigation can retry. Modules execute once per URL per document anyway. Styles use `<link rel="stylesheet">`: track them per controller and set `disabled` on links not owned by the active controller (D-14; prevents CSS bleed). No `fetch(` is used, so the hygiene "direct fetch" rule holds.
|
||||||
|
- `WidgetField.vue`: `onMounted` → `await load(schema.assets)` (FormView calls it once; the widget awaits the shared promise) → `Promise.race([customElements.whenDefined(tag), timeout(5000)])`. Then `document.createElement(tag)` inside try/catch (an invalid name throws). Set **attributes only**: `record-id` ("" on create), `field-name`, `locale` (`schema.meta.locale`), `fill-values` (JSON of current values for `field.fill`). Append to a `ref` host div that Vue never renders children into, and add a listener for `summer-action`. Setting *properties* before upgrade shadows the class accessors [ASSUMED], so use attributes and update `fill-values` via `watch`.
|
||||||
|
- **Event:** `summer-action` (bubbles, composed); `detail` is ignored in v1. While the POST runs, set `busy` on the element and ignore repeat events. On success, patch each `k in field.fill` that is present in `result.fill` through the injected patch function, then `showToast(result.message)`. On error, show a danger toast and set `state="error"`. On load failure or timeout, render an error box styled like `UnsupportedField` with a new `backend::lang.form.widget_failed` phrase.
|
||||||
|
- **Form plumbing:** `FieldControlProps` carries only the field's own `modelValue` [VERIFIED: admin/src/components/form/control.ts:7-21], so add `formContext.ts` with typed `InjectionKey`s for a read-only `values` ref and `patch(name, value)`. `FormView` provides them; no prop drilling through FormGrid/FormField.
|
||||||
|
- **registry.ts:** register `widget` → WidgetField and `partial` → PartialField. Split "valueless" from "record-bound": `isRegistered()` must return false for `widget`/`partial` so `editablePayload` never sends them, while `needsRecord()` stays relation-manager only (widgets and partials render on create too).
|
||||||
|
- **ListView:** `headingButtons` stays `create`. The toolbar gets `delete` plus names present in `schema.toolbarActions` (label per request, permission-filtered). A click runs `api.POST('/{vendor}/{plugin}/{controller}/toolbar/{action}')` → toast → reload the list and the header partial. `PartialHost` sits between `<header>` and the table card when `schema.headerPartial` is set, and re-fetches after bulk delete and after a toolbar action.
|
||||||
|
|
||||||
|
### Pattern 7: OpenAPI wiring (D-15 pipeline)
|
||||||
|
|
||||||
|
Add annotation funcs in `modules/cabana/admin_openapi.go` for the three API routes (`@Security BackendBearer`, 200 plus 401/403/404, and 422 on the POSTs). Add response types `Envelope[AdminActionResult]` and `Envelope[PartialView]`, and request type `AdminActionRequest{RecordID *uint64 \`json:"record_id,omitempty"\`; Values map[string]any \`json:"values,omitempty"\`}`. Regenerate with `scripts/check-admin-openapi.sh` (no args writes `admin/openapi/admin.json` + `admin/src/api/schema.d.ts`; `--check` diffs). Update both inventories:
|
||||||
|
- `phase09Routes` in `modules/cabana/security_coverage_test.go` gets the 3 API routes and the asset route (`public: true, spa: true`). `TestPhase09PermissionMatrix` counts mounted routes, and `TestPhase09ContractInventory` requires `len(spec.Paths) == len(pathsOf(phase09Routes))`.
|
||||||
|
- `TestPhase10OpenAPIConformance` gets one case per new route (it decodes with unknown fields disallowed).
|
||||||
|
|
||||||
|
`admin/src/api/types.ts` may alias only `Schemas['cabana.X']` or **GET** path/query params [VERIFIED: scripts/check-phase10.sh:330 `grep -vE "= (Schemas\['cabana\.[A-Za-z_-]+'\]|NonNullable<[A-Za-z]+Path\['get'\]\['parameters'\]\['query'\]>|[A-Za-z]+Path\['get'\]\['parameters'\]\['path'\])\$"`]. Add `PartialNode`, `PartialView`, `AdminActionResult`, `ControllerAssets` and `ToolbarAction` as `Schemas[...]` aliases; leave POST path params inferred at call sites.
|
||||||
|
|
||||||
|
### Anti-Patterns to Avoid
|
||||||
|
- **Letting plugins register the action POST route.** Surf refuses it, and it would drop `requireAjax`/`protect` from framework control.
|
||||||
|
- **Validating custom toolbar names in `toolbarButtons.UnmarshalYAML`.** Decode has no controller. Move the check into `compileToolbarButtons(ctl, …)`.
|
||||||
|
- **Serving `AdminFS` wholesale under `/assets/`.** That exposes YAML and templates. Serve the exact allowlist only.
|
||||||
|
- **Passing the GORM model to the template**, or letting `PartialData` load the record itself by id (scope bypass). cabana loads the record through the scope; the controller only projects it.
|
||||||
|
- **Using `loadRecord` for reads.** It takes `FOR UPDATE`.
|
||||||
|
- **Mounting the custom element with `<component :is>`.** Vue picks property vs attribute by `key in el`, which flips at upgrade time. Create it imperatively with `setAttribute`.
|
||||||
|
- **Relying on Tailwind utility classes inside partial HTML or widget shadow DOM.** They're purged from the build. Use plugin CSS plus the theme custom properties (`var(--c-surface)`, `var(--c-text)`, …; they inherit into shadow roots).
|
||||||
|
|
||||||
|
## Don't Hand-Roll
|
||||||
|
|
||||||
|
| Problem | Don't Build | Use Instead | Why |
|
||||||
|
|---------|-------------|-------------|-----|
|
||||||
|
| HTML tokenizing/tree building of partial output | regex/string splitting | `golang.org/x/net/html.ParseFragment` | HTML5 parsing edge cases (implicit closes, entities, void elements) |
|
||||||
|
| Contextual escaping of view-model values | manual `html.EscapeString` | `html/template` autoescape | URL/CSS/attr contexts; `javascript:` → `#ZgotmplZ` [CITED: pkg.go.dev/html/template] |
|
||||||
|
| Conditional GET/HEAD for assets | custom 304 logic | `http.ServeContent` + `ETag` header | Handles If-None-Match, HEAD, Range |
|
||||||
|
| CSRF on new POSTs | new header check | existing `requireAjax` | Already walked by `TestPhase10CSRF` |
|
||||||
|
| Permission evaluation | new matcher | `Allows()` / `reg.validatePermissions` | Wildcard `.*` semantics already correct |
|
||||||
|
| Record scope for widget/partial | per-plugin query | `FormExtendQuery` hook via a cabana helper | Same scope as show/save (T-10-09 pattern) |
|
||||||
|
| Custom-element definition wait | polling `customElements.get` | `customElements.whenDefined` + timeout | Standard promise API (probed in happy-dom) |
|
||||||
|
| MIME map | new table | export boardwalk's `contentTypes`/`contentType` | One source for `.js`/`.mjs`/`.css` |
|
||||||
|
|
||||||
|
**Key insight:** every dangerous primitive here (HTML parsing, escaping, CSRF, scoping, caching) already exists in the stdlib, x/net or the Phase 9/10 code. The phase is mostly wiring plus allowlists.
|
||||||
|
|
||||||
|
## Common Pitfalls
|
||||||
|
|
||||||
|
### Pitfall 1: Toolbar validation lives in YAML decode
|
||||||
|
**What goes wrong:** `discogsSync` fails boot even after registration.
|
||||||
|
**Why:** [VERIFIED: modules/cabana/list_schema.go:288-289 `// toolbarActions are the built-in toolbar actions; custom actions are Phase 10.1.` / `var toolbarActions = map[string]struct{}{"create": {}, "delete": {}}`] and :306-307 `return fmt.Errorf("toolbar.buttons: unsupported action %s (want create or delete)", action)`, inside `UnmarshalYAML`.
|
||||||
|
**How to avoid:** Keep identifier and duplicate checks in decode. Resolve names against built-ins plus `AdminActions()` in `compileToolbarButtons(ctl, …)`, and update the error text and its tests.
|
||||||
|
|
||||||
|
### Pitfall 2: `type: partial` rejection is pinned by tests
|
||||||
|
**What goes wrong:** Lifting the rejection breaks the existing tests.
|
||||||
|
**Why:** [VERIFIED: modules/cabana/form_schema.go:344-346 `if typ == "partial" { return FormField{}, fmt.Errorf("type partial is not supported") }`]; `form_schema_test.go:224-232` and `:274-280` assert that `partial` fails boot, including a `path: $/golem15/acme/controllers/collections/_editors.htm` case.
|
||||||
|
**How to avoid:** Rewrite those cases. A partial without `path`, with a `$/…` path, or with a missing template must still fail, with the new messages.
|
||||||
|
|
||||||
|
### Pitfall 3: Existing fonoteka tests pin the Albums shape
|
||||||
|
**What goes wrong:** fonoteka `go test` goes red after the YAML edits.
|
||||||
|
**Why:** `admin_albums_test.go:132` asserts `len(schema.Fields) != 5`, `admin_albums_test.go:357` and `admin_phase10_copy_test.go:48` assert `toolbarButtons` is `"create,delete"`, and `admin_phase10_controllers_test.go:100` compares served fields to the tracked fields.yaml keys.
|
||||||
|
**How to avoid:** Update them in the same plan and commit as the YAML change (green at every commit).
|
||||||
|
|
||||||
|
### Pitfall 4: `valueless` vs `recordBound`
|
||||||
|
**What goes wrong:** Widgets are hidden on create, or widget/partial names leak into the save body.
|
||||||
|
**Why:** [VERIFIED: admin/src/components/form/registry.ts:45 `const recordBound = new Set<string>([RELATION_MANAGER])` and :52-54 `return renderers.has(type) && !recordBound.has(type)`]. `editablePayload` sends every `isRegistered` type.
|
||||||
|
**How to avoid:** Add a separate `valueless` set (`relation-manager`, `widget`, `partial`) for `isRegistered`; keep `needsRecord` relation-manager only. Server side, `scalarFormField` already skips non-scalar types in `BindWritableFields` [VERIFIED: modules/cabana/crud.go:797-800 `case "text", "textarea", "number", "checkbox", "switch", "dropdown": return true`].
|
||||||
|
|
||||||
|
### Pitfall 5: Shared `/assets/` namespace with Vite dist
|
||||||
|
**What goes wrong:** Plugin JS gets `immutable` caching, or a dist asset returns 404.
|
||||||
|
**How to avoid:** Use a separate cabana handler with `no-cache` + ETag + `?v=`. A miss falls through to the SPA handler. Add a test that `GET {prefix}/assets/index-*.js` still serves the dist file.
|
||||||
|
|
||||||
|
### Pitfall 6: Vite dev proxy
|
||||||
|
**What goes wrong:** Plugin assets 404 under `npm run dev`.
|
||||||
|
**Why:** [VERIFIED: admin/vite.config.ts `proxy: { [\`${devPrefix}/api\`]: { target: devTarget, changeOrigin: false } }`] proxies only `/api`.
|
||||||
|
**How to avoid:** Add a `${devPrefix}/assets` proxy entry. This is a config-only change, but still run `check-admin-dist.sh`.
|
||||||
|
|
||||||
|
### Pitfall 7: Hygiene rules the new SPA files must satisfy
|
||||||
|
The Phase 10 hygiene stage fails on: application names in `admin/src admin/tests admin/openapi modules/boardwalk modules/cabana modules/phrasebook` (regex `pl[yý]tarium|fonoteka|albumy|kolekcj|winyl|p[lł]yt[aęy]`, check-phase10.sh:315); any raw-HTML sink word, **including in comments** (:319); `fetch(` outside `client.ts` (:322); extra files in `admin/src/api` (:326); and any `admin/src` module not imported by a test (:350-357). The acme fixture, framework comments and test fixtures must avoid Płytarium/Discogs vocabulary; use "acme", "gadgets", "lookup".
|
||||||
|
|
||||||
|
### Pitfall 8: Committed dist drift
|
||||||
|
**What goes wrong:** `check-admin-dist.sh` fails in CI.
|
||||||
|
**Why:** Any change under `admin/src` (or new Tailwind classes) changes `modules/boardwalk/dist`. The script rebuilds from the lockfile and runs `diff -r` [VERIFIED: scripts/check-admin-dist.sh].
|
||||||
|
**How to avoid:** The SPA plan ends with `npm --prefix admin run build` and commits `modules/boardwalk/dist` in the same logical change. fonoteka embeds the framework through the go.work replace, so fonoteka UAT sees the SPA only after that commit. **Plugin JS/CSS never triggers a Node rebuild**; only SPA host changes do.
|
||||||
|
|
||||||
|
### Pitfall 9: OpenAPI inventory counts
|
||||||
|
**What goes wrong:** Adding a route without updating `phase09Routes` fails `TestPhase09PermissionMatrix` (mounted count) and `TestPhase09ContractInventory` (path count).
|
||||||
|
**How to avoid:** Update the inventory, annotations and conformance cases in the same commit, then run `scripts/check-admin-openapi.sh` and commit the regenerated files.
|
||||||
|
|
||||||
|
### Pitfall 10: html/template Clone after Execute
|
||||||
|
**What goes wrong:** `Clone()` "returns an error if t has already been executed" (go doc, verified).
|
||||||
|
**How to avoid:** Never execute the pristine boot template. Execute only clones.
|
||||||
|
|
||||||
|
### Pitfall 11: Stats scoped to the wrong collection
|
||||||
|
**What goes wrong:** An admin sees counts from another collection (information disclosure).
|
||||||
|
**How to avoid:** The stats query uses `albumsAdminController.scopeAlbums(ctx, db)`. Add a Postgres test with two collections that asserts isolation.
|
||||||
|
|
||||||
|
### Pitfall 12: Winter permission catalog
|
||||||
|
**What goes wrong:** A new `golem15.fonoteka.discogs_*` permission diverges from the PHP roles data.
|
||||||
|
**How to avoid:** Use `golem15.fonoteka.access_albums` for both actions.
|
||||||
|
|
||||||
|
### Pitfall 13: Name clash inside cabana
|
||||||
|
cabana already has `partialSelection` (bulk delete, crud.go:497). Name the new types `PartialView`/`PartialNode`/`compiledPartial` and keep them in their own file.
|
||||||
|
|
||||||
|
### Pitfall 14: Fill keys that are not in the form
|
||||||
|
**What goes wrong:** A patched value is silently not saved (`editablePayload` only sends form fields), or a protected key is targeted.
|
||||||
|
**How to avoid:** Boot rule 4 (fill ⊆ `cc.Writable`). Discogs `fill: [year, format]` needs a `year` field added to `models/album/fields.yaml` (the `Year *int` column exists) — see Open Question 1.
|
||||||
|
|
||||||
|
## Code Examples
|
||||||
|
|
||||||
|
### Partial render + allowlist (sketch)
|
||||||
|
```go
|
||||||
|
// Source: pattern composed from go doc html/template (Clone/Funcs) and x/net/html ParseFragment
|
||||||
|
func (p *compiledPartial) render(ctx context.Context, tr *phrasebook.Translator, data any) ([]PartialNode, error) {
|
||||||
|
t, err := p.pristine.Clone() // pristine is never executed
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
t.Funcs(template.FuncMap{"trans": func(key string) string { return translateKey(ctx, tr, key) }})
|
||||||
|
var buf bytes.Buffer
|
||||||
|
if err := t.Execute(&limitedWriter{w: &buf, n: 64 << 10}, map[string]any{"Data": data}); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
ctxNode := &html.Node{Type: html.ElementNode, Data: "div", DataAtom: atom.Div}
|
||||||
|
nodes, err := html.ParseFragment(&buf, ctxNode)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return sanitizeNodes(nodes, &budget{nodes: 2000, depth: 32}) // allowlist walk
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Plugin custom element (fonoteka, plain JS, no fetch)
|
||||||
|
```js
|
||||||
|
// assets/js/discogs-lookup.js — defined at module top level
|
||||||
|
class DiscogsLookup extends HTMLElement {
|
||||||
|
static observedAttributes = ['busy', 'state']
|
||||||
|
connectedCallback() {
|
||||||
|
const root = this.shadowRoot ?? this.attachShadow({ mode: 'open' })
|
||||||
|
if (!root.firstChild) {
|
||||||
|
const button = document.createElement('button')
|
||||||
|
button.type = 'button'
|
||||||
|
button.textContent = this.getAttribute('label') ?? 'Discogs'
|
||||||
|
button.addEventListener('click', () =>
|
||||||
|
this.dispatchEvent(new CustomEvent('summer-action', { bubbles: true, composed: true })))
|
||||||
|
root.append(button)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
attributeChangedCallback() {
|
||||||
|
const b = this.shadowRoot?.querySelector('button')
|
||||||
|
if (b) b.disabled = this.hasAttribute('busy')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!customElements.get('golem15-fonoteka-discogs-lookup')) {
|
||||||
|
customElements.define('golem15-fonoteka-discogs-lookup', DiscogsLookup)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
(A `label` attribute is not in D-08's set. Either the SPA also sets the field's localized label or the element uses fixed text. Planner's choice; keep it attribute-only.)
|
||||||
|
|
||||||
|
### SPA loader (sketch)
|
||||||
|
```ts
|
||||||
|
// admin/src/app/pluginAssets.ts
|
||||||
|
const scripts = new Map<string, Promise<void>>()
|
||||||
|
export function loadScript(url: string): Promise<void> {
|
||||||
|
if (!url.startsWith(`${runtime.base}/assets/`)) return Promise.reject(new Error('foreign asset'))
|
||||||
|
let p = scripts.get(url)
|
||||||
|
if (!p) {
|
||||||
|
p = new Promise<void>((resolve, reject) => {
|
||||||
|
const el = document.createElement('script')
|
||||||
|
el.type = 'module'
|
||||||
|
el.src = url
|
||||||
|
el.addEventListener('load', () => resolve(), { once: true })
|
||||||
|
el.addEventListener('error', () => { scripts.delete(url); reject(new Error(url)) }, { once: true })
|
||||||
|
document.head.appendChild(el)
|
||||||
|
})
|
||||||
|
scripts.set(url, p)
|
||||||
|
}
|
||||||
|
return p
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Stub action (fonoteka)
|
||||||
|
```go
|
||||||
|
func (c albumsAdminController) AdminActions() []pact.AdminAction {
|
||||||
|
perms := []string{"golem15.fonoteka.access_albums"}
|
||||||
|
return []pact.AdminAction{
|
||||||
|
{Name: "discogsLookup", Permissions: perms, Run: func(ctx context.Context, in pact.AdminActionInput) (pact.AdminActionResult, error) {
|
||||||
|
// Phase 14 replaces this stub with the Discogs client.
|
||||||
|
return pact.AdminActionResult{
|
||||||
|
Message: "golem15.fonoteka::lang.discogs.stub_filled",
|
||||||
|
Fill: map[string]any{"year": 1977, "format": "LP"},
|
||||||
|
}, nil
|
||||||
|
}},
|
||||||
|
{Name: "discogsSync", Label: "golem15.fonoteka::lang.discogs.sync_button", Permissions: perms, Run: func(ctx context.Context, _ pact.AdminActionInput) (pact.AdminActionResult, error) {
|
||||||
|
return pact.AdminActionResult{Message: "golem15.fonoteka::lang.discogs.stub_not_implemented"}, nil
|
||||||
|
}},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
The phrase keys above are **proposals** [ASSUMED]. The `discogs:` group already exists in `lang/pl/lang.yaml` (line 233); add the keys to both `en` and `pl`.
|
||||||
|
|
||||||
|
## State of the Art
|
||||||
|
|
||||||
|
| Old Approach | Current Approach | When Changed | Impact |
|
||||||
|
|--------------|------------------|--------------|--------|
|
||||||
|
| Winter `addJs`/`addCss` + server-rendered backend pages | Go controller method + SPA-loaded ES module custom elements | This phase | App devs still never need Node |
|
||||||
|
| Winter `type: partial` rendered inline as HTML | html/template → sanitized node tree → Vue `h()` | This phase | No raw-HTML sink in the SPA |
|
||||||
|
| Winter AJAX `onXxx` handlers on the controller | `pact.AdminAction.Run` dispatched from cabana routes | This phase | CSRF, auth and scope come from the framework |
|
||||||
|
| `innerHTML` + sanitizer libraries | Structured rendering (and, emerging, the browser Sanitizer API `setHTML`) | 2025-2026 | `setHTML` browser support not verified here; not used [ASSUMED] |
|
||||||
|
|
||||||
|
**Deprecated/outdated:** Phase 10 RESEARCH's "dev-mode switch serving plugin assets from disk". CONTEXT D-15 rejected it.
|
||||||
|
|
||||||
|
## Assumptions Log
|
||||||
|
|
||||||
|
| # | Claim | Section | Risk if Wrong |
|
||||||
|
|---|-------|---------|---------------|
|
||||||
|
| A1 | Sandboxed-iframe subresource requests don't carry the SameSite=Strict cookie, and an opaque-origin frame can't be auto-sized | Pattern 4 | Low. Only affects the rejected option |
|
||||||
|
| A2 | Declarative shadow DOM needs a client-side HTML parser in an SPA | Pattern 4 | Low. Rejected option |
|
||||||
|
| A3 | Setting properties on a not-yet-upgraded custom element shadows class accessors, so attributes are safer | Pattern 6 | Low. Attribute-only is safe regardless |
|
||||||
|
| A4 | Dynamically inserted same-origin `<script type=module src>` is allowed under `script-src 'self'` | Pattern 5/6 | Medium. Verify in a browser during UAT (happy-dom does not enforce CSP) |
|
||||||
|
| A5 | Proposed phrase keys, fixture fill `{year: 1977, format: "LP"}`, tag name, action names | Code Examples | Low. Discretion items |
|
||||||
|
| A6 | Adding a `year` field to the Albums admin form is acceptable | Pitfall 14 | Medium. User may prefer `fill: [format]` only |
|
||||||
|
| A7 | Browser `setHTML` Sanitizer API support status | State of the Art | None. Not used |
|
||||||
|
| A8 | Promoting `golang.org/x/net` from indirect to direct is acceptable under the stdlib-first rule | Standard Stack | Medium. Fallback is `encoding/xml` non-strict |
|
||||||
|
|
||||||
|
## Open Questions
|
||||||
|
|
||||||
|
1. **Which fields does the Discogs widget fill?**
|
||||||
|
- Known: the Albums form has `name, artists, format, genre, shelf`. `fill` must be writable scalars, and the model has `Year *int`, `Label`, `Country` and others.
|
||||||
|
- Recommendation: add `year` (`type: number`) to the Albums form and use `fill: [year, format]`. Fallback: `fill: [format]` only, with no form change.
|
||||||
|
2. **Promote `golang.org/x/net/html`?** Recommended; needs an explicit phase-decision note (CLAUDE.md rule 4). Fallback: `encoding/xml`.
|
||||||
|
3. **Stats strip numbers.** Recommend total albums in the active collection plus counts per `format` (one `GROUP BY format` query under `scopeAlbums`). Optionally add "without shelf". Discretion.
|
||||||
|
4. **Widget `label` attribute.** D-08 lists record-id, field name, locale and fill snapshot. Should the SPA also pass the field's localized label? Recommend yes, as an additive attribute. It is not a token or cookie, so D-08's intent holds.
|
||||||
|
5. **Plan count.** Proposed 4 plans (framework Go → framework SPA → fonoteka Albums → tests + gate). Must be confirmed at the checkpoint.
|
||||||
|
|
||||||
|
## Environment Availability
|
||||||
|
|
||||||
|
| Dependency | Required By | Available | Version | Fallback |
|
||||||
|
|------------|------------|-----------|---------|----------|
|
||||||
|
| Go toolchain | all Go work | ✓ | go1.27.0 | — |
|
||||||
|
| golang.org/x/net (module cache) | partial sanitizer | ✓ | v0.58.0 | encoding/xml |
|
||||||
|
| Node | admin build/tests | ✓ | v22.23.2 (engines `>=22.6`) | — |
|
||||||
|
| npm | `npm ci` | ✓ | 12.0.2 | — |
|
||||||
|
| admin/node_modules | vitest, openapi-typescript | ✓ | happy-dom 20.11.6, openapi-typescript 7.13.0 | `npm --prefix admin ci` |
|
||||||
|
| swag via `go run` | OpenAPI regen | ✓ (probed) | v1.16.6 | — |
|
||||||
|
| Docker | testcontainers Postgres | ✓ | 29.7.2 | — |
|
||||||
|
| psql / pg_isready | manual DB checks | ✓ | — | — |
|
||||||
|
| Real browser | CSP and custom-element UAT | manual | — | human UAT step |
|
||||||
|
|
||||||
|
**Missing dependencies with no fallback:** none.
|
||||||
|
**Missing with fallback:** none. A real-browser check of CSP/module loading is a manual UAT item (A4).
|
||||||
|
|
||||||
|
## Validation Architecture
|
||||||
|
|
||||||
|
### Test Framework
|
||||||
|
| Property | Value |
|
||||||
|
|----------|-------|
|
||||||
|
| Framework | Go `testing` (+ testcontainers Postgres); Vitest 3.2.7 + happy-dom 20.11.6 + @vue/test-utils 2.4.11 |
|
||||||
|
| Config file | `admin/vitest.config.ts` (`include: ['tests/**/*.test.ts']`, setup `tests/setup.ts`) |
|
||||||
|
| Quick run command | `go test ./modules/cabana -run 'TestPhase101' -count=1` and `npm --prefix admin test -- tests/form tests/list tests/app` |
|
||||||
|
| Full suite command | `go vet ./... && go test ./...` (both repos, fonoteka with `./plugins/golem15/fonoteka/... ./plugins/golem15/user/...`) and `npm --prefix admin run typecheck && npm --prefix admin test` |
|
||||||
|
| Phase gate | `scripts/check-phase10.1.sh --all` (new; reuses the `phase10_detect` fail-closed detector pattern) plus `scripts/check-phase10.sh --all` staying green |
|
||||||
|
|
||||||
|
### Phase Requirements → Test Map
|
||||||
|
| Decision | Behavior | Test Type | Automated Command | File Exists? |
|
||||||
|
|----------|----------|-----------|-------------------|-------------|
|
||||||
|
| D-06, D-09 | widget/partial types; key-per-type; tag regex + plugin prefix; unknown key fails | unit | `go test ./modules/cabana -run '^TestPhase101FormExtensionSchema$'` | ❌ Wave 0 |
|
||||||
|
| D-07 | fill ⊆ writable scalar fields (boot); response fill filtered server-side | unit + Postgres | `go test ./modules/cabana -run '^TestPhase101Actions$'` | ❌ |
|
||||||
|
| D-11 | `headerPartial` compile; missing `_x.htm` / parse error / missing `AdminPartialData` fails boot | unit | `go test ./modules/cabana -run '^TestPhase101PartialSchema$'` | ❌ |
|
||||||
|
| D-10, D-17 | sanitizer allowlist: script/on*/style/javascript:, `//` hrefs dropped; escaping of record data; caps; model-type guard | unit | `go test ./modules/cabana -run '^TestPhase101PartialSanitizer$'` | ❌ |
|
||||||
|
| D-12 | toolbar names resolved against AdminActions; unknown fails; label keys validated; permission-filtered in schema | unit | `go test ./modules/cabana -run '^TestPhase101Toolbar$'` | ❌ (update `list_schema_test.go`) |
|
||||||
|
| D-13, D-16 | asset allowlist; MIME; nosniff; CSP; CORP; ETag/304; traversal and undeclared file 404; dist fall-through | unit | `go test ./modules/cabana -run '^TestPhase101Assets$'` | ❌ |
|
||||||
|
| D-05 (server) | new POSTs refused without X-Requested-With | unit (auto) | `go test ./modules/cabana -run '^TestPhase10CSRF$'` | ✅ (auto-walks new routes) |
|
||||||
|
| D-12, D-05 | inventory + OpenAPI + conformance of 3 new routes | unit + Postgres | `go test ./modules/cabana -run '^(TestPhase09PermissionMatrix|TestPhase09ContractInventory|TestPhase10OpenAPIConformance)$'` and `scripts/check-admin-openapi.sh --check` | ✅ (extend) |
|
||||||
|
| D-14 | loader idempotent; foreign URL refused; retry after error; CSS disabled off-controller | unit (vitest) | `npm --prefix admin test -- tests/app/pluginAssets.test.ts` | ❌ |
|
||||||
|
| D-05, D-07, D-08 | WidgetField: attributes set, event → POST body, patch only fill keys, busy, timeout → error box, create mode (empty record-id) | unit (vitest) | `npm --prefix admin test -- tests/form/WidgetField.test.ts` | ❌ |
|
||||||
|
| D-17 | PartialHost renders tree via h(); unknown tag/attr dropped client-side; text stays text | unit (vitest) | `npm --prefix admin test -- tests/list/PartialHost.test.ts` | ❌ |
|
||||||
|
| D-03, D-12 | ListView header slot; custom toolbar button → POST → toast → reload | unit (vitest) | `npm --prefix admin test -- tests/list/ListToolbar.test.ts tests/views/ListView.test.ts` | ✅ (extend) |
|
||||||
|
| D-09 | registry: widget/partial registered, not in payload, render on create | unit (vitest) | `npm --prefix admin test -- tests/form/registry.test.ts tests/form/formState.test.ts` | ✅ (extend) |
|
||||||
|
| D-17 | no raw-HTML sinks incl. setHTML/DOMParser/srcdoc; plugin assets contain no `fetch(`/`XMLHttpRequest`/`document.cookie` | gate | `scripts/check-phase10.1.sh --hygiene` (+ `--self-test` plants) | ❌ |
|
||||||
|
| D-04 (dist) | committed dist matches the source | gate | `scripts/check-admin-dist.sh` | ✅ |
|
||||||
|
| D-01, D-02, D-03, D-12 | Albums: stats strip scoped per collection; widget stub fills; `discogsSync` toasts; limited admin 403; assets served | integration (Postgres) | `cd ../fonoteka.go && go test ./plugins/golem15/fonoteka -run '^TestPhase101AlbumsExtension$' -count=1` | ❌ |
|
||||||
|
| D-01 | framework repo has no Płytarium names | gate | `scripts/check-phase10.sh --hygiene` | ✅ |
|
||||||
|
| A4 | real browser loads module script under CSP; widget renders; fill then save persists | manual UAT | `summer serve` + browser | manual |
|
||||||
|
|
||||||
|
### Sampling Rate
|
||||||
|
- **Per task commit:** the quick run command for the touched side (cabana `-run TestPhase101` or the touched vitest files), plus `go vet ./...`.
|
||||||
|
- **Per wave merge:** the full suite in both repos, `scripts/check-admin-openapi.sh --check`, and `scripts/check-admin-dist.sh` after SPA changes.
|
||||||
|
- **Phase gate:** `scripts/check-phase10.1.sh --all` and `scripts/check-phase10.sh --all` green before `/gsd-verify-work`.
|
||||||
|
|
||||||
|
### Wave 0 Gaps
|
||||||
|
- [ ] `modules/cabana/testdata/extension/` acme fixture tree (controllers/gadgets config yaml, `_stats.htm`, `_summary.htm`, models fields/columns, `assets/js/lookup.js`, `assets/css/gadgets.css`)
|
||||||
|
- [ ] `modules/cabana/phase101_*_test.go` (schema, sanitizer, assets, actions, toolbar)
|
||||||
|
- [ ] `admin/tests/fixtures/extension.*.json` (list/form schema with assets, widget, partial, toolbarActions; partial nodes)
|
||||||
|
- [ ] `admin/tests/app/pluginAssets.test.ts`, `tests/form/WidgetField.test.ts`, `tests/form/PartialField.test.ts`, `tests/list/PartialHost.test.ts` (hygiene: every new src module must be imported by a test)
|
||||||
|
- [ ] `fonoteka.go/plugins/golem15/fonoteka/admin_phase101_albums_test.go`
|
||||||
|
- [ ] `scripts/check-phase10.1.sh` with `--self-test`, `--go`, `--security`, `--postgres`, `--spa`, `--openapi`, `--dist`, `--hygiene`, `--evidence`, `--all`
|
||||||
|
- No framework install needed.
|
||||||
|
|
||||||
|
## Security Domain
|
||||||
|
|
||||||
|
### Applicable ASVS Categories
|
||||||
|
|
||||||
|
| ASVS Category | Applies | Standard Control |
|
||||||
|
|---------------|---------|-----------------|
|
||||||
|
| V2 Authentication | no (unchanged) | existing backend guard, cookie/Bearer |
|
||||||
|
| V3 Session Management | yes (unchanged) | HttpOnly/Secure/SameSite=Strict cookie; plugin JS cannot read it (T-10-01) |
|
||||||
|
| V4 Access Control | yes | `protect()` + `AdminAction.Permissions` via `Allows`; scoped record load via `FormExtendQuery`; stats via `scopeAlbums` |
|
||||||
|
| V5 Input Validation | yes | strict YAML (`DisallowUnknownField`), JSON body `DisallowUnknownFields`, identifier path params, fill-key allowlists |
|
||||||
|
| V6 Cryptography | no | sha256 used for cache validation only |
|
||||||
|
| V12 Files and Resources | yes | exact asset allowlist, extension allowlist, no FS exposure |
|
||||||
|
| V13 API | yes | `requireAjax` on every new POST; OpenAPI conformance |
|
||||||
|
| V14 Configuration | yes | CSP `script-src 'self'`, nosniff, CORP same-origin, explicit MIME |
|
||||||
|
|
||||||
|
### Known Threat Patterns (proposed register rows for 10.1-SECURITY review)
|
||||||
|
|
||||||
|
| ID | Pattern | STRIDE | Standard Mitigation | Failing-when-broken test |
|
||||||
|
|----|---------|--------|---------------------|--------------------------|
|
||||||
|
| T-10.1-01 | Asset route serves undeclared files (YAML, templates) or traverses | Info Disclosure | exact-key allowlist built at boot; miss → SPA fall-through | `TestPhase101Assets` |
|
||||||
|
| T-10.1-02 | MIME sniffing / wrong type for module script | Tampering | explicit Content-Type + nosniff + CORP | `TestPhase101Assets` |
|
||||||
|
| T-10.1-03 | Stale plugin JS after rebuild | Tampering | `?v=hash` + `no-cache` + ETag | `TestPhase101Assets` |
|
||||||
|
| T-10.1-04 | CSRF on widget/toolbar POST | Tampering | `requireAjax` | `TestPhase10CSRF` (auto-walk), `TestPhase10Coverage` |
|
||||||
|
| T-10.1-05 | Action run without permission | EoP | `protect` + action perms; perms validated at boot | `TestPhase101Actions`, fonoteka limited-admin 403 |
|
||||||
|
| T-10.1-06 | IDOR via `record_id` / partial `?id=` | EoP / Info Disclosure | cabana loads through `FormExtendQuery`; 404 out of scope | `TestPhase101Actions`, `TestPhase101AlbumsExtension` |
|
||||||
|
| T-10.1-07 | Fill mass-assignment | Tampering | fill ⊆ writable (boot); server drops extra keys; SPA patches only `field.fill`; save still runs `ProjectWritableFields` + rules | `TestPhase101Actions`, `WidgetField.test.ts` |
|
||||||
|
| T-10.1-08 | XSS via partial (record data → markup) | Tampering | html/template escaping + x/net/html allowlist tree + SPA h() allowlist; no raw-HTML sink; extended hygiene | `TestPhase101PartialSanitizer`, `PartialHost.test.ts`, `check-phase10.1.sh --hygiene` |
|
||||||
|
| T-10.1-09 | View model over-exposure / cross-collection stats | Info Disclosure | curated VM; model-type guard; `scopeAlbums` | `TestPhase101PartialSanitizer`, `TestPhase101AlbumsExtension` |
|
||||||
|
| T-10.1-10 | Plugin JS abuses the same-origin session | EoP | plugin JS is trusted compiled code (TCB, like plugin Go); CSP `'self'`; HttpOnly cookie; hygiene scan of plugin `assets/**/*.js` for `fetch(`, `XMLHttpRequest`, `document.cookie` (D-05 convention) | `check-phase10.1.sh --hygiene` |
|
||||||
|
| T-10.1-11 | Custom-element / CSS collisions across plugins | Tampering | tag prefix `{vendor}-{plugin}-` at boot; CSS disabled off-controller | `TestPhase101FormExtensionSchema`, `pluginAssets.test.ts` |
|
||||||
|
| T-10.1-12 | DoS via huge partial output | DoS | 64 KiB / 2 000 nodes / depth 32 caps | `TestPhase101PartialSanitizer` |
|
||||||
|
| T-10.1-13 | Schema-supplied foreign script URL | Tampering | SPA refuses URLs outside `${base}/assets/`; CSP backup | `pluginAssets.test.ts` |
|
||||||
|
| T-10.1-SC | Supply chain | Tampering | no npm change; x/net promoted from indirect | `check-phase10.sh --spa` (npm ci) |
|
||||||
|
|
||||||
|
T-10-16's residual note changes: "plugin HTML reaches the DOM only as a sanitized node tree". T-10-04 is unchanged (no framing carve-out).
|
||||||
|
|
||||||
|
## Sources
|
||||||
|
|
||||||
|
### Primary (HIGH confidence)
|
||||||
|
- Codebase, read this session: `modules/cabana/{form_schema,list_schema,registry,http,csrf,prefix,schema,schema_types,contracts,crud,settings,messages,admin_openapi}.go`; `modules/boardwalk/boardwalk.go`; `modules/pact/capabilities.go`; `modules/surf/{router,admin_prefix_test}.go`; `modules/cabana/{security_coverage,phase09_contract,phase10_csrf,openapi_conformance}_test.go`; `scripts/{check-phase10,check-admin-openapi,check-admin-dist}.sh`; `admin/{package.json,vite.config.ts,vitest.config.ts}`; `admin/src/{api/types.ts,api/client.ts,app/runtime.ts,components/form/*,components/list/ListToolbar.vue,views/ListView.vue,views/FormView.vue,styles/main.css}`; fonoteka `admin.go`, `admin_permissions.go`, `controllers/*.go`, `controllers/albums/*.yaml`, `models/album.go`, `models/album/fields.yaml`, and existing admin tests.
|
||||||
|
- Probes run this session: swag v1.16.6 + `internal/tools/swagger2openapi` + openapi-typescript 7.13.0 on a recursive `PartialNode` (success); happy-dom 20.11.6 custom elements, whenDefined, CustomEvent, script error event.
|
||||||
|
- `go doc html/template Template.Clone` / `Template.Funcs`; `go doc golang.org/x/net/html ParseFragment`.
|
||||||
|
|
||||||
|
### Secondary (MEDIUM confidence)
|
||||||
|
- MDN `CustomElementRegistry.define` (valid names, reserved names, exceptions): https://developer.mozilla.org/en-US/docs/Web/API/CustomElementRegistry/define
|
||||||
|
- pkg.go.dev html/template (URL sanitization, ZgotmplZ): https://pkg.go.dev/html/template
|
||||||
|
|
||||||
|
### Tertiary (LOW confidence)
|
||||||
|
- Browser behaviour of sandboxed iframes and declarative shadow DOM (training knowledge; used only to reject options).
|
||||||
|
|
||||||
|
## Metadata
|
||||||
|
|
||||||
|
**Confidence breakdown:**
|
||||||
|
- Standard stack: HIGH. Everything is in-tree or probed; one indirect module is promoted.
|
||||||
|
- Architecture: HIGH. Constraints come from code read this session (surf prefix refusal, decode-time toolbar check, hygiene regexes, inventory tests).
|
||||||
|
- Pitfalls: HIGH. Each is tied to a file and line or an existing test.
|
||||||
|
- Browser runtime (CSP + module loading): MEDIUM. Needs manual UAT.
|
||||||
|
|
||||||
|
**Research date:** 2026-09-28
|
||||||
|
**Valid until:** 2026-10-28 (stable in-repo stack; re-check if Phase 11 changes cabana routing first)
|
||||||
Reference in New Issue
Block a user