feat(32-01): form extension placement rules, boot errors and owner-aware branches

- 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
This commit is contained in:
2026-10-26 22:00:51 +01:00
parent 4bb22a85dd
commit e7423bd21f
10 changed files with 1096 additions and 130 deletions

View File

@@ -40,7 +40,19 @@ func (s *service) widgetAction(w http.ResponseWriter, r *http.Request) {
WriteError(w, http.StatusNotFound, "not_found", msgNotFound)
return
}
action, ok := cc.Actions[field.Action]
// D-91: a form extension's widget runs one of the extension's
// Actions, and exists only for an administrator who sees the
// extension (its Permissions and Visible).
actions := cc.Actions
if ext := cc.extensionOf(field.Name); ext != nil {
principal, _ := bouncer.User(r.Context())
if !extensionVisible(r.Context(), principal, ext) {
WriteError(w, http.StatusNotFound, "not_found", msgNotFound)
return
}
actions = ext.actions
}
action, ok := actions[field.Action]
if !ok {
WriteError(w, http.StatusNotFound, "not_found", msgNotFound)
return

View File

@@ -262,6 +262,9 @@ type CompiledController struct {
// extension.
formExtensions []*compiledFormExtension
extensionFields map[string]*compiledFormExtension
// extensionPartials maps the name of every partial a form extension's
// partial field renders to its extension.
extensionPartials map[string]*compiledFormExtension
}
// Registry is the immutable controller map keyed by controller ID.

View File

@@ -538,7 +538,8 @@ func (s CRUDService) ShowRecord(ctx context.Context, cc *CompiledController, id
if err := hydratePluginFields(ctx, tx, cc, s.writer, target, result.Data, "update"); err != nil {
return err
}
if err := hydrateFormExtensions(withTx(ctx, tx), tx, cc, target, result.Data, "update"); err != nil {
visible := visibleExtensions(ctx, cc, principalOf(ctx))
if err := hydrateFormExtensions(withTx(ctx, tx), extensionHydrate{Tx: tx, Tr: s.tr, Writer: s.writer, CC: cc, Target: target, Op: "update", Visible: visible}, result.Data); err != nil {
return err
}
result.Meta.Actions, err = s.offeredRecordActions(withTx(ctx, tx), cc, target)
@@ -756,8 +757,13 @@ func (s CRUDService) save(ctx context.Context, cc *CompiledController, id any, i
op = "update"
}
// D-91: form extension values leave the body first, so no owner path
// (Fill, bindings, translations, attachments) ever reads them.
extensions := liftExtensionValues(cc, in.Body, op)
// (Fill, bindings, translations, attachments) ever reads them; the keys
// of an extension this administrator does not see are dropped.
visible := visibleExtensions(ctx, cc, principalOf(ctx))
extensions, err := liftExtensionValues(ctx, extensionLift{CC: cc, Op: op, Visible: visible, Writer: s.writer, Tr: s.tr, DB: s.DB}, in.Body)
if err != nil {
return RecordResult{}, err
}
// Writable relation keys are lifted before scalar projection (which drops
// every nested value); only keys present in the body are applied.
relations, err := liftRelationValues(cc, in.Body, op)
@@ -938,7 +944,7 @@ func (s CRUDService) save(ctx context.Context, cc *CompiledController, id any, i
}
// D-91: form extensions save their own values in this transaction,
// after the owner row is written; an error rolls everything back.
extNotices, err := saveFormExtensions(ctx, tx, cc, target, extensions, !update)
extNotices, err := saveFormExtensions(ctx, extensionSave{Tx: tx, Tr: s.tr, Writer: s.writer, CC: cc, Target: target, Values: extensions, Visible: visible, Created: !update})
if err != nil {
return err
}
@@ -953,7 +959,7 @@ func (s CRUDService) save(ctx context.Context, cc *CompiledController, id any, i
if err := hydratePluginFields(ctx, tx, cc, s.writer, target, result.Data, op); err != nil {
return err
}
return hydrateFormExtensions(ctx, tx, cc, target, result.Data, op)
return hydrateFormExtensions(ctx, extensionHydrate{Tx: tx, Tr: s.tr, Writer: s.writer, CC: cc, Target: target, Op: op, Visible: visible}, result.Data)
})
if err != nil {
return RecordResult{}, err

View File

@@ -71,7 +71,9 @@ func liftMLValues(ctx context.Context, cc *CompiledController, body map[string]a
}
var names []string
for _, field := range cc.Form.Fields {
if !isMLField(field) || !contextAllows(cc, field.Name, op) {
// A form extension's translatable field is lifted for its own Save
// (liftExtensionValues), never into the owner's translations.
if !isMLField(field) || !contextAllows(cc, field.Name, op) || cc.extensionOf(field.Name) != nil {
continue
}
if _, present := body[field.Name]; present {
@@ -193,7 +195,8 @@ func hydrateMLRecord(ctx context.Context, tx *gorm.DB, cc *CompiledController, w
}
var names []string
for _, field := range cc.Form.Fields {
if !isMLField(field) || !contextAllows(cc, field.Name, op) {
// A form extension serves its own values (hydrateFormExtensions).
if !isMLField(field) || !contextAllows(cc, field.Name, op) || cc.extensionOf(field.Name) != nil {
continue
}
names = append(names, field.Name)

View File

@@ -384,7 +384,8 @@ func liftPluginValues(cc *CompiledController, body map[string]any, op string) ma
}
for _, field := range cc.Form.Fields {
ft := field.fieldType
if ft == nil || ft.def.Translatable || !contextAllows(cc, field.Name, op) {
// A form extension's plugin field is decoded for its own Save.
if ft == nil || ft.def.Translatable || !contextAllows(cc, field.Name, op) || cc.extensionOf(field.Name) != nil {
continue
}
value, present := body[field.Name]
@@ -451,7 +452,9 @@ func decodePluginFields(ctx context.Context, in pluginDecodeInput) (pluginDecode
}
for _, field := range cc.Form.Fields {
ft := field.fieldType
if ft == nil {
// A form extension's plugin field never reaches the host column: its
// value is decoded for the extension's Save (saveFormExtensions).
if ft == nil || cc.extensionOf(field.Name) != nil {
continue
}
if !ft.def.Translatable {
@@ -701,7 +704,8 @@ func hydratePluginFields(ctx context.Context, tx *gorm.DB, cc *CompiledControlle
defaultKnown := false
for _, field := range cc.Form.Fields {
ft := field.fieldType
if ft == nil || ft.def.Load == nil || !contextAllows(cc, field.Name, op) {
// A form extension's plugin field is served by hydrateFormExtensions.
if ft == nil || ft.def.Load == nil || !contextAllows(cc, field.Name, op) || cc.extensionOf(field.Name) != nil {
continue
}
stored, ok := data[field.Name]

File diff suppressed because it is too large Load Diff

View File

@@ -71,7 +71,7 @@ func (p feOwnerPlugin) AdminFS() fs.FS {
return fstest.MapFS{
"controllers/posts/config_form.yaml": &fstest.MapFile{Data: []byte("name: Post\nform: ~/plugins/" + dir + "/models/post/fields.yaml\nmodelClass: Post\n")},
"controllers/posts/config_list.yaml": &fstest.MapFile{Data: []byte("list: ~/plugins/" + dir + "/models/post/columns.yaml\nmodelClass: Post\nrecordsPerPage: 20\n")},
"controllers/posts/_summary.htm": &fstest.MapFile{Data: []byte("<p>{{ .title }}</p>\n")},
"controllers/posts/_summary.htm": &fstest.MapFile{Data: []byte("<p>{{ .Data.title }}</p>\n")},
"models/post/columns.yaml": &fstest.MapFile{Data: []byte("columns:\n a:\n label: A\n")},
"models/post/fields.yaml": &fstest.MapFile{Data: []byte(p.fields)},
"assets/js/owner.js": &fstest.MapFile{Data: []byte("// owner\n")},
@@ -429,8 +429,8 @@ func feExtPartialPlugin(doc string) feContributor {
}
})
ext.fsys = fstest.MapFS{
"partials/_summary.htm": &fstest.MapFile{Data: []byte("<p>{{ .title }}</p>\n")},
"partials/_status.htm": &fstest.MapFile{Data: []byte("<p>{{ .title }}</p>\n")},
"partials/_summary.htm": &fstest.MapFile{Data: []byte("<p>{{ .Data.title }}</p>\n")},
"partials/_status.htm": &fstest.MapFile{Data: []byte("<p>{{ .Data.title }}</p>\n")},
"partials/_leak.htm": &fstest.MapFile{Data: []byte("<p>leak</p>\n")},
}
return ext

View File

@@ -149,6 +149,9 @@ func Activate(app *backpack.App, plugins []party.Plugin) (*Routes, error) {
if err := compileContributions(reg, plugins); err != nil {
return nil, err
}
if err := validateFormExtensionPermissions(reg); err != nil {
return nil, err
}
screens.reg = reg
if err := compileTokenScopes(reg, plugins); err != nil {
return nil, err
@@ -163,6 +166,9 @@ func Activate(app *backpack.App, plugins []party.Plugin) (*Routes, error) {
if err := validateTokenScopeMessageKeys(reg, tr); err != nil {
return nil, err
}
if err := checkFormExtensionTabLabels(reg, tr); err != nil {
return nil, err
}
}
guards, ok := app.Lookup[*bouncer.Registry]()
if !ok || guards == nil {
@@ -813,13 +819,24 @@ func (s *service) formSchema(w http.ResponseWriter, r *http.Request) {
// localized view must never share its backing array with the cache.
principal, _ := bouncer.User(r.Context())
hidden := hiddenSchemaFields(r.Context(), cc)
// D-91: an extension this administrator does not see (its
// Permissions or Visible) contributes no field.
visible := visibleExtensions(r.Context(), cc, principal)
kept := make([]FormField, 0, len(view.Fields))
for _, field := range view.Fields {
if hidden[field.Name] {
continue
}
ext := cc.extensionOf(field.Name)
if ext != nil && !visible[ext] {
continue
}
if field.Type == "widget" {
action, ok := cc.Actions[field.Action]
actions := cc.Actions
if ext != nil {
actions = ext.actions
}
action, ok := actions[field.Action]
if !ok || !Allows(principal, action.Permissions) {
continue
}

View File

@@ -11,6 +11,7 @@ import (
"strconv"
"strings"
"git.golem15.com/golem15/summercms/modules/bouncer"
"git.golem15.com/golem15/summercms/modules/pact"
"git.golem15.com/golem15/summercms/modules/phrasebook"
"git.golem15.com/golem15/summercms/modules/towel"
@@ -500,8 +501,23 @@ func (s *service) partial(w http.ResponseWriter, r *http.Request) {
s.protect(w, r, func(cc *CompiledController) {
name := r.PathValue("name")
compiled, ok := cc.partials[name]
provider, isProvider := cc.Controller.(pact.AdminPartialData)
if !ok || !isProvider || provider == nil {
var data func(ctx context.Context, name string, record any) (any, error)
if provider, isProvider := cc.Controller.(pact.AdminPartialData); isProvider && provider != nil {
data = provider.PartialData
}
formPartial := cc.formPartials[name]
// D-91: a form extension's partial renders from the contributing
// plugin's template through its PartialData, only for an
// administrator who sees the extension.
if ext := cc.extensionPartials[name]; ext != nil {
principal, _ := bouncer.User(r.Context())
if !extensionVisible(r.Context(), principal, ext) {
WriteError(w, http.StatusNotFound, "not_found", msgNotFound)
return
}
compiled, ok, data, formPartial = ext.partials[name], true, ext.def.PartialData, true
}
if !ok || data == nil {
WriteError(w, http.StatusNotFound, "not_found", msgNotFound)
return
}
@@ -509,7 +525,7 @@ func (s *service) partial(w http.ResponseWriter, r *http.Request) {
query := r.URL.Query()
if query.Has("id") {
id, err := strconv.ParseUint(query.Get("id"), 10, 64)
if err != nil || id == 0 || !cc.formPartials[name] {
if err != nil || id == 0 || !formPartial {
WriteError(w, http.StatusNotFound, "not_found", msgNotFound)
return
}
@@ -530,7 +546,7 @@ func (s *service) partial(w http.ResponseWriter, r *http.Request) {
moduleLog.Error("cabana: admin partial failed", "controller", controllerID(cc), "partial", name, "reason", reason, "error", err)
WriteError(w, http.StatusInternalServerError, "error", msgServerError)
}
vm, err := provider.PartialData(ctx, name, record)
vm, err := data(ctx, name, record)
if err != nil {
fail("view model", err)
return

View File

@@ -464,6 +464,12 @@ const (
// boot. Set at most one of them; an extension with neither adds no field and
// may still hide owner fields and run Load and Save.
//
// An extension adds value fields (text, textarea, number, checkbox, switch,
// dropdown with listed options, markdown, mltext, mltextarea, mlmarkdown and
// plugin field types), widget fields and partial fields; types that bind to
// the owner's model (fileupload, relation, repeater and the like) are
// refused, and an extension with value fields needs Save.
//
// Placement: a field goes Before or After its anchor field, or to the end of
// its tab without one; a field without tab: takes its anchor's tab. A new tab
// goes Before or After its anchor tab, or after the last tab without one;
@@ -535,8 +541,12 @@ type AdminFormLoad struct {
// record's save transaction (cabana.TxFromContext reads it too); the owner row
// is already written, so Record carries its primary key, also given as ID.
// Created is true on create. Values are the submitted values of the
// extension's fields whose context allows the operation, keyed by field name;
// a field the request did not send is absent.
// extension's value fields whose context allows the operation, keyed by field
// name; a field the request did not send is absent. A value is as decoded from
// the JSON body (numbers as json.Number), a translatable field's a
// map[string]string of locale to text, a plugin field type's the value its
// Decode returned. Save runs on every create and update of a record whose
// administrator sees the extension, with or without values.
type AdminFormSave struct {
DB *gorm.DB
Controller string