# Phase 12.1: User plugin admin screens - Pattern Map **Mapped:** 2026-10-04 **Files analyzed:** 47 new or modified files (22 framework, 25 plugin/application) **Analogs found:** 45 / 47 Path conventions used below: - Framework paths are relative to `summercms.go/` (the working directory). - `APP/` means `../fonoteka.go/` (sibling application repository). - `USER/` means `../fonoteka.go/plugins/golem15/user/` (the `sm-user-plugin` submodule; its own git repository). - All analog paths were checked with `git ls-files` in their own repository (framework, application, and the `sm-user-plugin` submodule). No mirror or generated path is named as an analog. `modules/boardwalk/dist`, `admin/openapi/admin.json` and `admin/src/api/schema.d.ts` are generated outputs: regenerate them, never hand-edit or copy from them. Names of new contracts (`AdminBulkAction`, `AdminRecordAction`, `ListRowStates`, `ForbiddenError`, `bulkActions:`, `recordActions:`, route shapes) are the RESEARCH.md recommendations and are still `[ASSUMED]` until the plan-count checkpoint confirms them. The patterns below hold whatever the final names are. ## File Classification ### Framework (`summercms.go`), lands first, tagged v0.1.3 | New/Modified File | Role | Data Flow | Closest Analog | Match Quality | |-------------------|------|-----------|----------------|---------------| | `modules/pact/capabilities.go` (modify: bulk action, record action, row-state contracts) | model (contract types) | request-response | same file, `AdminAction` / `HasAdminActions` lines 215-256 | exact | | `modules/cabana/actions.go` (modify: `bulkAction`, `recordAction` handlers) | controller | request-response | same file, `toolbarAction` 78-99, `widgetAction` 23-72, `runAction` 137-155 | exact | | `modules/cabana/crud.go` (modify: bulk action service, `ForbiddenError`, virtual fields, protected-key opt-in) | service | CRUD / batch | same file, `BulkDelete` 197-247, `writeCRUDError` 412-434, `lockScoped` 550-583 | exact | | `modules/cabana/extension.go` (modify: compile bulk and record actions into the one namespace) | service (boot compile) | transform | same file, `compileActions` 256-278 | exact | | `modules/cabana/list_schema.go` (modify: `bulkActions`, `invisible`, row state) | config compiler | transform | same file, `toolbarButtons` + `compileToolbarButtons` 298-363 | exact | | `modules/cabana/schema_types.go` (modify: `BulkAction` confirm, record actions, preview flag, row state, option types) | model (schema DTO) | transform | same file, `BulkAction` 49-52, `ListSchema` + `MarshalJSON` 56-121 | exact | | `modules/cabana/form_schema.go` (modify: `password`, `permissioneditor`, `preset`, `recordActions`, `preview` keys) | config compiler | transform | `modules/cabana/field_date.go` `compileDatepickerKeys` 44-132 | role-match | | `modules/cabana/field_permission.go` (new) | service (field type: compile, lift, store, project) | transform + CRUD | `modules/cabana/field_date.go` (compile half) and `modules/cabana/relation_field.go` `liftRelationValues` 354-422 (lift half) | role-match | | `modules/cabana/relation_field.go` (modify: locked options, writable protected key) | service | CRUD | same file, `FieldRelationContract` 22-42, `RelationOption` 52-55, line 198, `syncBelongsToMany` 497-537 | exact | | `modules/cabana/registry.go` (modify: `operationDeclared` for new ops) | service | request-response | same file, 122-141 | exact | | `modules/cabana/http.go` (modify: routes, schema filtering, row state in list) | route / controller | request-response | same file, routes 254-272, `listSchema` 702-729, `bulkDelete` 826-848, `protect` 932-950 | exact | | `modules/cabana/admin_openapi.go` (modify: swag stubs and body types) | config (API doc) | request-response | same file, `AdminBulkDelete` 379-397, `AdminToolbarAction` 437-456 | exact | | `modules/cabana/testdata//...` (new neutral `acme` fixture tree) | test fixture | file-I/O | `modules/cabana/testdata/extension/` (controllers/gadgets, models/gadget, lang) | exact | | `modules/cabana/*_test.go` (new: `TestBulkAction*`, `TestRecordAction*`, `TestPreview*`, `TestRowState*`, `TestPermissionEditor*`, `TestListSchemaBulkActions*`) | test | request-response | `modules/cabana/phase101_actions_test.go` (harness 81-318), `modules/cabana/bulk_test.go` | exact | | `admin/src/components/form/fields/PermissionEditorField.vue` (new) | component | request-response | `admin/src/components/form/fields/DropdownField.vue` | role-match | | `admin/src/components/form/fields/PasswordField.vue` (new) | component | request-response | `admin/src/components/form/fields/TextField.vue` | exact | | `admin/src/components/form/registry.ts` (modify) | config (registry) | transform | same file, lines 43-74 | exact | | `admin/src/components/list/ListToolbar.vue` + new bulk menu (S1) | component | event-driven | same file, 14-30 and 56-78 | exact | | `admin/src/views/ListView.vue` (modify: `onBulkAction`) | component (view) | request-response | same file, `onDelete` 199-228, `onAction` 231-250 | exact | | `admin/src/components/list/DataTable.vue` (modify: row state badges and classes, S4) | component | transform | same file, `` 232-242 | exact | | `admin/src/views/FormView.vue` (modify: preview mode, record actions, S2/S3) | component (view) | request-response | same file, `mode` line 54, `fields` 88-95, `redirectTarget` 236-240, `onDelete` 305-333 | exact | | `admin/src/app/router.ts`, `admin/src/app/winterUrl.ts` (modify: preview route and URL mapping) | route / utility | transform | same files, router 37 and 69-72, winterUrl 31-37 | exact | | `admin/src/api/types.ts` (modify: aliases) | model (types) | transform | same file, 36-44 | exact | | `admin/tests/**` (new tests and fixtures) | test | — | `admin/tests/list/ListToolbar.test.ts`, `admin/tests/form/DatepickerField.test.ts`, `admin/tests/fixtures/extension.*.json` | exact | | `docs/backend/{admin-controllers,forms,lists-and-filters,relation-manager,users-and-permissions,admin-spa}.md`, `modules/cabana/README.md`, `modules/pact/README.md` (modify) | docs | — | the same files (extend existing sections) | exact | | `scripts/check-phase12.1.sh` (new) | config (gate script) | batch | `scripts/check-phase12.2.sh` | exact | ### Plugin (`sm-user-plugin`, `USER/`) and application (`APP/`) | New/Modified File | Role | Data Flow | Closest Analog | Match Quality | |-------------------|------|-----------|----------------|---------------| | `USER/controllers/users_admin_controller.go` (new) | controller | CRUD | `APP/plugins/golem15/fonoteka/controllers/albums_admin_controller.go` | exact | | `USER/controllers/usergroups_admin_controller.go` (new) | controller | CRUD | `APP/plugins/golem15/fonoteka/controllers/collections_admin_controller.go` (hooks + 422) | exact | | `USER/controllers/organisations_admin_controller.go` (new) | controller | CRUD | `APP/plugins/golem15/fonoteka/controllers/collections_admin_controller.go` (relation manager contract) | exact | | `USER/controllers/admin_registry.go` + `AdminControllers(db)` (new) | config (registration) | — | `APP/plugins/golem15/fonoteka/controllers/admin_registry.go`, `genres_admin_controller.go` 23-31 | exact | | `USER/controllers/request_db.go` (new, or shared helper) | utility | — | `APP/plugins/golem15/fonoteka/controllers/request_db.go` | exact | | `USER/admin.go` (new: `AdminFS`, `AdminControllers`, capability assertions) | provider | — | `APP/plugins/golem15/fonoteka/admin.go` | exact | | `USER/admin_permissions.go`, `USER/admin_navigation.go` (new) | config | — | `APP/plugins/golem15/fonoteka/admin_permissions.go`, `admin_navigation.go` | exact | | `USER/plugin.go` (modify: interface assertions, `invite` mail templates) | provider | — | same file, 27-48 and 171-180 | exact | | `USER/controllers/users/{config_list,config_form,config_filter}.yaml`, `_hint.htm` (new) | config | file-I/O | `APP/plugins/golem15/fonoteka/controllers/collections/config_list.yaml`, `config_form.yaml`; `controllers/albums/_stats.htm` | exact (filter: see No Analog note) | | `USER/controllers/usergroups/*.yaml`, `USER/controllers/organisations/{config_list,config_form,config_relation}.yaml` (new) | config | file-I/O | `APP/plugins/golem15/fonoteka/controllers/collections/*.yaml` | exact | | `USER/models/{user,usergroup,organisation}/{fields,columns}.yaml` (new) | config | file-I/O | `APP/plugins/golem15/fonoteka/models/collection/{fields,columns}.yaml` | exact | | `USER/models/user.go` (modify: `AttachRelations`, read-only timestamps, `LastSeen`, `Permissions`) | model | CRUD | same file; `APP/plugins/golem15/feedback/models/submission.go` 59-65 | exact | | `USER/models/user_group.go`, `USER/models/organisation.go` (modify: `Fillable`, `Rules`, `MorphName`, `AttachRelations`, `users_count`) | model | CRUD | `USER/models/user.go` 40-67 | exact | | `USER/models/users_group.go` (new pivot model) | model | CRUD | `APP/plugins/golem15/fonoteka/models/album_artist.go` (pivot used by albums controller 128) | role-match | | `USER/models/frontend_permission.go` (new) | model | CRUD | `USER/models/user_group.go` | exact | | `USER/classes/permissions.go` (new resolver + `PermissionSet`) | utility | transform | `modules/cabana/contracts.go` `granted` 153-185 (wildcard logic, per RESEARCH Pitfall 6) | partial | | `USER/classes/privileged.go` (new) | utility | request-response | `USER/classes/user_groups.go` | exact | | `USER/classes/admin_actions.go` (new: activate, ban, unban, unsuspend, restore, deactivate, force delete) | service | CRUD | `USER/classes/throttle.go`, `USER/classes/codes.go`; `USER/controllers/api_controller.go` `deleteAvatar` 1205-1231 | role-match | | `USER/updates/2026MMDDNNNN_*.go` (three additive migrations) | migration | — | `USER/updates/202609220005_extend_users.go` (ALTER) and `202610020001_create_user_groups.go` (CREATE) | exact | | `USER/controllers/api_controller.go` (modify: `last_seen` write in `Login` 47 and `Refresh` 175) | controller | request-response | same file | exact | | `USER/views/mail/invite.htm`, `invite-en.htm` (new) | config (template) | — | `USER/views/mail/activate.htm`, `activate-en.htm` | exact | | `USER/lang/{en,pl}/lang.yaml`, `USER/config/config.yaml` (modify) | config | — | same files | exact | | `USER/*_test.go` admin tests (new) | test | request-response | `APP/plugins/golem15/fonoteka/admin_collections_test.go`, `admin_tracer_test.go` | role-match | | `APP/parity/schema_diff_test.go` (modify: one `allowedDiffs` entry) | test | — | same file (existing `user_groups` entry) | exact | ## Pattern Assignments ### `modules/pact/capabilities.go` (contract types) **Analog:** same file, lines 215-256. New contracts sit directly after `HasAdminActions` and copy its doc-comment style: what the framework owns, what `Run` owns, naming rule, permission rule. ```go type AdminAction struct { Name string Label string Permissions []string Run func(ctx context.Context, in AdminActionInput) (AdminActionResult, error) `json:"-"` } type AdminActionInput struct { Field string RecordID *uint64 Record any Values map[string]any } type AdminActionResult struct { Message string Fill map[string]any } // HasAdminActions is implemented by an admin controller that registers named // actions for its toolbar.buttons list and its `type: widget` form fields. type HasAdminActions interface { AdminActions() []AdminAction } ``` Keep the property stated at lines 231-232 ("toolbar actions carry no record ids, so an id list can never become an unscoped lookup"): a bulk action's input carries **loaded records**, never ids. `Run` keeps the `json:"-"` tag. Any export change here must update `modules/pact/README.md` in the same commit. --- ### `modules/cabana/actions.go` (bulk and record action handlers) **Analog:** same file. Handler skeleton to copy for both new handlers (lines 78-99): ```go func (s *service) toolbarAction(w http.ResponseWriter, r *http.Request) { s.protect(w, r, func(cc *CompiledController) { action, ok := toolbarActionOf(cc, r.PathValue("action")) if !ok { WriteError(w, http.StatusNotFound, "not_found", msgNotFound) return } if !s.allowAction(w, r, action) { return } in, err := decodeActionRequest(r) if err != nil { writeCRUDError(w, err) return } if in.RecordID != nil || in.Values != nil { writeCRUDError(w, &ValidationError{Details: map[string]any{"body": []string{"A toolbar action takes no record_id or values."}}}) return } s.runAction(w, r, cc, action, pact.AdminActionInput{}, nil) }) } ``` **"Declared in YAML and registered" lookup** (lines 103-116): copy `toolbarActionOf` for `bulkActionOf` (reads the list's declared bulk actions) and `recordActionOf` (reads the form's declared record actions). An undeclared name is a 404, not a 403. **Own-permission check** (lines 120-132): reuse `s.allowAction` unchanged; it logs the denial and answers 403. **Strict body decode** (lines 172-186): `decodeActionRequest` uses `UseNumber`, `DisallowUnknownFields` and refuses trailing tokens. A record action body is `{}` and reuses it. The bulk action body is the id list; decode it with the capped decoder used by bulk delete (`s.decodeCappedBulk`, `http.go:868-870`), then `normalizeIDs`. **Error mapping and envelope** (lines 137-155): `runAction` sets the locale on the context, maps `*ValidationError` to 422, logs any other error with `slog.Error("cabana: admin action failed", "controller", ..., "action", ...)` and answers the generic 500 body. The 403 branch (G4) is added here and in `writeCRUDError` together. **Record action scope:** a record action writes, so load through `loadRecord` (`crud.go:465-485`, `FOR UPDATE`, inside `lagoon.Transaction`), not `readScopedRecord` (`actions.go:192-215`, lock-free read used by widgets). Both return `recordNotFound{}` for missing and out-of-scope ids alike. --- ### `modules/cabana/crud.go` (bulk action service, 403 error type) **Analog:** `BulkDelete`, lines 197-247. Copy the whole shape; replace only the per-row work with one call to the plugin's `Run` carrying `rows`: ```go ids, err := normalizeIDs(in.IDs) if err != nil { return BulkResult{}, err } ... err = lagoon.Transaction(ctx, s.DB, func(ctx context.Context, tx *gorm.DB) error { ctx = withTx(ctx, tx) if err := ctx.Err(); err != nil { return lifecycleFailure(cc, err) } proto, err := newWritableModel(cc) if err != nil { return err } rows, err := lockScoped(ctx, tx, cc, proto, ids) if err != nil { return err } if len(rows) == 0 { result.Deleted = 0 return nil } if len(rows) != len(ids) { return partialSelection{} } ... }) ``` `withTx(ctx, tx)` is what lets the plugin's `Run` call `cabana.TxFromContext(ctx)`. `lockScoped` (550-583) applies `pact.ListExtendQuery`, so the Users controller's `Unscoped()` scope makes trashed users selectable for `restore`. **Error type pattern** (lines 50-55 and 69-71): `ForbiddenError` copies `ValidationError`'s form (exported struct, pointer receiver `Error()`), and gains one branch in each of the three places an error is classified: ```go // writeCRUDError, 412-434: one errors.As branch per outcome var ve *ValidationError if errors.As(err, &ve) { WriteErrorDetails(w, http.StatusUnprocessableEntity, "validation_failed", "Validation failed", ve.Details) return } ``` ```go // lifecycleFailure, 498-523: errors a hook may return unchanged var invalid *ValidationError if errors.As(err, &invalid) { return err } ``` Without the `lifecycleFailure` branch a hook's forbidden error is rewritten to the opaque `lifecycleError` and becomes a 500. The third place is `runAction` (`actions.go:141-150`). Use `WriteError(w, http.StatusForbidden, "forbidden", msgForbidden)` as `allowAction` does (`actions.go:130`). **Protected fill keys** (lines 833-843) stay as they are; `password`, `permissions` and `organisation_id` get typed exception paths, never a removal from this list. --- ### `modules/cabana/extension.go` and `modules/cabana/list_schema.go` (fail-loud compile) **Analog for registration:** `compileActions`, `extension.go:256-278`. Bulk and record actions join the same `map[string]pact.AdminAction`-style namespace with the same four boot errors: ```go if !identifier(action.Name) { return nil, fmt.Errorf("action name %q is not an identifier", action.Name) } if builtinToolbarActions[action.Name] { return nil, fmt.Errorf("action %s uses a reserved built-in name (create, delete)", action.Name) } if _, dup := out[action.Name]; dup { return nil, fmt.Errorf("duplicate action %s", action.Name) } if action.Run == nil { return nil, fmt.Errorf("action %s has no Run function", action.Name) } ``` **Analog for the YAML list key:** `toolbarButtons.UnmarshalYAML` + `compileToolbarButtons`, `list_schema.go:298-363`. A `bulkActions:` key (and `recordActions:` in `config_form.yaml`) copies it: a sequence of names, duplicates refused, a scalar refused with a pointed message, each name resolved against the controller's registered actions, a missing label refused: ```go action, ok := registered[name] if !ok { return nil, nil, fmt.Errorf("toolbar.buttons: unsupported action %s (want create, delete or an action the controller registers)", name) } if strings.TrimSpace(action.Label) == "" { return nil, nil, fmt.Errorf("toolbar.buttons: action %s needs a label", name) } ``` The cross-key rule "delete needs showCheckboxes: true" (line 348-350) is the model for "bulkActions needs showCheckboxes: true". New YAML keys are added as fields on `listDocument` (26-43) and `columnDocument` (62-69); decoding is strict, so a key not on the struct stays a boot error. Errors are wrapped with `bootErr(pluginID, ctl.ID(), cfgPath, err)` (129-132). The existing built-in bulk entry is appended at lines 152-155; declared bulk actions append to the same `bulk` slice after it so "bulk delete keeps working as today". --- ### `modules/cabana/schema_types.go` (schema DTOs) **Analog:** same file. A new slice on a schema type follows three rules visible in lines 49-121: 1. A small exported struct with `json` tags (`BulkAction` 49-52; `ToolbarAction` 174). 2. A doc comment on the field saying it is per-principal when it is (lines 71-73). 3. A nil guard in `MarshalJSON` so the SPA never receives `null` (lines 102-119): ```go if out.ToolbarActions == nil { out.ToolbarActions = []ToolbarAction{} } ``` Labels stay source keys in the cached schema and are localized on a copy (`localizeBulkActions`, `list_schema.go:554`); a new `Confirm` phrase key is localized in the same function. Row state on a list row and offered record actions on a record response are response data, not cached schema. --- ### `modules/cabana/field_permission.go` (new field type) and `modules/cabana/form_schema.go` **Analog (compile half):** `modules/cabana/field_date.go`. One file per field type holding: the key list valid only on this type, the list of generic keys refused on it, a `compileKeys(typ, values, field)` called from `compileFieldNode`, and a `compileFields(pluginID, cc)` boot check against the model. ```go // field_date.go:44-65 func compileDatepickerKeys(typ string, values map[string]ast.Node, field *FormField) error { if typ != "datepicker" { for _, key := range datepickerKeys { if _, ok := values[key]; ok { return fmt.Errorf("%s is only valid on type: datepicker", key) } } return nil } for _, key := range datepickerRefusedKeys { if _, ok := values[key]; ok { return fmt.Errorf("%s is not valid on type: datepicker", key) } } field.Mode = "datetime" if node, ok := values["mode"]; ok { mode, err := nodeString(node) if err != nil || (mode != "date" && mode != "datetime" && mode != "time") { return fmt.Errorf("mode %q must be date, datetime or time on type: datepicker", nodeText(node)) } field.Mode = mode } ``` `mode` is shared and gated by value: `field_file.go:74` currently refuses it on any type other than fileupload or datepicker, so that rule must name `permissioneditor` too (RESEARCH Pitfall 10). ```go // field_date.go:219-247: boot check, per-controller compiled map func compileDateFields(pluginID string, cc *CompiledController) error { ... for _, field := range cc.Form.Fields { if field.Type != "datepicker" { continue } ... if err := checkDateType(model, field); err != nil { return bootErr(pluginID, controllerID(cc), cc.Form.fieldsPath, err) } if cc.dates == nil { cc.dates = map[string]*compiledDate{} } cc.dates[field.Name] = &compiledDate{...} } return nil } ``` **Analog (lift, validate, store half):** `relation_field.go`, `liftRelationValues` 354-422. A `permissions` value is a JSON object, so it can never go through `ProjectWritableFields` (nested values are dropped, `crud.go:93`); it needs its own lift exactly as relation values have: ```go for _, name := range names { fr := cc.FieldRelations[name] if fr == nil || fr.ReadOnly || !contextAllows(cc, name, op) { continue } raw, present := body[name] if !present { continue } ... } if len(details) > 0 { return nil, &ValidationError{Details: details} } ``` Copy: sorted field iteration, `contextAllows(cc, name, op)` gate, "absent key is skipped", one `ValidationError` collecting every bad field with messages shaped `"The field must be ..."`. Validation of codes against the controller's option list follows `checkRelationScope` (444-475): re-check inside the save transaction, 422 on the field. **Form field type and key lists** live in `form_schema.go:22-44` (`formFieldTypes`, `formFieldKeys`); add `password`, `permissioneditor`, `preset` there. `password` must not be in `scalarFormField` binding to a column, must never be projected into a record response, and must never be a fill key. **Per-request option injection:** the `permissioneditor` option list is supplied by the controller per request; follow the form schema's existing per-principal filtering in `http.go` near line 678 ("Like toolbarActions in the list schema (D-12), a widget whose ...") and build a new slice so the cached schema is never mutated. --- ### `modules/cabana/relation_field.go` (locked options, writable protected key) **Analog:** same file. - Contract growth: add fields to `FieldRelationContract` (22-42) with the same one-line doc comment per field. The read-only decision is one line (198): `out.ReadOnly = protectedFillKey(contract.ForeignKey)`; the opt-in changes that line and the second guard in `assignBelongsTo` (481), which re-checks `protectedFillKey` independently. Both must change together or the write is silently skipped. - `RelationOption` (52-55) is `{Value uint, Label string}`; a `Locked` flag is added with `json:"locked,omitempty"` so existing responses are byte-identical when nothing is locked. - The locked-id guard belongs in `syncBelongsToMany` (497-537) **before** the delete at 505-507: read the parent's current pivot ids, compare the locked subset before and after, return the forbidden error. The function runs inside the save transaction, so returning an error rolls back the whole save (D-07 "no partial save"). It must also run on create (RESEARCH T-12-18 path 1). --- ### `modules/cabana/http.go` (routes, per-principal schema, list rows) **Route registration** (254-272, inside `r.GroupRaw(api, []string{"backend"}, ...)`): every write is wrapped in `requireAjax`: ```go g.Post("/{vendor}/{plugin}/{controller}/bulk-delete", requireAjax(s.bulkDelete)) g.Post("/{vendor}/{plugin}/{controller}/toolbar/{action}", requireAjax(s.toolbarAction)) ``` **Thin HTTP handler over the service** (826-848) — copy for the bulk action handler: ```go func (s *service) bulkDelete(w http.ResponseWriter, r *http.Request) { s.protect(w, r, func(cc *CompiledController) { if !s.operationDeclared(w, r, cc, "bulk-delete") { return } in, err := s.decodeCappedBulk(w, r) if err != nil { writeCRUDError(w, err) return } svc, err := s.crud() if err != nil { WriteError(w, http.StatusInternalServerError, "error", msgServerError) return } result, err := svc.BulkDelete(r.Context(), cc, in) if err != nil { writeCRUDError(w, err) return } WriteData(w, http.StatusOK, result, nil) }) } ``` **Per-principal filtering** (713-721) — copy for bulk actions in the list schema and for the offered record actions on the show response: ```go principal, _ := bouncer.User(r.Context()) allowed := make([]ToolbarAction, 0, len(view.ToolbarActions)) for _, action := range view.ToolbarActions { if registered, ok := cc.Actions[action.Name]; ok && Allows(principal, registered.Permissions) { allowed = append(allowed, action) } } view.ToolbarActions = allowed ``` **Row projection** (`projectRow`, 969-995) builds `id` plus one key per column. Row state is added after projection from one batch hook call per page; `ExecuteList` (`query.go:81-155`) runs on the pool, so the hook receives the list's `*gorm.DB` explicitly (RESEARCH Pitfall 7). An `invisible` column is still projected for search and sort on the server but flagged so the SPA does not render it. **`operationDeclared`** (`registry.go:122-132`) is the capability switch; add cases for the new operations there, not in handlers. --- ### `modules/cabana/admin_openapi.go` (swag stubs) **Analog:** `AdminBulkDelete` (379-397) for the bulk action route, `AdminToolbarAction` (437-456) for the record action route. One empty stub func per route, body and result types are real exported Go types in package cabana: ```go // AdminToolbarAction documents the toolbar action route. // // @Summary Run a toolbar action // @Description Runs a controller-registered action that the list's toolbar.buttons declares. The body must be {}: ... // @Tags admin // @Accept json // @Produce json // @Security BackendBearer // @Param vendor path string true "Vendor" // @Param plugin path string true "Plugin" // @Param controller path string true "Controller" // @Param action path string true "Action name" // @Param body body AdminActionRequest true "Empty object" // @Success 200 {object} Envelope[AdminActionResult] // @Failure 401 {object} ErrorEnvelope // @Failure 403 {object} ErrorEnvelope // @Failure 404 {object} ErrorEnvelope // @Failure 422 {object} ErrorEnvelope // @Router /{vendor}/{plugin}/{controller}/toolbar/{action} [post] func AdminToolbarAction() {} ``` Bulk routes also list `@Failure 409` (partial selection). The body for id lists is the existing `AdminIDsRequest` (line 389). After editing: `scripts/check-admin-openapi.sh`, then add aliases to `admin/src/api/types.ts`, then rebuild `dist/` in the same commit. The route, its stub and its permission-matrix row are added together or `TestPhase09ContractInventory`, `TestPhase09PermissionMatrix`, `TestPhase10OpenAPIConformance` fail. --- ### Framework tests and fixture (`modules/cabana/*_test.go`, `modules/cabana/testdata/...`) **Analog:** `modules/cabana/phase101_actions_test.go` and `modules/cabana/testdata/extension/` (tree: `controllers/gadgets/{config_list.yaml,config_form.yaml,_stats.htm,_summary.htm}`, `models/gadget/{columns.yaml,fields.yaml}`, `lang/{en,pl}/lang.yaml`, `assets/`). Fixture plugin and controller (81-128): neutral `acme.demo` ids, `AdminFS` served from `os.DirFS`, a tenant scope so "out of scope" is testable: ```go func (actPlugin) ID() string { return "acme.demo" } func (actPlugin) Permissions() []pact.Permission { return []pact.Permission{{Code: "acme.demo.access", Roles: []string{"developer"}}, {Code: "acme.demo.run", Roles: []string{"developer"}}} } func (actPlugin) AdminFS() fs.FS { return os.DirFS(actDir) } ... func (actController) ListExtendQuery(_ context.Context, db *gorm.DB) *gorm.DB { return db.Where("tenant = ?", "acme") } func (actController) FormExtendQuery(_ context.Context, db *gorm.DB) *gorm.DB { return db.Where("tenant = ?", "acme") } ``` - A spy records what reached `Run` (`actSpy`, 62-79) — this is how "ids outside the scope never reach the plugin" is asserted. - Four auth modes in one helper (`actEnv.call`, 205-234): `bearer`, `limited` (controller permission only, not the action's), `cookie` (with `X-Requested-With`), `cookie-only` (CSRF refusal). - Environment builder (`newActEnv`, 254-318): `adminGorm(t)`, `AutoMigrate` fixture models, `insertAdmin`, a limited role row with `{"acme.demo.access":1}`, `compass.Open` on a temp dir, `lagoon.Publish`, `phrasebook.Activate`, `surf.Assemble`, login for both tokens. - Bulk semantics tests to mirror: `modules/cabana/bulk_test.go` (`TestBulkDeleteEmpty`, `Duplicates`, `Order`, `Idempotent`, `Rollback`, `Concurrent`). - File-attaching fixture model: `phase122_fixture_test.go:57-77` (`MorphName`, `AttachRelations`, `Fillable`). Fixtures, READMEs and docs never name the application; the gate's hygiene stage enforces it for files listed in `PHASE_FILES`. --- ### SPA: `admin/src/components/form/fields/PasswordField.vue`, `PermissionEditorField.vue`, `registry.ts` **Analog for PasswordField:** `TextField.vue` (whole file, 27 lines). Same props (`FieldControlProps`), same emit, same ARIA wiring; only `type` and autocomplete differ: ```vue ``` **Analog for PermissionEditorField:** `DropdownField.vue` — options come from `props.field` (schema-supplied, line 14), the emitted value keeps the options' JSON types, and a stored value no option matches is never shown as a wrong choice (lines 25-34, 66-70). Field components import helpers from `../control`, never from `../registry` (import cycle; `control.ts:1-3`). Use `reka-ui` primitives already in the SPA; no new npm package. Emission rules are in UI-SPEC S5: radio emits `1` / `-1` and omits inherit, checkbox emits `1` and omits unchecked, codes outside the options are never sent. **Registry** (`registry.ts:43-74`): one line per type in `renderers`, plus set membership decisions: ```ts const renderers = new Map([ ['text', TextField], ... ['datepicker', DatepickerField], ]) const selfLabelled = new Set(['switch', 'checkbox', RELATION_MANAGER]) const valueless = new Set([RELATION_MANAGER, 'widget', 'partial', 'fileupload']) const groupLabelledTypes = new Set(['widget', 'partial', 'fileupload']) ``` `permissioneditor` holds a form value (not `valueless`) and is a group (add to `groupLabelledTypes`). `password` is a plain value field. Extend the file's header comment with a "Phase 12.1 adds ..." sentence as phases 10.1 and 12.2 did (lines 6-12). --- ### SPA: `ListToolbar.vue`, `ListView.vue`, `DataTable.vue` (S1, S4) **Toolbar props and events** (`ListToolbar.vue:14-30`): the server-filtered action list arrives as a prop with a default, the busy name as a prop, and the component only emits: ```ts const props = withDefaults( defineProps<{ ... actions?: ToolbarAction[] busyAction?: string | null }>(), { actions: () => [], busyAction: null }, ) const emit = defineEmits<{ 'update:search': [value: string]; delete: []; action: [name: string] }>() ``` Rendered buttons carry `:data-action="name"`, `:disabled` and `:aria-busy` (67-77); tests select by `data-action`. **Bulk request flow** (`ListView.vue:199-228`) — copy `onDelete` for `onBulkAction(name)`; it already has every step S1 needs (id normalisation, confirm, busy guard, typed POST, toast, selection clear, list reload, header partial reload, failure toast): ```ts const ids = selected.value.map(Number).filter((id) => Number.isInteger(id) && id > 0) if (ids.length === 0 || deleting.value) { return } const ok = await confirm.ask({ message: message(messages.value?.deleteConfirm, ids.length), confirmLabel: t('backend::lang.form.delete'), danger: true, }) ... const result = await api.POST('/{vendor}/{plugin}/{controller}/bulk-delete', { params: { path }, body: { ids } }) if (result.data) { showToast(message(messages.value?.deleted, result.data.data.deleted)) selected.value = [] await loadList() partialReload.value += 1 return } showToast(result.error?.error.message || t('backend::lang.list.delete_failed'), 'danger') ``` S1 differs on error only: 409 clears the selection and reloads, 403 keeps the selection. Which actions render is decided by the server list (`ListView.vue:76-79`): an action missing from the schema is not rendered at all. **Row state** (`DataTable.vue:232-242`): the row `` already takes a class array; state classes join it and never change the row background (UI-SPEC S4). Badges go in the first cell. --- ### SPA: `FormView.vue`, `router.ts`, `winterUrl.ts` (S2, S3 preview) - **Mode** is derived from the route name at `FormView.vue:54` (`const mode: FormMode = route.name === 'create' ? 'create' : 'update'`); preview becomes a third mode from a third route name. Field filtering is `contextAllows(field, mode)` at lines 88-95. - **Route:** add next to `record` in `router.ts:71` and add the name to `CONTROLLER_ROUTES` (line 37), or plugin stylesheets are not activated on the preview screen. ```ts const CONTROLLER_ROUTES = new Set(['list', 'create', 'record']) ... { path: '/:vendor/:plugin/:controller/:id(\\d+)', name: 'record', component: FormView, meta: { shell: true } }, ``` - **URL mapping** (`winterUrl.ts:31-37`): copy the `update/:id` branch for `preview/:id`. The safety rule stays: only the current controller's own routes can come out, ids must match `DIGITS`, anything else falls back to the list. Update the header comment table (lines 6-9). ```ts if (rest.length === 2 && rest[0] === 'update') { const target = rest[1] === ':id' ? String(id ?? '') : (rest[1] ?? '') return DIGITS.test(target) ? `${base}/${target}` : base } ``` - **Redirects** go through `redirectTarget` (236-240), which already routes plugin YAML strings through `mapWinterUrl`. - **Record action request flow:** copy `onDelete` (305-333): `busy` guard, `confirm.ask`, typed `api.*` call with `params: { path: { ...path, id: recordId } }`, toast, `finally { busy.value = false }`. After success a record action reloads the record in place instead of navigating (UI-SPEC S2). --- ### SPA tests (`admin/tests/**`) **Analog:** `admin/tests/list/ListToolbar.test.ts` (1-45): vitest + `@vue/test-utils` `mount`, a `base` props object spread per case, `resetState()` in `beforeEach`, selectors by `data-*` attributes, `wrapper.emitted(...)` assertions, describe titles carrying the decision id (`'list toolbar (D-14)'`). Field tests: `admin/tests/form/DatepickerField.test.ts`, `RelationField.test.ts`. URL and router tests: `admin/tests/app/winterUrl.test.ts`, `router.test.ts`. Schema fixtures are JSON files in `admin/tests/fixtures/` (`extension.list-schema.json`, `extension.form-schema.json`, `widgets.record.json`) typed through `fixtures/typed.ts`. --- ### `scripts/check-phase12.1.sh` **Analog:** `scripts/check-phase12.2.sh`. Copy the header contract (fail closed on a failing, skipped, zero-match or non-building test run), `ROOT` / `APP` resolution with an overridable env var, the `SECURITY_*` arrays of test-name prefixes, and the `PHASE_FILES` hygiene list: ```bash set -euo pipefail ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" APP="${PHASE122_APP:-$ROOT/../fonoteka.go}" SECURITY_CABANA=(TestRelationChildScope TestProtectedFile TestDeferredCommit TestFileupload TestRelationChild) ``` Stages: `--go`, `--security`, `--spa`, `--openapi`, `--dist`, `--docs`, `--hygiene`, `--app`, `--all`, `--self-test`. --- ### `USER/controllers/users_admin_controller.go` (controller, CRUD) **Analog:** `APP/plugins/golem15/fonoteka/controllers/albums_admin_controller.go`. **Imports and identity** (1-37): ```go type albumsAdminController struct { db func() *gorm.DB } func (albumsAdminController) ID() string { return "golem15.fonoteka.albums" } func (albumsAdminController) ModelName() string { return `Golem15\Fonoteka\Models\Album` } func (albumsAdminController) ConfigDir() string { return "controllers/albums" } func (albumsAdminController) RequiredPermissions() []string { return []string{"golem15.fonoteka.access_albums"} } func (albumsAdminController) NewRecord() any { return &models.Album{} } ``` For the user plugin: ids `golem15.user.users` / `usergroups` / `organisations`, model names `Golem15\User\Models\User` etc., `ConfigDir` `controllers/users`. `db` is a `func() *gorm.DB` resolved per call, never a cached handle. **Compile-time capability assertions** next to the type (39-43), one line per optional interface the controller implements: ```go var ( _ pact.AdminClientAssets = albumsAdminController{} _ pact.HasAdminActions = albumsAdminController{} _ pact.AdminPartialData = albumsAdminController{} ) ``` **Action registration** (68-90): each action names its own permission and returns a phrase key as `Message`. Bulk and record actions follow this shape with the new contract types: ```go { Name: "discogsSync", Label: "golem15.fonoteka::lang.discogs.sync_button", Permissions: []string{"golem15.fonoteka.access_albums"}, Run: func(context.Context, pact.AdminActionInput) (pact.AdminActionResult, error) { return pact.AdminActionResult{Message: "golem15.fonoteka::lang.discogs.stub_not_implemented"}, nil }, }, ``` **Field relations** (115-135) — the exact shape `organisation` (belongsTo) and `groups` (belongsToMany with pivot) need: ```go { Field: "genre", Kind: "belongsTo", NewRelated: func() any { return &models.Genre{} }, ForeignKey: "genre_id", LabelColumn: "name", }, { Field: "artists", Kind: "belongsToMany", NewRelated: func() any { return &models.Artist{} }, NewPivot: func() any { return &models.AlbumArtist{} }, ParentForeignKey: "album_id", RelatedForeignKey: "artist_id", OrderColumn: "sort_order", LabelColumn: "name", }, ``` `users_groups` columns are `user_id` and `user_group_id` (`USER/updates/202610020001_create_user_groups.go:28-32`); there is no order column. **List and form scopes** (137-162): both delegate to one function and guard a nil handle. The Users controller returns `db.Unscoped()` from both (`withTrashed`, D-13): ```go func (c albumsAdminController) ListExtendQuery(ctx context.Context, db *gorm.DB) *gorm.DB { return c.scopeAlbums(ctx, db) } func (c albumsAdminController) FormExtendQuery(ctx context.Context, db *gorm.DB) *gorm.DB { return c.scopeAlbums(ctx, db) } ``` **Hooks with typed model assertion and 422** (164-182): ```go album, ok := model.(*models.Album) if !ok || album == nil { return errors.New("albums admin model is not an album") } ... return &cabana.ValidationError{Details: map[string]any{resolved.Field: []string{resolved.Message}}} ``` **Partial view model for the status hint** (222-245): a curated struct of labels and scalars, never a GORM model (the framework refuses one, `pact/capabilities.go:258-267`); unknown partial names are an error: ```go func (c albumsAdminController) PartialData(ctx context.Context, name string, _ any) (any, error) { if name != "stats" { return nil, fmt.Errorf("albums admin: unknown partial %q", name) } return c.statsView(ctx) } ``` **Dropdown / option lists** (96-107, 200-220): switch on the field name, never serve one field's choices to another, never cache. --- ### `USER/controllers/usergroups_admin_controller.go` and `organisations_admin_controller.go` **Analog:** `APP/plugins/golem15/fonoteka/controllers/collections_admin_controller.go`. **Hook reading the write transaction and the backend principal** (103-131) — the shape for the D-06 privileged-code checks and the G10 `regex` / `alpha_dash` checks: ```go func (c collectionsAdminController) FormBeforeCreate(ctx context.Context, model any) error { collection, ok := model.(*models.Collection) if !ok || collection == nil { return errors.New("collections admin model mismatch") } base := requestDB(ctx, c.db) if base == nil { return errors.New("collections admin database is missing") } principal, ok := bouncer.User(ctx) if !ok || principal == nil || principal.ID == 0 { return errors.New("backend principal is missing") } db := base.WithContext(ctx) ... if len(owners) != 1 { return &cabana.ValidationError{Details: map[string]any{"owner": []string{"The backend email must identify exactly one active user."}}} } ``` Permission checks inside a hook use `cabana.Allows(principal, []string{code})`, never string comparison. **Relation manager contract** (39-59) — for Organisations `members` the kind is `hasMany` with `ForeignKey: "organisation_id"` (no pivot); the `Columns` map (YAML column key to physical column) is required because each `config_relation.yaml` column key must be in it: ```go func (collectionsAdminController) AdminRelationContracts() []cabana.RelationContract { return []cabana.RelationContract{{ Name: "editors", NewRelated: func() any { return &usermodels.User{} }, NewPivot: func() any { return &models.CollectionEditor{} }, ParentForeignKey: "collection_id", RelatedForeignKey: "user_id", Columns: map[string]string{ "username": "email", "email": "email", }, ... }} } ``` **Relation scope that fails closed on an unknown relation name** (75-83): ```go if relation != "editors" { return db.Where("1 = 0") } ``` There is a `RelationBeforeLink` hook (85-101) but no unlink hook; do not add a groups relation manager (RESEARCH anti-pattern) — the user form's `groups` field stays the single writer of `users_groups`. --- ### `USER/controllers/request_db.go`, `USER/controllers/admin_registry.go` **Analog:** `APP/plugins/golem15/fonoteka/controllers/request_db.go` (whole file) — copy verbatim into the plugin's `controllers` package: ```go func requestDB(ctx context.Context, pool func() *gorm.DB) *gorm.DB { if tx, ok := cabana.TxFromContext(ctx); ok { return tx } if pool == nil { return nil } return pool() } ``` **Analog:** `admin_registry.go` (interface assertions in `init`) and `genres_admin_controller.go:23-31` (the exported constructor list): ```go func AdminControllers(db func() *gorm.DB) []pact.AdminController { return []pact.AdminController{ genresAdminController{}, albumsAdminController{db: db}, ... } } ``` --- ### `USER/admin.go`, `USER/admin_permissions.go`, `USER/admin_navigation.go`, `USER/plugin.go` **Analog:** `APP/plugins/golem15/fonoteka/admin.go` (whole file, 36 lines). The admin tree is embedded file by file, not by directory, in a separate file from `plugin.go`: ```go //go:embed controllers/genres/config_form.yaml controllers/genres/config_list.yaml models/genre/fields.yaml models/genre/columns.yaml ... controllers/albums/_stats.htm ... var adminFS embed.FS func (p *Plugin) AdminFS() fs.FS { return adminFS } func (p *Plugin) AdminControllers() []pact.AdminController { return controllers.AdminControllers(func() *gorm.DB { if p == nil || p.app == nil { return nil } db, ok := p.app.Lookup[*gorm.DB]() if !ok { return nil } return db }) } var ( _ pact.AdminAssets = (*Plugin)(nil) _ pact.HasAdminControllers = (*Plugin)(nil) _ pact.HasPermissions = (*Plugin)(nil) _ pact.HasNavigation = (*Plugin)(nil) ) ``` Every YAML and `.htm` partial must be in the embed list, or boot fails with "template is not in the plugin's embedded files" (`extension.go:158`). In the user plugin, `USER/controllers/` already contains a non-admin file (`winter_error_page.html`), so keep the explicit file list. **Permissions** (`admin_permissions.go:5-15`): a tab constant and one entry per code. The five codes for this plugin are in RESEARCH "Code Examples" (four PHP codes plus the privileged-groups code). ```go const fonotekaPermissionTab = "golem15.fonoteka::lang.plugin.tab" func (*Plugin) Permissions() []pact.Permission { return []pact.Permission{ { Code: "golem15.fonoteka.access_collections", Tab: fonotekaPermissionTab, Label: "golem15.fonoteka::lang.collection.access", Roles: []string{"developer"}, }, ``` **Navigation** (`admin_navigation.go:9-45`): `Controller` is the admin controller id (not a URL), icons are lucide names, the main item uses a wildcard permission, each side item its own: ```go { Code: "fonoteka", Label: "golem15.fonoteka::lang.plugin.menu_label", Icon: "disc-3", Permissions: []string{"golem15.fonoteka.*"}, Order: 500, Controller: "golem15.fonoteka.albums", SideMenu: []pact.NavigationItem{ ... }, }, ``` PHP values to carry: code `user`, order `555`, side menu `users`, `usergroups`, `organisations` (RESEARCH "Plugin registration"). **`plugin.go`**: extend the assertion block at 27-39 and add the two `invite` names to `MailTemplates()` (171-180), matching the existing `name` / `name-en` pairs. --- ### Plugin YAML (`USER/controllers/*/`, `USER/models/*/`) **Analog:** `APP/plugins/golem15/fonoteka/controllers/collections/*.yaml` and `models/collection/*.yaml`. `config_list.yaml` (Go dialect: `toolbar.buttons` is a list, `messages` block of phrase keys): ```yaml list: ~/plugins/golem15/fonoteka/models/collection/columns.yaml modelClass: Golem15\Fonoteka\Models\Collection title: golem15.fonoteka::lang.collection.label_plural recordUrl: golem15/fonoteka/collections/update/:id noRecordsMessage: backend::lang.list.no_records recordsPerPage: 20 showCheckboxes: true toolbar: buttons: [create, delete] search: prompt: backend::lang.list.search_prompt messages: recordCount: golem15.fonoteka::lang.collection.record_count create: golem15.fonoteka::lang.collection.new searchPrompt: golem15.fonoteka::lang.collection.search_prompt deleteConfirm: golem15.fonoteka::lang.collection.delete_confirm deleted: golem15.fonoteka::lang.collection.deleted_count ``` `config_form.yaml` (only `name`, `form`, `modelClass`, `defaultRedirect`, `create`, `update`, `messages`; no `title` under `create` / `update`): ```yaml name: golem15.fonoteka::lang.collection.create form: ~/plugins/golem15/fonoteka/models/collection/fields.yaml modelClass: Golem15\Fonoteka\Models\Collection defaultRedirect: golem15/fonoteka/collections create: redirect: golem15/fonoteka/collections/update/:id redirectClose: golem15/fonoteka/collections update: redirect: golem15/fonoteka/collections redirectClose: golem15/fonoteka/collections messages: create: golem15.fonoteka::lang.collection.new update: golem15.fonoteka::lang.collection.update_title saved: golem15.fonoteka::lang.collection.saved deleteConfirm: golem15.fonoteka::lang.collection.delete_record_confirm deleted: golem15.fonoteka::lang.collection.deleted ``` `fields.yaml` (single top-level `fields:`, every field has `type`, tabs are a per-field `tab:` key, relation manager carries `context: update`): ```yaml fields: name: label: golem15.fonoteka::lang.collection.name span: left type: text required: true owner: label: golem15.fonoteka::lang.collection.owner span: right type: relation nameFrom: username emptyOption: golem15.fonoteka::lang.collection.current_user editors: type: relation-manager relation: editors tab: golem15.fonoteka::lang.collection.editors_tab span: full context: update ``` `columns.yaml`: ```yaml columns: name: label: golem15.fonoteka::lang.collection.name searchable: true created_at: label: backend::lang.list.column_created type: datetime sortable: true ``` `config_relation.yaml` (inline `columns`, `link|unlink` not `add|remove`, a `messages` block): ```yaml editors: label: golem15.fonoteka::lang.collection.editors messages: link: golem15.fonoteka::lang.collection.editors_link ... view: list: columns: email: label: golem15.fonoteka::lang.collection.editor_email toolbarButtons: link|unlink showSearch: true manage: list: columns: email: label: golem15.fonoteka::lang.collection.editor_email showSearch: true ``` Do not copy PHP YAML verbatim: the full list of keys that are boot errors is in RESEARCH "Anti-Patterns to Avoid". --- ### `USER/models/*.go` **Analog:** `USER/models/user.go` (40-71) for the capability methods a writable admin model needs — cabana requires `lagoon.HasFillable` and `Rules()` on every writable model (`crud.go:449-454`), so `UserGroup` and `Organisation` must gain both: ```go func (User) TableName() string { return "users" } // MorphName is the PHP class string stored in system_files.attachment_type. func (User) MorphName() string { return `Golem15\User\Models\User` } func (User) Fillable() []string { return []string{"name", "surname", "email", ...} } func (User) Rules() map[string]string { return map[string]string{ "email": "required|between:6,255|email|unique:users", ... } } func init() { Register(User{}) } ``` `User.Rules()` is the register contract and must not change (G5). Every new model file ends with `init() { Register(...) }` (`USER/models/registry.go`). **Attach relation** (`APP/plugins/golem15/feedback/models/submission.go:59-65`): ```go func (Submission) MorphName() string { return `Golem15\Feedback\Models\FeedbackSubmission` } // AttachRelations declares the public attachOne screenshot. func (Submission) AttachRelations() []attach.Relation { return []attach.Relation{{Name: ScreenshotField, Public: true}} } ``` For `User` the relation name must be `"avatar"`, the value the API already writes (`USER/controllers/api_controller.go:1209`). **Secret and non-payload fields:** `json:"-"` as on `Password`, `ActivationCode`, `Groups` (`user.go:13,19,37`). `LastSeen`, `Permissions`, and read-only `CreatedAt` / `UpdatedAt` (gorm `->`) all take `json:"-"` (RESEARCH Pitfall 11 and anti-patterns). **Nullable column style:** pointer types as in `user_group.go` (`Code *string`, `CreatedAt *time.Time`). --- ### `USER/updates/*.go` (three additive migrations) **Analog for ALTER:** `USER/updates/202609220005_extend_users.go` — one package-level slice, `execStmts` helper, rollback in reverse order with `IF EXISTS`, self-registration in `init`: ```go var extendUsersMigrations = []*gormigrate.Migration{ { ID: "202609220005_extend_users", Migrate: func(tx *gorm.DB) error { return execStmts(tx, []string{ `ALTER TABLE users ADD COLUMN name TEXT`, ... }) }, Rollback: func(tx *gorm.DB) error { return execStmts(tx, []string{ `ALTER TABLE users DROP COLUMN IF EXISTS deleted_at`, ... }) }, }, } func init() { Register(extendUsersMigrations...) } ``` **Analog for CREATE TABLE:** `USER/updates/202610020001_create_user_groups.go` (13-54): header comment naming the PHP migrations folded in (here `updates/v2.8.0/*`), raw SQL with `SERIAL PRIMARY KEY`, `TIMESTAMPTZ NULL`, `DROP TABLE IF EXISTS` rollback. ID format is `YYYYMMDDNNNN_name`. Shipped migrations are frozen; add new files only. Migration tests: `USER/updates/user_groups_test.go`, `postgres_test.go` (harness). --- ### `USER/classes/privileged.go`, `permissions.go`, `admin_actions.go` **Analog:** `USER/classes/user_groups.go` (whole file). Free functions taking `(ctx, db, ...)`, a nil-handle guard with the plugin-prefixed error, `db.WithContext(ctx)`, table-qualified joins: ```go func HasGroupCode(ctx context.Context, db *gorm.DB, userID uint, code string) (bool, error) { if db == nil { return false, fmt.Errorf("golem15.user: gorm handle is missing") } var n int64 err := db.WithContext(ctx). Table("user_groups"). Joins("JOIN users_groups ON users_groups.user_group_id = user_groups.id"). Where("users_groups.user_id = ? AND user_groups.code = ?", userID, code). Count(&n).Error ... } ``` Group codes compare case-sensitively and a NULL code is never privileged (the query at line 21 already skips NULL codes). The privileged list is read from config per request (`p.app.Config`, as `plugin.go:102` reads `golem15.user.jwt.blacklist_sweep_interval`), default added to `USER/config/config.yaml`. **Attachment cleanup on force delete** — `USER/controllers/api_controller.go:1216-1230`: collect blob keys in the after-commit callback, delete blobs only after the transaction: ```go err := gdb.WithContext(ctx).Transaction(func(tx *gorm.DB) error { return attach.DeleteForOwner(tx, user, id, func(blobKeys []string) error { keys = append(keys, blobKeys...) return nil }) }) ... return attach.DeleteKeys(ctx, bucket, keys) ``` Inside an admin hook the transaction is the one from `requestDB(ctx, c.db)`; do not open a second one. Delete `user_throttle` and `users_groups` rows in the same transaction before the `Unscoped().Delete` (RESEARCH Pitfall 3). Throttle and activation helpers to reuse rather than re-implement: `USER/classes/throttle.go`, `USER/classes/codes.go` (`IssueActivationCode`, `VerifyActivationCode`), `bouncer.HashPassword(cost, password)`. Unit-test layout: `USER/classes/user_groups_test.go`, `throttle_test.go`. --- ### Plugin admin tests (`USER/*_test.go`) **Analog:** `APP/plugins/golem15/fonoteka/admin_collections_test.go` and `admin_tracer_test.go`. Boot the real plugin set and assemble the handler (`admin_tracer_test.go:271-291`): ```go application := backpack.New(cfg) if err := lagoon.Publish(application, bootSQL, gdb); err != nil { t.Fatal(err) } plugins, err := party.Activate(application, []string{"golem15.user", "golem15.golem", "golem15.fonoteka"}) ... if err := lagoon.Migrate(gdb, plugins); err != nil { t.Fatal(err) } h, err := surf.Assemble(application, plugins) ``` Mint a backend principal with a chosen permission set (`admin_collections_test.go:578-599`) — the tool for the T-12-18 "with and without the extra permission" matrix: ```go permissions := `{}` if allow { permissions = `{"golem15.fonoteka.access_collections":1}` } var roleID uint if err := gdb.Raw(`INSERT INTO backend_user_roles (name, code, permissions, is_system, created_at, updated_at) VALUES (?, ?, ?, FALSE, NOW(), NOW()) RETURNING id`, ...).Scan(&roleID).Error; err != nil { t.Fatal(err) } if err := gdb.Exec(`INSERT INTO backend_users (login, email, password, is_activated, is_superuser, role_id, created_at, updated_at) VALUES (?, ?, ?, TRUE, FALSE, ?, NOW(), NOW())`, ...).Error; err != nil { t.Fatal(err) } return loginToken(t, h, login, password), login + "@example.test" ``` The user plugin's existing tests (`USER/session_test.go` helpers `insertUser`, `loginToken`, `postJSON`, `bearer`, `captureMail` at 354-400) cover the user API side: reuse `captureMail` for the invite-mail assertion and `insertUser` for seeding. The plugin has no admin harness yet (RESEARCH Wave 0 gap); the plugin set to activate in its own module is narrower than the application's and needs to be established by the first plugin plan. ## Shared Patterns ### Authorization layering (every new write route) **Source:** `modules/cabana/http.go:932-950` (`protect`), `modules/cabana/actions.go:120-132` (`allowAction`) **Apply to:** bulk action and record action handlers, every plugin action ```go principal, _ := bouncer.User(r.Context()) if principal == nil || !principal.Backend { WriteError(w, http.StatusUnauthorized, "unauthenticated", msgUnauthenticated) return } if !Allows(principal, requiredOf(cc.Controller)) { s.logAuth(r, "denied", principal.ID) WriteError(w, http.StatusForbidden, "forbidden", msgForbidden) return } ``` Order is fixed: backend guard, `requireAjax`, `protect` (controller permissions), declared-in-YAML check, `allowAction` (action permissions), then scope resolution, then plugin code. ### Ids never reach plugin code **Source:** `modules/cabana/crud.go:550-583` (`lockScoped`), `465-485` (`loadRecord`) **Apply to:** bulk actions (list scope), record actions (form scope) The plugin receives loaded, locked records. Zero matches is a no-op, a partial match is `partialSelection{}` (409), missing and out-of-scope single ids are the same 404. ### Transaction from context **Source:** `modules/cabana/tx_context.go:27` (`TxFromContext`), `APP/plugins/golem15/fonoteka/controllers/request_db.go` **Apply to:** every plugin hook, action and scope that touches `users`, `user_throttle`, `users_groups` or attachments ### Error outcomes a plugin may produce **Source:** `modules/cabana/crud.go:412-434`, `498-523`; `modules/cabana/actions.go:141-150` **Apply to:** all hooks and actions `*cabana.ValidationError{Details: map[string]any{field: []string{msg}}}` is a 422. The new forbidden error is a 403. Anything else is logged and answered with the opaque 500 body; error text never reaches the client. Unsupported `lagoon.Validate` rule tokens (`regex`, `alpha_dash`) become a 500, so they are checked in hooks and returned as `ValidationError`. ### Fail-loud boot **Source:** `modules/cabana/extension.go:256-278`, `modules/cabana/list_schema.go:298-363` **Apply to:** every new YAML key and every new registration Unknown keys, unknown types, duplicate names, reserved names, an action a YAML file names but the controller does not register, and a missing label are all boot errors wrapped by `bootErr(pluginID, controllerID, file, err)`. ### Cached schema is never mutated **Source:** `modules/cabana/http.go:713-721` **Apply to:** bulk actions in the list schema, record actions on the record response, permission options in the form schema Filter into a new slice on the localized copy. ### Framework change bundle **Source:** project `CLAUDE.md` Documentation section; RESEARCH "OpenAPI → TS types → dist pipeline" **Apply to:** every framework plan One commit carries: Go change, swag stub in `admin_openapi.go`, regenerated `admin/openapi/admin.json` and `admin/src/api/schema.d.ts`, alias in `admin/src/api/types.ts`, rebuilt `modules/boardwalk/dist`, module README, affected `docs/backend/*.md` page. Go fences in docs are `src=` references to compiled code. Neutral names only (`acme`, `blog`). ### Phrase keys, not text **Source:** `APP/plugins/golem15/fonoteka/controllers/albums_admin_controller.go:72,76`; `USER/lang/{en,pl}/lang.yaml` **Apply to:** action labels, confirm texts, result messages, permission labels, navigation labels, YAML labels Keys are `golem15.user::lang..` in both `en` and `pl`; framework keys are `backend::lang.*` (full list for this phase in UI-SPEC "Framework: all new keys"). ## No Analog Found | File | Role | Data Flow | Reason | |------|------|-----------|--------| | `USER/controllers/users/config_filter.yaml` | config | file-I/O | No application controller under `APP/plugins/golem15/fonoteka/controllers/` ships a `config_filter.yaml`. Use the framework fixture `modules/cabana/testdata/list/all_filters.yaml` and `modules/cabana/filter_schema.go` for the accepted keys (`type: switch` + `column`, `type: daterange` + `column`, `scope`), plus `docs/backend/lists-and-filters.md`. Neither was read in this pass. | | `USER/classes/permissions.go` (merged-permission resolver, tolerant `PermissionSet` scanner) | utility | transform | Nothing in either Go repository merges group and user permission maps. Port PHP `models/User.php` `getMergedPermissions` and Winter `hasPermission` line by line (RESEARCH Pitfalls 5 and 6); `modules/cabana/contracts.go` `granted` (153-185) is a reference for the wildcard matching only. | Partial gaps inside files that otherwise have analogs: - **Preview screen (S3):** there is no read-only record rendering in the SPA. `FormView.vue` is the host, but the `dl` grid and per-type read-only boxes are new; the contract is UI-SPEC S3. - **Row state batch hook:** no existing list hook returns per-row data. Closest shape is `pact.ListExtendQuery` (a controller interface the framework type-asserts in `lockScoped`, `crud.go:558-562`). - **Form virtual fields (G2) and rules-per-operation (G5):** no precedent; both are contract growth awaiting user confirmation. A context accessor would sit next to `TxFromContext` in `modules/cabana/tx_context.go` and follow its key-type pattern. ## Metadata **Analog search scope:** `summercms.go/modules/cabana`, `modules/pact`, `admin/src`, `admin/tests`, `scripts`; `fonoteka.go/plugins/golem15/fonoteka` (controllers, admin registration, admin tests), `fonoteka.go/plugins/golem15/user` (all packages), `fonoteka.go/plugins/golem15/feedback/models` **Files scanned:** about 45 read in whole or in targeted ranges **Not read in this pass:** the PHP reference tree (RESEARCH.md already inventories it), `docs/backend/*.md` bodies, `modules/cabana/README.md`, `modules/cabana/filter_schema.go`, `modules/cabana/relation.go`, `modules/cabana/field_file.go` beyond the lines RESEARCH.md quotes, `modules/cabana/contracts.go`, and `fonoteka.go/parity/schema_diff_test.go`. Line references to those files come from RESEARCH.md and should be re-checked by the executor. **Line-number validity:** cabana line references hold until other work touches `modules/cabana` (RESEARCH gives them seven days). **Pattern extraction date:** 2026-10-04