feat(32-02): record events and content descriptors (tracer)
- pact.RecordSaved/RecordDeleted/RecordChange, fired as pointers after commit - pact.ContentDescriptor and request/result types; MenuItemType.Content - AdminFormSave.Changes carries the owner's changes to extension Save - cabana.EmitRecordSaved/EmitRecordDeleted; admin save snapshots columns and translated values and fires RecordSaved through lagoon.AfterCommit - signpost validates Content and gains ContentTypes, ContentTypeForModel, Describe
This commit is contained in:
@@ -61,6 +61,7 @@ Use cases:
|
||||
- Danger record actions: `pact.AdminRecordAction.Danger` is served as `danger` on the record response's `cabana.RecordAction` (absent when false, so existing payloads are unchanged); the admin's confirm dialog then uses its danger variant.
|
||||
- Prompted record actions: a `pact.AdminRecordAction` with a `Prompt` (`pact.AdminActionPrompt`) asks the administrator for one text value in its confirm dialog. The record response serves it as `prompt` on the action's `cabana.RecordAction` (absent for every other action, so existing payloads are unchanged), and the action route accepts the body key `payload`, a JSON string, only for such an action: trimmed, not empty and at most `max_length` runes (422 on `payload` with `backend::lang.form.action_prompt_required` or `action_prompt_too_long`, `backend::lang.form.action_prompt_invalid` for another JSON type), then handed to `Run` as `pact.AdminRecordActionInput.Value`. Every other record action keeps refusing `record_id`, `values` and `payload`. A prompt without a label fails boot, owned or contributed.
|
||||
- My account contributions: a plugin implementing `pact.HasAccountContributions` adds sections (`pact.AccountContribution`) to the administrator's own account page, each with a status line and buttons (`pact.AccountAction`). GET `/account/contributions` lists them for the signed-in administrator and POST `/account/contributions/{name}/actions/{action}` runs one for that administrator only, behind the interactive-only guard, the action's permissions and its `Applies` rule, inside a transaction; the answer may carry a URL the admin opens in a new tab. That URL is kept only when it is an absolute `http`/`https` URL with a host or a same-origin path (one leading `/`); anything else, `javascript:` and `data:` included, is dropped and logged without the URL. A section whose `Status` fails is listed with an empty status and `error: true`, and an action whose `Applies` fails is left out and marks its section `error: true` as well; both are logged and the other sections are answered as usual. A status is translated only when it is a phrase key. Names are generic: cabana never names the contributing plugin. The existing `/account*` and `/auth/me` payloads are unchanged. A non-identifier or duplicate section name, an empty label, a duplicate or non-identifier action name, a nil `Run` or an empty action label stops the boot.
|
||||
- Record events: every admin save fires `pact.RecordSaved` on the application's event bus after the commit, with the controller ID, the record's morph type, its id, the request locale, `Created` and the changed attributes with old and new values (column changes, then translated values per locale, read before the save writes them). A rolled-back save fires nothing and a listener's error is logged, never returned. The same changes reach a form extension's `Save` in `pact.AdminFormSave.Changes`. Plugin code that writes records directly fires the same events with `cabana.EmitRecordSaved` and `cabana.EmitRecordDeleted`.
|
||||
- Backend subject events: a save on the Administrators screen that changed an administrator's role, permission overrides or superuser flag, a Roles save that changed a role's grants (one event per administrator holding it), a deactivation or deletion (record and bulk forms) and a revoked personal API token fire `pact.MCPSubjectChanged` on the application's event bus with guard `backend`, so a module acting on behalf of an administrator elsewhere (the MCP endpoint) re-lists what they may do or ends their sessions. A revoked token's event carries its `TokenID`, so only the sessions opened with that token end; the administrator's other sessions stay open. Only the My account revoke fires it: `admin:token:revoke` runs in its own process, where no MCP endpoint listens, so that token's sessions end when the token is next verified (the session's next request or its stream's periodic bearer re-check). A listener's error is logged and never fails the save.
|
||||
- Singleton settings screens declared with `pact.HasSettings`, read and saved by `cabana.SettingsService`. A `type: password` field on a settings form is a write-only secret on a `lagoon.Encrypted` column (any other column type fails boot naming the field): the GET omits it from `data` and reports `stored`, `env` or `empty` per field in `meta.secrets` (`cabana.SettingsMeta`; `env` when the column is empty and the model's `cabana.SecretFallbacks` config key resolves to a non-empty value); on PUT an absent key or an empty string keeps the stored value, JSON null clears it, a non-string is a 422 on the field, and the answer carries the new states, never a value. A `type: richeditor` on a settings form is sanitized on save exactly like a controller save, with the removals in `meta.notices`; `repeater` and `dependsOn` stay refused. Settings fields keep their `tab`. A `type: dropdown` whose `options` names a method takes its choices from the settings model, which must implement `pact.DropdownOptionsProvider` (boot fails naming the field otherwise); the settings schema serves them localized. A settings entry whose `Controller` field is set links to that admin controller instead: start-up fails when it also declares a model, form or model factory, or names an unknown controller; `GET /settings` lists it, with its `controller`, only to administrators who pass its permissions and may open the controller; and the singleton settings endpoints answer 404 for its code.
|
||||
- Backend navigation (`pact.HasNavigation`) and permissions (`pact.HasPermissions`), filtered per user by `cabana.Registry.Metadata`. An entry whose `pact.NavigationItem.Visible` func answers false for the request is left out like a denied one (a hidden main item with its side menu), and a main item whose controller is a side item hidden this way links to its first visible openable side item. An administrator's own `backend_users.permissions` are merged over the role's as in Winter (a `-1` denies a code the role grants). `cabana.Allows` implements the permission check with Winter's `hasAnyAccess` semantics for JWT sessions: superusers pass, a principal needs any one of the listed codes, and wildcards match on both sides (a grant ending in `.*` covers every code with that prefix, and a required code ending in `.*` is met by any grant under it). When the principal carries a non-full `bouncer.ScopeLimit` (a personal API token), the scope is checked first: one method-allowed scope must match every required code and the owner must hold a required code inside that scope; a superuser with a scoped token is limited to the scope, and an empty requirement list is refused unless the scope is full.
|
||||
@@ -313,6 +314,8 @@ func (p *Plugin) AdminFS() fs.FS { return adminFS }
|
||||
| `cabana.BackendUser.MorphName` / `cabana.BackendUser.AttachRelations` | `attach.Owner` and `attach.HasRelations` of the backend user: one public attachOne `avatar`. |
|
||||
| `cabana.Allows` | Checks a principal against required permission codes. |
|
||||
| `cabana.TxFromContext` | The transaction a write route is running in, from the context of a lifecycle hook or scope. |
|
||||
| `cabana.EmitRecordSaved` | Fires a `pact.RecordSaved` on the application's bus after the given lagoon transaction commits (at once with a nil transaction), for plugin code that writes records outside the admin forms; a listener's error is logged. |
|
||||
| `cabana.EmitRecordDeleted` | The same for `pact.RecordDeleted`. |
|
||||
| `cabana.VirtualFieldsFromContext` | The submitted values of the form's virtual fields (`pact.FormVirtualFields`), from the context of a Form hook during a create or update; a copy, keyed by field name. |
|
||||
| `cabana.FieldWrite` | One value a save wrote for a plugin field type: the content `Locale` (translatable types), `Default` (written to the host column) and the decoded `Value`. |
|
||||
| `cabana.FieldWritesFromContext` | The values a create or update save writes for the form's plugin field types, per field and locale, from the context of a Form hook; untouched values are not listed. A copy; false outside a save. |
|
||||
|
||||
@@ -11,6 +11,7 @@ import (
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"git.golem15.com/golem15/summercms/modules/backpack"
|
||||
"git.golem15.com/golem15/summercms/modules/bouncer"
|
||||
"git.golem15.com/golem15/summercms/modules/lagoon"
|
||||
"git.golem15.com/golem15/summercms/modules/pact"
|
||||
@@ -32,6 +33,9 @@ type CRUDService struct {
|
||||
// writer persists non-default locale values for mltext/mltextarea/mlmarkdown
|
||||
// fields. Nil when the translate plugin is not mounted.
|
||||
writer TranslationWriter
|
||||
// app receives the record events of the service's writes
|
||||
// (pact.RecordSaved, pact.RecordDeleted). Nil fires none.
|
||||
app *backpack.App
|
||||
}
|
||||
|
||||
// RecordInput is a decoded JSON object. Keys are untrusted. SessionKey is
|
||||
@@ -825,6 +829,9 @@ func (s CRUDService) save(ctx context.Context, cc *CompiledController, id any, i
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// D-92: the record's values before the save, for the changes its
|
||||
// RecordSaved event and the extensions' Save report.
|
||||
var before *recordSnapshot
|
||||
if update {
|
||||
pk, err := coercePK(target, id)
|
||||
if err != nil {
|
||||
@@ -833,6 +840,9 @@ func (s CRUDService) save(ctx context.Context, cc *CompiledController, id any, i
|
||||
if err := loadRecord(ctx, tx, cc, target, pk); err != nil {
|
||||
return err
|
||||
}
|
||||
if before, err = takeRecordSnapshot(ctx, tx, s.writer, target, translations); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
projected := projectOperation(cc, in.Body, op)
|
||||
decoded, err := decodePluginFields(ctx, pluginDecodeInput{Tx: tx, Tr: s.tr, Writer: s.writer, CC: cc, Target: target, Values: pluginValues, Projected: projected, Translations: translations, Update: update})
|
||||
@@ -942,13 +952,19 @@ func (s CRUDService) save(ctx context.Context, cc *CompiledController, id any, i
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
changes, err := recordChanges(ctx, tx, s.writer, target, before, translations)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// 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, extensionSave{Tx: tx, Tr: s.tr, Writer: s.writer, CC: cc, Target: target, Values: extensions, Visible: visible, Created: !update})
|
||||
extNotices, err := saveFormExtensions(ctx, extensionSave{Tx: tx, Tr: s.tr, Writer: s.writer, CC: cc, Target: target, Values: extensions, Visible: visible, Created: !update, Changes: changes})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
notices = append(notices, extNotices...)
|
||||
// D-92: subscribers hear about the save once it commits.
|
||||
emitRecordSaved(ctx, s.app, tx, cc, target, !update, changes)
|
||||
result, err = projectFullRecord(ctx, tx, cc, target)
|
||||
if err != nil {
|
||||
return err
|
||||
|
||||
@@ -1250,6 +1250,7 @@ type extensionSave struct {
|
||||
Values extensionValues
|
||||
Visible map[*compiledFormExtension]bool
|
||||
Created bool
|
||||
Changes []pact.RecordChange
|
||||
}
|
||||
|
||||
// saveFormExtensions runs every visible extension's Save inside the
|
||||
@@ -1318,6 +1319,7 @@ func saveFormExtensions(ctx context.Context, in extensionSave) ([]pluginNotice,
|
||||
ID: recordIDText(in.Target),
|
||||
Created: in.Created,
|
||||
Values: values,
|
||||
Changes: append([]pact.RecordChange(nil), in.Changes...),
|
||||
})
|
||||
if err != nil {
|
||||
return nil, extensionFailure(cc, ext, "save", err)
|
||||
|
||||
@@ -1127,7 +1127,7 @@ func (s *service) crud() (CRUDService, error) {
|
||||
if err != nil {
|
||||
return CRUDService{}, err
|
||||
}
|
||||
svc := CRUDService{DB: db, bucket: s.bucket(), tr: s.translator()}
|
||||
svc := CRUDService{DB: db, bucket: s.bucket(), tr: s.translator(), app: s.app}
|
||||
if s.app != nil {
|
||||
if w, ok := s.app.Lookup[TranslationWriter](); ok {
|
||||
svc.writer = w
|
||||
|
||||
270
modules/cabana/record_events.go
Normal file
270
modules/cabana/record_events.go
Normal file
@@ -0,0 +1,270 @@
|
||||
package cabana
|
||||
|
||||
import (
|
||||
"context"
|
||||
"reflect"
|
||||
"time"
|
||||
|
||||
"git.golem15.com/golem15/summercms/modules/backpack"
|
||||
"git.golem15.com/golem15/summercms/modules/lagoon"
|
||||
"git.golem15.com/golem15/summercms/modules/pact"
|
||||
"git.golem15.com/golem15/summercms/modules/towel"
|
||||
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/schema"
|
||||
)
|
||||
|
||||
// EmitRecordSaved fires ev as a *pact.RecordSaved through the application's
|
||||
// event bus once tx commits, for plugin code that writes records outside the
|
||||
// admin forms (its own API handlers, console imports, record actions). tx is
|
||||
// the write's lagoon.Transaction handle: the event is buffered until that
|
||||
// transaction commits and dropped when it rolls back. With a nil tx, or
|
||||
// outside a transaction, it fires at once, so call it after the write has
|
||||
// committed. A missing application or bus is a no-op and a listener's error
|
||||
// is logged: the write that caused the event never fails because of it.
|
||||
func EmitRecordSaved(ctx context.Context, app *backpack.App, tx *gorm.DB, ev pact.RecordSaved) {
|
||||
if app == nil || app.Events == nil {
|
||||
return
|
||||
}
|
||||
if ctx == nil {
|
||||
ctx = context.Background()
|
||||
}
|
||||
ev.Changes = append([]pact.RecordChange(nil), ev.Changes...)
|
||||
lagoon.AfterCommit(ctx, tx, func(ctx context.Context, _ *gorm.DB) {
|
||||
event := ev
|
||||
if err := app.Events.Fire(ctx, &event); err != nil {
|
||||
moduleLog.WarnContext(ctx, "cabana: record saved listener failed", "controller", ev.Controller, "model", ev.Model, "id", ev.ID, "error", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// EmitRecordDeleted fires ev as a *pact.RecordDeleted after tx commits, with
|
||||
// the rules of EmitRecordSaved.
|
||||
func EmitRecordDeleted(ctx context.Context, app *backpack.App, tx *gorm.DB, ev pact.RecordDeleted) {
|
||||
if app == nil || app.Events == nil {
|
||||
return
|
||||
}
|
||||
if ctx == nil {
|
||||
ctx = context.Background()
|
||||
}
|
||||
lagoon.AfterCommit(ctx, tx, func(ctx context.Context, _ *gorm.DB) {
|
||||
event := ev
|
||||
if err := app.Events.Fire(ctx, &event); err != nil {
|
||||
moduleLog.WarnContext(ctx, "cabana: record deleted listener failed", "controller", ev.Controller, "model", ev.Model, "id", ev.ID, "error", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// emitRecordSaved fires the admin save of model through controller cc after
|
||||
// tx commits.
|
||||
func emitRecordSaved(ctx context.Context, app *backpack.App, tx *gorm.DB, cc *CompiledController, model any, created bool, changes []pact.RecordChange) {
|
||||
if app == nil || app.Events == nil {
|
||||
return
|
||||
}
|
||||
locale, _ := towel.Locale(ctx)
|
||||
EmitRecordSaved(ctx, app, tx, pact.RecordSaved{
|
||||
Controller: controllerID(cc),
|
||||
Model: recordMorphType(tx, model),
|
||||
ID: recordIDText(model),
|
||||
Locale: locale,
|
||||
Created: created,
|
||||
Changes: changes,
|
||||
})
|
||||
}
|
||||
|
||||
// emitRecordDeleted fires the admin delete of model through controller cc
|
||||
// after tx commits.
|
||||
func emitRecordDeleted(ctx context.Context, app *backpack.App, tx *gorm.DB, cc *CompiledController, model any) {
|
||||
if app == nil || app.Events == nil {
|
||||
return
|
||||
}
|
||||
EmitRecordDeleted(ctx, app, tx, pact.RecordDeleted{
|
||||
Controller: controllerID(cc),
|
||||
Model: recordMorphType(tx, model),
|
||||
ID: recordIDText(model),
|
||||
})
|
||||
}
|
||||
|
||||
// recordMorphType is lagoon.MorphType, logged and "" when it fails.
|
||||
func recordMorphType(db *gorm.DB, model any) string {
|
||||
morph, err := lagoon.MorphType(db, model)
|
||||
if err != nil {
|
||||
moduleLog.Warn("cabana: record event morph type", "error", err)
|
||||
return ""
|
||||
}
|
||||
return morph
|
||||
}
|
||||
|
||||
// recordSnapshot holds a record's column values and, for the translatable
|
||||
// fields a save submits, their stored non-default locale values, taken
|
||||
// before the save changes them, so the save can report what it changed.
|
||||
type recordSnapshot struct {
|
||||
columns []string
|
||||
values map[string]any
|
||||
translations map[string]map[string]any // field -> locale -> old value (nil when missing)
|
||||
}
|
||||
|
||||
// snapshotColumns lists the columns a change is reported for: every column
|
||||
// except the primary key and the timestamps GORM maintains itself.
|
||||
func snapshotColumns(db *gorm.DB, model any) ([]*schema.Field, error) {
|
||||
stmt := &gorm.Statement{DB: db}
|
||||
if err := stmt.Parse(model); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := []*schema.Field{}
|
||||
for _, name := range stmt.Schema.DBNames {
|
||||
field := stmt.Schema.FieldsByDBName[name]
|
||||
if field == nil || field.PrimaryKey || field.AutoCreateTime != 0 || field.AutoUpdateTime != 0 || !field.Readable {
|
||||
continue
|
||||
}
|
||||
out = append(out, field)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// takeRecordSnapshot reads model's column values and, through writer, the
|
||||
// stored values of the submitted translatable fields in their non-default
|
||||
// locales. A model that GORM cannot parse snapshots nothing.
|
||||
func takeRecordSnapshot(ctx context.Context, tx *gorm.DB, writer TranslationWriter, model any, translations []mlValue) (*recordSnapshot, error) {
|
||||
snap := &recordSnapshot{values: map[string]any{}, translations: map[string]map[string]any{}}
|
||||
fields, err := snapshotColumns(tx, model)
|
||||
if err != nil {
|
||||
moduleLog.Warn("cabana: record event snapshot", "error", err)
|
||||
return snap, nil
|
||||
}
|
||||
rv := reflect.ValueOf(model)
|
||||
for _, field := range fields {
|
||||
value, _ := field.ValueOf(ctx, rv)
|
||||
snap.columns = append(snap.columns, field.DBName)
|
||||
snap.values[field.DBName] = changeValue(value)
|
||||
}
|
||||
if writer == nil || len(translations) == 0 {
|
||||
return snap, nil
|
||||
}
|
||||
def, err := writer.DefaultLocale(ctx, tx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, item := range translations {
|
||||
for locale := range item.values {
|
||||
if locale == def {
|
||||
continue
|
||||
}
|
||||
text, ok, err := writer.TranslatedExact(ctx, tx, model, item.field, locale)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if snap.translations[item.field] == nil {
|
||||
snap.translations[item.field] = map[string]any{}
|
||||
}
|
||||
if ok {
|
||||
snap.translations[item.field][locale] = text
|
||||
} else {
|
||||
snap.translations[item.field][locale] = nil
|
||||
}
|
||||
}
|
||||
}
|
||||
return snap, nil
|
||||
}
|
||||
|
||||
// recordChanges compares model after the save with the snapshot taken
|
||||
// before it: one change per column whose value differs, then one per
|
||||
// submitted translatable field and non-default locale whose text differs.
|
||||
// On create (snap nil) it lists every column the save wrote a value to and
|
||||
// every submitted non-empty translation, each with a nil Old.
|
||||
func recordChanges(ctx context.Context, tx *gorm.DB, writer TranslationWriter, model any, snap *recordSnapshot, translations []mlValue) ([]pact.RecordChange, error) {
|
||||
changes := []pact.RecordChange{}
|
||||
fields, err := snapshotColumns(tx, model)
|
||||
if err != nil {
|
||||
moduleLog.Warn("cabana: record event changes", "error", err)
|
||||
return changes, nil
|
||||
}
|
||||
rv := reflect.ValueOf(model)
|
||||
for _, field := range fields {
|
||||
value, zero := field.ValueOf(ctx, rv)
|
||||
next := changeValue(value)
|
||||
if snap == nil {
|
||||
if zero || next == nil {
|
||||
continue
|
||||
}
|
||||
changes = append(changes, pact.RecordChange{Attribute: field.DBName, New: next})
|
||||
continue
|
||||
}
|
||||
prev, known := snap.values[field.DBName]
|
||||
if known && sameChangeValue(prev, next) {
|
||||
continue
|
||||
}
|
||||
changes = append(changes, pact.RecordChange{Attribute: field.DBName, Old: prev, New: next})
|
||||
}
|
||||
if writer == nil || len(translations) == 0 {
|
||||
return changes, nil
|
||||
}
|
||||
def, err := writer.DefaultLocale(ctx, tx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, item := range translations {
|
||||
for _, locale := range sortedLocales(item.values) {
|
||||
if locale == def {
|
||||
continue
|
||||
}
|
||||
next := item.values[locale]
|
||||
var prev any
|
||||
if snap != nil {
|
||||
prev = snap.translations[item.field][locale]
|
||||
}
|
||||
old, _ := prev.(string)
|
||||
if old == next {
|
||||
continue
|
||||
}
|
||||
changes = append(changes, pact.RecordChange{Attribute: item.field, Locale: locale, Old: prev, New: next})
|
||||
}
|
||||
}
|
||||
return changes, nil
|
||||
}
|
||||
|
||||
// changeValue is a column value as a change reports it: pointers are
|
||||
// dereferenced (a nil pointer is nil) and slices and maps are copied, so a
|
||||
// later write to the model does not reach the reported value.
|
||||
func changeValue(value any) any {
|
||||
rv := reflect.ValueOf(value)
|
||||
for rv.IsValid() && rv.Kind() == reflect.Pointer {
|
||||
if rv.IsNil() {
|
||||
return nil
|
||||
}
|
||||
rv = rv.Elem()
|
||||
}
|
||||
if !rv.IsValid() {
|
||||
return nil
|
||||
}
|
||||
switch rv.Kind() {
|
||||
case reflect.Slice:
|
||||
if rv.IsNil() {
|
||||
return nil
|
||||
}
|
||||
out := reflect.MakeSlice(rv.Type(), rv.Len(), rv.Len())
|
||||
reflect.Copy(out, rv)
|
||||
return out.Interface()
|
||||
case reflect.Map:
|
||||
if rv.IsNil() {
|
||||
return nil
|
||||
}
|
||||
out := reflect.MakeMapWithSize(rv.Type(), rv.Len())
|
||||
iter := rv.MapRange()
|
||||
for iter.Next() {
|
||||
out.SetMapIndex(iter.Key(), iter.Value())
|
||||
}
|
||||
return out.Interface()
|
||||
}
|
||||
return rv.Interface()
|
||||
}
|
||||
|
||||
// sameChangeValue compares two change values; times compare as instants.
|
||||
func sameChangeValue(a, b any) bool {
|
||||
ta, okA := a.(time.Time)
|
||||
tb, okB := b.(time.Time)
|
||||
if okA && okB {
|
||||
return ta.Equal(tb)
|
||||
}
|
||||
return reflect.DeepEqual(a, b)
|
||||
}
|
||||
Reference in New Issue
Block a user