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_useris 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_guestcolumn 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_usersis 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 writesusers_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.userconfig 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.Groupsstays 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
pactaction 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 arecordUrlthat 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.phpexactly. The Users list and form include trashed users (withTrashed).deactivateis the soft delete,restorebrings a user back, anddelete(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 existinguser_throttletable.
Fields and columns
- D-15: Frontend permissions are ported in full: the
golem15_user_frontend_permissionstable (code, label, tab, comment), theusers.permissionscolumn, the existinguser_groups.permissionscolumn, the Permissions tab on the user form (radio mode: allow / deny / inherit) and the group form (checkbox mode), and a Go resolver equivalent to PHPUser::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 checkboxmode, and an option list supplied by the controller. It replaces the PHP YAML'sGolem15\User\FormWidgets\FrontendPermissionEditorclass 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_seenis added tousersas 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:
usernameis 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, andcreated_ip_address/last_ip_addresson preview. - D-20: Avatars use the Phase 12.2
fileuploadfield (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_mailand 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 amembersrelation manager that assigns existing users (PHPadd|removeon the hasMany, which sets or clears the user'sorganisation_id). The user form'sorganisationfield is a belongsTorelationpicker. - D-23: Users filters follow PHP
config_filter.yaml: groups (scopefilterByGroup), created date (daterange) and activated (switch). The PHPconditions:strings are re-expressed as model scopes, since aconditionskey is a boot error (P9). - D-24: User Groups list keeps the
users_countcolumn.
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 rebuiltdist/, and is tagged v0.1.3 (v0.1.2already 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, userequireAjaxon 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_seenis 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
Userdelete 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
usernamecolumn and login by username. - A plugin-declared frontend permission registry synced at boot.
- Using
permissioneditorfor 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 usesmake: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 vetandgo 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.mdand the affecteddocs/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 insm-user-plugin(mounted at../fonoteka.go/plugins/golem15/user), planning docs insummercms.go/.planning. - Core plugin contract:
golem15.useris 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 bycompileActions(extension.go:256-278). Reserved names, verbatim[VERIFIED: modules/cabana/extension.go:47]:var builtinToolbarActions = map[string]bool{"create": true, "delete": true}. pact.AdminActionfields, 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_idorvaluesis 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*ValidationErrorto 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.yamlkeys accepted bylistDocument[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.buttonsmust be a YAML list; a scalar such as PHP'slist_toolbaris refused with a message naming the Winter partial (list_schema.go:325-327).columns.yamlper-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 norelation/select) is a boot error.- Rows are projected by
projectRow(http.go:969-995):idplus 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 noTxFromContext.
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.typeis required. The fields file has one top-level key,fields(formFieldsFile,form_schema.go:64-66). modeis refused on any type other than fileupload or datepicker[VERIFIED: field_file.go:74]:"mode is only valid on type: fileupload or datepicker".permissioneditormust be added to that rule.config_form.yamlkeys[VERIFIED: form_schema.go:49-62]:name,form,modelClass,defaultRedirect,create,update,messages;create/updateaccept onlyredirectandredirectClose.contextaccepts any identifier or list of identifiers (compileContext,form_schema.go:555-576), socontext: previewalready compiles.contextAllows(cc, name, op)(crud.go:854-873) is called withcreateorupdate, 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 withfield 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 byProjectWritableFields. - 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.ValidationErrorthrough as 422; any other hook error becomes an opaque 500.
Relation fields and relation managers
type: relationneeds acabana.FieldRelationContractfromAdminFieldRelations(). 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 throughRelationExtendOptionsQuery(checkRelationScope). RelationOptionis{Value uint, Label string}[VERIFIED: relation_field.go:52-55]; there is no disabled or locked flag.relation-managerneedsAdminRelationContracts(); 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'sForeignKeythroughsetModelColumn(relation.go:928-934); unlink needs a nullable key.config_relation.yamllists columns inline underview.list.columnsandmanage.list.columns; each column key must be in the contract'sColumnsmap (relation.go:413-415). A path string forlist:does not decode.- There is a
RelationBeforeLinkhook 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.tsmaps type → component in aMap;valuelesstypes are left out of the save body. A new field type is one component undercomponents/form/fields/plus one registry line. - Routes (
app/router.ts):list,create,record(/:vendor/:plugin/:controller/:id(\d+)), all renderingListVieworFormView.CONTROLLER_ROUTES(new Set(['list', 'create', 'record'])) drives plugin stylesheet activation and must gain any new controller route. app/winterUrl.tsmaps onlycreateandupdate/:id; anything else (including PHP'spreview/:id) falls back to the list.views/ListView.vueholds selection, bulk delete (onDelete) and toolbar actions (onAction);components/list/ListToolbar.vuerenders delete and registered toolbar buttons;components/list/DataTable.vuerenders rows (<tr>at line 232).views/FormView.vuefilters fields withcontextAllows(field, mode)where mode iscreateorupdate.- Types are aliases onto the generated schema (
api/types.ts); nothing is hand-written.
OpenAPI → TS types → dist/ pipeline
- Add or change swag annotations in
modules/cabana/admin_openapi.go(stub functions with// @Routercomments; request/response types are real Go types in package cabana). scripts/check-admin-openapi.shregeneratesadmin/openapi/admin.jsonandadmin/src/api/schema.d.ts;--checkfails on drift.- Add aliases in
admin/src/api/types.ts. npm --prefix admin run buildwritesmodules/boardwalk/dist;scripts/check-admin-dist.shrebuilds into a temp dir and diffs. The rebuiltdist/is committed with its source in the same commit (12.2 rule: "Every task rebuildsmodules/boardwalk/dist... soscripts/check-admin-dist.shstays clean at every commit"[VERIFIED: .planning/phases/12.2-.../12.2-04-PLAN.md:124]).- Tailwind excludes
schema.d.ts,openapi/andadmin/testsfrom its class scan, so type-only changes do not churndist/.
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 | |
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
Recommended Project Structure
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").
Recommended contract names (Claude's discretion; all [ASSUMED] until confirmed)
| 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/UpdatedAtas ordinary fields tomodels.User: GORM would start stampingupdated_aton every user write and the fields could leak into serialization. Add them read-only (->) withjson:"-". - Copying PHP YAML verbatim:
tabs:,secondaryTabs:,cssClass,hidden,disabled,preset,invisible,valueFrom,default(on a column),titleundercreate/update,preview:, a scalartoolbar.buttons,conditions:andadd|removeare 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, requiresgit.golem15.com/golem15/summercms v0.0.0withreplace => ../../../../summercms.go. The application'sgo.modand 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; nogo.modversion changes. - The submodule's
masteris 5 commits ahead oforigin/master(unpushed). The fonoteka.go submodule pointer bump at the end of this phase depends on a push. plugin.goimplementsHasConfig,HasMigrations,HasMiddleware,HasModels,HasRoutes,HasMailTemplates,HasLang,HasCommands,surf.BucketProvider. It embedsconfig,views/mail,lang. It has noAdminFS, admin controllers, permissions or navigation.models.User: noCreatedAt,UpdatedAt,LastSeen,LastLoginorPermissionsfield.Groups []UserGroupwithmany2many:users_groups;joinForeignKey:user_id;joinReferences:user_group_idandjson:"-".DeletedAt gorm.DeletedAt.MorphName()returnsGolem15\User\Models\User.Rules()is the register rule set (password: required|between:8,255|confirmed).models.UserGroup:Code,Description,Permissionsare*string; timestamps are*time.Time. NoFillable(), noRules().models.Organisation: noFillable(),Rules(),MorphName(),Membersfield. cabana requireslagoon.HasFillableon a writable model (newWritableModel).models.Throttle:is_banned,is_suspended,suspended_at; nobanned_atorlast_attempt_at(the parity allow-list records this).- There is no pivot model struct for
users_groups; the belongsToMany field contract needs one (columnsuser_id,user_group_id, composite primary key nameduser_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_atexist as columns;last_seen,permissions,last_logindo not. user_throttle.user_idisREFERENCES users(id)withoutON DELETE[VERIFIED: updates/202609220006_create_user_throttle.go:15]. The application's tables referenceusers(id)withON DELETE CASCADE(orSET NULLforaccepted_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 nolast_loginorlast_seen.controllers.Refresh(175-201) verifies and rotates the token throughbouncer.Refreshand never loads the user row; alast_seenwrite on refresh needs the subject id from the token. - Suspension semantics in Go: suspended means
is_suspendedandsuspended_atwithingolem15.user.throttle.suspension_minutes(throttleBlocked,classes/throttle.go). PHPisSuspended()also lifts an expired suspension as a side effect; the GoAppliescheck must be a pure read. - Mail templates:
activate,restore,reactivate(each with-en). Noinvite. - Config namespace
golem15.user.*fromconfig/config.yaml:jwt,activation,registration,throttle,password.compass.ConfighasString,Int,Bool,Has,Lookup,LoadSection; a string list is read withLoadSectionorLookup.
Parity schema allow-list (../fonoteka.go/parity/schema_diff_test.go)
usersis special-cased: its columns are not diffed; the test only requires that PHP has more columns than Go (phpN <= goNfails). The PHP snapshot'suserstable already haspermissions textandlast_seen timestamp(0), so adding both keeps the gap and needs no new entry.user_groups,users_groups,user_throttleare already allow-listed as extra Go tables.golem15_user_frontend_permissionsis not in the snapshot (grep "CREATE TABLE"shows only fonoteka tables,golem15_user_organisations,system_files,users), so it needs one newallowedDiffsentry, worded like the existinguser_groupsone.golem15_user_organisationsis 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.
Pitfall 12: The invitation link shape
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.
-
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.
-
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.
- What we know:
-
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.
-
last_seenwrite 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.
-
Admin-created users: activated or not?
- What we know: in PHP a backend-created user is not activated; with
send_invitethe mail carries a link, without it the admin activates manually from the preview hint. - Recommendation: same in Go;
is_activatedstays a protected key and only theactivateactions set it. - RESOLVED by D-29: a user created in the admin starts not activated; only the
activateactions setis_activated.
- What we know: in PHP a backend-created user is not activated; with
-
Does bulk
activateon an already activated user fail the whole batch?- What we know: PHP
attemptActivationthrows "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
activateskips users that are already active and reports the affected count.
- What we know: PHP
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 --checkandscripts/check-admin-dist.shwhenadmin/or swag annotations changed. - Per wave merge: both full suites and the docs checks.
- Phase gate: a
scripts/check-phase12.1.shmodelled onscripts/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-pluginadmin test harness: boot the plugin with cabana mounted, mint a backend principal with chosen permissions (the application'sadmin_command_test.goand 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.tsscripts/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 andusers),go.work,go.mod,.gitmodules,plugins/golem15/fonoteka/controllers/collections_admin_controller.goand its YAML- PHP reference
/media/nvme/dev/golem15/fonoteka/plugins/golem15/user:controllers/Users.php,UserGroups.php,Organisations.php, every YAML and partial undercontrollers/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.phpand 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
Countbehaviour (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