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

91 KiB

Phase 12.1: User plugin admin screens - Research

Researched: 2026-10-04 Domain: cabana admin pipeline (Go + Vue SPA) and the golem15.user plugin port of three WinterCMS backend screens Confidence: HIGH for the current code shape and the PHP inventory (all read this session); MEDIUM for the recommended new contracts (design choices inside Claude's discretion, several need user confirmation)

<user_constraints>

User Constraints (from CONTEXT.md)

Locked Decisions

Impersonate and guests

  • D-01: Impersonate is not ported: no action, no button. PHP impersonation is session-based and never reaches the JWT-driven frontend, so nothing is lost at cutover.
  • D-02: The permission golem15.users.impersonate_user is still registered by the Go plugin, so backend roles imported at cutover that reference it stay valid and a later phase can use it. The plugin registers all four PHP codes: golem15.users.access_users, access_groups, access_settings, impersonate_user.
  • D-03: The guest concept is dropped entirely: no convert-guest action, no is_guest column or list column, no guest hint. The seeded Guest group row stays as data.

Group membership and privileged groups (T-12-18)

  • D-04: golem15.users.access_users is enough to change a user's membership of ordinary groups, as in PHP. Adding or removing a privileged group additionally requires a new backend permission. The check is on the server and covers every path that writes users_groups (the user form's groups field, any relation manager, bulk or record actions). — Reversibility: costly — the permission code is stored in backend roles; renaming it later means rewriting role rows in every host application.
  • D-05: Privileged groups are a config list of group codes under the golem15.user config namespace, default [admin]. The plugin is shared across projects, so an application adds a code without a plugin change.
  • D-06: The User Groups screen is gated by golem15.users.access_groups. Creating a group with a privileged code, changing a code to or from a privileged one, and deleting a privileged group all require the same extra permission as D-04. Ordinary groups are edited freely.
  • D-07: For an admin without the extra permission, privileged groups are shown in the user form's groups field but locked (disabled), so the admin can see that a user is a site admin. A save that tries to change a privileged membership anyway is refused with a forbidden error and changes nothing (no partial save).
  • D-08: User.Groups stays out of every user API payload (P12 D-25). Only the admin API reads or writes it.

Actions, delete semantics and preview (framework + plugin)

  • D-09: cabana gains declared bulk actions, generic for every plugin: a controller registers named bulk actions, the list declares which ones it offers, the SPA sends the selected ids, and the framework resolves them through the controller's list scope inside one transaction before calling the plugin. Ids outside the scope never reach the plugin. Bulk delete keeps working as today. — Reversibility: costly — list YAML and the pact action contract grow; every plugin's list config may come to depend on the shape.
  • D-10: cabana gains record actions: named actions shown as buttons for one record, run against the record loaded through the controller's form scope, each with its own permissions on top of the controller's, and each able to say whether it applies to the record's current state. — Reversibility: costly — same contract growth as D-09.
  • D-11: cabana gains Winter's preview context: a read-only record screen with its own toolbar, fields with context: preview, and a recordUrl that may point at it. The Users list opens preview first, then Update, as in PHP. Status hints (not activated, banned, suspended, deleted) and the record actions live on the preview screen. — Reversibility: costly — a new screen and route in the SPA and a new context value in the form schema.
  • D-12: cabana gains row state: a controller returns a state per list row from a fixed framework-defined set (Winter's listInjectRowClass, limited to known states such as deleted, negative, disabled, not free CSS classes). The SPA styles states with design tokens. Users: deleted when trashed, negative when banned, disabled when not activated.
  • D-13: Delete semantics follow PHP Users.php exactly. The Users list and form include trashed users (withTrashed). deactivate is the soft delete, restore brings a user back, and delete (form button and bulk) is a permanent force delete. No extra typed confirmation beyond the standard confirm.
  • D-14: Bulk actions on Users, as in PHP index_onBulkAction: delete, activate, deactivate, restore, ban, unban. Record actions: activate, unban, unsuspend. Ban and suspend state lives in the existing user_throttle table.

Fields and columns

  • D-15: Frontend permissions are ported in full: the golem15_user_frontend_permissions table (code, label, tab, comment), the users.permissions column, the existing user_groups.permissions column, the Permissions tab on the user form (radio mode: allow / deny / inherit) and the group form (checkbox mode), and a Go resolver equivalent to PHP User::getMergedPermissions() that a plugin can call to ask whether a user holds a permission. Nothing in the application calls the resolver yet; unit tests prove it. No plugin-declared registry: rows come from migrations or seed, as in PHP. — Reversibility: costly — additive migrations in the shared user plugin; the stored JSON shape must match PHP for the cutover import.
  • D-16: The editor is a built-in framework field type, permissioneditor, in cabana and the SPA: tabbed, with a radio or checkbox mode, and an option list supplied by the controller. It replaces the PHP YAML's Golem15\User\FormWidgets\FrontendPermissionEditor class name. It is built to be reusable for backend role permissions later. The 10.1 widget contract (scalar fill keys only) is not widened. — Reversibility: costly — a new field type in the typed schema and the generated TS types.
  • D-17: last_seen is added to users as an additive column, updated by the Go auth path on login or token refresh, and shown in the Users list as in PHP. It must not appear in any user API payload unless PHP already returns it.
  • D-18: username is dropped from the ported YAML and no column is added (login is by email).
  • D-19: The user form carries the PHP create/update behaviour: password with confirmation on create, password reset on update, send_invite (default on, create only) that sends the invitation mail, and created_ip_address / last_ip_address on preview.
  • D-20: Avatars use the Phase 12.2 fileupload field (mode: image) on both the user form (260x260) and the organisation form (120x120). They must bind to the same attachment the user API already serves, so an avatar set in the admin shows in the app and the other way round.
  • D-21: block_mail and MailBlocker are left out. A todo records them for the mailing work (mail settings, templates).

Screens, as ported

  • D-22: Organisations: list plus form (name, slug with preset from name, description, avatar) gated by golem15.users.access_users, and a members relation manager that assigns existing users (PHP add|remove on the hasMany, which sets or clears the user's organisation_id). The user form's organisation field is a belongsTo relation picker.
  • D-23: Users filters follow PHP config_filter.yaml: groups (scope filterByGroup), created date (daterange) and activated (switch). The PHP conditions: strings are re-expressed as model scopes, since a conditions key is a boot error (P9).
  • D-24: User Groups list keeps the users_count column.

Release and ordering

  • D-25: Framework first. The cabana and SPA work (D-09 to D-12, D-16) lands in the early plans with module READMEs, docs/ pages, the admin OpenAPI document, generated TS types and the rebuilt dist/, and is tagged v0.1.3 (v0.1.2 already exists). The plugin screens build on that tag. Framework fixtures use neutral names and never name the application.

Claude's Discretion

  • The code of the extra permission in D-04 (for example golem15.users.manage_privileged_groups), its label and tab, and the config key name for the privileged list.
  • YAML keys and Go interface names for bulk actions, record actions, preview and row state, provided they follow the existing fail-loud rules (unknown keys and unregistered actions are boot errors), sit under the {prefix}/api/v1/{vendor}/{plugin}/{controller}/... scheme, use requireAjax on writes and carry swag annotations.
  • The exact set of row states and their token styling; whether a state is also announced in text for accessibility.
  • What the preview screen shows beyond status hints, preview-context fields and actions (PHP's scoreboard is optional).
  • Whether last_seen is written on login only or also on refresh, and any throttling of that write.
  • How force delete cleans up what hangs off a user (attachments, throttle, groups pivot), following PHP User delete behaviour.
  • Plan count and split, subject to the plan-count checkpoint and "unit tests are the last plan". Run the security-review agent: the phase touches authorization (T-12-18), the plugin API (pact) and destructive bulk operations.

Deferred Ideas (OUT OF SCOPE)

  • Impersonate a frontend user from the admin by minting a short-lived user JWT, with an audit record and a Nuxt entry point. The permission is already registered (D-02).
  • MailBlocker and the block-mail checkbox: recorded as todo mailblocker-with-mailing.md, to be done with the mailing development.
  • Guest users and convert-guest, for a project that uses guests.
  • A username column and login by username.
  • A plugin-declared frontend permission registry synced at boot.
  • Using permissioneditor for backend role permissions.
  • The user plugin's Settings screen (access_settings).

Reviewed Todos (not folded)

  • nest-framework-packages-under-modules.md, per-module-readmes-after-nest.md, readme-go-fences-src.md, rewrite-summercms-readme.md, refresh-fonoteka-readme.md: repo and README housekeeping; keyword matches only.
  • 2026-10-01-benchmark-the-application-on-wintercms-vs-summercms.md: benchmarking, unrelated.
  • backend-admin-api-tokens.md: backend admin auth, not frontend user administration.
  • orphan-pending-routes.md: covered by Phase 14.1.
  • scaffold-admin-controller-incomplete.md, scaffold-same-second-migration-order.md, scaffold-generated-header-and-command-deps.md, bonfire-duplicate-command-names.md: CLI scaffolding bugs; worth knowing if the planner uses make:admin-controller, but not this phase's scope.
  • lagoon-readme-after-commit-callback-order.md, sitemap-plugin-port.md: unrelated. </user_constraints>

Summary

All research for this phase was done by reading the two Go repositories and the PHP reference. No external library is added and no web lookup was needed, so the research-plan/Context7 seam was not used; every claim below is either read from a file this session or marked [ASSUMED].

The five framework features in CONTEXT.md (bulk actions, record actions, preview, row state, permissioneditor) all have clean seams in cabana: one action namespace (CompiledController.Actions), a transaction-scoped id resolver that bulk delete already uses (lockScoped), a scoped single-record loader (loadRecord / readScopedRecord), a form context list that already accepts any identifier, and a typed schema that flows to the SPA through swag, swagger2openapi and openapi-typescript. The contract tests TestPhase09ContractInventory, TestPhase09PermissionMatrix and TestPhase10OpenAPIConformance force the route table, the permission matrix and the OpenAPI document to stay in step.

The main finding is that those five features are not enough. Reading the Users form field by field against cabana shows eight further gaps that block locked decisions (D-07, D-13, D-19, D-22, D-24). The most serious: a controller hook never sees form values that are not model columns, so password, password_confirmation and send_invite cannot reach the plugin; password, permissions, is_activated and organisation_id are hard-coded protected fill keys, which makes the organisation picker read-only and the password unwritable; a hook cannot answer 403; a relation field has no way to show an option as locked; and the User.Rules() the admin save would validate against demands password|confirmed, which fails on every admin update. Each gap has a recommended answer below, but four of them grow the pact/cabana contract and need the user's confirmation at the plan-count checkpoint.

Primary recommendation: Plan the framework half as "five decided features plus a small set of enabling seams" (form virtual fields with a password type, an explicit writable-foreign-key opt-in, locked relation options, a 403 error type, preset, invisible columns), tag v0.1.3, then port the three screens with flattened YAML and plugin-side hooks. Present the extra seams to the user before writing plans.

Project Constraints (from CLAUDE.md)

  • Lean planning: few, large plans. Present the plan count with a one-line scope each and wait for confirmation before writing PLAN.md files.
  • Unit tests are the last plan of the phase. Earlier plans may carry smoke tests.
  • Standard library first. A dependency is added only when research or a phase decision names it. This research names none.
  • go vet and go test ./... green at every commit, in both repositories.
  • Plugins are compiled; no runtime plugin loading.
  • API parity: the user API payloads must not change (D-08, D-17).
  • No co-author tags. One logical change per commit. Planning docs and code in separate commits.
  • A change to a module's exported API, config keys or CLI commands updates that module's README.md and the affected docs/ pages in the same change. Every identifier named in a README or docs page must exist (go test ./cmd/summer -run TestDocsTree, summer docs:build --check).
  • Framework READMEs, docs and fixtures never name a consuming application; use acme / blog.
  • Config keys named in docs are not machine-checked; review by hand.
  • Two repositories: framework code in summercms.go, plugin code in sm-user-plugin (mounted at ../fonoteka.go/plugins/golem15/user), planning docs in summercms.go/.planning.
  • Core plugin contract: golem15.user is shared across projects; migrations are additive, routes and payloads unchanged.
  • The admin SPA has an approved exact-pin package gate (17 pins at Phase 10, plus @internationalized/date): any new npm package or version needs a checkpoint. This research recommends no new npm package.

Architectural Responsibility Map

Capability Primary Tier Secondary Tier Rationale
Bulk action dispatch, id scoping, transaction API / Backend (cabana) — D-09: ids must be resolved through the list scope before plugin code runs
Bulk action business logic (ban, restore, activate) API / Backend (plugin controller) Database Plugin owns user_throttle and users writes
Record action dispatch, applicability check API / Backend (cabana + plugin) Browser (button rendering) The server decides which actions apply; the SPA only renders the offered list
Preview screen Browser (SPA route and view) API (schema flag, redirects) Read-only rendering of data the show route already returns
Row state API / Backend (controller hook) Browser (token styling) State is derived from data the browser does not have (user_throttle)
permissioneditor value validation and storage API / Backend (cabana + model) Browser (editor UI) Codes and allowed values are validated against the controller's option list on the server
Privileged-group authorization (T-12-18) API / Backend — D-04: server-side on every users_groups write path; the locked UI is a display aid only
Password hashing, invitation mail API / Backend (plugin hook) — Reuses bouncer.HashPassword and the plugin's mail path
Avatar storage Database / Storage (system_files + blob bucket) API (cabana file routes) Same attach.File rows the user API writes
last_seen write API / Backend (user plugin auth handlers) Database D-17
Admin navigation and permissions API / Backend (pact.HasNavigation, HasPermissions) Browser Registry filters navigation per principal

Standard Stack

No new library. Everything is already in the two module graphs.

Core (already present, verified in go.mod / package.json this session)

Library Version Purpose Why Standard
modules/cabana, modules/pact, modules/lagoon, modules/bouncer in-repo Admin pipeline, capability interfaces, data layer, auth The framework this phase extends
gorm.io/gorm v1.31.2 [VERIFIED: ../fonoteka.go/plugins/golem15/user/go.mod] ORM Project decision
go-gormigrate/gormigrate/v2 v2.1.7 [VERIFIED: same go.mod] Additive plugin migrations Project decision
goccy/go-yaml v1.19.2 [VERIFIED: same go.mod] Strict YAML decoding in cabana Project decision
swaggo/swag v1.16.6 [VERIFIED: scripts/check-admin-openapi.sh:33] Admin OpenAPI generation Pinned in the generation script
vue 3.5.35 [VERIFIED: admin/package.json] Admin SPA Exact pin
reka-ui 2.9.10 [VERIFIED: admin/package.json] Headless tabs, radio groups, checkboxes for the permission editor Already the SPA's primitive library
openapi-typescript 7.13.0 [VERIFIED: admin/package.json] TS types from the OpenAPI document Exact pin
vitest 3.2.7 [VERIFIED: admin/package.json] SPA unit tests Exact pin
testcontainers-go v0.44.0 [VERIFIED: plugin go.mod] Real Postgres in Go tests Project decision

Alternatives Considered

Instead of Could Use Tradeoff
New password field type plus virtual fields An admin-only wrapper struct embedding models.User No contract growth, but hooks still cannot see password_confirmation or send_invite; does not solve D-19 on its own
preset in the framework Server-side slug default only (BeforeValidate) Loses the live fill D-22 names; keeps v0.1.3 smaller
invisible list columns Show surname and the IP columns, or drop them Losing them loses PHP's search on surname and IP

Installation: none.

Package Legitimacy Audit

This phase installs no external package in either the Go modules or admin/package.json, so the legitimacy gate has nothing to check.

Packages removed due to [SLOP] verdict: none Packages flagged as suspicious [SUS]: none

Current Shape of the Framework (extension seams)

Routes (modules/cabana/http.go:219-344)

Every controller route is registered in service.mount inside r.GroupRaw(api, []string{"backend"}, ...); writes are wrapped in requireAjax. The relevant existing write routes, quoted verbatim [VERIFIED: modules/cabana/http.go:254-272]:

g.Post("/{vendor}/{plugin}/{controller}", requireAjax(s.create))
g.Post("/{vendor}/{plugin}/{controller}/bulk-delete", requireAjax(s.bulkDelete))
g.Post("/{vendor}/{plugin}/{controller}/widgets/{field}", requireAjax(s.widgetAction))
g.Post("/{vendor}/{plugin}/{controller}/toolbar/{action}", requireAjax(s.toolbarAction))
g.Get("/{vendor}/{plugin}/{controller}/partials/{name}", s.partial)
g.Get("/{vendor}/{plugin}/{controller}/{id}", s.show)
g.Put("/{vendor}/{plugin}/{controller}/{id}", requireAjax(s.update))
g.Delete("/{vendor}/{plugin}/{controller}/{id}", requireAjax(s.deleteRecord))

Six-segment GET routes share one pattern dispatched by nestedGet (http.go:397-414) because ServeMux cannot hold them side by side. New POST routes with a distinct literal segment do not collide with the existing POST patterns [ASSUMED: the surf overlap check is not re-run here; TestPhase09ContractInventory will prove it].

service.protect (http.go:932-950) resolves the controller, requires a backend principal and enforces RequiredPermissions. service.allowAction (actions.go:120-132) checks an action's own permissions on top and logs a denial.

Actions (modules/cabana/actions.go, extension.go, modules/pact/capabilities.go:215-256)

  • One namespace: CompiledController.Actions map[string]pact.AdminAction, built by compileActions (extension.go:256-278). Reserved names, verbatim [VERIFIED: modules/cabana/extension.go:47]: var builtinToolbarActions = map[string]bool{"create": true, "delete": true}.
  • pact.AdminAction fields, verbatim [VERIFIED: modules/pact/capabilities.go:222-227]: Name, Label, Permissions, Run func(ctx context.Context, in AdminActionInput) (AdminActionResult, error).
  • AdminActionInput [VERIFIED: capabilities.go:236-241]: Field, RecordID *uint64, Record any, Values map[string]any. AdminActionResult: Message, Fill.
  • A toolbar action body must be {}; record_id or values is a 422 (actions.go:93-96). This is the property D-09 must keep: an id list never becomes an unscoped lookup.
  • runAction (actions.go:137-155) maps a *ValidationError to 422 and every other error to a logged 500.
  • Action permissions are validated at boot against registered permissions (registry.go:225-229).

Bulk delete, the model for D-09 (modules/cabana/crud.go:197-247, 550-583)

BulkDelete does normalizeIDs → lagoon.Transaction → lockScoped (applies pact.ListExtendQuery, FOR UPDATE, ordered by primary key) → zero rows is a no-op, a partial match is partialSelection{} (409) → per-row deleteRecord. The body type is BulkDeleteInput{IDs []any} and the result BulkResult{Deleted int} [VERIFIED: crud.go:41-49]. The transaction is put on the context with withTx, and a hook reads it with cabana.TxFromContext(ctx) (tx_context.go:27).

operationDeclared (registry.go:122-132): bulk-delete needs the toolbar delete button, which needs showCheckboxes: true. The schema's BulkActions already exists as a list [VERIFIED: schema_types.go:48-52, 82] and today carries only {Name: "delete", Label: "backend::lang.list.delete_selected"} (list_schema.go:152-155).

Single-record scope, the model for D-10 (crud.go:465-485, actions.go:192-215)

loadRecord applies pact.FormExtendQuery, locks FOR UPDATE and returns recordNotFound{} for missing and out-of-scope ids alike. readScopedRecord is the same without the lock. writeCRUDError (crud.go:412-434) knows exactly four outcomes: 413, 422 (*ValidationError), 404 (recordNotFound), 409 (partialSelection), else 500. There is no error a hook or action can return to produce 403.

List schema (list_schema.go, schema_types.go)

  • config_list.yaml keys accepted by listDocument [VERIFIED: list_schema.go:26-43]: list, modelClass, title, recordUrl, noRecordsMessage, recordsPerPage, perPageOptions, showCheckboxes, showSetup, showSorting, showSearch, defaultSort, toolbar, filter, messages, headerPartial. Decoding is strict: any other key is a boot error.
  • toolbar.buttons must be a YAML list; a scalar such as PHP's list_toolbar is refused with a message naming the Winter partial (list_schema.go:325-327).
  • columns.yaml per-column keys [VERIFIED: list_schema.go:62-69]: label, searchable, sortable, type, relation, select. Column types, verbatim [VERIFIED: list_schema.go:22-24]: "text", "datetime", "switch", "date", "time". A column key that is not a model column (and has no relation/select) is a boot error.
  • Rows are projected by projectRow (http.go:969-995): id plus one key per column, read from the model by gorm column name.
  • ExecuteList (query.go:81-155) is not in a transaction; a list hook has no TxFromContext.

Form schema (form_schema.go, crud.go)

  • Field types, verbatim [VERIFIED: form_schema.go:23-27]: "text", "textarea", "number", "checkbox", "switch", "dropdown", "relation", "relation-manager", "widget", "partial", "fileupload", "datepicker".
  • Field keys, verbatim [VERIFIED: form_schema.go:34-44]: label, comment, span, type, required, tab, context, attributes, size, default, nameFrom, emptyOption, options, relation, widget, action, fill, path, mode, fileTypes, mimeTypes, maxFilesize, maxFiles, imageWidth, imageHeight, thumbOptions, useCaption, prompt, format, minDate, maxDate, yearRange, firstDay, twelveHour, ignoreTimezone. type is required. The fields file has one top-level key, fields (formFieldsFile, form_schema.go:64-66).
  • mode is refused on any type other than fileupload or datepicker [VERIFIED: field_file.go:74]: "mode is only valid on type: fileupload or datepicker". permissioneditor must be added to that rule.
  • config_form.yaml keys [VERIFIED: form_schema.go:49-62]: name, form, modelClass, defaultRedirect, create, update, messages; create/update accept only redirect and redirectClose.
  • context accepts any identifier or list of identifiers (compileContext, form_schema.go:555-576), so context: preview already compiles. contextAllows(cc, name, op) (crud.go:854-873) is called with create or update, so a preview-only field is never writable.
  • Writable binding (BindWritableFields, crud.go:103-128): every scalar field (scalarFormField: text, textarea, number, checkbox, switch, dropdown, datepicker) must be a model column, else boot fails with field X is not a model column.
  • Protected fill keys, verbatim [VERIFIED: modules/cabana/crud.go:833-843]: "id", "created_at", "updated_at", "deleted_at", "owner_id", "user_id", "collection_id", "organisation_id", "organization_id", "scope_id", "role_id", "permissions", "is_superuser", "is_system", "is_activated", "password". A scalar field with one of these names is skipped silently; nested (map or list) values are always dropped by ProjectWritableFields.
  • Save pipeline (crud.go:300-410): lift relation values → transaction → load → lagoon.Fill → BeforeValidate → lagoon.Validate(mergedRules) → FormBeforeCreate/Update → checkRelationScope → assignBelongsTo → Save/Create → syncBelongsToMany → deferred file commit → FormAfterCreate/Update → project. Hooks receive (ctx, model) only.
  • lifecycleFailure (crud.go:498-523) lets a hook's *cabana.ValidationError through as 422; any other hook error becomes an opaque 500.

Relation fields and relation managers

  • type: relation needs a cabana.FieldRelationContract from AdminFieldRelations(). A belongsTo whose foreign key is a protected fill key is read-only [VERIFIED: relation_field.go:198]: out.ReadOnly = protectedFillKey(contract.ForeignKey).
  • belongsToMany needs a pivot model (NewPivot), ParentForeignKey, RelatedForeignKey. Save replaces all pivot rows of the parent (syncBelongsToMany, relation_field.go:497-537): delete, then bulk insert. Submitted ids are revalidated through RelationExtendOptionsQuery (checkRelationScope).
  • RelationOption is {Value uint, Label string} [VERIFIED: relation_field.go:52-55]; there is no disabled or locked flag.
  • relation-manager needs AdminRelationContracts(); kinds [VERIFIED: relation.go:29-35]: RelationBelongsToMany = "belongsToMany", RelationHasMany = "hasMany". View-panel buttons, verbatim [VERIFIED: relation.go:444]: "create", "update", "delete", "link", "unlink". A hasMany link sets the child's ForeignKey through setModelColumn (relation.go:928-934); unlink needs a nullable key.
  • config_relation.yaml lists columns inline under view.list.columns and manage.list.columns; each column key must be in the contract's Columns map (relation.go:413-415). A path string for list: does not decode.
  • There is a RelationBeforeLink hook and six child-record hooks; there is no unlink hook.

File upload (D-20)

compileFileFields (field_file.go:283-365) requires the record model to implement attach.Owner (MorphName) and attach.HasRelations, and to declare a relation whose Name equals the field name. attach.Relation is {Name string, Many bool, Public bool} [VERIFIED: modules/lagoon/attach/relation.go:9-13]. cabana finds a record's files with attachment_type = ? AND attachment_id = ? AND field = ? (field_file.go:522). The user API writes avatars with exactly those three values (Field: "avatar", AttachmentID the decimal user id, AttachmentType: user.MorphName()) [VERIFIED: ../fonoteka.go/plugins/golem15/user/controllers/api_controller.go:1153-1161]. So one AttachRelations() method on models.User binds the admin field to the API's avatar, in both directions.

SPA (admin/src)

  • Field registry: components/form/registry.ts maps type → component in a Map; valueless types are left out of the save body. A new field type is one component under components/form/fields/ plus one registry line.
  • Routes (app/router.ts): list, create, record (/:vendor/:plugin/:controller/:id(\d+)), all rendering ListView or FormView. CONTROLLER_ROUTES (new Set(['list', 'create', 'record'])) drives plugin stylesheet activation and must gain any new controller route.
  • app/winterUrl.ts maps only create and update/:id; anything else (including PHP's preview/:id) falls back to the list.
  • views/ListView.vue holds selection, bulk delete (onDelete) and toolbar actions (onAction); components/list/ListToolbar.vue renders delete and registered toolbar buttons; components/list/DataTable.vue renders rows (<tr> at line 232).
  • views/FormView.vue filters fields with contextAllows(field, mode) where mode is create or update.
  • Types are aliases onto the generated schema (api/types.ts); nothing is hand-written.

OpenAPI → TS types → dist/ pipeline

  1. Add or change swag annotations in modules/cabana/admin_openapi.go (stub functions with // @Router comments; request/response types are real Go types in package cabana).
  2. scripts/check-admin-openapi.sh regenerates admin/openapi/admin.json and admin/src/api/schema.d.ts; --check fails on drift.
  3. Add aliases in admin/src/api/types.ts.
  4. npm --prefix admin run build writes modules/boardwalk/dist; scripts/check-admin-dist.sh rebuilds into a temp dir and diffs. The rebuilt dist/ is committed with its source in the same commit (12.2 rule: "Every task rebuilds modules/boardwalk/dist ... so scripts/check-admin-dist.sh stays clean at every commit" [VERIFIED: .planning/phases/12.2-.../12.2-04-PLAN.md:124]).
  5. Tailwind excludes schema.d.ts, openapi/ and admin/tests from its class scan, so type-only changes do not churn dist/.

Docs checker

go test ./cmd/summer -run TestDocsTree -count=1 and go run ./cmd/summer docs:build --check both pass on the current tree (run this session: ok ... cmd/summer 1.696s, docs:build: no problems found). Pages to update: docs/backend/admin-controllers.md, forms.md, lists-and-filters.md, relation-manager.md, users-and-permissions.md, admin-spa.md, plus modules/cabana/README.md (sections: Overview, Features, Admin API routes, Usage, API reference, Configuration, CLI commands, Dependencies, Testing) and modules/pact/README.md. Go fences in docs pages must be src= references to compiled code; hand-written Go fences are refused.

PHP Screen Inventory and Gap Map

Legend: OK works in cabana today; PORT works after a YAML rewrite or plugin code; FW-D framework feature already decided (D-09..D-12, D-16); FW-NEW framework gap not listed in CONTEXT.md.

Users list (controllers/users/config_list.yaml, models/user/columns.yaml, config_filter.yaml, _list_toolbar.htm)

PHP element Status Notes
recordUrl: golem15/user/users/preview/:id FW-D (D-11) mapWinterUrl must learn preview/:id
showCheckboxes, showSetup, recordsPerPage: 20, search prompt OK
toolbar.buttons: list_toolbar (partial) PORT Rewrite as a list: [create, delete]
New user button OK built-in create
Bulk actions dropdown: delete, activate, deactivate, restore, ban, unban, each with its own confirm text FW-D (D-09) Each needs a label and a confirm message key
listInjectRowClass: strike (trashed), negative (banned), disabled (not activated); several at once FW-D (D-12) Must return a set, not one value; banned needs user_throttle
listExtendQuery: withTrashed PORT ListExtendQuery returning db.Unscoped()
column id (invisible) FW-NEW (G6) or drop
column username (invisible, searchable) dropped (D-18)
column name (searchable), email (searchable) OK
column surname (searchable, invisible) FW-NEW (G6) Without invisible, show it or lose the search
column created_at (type: timetense) PORT Use datetime; models.User has no created_at field yet (the column exists)
column last_seen (type: timetense) PORT New column and field (D-17); use datetime
column is_guest dropped (D-03)
columns created_ip_address, last_ip_address (searchable, invisible) FW-NEW (G6)
filter groups (scope: filterByGroup, modelClass, nameFrom) PORT models.User implements pact.FilterScope and pact.FilterOptions; the value is one id (PHP takes several)
filter created_date (daterange, conditions) PORT type: daterange + column: created_at; needs a time.Time model field
filter activated (switch, conditions pair) PORT type: switch + column: is_activated; no scope needed
event golem15.user.view.extendListToolbar not ported No listener in the application [ASSUMED]
addJs bulk-actions.js not ported Replaced by D-09

Users preview (preview.htm, _preview_toolbar.htm, _hint_*.htm, _preview_scoreboard.htm)

PHP element Status Notes
Preview screen, form rendered read-only FW-D (D-11)
Hint precedence: guest → banned → trashed → not activated (one hint shown) PORT on top of FW-D Guest dropped. A type: partial field with context: preview renders the hint from a controller view model; no new mechanism needed
Toolbar: back to list, Update details FW-D (D-11)
Toolbar: Impersonate dropped (D-01)
Toolbar: Unsuspend, only when isSuspended() FW-D (D-10) applicability check
Hint link: Activate manually (onActivate) FW-D (D-10) record action activate, applies when not activated
Hint link: Unban (onUnban) FW-D (D-10) record action unban, applies when banned
Scoreboard: name, email, joined, status, last seen, online discretion Could be a second partial; optional
Fields with context: preview: created_ip_address, last_ip_address OK after FW-D Already compile
event extendPreviewToolbar not ported

Users form (models/user/fields.yaml, config_form.yaml, update.htm, Users.php)

PHP element Status Notes
top-level tabs: / secondaryTabs: PORT Flatten into fields: with a tab: per field; no secondary-tab sidebar in the SPA
name, surname (no type) PORT Add type: text
email PORT type: text
send_invite (checkbox, default true, context: create) FW-NEW (G2) Not a model column: boot error today
block_mail dropped (D-21)
password@create, password@update (type: password) FW-NEW (G1, G2) No password type; @ is not an identifier; password is a protected fill key
password_confirmation (type: password, context: [create, update]) FW-NEW (G1, G2) Not a model column
username dropped (D-18)
groups (type: relation, belongsToMany, emptyOption) PORT + FW-NEW (G4) Works as a relation field with a pivot model for users_groups; D-07's locked options and 403 do not exist
organisation (type: relation, nameFrom: name, emptyOption) FW-NEW (G3) organisation_id is a protected fill key, so the field compiles read-only
created_ip_address, last_ip_address (disabled: true, context: preview) PORT Drop disabled (unknown key); preview is read-only anyway
permissions (FrontendPermissionEditor, mode: radio, context: update) FW-D (D-16) Value is a map, column name is a protected fill key; needs its own lift-and-store path
avatar (fileupload, mode: image, 260x260) PORT models.User needs AttachRelations()
cssClass, hidden, disabled keys PORT Remove; unknown keys fail boot
config_form.yaml create.redirect: .../preview/:id, update.redirectClose: .../preview/:id FW-D (D-11) via mapWinterUrl
formExtendQuery: withTrashed PORT FormExtendQuery returning db.Unscoped()
update_onDelete: forceDelete() PORT (G8) Built-in delete is a soft delete for a gorm.DeletedAt model
formAfterUpdate / formExtendModel (MailBlocker) dropped (D-21)
formExtendFields (username) dropped (D-18)
Cancel on update goes to preview FW-D (D-11)
Model rules `email: required between:6,255 email
afterCreate: send_invite → sendInvitation() (mail golem15.user::mail.invite, 72 h link) PORT The Go plugin has no invite template; port views/mail/invite.htm

User Groups (UserGroups.php, usergroups/*.yaml, models/usergroup/*.yaml)

PHP element Status Notes
requiredPermissions: golem15.users.access_groups OK
list: name (searchable), code, created_at, id (invisible) OK / G6
list: users_count (relation: users_count, valueFrom: count, default: 0, sortable: false) PORT (G9) valueFrom and default are unknown keys; use a read-only model column filled by a subquery
recordUrl: .../update/:id, showSetup, showSorting, no checkboxes OK
toolbar: New group OK buttons: [create]
form: name (left), code (right, preset: name), description (textarea, size: tiny) PORT + FW-NEW (G7) preset is an unknown key
form: permissions (editor, mode: checkbox) FW-D (D-16)
config_form.yaml create.title, update.title, preview.title PORT Remove; unknown keys fail boot
rules `name: required between:3,64, code: required regex:/^[a-zA-Z0-9_-]+$/
no delete button in PHP form or list discretion D-06 speaks of deleting a privileged group; the standard form delete button will exist once a form exists

Organisations (Organisations.php, organisations/*.yaml, models/organisation/*.yaml)

PHP element Status Notes
requiredPermissions: golem15.users.access_users OK
list: name, slug (searchable), created_at (datetime), id (invisible) OK / G6
toolbar: New organisation OK
form: name (required), slug (preset: {field: name, type: slug}), description (textarea, small) PORT + FW-NEW (G7)
form: avatar (fileupload image 120x120) PORT models.Organisation needs MorphName() and AttachRelations()
form: members (type: partial → relationRender) PORT type: relation-manager, relation: members, context: update
config_relation.yaml members: `toolbarButtons: add remove, list: $/.../columns.yaml` PORT
rules `name: required max:255, slug: required alpha_dash
beforeValidate: slug from name when empty PORT lagoon.HasBeforeValidate on the model

Plugin registration (Plugin.php)

Permissions, verbatim [VERIFIED: /media/nvme/dev/golem15/fonoteka/plugins/golem15/user/Plugin.php:164-179]: golem15.users.access_users, golem15.users.access_groups, golem15.users.access_settings, golem15.users.impersonate_user, all with tab golem15.user::lang.plugin.tab.

Navigation [VERIFIED: Plugin.php:186-214]: main item user (label golem15.user::lang.users.menu_label, icon icon-user, permissions golem15.users.*, order 555) with side menu users (access_users), usergroups (access_groups), organisations (access_users).

Framework Gaps Beyond CONTEXT.md

These block locked decisions and are in scope under the roadmap rule "summercms.go only if the admin pipeline is missing a feature the screens need". Each recommendation is a design choice [ASSUMED] until the user confirms it.

# Gap Blocks Evidence Recommended answer
G1 No password field type D-19 formFieldTypes list above Add type: password: masked input, never projected into a record response, never a fill key
G2 Hooks cannot see non-column form values (password, password_confirmation, send_invite) D-19 Hook signatures take (ctx, model); BindWritableFields fails boot for a scalar field that is not a column "Form virtual fields": a controller lists field names exempt from column binding; their submitted scalar values (filtered by context) reach hooks through a context accessor next to TxFromContext
G3 organisation picker is read-only D-22 relation_field.go:198, crud.go:836 An explicit opt-in on FieldRelationContract that declares a protected foreign key writable for this field
G4 No locked relation options and no 403 from plugin code D-04, D-06, D-07 RelationOption has two fields; writeCRUDError has no forbidden branch An exported cabana.ForbiddenError mapped to 403 everywhere writeCRUDError/runAction map errors; an optional controller interface returning the related ids the current principal may not change, which the framework marks locked in options and labels and enforces in syncBelongsToMany (a change in the locked subset is 403 and rolls back)
G5 Admin save validates against the model's register rules D-19 mergedRules reads model.Rules(); valuesForRules reads model columns only, so confirmed compares the stored hash with a missing password_confirmation Either an optional controller interface that supplies the rule set per operation, or an admin record type wrapping models.User with its own Rules(); see Open Question 2
G6 No invisible list column PHP parity of search columnDocument keys Add invisible: true: the column is searchable and sortable server-side but not rendered
G7 No preset D-22 formFieldKeys Add preset (string or {field, type}) for text fields: the SPA fills the field from the source while it is untouched, on create only. Keep the server default in BeforeValidate
G8 Built-in delete soft-deletes a DeletedAt model D-13 deleteRecord calls tx.Delete(model) No framework change: FormAfterDelete on the Users controller hard-deletes with Unscoped() inside the same transaction

Gaps solved in the plugin with no framework change:

# Gap Answer
G9 users_count A read-only field on the group model with a users_count column tag, filled by a ListExtendQuery select subquery; sortable: false [ASSUMED: GORM -> read-only fields and Count over a custom select behave as expected; cover with a test]
G10 regex, alpha_dash Keep them out of Rules() and check them in FormBeforeCreate/FormBeforeUpdate, returning *cabana.ValidationError (verified to pass through as 422). lagoon.Validate supports exactly: nullable, required, integer, numeric, between, min, max, in, unique, boolean, email, confirmed, different, mimes [VERIFIED: modules/lagoon/validate.go:57-112]; any other token returns an error that the save turns into a 500
G11 timetense Use datetime
G12 name@context field names One password field with context: [create, update] and one label

Architecture Patterns

System Architecture Diagram

Admin SPA (ListView / FormView / new preview mode)
   |  selected ids            |  record id + action        |  form body (scalars, relation ids,
   v                          v                             v  permission map, virtual fields)
POST .../bulk/{action}    POST .../{id}/actions/{action}   POST|PUT .../{controller}[/{id}]
   |                          |                             |
   +----------- backend guard -> requireAjax -> protect (controller permissions) -----------+
   |                          |                             |
 declared in list YAML?     declared in form YAML?        operationDeclared
 allowAction (own perms)    allowAction (own perms)         |
   |                          |                             v
 lagoon.Transaction         lagoon.Transaction            lagoon.Transaction
 lockScoped(ListExtendQuery) loadRecord(FormExtendQuery)   loadRecord -> Fill -> Validate
   | 0 rows: no-op            | not found: 404              -> FormBefore* (plugin: password hash,
   | partial: 409             | Applies == false: 409          privileged-code check)
   v                          v                             -> relation scope check
 plugin Run(ctx, records)   plugin Run(ctx, record)         -> locked-id guard (403)  <-- T-12-18
   |                          |                             -> Save -> syncBelongsToMany
   +--> users / user_throttle / users_groups (Postgres) <---+ -> permission map store
                                                            -> FormAfter* (plugin: invite mail,
GET .../{controller}  -> ExecuteList -> rows + row state        force delete cleanup)
GET .../{id}          -> record + labels + offered record actions
GET .../schema/list   -> columns, filters, bulk actions (filtered by permission)
GET .../schema/form   -> fields (permission options injected per request), preview flag
summercms.go/
├── modules/pact/capabilities.go        # new action and row-state contracts
├── modules/cabana/
│   ├── actions.go                      # bulk and record action handlers
│   ├── list_schema.go, schema_types.go # bulkActions, invisible, row state
│   ├── form_schema.go, crud.go         # preview, password, virtual fields, preset, permissioneditor
│   ├── field_permission.go             # new: permissioneditor compile, lift, store, project
│   ├── relation_field.go               # locked options, writable protected key
│   ├── http.go, admin_openapi.go       # routes and swag stubs
│   └── testdata/                       # neutral fixture plugin (acme)
├── admin/src/
│   ├── components/form/fields/PermissionEditorField.vue, PasswordField.vue
│   ├── components/list/ (bulk action menu, row state classes)
│   └── views/FormView.vue (preview mode) + app/router.ts + app/winterUrl.ts
└── docs/backend/*.md, modules/cabana/README.md, modules/pact/README.md

sm-user-plugin/ (../fonoteka.go/plugins/golem15/user)
├── plugin.go                           # HasAdminControllers, AdminAssets, HasPermissions, HasNavigation
├── controllers/
│   ├── users_admin_controller.go, usergroups_admin_controller.go, organisations_admin_controller.go
│   ├── users/ (config_list.yaml, config_form.yaml, config_filter.yaml, _hint.htm)
│   ├── usergroups/ (config_list.yaml, config_form.yaml)
│   └── organisations/ (config_list.yaml, config_form.yaml, config_relation.yaml)
├── models/ (user/, usergroup/, organisation/ YAML; frontend_permission.go; users_group.go pivot)
├── classes/ (permissions.go resolver; privileged.go; admin_actions.go)
├── updates/ (three additive migrations)
├── views/mail/invite.htm, invite-en.htm
├── lang/{en,pl}/lang.yaml
└── config/config.yaml (privileged group codes)

Pattern 1: An action that takes ids resolves them through the list scope first

What: Copy BulkDelete's shape: normalize ids, open lagoon.Transaction, lockScoped, refuse a partial match with 409, then call the plugin with loaded records, never with raw ids. When to use: D-09. Example: [VERIFIED: modules/cabana/crud.go:212-242]

err = lagoon.Transaction(ctx, s.DB, func(ctx context.Context, tx *gorm.DB) error {
	ctx = withTx(ctx, tx)
	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{}
	}
	// per-row work
	return nil
})

Note: PHP skips ids it cannot find; cabana's existing rule is "mixed present and absent is a 409 and rolls back" (Phase 9 decision). Keep cabana's rule for the new bulk actions so the behaviour matches bulk delete.

Pattern 2: Plugin hooks read the write's transaction from the context

What: cabana.TxFromContext(ctx) inside FormBefore*/FormAfter*, relation hooks and scopes. When to use: Every hook in the three controllers that touches user_throttle, users_groups or attachments. Example: see collectionsAdminController.FormBeforeCreate in ../fonoteka.go/plugins/golem15/fonoteka/controllers/collections_admin_controller.go:106-138 (uses a requestDB(ctx, c.db) helper and returns &cabana.ValidationError{Details: ...} for a 422).

Pattern 3: Schema entries are filtered per principal at request time

What: The cached schema holds everything; the handler drops what the admin may not run (listSchema filters ToolbarActions, formSchema filters widgets) into a new slice so the cache is never mutated. When to use: Bulk actions in the list schema, record actions on the record response, permission options in the form schema. Example: [VERIFIED: modules/cabana/http.go:714-721]

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

Pattern 4: Model-owned metadata, never guessed

What: Pivot tables, foreign keys and label columns come from controller contracts (FieldRelationContract, RelationContract). When to use: groups (pivot model for users_groups with columns user_id, user_group_id), organisation (belongsTo, ForeignKey: "organisation_id"), members (hasMany, ForeignKey: "organisation_id").

Feature pact / cabana YAML Route
Bulk actions pact.AdminBulkAction{Name, Label, Confirm, Permissions, Run}, pact.HasAdminBulkActions; input carries the loaded records config_list.yaml bulkActions: [activate, deactivate, restore, ban, unban] (requires showCheckboxes: true; built-in delete stays on toolbar.buttons) POST /{vendor}/{plugin}/{controller}/bulk/{action}, body AdminIDsRequest
Record actions pact.AdminRecordAction{Name, Label, Confirm, Permissions, Applies, Run}, pact.HasAdminRecordActions config_form.yaml recordActions: [activate, unban, unsuspend] POST /{vendor}/{plugin}/{controller}/{id}/actions/{action}, body {}
Preview form schema carries a preview flag; record response lists offered actions config_form.yaml preview: block (redirect targets); fields use context: preview SPA route /:vendor/:plugin/:controller/:id(\d+)/preview; no new API read route
Row state pact.ListRowStates hook, batch form (all page rows in one call) none list rows carry the state set
Permission editor controller supplies options {code, label, tab, comment} type: permissioneditor, mode: radio or checkbox options injected into the form schema per request

All action names share the single controller namespace with toolbar and widget actions; create and delete stay reserved. A bulk or record action a YAML file names but the controller does not register is a boot error, as for toolbar.buttons.

Row state must be a batch hook: the Users implementation needs one user_throttle query for the whole page, and a per-row hook would be an N+1. Recommended set: deleted, negative, disabled (the three D-12 names). A row can hold more than one (PHP joins up to three classes). Values outside the set are dropped and logged, never sent.

Anti-Patterns to Avoid

  • Passing ids to plugin code: breaks the 10.1 D-12 property. The plugin receives loaded records.
  • Checking privileged groups only in the SPA: D-04 is a server rule; the locked UI is a courtesy.
  • Adding CreatedAt/UpdatedAt as ordinary fields to models.User: GORM would start stamping updated_at on every user write and the fields could leak into serialization. Add them read-only (->) with json:"-".
  • Copying PHP YAML verbatim: tabs:, secondaryTabs:, cssClass, hidden, disabled, preset, invisible, valueFrom, default (on a column), title under create/update, preview:, a scalar toolbar.buttons, conditions: and add|remove are each a boot error today.
  • A groups relation manager in addition to the groups field: it would add link and unlink write paths to users_groups, and unlink has no hook to guard. Keep one writer: the form field.
  • Naming the application in framework fixtures, READMEs or docs.

Don't Hand-Roll

Problem Don't Build Use Instead Why
Password hashing A new bcrypt call bouncer.HashPassword(cost, password) with golem15.user.password.bcrypt_cost (as Login does) Same cost and rehash rules as the API
Activation A direct is_activated = true update The plugin's existing activation helpers (IssueActivationCode, VerifyActivationCode) or a small shared function next to them PHP attemptActivation also clears the code, stamps activated_at and restores a trashed user
Avatar storage A second attachment path attach.HasRelations on the model plus cabana's file routes Same system_files row as the API
Attachment cleanup on force delete Manual DELETE FROM system_files attach.DeleteForOwner(tx, owner, ownerID, afterCommit) + attach.DeleteKeys (as deleteAvatar does, api_controller.go:1205-1230) Blob deletion must wait for the commit
Id-list scoping A new WHERE id IN lockScoped Applies the list scope and the lock
Permission checks String comparison cabana.Allows(principal, []string{code}) Wildcards and superuser handling
Tabs, radio groups, checkboxes in the SPA Custom components reka-ui primitives already used by the form Accessibility and no new package
TS types Hand-written interfaces scripts/check-admin-openapi.sh The drift gate fails otherwise

Key insight: every piece of the three screens already has a framework home; the work is adding seams, not parallel code paths.

Current State of sm-user-plugin

[VERIFIED: files read under ../fonoteka.go/plugins/golem15/user this session]

  • Module git.golem15.com/golem15/sm-user-plugin, requires git.golem15.com/golem15/summercms v0.0.0 with replace => ../../../../summercms.go. The application's go.mod and every plugin use the same local replace, so "builds on the v0.1.3 tag" means the tag exists on the framework commit the plugin was developed against; no go.mod version changes.
  • The submodule's master is 5 commits ahead of origin/master (unpushed). The fonoteka.go submodule pointer bump at the end of this phase depends on a push.
  • plugin.go implements HasConfig, HasMigrations, HasMiddleware, HasModels, HasRoutes, HasMailTemplates, HasLang, HasCommands, surf.BucketProvider. It embeds config, views/mail, lang. It has no AdminFS, admin controllers, permissions or navigation.
  • models.User: no CreatedAt, UpdatedAt, LastSeen, LastLogin or Permissions field. Groups []UserGroup with many2many:users_groups;joinForeignKey:user_id;joinReferences:user_group_id and json:"-". DeletedAt gorm.DeletedAt. MorphName() returns Golem15\User\Models\User. Rules() is the register rule set (password: required|between:8,255|confirmed).
  • models.UserGroup: Code, Description, Permissions are *string; timestamps are *time.Time. No Fillable(), no Rules().
  • models.Organisation: no Fillable(), Rules(), MorphName(), Members field. cabana requires lagoon.HasFillable on a writable model (newWritableModel).
  • models.Throttle: is_banned, is_suspended, suspended_at; no banned_at or last_attempt_at (the parity allow-list records this).
  • There is no pivot model struct for users_groups; the belongsToMany field contract needs one (columns user_id, user_group_id, composite primary key named user_group).
  • Migrations: 202609170001_create_users, 10_organisations, 202609220005_extend_users, 202609220006_create_user_throttle, 202609220007_create_jwt_blacklist, 202610020001_create_user_groups. users.created_at/updated_at exist as columns; last_seen, permissions, last_login do not.
  • user_throttle.user_id is REFERENCES users(id) without ON DELETE [VERIFIED: updates/202609220006_create_user_throttle.go:15]. The application's tables reference users(id) with ON DELETE CASCADE (or SET NULL for accepted_by) [VERIFIED: grep of ../fonoteka.go/plugins/golem15/fonoteka/updates].
  • Auth path: controllers.Login (api_controller.go:47-124) restores a trashed user and resets the throttle, but writes no last_login or last_seen. controllers.Refresh (175-201) verifies and rotates the token through bouncer.Refresh and never loads the user row; a last_seen write on refresh needs the subject id from the token.
  • Suspension semantics in Go: suspended means is_suspended and suspended_at within golem15.user.throttle.suspension_minutes (throttleBlocked, classes/throttle.go). PHP isSuspended() also lifts an expired suspension as a side effect; the Go Applies check must be a pure read.
  • Mail templates: activate, restore, reactivate (each with -en). No invite.
  • Config namespace golem15.user.* from config/config.yaml: jwt, activation, registration, throttle, password. compass.Config has String, Int, Bool, Has, Lookup, LoadSection; a string list is read with LoadSection or Lookup.

Parity schema allow-list (../fonoteka.go/parity/schema_diff_test.go)

  • users is special-cased: its columns are not diffed; the test only requires that PHP has more columns than Go (phpN <= goN fails). The PHP snapshot's users table already has permissions text and last_seen timestamp(0), so adding both keeps the gap and needs no new entry.
  • user_groups, users_groups, user_throttle are already allow-listed as extra Go tables.
  • golem15_user_frontend_permissions is not in the snapshot (grep "CREATE TABLE" shows only fonoteka tables, golem15_user_organisations, system_files, users), so it needs one new allowedDiffs entry, worded like the existing user_groups one.
  • golem15_user_organisations is a shared table and is column-diffed: do not add columns to it.

T-12-18: Every users_groups Write Path

Threat as recorded [VERIFIED: .planning/phases/12-.../12-01-PLAN.md:343]: "No route writes users_groups; Groups is never serialized; the site-admin predicate reads group codes server-side only". The consumer is HasGroupCode(ctx, db, user.ID, siteAdminGroup) in ../fonoteka.go/plugins/golem15/fonoteka/classes/gates.go:31.

Today no production code writes users_groups [VERIFIED: grep across ../fonoteka.go; only test seeds insert rows]. After this phase:

# Path Writes Guard needed
1 User form groups field (create and update) → syncBelongsToMany Replaces all of a user's pivot rows Locked-id guard: the privileged subset before and after must be equal unless the principal holds the extra permission; else 403, whole save rolled back (D-07). Applies on create too (an unprivileged admin must not create a user already in admin)
2 User force delete (form button, bulk delete) Removes the user's pivot rows None beyond access_users: removing a user cannot grant privilege
3 User Groups form: create or update with a privileged code (either direction) No pivot write, but every member of a renamed group gains or loses the privilege Extra permission (D-06), checked in FormBeforeCreate/FormBeforeUpdate against the stored code and the submitted code
4 User Groups delete of a privileged group Removes pivot rows of that group Extra permission (D-06), in FormBeforeDelete; also delete the pivot rows (no FK cascade on users_groups)
5 Bulk and record actions (D-14) None of the six bulk or three record actions touches groups Keep it that way; a unit test asserts the pivot is unchanged after each action
6 Organisation members relation manager users.organisation_id only Not a groups path
7 Config change to the privileged list Changes which groups are privileged Operational, outside the admin UI
8 Bulk restore / activate of a trashed site admin No pivot write, but re-enables an account whose membership survived the soft delete Accepted: PHP behaves the same; note it in the security review

Privilege check helper: compare group codes (case-sensitive, as HasGroupCode does) against the configured list; a group with a NULL code is never privileged. The privileged list must be read per request, not cached at boot, so an overlay change takes effect on reload.

The extra permission (recommended code golem15.users.manage_privileged_groups, tab golem15.user::lang.plugin.tab) must be registered through pact.HasPermissions or boot fails when an action or hook names it in a permission list validated by Registry.validatePermissions.

Common Pitfalls

Pitfall 1: Admin update of a user fails validation on password

What goes wrong: Every save returns 422 "password confirmation". Why it happens: mergedRules uses User.Rules(); confirmed compares the stored hash with values["password_confirmation"], which is absent because valuesForRules reads model columns only. How to avoid: Resolve G5 before writing the Users controller. Warning signs: A smoke test that updates only name returns 422.

Pitfall 2: An unknown rule token becomes a 500

What goes wrong: regex or alpha_dash in Rules() makes every save of that model answer the opaque 500. Why it happens: lagoon.Validate returns an error for an unrecognized token and save maps it to CapabilityError. How to avoid: Keep Rules() to the supported tokens; check the rest in hooks.

Pitfall 3: Force delete fails on the throttle foreign key

What goes wrong: Hard-deleting a user who ever failed a login returns 500. Why it happens: user_throttle.user_id REFERENCES users(id) has no ON DELETE. How to avoid: Delete user_throttle rows, users_groups rows and attachments in the same transaction before the Unscoped().Delete. Do not edit the shipped migration (frozen); a new additive migration changing the constraint is possible but not needed.

Pitfall 4: Email uniqueness ignores trashed users

What goes wrong: Creating a user with the email of a deactivated user passes validation, then hits the users.email UNIQUE constraint and answers 500. Why it happens: uniqueOK adds deleted_at IS NULL when the table has that column (validate.go:370-372); Laravel's unique:users counts trashed rows. How to avoid: An Unscoped pre-check in FormBeforeCreate/FormBeforeUpdate returning a 422 on email.

Pitfall 5: Stored permission JSON shapes differ between PHP writers

What goes wrong: A strict JSON-object decoder fails on imported rows. Why it happens: PHP setPermissionsAttribute writes '' for an empty user set and drops zero values [VERIFIED: vendor/winter/storm/src/Auth/Models/User.php:560-579]; group permissions are a jsonable attribute whose empty form is not an object [ASSUMED: "[]" or NULL]; values may arrive as strings. How to avoid: A plugin-owned PermissionSet type with a tolerant Scan (NULL, '', [], numeric strings) and a writer that emits an object of integers, with user values limited to -1 and 1 (zero removed) and group values to 1. lagoon.Jsonable[map[string]int] would reject [].

Pitfall 6: The merged-permission resolver is not "deny wins"

What goes wrong: A port that treats -1 in any group as final diverges from PHP. Why it happens: PHP merges groups with "most positive wins", then overlays user values with array_merge (user overrides group) [VERIFIED: models/User.php:257-275]; hasPermission then needs the value to be exactly 1 and supports trailing and leading * wildcards on both sides. How to avoid: Port getMergedPermissions and hasPermission line by line; cabana.granted (contracts.go:153-185) is already a port of the wildcard logic for a boolean map and is a useful reference.

Pitfall 7: A list hook has no transaction

What goes wrong: A row-state hook that calls TxFromContext gets false. Why it happens: ExecuteList runs on the pool, not in a transaction. How to avoid: Pass the list's *gorm.DB handle to the row-state hook explicitly.

Pitfall 8: Stale dist/ or OpenAPI output

What goes wrong: The phase gate fails on drift. Why it happens: dist/ and schema.d.ts are committed build products. How to avoid: Every task that touches admin/src or the swag annotations regenerates and commits them in the same commit.

Pitfall 9: New routes missing from the contract inventory

What goes wrong: TestPhase09ContractInventory, TestPhase09PermissionMatrix or TestPhase10OpenAPIConformance fail. Why it happens: They compare the mounted route table, the permission matrix and the OpenAPI document. How to avoid: Add the route, its swag stub and its matrix row together.

Pitfall 10: permissions and mode collide with existing rules

What goes wrong: A permissioneditor field named permissions is silently unbound, or mode is refused. Why it happens: permissions is a protected fill key for scalar binding and mode is only valid on fileupload and datepicker. How to avoid: Give the new type its own lift, store and project path (like relation fields) and extend the mode rule.

Pitfall 11: last_seen leaking into the user payload

What goes wrong: The Nuxt contract changes. Why it happens: A new exported field on models.User without json:"-", or a payload builder change. How to avoid: json:"-" on the field; the payload is built by apiArray from an explicit map. No parity fixture contains last_seen [VERIFIED: grep of ../fonoteka.go/parity/testdata matches only the schema snapshot], so the parity corpus will catch a leak.

What goes wrong: An invited user cannot activate. Why it happens: PHP builds a signed route URL valid for 72 hours; the Go plugin's activationLink builds {base}/activate?code={id}!{code} and the code TTL is golem15.user.activation.activation_ttl_hours (72). How to avoid: Reuse IssueActivationCode + activationLink for the invite mail; do not port Laravel signed URLs.

Pitfall 13: Submodule and tag ordering

What goes wrong: The application pointer bump references an unpushed plugin commit, or the tag is cut before dist/ is rebuilt. How to avoid: Order: framework commits green → gate → tag v0.1.3 → plugin commits → push plugin → bump the submodule pointer in fonoteka.go.

Code Examples

Registering permissions and navigation in a plugin

// Source: modules/pact/capabilities.go:269-297 (types read this session)
func (p *Plugin) Permissions() []pact.Permission {
	return []pact.Permission{
		{Code: "golem15.users.access_users", Tab: "golem15.user::lang.plugin.tab", Label: "golem15.user::lang.plugin.access_users"},
		{Code: "golem15.users.access_groups", Tab: "golem15.user::lang.plugin.tab", Label: "golem15.user::lang.plugin.access_groups"},
		{Code: "golem15.users.access_settings", Tab: "golem15.user::lang.plugin.tab", Label: "golem15.user::lang.plugin.access_settings"},
		{Code: "golem15.users.impersonate_user", Tab: "golem15.user::lang.plugin.tab", Label: "golem15.user::lang.plugin.impersonate_user"},
	}
}

The four codes, the tab key and the label keys are the PHP values quoted in the inventory above. The fifth (privileged groups) code is Claude's discretion.

A belongsTo and a belongsToMany field contract

// Source: shape from ../fonoteka.go/plugins/golem15/fonoteka/controllers/collections_admin_controller.go:66-74
// and modules/cabana/relation_field.go:22-42
func (usersAdminController) AdminFieldRelations() []cabana.FieldRelationContract {
	return []cabana.FieldRelationContract{
		{Field: "organisation", Kind: "belongsTo", NewRelated: func() any { return &models.Organisation{} }, ForeignKey: "organisation_id", LabelColumn: "name"},
		{Field: "groups", Kind: "belongsToMany", NewRelated: func() any { return &models.UserGroup{} },
			NewPivot: func() any { return &models.UsersGroup{} }, ParentForeignKey: "user_id", RelatedForeignKey: "user_group_id", LabelColumn: "name"},
	}
}

models.UsersGroup does not exist yet [ASSUMED name]; the column names user_id and user_group_id are from updates/202610020001_create_user_groups.go. organisation stays read-only until G3 lands.

A hook that answers 422

// Source: ../fonoteka.go/plugins/golem15/fonoteka/controllers/collections_admin_controller.go:134-136
return &cabana.ValidationError{Details: map[string]any{"owner": []string{"The backend email must identify exactly one active user."}}}

Flattened list YAML in the Go dialect

# Source: ../fonoteka.go/plugins/golem15/fonoteka/controllers/collections/config_list.yaml
list: ~/plugins/golem15/fonoteka/models/collection/columns.yaml
modelClass: Golem15\Fonoteka\Models\Collection
recordUrl: golem15/fonoteka/collections/update/:id
recordsPerPage: 20
showCheckboxes: true
toolbar:
    buttons: [create, delete]
    search:
        prompt: backend::lang.list.search_prompt

State of the Art

Old Approach Current Approach When Changed Impact
Winter toolbar partial with data-request="onBulkAction" Declared bulk actions over a REST route This phase (D-09) REQUIREMENTS "Out of Scope" already says bulk actions become REST endpoints
listInjectRowClass free CSS classes Fixed state set styled by design tokens This phase (D-12) No plugin CSS needed for row state
PHP form widget class name in YAML Built-in type: permissioneditor This phase (D-16)
Toolbar actions without ids (10.1 D-12) Bulk actions with ids resolved through the list scope This phase The "no unscoped lookup" property is kept by construction

Deprecated/outdated: nothing in the stack. v0.1.2 is the latest framework tag (git tag shows v0.1.1, v0.1.2); this phase cuts v0.1.3.

Assumptions Log

# Claim Section Risk if Wrong
A1 The new POST routes (.../bulk/{action}, .../{id}/actions/{action}) do not conflict in surf/ServeMux Routes A different path shape is needed; found at the first router test
A2 Form virtual fields via a controller-declared list and a context accessor is the right seam for G2 Framework Gaps Contract growth the user did not approve; costly to rename later
A3 A writable opt-in on FieldRelationContract is the right answer for G3 Framework Gaps Same
A4 Locked relation ids plus cabana.ForbiddenError is the right answer for G4 Framework Gaps Same; D-07 cannot be met without some equivalent
A5 preset and invisible are wanted in the framework rather than dropped Framework Gaps Larger v0.1.3 than the user expects
A6 A GORM read-only field plus a select subquery gives users_count without breaking Count or Save G9 Needs a different mechanism (a framework computed column)
A7 An embedded wrapper struct works with lagoon.Fill, GORM hooks and modelFields (only relevant if G5 is solved that way) G5 Boot or save failures; fall back to a rules interface
A8 PHP group permissions empty form is [] or NULL Pitfall 5 The tolerant scanner covers both, so low
A9 Winter detaches belongsToMany pivot rows when a user is hard-deleted Force delete cleanup Orphan pivot rows if not cleaned; the recommended cleanup deletes them regardless
A10 No application listener uses PHP's extendListToolbar / extendPreviewToolbar view events Inventory A feature silently disappears at cutover
A11 The recommended interface, YAML key and route names Contract names table Naming only; cheap before release, costly after v0.1.3
A12 Row states limited to deleted, negative, disabled Row state A later plugin needs more; adding a state is additive

Open Questions (RESOLVED)

All six questions were answered by the user at the plan-count checkpoint on 2026-10-04. The answers are locked decisions in 12.1-CONTEXT.md; each question below names the decision that settles it.

  1. Are the extra framework seams (G1 to G7) accepted into v0.1.3?

    • What we know: D-07, D-19 and D-22 cannot be met with the five decided features alone; evidence is quoted above.
    • What's unclear: whether the user wants all of them in this phase or prefers to relax a decision (for example an organisation field that stays read-only on the user form and is managed only from the organisation's members tab).
    • Recommendation: present G1 to G7 at the plan-count checkpoint as one table with "needed for D-xx" and let the user accept or cut each.
    • RESOLVED by D-27: all seven seams (G1 to G7) are accepted into v0.1.3.
  2. How should the admin form's validation rules be supplied (G5)?

    • What we know: User.Rules() is the register contract and cannot change.
    • What's unclear: framework rules interface on the controller versus an admin-only wrapper record type in the plugin.
    • Recommendation: the controller interface. It is a few lines in mergedRules, is useful to every plugin whose API and admin rules differ, and avoids the embedded-struct risks in A7.
    • RESOLVED by D-28: an optional controller interface returns the rule set per operation; no wrapper record type.
  3. Does the User Groups screen get a delete button?

    • What we know: PHP's group form and list have no delete; D-06 names "deleting a privileged group".
    • Recommendation: keep the standard form delete (any cabana form has one), guard it per D-06, and clean the pivot rows.
    • RESOLVED by D-29: User Groups keep the standard form delete, guarded per D-06, with pivot cleanup.
  4. last_seen write policy.

    • What we know: PHP only touches it from the CMS session component, at most once per five minutes; the JWT API never does. Go refresh does not load the user.
    • Recommendation: write on login and on refresh, as one UPDATE users SET last_seen = now() WHERE id = ? AND (last_seen IS NULL OR last_seen < now() - interval '5 minutes'), errors logged and ignored so auth never fails on it.
    • RESOLVED by D-29: written on login and on refresh, at most once per five minutes; a failed write never fails auth.
  5. Admin-created users: activated or not?

    • What we know: in PHP a backend-created user is not activated; with send_invite the mail carries a link, without it the admin activates manually from the preview hint.
    • Recommendation: same in Go; is_activated stays a protected key and only the activate actions set it.
    • RESOLVED by D-29: a user created in the admin starts not activated; only the activate actions set is_activated.
  6. Does bulk activate on an already activated user fail the whole batch?

    • What we know: PHP attemptActivation throws "User is already active!" mid-loop, leaving earlier rows changed.
    • Recommendation: skip already-active users and report the affected count; record it as a deliberate deviation (the admin API has no PHP contract to match).
    • RESOLVED by D-29: bulk activate skips users that are already active and reports the affected count.

Environment Availability

Dependency Required By Available Version Fallback
Go both repos ✓ go1.27.0 —
Node.js admin SPA build and tests ✓ v22.23.2 (engines >=22.6) —
npm admin SPA ✓ 12.0.2 —
Docker testcontainers Postgres in Go tests ✓ client 29.7.2; the plugin's updates test package started its harness in 4.9 s this session —
swag OpenAPI generation ✓ via go run github.com/swaggo/swag/cmd/swag@v1.16.6 v1.16.6 —
PHP reference tree read-only inventory ✓ /media/nvme/dev/golem15/fonoteka/plugins/golem15/user —
graphify knowledge graph optional research context ✗ (disabled) — Direct code reading (done)

Missing dependencies with no fallback: none.

Validation Architecture

Test Framework

Property Value
Framework (Go) standard testing + testcontainers Postgres (cabana and plugin harnesses already exist)
Framework (SPA) vitest 3.2.7 + @vue/test-utils 2.4.11 + happy-dom
Config file admin/vitest.config.ts; Go needs none
Quick run command (framework) go vet ./modules/cabana/ ./modules/pact/ && go test ./modules/cabana/... ./modules/pact/... -count=1
Quick run command (plugin) go -C ../fonoteka.go test ./plugins/golem15/user/... -count=1
Quick run command (SPA) npm --prefix admin run typecheck && npm --prefix admin test
Full suite (framework) go vet ./... && go test ./... -count=1
Full suite (application) go -C ../fonoteka.go vet ./... && go -C ../fonoteka.go test ./... -count=1
Docs go test ./cmd/summer -run TestDocsTree -count=1 and go run ./cmd/summer docs:build --check
Generated artefacts scripts/check-admin-openapi.sh --check and scripts/check-admin-dist.sh

Resolved this session (all from the summercms.go root): go vet ./modules/cabana/ ./modules/pact/ (clean), go test ./modules/cabana/... ./modules/pact/... -run XXX_none (packages compile), go test ./cmd/summer -run TestDocsTree -count=1 (ok), go run ./cmd/summer docs:build --check (no problems found), go -C ../fonoteka.go test ./plugins/golem15/user/... -run XXX_none (five packages resolve; console, controllers, models have no test files today), go -C ../fonoteka.go vet ./plugins/golem15/user/... (clean). Not run this session: the full suites, npm --prefix admin test, the two scripts/check-admin-*.sh gates, and go -C ../fonoteka.go test ./parity -run TestSchemaMatchesPHPSnapshot (the test exists at parity/schema_diff_test.go:70).

Success Criteria → Test Map

The phase has no requirement IDs; success criteria (SC) and decisions are the units.

Unit Behavior Test Type Automated Command File Exists?
D-09 Bulk action: ids outside the list scope never reach Run; partial selection 409; undeclared or unregistered action refused; own permission enforced; one transaction, rollback on error integration (cabana fixture) go test ./modules/cabana/... -run 'TestBulkAction' -count=1 ❌ Wave 0
D-09 A YAML bulkActions name the controller does not register fails boot unit go test ./modules/cabana/... -run 'TestListSchemaBulkActions' -count=1 ❌ Wave 0
D-10 Record action: form scope, 404 for out-of-scope, Applies false refused, own permission, offered list filtered integration go test ./modules/cabana/... -run 'TestRecordAction' -count=1 ❌ Wave 0
D-11 context: preview field never writable; preview redirects map; record response lists actions unit + SPA go test ./modules/cabana/... -run 'TestPreview'; npm --prefix admin test -- winterUrl FormView ❌ Wave 0
D-12 Row state from the fixed set; unknown value dropped; batch hook called once per page integration + SPA go test ./modules/cabana/... -run 'TestRowState'; npm --prefix admin test -- DataTable ❌ Wave 0
D-16 permissioneditor: unknown code 422, value outside mode's set 422, stored JSON shape, projection on show integration + SPA go test ./modules/cabana/... -run 'TestPermissionEditor'; npm --prefix admin test -- PermissionEditorField ❌ Wave 0
contract New routes in the inventory, permission matrix and OpenAPI document contract `go test ./modules/cabana/... -run 'TestPhase09ContractInventory TestPhase09PermissionMatrix
SC-1 Three controllers boot; list, filters, form schemas served; navigation and permissions gate them integration (plugin) `go -C ../fonoteka.go test ./plugins/golem15/user/... -run 'TestAdmin(Users Groups
SC-2 Groups field sync; organisation members link and unlink set and clear organisation_id integration (plugin) `... -run 'TestAdminUserGroupsField TestAdminOrganisationMembers'`
SC-3 / D-13 / D-14 activate, unban, unsuspend, deactivate, restore, ban, force delete with cleanup (throttle, pivot, avatar) integration (plugin) `... -run 'TestAdminUserActions TestAdminUserForceDelete'`
SC-4 / T-12-18 Without the extra permission: changing a privileged membership is 403 and nothing changes; privileged code create, rename and delete refused; with it: allowed; every other path leaves users_groups unchanged integration (plugin) ... -run 'TestAdminPrivilegedGroups' ❌ Wave 0
D-15 Resolver equals PHP getMergedPermissions + hasPermission on a table of cases (group merge, user override, deny, wildcards, tolerant scan) unit `... -run 'TestMergedPermissions TestPermissionSetScan'`
D-17 last_seen written on login and refresh; absent from every user payload integration ... -run 'TestLastSeen' plus the existing parity corpus ❌ Wave 0
D-19 Create with password and confirmation; mismatch 422; reset on update; send_invite sends one mail; password never in a response integration `... -run 'TestAdminUserPassword TestAdminUserInvite'`
D-20 An avatar uploaded through the admin file route appears in apiArray; one uploaded through the API appears in the admin file list integration ... -run 'TestAdminAvatarSharedWithAPI' ❌ Wave 0
D-08 A user with groups marshals without groups regression existing TestPhase12Threats/T-12-18 in the application ✅
schema Go schema versus PHP snapshot with one new allow-list entry integration go -C ../fonoteka.go test ./parity -run TestSchemaMatchesPHPSnapshot -count=1 ✅ (extend)
migrations Three additive migrations up and down integration go -C ../fonoteka.go test ./plugins/golem15/user/updates/... -count=1 ✅ harness, ❌ cases
docs Identifiers, links, snippets checker go test ./cmd/summer -run TestDocsTree -count=1 ✅

Sampling Rate

  • Per task commit: the quick run command of the repository the task wrote to; plus scripts/check-admin-openapi.sh --check and scripts/check-admin-dist.sh when admin/ or swag annotations changed.
  • Per wave merge: both full suites and the docs checks.
  • Phase gate: a scripts/check-phase12.1.sh modelled on scripts/check-phase12.2.sh (stages --go, --security, --spa, --openapi, --dist, --docs, --hygiene, --app, --all), green before /gsd-verify-work.

Wave 0 Gaps

  • A neutral cabana fixture plugin (modules/cabana/testdata/..., acme) with bulk actions, record actions, a preview field, row state and a permission editor.
  • sm-user-plugin admin test harness: boot the plugin with cabana mounted, mint a backend principal with chosen permissions (the application's admin_command_test.go and the fonoteka plugin's admin tests show the pattern).
  • SPA fixtures under admin/tests/fixtures/ for the new schema fields.
  • scripts/check-phase12.1.sh.
  • Framework install: none needed.

Security Domain

security_enforcement is not set in .planning/config.json, so it is enabled.

Applicable ASVS Categories

ASVS Category Applies Standard Control
V2 Authentication yes (admin sets and resets user passwords) bouncer.HashPassword with the configured bcrypt cost; minimum length from golem15.user.password.min_length; the password never appears in a response or a log
V3 Session Management partly A password reset by an admin should stamp tokens_valid_after so existing JWTs die, as the user's own change-password path does [ASSUMED: confirm against ChangePassword]; ban and deactivate rely on the login-time checks
V4 Access Control yes (core of the phase) Backend guard, RequiredPermissions, per-action permissions, the privileged-groups permission, list and form scopes for every id
V5 Input Validation yes Strict JSON decoding (DisallowUnknownFields) for action bodies, normalizeIDs, permission codes validated against the controller's option list, values against the mode's set
V6 Cryptography no new use —
V7 Logging yes logAuth on denials (existing); log bulk destructive actions with admin id, action and count, never record contents

Known Threat Patterns

Pattern STRIDE Standard Mitigation
Privilege escalation by adding a user to admin (T-12-18) Elevation of Privilege Server-side locked-id guard in the groups sync; 403 and rollback; test with and without the permission
Escalation by renaming a group's code to a privileged one Elevation of Privilege D-06 check on create, update (both directions) and delete
IDOR through bulk ids Elevation of Privilege lockScoped before plugin code; partial selection refused
Running a record action on a record outside the form scope Elevation of Privilege loadRecord with FormExtendQuery; one 404 for missing and out-of-scope
CSRF on action routes Tampering requireAjax on every new write route; cookie-only writes without the header already answer 403
Mass assignment of is_activated, password, permissions, organisation_id Tampering Protected fill keys stay protected by default; each exception is an explicit, typed path
Password or hash disclosure Information Disclosure password type never projected; models.User.Password is json:"-"; opaque 500 bodies
Frontend permission code injection Tampering Only codes present in the controller's option list are stored
Mail abuse through bulk invite Denial of Service send_invite is create-only, one mail per created user; no bulk invite action
Destructive bulk delete by mistake Denial of Service Standard confirm (D-13), one transaction, count in the response
Fixture or doc naming the application Information Disclosure (hygiene) The existing hygiene stage refuses application names in the framework tree
Account takeover by re-enabling a trashed site admin Elevation of Privilege Accepted with PHP parity; recorded in the security review (path 8)

The phase touches authorization, the plugin API and destructive bulk operations, so the security-review agent runs (CONTEXT.md, Claude's Discretion). Every mitigated threat should get a removal row in the phase gate, as Phases 12 and 14 did.

Sources

Primary (HIGH confidence, read this session)

  • modules/cabana/: actions.go, contracts.go, schema_types.go, list_schema.go, form_schema.go, filter_schema.go, crud.go, http.go, query.go, registry.go, extension.go, relation.go (parts), relation_field.go, field_file.go (parts), tx_context.go, lang.go, admin_openapi.go (outline)
  • modules/pact/capabilities.go (whole file)
  • modules/lagoon/validate.go, modules/lagoon/attach/relation.go, attach/file.go (parts)
  • admin/package.json, admin/src/app/router.ts, controllerRoutes.ts, winterUrl.ts, components/form/registry.ts, control.ts, views/ListView.vue, components/list/ListToolbar.vue, components/form/fields/RelationField.vue (part), api/types.ts
  • scripts/check-admin-openapi.sh, scripts/check-admin-dist.sh, scripts/check-phase12.2.sh (stage list)
  • ../fonoteka.go/plugins/golem15/user/: plugin.go, go.mod, config/config.yaml, models/*.go, classes/user_groups.go, classes/throttle.go, classes/user_lookup.go, controllers/api_controller.go (login, refresh, avatar), updates/*.go, README.md
  • ../fonoteka.go/parity/schema_diff_test.go, parity/testdata/php_schema_snapshot.sql (table list and users), go.work, go.mod, .gitmodules, plugins/golem15/fonoteka/controllers/collections_admin_controller.go and its YAML
  • PHP reference /media/nvme/dev/golem15/fonoteka/plugins/golem15/user: controllers/Users.php, UserGroups.php, Organisations.php, every YAML and partial under controllers/users, usergroups, organisations, models/user|usergroup|organisation/*.yaml, models/User.php (lines 20-290, 370-750), UserGroup.php, Organisation.php, Throttle.php, FrontendPermission.php, formwidgets/FrontendPermissionEditor.php and its partial, updates/v2.8.0/*, Plugin.php (permissions, navigation), views/mail/invite.htm
  • vendor/winter/storm/src/Auth/Models/Throttle.php, User.php (attemptActivation, hasAccess, hasPermission, setPermissionsAttribute), Auth/Manager.php (findThrottleByUserId)
  • .planning/: 12.1-CONTEXT.md, REQUIREMENTS.md, STATE.md, ROADMAP.md (Phase 12.1 and 12.2 sections), 12-01-PLAN.md (T-12-18), 12-CONTEXT.md (D-25), 10.1-CONTEXT.md (D-12), config.json

Secondary (MEDIUM confidence)

  • None.

Tertiary (LOW confidence)

  • Training knowledge of GORM read-only field and Count behaviour (A6) and of Winter's pivot detach on delete (A9).

Metadata

Confidence breakdown:

  • Standard stack: HIGH - no new dependency; versions read from the module files
  • Current framework shape and PHP inventory: HIGH - read line by line this session
  • Gap list (G1 to G12): HIGH that each gap exists (code quoted); MEDIUM on the recommended answers (design choices)
  • New contract names and route shapes: MEDIUM - within Claude's discretion, unverified against the router
  • Pitfalls: HIGH for 1-4, 7-11, 13 (derived from code); MEDIUM for 5, 6, 12

Research date: 2026-10-04 Valid until: 2026-11-03 for the PHP inventory; 7 days for cabana line references if Phase 14.1 or other work touches modules/cabana first