diff --git a/CHANGELOG.md b/CHANGELOG.md index 9eb95298..ccbb70d3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -58,6 +58,15 @@ * `AppConfig` validation errors no longer render twice — removed a redundant `valueType` validator that re-ran the `value` check and emitted a duplicate generic message. +### ⚙️ Technical + +* Added `ConfigSpec`, `PreferenceSpec`, and `RoleSpec` typed classes for use with + `ensureRequiredConfigsCreated()`, `ensureRequiredPrefsCreated()`, and + `ensureRequiredRolesCreated()`, replacing untyped `Map` arguments with classes that provide IDE + autocomplete and compile-time validation. Previous `Map`-based signatures remain supported as + deprecated overloads and will be removed in v42. `ConfigSpec.typedClass` is the supported way to + register a `TypedConfigMap` subclass against a JSON config. + ### 🤖 AI Docs + Tooling * **New `coding-conventions.md` doc** — authoritative coding conventions reference for hoist-core, consolidating guidance previously scattered across `CLAUDE.md` and individual feature docs. Paired sibling to the hoist-react `coding-conventions.md`. Covers naming, logging (`LogSupport`, `withInfo`/`withDebug`, structured map form), exceptions, services and lifecycle, controllers and security, GORM, clustering, HTTP/email/background work, Groovy idioms, and commit/PR formatting. diff --git a/docs/authorization.md b/docs/authorization.md index edcf472f..c81aa9fe 100644 --- a/docs/authorization.md +++ b/docs/authorization.md @@ -20,6 +20,7 @@ establishes *who* the user is, authorization determines *what* they can do. | `DefaultRoleAdminService` | `grails-app/services/io/xh/hoist/role/provided/` | Admin Console read operations (role listing with effective memberships) | | `DefaultRoleUpdateService` | `grails-app/services/io/xh/hoist/role/provided/` | Role mutations (CRUD, `ensureRequiredRolesCreated`, `assignRole`) | | `RoleAdminController` | `grails-app/controllers/io/xh/hoist/admin/` | Admin Console endpoints for role management | +| `RoleSpec` | `src/main/groovy/io/xh/hoist/role/provided/` | Typed specification for required role definitions | | `Role` | `grails-app/domain/io/xh/hoist/role/provided/` | GORM domain — role definitions | | `RoleMember` | `grails-app/domain/io/xh/hoist/role/provided/` | GORM domain — role memberships | | `HoistInterceptor` | `grails-app/controllers/io/xh/hoist/` | Grails interceptor enforcing annotations | @@ -158,6 +159,7 @@ The simplest approach — extend it as your app's `RoleService`: package com.myapp import io.xh.hoist.role.provided.DefaultRoleService +import io.xh.hoist.role.provided.RoleSpec class RoleService extends DefaultRoleService { @@ -166,9 +168,9 @@ class RoleService extends DefaultRoleService { // Create app-specific roles (no-op if they already exist) ensureRequiredRolesCreated([ - [name: 'APP_USER', category: 'App', notes: 'Standard application access', roles: ['APP_ADMIN']], - [name: 'APP_ADMIN', category: 'App', notes: 'Full admin access'], - [name: 'TRADER', category: 'Trading', notes: 'Can execute trades'] + new RoleSpec(name: 'APP_USER', category: 'App', notes: 'Standard application access', roles: ['APP_ADMIN']), + new RoleSpec(name: 'APP_ADMIN', category: 'App', notes: 'Full admin access'), + new RoleSpec(name: 'TRADER', category: 'Trading', notes: 'Can execute trades') ]) } } @@ -190,8 +192,8 @@ role definition means "members of these listed roles also get this role": ```groovy ensureRequiredRolesCreated([ - [name: 'APP_USER', category: 'App', roles: ['APP_ADMIN']], // admins also get APP_USER - [name: 'APP_ADMIN', category: 'App'], + new RoleSpec(name: 'APP_USER', category: 'App', roles: ['APP_ADMIN']), // admins also get APP_USER + new RoleSpec(name: 'APP_ADMIN', category: 'App'), ]) ``` @@ -205,7 +207,7 @@ via `LdapService`: ```groovy ensureRequiredRolesCreated([ - [name: 'APP_USER', directoryGroups: ['CN=AppUsers,OU=Groups,DC=company,DC=com']] + new RoleSpec(name: 'APP_USER', directoryGroups: ['CN=AppUsers,OU=Groups,DC=company,DC=com']) ]) ``` @@ -312,6 +314,12 @@ has `HOIST_ROLE_MANAGER`. This prevents an admin from impersonating a role manag its database, and provides a complete Admin Console UI for managing roles. Most applications should extend it and create app-specific roles in `ensureRequiredConfigAndRolesCreated()`. +The `ensureRequiredRolesCreated()` method accepts a `List` where each `RoleSpec` specifies +the role's `name` and optional fields (`category`, `notes`, `users`, `directoryGroups`, `roles`). +It creates any missing roles with the supplied defaults — existing roles are never modified. A +deprecated overload accepting `List` is still supported for backward compatibility but should +be migrated to `RoleSpec`. + ### Custom RoleService Hoist is deliberately flexible about where role assignments come from. Some applications or customer diff --git a/docs/coding-conventions.md b/docs/coding-conventions.md index b1d2d94e..5bcee2a9 100644 --- a/docs/coding-conventions.md +++ b/docs/coding-conventions.md @@ -403,37 +403,76 @@ case. ### Bootstrap Required Resources -Services that depend on specific configs, prefs, or roles should declare them in the corresponding -`ensureRequired*Created()` hook. The framework runs these during bootstrap so a fresh database comes -up with the rows needed: +Apps declare the configs, prefs, and roles they depend on so a fresh database comes up with the +rows needed. Use the typed spec classes (`ConfigSpec`, `PreferenceSpec`, `RoleSpec`) — they give +you IDE autocomplete, compile-time validation of field names, and a stable contract that mirrors +the seedable fields of the underlying domain class. + +Configs and prefs are typically declared from `BootStrap.groovy` by calling the corresponding +service: ```groovy -class PortfolioService extends BaseService { +import io.xh.hoist.config.ConfigSpec +import io.xh.hoist.pref.PreferenceSpec - Map getRequiredConfigs() { - [ - myAppPortfolioRefreshInterval: [ - valueType : 'int', +class BootStrap { + + def configService + def prefService + + def init = { servletContext -> + configService.ensureRequiredConfigsCreated([ + new ConfigSpec( + name: 'myAppPortfolioRefreshInterval', + valueType: 'int', defaultValue: 60, - groupName : 'PortfolioService', - note : 'Refresh interval in seconds' - ] - ] + groupName: 'PortfolioService', + note: 'Refresh interval in seconds' + ), + new ConfigSpec( + name: 'myAppPricingSource', + valueType: 'json', + defaultValue: [endpoint: 'https://prices.example.com'], + typedClass: PricingConfig, + groupName: 'PortfolioService' + ) + ]) + + prefService.ensureRequiredPrefsCreated([ + new PreferenceSpec( + name: 'myAppPortfolioDefaultView', + type: 'string', + defaultValue: 'summary', + groupName: 'PortfolioService' + ) + ]) } +} +``` - Map getRequiredPrefs() { - [ - myAppPortfolioDefaultView: [ - type : 'string', - defaultValue: 'summary', - groupName : 'PortfolioService' - ] - ] +Roles are declared by overriding `ensureRequiredConfigAndRolesCreated()` in the app's +`RoleService` (extending `DefaultRoleService`): + +```groovy +import io.xh.hoist.role.provided.DefaultRoleService +import io.xh.hoist.role.provided.RoleSpec + +class RoleService extends DefaultRoleService { + + protected void ensureRequiredConfigAndRolesCreated() { + super.ensureRequiredConfigAndRolesCreated() + + ensureRequiredRolesCreated([ + new RoleSpec(name: 'APP_USER', category: 'App', notes: 'Standard access', roles: ['APP_ADMIN']), + new RoleSpec(name: 'APP_ADMIN', category: 'App', notes: 'Full admin access'), + new RoleSpec(name: 'TRADER', category: 'Trading', notes: 'Can execute trades') + ]) } } ``` -See [Configuration](configuration.md) and [Preferences](preferences.md) for full schemas. +See [Configuration](configuration.md), [Preferences](preferences.md), and +[Authorization](authorization.md) for the full schemas. ### Use `configService` Typed Getters diff --git a/docs/configuration.md b/docs/configuration.md index 726a115b..52b74509 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -208,6 +208,7 @@ Key capabilities: | `AppConfig` | `grails-app/domain/io/xh/hoist/config/` | GORM domain — database-backed config entries | | `ConfigService` | `grails-app/services/io/xh/hoist/config/` | Primary service — typed getters, event publishing | | `ConfigDiffService` | `grails-app/services/io/xh/hoist/config/` | Cross-environment config synchronization | +| `ConfigSpec` | `src/main/groovy/io/xh/hoist/config/` | Typed specification for required config definitions | | `ConfigAdminController` | `grails-app/controllers/io/xh/hoist/admin/` | Admin console endpoint for config management | ### Key Classes @@ -294,8 +295,8 @@ the requested type doesn't match the config's `valueType`. ##### Typed configs via `TypedConfigMap` For structured JSON configs with a stable, known key set, declare a `TypedConfigMap` subclass -and wire it in via the `typedClass:` key on the config's `ensureRequiredConfigsCreated` -entry. This gives you: +and wire it in via the `typedClass:` field on the config's `ConfigSpec` entry passed to +`ensureRequiredConfigsCreated`. This gives you: 1. **One source of truth for shape + defaults.** Property initializers on the class are the fallback values; when the stored map is missing a key, the declared default applies. @@ -327,13 +328,14 @@ class PricingConfig extends TypedConfigMap { // 2. Register it in BootStrap alongside the other config metadata. configService.ensureRequiredConfigsCreated([ - pricingSourceConfig: [ + new ConfigSpec( + name: 'pricingSourceConfig', valueType: 'json', defaultValue: [endpoint: 'https://prices.example.com', timeoutMs: 5000, fallbackEnabled: true], typedClass: PricingConfig, groupName: 'Pricing', note: '...' - ] + ) ]) // 3. Read it, anywhere. @@ -401,39 +403,49 @@ obscured as `'*********'`, and JSON values are parsed to objects. ##### `ensureRequiredConfigsCreated(reqConfigs)` -Called during application bootstrap to declare configs the application depends on. Creates missing -configs with default values; logs errors for type mismatches or `clientVisible` flag mismatches on -existing configs (but does not auto-fix them). +Called during application bootstrap to declare configs the application depends on. Accepts a +`List` where each `ConfigSpec` specifies the config's `name`, `valueType`, +`defaultValue`, and optional fields (`clientVisible`, `groupName`, `note`). Creates missing configs +with default values; logs errors for type mismatches or `clientVisible` flag mismatches on existing +configs (but does not auto-fix them). Applications should declare all long-lived, expected configs here — not just those that are strictly required at startup. This serves as an effective inventory of the application's soft configs, ensures that an app starting against a fresh database has a complete set of entries visible and adjustable in the Admin Console, and guarantees consistency across environments. +A deprecated overload accepting `Map` (where the outer key is the config name) is +still supported for backward compatibility but should be migrated to `ConfigSpec`. + ```groovy +import io.xh.hoist.config.ConfigSpec + class BootStrap { def configService def init = { configService.ensureRequiredConfigsCreated([ - 'apiEndpoint': [ + new ConfigSpec( + name: 'apiEndpoint', valueType: 'string', defaultValue: 'https://api.example.com', clientVisible: true, groupName: 'API', note: 'External API base URL' - ], - 'maxConnections': [ + ), + new ConfigSpec( + name: 'maxConnections', valueType: 'int', defaultValue: '100', groupName: 'Performance' - ], - 'apiSecret': [ + ), + new ConfigSpec( + name: 'apiSecret', valueType: 'pwd', defaultValue: 'changeme', groupName: 'API', note: 'API authentication secret' - ] + ) ]) } } diff --git a/docs/preferences.md b/docs/preferences.md index b8e7fb34..bf9f6972 100644 --- a/docs/preferences.md +++ b/docs/preferences.md @@ -22,6 +22,7 @@ application settings, while preferences are per-user. | `UserPreference` | `grails-app/domain/io/xh/hoist/pref/` | GORM domain — per-user preference values | | `PrefService` | `grails-app/services/io/xh/hoist/pref/` | Primary service — typed getters/setters | | `PrefDiffService` | `grails-app/services/io/xh/hoist/pref/` | Cross-environment preference synchronization | +| `PreferenceSpec` | `src/main/groovy/io/xh/hoist/pref/` | Typed specification for required preference definitions | | `PreferenceAdminController` | `grails-app/controllers/io/xh/hoist/admin/` | Admin Console CRUD endpoints for preference definitions | | `XhController` | `grails-app/controllers/io/xh/hoist/impl/` | Client-facing `getPrefs` / `setPrefs` endpoints | @@ -158,35 +159,46 @@ parsed to objects. #### `ensureRequiredPrefsCreated(requiredPrefs)` Applications should register all preferences they intend to use via this method in their -`BootStrap.groovy`. A preference must exist as a `Preference` record in the database before it can -be read or written — calls to `PrefService` for a non-existent preference will throw a -`RuntimeException`. This method creates any missing preferences with the supplied defaults and logs -errors if an existing preference has a mismatched type. Note the API uses `note` (singular) for -consistency with `ensureRequiredConfigsCreated()`, even though the `Preference` domain property is -`notes` (plural). +`BootStrap.groovy`. Accepts a `List` where each `PreferenceSpec` specifies the +preference's `name`, `type`, `defaultValue`, and optional fields (`groupName`, `notes`). A +preference must exist as a `Preference` record in the database before it can be read or written — +calls to `PrefService` for a non-existent preference will throw a `RuntimeException`. This method +creates any missing preferences with the supplied defaults and logs errors if an existing +preference has a mismatched type. + +A deprecated overload accepting `Map` (where the outer key is the preference name) +is still supported for backward compatibility but should be migrated to `PreferenceSpec`. Note that +the old Map API used `note` (singular) for consistency with the former `ensureRequiredConfigsCreated()` +Map API; the deprecated overload maps `note` to the correct `notes` (plural) field on `Preference` +automatically. ```groovy +import io.xh.hoist.pref.PreferenceSpec + class BootStrap { def prefService def init = { prefService.ensureRequiredPrefsCreated([ - 'theme': [ + new PreferenceSpec( + name: 'theme', type: 'string', defaultValue: 'light', groupName: 'UI', - note: 'User interface theme' - ], - 'defaultPageSize': [ + notes: 'User interface theme' + ), + new PreferenceSpec( + name: 'defaultPageSize', type: 'int', defaultValue: '25', groupName: 'UI' - ], - 'dashboardLayout': [ + ), + new PreferenceSpec( + name: 'dashboardLayout', type: 'json', defaultValue: [panels: []], groupName: 'Dashboard' - ] + ) ]) } } diff --git a/grails-app/init/io/xh/hoist/BootStrap.groovy b/grails-app/init/io/xh/hoist/BootStrap.groovy index 6c5665e4..d1de00f6 100644 --- a/grails-app/init/io/xh/hoist/BootStrap.groovy +++ b/grails-app/init/io/xh/hoist/BootStrap.groovy @@ -12,6 +12,7 @@ import io.xh.hoist.admin.MemoryMonitoringConfig import io.xh.hoist.alertbanner.AlertBannerConfig import io.xh.hoist.cluster.ClusterService import io.xh.hoist.config.ChangelogConfig +import io.xh.hoist.config.ConfigSpec import io.xh.hoist.config.IdleConfig import io.xh.hoist.environment.EnvPollConfig import io.xh.hoist.export.ExportConfig @@ -19,6 +20,7 @@ import io.xh.hoist.ldap.LdapConfig import io.xh.hoist.log.LogArchiveConfig import io.xh.hoist.log.LogSupport import io.xh.hoist.monitor.MonitorConfig +import io.xh.hoist.pref.PreferenceSpec import io.xh.hoist.telemetry.metric.MetricsConfig import io.xh.hoist.telemetry.trace.TraceConfig import io.xh.hoist.track.ActivityTrackingConfig @@ -95,7 +97,8 @@ class BootStrap implements LogSupport { private void ensureRequiredConfigsCreated() { configService.ensureRequiredConfigsCreated([ - xhActivityTrackingConfig: [ + new ConfigSpec( + name: 'xhActivityTrackingConfig', valueType: 'json', defaultValue: [ clientHealthReport: [intervalMins: -1], @@ -110,52 +113,59 @@ class BootStrap implements LogSupport { clientVisible: true, groupName: 'xh.io', note: 'Configures built-in Activity Tracking via TrackService.' - ], - xhAlertBannerConfig: [ + ), + new ConfigSpec( + name: 'xhAlertBannerConfig', valueType: 'json', defaultValue: [enabled: true], typedClass: AlertBannerConfig, clientVisible: true, groupName: 'xh.io', note: 'Configures support for showing an app-wide alert banner.\n\nAdmins configure and activate alert banners from the Hoist Admin console. To generally enable this system, set "enabled" to true. The xhEnvPollConfig.interval config governs client polling for updates.' - ], - xhAppInstances: [ + ), + new ConfigSpec( + name: 'xhAppInstances', valueType: 'json', defaultValue: [], clientVisible: true, groupName: 'xh.io', note: 'List of root URLs for running instances of this app across environments. Currently only used for as a convenience feature in the Admin config diff tool.' - ], - xhAppTimeZone: [ + ), + new ConfigSpec( + name: 'xhAppTimeZone', valueType: 'string', defaultValue: 'UTC', clientVisible: true, groupName: 'xh.io', note: 'Official TimeZone for this application - e.g. the zone of the head office. Used to format/parse business related dates that need to be considered and displayed consistently at all locations. Set to a valid Java TimeZone ID.' - ], - xhAutoRefreshIntervals: [ + ), + new ConfigSpec( + name: 'xhAutoRefreshIntervals', valueType: 'json', defaultValue: [app: -1], clientVisible: true, groupName: 'xh.io', note: 'Map of clientAppCodes to intervals (in seconds) on which the client-side AutoRefreshService should fire. Note the xhAutoRefreshEnabled preference must also be true for the client service to activate.' - ], - xhChangelogConfig: [ + ), + new ConfigSpec( + name: 'xhChangelogConfig', valueType: 'json', defaultValue: [enabled: true, excludedVersions: [], excludedCategories: [], limitToRoles: []], typedClass: ChangelogConfig, clientVisible: true, groupName: 'xh.io', note: 'Configures built-in application changelog (release notes), with options to disable the feature entirely, exclude particular releases or categories of changes from the log, and/or only show to users with selected roles.' - ], - xhClientErrorConfig: [ + ), + new ConfigSpec( + name: 'xhClientErrorConfig', valueType: 'json', defaultValue: [intervalMins: 2], typedClass: ClientErrorConfig, groupName: 'xh.io', note: 'Configures handling of client error reports. Errors are queued when received and processed every [intervalMins].' - ], - xhConnPoolMonitoringConfig: [ + ), + new ConfigSpec( + name: 'xhConnPoolMonitoringConfig', valueType: 'json', defaultValue: [ enabled: true, @@ -166,60 +176,69 @@ class BootStrap implements LogSupport { typedClass: ConnPoolMonitoringConfig, groupName: 'xh.io', note: 'Configures built-in JDBC connection pool monitoring.' - ], - xhEmailDefaultDomain: [ + ), + new ConfigSpec( + name: 'xhEmailDefaultDomain', valueType: 'string', defaultValue: 'example.com', groupName: 'xh.io', note: 'Default domain name appended by Hoist EmailServices when unqualified usernames are passed to the service as email recipients/senders.' - ], - xhEmailDefaultSender: [ + ), + new ConfigSpec( + name: 'xhEmailDefaultSender', valueType: 'string', defaultValue: 'support@example.com', groupName: 'xh.io', note: 'Email address for Hoist EmailService to use as default sender address.' - ], - xhEmailFilter: [ + ), + new ConfigSpec( + name: 'xhEmailFilter', valueType: 'string', defaultValue: 'none', groupName: 'xh.io', note: 'Comma-separated list of email addresses to which Hoist EmailService can send mail. For testing / dev purposes. If specified, emails to addresses not in this list will be quietly dropped. Value "none" does not filter recipients.' - ], - xhEmailOverride: [ + ), + new ConfigSpec( + name: 'xhEmailOverride', valueType: 'string', defaultValue: 'none', groupName: 'xh.io', note: 'Email address to which Hoist emailService should send all mail, regardless of specified recipient. For testing / dev purposes. Use to test actual sending of mails while still not mailing end-users. Value "none" disables any override.' - ], - xhEmailSupport: [ + ), + new ConfigSpec( + name: 'xhEmailSupport', valueType: 'string', defaultValue: 'none', clientVisible: true, groupName: 'xh.io', note: 'Email address to which support and feedback submissions should be sent. Value "none" to disable support emails.' - ], - xhEnableImpersonation: [ + ), + new ConfigSpec( + name: 'xhEnableImpersonation', valueType: 'bool', defaultValue: false, clientVisible: true, groupName: 'xh.io', note: 'True to enable identity impersonation by authorized users.' - ], - xhEnableLogViewer: [ + ), + new ConfigSpec( + name: 'xhEnableLogViewer', valueType: 'bool', defaultValue: true, clientVisible: true, groupName: 'xh.io', note: 'True to enable the log viewer included with the Hoist Admin console as well as the associated server-side endpoints.' - ], - xhEnableMonitoring: [ + ), + new ConfigSpec( + name: 'xhEnableMonitoring', valueType: 'bool', defaultValue: true, clientVisible: true, groupName: 'xh.io', note: 'True to enable the monitor tab included with the Hoist Admin console and the associated server-side jobs' - ], - xhEnvPollConfig: [ + ), + new ConfigSpec( + name: 'xhEnvPollConfig', valueType: 'json', defaultValue: [ interval: 10, @@ -233,14 +252,16 @@ class BootStrap implements LogSupport { "\t+ 'forceReload': Force clients to refresh immediately. To be used when an updated server is known to be incompatible with a previously deployed client.\n" + "\t+ 'promptReload': Show an update prompt banner, allowing users to refresh when convenient.\n" + "\t+ 'silent': No action taken." - ], - xhExpectedServerTimeZone: [ + ), + new ConfigSpec( + name: 'xhExpectedServerTimeZone', valueType: 'string', defaultValue: '*', groupName: 'xh.io', note: 'Expected time zone of the server-side JVM - set to a valid Java TimeZone ID. NOTE: this config is checked at startup to ensure the server is running in the expected zone and will throw a fatal exception if it is invalid or does not match the zone reported by Java.\n\nChanging this config has no effect on a running server, and will not itself change the default Zone of the JVM.\n\nIf you REALLY do not want this behavior, a value of "*" will suppress this check.' - ], - xhExportConfig: [ + ), + new ConfigSpec( + name: 'xhExportConfig', valueType: 'json', defaultValue: [ streamingCellThreshold: 100000, @@ -250,23 +271,26 @@ class BootStrap implements LogSupport { clientVisible: true, groupName: 'xh.io', note: 'Configures exporting data to Excel.' - ], - xhFlags: [ + ), + new ConfigSpec( + name: 'xhFlags', valueType: 'json', defaultValue: [:], clientVisible: true, groupName: 'xh.io', note: 'Flags for experimental features.' - ], - xhIdleConfig: [ + ), + new ConfigSpec( + name: 'xhIdleConfig', valueType: 'json', defaultValue: [timeout: 120, appTimeouts: [:]], typedClass: IdleConfig, clientVisible: true, groupName: 'xh.io', note: 'Governs how client application will enter "sleep mode", suspending background requests and prompting the user to reload to resume. Timeouts are in minutes of inactivity. -1 to disable.' - ], - xhLdapConfig: [ + ), + new ConfigSpec( + name: 'xhLdapConfig', valueType: 'json', defaultValue: [ enabled: false, @@ -285,18 +309,21 @@ class BootStrap implements LogSupport { typedClass: LdapConfig, groupName: 'xh.io', note: 'Supports connecting to LDAP servers.' - ], - xhLdapUsername: [ + ), + new ConfigSpec( + name: 'xhLdapUsername', valueType: 'string', defaultValue: 'none', groupName: 'xh.io' - ], - xhLdapPassword: [ + ), + new ConfigSpec( + name: 'xhLdapPassword', valueType: 'pwd', defaultValue: 'none', groupName: 'xh.io' - ], - xhLogArchiveConfig: [ + ), + new ConfigSpec( + name: 'xhLogArchiveConfig', valueType: 'json', defaultValue: [ archiveAfterDays: 30, @@ -305,8 +332,9 @@ class BootStrap implements LogSupport { typedClass: LogArchiveConfig, groupName: 'xh.io', note: 'Configures automatic cleanup and archiving of log files. Files older than "archiveAfterDays" will be moved into zipped bundles within the specified "archiveFolder".' - ], - xhMemoryMonitoringConfig: [ + ), + new ConfigSpec( + name: 'xhMemoryMonitoringConfig', valueType: 'json', defaultValue: [ enabled: true, @@ -321,8 +349,9 @@ class BootStrap implements LogSupport { clientVisible: true, groupName: 'xh.io', note: 'Configures built-in memory usage and GC monitoring.' - ], - xhMonitorConfig: [ + ), + new ConfigSpec( + name: 'xhMonitorConfig', valueType: 'json', defaultValue: [ monitorRefreshMins: 10, @@ -336,14 +365,16 @@ class BootStrap implements LogSupport { typedClass: MonitorConfig, groupName: 'xh.io', note: 'Configures server-side status monitoring and notifications. Note failNotifyThreshold and warnNotifyThreshold are the number of refresh cycles a monitor will need to be in said status to trigger "alertMode".' - ], - xhMonitorEmailRecipients: [ + ), + new ConfigSpec( + name: 'xhMonitorEmailRecipients', valueType: 'string', defaultValue: 'none', groupName: 'xh.io', note: 'Email address to which status monitor alerts should be sent. Value "none" disables emailed alerts.' - ], - xhMetricsConfig: [ + ), + new ConfigSpec( + name: 'xhMetricsConfig', valueType: 'json', defaultValue: [ prometheusEnabled: false, @@ -354,8 +385,9 @@ class BootStrap implements LogSupport { typedClass: MetricsConfig, groupName: 'xh.io', note: 'Parameters for observable metric support' - ], - xhTraceConfig: [ + ), + new ConfigSpec( + name: 'xhTraceConfig', valueType: 'json', defaultValue: [ enabled: false, @@ -369,14 +401,16 @@ class BootStrap implements LogSupport { clientVisible: true, groupName: 'xh.io', note: 'Parameters for distributed tracing support.' - ], - xhMetricsPublished: [ + ), + new ConfigSpec( + name: 'xhMetricsPublished', valueType: 'json', defaultValue: [], groupName: 'xh.io', note: 'List of metric names to include in Prometheus, OTLP or other exports. Empty list = no metrics exported.' - ], - xhWebSocketConfig: [ + ), + new ConfigSpec( + name: 'xhWebSocketConfig', valueType: 'json', defaultValue: [ sendTimeLimitMs: 1000, @@ -385,48 +419,54 @@ class BootStrap implements LogSupport { typedClass: WebSocketConfig, groupName: 'xh.io', note: 'Parameters for the managed WebSocket sessions created by Hoist.' - ] + ) ]) } private void ensureRequiredPrefsCreated() { prefService.ensureRequiredPrefsCreated([ - xhAutoRefreshEnabled: [ + new PreferenceSpec( + name: 'xhAutoRefreshEnabled', type: 'bool', defaultValue: true, groupName: 'xh.io', - note: 'True to enable the client AutoRefreshService, which will trigger a refresh of client app data if/as specified by the xhAutoRefreshIntervals config. Note if disabled at the app level via config, this pref will have no effect.' - ], - xhIdleDetectionDisabled: [ + notes: 'True to enable the client AutoRefreshService, which will trigger a refresh of client app data if/as specified by the xhAutoRefreshIntervals config. Note if disabled at the app level via config, this pref will have no effect.' + ), + new PreferenceSpec( + name: 'xhIdleDetectionDisabled', type: 'bool', defaultValue: false, groupName: 'xh.io', - note: 'Set to true prevent IdleService from suspending the application due to inactivity.' - ], - xhLastReadChangelog: [ + notes: 'Set to true prevent IdleService from suspending the application due to inactivity.' + ), + new PreferenceSpec( + name: 'xhLastReadChangelog', type: 'string', defaultValue: '0.0.0', groupName: 'xh.io', - note: 'The most recent changelog entry version viewed by the user - read/written by XH.changelogService.' - ], - xhShowVersionBar: [ + notes: 'The most recent changelog entry version viewed by the user - read/written by XH.changelogService.' + ), + new PreferenceSpec( + name: 'xhShowVersionBar', type: 'string', defaultValue: 'auto', groupName: 'xh.io', - note: "Control display of Hoist footer with app version info. Options are 'auto' (show in non-prod env, or always for admins), 'always', and 'never'." - ], - xhSizingMode: [ + notes: "Control display of Hoist footer with app version info. Options are 'auto' (show in non-prod env, or always for admins), 'always', and 'never'." + ), + new PreferenceSpec( + name: 'xhSizingMode', type: 'json', defaultValue: [:], groupName: 'xh.io', - note: 'Sizing mode used by Grid and any other responsive components. Keyed by platform: [desktop|mobile|tablet].' - ], - xhTheme: [ + notes: 'Sizing mode used by Grid and any other responsive components. Keyed by platform: [desktop|mobile|tablet].' + ), + new PreferenceSpec( + name: 'xhTheme', type: 'string', defaultValue: 'system', groupName: 'xh.io', - note: 'Visual theme for the client application - "light", "dark", or "system".' - ] + notes: 'Visual theme for the client application - "light", "dark", or "system".' + ) ]) } diff --git a/grails-app/services/io/xh/hoist/config/ConfigService.groovy b/grails-app/services/io/xh/hoist/config/ConfigService.groovy index ec7d750f..2687b1b4 100644 --- a/grails-app/services/io/xh/hoist/config/ConfigService.groovy +++ b/grails-app/services/io/xh/hoist/config/ConfigService.groovy @@ -77,10 +77,10 @@ class ConfigService extends BaseService { * applied for any keys missing from the stored value. * * The supplied class must extend {@link TypedConfigMap} and be registered against a - * backing `AppConfig` name via a `typedClass:` entry in `ensureRequiredConfigsCreated`. - * This is the preferred way to read structured configs — it centralizes defaults and - * documentation on the typed class itself, rather than scattering `?:` fallbacks across - * call sites. + * backing `AppConfig` name via a `typedClass:` entry on the {@link ConfigSpec} passed to + * {@link #ensureRequiredConfigsCreated}. This is the preferred way to read structured configs — + * it centralizes defaults and documentation on the typed class itself, rather than scattering + * `?:` fallbacks across call sites. */ T getObject(Class clazz) { String name = nameByConfigType[clazz] @@ -181,96 +181,87 @@ class ConfigService extends BaseService { } /** - * Check a list of core configurations required for Hoist/application operation - ensuring that these configs are - * present and that their valueTypes and clientVisible flags are are as expected. Will create missing configs with - * supplied default values if not found. + * Check a list of core configurations required for Hoist/application operation — ensuring + * that these configs are present and that their valueTypes and clientVisible flags are as + * expected. Will create missing configs with supplied default values if not found. * - * Supported keys per entry: - * - `valueType` (required) — one of `string|int|long|double|bool|json|pwd` - * - `defaultValue` — seed value written to the DB when the config row is first created - * - `clientVisible` — true to include in `getClientConfig()` payloads - * - `groupName`, `note` — metadata shown in the Admin Console - * - `typedClass` (optional, JSON-type configs only) — a concrete {@link TypedConfigMap} - * subclass to bind to the entry's key. When present: - * + Server code can load the config via {@link #getObject(Class)}. - * + The class's property-initializer defaults are applied at read time for any - * key missing from the stored map — centralizing defaults next to the type. - * + A `WARN` is logged at startup for any key whose typed-class default differs - * from the BootStrap `defaultValue`, flagging drift between the two. - * `typedClass` is fully optional — entries without it may still be gained as a generic JSON - * object via getMap(). + * Each {@link ConfigSpec} may optionally declare a `typedClass` (JSON-type configs only) that + * extends {@link TypedConfigMap}. When present: + * - Server code can load the config via {@link #getObject(Class)}. + * - The class's property-initializer defaults are applied at read time for any key missing + * from the stored map. + * - A `WARN` is logged at startup for any key whose typed-class default differs from the + * BootStrap `defaultValue`, flagging drift between the two. * - * @param reqConfigs - map of configName to entry-config map as described above + * @param configSpecs - List of {@link ConfigSpec} defining the required configs. */ @Transactional - void ensureRequiredConfigsCreated(Map reqConfigs) { + void ensureRequiredConfigsCreated(List configSpecs) { + configSpecs = configSpecs.collect { + it instanceof ConfigSpec ? it : new ConfigSpec(it as Map) + } + def currConfigs = AppConfig.list(), created = 0 - reqConfigs.each { confName, confDefaults -> - def currConfig = currConfigs.find { it.name == confName }, - valType = confDefaults.valueType, - defaultVal = confDefaults.defaultValue, - clientVisible = confDefaults.clientVisible ?: false, - note = confDefaults.note ?: '' + configSpecs.each { ConfigSpec spec -> + def currConfig = currConfigs.find { it.name == spec.name }, + defaultVal = spec.defaultValue if (!currConfig) { - - if (valType == 'json') defaultVal = serializePretty(defaultVal) + if (spec.valueType == 'json') defaultVal = serializePretty(defaultVal) new AppConfig( - name: confName, - valueType: valType, + name: spec.name, + valueType: spec.valueType, value: defaultVal, - groupName: confDefaults.groupName ?: 'Default', - clientVisible: clientVisible, + groupName: spec.groupName, + clientVisible: spec.clientVisible, lastUpdatedBy: 'hoist-bootstrap', - note: note + note: spec.note ?: '' ).save() logWarn( - "Required config $confName missing and created with default value", + "Required config ${spec.name} missing and created with default value", 'verify default is appropriate for this application' ) created++ } else { - if (currConfig.valueType != valType) { + if (currConfig.valueType != spec.valueType) { logError( - "Unexpected value type for required config $confName", - "expected $valType got ${currConfig.valueType}", + "Unexpected value type for required config ${spec.name}", + "expected ${spec.valueType} got ${currConfig.valueType}", 'review and fix!' ) } - if (currConfig.clientVisible != clientVisible) { + if (currConfig.clientVisible != spec.clientVisible) { logError( - "Unexpected clientVisible for required config $confName", - "expected $clientVisible got ${currConfig.clientVisible}", + "Unexpected clientVisible for required config ${spec.name}", + "expected ${spec.clientVisible} got ${currConfig.clientVisible}", 'review and fix!' ) } } - if (confDefaults.typedClass) { - registerTypedConfig(confName, confDefaults.typedClass as Class, confDefaults.defaultValue as Map) + if (spec.typedClass) { + registerTypedConfig(spec.name, spec.typedClass, spec.defaultValue as Map) } } - logDebug("Validated presense of ${reqConfigs.size()} required configs", "created ${created}") + logDebug("Validated presense of ${configSpecs.size()} required configs", "created ${created}") } - //--------------------------- - // Typed Config Registration - //--------------------------- - private void registerTypedConfig(String confName, Class typedClass, Map bootstrapDefault) { - if (!TypedConfigMap.isAssignableFrom(typedClass)) { - throw new RuntimeException( - "typedClass for config '$confName' must extend TypedConfigMap — got ${typedClass.name}" - ) - } - def asTyped = typedClass as Class - configTypeByName[confName] = asTyped - nameByConfigType[asTyped] = confName - configDriftService.checkTypedConfigDivergence(confName, asTyped, bootstrapDefault) + /** + * @deprecated Use {@link #ensureRequiredConfigsCreated(List)} with {@link ConfigSpec} instead. + * Targeted for removal in v42. + */ + @Deprecated + @Transactional + void ensureRequiredConfigsCreated(Map reqConfigs) { + logWarn('ensureRequiredConfigsCreated(Map) is deprecated — use List instead') + ensureRequiredConfigsCreated( + reqConfigs.collect { name, defaults -> new ConfigSpec([name: name] + defaults) } + ) } void fireConfigChanged(AppConfig obj) { @@ -286,6 +277,17 @@ class ConfigService extends BaseService { configTypeByName[name] } + private void registerTypedConfig(String confName, Class typedClass, Map bootstrapDefault) { + if (!TypedConfigMap.isAssignableFrom(typedClass)) { + throw new RuntimeException( + "typedClass for config '$confName' must extend TypedConfigMap — got ${typedClass.name}" + ) + } + def asTyped = typedClass as Class + configTypeByName[confName] = asTyped + nameByConfigType[asTyped] = confName + configDriftService.checkTypedConfigDivergence(confName, asTyped, bootstrapDefault) + } @ReadOnly private Object getInternalByName(String name, String valueType, Object notFoundValue) { diff --git a/grails-app/services/io/xh/hoist/pref/PrefService.groovy b/grails-app/services/io/xh/hoist/pref/PrefService.groovy index 5d295034..99f0b7e7 100644 --- a/grails-app/services/io/xh/hoist/pref/PrefService.groovy +++ b/grails-app/services/io/xh/hoist/pref/PrefService.groovy @@ -120,55 +120,75 @@ class PrefService extends BaseService { } /** - * Check a list of core preferences required for Hoist/application operation - ensuring that + * Check a list of core preferences required for Hoist/application operation — ensuring that * these prefs are present and that their types are as expected if so. * * Will create missing prefs with supplied default values if not found. * - * @param requiredPrefs - map of prefName to map of `[type, groupName, defaultValue, note]` + * @param prefSpecs - List of {@link PreferenceSpec} defining the required preferences. */ @Transactional - void ensureRequiredPrefsCreated(Map requiredPrefs) { + void ensureRequiredPrefsCreated(List prefSpecs) { + prefSpecs = prefSpecs.collect { + it instanceof PreferenceSpec ? it : new PreferenceSpec(it as Map) + } + def currPrefs = Preference.list(), created = 0 - requiredPrefs.each { prefName, prefDefaults -> - def currPref = currPrefs.find { it.name == prefName }, - valType = prefDefaults.type, - defaultVal = prefDefaults.defaultValue, - // Mismatch on notes <> note vs. AppConfig - stuck with singular "note" for - // this API for consistency with ensureRequiredConfigsCreated() - notes = prefDefaults.note ?: '' + prefSpecs.each { PreferenceSpec spec -> + def currPref = currPrefs.find { it.name == spec.name }, + defaultVal = spec.defaultValue if (!currPref) { - if (valType == 'json') defaultVal = serializePretty(defaultVal) + if (spec.type == 'json') defaultVal = serializePretty(defaultVal) new Preference( - name: prefName, - type: valType, + name: spec.name, + type: spec.type, defaultValue: defaultVal, - groupName: prefDefaults.groupName ?: 'Default', - notes: notes, + groupName: spec.groupName, + notes: spec.notes ?: '', lastUpdatedBy: 'hoist-bootstrap' ).save() logWarn( - "Required preference $prefName missing and created with default value", - 'verify default is appropriate for this application' + "Required preference ${spec.name} missing and created with default value", + 'verify default is appropriate for this application' ) created++ } else { - if (currPref.type != valType) { + if (currPref.type != spec.type) { logError( - "Unexpected value type for required preference $prefName", - "expected $valType got ${currPref.type}", - 'review and fix!' + "Unexpected value type for required preference ${spec.name}", + "expected ${spec.type} got ${currPref.type}", + 'review and fix!' ) } } } - logDebug("Validated presense of ${requiredPrefs.size()} required configs", "created $created") + logDebug("Validated presense of ${prefSpecs.size()} required prefs", "created $created") + } + + /** + * @deprecated Use {@link #ensureRequiredPrefsCreated(List)} with {@link PreferenceSpec} instead. + * Targeted for removal in v42. + */ + @Deprecated + @Transactional + void ensureRequiredPrefsCreated(Map requiredPrefs) { + logWarn('ensureRequiredPrefsCreated(Map) is deprecated — use List instead') + ensureRequiredPrefsCreated( + requiredPrefs.collect { name, defaults -> + // Legacy API used singular 'note' — map to 'notes' for PreferenceSpec. + def specMap = [name: name] + defaults + if (specMap.containsKey('note') && !specMap.containsKey('notes')) { + specMap.notes = specMap.remove('note') + } + new PreferenceSpec(specMap) + } + ) } @ReadOnly diff --git a/grails-app/services/io/xh/hoist/role/provided/DefaultRoleUpdateService.groovy b/grails-app/services/io/xh/hoist/role/provided/DefaultRoleUpdateService.groovy index c23cdaa3..9e0723eb 100644 --- a/grails-app/services/io/xh/hoist/role/provided/DefaultRoleUpdateService.groovy +++ b/grails-app/services/io/xh/hoist/role/provided/DefaultRoleUpdateService.groovy @@ -61,11 +61,15 @@ class DefaultRoleUpdateService extends BaseService { @Transactional - void ensureRequiredRolesCreated(List roleSpecs) { + void ensureRequiredRolesCreated(List roleSpecs) { + roleSpecs = roleSpecs.collect { + it instanceof RoleSpec ? it : new RoleSpec(it as Map) + } + List currRoles = Role.list() int created = 0 - roleSpecs.each { spec -> + roleSpecs.each { RoleSpec spec -> Role currRole = currRoles.find { it.name == spec.name } if (!currRole) { Role newRole = new Role( diff --git a/src/main/groovy/io/xh/hoist/config/ConfigSpec.groovy b/src/main/groovy/io/xh/hoist/config/ConfigSpec.groovy new file mode 100644 index 00000000..902b8bc4 --- /dev/null +++ b/src/main/groovy/io/xh/hoist/config/ConfigSpec.groovy @@ -0,0 +1,35 @@ +/* + * This file belongs to Hoist, an application development toolkit + * developed by Extremely Heavy Industries (www.xh.io | info@xh.io) + * + * Copyright © 2026 Extremely Heavy Industries Inc. + */ + +package io.xh.hoist.config + +import groovy.transform.MapConstructor + +/** + * Typed specification for a required config to be created by + * {@link ConfigService#ensureRequiredConfigsCreated}. + * + * Mirrors the seedable fields of {@link AppConfig} — if a new seedable field is added to the + * domain class, it should be added here as well. + * + * Provides IDE autocomplete and compile-time validation for config definitions. + */ +@MapConstructor +class ConfigSpec { + String name + String valueType + Object defaultValue + boolean clientVisible = false + String groupName = 'Default' + String note + + /** + * Optional {@link TypedConfigMap} subclass binding the shape of this config (JSON-type only). + * Recommended for any structured config with a stable key set — see docs/configuration.md. + */ + Class typedClass +} diff --git a/src/main/groovy/io/xh/hoist/monitor/provided/DefaultMonitorDefinitionService.groovy b/src/main/groovy/io/xh/hoist/monitor/provided/DefaultMonitorDefinitionService.groovy index f8c3d2af..8c4b3966 100644 --- a/src/main/groovy/io/xh/hoist/monitor/provided/DefaultMonitorDefinitionService.groovy +++ b/src/main/groovy/io/xh/hoist/monitor/provided/DefaultMonitorDefinitionService.groovy @@ -11,6 +11,7 @@ import grails.gorm.transactions.ReadOnly import grails.gorm.transactions.Transactional import groovy.sql.Sql import io.xh.hoist.BaseService +import io.xh.hoist.config.ConfigSpec import io.xh.hoist.monitor.Monitor import io.xh.hoist.monitor.MonitorResult import io.xh.hoist.monitor.MonitorSpec @@ -146,14 +147,19 @@ class DefaultMonitorDefinitionService extends BaseService { */ protected void ensureRequiredConfigAndMonitorsCreated() { configService.ensureRequiredConfigsCreated([ - xhMonitorConfig: [ + new ConfigSpec( + name: 'xhMonitorConfig', valueType: 'json', - monitorRefreshMins: 5, - monitorStartupDelayMins: 1, - warnNotifyThreshold: 5, - failNotifyThreshold: 2, - monitorRepeatNotifyMins: 60 - ] + defaultValue: [ + monitorRefreshMins: 5, + monitorStartupDelayMins: 1, + warnNotifyThreshold: 5, + failNotifyThreshold: 2, + monitorRepeatNotifyMins: 60 + ], + groupName: 'xh.io', + note: 'Configures server-side status monitoring and notifications.' + ) ]) ensureRequiredMonitorsCreated([ diff --git a/src/main/groovy/io/xh/hoist/pref/PreferenceSpec.groovy b/src/main/groovy/io/xh/hoist/pref/PreferenceSpec.groovy new file mode 100644 index 00000000..0dfcbd33 --- /dev/null +++ b/src/main/groovy/io/xh/hoist/pref/PreferenceSpec.groovy @@ -0,0 +1,28 @@ +/* + * This file belongs to Hoist, an application development toolkit + * developed by Extremely Heavy Industries (www.xh.io | info@xh.io) + * + * Copyright © 2026 Extremely Heavy Industries Inc. + */ + +package io.xh.hoist.pref + +import groovy.transform.MapConstructor + +/** + * Typed specification for a required preference to be created by + * {@link PrefService#ensureRequiredPrefsCreated}. + * + * Mirrors the seedable fields of {@link Preference} — if a new seedable field is added to the + * domain class, it should be added here as well. + * + * Provides IDE autocomplete and compile-time validation for preference definitions. + */ +@MapConstructor +class PreferenceSpec { + String name + String type + Object defaultValue + String groupName = 'Default' + String notes +} diff --git a/src/main/groovy/io/xh/hoist/role/provided/DefaultRoleService.groovy b/src/main/groovy/io/xh/hoist/role/provided/DefaultRoleService.groovy index 4dc2b4ad..3484bb70 100644 --- a/src/main/groovy/io/xh/hoist/role/provided/DefaultRoleService.groovy +++ b/src/main/groovy/io/xh/hoist/role/provided/DefaultRoleService.groovy @@ -10,6 +10,7 @@ package io.xh.hoist.role.provided import grails.gorm.transactions.ReadOnly import io.xh.hoist.cachedvalue.CachedValue import io.xh.hoist.config.ConfigService +import io.xh.hoist.config.ConfigSpec import io.xh.hoist.ldap.LdapService import io.xh.hoist.role.BaseRoleService import io.xh.hoist.user.HoistUser @@ -242,59 +243,53 @@ class DefaultRoleService extends BaseRoleService { */ protected void ensureRequiredConfigAndRolesCreated() { configService.ensureRequiredConfigsCreated([ - xhRoleModuleConfig: [ - valueType : 'json', + new ConfigSpec( + name: 'xhRoleModuleConfig', + valueType: 'json', defaultValue: [ refreshIntervalSecs: 300 ], - groupName : 'xh.io', - note : 'Configures built-in role management via DefaultRoleService.' - ] + groupName: 'xh.io', + note: 'Configures built-in role management via DefaultRoleService.' + ) ]) ensureRequiredRolesCreated([ - [ - name : 'HOIST_ADMIN', + new RoleSpec( + name: 'HOIST_ADMIN', category: 'Hoist', - notes : 'Hoist Admins have full access to all Hoist Admin tools and functionality.' - ], - [ - name : 'HOIST_ADMIN_READER', + notes: 'Hoist Admins have full access to all Hoist Admin tools and functionality.' + ), + new RoleSpec( + name: 'HOIST_ADMIN_READER', category: 'Hoist', - notes : 'Hoist Admin Readers have read-only access to all Hoist Admin tools and functionality.', - roles : ['HOIST_ADMIN'] - ], - [ - name : 'HOIST_IMPERSONATOR', + notes: 'Hoist Admin Readers have read-only access to all Hoist Admin tools and functionality.', + roles: ['HOIST_ADMIN'] + ), + new RoleSpec( + name: 'HOIST_IMPERSONATOR', category: 'Hoist', - notes : 'Hoist Impersonators can impersonate other users.', - roles : ['HOIST_ADMIN'] - ], - [ - name : 'HOIST_ROLE_MANAGER', + notes: 'Hoist Impersonators can impersonate other users.', + roles: ['HOIST_ADMIN'] + ), + new RoleSpec( + name: 'HOIST_ROLE_MANAGER', category: 'Hoist', - notes : 'Hoist Role Managers can manage roles and their memberships.', - ] + notes: 'Hoist Role Managers can manage roles and their memberships.' + ) ]) } /** - * Check a list of core roles required for Hoist/application operation - ensuring that these + * Check a list of core roles required for Hoist/application operation — ensuring that these * roles are present. Will create missing roles with supplied default values if not found. * - * Note that roles that *do* exist will *not* be modified in any way - i.e. this method cannot + * Note that roles that *do* exist will *not* be modified in any way — i.e. this method cannot * be used to ensure or update the membership of existing roles, only to create new ones. * - * @param roleSpecs - collection of specs for roles to be created as needed, as Maps with keys: - * - name: required, unique role name - * - category: optional - * - notes: optional - * - users: optional, list of usernames to add as members to the role - * - directoryGroups: optional, list of directory group DNs to add as members to the role - * - roles: optional, list of other role names to add as members to the role, granting - * users in those roles the permissions of the new role. + * @param roleSpecs - List of {@link RoleSpec} defining the required roles. */ - void ensureRequiredRolesCreated(List roleSpecs) { + void ensureRequiredRolesCreated(List roleSpecs) { defaultRoleUpdateService.ensureRequiredRolesCreated(roleSpecs) } diff --git a/src/main/groovy/io/xh/hoist/role/provided/RoleSpec.groovy b/src/main/groovy/io/xh/hoist/role/provided/RoleSpec.groovy new file mode 100644 index 00000000..4f49f317 --- /dev/null +++ b/src/main/groovy/io/xh/hoist/role/provided/RoleSpec.groovy @@ -0,0 +1,28 @@ +/* + * This file belongs to Hoist, an application development toolkit + * developed by Extremely Heavy Industries (www.xh.io | info@xh.io) + * + * Copyright © 2026 Extremely Heavy Industries Inc. + */ + +package io.xh.hoist.role.provided + +import groovy.transform.MapConstructor + +/** + * Typed specification for a required role to be created by + * {@link DefaultRoleService#ensureRequiredRolesCreated}. + * + * Mirrors the seedable fields of {@link Role} plus optional initial member assignments. + * + * Provides IDE autocomplete and compile-time validation for role definitions. + */ +@MapConstructor +class RoleSpec { + String name + String category + String notes + List users + List directoryGroups + List roles +} diff --git a/src/main/groovy/io/xh/hoist/security/Access.groovy b/src/main/groovy/io/xh/hoist/security/Access.groovy index 7064b7c6..9cf19959 100644 --- a/src/main/groovy/io/xh/hoist/security/Access.groovy +++ b/src/main/groovy/io/xh/hoist/security/Access.groovy @@ -18,7 +18,8 @@ import java.lang.annotation.Target * Controller annotation to list roles required to execute any action. * Current user must have ALL roles listed to access. * - * @deprecated - use @AccessRequiresRole or @AccessRequiresAllRoles instead. + * @deprecated Use @AccessRequiresRole or @AccessRequiresAllRoles instead. + * Targeted for removal in v40. */ @Deprecated @Inherited