Files
summercms/.planning/phases/12.1-user-plugin-admin-screens/12.1-PATTERNS.md
2026-10-04 19:41:08 +02:00

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/ (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/<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:

  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):
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 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:

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 (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:

<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 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.
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).
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:

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.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