- placement: tabs before/after tab anchors, anchor cursor for plugin
order, own-field anchors sorted with cycle detection, Requires() rule,
anchor tab inheritance, Hide with reference checks, zone refusal (D-101)
- boot errors name plugin, controller and offending name; fileupload is
refused (D-100); localized tab label collisions checked in Activate
- extension widgets dispatch to AdminFormExtension.Actions, partials
render from the contributor's Dir through PartialData, plugin field
types decode for Save and load after Load, translatable values reach
Save as locale maps and never the owner's translations
- Permissions and Visible apply to schema, show, save, widgets and
partials; AdminFormErrors answer 422, Save notices join meta.notices
- pact.AdminActionExtension.Form with AdminFormExtension: a fields
document (fields.yaml format plus before:/after:/zone: and a tabs:
list) given as an AdminFS path or YAML bytes, Load and Save hooks,
AdminFormLoad/Save/SaveResult, AdminFormErrors and zone constants
- cabana compileFormExtensions places extension fields at owner
anchors at boot, regrouping an extended form by tab
- save lifts extension values before any owner path and runs Save in
the record's transaction after the owner row; show merges Load
- TestFormExtensionTracer: c after a on a, b gives a, c, b through the
admin API, stored in the contributor's table
- cabana wraps the bouncer backend guard middleware: a marker that lists the
backend guard with Backend false answers 401 before the guard honours it,
as ResolveBackend already does, so a frontend user id is never read as an
administrator's by the admin API or a backend-guarded plugin route
- found by plan 19.7-05's TestDispatchMarker (admin API in-process case)
- pact.HasAccountContributions compiled at Activate; GET /account/contributions
and POST /account/contributions/{name}/actions/{action} behind interactiveOnly
(AccountContributionView, AccountContributionActionView, AccountActionResultView)
- RecordAction.prompt (RecordActionPrompt) for a pact.AdminRecordAction with a
Prompt; the record action route accepts payload only for such an action
(trimmed JSON string, non-empty, capped at max_length) and hands it to Run as
Value through CRUDService.RecordActionWithValue; a prompt without a label
fails boot
- fireSubjectChanged: pact.MCPSubjectChanged for revoked tokens, Administrators
saves that changed role, permissions or superuser, Roles saves that changed
grants, deactivation and deletion
- phrases backend::lang.form.action_prompt_required/_too_long (en, pl)
- smoke tests TestAccountContributionsSmoke and TestRecordActionPromptSmoke;
TestRecordActionPromptRun on Postgres; route inventories, CSRF walk and
OpenAPI conformance cases extended for the two routes
- regenerated admin/openapi/admin.json and admin/src/api/schema.d.ts; cabana
README, docs/backend/{admin-controllers,users-and-permissions}.md
- create and edit administrators: password (min 4, max 72 bytes, bcrypt
at admin.password.bcrypt_cost), role picker, radio permission overrides,
superuser switch; no mail on create
- superusers hidden from non-superusers in list, lookups and schemas;
is_superuser projected to superusers so an untouched save keeps it
- save-path rules: superuser-only switch, role and permission ceilings,
no outranking, no own role/flag/overrides/password change, last active
superuser not demoted (advisory lock + count in the write tx)
- password set for another admin bumps tokens_valid_after
- role, superuser, activation and last sign-in list filters
- pact.HasAdminActionExtensions / AdminActionExtension: plugins add record
actions to any admin controller by ID, merged at boot before permission
validation (unknown target, no preview, duplicate name stop the boot)
- internal summercms.cabana pseudo-plugin, appended only when some plugin
registers admin controllers: Administrators list and preview, settings
link under System, backend.manage_users and backend.manage_roles with no
role grants
- backend::lang user, user.role and permissions copy (en, pl)
- smoke tests: compile-time seam and settings, Postgres list/preview/action
- principalFrom sets PreferredLocale from metadata admin_locale; personal
API token principals clear it, so token clients keep the request locale
- the guarded admin group runs locale.from-principal after the backend
guard, overriding the LocaleResolver or Accept-Language locale
- GET /lang honours a supported ?locale= and reports meta.locales
(cabana.LangEnvelope, cabana.LangMeta)
- Docker test for the override, refusals and /lang; docs and OpenAPI
- GET and PUT /account/preferences (own row only, interactive sessions,
CSRF header on cookie writes) store admin_theme and admin_locale in
backend_users.metadata, keeping every other key
- GET /auth/me and the My account answers carry preferences
- phrasebook.Translator.Locales lists the locales defining a prefix
- route tables, conformance cases, OpenAPI document, TS types and docs
- the settings schema passes the settings model as the DropdownOptionsProvider
- boot refuses a dropdown method on a settings model without DropdownOptions
- cabana README and docs/backend/settings.md describe it
- type: password on a settings form binds a lagoon.Encrypted column as a
write-only secret (any other column fails boot naming the field): the
GET omits it from data and reports stored, env or empty per field in
meta.secrets; on PUT an absent key or "" keeps the value, null clears
it, a non-string is a 422; the answer never carries a value
- cabana.SecretFallbacks (field to config key) gives the env state;
cabana.SettingsMeta, SecretStored/SecretEnv/SecretEmpty exported
- settings forms accept richeditor: the Put sanitizes through seaglass
like a controller save and returns meta.notices; repeater and
dependsOn stay refused (settingsFormOnly next to controllerFormOnly)
- backend::lang secret.* phrases in en and pl
- admin SPA: PasswordField secret status line (stored with Remove stored
value, env, empty, pending clear with Undo), SettingsFormView tabs with
error-tab activation and the secrets/notices meta, settings payload
that never sends an untouched secret; regenerated OpenAPI and TS
types; rebuilt dist
- vault fixture, TestSettingsSecretCompile, Docker TestSettingsSecretSmoke
and TestSettingsRichEditorSmoke, conform settings fixture with a
password field; settings.md sections, forms.md notes, cabana README
- columns.yaml type: money with currency or currencyFrom (exactly one),
never searchable, not sortable by default; every list row carries the
currencyFrom column's value under its own key; the schema serves
currency and currencyFrom on the column
- pact.AdminRecordAction.Danger, served as danger on cabana.RecordAction
(absent when false); RecordActions.vue passes it to the confirm dialog
- CellValue money kind through formatMoney with the row's code, the em
dash when empty and the amount alone without a code; DataTable aligns
money headers and cells to the right
- PreviewView renders Edit only when a registered value field allows
update (F6)
- main.css .summer-table* and .summer-stat__note kit classes (F7) over
--c-* variables; cabana README kit rows
- fixture record actions void (danger) and resend; TestMoneyListCompile,
TestMoneyListSmoke and TestRecordActionDangerSmoke; regenerated OpenAPI
and TS types; rebuilt dist; lists-and-filters and admin-controllers docs
- type: money on fields.yaml with currency or currencyFrom, scale (2..4,
default 4) and max; boot fails naming the field on two sources, none,
a bad scale or max, a column that is not a string or a Scanner/Valuer,
or a currencyFrom that is not a string column
- a save answers 422 money.invalid, money.too_precise or money.too_large
on the field and fills the accepted decimal string unchanged; the same
check runs on controller, settings and relation child saves
- backend::lang money.* phrases in en and pl
- admin SPA: money.ts (parseMoneyInput, trimMoneyDisplay, formatMoney over
Intl with the string itself), MoneyField.vue with the currency suffix,
blur validation and the read-only box, preview through the control,
the field scope on PreviewView; regenerated OpenAPI and TS types,
rebuilt modules/boardwalk/dist
- acme.payments fixture with TestMoneyFieldCompile and the Docker
TestMoneyFieldSmoke; forms.md Money section and cabana README
- cabana: config_reorder.yaml (title, nameFrom, modelClass, parentColumn,
sortColumn, maxDepth, partition) with fail-loud boot checks and the
built-in reorder toolbar button; list schema carries reorder
- GET/POST {controller}/reorder: scoped set (ListExtendQuery plus the
partition) locked FOR UPDATE, 409 reorder_stale on any set difference,
422 for duplicates, foreign parents, cycles and maxDepth, both columns
written without model hooks
- pact.AdminSingleton: GET {controller}/singleton (first or create, form
scope checked), form schema singleton, list/create/bulk/delete 404,
no config_list.yaml needed
- swag, inventories, token classes, CSRF walk, conformance cases, backend
lang reorder.stale and load_failed, reorder and singleton smoke tests, docs
- The process-global debounce map was keyed by token ID, so a stamp from one test database suppressed a later write in another and turned TestBackendTokenPostgres red depending on order
- Each compositeGuard now owns a stampThrottle; the backend guard and the ResolveBackend guard of one activation share it, so a single-app binary still writes last_used_at at most once a minute per token
- TestTokenLastUsedThrottlePerGuard pins the per-guard isolation
- translatable types lift, store and hydrate through the ML path
(isMLField); Decode and Load run per locale and notices carry the locale
- on update a value equal to the served value (Load of the stored host
value or exact translation) is neither decoded nor written
- FieldWrite, FieldWritesFromContext and WithFieldWrites expose what a save
writes to the Form hooks
- list columns may use a plugin alias and show the type's ListValue
- POST .../{id}/fields/{field}/upload hands the file to the type's Upload
(guard, X-Requested-With, permissions, field context, record scope, body
cap); OpenAPI, route inventories, README and docs updated
- compileFieldTypes builds a per-boot field type registry (alias, core and
cross-plugin collisions, element prefix, assets, option keys) passed down
the compile functions; settings and relation forms refuse plugin aliases
- fields.yaml accepts a plugin alias with the general keys plus the type's
options (typeOptions, element, translatable in the schema); CheckOptions
runs at boot; CompileFormWithTypes for plugin tests
- save decodes plugin values inside the transaction (FormFieldError is a
422, other errors opaque 500) and answers meta.notices (FieldNotice) only
when a type reported something; show and save serve values through Load
- GET /field-types lists the types with versioned asset URLs; OpenAPI,
schema.d.ts, README and docs/backend/field-types.md updated
- enabledContentLocales moves TranslationWriter.DefaultLocale to index 0
and keeps the other enabled codes in the writer's order
- every ML control opens on enabledLocales[0], so the form now shows the
host-column values (content, excerpt, title, slug) instead of an empty
non-default locale
- a DefaultLocale error is logged and keeps the writer's order
- README and forms docs: default locale first
- cabana.BackendUser is an attach.Owner (MorphName Backend\Models\User,
cabana.BackendUserMorphType) with one public attachOne avatar
(cabana.BackendUserAvatarField), as WinterCMS stores it
- POST /account/avatar stores one file_data image (jpg, jpeg, png, gif or
webp, image guard, at most cabana.AvatarMaxBytes = 2 MiB, under a
server-generated name) and replaces the previous avatar: old rows are
deleted in the transaction, their blobs after the commit
- DELETE /account/avatar removes the avatar and is idempotent
- GET /auth/me and the account routes return avatar {url, thumb_url}
(250x250 crop); cabana.BackendUserAvatar lets plugins read it
- swag annotations, regenerated admin.json and schema.d.ts; route
inventories, CSRF walk (29) and OpenAPI conformance learn both routes
- SPA: avatar upload and remove on My account, avatar image in the user
menu trigger and header; backend::lang.myaccount avatar strings
- rebuilt boardwalk dist; cabana README, users-and-permissions and
admin-spa docs
- lagoon: 202610090001_backend_users_metadata adds WinterCMS's nullable
jsonable backend_users.metadata column (TEXT); rollback drops only it
- cabana.BackendUser.Metadata and Subtitle read metadata.subtitle leniently
(NULL, blank, null, [], invalid JSON and non-strings read as empty)
- GET /auth/me returns subtitle; PUT /account edits the signed-in admin's
own names, email and subtitle, and the password with current_password;
any other key is a 422 and nothing is written
- email: required, valid, 6-255 chars, unique against other admins' email
and login ignoring case (soft-deleted included); a unique-index race is 422
- a password change sets tokens_valid_after, revokes the presented token and
gives a cookie session a fresh cookie
- swag annotation, regenerated admin.json and schema.d.ts; route inventories,
CSRF walk (27) and OpenAPI conformance learn the route
- SPA: My account item in the user menu opens /account (AccountView), which
saves through PUT /account and updates the top bar via setCurrentUser
- backend::lang.myaccount strings (en, pl), rebuilt boardwalk dist
- cabana and lagoon READMEs, users-and-permissions and admin-spa docs
- POST {prefix}/api/v1/markdown/preview renders {markdown} through
cabana.RenderMarkdown in the backend-guarded group behind requireAjax
- refused output is a 422 validation_failed on markdown with a fixed message
- swag annotation, regenerated admin.json and schema.d.ts
- route inventories, CSRF walk (26) and OpenAPI conformance learn the route
- README and docs/backend/forms.md document the route
RelationService looks up TranslationWriter like CRUDService so CreateChild can lift locale maps before fillChild, write Polish after the child PK, and return a hydrated title map.
Co-authored-by: Cursor <cursoragent@cursor.com>
MLMarkdownField and relation-child forms share the enabled-locale
inject and adopt merge; OpenAPI, types, docs, and boardwalk dist match.
Co-authored-by: Cursor <cursoragent@cursor.com>
Form schema meta lists enabled locales, Show/save expand mltext maps
without D-11 fallback, and the SPA seeds and adopt-merges every locale.
Co-authored-by: Cursor <cursoragent@cursor.com>
Lift locale maps before ProjectWritableFields so a Journal-shaped save can persist the default host scalar and non-default locales through TranslationWriter without dropping nested JSON.
Co-authored-by: Cursor <cursoragent@cursor.com>
- FieldRelationContract.WritableForeignKey makes a belongsTo field over a
protected foreign key writable; the protected key list is unchanged
- cabana.RelationLockProvider names related ids an administrator may not add
or remove: options and labels carry locked, and a create or update that
changes the locked subset is 403 before any row is written
- columns.yaml invisible keeps a column searchable and out of the rows
- a controller implementing pact.FilterOptions serves a scope filter's
choices before the model
- SPA: locked chips and options in RelationField, DataTable skips invisible
columns
- README, docs, OpenAPI document, TS types and dist updated
- type: permissioneditor with mode radio (1, -1) or checkbox (1); the
controller serves the options per request through
cabana.PermissionEditorProvider and reads and stores the values
- a save answers 422 for a non-object, an unknown code or a value outside the
mode's set and 403 for a changed locked code; stored codes that are not
offered are kept
- record responses carry the stored permissions as an object
- SPA: PermissionEditorField with sections by tab, locked rows and a read-only
mode for the preview
- README, docs, OpenAPI document, TS types and dist updated
- pact.ListRowStates with the fixed RowState set deleted, negative, disabled
- list response meta.row_states keyed by row id; unknown values dropped
- list messages rowStateDeleted, rowStateNegative, rowStateDisabled
- update writes through the scope the load used, so a soft-deleted record
a controller includes stays soft-deleted
- DataTable row state badges and text styles
- roster fixture, smoke tests, OpenAPI, TS types, dist, READMEs, docs
- pact.HasAdminRecordActions with AdminRecordAction (Applies, Run)
- config_form.yaml recordActions, compiled fail-loud
- show response meta.actions lists the permitted actions that apply
- POST .../{controller}/{id}/actions/{action}: record loaded and locked
through the form scope; 404 out of scope, 409 when it does not apply
- RecordActions.vue with confirm and request flow (mounted by plan 02)
- roster fixture, smoke tests, OpenAPI, TS types, READMEs, docs
- pact.HasAdminBulkActions with AdminBulkAction, its input and result
- config_list.yaml bulkActions, compiled fail-loud, needs showCheckboxes
- POST .../{controller}/bulk/{action}: ids resolved and locked through the
list scope in one transaction; partial selection is 409
- list schema offers declared actions per principal, with confirm text
- admin SPA bulk actions menu with confirm, busy state and failure toasts
- acme.roster fixture, tracer test, OpenAPI, TS types, dist, READMEs, docs
Keep form save behind in-flight uploads, make retries idempotent via X-Upload-Id, cap remaining JSON bodies, and surface pending pivot type errors instead of zeroing them.
Co-authored-by: Cursor <cursoragent@cursor.com>
- record id 0 with X-Session-Key manages deferrable relations: create, link, unlink, delete and pivot edits are held in deferred_bindings
- the record's create save applies relation bindings with the file bindings; an ineligible link is a 422 on the relation-manager field
- child forms upload files through .../records/{child}/files/{field} keyed by X-Child-Session-Key; the child save commits them
- boot refuses a deferrable relation with create whose related model no plugin lists in Models()
- loadChild finds a child with one query carrying the parent predicate; a foreign child is 404
- GET/PUT .../records/{child} and POST .../delete (all or nothing) per relation kind
- hasMany link adopts NULL-key rows and unlink clears the key; pending created children are never candidates
- link accepts pivot values for one id through the pivot.form whitelist; GET/PUT .../pivot/{child}
- Link and Unlink share linkRelated/unlinkRelated for the deferred commit
- RelationContract gains Kind (empty is belongsToMany) and ForeignKey, with kind-aware boot checks
- manage.form, view.form and pivot.form compile against the related or pivot model; $/ paths resolve inside the plugin
- view toolbarButtons accept create|update|delete|link|unlink, each the capability of its routes
- POST .../relations/{name}/records creates a child through the manage form; the server sets the hasMany key
- relation schema carries kind, deferrable and the localized forms; 17 new relation message keys in en and pl
- DELETE, PUT and POST reorder under .../{id}/files/{field}, each scoped by one parent query (404 for a foreign file)
- protected download and thumb routes: is_public=false only, nosniff, private no-store, sandbox CSP, inline only for jpeg/png/gif/webp
- the save applies deferred removals, replaces attachOne files and rechecks maxFiles and required
- blobs of deleted files are removed after commit
- swagger2openapi emits binary content for file responses
- admin OpenAPI, TS types, conformance, README and attachments docs
- type: fileupload compiles the D-08 keys and binds to the model's attach.Relation at boot
- X-Session-Key (cabana.SessionKeyHeader) carries the form session key; RecordInput.SessionKey
- GET and POST .../{id}/files/{field}: list with pending uploads, multipart upload into attach.Store
- the create and update save attaches the session's pending files in its transaction
- swagger2openapi folds formData parameters into a multipart requestBody
- admin OpenAPI, TS types, conformance cases, README and forms docs
formSchema now filters type: widget fields by the action's permissions,
the same D-12 filtering listSchema applies to toolbarActions, so an admin
without the action permission no longer gets a button that always
answers 403, and the action name is not revealed. The filtered fields are
a new slice, so the cached schema is never modified.
- fields.yaml type: partial with a bare path name and config_list.yaml
headerPartial resolve to {ConfigDir}/_{name}.htm, parsed at boot; the
controller must implement pact.AdminPartialData
- html/template render against a curated view model, then x/net/html
ParseFragment and a tag, attribute and URL allowlist with 64 KiB, 2000-node
and depth-32 caps; the model type and trusted template types are refused
- GET .../partials/{name} with optional ?id= loaded through the form scope
- golang.org/x/net becomes a direct requirement (D-18), no new module
- boardwalk exports ContentType and SetSecurityHeaders
- pact.AdminClientAssets files are read and hashed at boot and served by exact
key under {prefix}/assets/{vendor}/{plugin}/ with nosniff, CSP, CORP,
no-cache and an ETag; a miss falls through to the SPA
- list and form schemas carry assets URLs with a ?v= hash
- toolbar.buttons resolves create, delete and registered actions after decode;
toolbarActions is permission-filtered per admin
- POST .../toolbar/{action} behind requireAjax and action permissions