Plan checker iteration 1 flagged unresolved research questions and a missing decision note for golang.org/x/net/html. D-18 approves x/net/html for the partial sanitizer; D-19 fixes the Discogs widget fill to [year, format]. STATE marks the phase ready to execute.
34 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 | 03 | execute | 3 |
|
|
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: D-01 requires the phase to land on Płytarium Albums, not only a toy fixture; D-02 keeps both Discogs actions as stubs that Phase 14 replaces. The work uses only Go, YAML, an html/template partial, plain JS and CSS; no Node step runs in this repository (D-04). Output: Albums controller capabilities, YAML edits, template, JS and CSS assets, pl and en copy, updated Phase 10 tests and a smoke test.
Repo: fonoteka.go only (commits land in ../fonoteka.go). The framework it needs (10.1-01 routes, 10.1-02 SPA dist) is consumed through the go.work replace. 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 the UI-SPEC "Application proof" rows.
- Discretion resolved per UI-SPEC and RESEARCH Open Questions 1 and 3: stats show the total, per-format counts and no-shelf; the widget fills
[year, format], which needs the newyearfield (D-19); action namesdiscogsLookupanddiscogsSync; both reusegolem15.fonoteka.access_albums(Pitfall 12, no Winter catalog divergence); the widget renders on create and update. - Albums has no real form partial; the form
type: partialpath is proven by the acme fixture in 10.1-01 (D-03 discretion). - Tests here are smoke tests; the full Albums acceptance test is 10.1-04 (CLAUDE.md rule 3).
Artifacts this phase produces
- fonoteka methods on
albumsAdminController:PartialData,AdminJS,AdminCSS,AdminActions; view-model typesalbumsStatsView(Total,Formats,NoShelf) andalbumsStatItem(Label,Count) - Stub actions
discogsLookup(fillyear,format) anddiscogsSync - Files:
controllers/albums/_stats.htm,assets/js/discogs-lookup.js(custom elementgolem15-fonoteka-discogs-lookup),assets/css/albums.css - YAML: config_list.yaml
headerPartial: stats,toolbar.buttons: [create, delete, discogsSync]; fields.yamlyear(number) anddiscogs(widget) - Lang keys (en, pl):
golem15.fonoteka::lang.item.year,stats.total,stats.no_shelf,discogs.lookup_button,discogs.lookup_label,discogs.lookup_comment,discogs.stub_filled,discogs.sync_button,discogs.stub_not_implemented - Test:
TestPhase101AlbumsSmoke(subtests stats, widget, assets, toolbar)
(2) Template controllers/albums/_stats.htm per the UI-SPEC partial style kit: a <dl class="summer-stats"> whose first <div class="summer-stat"> has <dt class="summer-stat__label">{{ trans "golem15.fonoteka::lang.stats.total" }}</dt><dd class="summer-stat__value">{{ .Data.Total }}</dd>, then {{ range .Data.Formats }} one item per format with {{ trans .Label }} and {{ .Count }}, then {{ if .Data.NoShelf }} an item with {{ trans "golem15.fonoteka::lang.stats.no_shelf" }} and {{ .Data.NoShelf }}. Kit classes only, no inline style, no plugin CSS. With zero albums it still renders the total item with 0.
(3) config_list.yaml gains top-level headerPartial: stats. admin.go's //go:embed list appends controllers/albums/_stats.htm. Add a stats: group with total and no_shelf to lang/en/lang.yaml ("All albums", "No shelf") and lang/pl/lang.yaml ("Wszystkie albumy", "Bez półki").
(4) Smoke test: new admin_phase101_smoke_test.go with TestPhase101AlbumsSmoke and its subtest stats: boot with bootDB and assembleTracer, create an admin with {"golem15.fonoteka.access_albums":1} through phase10CookieAdmin, bind its email to a collection with albumsFrontend, insert albums in that collection (two LP, one CD, one without shelf) and albums in a second collection; GET adminAPI("/golem15/fonoteka/albums/schema/list") shows "headerPartial":"stats"; GET adminAPI("/golem15/fonoteka/albums/partials/stats") with the cookie and locale en returns 200 whose node tree text contains "All albums", the admin's own total, "LP" with 2, "CD" with 1 and "No shelf", and never the other collection's total.
cd ../fonoteka.go && go vet ./... ./plugins/golem15/fonoteka/... && go test ./plugins/golem15/fonoteka -run '^(TestPhase101AlbumsSmoke|TestPhase10LangCatalog|TestAlbumsAdminList|TestAlbumsAdminRegistration|TestPhase10ControllerCopy)$' -count=1 -v
<fails_when>Non-zero exit; the output lacks "--- PASS: TestPhase101AlbumsSmoke" or "--- PASS: TestPhase101AlbumsSmoke/stats", or prints "no tests to run" or "--- SKIP"; boot fails with a "cabana: admin schema" error.</fails_when>
<acceptance_criteria>
- grep -c '^headerPartial: stats' ../fonoteka.go/plugins/golem15/fonoteka/controllers/albums/config_list.yaml prints 1.
- grep -c 'controllers/albums/_stats.htm' ../fonoteka.go/plugins/golem15/fonoteka/admin.go prints 1.
- grep -c 'scopeAlbums' ../fonoteka.go/plugins/golem15/fonoteka/controllers/albums_admin_controller.go prints at least 5 (the three stats queries plus the existing list and form scopes).
- grep -c 'summer-stat__value' ../fonoteka.go/plugins/golem15/fonoteka/controllers/albums/_stats.htm prints at least 2 and grep -c 'style=' ../fonoteka.go/plugins/golem15/fonoteka/controllers/albums/_stats.htm prints 0.
- TestPhase101AlbumsSmoke/stats proves the other collection's albums are not counted.
</acceptance_criteria>
An admin opening Albums gets a server-rendered statistics strip for their own collection above the list.
(2) Controller (D-02, D-13): AdminJS() returns assets/js/discogs-lookup.js; AdminCSS() returns assets/css/albums.css; AdminActions() returns discogsLookup with Label golem15.fonoteka::lang.discogs.lookup_button, Permissions [golem15.fonoteka.access_albums] and a Run that returns Message golem15.fonoteka::lang.discogs.stub_filled and Fill {"year": 1977, "format": "LP"}. A comment says Phase 14 replaces this stub with the Discogs client; the stub reads no record, no credential and makes no outbound call. Add compile-time assertions that albumsAdminController implements pact.AdminClientAssets, pact.HasAdminActions and pact.AdminPartialData.
(3) assets/js/discogs-lookup.js (D-04; plain ES module, no import, no Vue, no user-facing string, no network request, no cookie or storage access): a class extending HTMLElement with observedAttributes label, busy-label, busy and state. connectedCallback creates one light-DOM <button type="button"> if it does not exist yet, with text from the label attribute. A click dispatches new CustomEvent('summer-action', { bubbles: true, composed: true }) unless the element has busy. attributeChangedCallback keeps the button in sync: with busy present the button is disabled, has aria-busy="true" and shows the busy-label text; otherwise it is enabled and shows label. Define it only when customElements.get('golem15-fonoteka-discogs-lookup') is undefined.
(4) assets/css/albums.css styles only golem15-fonoteka-discogs-lookup button per the UI-SPEC S3 table, reading only public --c-* variables: height 42px, padding 0 16px, min-width 160px, border-radius 10px, 1px solid var(--c-border-strong), background var(--c-surface), color var(--c-text), font: inherit, font-weight 600, white-space nowrap, background var(--c-hover) on hover with a 150ms ease-out transition, :focus-visible outline 3px solid var(--c-ring) with offset 2px, and [disabled] opacity .6 with cursor not-allowed. No hex colours.
(5) admin.go's //go:embed list appends assets/js/discogs-lookup.js assets/css/albums.css. Lang en and pl, using the UI-SPEC strings: item.year ("Release year" / "Rok wydania"); in the existing discogs group lookup_button ("Load from Discogs" / "Wczytaj z Discogs"), lookup_label ("Discogs"), lookup_comment ("Fills in Release year and Format. Click Save to keep them." / "Uzupełnia Rok wydania i Format. Kliknij Zapisz, aby je zachować."), stub_filled ("Release year and Format were filled with test data. Save to keep them." / "Uzupełniono Rok wydania i Format danymi testowymi. Zapisz, aby je zachować.").
(6) Tests pinning the old shape, in the same commit: admin_albums_test.go's form field count becomes 7; admin_phase10_controllers_test.go's phase10BuiltinFieldTypes gains "widget" (the SPA ships its renderer since 10.1-02). Extend TestPhase101AlbumsSmoke with subtests widget (the form schema has the discogs field with widget, action and fill [year format], an actionLabel resolved to "Load from Discogs" in en and "Wczytaj z Discogs" in pl, and assets.scripts naming /plytadmin/assets/golem15/fonoteka/js/discogs-lookup.js?v=; creating an album then POSTing widgets/discogs with its record_id and values returns 200 with fill exactly {year: 1977, format: "LP"} and the resolved stub_filled message; a PUT carrying year 1977 and format LP persists both) and assets (GET the script and stylesheet URLs from the schema returns 200 with text/javascript; charset=utf-8 and text/css; charset=utf-8, nosniff and an ETag; GET /plytadmin/assets/golem15/fonoteka/models/album/fields.yaml is 404).
cd ../fonoteka.go && go vet ./... ./plugins/golem15/fonoteka/... && go test ./plugins/golem15/fonoteka -run '^(TestPhase101AlbumsSmoke|TestPhase10LangCatalog|TestAlbumsAdminForm|TestAlbumsAdminRegistration|TestAlbumsAdminCRUD|TestPhase10Controllers|TestPhase10AlbumRelations|TestPhase10AssembledAcceptance)$' -count=1 -v
<fails_when>Non-zero exit; the output lacks a "--- PASS" line for TestPhase101AlbumsSmoke/widget, TestPhase101AlbumsSmoke/assets, TestPhase10LangCatalog, TestPhase10Controllers or TestPhase10AssembledAcceptance, or prints "no tests to run" or "--- SKIP"; boot fails with a "cabana: admin schema" error.</fails_when>
<acceptance_criteria>
- grep -c 'action: discogsLookup' ../fonoteka.go/plugins/golem15/fonoteka/models/album/fields.yaml prints 1 and grep -c 'widget: golem15-fonoteka-discogs-lookup' ../fonoteka.go/plugins/golem15/fonoteka/models/album/fields.yaml prints 1.
- grep -cE 'fetch\(|XMLHttpRequest|document\.cookie|localStorage|sessionStorage' ../fonoteka.go/plugins/golem15/fonoteka/assets/js/discogs-lookup.js prints 0 and grep -cE '^\s*import\s' ../fonoteka.go/plugins/golem15/fonoteka/assets/js/discogs-lookup.js prints 0.
- grep -cE '#[0-9a-fA-F]{3,6}' ../fonoteka.go/plugins/golem15/fonoteka/assets/css/albums.css prints 0.
- grep -c 'golem15.fonoteka.access_albums' ../fonoteka.go/plugins/golem15/fonoteka/controllers/albums_admin_controller.go prints at least 2.
- grep -c 'assets/js/discogs-lookup.js' ../fonoteka.go/plugins/golem15/fonoteka/admin.go prints 1.
</acceptance_criteria>
The Albums form serves a Discogs lookup widget whose stub returns Release year 1977 and Format LP into the unsaved form, with its plain-JS element and stylesheet served from the plugin's embedded files.
(2) Tests pinning the old toolbar, in the same commit: admin_albums_test.go's expected "toolbarButtons":["create","delete"] for Albums becomes ["create","delete","discogsSync"]; admin_phase10_copy_test.go keeps "create,delete" for the other four controllers and expects "create,delete,discogsSync" for albums (a per-case expectation). Extend TestPhase101AlbumsSmoke with subtest toolbar: the developer admin's list schema has toolbarActions [{name: discogsSync, label: "Sync with Discogs"}] in en and the pl label in pl; POST adminAPI("/golem15/fonoteka/albums/toolbar/discogsSync") with {}, the cookie and X-Requested-With returns 200 with the resolved stub_not_implemented message and an empty fill; the same POST without X-Requested-With is 403; an admin whose role grants only golem15.fonoteka.access_genres gets 403 on the toolbar, widget and partial routes.
(3) Run the whole fonoteka plugin suite and the Phase 10 hygiene gate from summercms.go to confirm nothing application-specific leaked into the framework.
(cd ../fonoteka.go && go vet ./... ./plugins/golem15/fonoteka/... && go test ./plugins/golem15/fonoteka/... -count=1 && go test ./plugins/golem15/fonoteka -run '^(TestPhase101AlbumsSmoke|TestAlbumsAdminList|TestPhase10ControllerCopy)$' -count=1 -v) && scripts/check-phase10.sh --hygiene
<fails_when>Any command exits non-zero; the verbose run lacks "--- PASS: TestPhase101AlbumsSmoke/toolbar", "--- PASS: TestAlbumsAdminList" or "--- PASS: TestPhase10ControllerCopy", or prints "no tests to run" or "--- SKIP"; check-phase10.sh prints a line starting with "refuse:".</fails_when>
In ../fonoteka.go run summer serve (or the built binary's serve command), open http://localhost:8080/plytadmin, log in as an admin bound to a collection and open Albumy with the browser console open. Then: (a) check the statistics strip above the list; (b) click "Synchronizuj z Discogs"; (c) open an album, click "Wczytaj z Discogs", then Zapisz and reload; (d) open the create form; (e) narrow the window to 768px in pl and repeat (a) to (c); (f) switch to another controller such as Gatunki.
No CSP or module-loading error in the console (RESEARCH A4: the same-origin module script loads under script-src 'self'); the strip shows Wszystkie albumy with the collection's counts and wraps without horizontal scroll at 768px with labels such as "Cassette (MC)" and "Bez półki" readable; the toolbar action shows its test-mode toast; the widget button shows its label, turns busy during the request, fills Rok wydania 1977 and Format LP without saving, and after Zapisz and reload both values persist; the widget also renders on the create form; button labels do not truncate at 768px; on Gatunki the Albums stylesheet no longer applies.
<why_human>CSP enforcement, real module loading and custom-element upgrade happen only in a browser, and the 768px layout backstops need visual judgment; Vitest's happy-dom does not enforce CSP.</why_human>
<acceptance_criteria>
- grep -c 'buttons: \[create, delete, discogsSync\]' ../fonoteka.go/plugins/golem15/fonoteka/controllers/albums/config_list.yaml prints 1.
- grep -c 'discogsSync' ../fonoteka.go/plugins/golem15/fonoteka/controllers/albums_admin_controller.go prints at least 1 and grep -c 'stub_not_implemented' ../fonoteka.go/plugins/golem15/fonoteka/lang/pl/lang.yaml prints 1.
- TestPhase101AlbumsSmoke/toolbar proves the limited admin's 403 on the toolbar, widget and partial routes.
- The whole go test ./plugins/golem15/fonoteka/... run passes.
</acceptance_criteria>
The Albums list offers a permission-gated "Sync with Discogs" action that toasts from its stub, and the three Albums surfaces are ready for the in-browser check.
Source coverage (this plan)
| Source | Item | Task |
|---|---|---|
| CONTEXT | D-01 proof on Albums (strip, widget, toolbar) | 1, 2, 3 |
| CONTEXT | D-02 enabled widget button, stub endpoint with fixture payload | 2, 3 |
| CONTEXT | D-03 strip is list chrome via headerPartial | 1 |
| CONTEXT | D-04 plain JS custom element | 2 |
| CONTEXT | D-06, D-07 widget YAML shape and fill write-back | 2 |
| CONTEXT | D-10 curated view model, escaping on | 1 |
| CONTEXT | D-11 headerPartial declared in config_list.yaml | 1 |
| CONTEXT | D-12 third toolbar action registered on the controller | 3 |
| CONTEXT | D-13 to D-16 controller JS/CSS through AdminJS/AdminCSS, embedded, same-origin | 2 |
| UI-SPEC | Application proof rows, S1 populated/zero/overflow, backstops | 1-3 |
| RESEARCH | Open Questions 1, 3; Pitfalls 3, 11, 12, 14; A4 manual UAT | 1-3 |
<threat_model>
Trust Boundaries
| Boundary | Description |
|---|---|
| Admin session → Albums stats view model | Counts must stay inside the admin's resolved collection |
| Plugin JS → admin origin | Same-origin script shipped by the application |
| Admin → Discogs stub actions | Stub endpoints reachable by any admin who passes the Albums permission |
STRIDE Threat Register
| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan |
|---|---|---|---|---|---|
| T-10.1-10 | Elevation of Privilege | Albums plugin JS running in the admin origin | medium | mitigate | Plugin JS is trusted compiled code (like plugin Go); the element makes no network request and reads no cookie or storage, and signals only through summer-action (D-05); the session cookie is HttpOnly; 10.1-04's hygiene stage scans plugin asset JS for network and cookie access (Task 2). |
| T-10.1-15 | Information Disclosure | Albums statistics strip | high | mitigate | Every stats query is built fresh and scoped with scopeAlbums (collection binding, 1 = 0 when unbound); the view model holds labels and integers only; the smoke test and 10.1-04's two-collection test prove isolation (Task 1). |
| T-10.1-21 | Elevation of Privilege | discogsLookup and discogsSync stubs | medium | mitigate | Both require golem15.fonoteka.access_albums on top of the controller permission; a Genres-only admin gets 403 (Task 3). |
| T-10.1-22 | Tampering | stub fill payload | low | accept | The stub only patches the unsaved form; nothing persists until the admin saves, and the save runs the Album rules (year between 1889 and 2100, format in the option list). |
| T-10.1-SC | Tampering | dependencies | high | mitigate | No Go module or npm package added in fonoteka.go; the element is hand-written plain JS. |
| </threat_model> |
<success_criteria>
- The Albums list shows a collection-scoped statistics strip; the Albums form shows the Discogs lookup widget filling Release year and Format; the Albums toolbar offers Sync with Discogs, both Discogs actions as stubs.
- All application copy exists in pl and en; the Phase 10 Albums tests reflect the new shape; no framework file names the application. </success_criteria>