60 KiB
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/(thesm-user-pluginsubmodule; its own git repository).- All analog paths were checked with
git ls-filesin their own repository (framework, application, and thesm-user-pluginsubmodule). No mirror or generated path is named as an analog.modules/boardwalk/dist,admin/openapi/admin.jsonandadmin/src/api/schema.d.tsare 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/<fixture>/... (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, <tr> 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.
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):
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:
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:
// 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
}
// 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:
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:
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:
- A small exported struct with
jsontags (BulkAction49-52;ToolbarAction174). - A doc comment on the field saying it is per-principal when it is (lines 71-73).
- A nil guard in
MarshalJSONso the SPA never receivesnull(lines 102-119):
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 compile<Type>Keys(typ, values, field) called from compileFieldNode, and a compile<Type>Fields(pluginID, cc) boot check against the model.
// 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).
// 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:
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> 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 inassignBelongsTo(481), which re-checksprotectedFillKeyindependently. Both must change together or the write is silently skipped. RelationOption(52-55) is{Value uint, Label string}; aLockedflag is added withjson:"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:
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:
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:
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:
// 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:
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(withX-Requested-With),cookie-only(CSRF refusal). - Environment builder (
newActEnv, 254-318):adminGorm(t),AutoMigratefixture models,insertAdmin, a limited role row with{"acme.demo.access":1},compass.Openon 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:
<script setup lang="ts">
import { computed } from 'vue'
import { controlAttributes, controlClass, type FieldControlProps } from '../control'
const props = defineProps<FieldControlProps>()
const emit = defineEmits<{ 'update:modelValue': [value: string] }>()
...
</script>
<template>
<input
:id="controlId"
v-bind="attrs"
:name="field.name"
type="text"
:value="text"
:required="field.required || undefined"
:aria-required="field.required ? 'true' : undefined"
:aria-invalid="invalid ? 'true' : undefined"
:aria-describedby="describedBy || undefined"
:class="controlClass(invalid)"
class="h-input"
@input="emit('update:modelValue', ($event.target as HTMLInputElement).value)"
/>
</template>
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:
const renderers = new Map<string, Component>([
['text', TextField],
...
['datepicker', DatepickerField],
])
const selfLabelled = new Set<string>(['switch', 'checkbox', RELATION_MANAGER])
const valueless = new Set<string>([RELATION_MANAGER, 'widget', 'partial', 'fileupload'])
const groupLabelledTypes = new Set<string>(['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:
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):
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 <tr> 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 iscontextAllows(field, mode)at lines 88-95. - Route: add next to
recordinrouter.ts:71and add the name toCONTROLLER_ROUTES(line 37), or plugin stylesheets are not activated on the preview screen.
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 theupdate/:idbranch forpreview/:id. The safety rule stays: only the current controller's own routes can come out, ids must matchDIGITS, anything else falls back to the list. Update the header comment table (lines 6-9).
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 throughmapWinterUrl. - Record action request flow: copy
onDelete(305-333):busyguard,confirm.ask, typedapi.*call withparams: { 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:
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):
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:
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:
{
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:
{
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):
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):
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:
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:
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:
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):
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:
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):
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: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).
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:
{
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):
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):
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):
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:
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):
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:
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):
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:
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:
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:
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):
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:
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
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.<group>.<key> 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.vueis the host, but thedlgrid 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 inlockScoped,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
TxFromContextinmodules/cabana/tx_context.goand 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