Four plans: framework Go contracts and routes, framework SPA hosts, Albums proof in fonoteka.go, and unit tests with the phase gate. Adds ADMIN-07 to REQUIREMENTS.md and fills the Phase 10.1 roadmap goal, success criteria and plan list.
40 KiB
phase, plan, type, wave, depends_on, files_modified, autonomous, requirements, estimate, must_haves
| phase | plan | type | wave | depends_on | files_modified | autonomous | requirements | estimate | must_haves | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| 10.1-runtime-admin-extension-point | 02 | execute | 2 |
|
|
true |
|
|
|
Phase Goal
A plugin extends the compiled admin SPA without a Node rebuild: controller JS/CSS served same-origin from embedded files, type: widget custom elements whose actions the SPA posts, type: partial and list headerPartial rendered server-side without a raw-HTML sink, and registered toolbar actions (ADMIN-07).
Purpose: This is the only Node-built part of the phase; after it, application plugins extend the admin with Go, YAML, templates and plain JS alone (D-04). Decisions implemented: D-04, D-05, D-07 (client patch), D-08, D-09, D-12 (SPA), D-14, D-16 (loader prefix check), D-17 (client half); UI-SPEC surfaces S1 to S6.
Output: new SPA modules and components, updated form and list views, the backend::lang.extension.* strings, smoke tests importing every new module, rebuilt modules/boardwalk/dist.
Repo: summercms.go only. Code and planning docs in separate commits; never add co-author tags.
<execution_context>
@/.claude/gsd-core/workflows/execute-plan.md
@/.claude/gsd-core/templates/summary.md
</execution_context>
Planning notes
- Spec-less probe fallback skipped: no requirement IDs were mapped for Phase 10.1 before this planning run; ADMIN-07 is introduced by it. Truths come from CONTEXT D-01..D-17 and UI-SPEC surfaces S1-S6; every resolved UI-SPEC "UI Considerations" row assigned to the SPA is a truth above (the S1 host "partial / zero-one-many" row was dismissed in the UI-SPEC and needs no must-have; Albums-specific rows live in 10.1-03).
- Discretion resolved: the widget also receives
labelandbusy-labelattributes (RESEARCH Open Question 4; additive, no credential) so plugin JS carries no strings; widgets render on create and update; the partial client allowlist lives inpartialNodes.tsso 10.1-04 can unit-test it directly. - Tests here are smoke tests (CLAUDE.md rule 3); branch-level Vitest suites are 10.1-04.
Artifacts this phase produces
admin/src/app/pluginAssets.ts:loadScript(url),loadStyles(controllerId, urls),activateStyles(controllerId),loadControllerAssets(controllerId, assets)admin/src/components/form/formContext.ts:FORM_VALUES,FORM_PATCH,FORM_LOCALEInjectionKeysadmin/src/components/form/fields/WidgetField.vue,admin/src/components/form/fields/PartialField.vueadmin/src/components/partial/PartialHost.vue(propssource,name,recordId,variant: 'header' | 'field',reloadKey)admin/src/components/partial/partialNodes.ts:PARTIAL_TAGS,partialAttrAllowed(tag, name, value),renderPartialNodes(nodes)- registry.ts:
widgetandpartialrenderers,valuelessset, exportedgroupLabelled(type) - ListToolbar props
actions: ToolbarAction[],busyAction: string | null, eventaction: [name] - types.ts aliases
AdminActionRequest,AdminActionResult,ControllerAssets,ToolbarAction,PartialNode,PartialView - CSS kit classes
.summer-partial,.summer-stats,.summer-stat,.summer-stat__label,.summer-stat__value - Phrase keys
backend::lang.extension.busy,.widget_failed,.partial_failed,.action_failed(en, pl) - Custom-element contract: event name
summer-action; attributesrecord-id,field-name,locale,fill-values,label,busy-label,busy,state - Vite dev proxy entry
${devPrefix}/assets - Fixtures
tests/fixtures/extension.form-schema.json,extension.list-schema.json,extension.partial.json; smoke testtests/smoke/extension.smoke.test.ts
(2) New admin/src/app/pluginAssets.ts (D-14, D-16): a module-level Map<string, Promise<void>> of scripts. loadScript(url) rejects any URL that does not start with ${runtime.base}/assets/ (T-10.1-13), otherwise appends one <script type="module"> with that src to document.head, resolves on load, and on error deletes the map entry and rejects, so a later navigation can retry; a URL already in the map returns the same promise. loadStyles(controllerId, urls) creates one <link rel="stylesheet" data-summer-controller="{controllerId}"> per new URL (same prefix check). activateStyles(controllerId) sets disabled on every plugin link owned by another controller and clears it on this controller's links. loadControllerAssets(controllerId, assets) runs activateStyles and loadStyles, then resolves when every script promise settles. Plugin files load only through script and link elements; no other network call.
(3) New admin/src/components/form/formContext.ts with typed InjectionKeys FORM_VALUES (read-only Ref of AdminRecord), FORM_PATCH ((name: string, value: unknown) => void) and FORM_LOCALE (Ref of string). FormView provides the values read-only, patch as its existing update(name, value) (so the form turns dirty and that field's errors clear) and the locale from schema.meta.locale; when the schema arrives it starts loadControllerAssets(controllerId, schema.assets) without awaiting it before rendering.
(4) registry.ts: register widget → WidgetField; add const valueless = new Set([RELATION_MANAGER, 'widget']) and make isRegistered use it, so editablePayload never sends a widget, while needsRecord stays relation-manager only (widgets render on create, UI-SPEC S3); export groupLabelled(type), true for widget. FormField.vue: for a groupLabelled type render the visible label as <span id="{base}-label" class="font-semibold"> (same asterisk rule) instead of <label for>.
(5) New admin/src/components/form/fields/WidgetField.vue (imports ../control, never ../registry; D-04, D-05, D-07, D-08): a host <div :id="controlId" role="group" :aria-labelledby="${controlId}-label" :aria-describedby class="flex min-h-input items-center"> containing a ref div that Vue never renders children into. While loading, show a 42px by 160px radius-10 bg-skel bar and set aria-busy="true" on the group. Wait for loadControllerAssets of the form (idempotent) and Promise.race([customElements.whenDefined(field.widget), a 5000 ms timeout]); then create the element with document.createElement(field.widget) inside try/catch and append it to the ref div. Set attributes only: record-id (the record id, or "" on create), field-name, locale (FORM_LOCALE, falling back to currentLocale), fill-values (JSON of the current values of field.fill, kept current with a watch on FORM_VALUES; {} when empty), label (field.actionLabel, falling back to field.label) and busy-label (t('backend::lang.extension.busy')). Listen for summer-action: ignore it while busy; set the busy attribute and remove state; POST /{vendor}/{plugin}/{controller}/widgets/{field} through api with path {...source, field: field.name} and body {record_id: recordId ?? undefined, values}; on success call FORM_PATCH for each key of field.fill present in the response fill (never any other key, T-10.1-17) and showToast(result.data.data.message); on failure showToast(result.error?.error.message || t('backend::lang.extension.action_failed'), 'danger') and set state="error"; always remove busy. On a load failure or timeout, mount nothing and render the S5 box: UnsupportedField's class string with role="alert", CircleAlert 16px text-danger aria-hidden and t('backend::lang.extension.widget_failed'); when the text wraps the box switches to items-start with 10px vertical padding. Remove the listener on unmount. Never hand the element a token, cookie, Vue instance or function (T-10.1-18).
(6) Add the extension: group to modules/phrasebook/backend/lang/en/lang.yaml and pl/lang.yaml with keys busy, widget_failed, partial_failed and action_failed, using the exact en and pl strings from the UI-SPEC Copywriting Contract (TestPhase10SPAKeysResolve then proves every key the SPA uses resolves).
(7) Smoke: admin/tests/fixtures/extension.form-schema.json is an acme.demo.widgets form whose lookup field has widget acme-demo-lookup, action lookup, actionLabel "Look up" and fill [name], with assets.scripts holding one /admin-test/assets/acme/demo/js/lookup.js?v=abc URL; type it in typed.ts against cabana.Envelope-cabana_FormView. admin/tests/smoke/extension.smoke.test.ts defines an acme-demo-lookup element in the test before mount, stubs the script load, mounts the form route and proves: the attributes above are set; a summer-action POSTs {record_id, values} with X-Requested-With; a response fill containing name and a non-fill color patches only name and shows the toast; a second event while busy sends nothing; the save body omits lookup; the widget renders on create with record-id "". The test imports pluginAssets, formContext and WidgetField directly (hygiene rule: every src module imported by a test).
(8) Run npm --prefix admin run build and commit modules/boardwalk/dist with the code.
npm --prefix admin run typecheck && npm --prefix admin test -- tests/smoke && go test ./modules/phrasebook -run '^TestPhase10SPAKeysResolve$' -count=1 -v && scripts/check-admin-dist.sh && scripts/check-phase10.sh --hygiene
<fails_when>Any command exits non-zero; vitest prints "No test files found" or a "FAIL" line; the go test output lacks "--- PASS: TestPhase10SPAKeysResolve" or prints "no tests to run"; check-admin-dist.sh prints a diff; check-phase10.sh prints a line starting with "refuse:".</fails_when>
<acceptance_criteria>
- grep -c "\['widget', WidgetField\]" admin/src/components/form/registry.ts prints 1.
- grep -c "runtime.base" admin/src/app/pluginAssets.ts prints at least 1.
- grep -c 'widgets/{field}' admin/src/components/form/fields/WidgetField.vue prints at least 1 and grep -c 'customElements.whenDefined' admin/src/components/form/fields/WidgetField.vue prints at least 1.
- grep -c 'extension:' modules/phrasebook/backend/lang/en/lang.yaml and grep -c 'extension:' modules/phrasebook/backend/lang/pl/lang.yaml each print 1.
- The extension smoke test covers every behaviour listed in item (7) and passes.
</acceptance_criteria>
On a form with a widget field an admin sees the plugin element, clicks it, and gets only the declared fill fields updated (unsaved) with a toast, through the SPA's own authenticated POST.
(2) New admin/src/components/partial/partialNodes.ts (D-17, T-10.1-14): PARTIAL_TAGS (the same tag list as modules/cabana/partial_render.go), partialAttrAllowed(tag, name, value) (global class, title, lang, dir, role, aria-* and data-; a[href] only when it starts with exactly one "/" or with "#"; img[src] only a same-origin "/" path, plus alt, width, height; td/th colspan, rowspan, scope; time datetime; data value; meter value, min, max, low, high, optimum; progress value, max; never id, style or on) and renderPartialNodes(nodes) building VNodes with Vue h(): an allowed tag becomes h(tag, allowedAttrs, children), any other tag contributes only its children, text nodes become plain text children, and recursion stops at depth 32. It never parses a string and never uses a raw-HTML sink (the Phase 10 hygiene list also matches comments, so keep those words out of comments).
(3) New admin/src/components/partial/PartialHost.vue with props source (ControllerParams), name, recordId (optional), variant ('header' | 'field') and reloadKey (number): GET /{vendor}/{plugin}/{controller}/partials/{name} through api, with query id only when recordId is set; the root element has class summer-partial and renders renderPartialNodes. States (UI-SPEC S1, S2): the first load shows, for header, one full-width 80px radius-16 bg-skel block (aria-hidden) and, for field, a 44px radius-10 bg-skel bar, with aria-busy="true" on the host; a reloadKey change refetches while keeping the previous nodes visible with aria-busy and no skeleton; zero nodes render nothing (the header variant then occupies no gap); an error renders the S5 box (UnsupportedField geometry, role=alert, CircleAlert, t('backend::lang.extension.partial_failed')) at full width for header. No live region.
(4) New admin/src/components/form/fields/PartialField.vue wrapping PartialHost variant field with name = field.path, source and recordId from the props (on create it fetches without id). registry.ts registers partial → PartialField and adds partial to the valueless set and to groupLabelled; FormField.vue renders no label row for a groupLabelled field without a label; the host is role="group" labelled by the label span when there is one.
(5) ListView.vue (D-03, D-11): when schema.headerPartial is set, render <PartialHost variant="header"> between the page <header> and the list card; keep a partialReload counter passed as reloadKey and increment it after a successful bulk delete (after loadList in onDelete). Do not refetch on search, filter, sort or page changes.
(6) admin/src/styles/main.css @layer components: .summer-partial (14px/1.5, color: var(--c-text), overflow-wrap: anywhere; p, ul and ol margin: 0 0 8px with the last child margin 0; links in var(--c-text), underlined, with the 3px focus ring), .summer-stats (background var(--c-surface), 1px var(--c-border), radius 16px, box-shadow var(--c-shadow-card), padding 16px 20px, display: flex; flex-wrap: wrap; column-gap: 32px; row-gap: 8px, margin 0), .summer-stat (flex column-reverse, gap 4px, min-width 0), .summer-stat__label (13px/1.5, weight 400, var(--c-muted), margin 0, wraps) and .summer-stat__value (20px/1.2, weight 600, var(--c-text), font-variant-numeric: tabular-nums, margin 0), reading only existing --c-* variables.
(7) modules/cabana/README.md: a "Partial style kit and plugin CSS variables" subsection listing the kit classes with the recommended <dl class="summer-stats"> markup and the public variables plugin CSS may read (--c-bg, --c-surface, --c-subtle, --c-border, --c-border-strong, --c-text, --c-muted, --c-placeholder, --c-primary, --c-on-primary, --c-danger, --c-danger-soft, --c-hover, --c-sel, --c-skel, --c-ring), stating that plugins must not hardcode hex colours or rely on Tailwind utilities.
(8) Smoke: extension.list-schema.json (acme.demo.widgets list with headerPartial stats, assets, toolbarActions []) and extension.partial.json (a PartialView whose nodes include a dl.summer-stats strip, a script element node, an a with an onclick attribute and a javascript: href, and a text node holding angle brackets), both typed in typed.ts. Extend extension.smoke.test.ts: the strip renders with its classes; the script node, onclick and javascript: href never reach the DOM; the angle-bracket text renders as text; zero nodes render nothing; a 500 shows the partial_failed box while the table still renders; a bulk delete refetches the partial with prior nodes kept; a form partial renders on create without id and on update with id. Import partialNodes, PartialHost and PartialField.
(9) Rebuild with npm --prefix admin run build and commit modules/boardwalk/dist.
npm --prefix admin run typecheck && npm --prefix admin test -- tests/smoke && go test ./modules/phrasebook -run '^TestPhase10SPAKeysResolve$' -count=1 -v && scripts/check-admin-dist.sh && scripts/check-phase10.sh --hygiene
<fails_when>Any command exits non-zero; vitest prints "No test files found" or a "FAIL" line; the go test output lacks "--- PASS: TestPhase10SPAKeysResolve"; check-admin-dist.sh prints a diff; check-phase10.sh prints a line starting with "refuse:".</fails_when>
<acceptance_criteria>
- grep -c "\['partial', PartialField\]" admin/src/components/form/registry.ts prints 1.
- grep -c 'partials/{name}' admin/src/components/partial/PartialHost.vue prints at least 1 and grep -c 'headerPartial' admin/src/views/ListView.vue prints at least 1.
- grep -c '\.summer-stats' admin/src/styles/main.css prints at least 1 and grep -c 'overflow-wrap: anywhere' admin/src/styles/main.css prints at least 1.
- grep -c 'summer-stats' modules/cabana/README.md prints at least 1.
- scripts/check-phase10.sh --hygiene exits 0 (no raw-HTML sink, every new module imported by a test).
</acceptance_criteria>
A header partial above a list and a form partial in a row render the server's node tree as native, themed markup with loading, empty and error states, and hostile nodes never reach the DOM.
(2) ListView.vue (D-12): toolbarButtons keeps the declared order minus create, holding delete and every name present in schema.toolbarActions (the server already filtered them by permission; a name missing there is not rendered, never shown disabled). Pass actions (schema.toolbarActions) and busyAction to ListToolbar. onAction(name): ignore when busyAction is set; set busyAction; POST /{vendor}/{plugin}/{controller}/toolbar/{action} through api with body {}; on success showToast(result.data.data.message), then await loadList() and increment partialReload; on failure showToast(result.error?.error.message || t('backend::lang.extension.action_failed'), 'danger'); finally clear busyAction. No confirmation dialog. When the list schema arrives start loadControllerAssets(controllerId, schema.assets) (D-14); the table renders without waiting for it.
(3) ListToolbar.vue: props gain actions: ToolbarAction[] and busyAction: string | null; emits gain action: [name: string]. In the existing button loop, a name other than delete that has an entry in actions renders <Button variant="outline" size="md" :data-action="name"> with the entry's label as text (no icon), disabled and aria-busy="true" while busyAction equals it, independent of the selection count, emitting action. Delete keeps its Phase 10 behaviour.
(4) pluginAssets.ts: make sure activateStyles runs on every list and form mount (both views call loadControllerAssets), so the stylesheets of other controllers are disabled (UI-SPEC S6, T-10.1-16).
(5) admin/vite.config.ts: add a ${devPrefix}/assets proxy entry to devTarget with changeOrigin: false beside the existing /api entry, so plugin assets load under npm run dev (Pitfall 6).
(6) Smoke: extension.list-schema.json gains buttons [create, delete, recount] with toolbarActions [{name: recount, label: Recount}], plus a second list fixture variant where toolbarActions is empty. Extend extension.smoke.test.ts: the Recount button renders after Delete, is enabled with no selection, POSTs {} to .../toolbar/recount with X-Requested-With, toasts the message, reloads the list and refetches the header partial; with empty toolbarActions no Recount button renders; a 500 shows a danger toast; opening a second controller disables the first controller's stylesheet link.
(7) Rebuild with npm --prefix admin run build and commit modules/boardwalk/dist.
npm --prefix admin run typecheck && npm --prefix admin test && scripts/check-admin-dist.sh && scripts/check-phase10.sh --hygiene && go test ./modules/phrasebook ./modules/boardwalk -count=1
<fails_when>Any command exits non-zero; vitest prints "No test files found", a "FAIL" line or "Unhandled Rejection"; check-admin-dist.sh prints a diff; check-phase10.sh prints a line starting with "refuse:".</fails_when>
Run the Vite dev server against a running summer serve (SUMMER_ADMIN_DEV_TARGET) and open a list whose controller declares assets; open the browser network tab.
The plugin script and stylesheet load through the /assets proxy with 200 and a JavaScript/CSS content type; switching to another controller disables the first stylesheet link.
<why_human>The Vite dev proxy and real module loading run only in a browser; happy-dom does not load module scripts.</why_human>
<acceptance_criteria>
- grep -c 'toolbar/{action}' admin/src/views/ListView.vue prints at least 1.
- grep -c 'aria-busy' admin/src/components/list/ListToolbar.vue prints at least 1.
- grep -c '/assets' admin/vite.config.ts prints at least 1.
- The full Vitest suite passes, including the Phase 10 ListToolbar and ListView tests unchanged in behaviour for create and delete.
</acceptance_criteria>
A registered toolbar action appears in the list toolbar for admins who may run it, runs with busy state, toast, list reload and partial refetch, and plugin stylesheets stay scoped to their controller.
Source coverage (this plan)
| Source | Item | Task |
|---|---|---|
| CONTEXT | D-04 plain JS custom elements, no Vue in plugins | 1 |
| CONTEXT | D-05 SPA owns HTTP (widget POST) | 1 |
| CONTEXT | D-07 patch only fill keys (client) | 1 |
| CONTEXT | D-08 element attributes, no token or cookie | 1 |
| CONTEXT | D-09 widget and partial registered, valueless | 1, 2 |
| CONTEXT | D-03 / D-11 list-header slot above the table | 2 |
| CONTEXT | D-12 custom toolbar buttons, POST, toast | 3 |
| CONTEXT | D-14 assets load per controller | 1, 3 |
| CONTEXT | D-16 same-origin prefix check in the loader | 1 |
| CONTEXT | D-17 client half: h() renderer, no raw sink | 2 |
| UI-SPEC | S1-S6, Copywriting Contract framework keys, partial style kit, public CSS variables | 1-3 |
| RESEARCH | Pattern 6, Pitfalls 4, 6, 7, 8 | 1-3 |
<threat_model>
Trust Boundaries
| Boundary | Description |
|---|---|
| Server schema → SPA loader | Asset URLs from the schema decide which scripts run in the admin origin |
| Server node tree → DOM | Partial content from plugin templates becomes DOM nodes |
| SPA → plugin custom element | Same-origin plugin code receives data from the SPA and signals it through events |
STRIDE Threat Register
| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan |
|---|---|---|---|---|---|
| T-10.1-13 | Tampering | pluginAssets loader (foreign script URL from a schema) | medium | mitigate | loadScript and loadStyles refuse any URL outside ${runtime.base}/assets/; CSP script-src 'self' backs it up (Task 1). |
| T-10.1-14 | Tampering | PartialHost node rendering (XSS, client half) | high | mitigate | renderPartialNodes rebuilds only allowlisted tags and attributes with h(), keeps text as text, never parses strings; the Phase 10 hygiene stage refuses raw-HTML sinks in admin/src (Task 2). |
| T-10.1-16 | Tampering | plugin CSS bleeding across controllers | low | mitigate | activateStyles disables stylesheet links owned by other controllers on every list and form mount (Tasks 1, 3). |
| T-10.1-17 | Tampering | client-side fill write-back | medium | mitigate | WidgetField patches only keys in field.fill that the response returns; the save still goes through the server's writable projection (Task 1). |
| T-10.1-18 | Information Disclosure | plugin custom element receiving credentials | high | mitigate | The SPA sets attributes only (record id, field name, locale, fill snapshot, labels) and performs the POST itself; the session cookie stays HttpOnly and no token exists in JS (Phase 10 T-10-01) (Task 1). |
| T-10.1-SC | Tampering | npm dependencies | high | mitigate | No package added or re-pinned; the build uses the committed lockfile, and check-admin-dist.sh rebuilds from it. |
| </threat_model> |
<success_criteria>
- Widgets mount plugin elements and bridge summer-action to the typed POST, patching only fill keys.
- Header and form partials render allowlisted node trees with the UI-SPEC loading, empty and error states.
- Custom toolbar actions run from the list with busy state, toast, reload and partial refetch.
- Plugin assets load per controller from the admin origin only; no npm change; dist rebuilt and drift-free. </success_criteria>