command_tower 0.15.0 → 0.17.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (65) hide show
  1. checksums.yaml +4 -4
  2. data/app/controllers/command_tower/application_controller.rb +1 -0
  3. data/app/controllers/command_tower/me/experience_states_controller.rb +51 -0
  4. data/app/controllers/concerns/command_tower/execution/client_compatibility_boundary.rb +39 -0
  5. data/app/deserializers/command_tower/deserializers/me/experience_states/complete_deserializer.rb +52 -0
  6. data/app/errors/command_tower/errors/account/experience_states_host_unconfigured_error.rb +21 -0
  7. data/app/errors/command_tower/errors/client_update_required_error.rb +27 -0
  8. data/app/models/command_tower/user_experience_state.rb +17 -0
  9. data/app/serializers/command_tower/serializers/me/experience_states/experience_state_serializer.rb +36 -0
  10. data/app/serializers/command_tower/serializers/messaging/inbox.rb +2 -1
  11. data/app/services/command_tower/email_theme/resolver.rb +45 -0
  12. data/app/services/command_tower/messaging/notification_types/declaration.rb +14 -1
  13. data/app/services/command_tower/messaging/rendering/channel_renderer.rb +44 -13
  14. data/app/services/command_tower/messaging/rendering/inbox_document.rb +131 -0
  15. data/app/services/command_tower/messaging/rendering/inbox_document_renderer.rb +207 -0
  16. data/app/services/command_tower/messaging/rendering/template_resolver.rb +128 -0
  17. data/app/services/command_tower/services/account/experience_states/complete.rb +55 -0
  18. data/app/services/command_tower/services/account/experience_states/list.rb +28 -0
  19. data/app/services/command_tower/services/client_compatibility/evaluate.rb +219 -0
  20. data/app/services/command_tower/services/messaging/inbox.rb +8 -1
  21. data/app/views/command_tower/email_verification_mailer/verify_email.html.erb +18 -17
  22. data/app/views/command_tower/messaging/rendering/email.html.erb +6 -5
  23. data/app/views/command_tower/password_reset_mailer/reset_password.html.erb +24 -23
  24. data/app/workflows/command_tower/workflows/auth/plain_text/login_workflow.rb +3 -0
  25. data/app/workflows/command_tower/workflows/auth/session/show_workflow.rb +3 -0
  26. data/app/workflows/command_tower/workflows/client_compatibility/evaluate_workflow.rb +83 -0
  27. data/app/workflows/command_tower/workflows/client_compatibility/recommendation_meta.rb +25 -0
  28. data/app/workflows/command_tower/workflows/me/error_mapping.rb +2 -1
  29. data/app/workflows/command_tower/workflows/me/experience_states/complete_workflow.rb +49 -0
  30. data/app/workflows/command_tower/workflows/me/experience_states/list_workflow.rb +30 -0
  31. data/app/workflows/command_tower/workflows/me/experience_states/workflow_support.rb +27 -0
  32. data/config/routes.rb +3 -0
  33. data/db/migrate/20260906180000_create_user_experience_states.rb +25 -0
  34. data/docs/api_reference.md +34 -2
  35. data/docs/controllers.md +1 -0
  36. data/docs/extending.md +2 -1
  37. data/docs/host_integration_guide.md +37 -1
  38. data/docs/initializing.md +1 -0
  39. data/docs/messaging_integration_guide.md +38 -0
  40. data/docs/upgrades/0.16.0.md +30 -0
  41. data/docs/upgrades/0.17.0.md +33 -0
  42. data/docs/upgrades/README.md +2 -0
  43. data/lib/command_tower/authorization/default.yml +5 -0
  44. data/lib/command_tower/client_compatibility/version.rb +45 -0
  45. data/lib/command_tower/client_compatibility.rb +23 -0
  46. data/lib/command_tower/configuration/application/config.rb +5 -0
  47. data/lib/command_tower/configuration/config.rb +6 -0
  48. data/lib/command_tower/configuration/email_theme/config.rb +69 -0
  49. data/lib/command_tower/configuration/registry/audit/config.rb +12 -0
  50. data/lib/command_tower/configuration/registry/client_compatibility/binding_definition.rb +42 -0
  51. data/lib/command_tower/configuration/registry/client_compatibility/config.rb +353 -0
  52. data/lib/command_tower/configuration/registry/client_compatibility/contract_definition.rb +16 -0
  53. data/lib/command_tower/configuration/registry/client_compatibility/entity_requirement_definition.rb +67 -0
  54. data/lib/command_tower/configuration/registry/client_compatibility/minimum_overrides.rb +46 -0
  55. data/lib/command_tower/configuration/registry/client_compatibility/platform_definition.rb +66 -0
  56. data/lib/command_tower/configuration/registry/config.rb +14 -0
  57. data/lib/command_tower/configuration/registry/inbox_presentations/config.rb +105 -0
  58. data/lib/command_tower/configuration/registry/inbox_presentations/presentation_definition.rb +71 -0
  59. data/lib/command_tower/current.rb +1 -0
  60. data/lib/command_tower/engine.rb +4 -0
  61. data/lib/command_tower/inbox_presentations.rb +19 -0
  62. data/lib/command_tower/install/baseline.rb +1 -0
  63. data/lib/command_tower/version.rb +1 -1
  64. data/spec/factories/user_experience_states.rb +13 -0
  65. metadata +37 -2
@@ -0,0 +1,353 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "command_tower/client_compatibility"
4
+ require "command_tower/client_compatibility/version"
5
+ require "command_tower/configuration/registry/client_compatibility/platform_definition"
6
+ require "command_tower/configuration/registry/client_compatibility/entity_requirement_definition"
7
+ require "command_tower/configuration/registry/client_compatibility/binding_definition"
8
+ require "command_tower/configuration/registry/client_compatibility/contract_definition"
9
+
10
+ module CommandTower
11
+ module Configuration
12
+ module Registry
13
+ module ClientCompatibility
14
+ # Two-source registry composing platform-global policy, named client
15
+ # contracts, capability-scoped entity requirements, and host bindings.
16
+ # Mirrors the `principal_capabilities` / `admin_workspace` / `audit`
17
+ # registry pattern (CT-owned vs host-owned, `finalize!`/freeze,
18
+ # `reset_host_definitions!` for specs). Authority §5-§9.
19
+ #
20
+ # V1 ships with an empty CommandTower-owned catalog by design: no
21
+ # platform requires any contract or entity requirement out of the box.
22
+ class Config
23
+ PLATFORMS = %i[web ios android].freeze
24
+ MODES = %i[observe enforce].freeze
25
+ CONTRACT_ID = /\A[a-z][a-z0-9_]*(\.[a-z0-9][a-z0-9_]*)*\z/
26
+
27
+ # CommandTower-owned seed catalog. Deliberately empty for V1 — see
28
+ # authority §7.5. Do not seed placeholder/fictional contracts here.
29
+ PLATFORM_CONTRACTS = [].freeze
30
+ PLATFORM_ENTITY_REQUIREMENTS = {}.freeze
31
+
32
+ attr_reader :mode
33
+
34
+ def initialize
35
+ @mode = :observe
36
+ @platforms = {}
37
+ @contracts = {}
38
+ @entity_requirements = {}
39
+ @bindings = {}
40
+ @finalized = false
41
+ seed_platform_catalog!
42
+ end
43
+
44
+ def mode=(value)
45
+ raise_if_finalized!
46
+ normalized = value.to_s.to_sym
47
+ unless MODES.include?(normalized)
48
+ raise CommandTower::ClientCompatibility::InvalidModeError,
49
+ "client_compatibility mode must be one of #{MODES.inspect}, got #{value.inspect}"
50
+ end
51
+
52
+ @mode = normalized
53
+ end
54
+
55
+ def platform(name)
56
+ raise_if_finalized!
57
+ key = normalize_platform!(name)
58
+ definition = @platforms[key] || PlatformDefinition.new
59
+ yield definition if block_given?
60
+ definition.validate_definition!(platform: key)
61
+ @platforms[key] = definition
62
+ definition
63
+ end
64
+
65
+ def configured_platforms
66
+ @platforms.keys.dup.freeze
67
+ end
68
+
69
+ def platform_policy(name)
70
+ @platforms[name.to_s.strip.downcase.to_sym]
71
+ end
72
+
73
+ def client_contract(name, owner: :host)
74
+ raise_if_finalized!
75
+ normalized = normalize_contract_id!(name)
76
+ existing = @contracts[normalized]
77
+ if existing
78
+ if existing.owner == :command_tower && owner.to_sym == :host
79
+ raise CommandTower::ClientCompatibility::HostOverrideError,
80
+ "host cannot redefine CommandTower-owned client contract #{normalized}"
81
+ end
82
+
83
+ raise CommandTower::ClientCompatibility::DuplicateContractError,
84
+ "client contract #{normalized} is already registered"
85
+ end
86
+
87
+ definition = ContractDefinition.new
88
+ definition.id = normalized
89
+ definition.owner = owner.to_sym
90
+ @contracts[normalized] = definition
91
+ definition
92
+ end
93
+
94
+ def contract_registered?(name)
95
+ @contracts.key?(normalize_contract_id!(name))
96
+ rescue CommandTower::ClientCompatibility::InvalidContractIdError
97
+ false
98
+ end
99
+
100
+ def bind(contract_id)
101
+ raise_if_finalized!
102
+ normalized = normalize_contract_id!(contract_id)
103
+ unless @contracts.key?(normalized)
104
+ raise CommandTower::ClientCompatibility::UnknownClientContractError,
105
+ "cannot bind unknown client contract #{normalized}"
106
+ end
107
+
108
+ if @bindings.key?(normalized)
109
+ raise CommandTower::ClientCompatibility::DuplicateBindingError,
110
+ "client contract #{normalized} is already bound"
111
+ end
112
+
113
+ definition = BindingDefinition.new
114
+ yield definition if block_given?
115
+ definition.validate!(contract_id: normalized)
116
+ @bindings[normalized] = definition
117
+ definition
118
+ end
119
+
120
+ def binding_for(contract_id)
121
+ @bindings[normalize_contract_id!(contract_id)]
122
+ rescue CommandTower::ClientCompatibility::InvalidContractIdError
123
+ nil
124
+ end
125
+
126
+ def entity(name, owner: :host)
127
+ raise_if_finalized!
128
+ normalized = normalize_entity_name!(name)
129
+ existing = @entity_requirements[normalized]
130
+ if existing
131
+ if existing.owner == :command_tower && owner.to_sym == :host
132
+ raise CommandTower::ClientCompatibility::HostOverrideError,
133
+ "host cannot redefine CommandTower-owned client compatibility entity requirement #{normalized}"
134
+ end
135
+
136
+ raise CommandTower::ClientCompatibility::DuplicateEntityRequirementError,
137
+ "client compatibility entity requirement #{normalized} is already registered"
138
+ end
139
+
140
+ definition = EntityRequirementDefinition.new
141
+ yield definition if block_given?
142
+ definition.owner = owner
143
+ definition.validate_definition!(name: normalized)
144
+ @entity_requirements[normalized] = definition
145
+ definition
146
+ end
147
+
148
+ def entity_requirement(name)
149
+ @entity_requirements[name.to_s]
150
+ end
151
+
152
+ def registered_entity_requirement?(name)
153
+ @entity_requirements.key?(name.to_s)
154
+ end
155
+
156
+ # Whether the host has meaningfully engaged this registry at all.
157
+ # A completely untouched registry (no platforms, no host contracts,
158
+ # no host entity requirements, no bindings, default observe mode)
159
+ # is inert: evaluation is a no-op and boot MUST NOT fail on it.
160
+ def opted_in?
161
+ @mode == :enforce ||
162
+ @platforms.any? ||
163
+ @contracts.any? { |_id, definition| definition.owner == :host } ||
164
+ @entity_requirements.any? { |_name, definition| definition.owner == :host } ||
165
+ @bindings.any?
166
+ end
167
+
168
+ def finalize!
169
+ (@platforms.values + @entity_requirements.values + @bindings.values).each do |definition|
170
+ next unless definition.respond_to?(:class_composer_freeze_objects!)
171
+
172
+ definition.class_composer_freeze_objects!(behavior: :raise, children: true)
173
+ end
174
+ @platforms.freeze
175
+ @contracts.freeze
176
+ @entity_requirements.freeze
177
+ @bindings.freeze
178
+ @finalized = true
179
+ self
180
+ end
181
+
182
+ def finalized?
183
+ @finalized
184
+ end
185
+
186
+ # Boot-time fail-closed validation (authority §9). No-op unless the
187
+ # host has opted in (see `opted_in?`).
188
+ def validate!(entities)
189
+ return self unless opted_in?
190
+
191
+ if @platforms.empty?
192
+ raise CommandTower::ClientCompatibility::NoConfiguredPlatformsError,
193
+ "client_compatibility is configured but declares zero platforms"
194
+ end
195
+
196
+ validate_entity_requirements!(entities)
197
+ validate_bindings!
198
+ self
199
+ end
200
+
201
+ # Process-level ENV overlays (never the client's own headers).
202
+ # `COMMAND_TOWER_CLIENT_COMPATIBILITY_MODE` may set observe/enforce.
203
+ # `COMMAND_TOWER_CLIENT_COMPATIBILITY_MINIMUM_{WEB,IOS,ANDROID}` may
204
+ # only RAISE an already-configured platform's minimum — never lower
205
+ # it, and never configure a platform the host did not enable.
206
+ def apply_env_overlay!(env: ENV)
207
+ raise_if_finalized!
208
+
209
+ overlay_mode = env["COMMAND_TOWER_CLIENT_COMPATIBILITY_MODE"]
210
+ self.mode = overlay_mode if overlay_mode.present?
211
+
212
+ PLATFORMS.each { |platform| apply_env_minimum_overlay!(platform, env:) }
213
+
214
+ self
215
+ end
216
+
217
+ def reset_host_definitions!
218
+ thaw_for_test!
219
+ @platforms = {}
220
+ @contracts.delete_if { |_id, definition| definition.owner == :host }
221
+ @entity_requirements.delete_if { |_name, definition| definition.owner == :host }
222
+ @bindings = {}
223
+ @mode = :observe
224
+ self
225
+ end
226
+
227
+ private
228
+
229
+ def apply_env_minimum_overlay!(platform, env:)
230
+ raw = env[env_minimum_key(platform)]
231
+ return if raw.blank?
232
+
233
+ definition = @platforms[platform]
234
+ return if definition.nil? # ENV cannot configure a platform the host never enabled
235
+
236
+ normalized = CommandTower::ClientCompatibility::Version.normalize(raw)
237
+ if normalized.nil?
238
+ raise CommandTower::ClientCompatibility::InvalidVersionError,
239
+ "ENV #{env_minimum_key(platform)} has invalid version #{raw.inspect}"
240
+ end
241
+
242
+ definition.minimum = normalized if Gem::Version.new(normalized) > Gem::Version.new(definition.minimum)
243
+ end
244
+
245
+ def env_minimum_key(platform)
246
+ "COMMAND_TOWER_CLIENT_COMPATIBILITY_MINIMUM_#{platform.to_s.upcase}"
247
+ end
248
+
249
+ def seed_platform_catalog!
250
+ PLATFORM_CONTRACTS.each { |name| client_contract(name, owner: :command_tower) }
251
+ PLATFORM_ENTITY_REQUIREMENTS.each do |name, contract|
252
+ entity(name, owner: :command_tower) { |requirement| requirement.client_contract = contract }
253
+ end
254
+ end
255
+
256
+ def validate_entity_requirements!(entities)
257
+ @entity_requirements.each do |name, definition|
258
+ unless entities.key?(name)
259
+ raise CommandTower::ClientCompatibility::UnknownEntityError,
260
+ "client compatibility entity requirement references unknown RBAC entity #{name}"
261
+ end
262
+
263
+ if definition.client_contract.present?
264
+ unless @contracts.key?(definition.client_contract)
265
+ raise CommandTower::ClientCompatibility::UnknownClientContractError,
266
+ "entity #{name} requires unknown client contract #{definition.client_contract}"
267
+ end
268
+
269
+ next
270
+ end
271
+
272
+ validate_direct_minimum_floor!(name:, definition:)
273
+ end
274
+ end
275
+
276
+ def validate_direct_minimum_floor!(name:, definition:)
277
+ @platforms.each do |platform, policy|
278
+ direct = definition.minimum.for_platform(platform)
279
+ next if direct.blank?
280
+
281
+ if Gem::Version.new(direct) < Gem::Version.new(policy.minimum)
282
+ raise CommandTower::ClientCompatibility::ConflictingRequirementError,
283
+ "entity #{name} minimum.#{platform} #{direct} is lower than platform-global minimum #{policy.minimum}"
284
+ end
285
+ end
286
+ end
287
+
288
+ def validate_bindings!
289
+ referenced_contract_ids.each do |contract_id|
290
+ binding = @bindings[contract_id]
291
+ @platforms.each_key do |platform|
292
+ next if binding && binding.for_platform(platform).present?
293
+
294
+ raise CommandTower::ClientCompatibility::UnboundClientContractError,
295
+ "client contract #{contract_id} has no host binding for configured platform #{platform}"
296
+ end
297
+ end
298
+ end
299
+
300
+ def referenced_contract_ids
301
+ @entity_requirements.values.filter_map { |definition| definition.client_contract }.uniq
302
+ end
303
+
304
+ def normalize_platform!(name)
305
+ key = name.to_s.strip.downcase.to_sym
306
+ unless PLATFORMS.include?(key)
307
+ raise CommandTower::ClientCompatibility::InvalidPlatformError,
308
+ "unknown canonical platform #{name.inspect}; must be one of #{PLATFORMS.inspect}"
309
+ end
310
+
311
+ key
312
+ end
313
+
314
+ def normalize_contract_id!(name)
315
+ token = name.to_s.strip
316
+ unless token.match?(CONTRACT_ID)
317
+ raise CommandTower::ClientCompatibility::InvalidContractIdError,
318
+ "invalid client contract id #{name.inspect}"
319
+ end
320
+
321
+ token
322
+ end
323
+
324
+ def normalize_entity_name!(name)
325
+ token = name.to_s.strip
326
+ unless token.match?(CommandTower::Events::SEGMENT)
327
+ raise CommandTower::ClientCompatibility::InvalidEntityNameError,
328
+ "invalid client compatibility entity name #{name.inspect}"
329
+ end
330
+
331
+ token
332
+ end
333
+
334
+ def raise_if_finalized!
335
+ return unless @finalized
336
+
337
+ raise CommandTower::ClientCompatibility::FrozenRegistryError, "client compatibility registry is frozen"
338
+ end
339
+
340
+ def thaw_for_test!
341
+ return unless @finalized
342
+
343
+ @platforms = @platforms.dup
344
+ @contracts = @contracts.dup
345
+ @entity_requirements = @entity_requirements.dup
346
+ @bindings = @bindings.dup
347
+ @finalized = false
348
+ end
349
+ end
350
+ end
351
+ end
352
+ end
353
+ end
@@ -0,0 +1,16 @@
1
+ # frozen_string_literal: true
2
+
3
+ module CommandTower
4
+ module Configuration
5
+ module Registry
6
+ module ClientCompatibility
7
+ # A named client contract: a CT-owned or host-owned identity that
8
+ # per-platform host-app versions are bound to. Authority §7. V1 ships
9
+ # with an empty CT-owned catalog by design — no placeholder contracts.
10
+ class ContractDefinition
11
+ attr_accessor :owner, :id
12
+ end
13
+ end
14
+ end
15
+ end
16
+ end
@@ -0,0 +1,67 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "class_composer"
4
+ require "command_tower/configuration/registry/client_compatibility/minimum_overrides"
5
+
6
+ module CommandTower
7
+ module Configuration
8
+ module Registry
9
+ module ClientCompatibility
10
+ # Capability-scoped version requirement bound to an existing RBAC entity.
11
+ # Authority §6. CommandTower-owned entity requirements MUST reference a
12
+ # named `client_contract`; host-owned entity requirements may reference a
13
+ # `client_contract` OR set direct per-platform minima, never both.
14
+ class EntityRequirementDefinition
15
+ include ClassComposer::Generator
16
+
17
+ add_composer :client_contract,
18
+ desc: "Named CT-owned client contract this entity requirement is bound to",
19
+ allowed: [String, Symbol, NilClass],
20
+ default: nil
21
+
22
+ attr_accessor :owner, :id
23
+ attr_reader :minimum
24
+
25
+ def initialize
26
+ @minimum = MinimumOverrides.new
27
+ end
28
+
29
+ def validate_definition!(name:)
30
+ @id = name.to_s
31
+ self.owner = owner&.to_sym || :host
32
+
33
+ contract_present = client_contract.present?
34
+ direct_present = minimum.any_set?
35
+
36
+ if contract_present && direct_present
37
+ raise CommandTower::ClientCompatibility::ConflictingRequirementError,
38
+ "entity requirement #{name} MUST NOT set both client_contract and a direct minimum"
39
+ end
40
+
41
+ if owner == :command_tower && !contract_present
42
+ raise CommandTower::ClientCompatibility::ConflictingRequirementError,
43
+ "CommandTower-owned entity requirement #{name} MUST declare a client_contract, not a direct host version"
44
+ end
45
+
46
+ self.client_contract = normalize_contract_id!(client_contract, name:) if contract_present
47
+ minimum.validate!(name:) if direct_present
48
+
49
+ self
50
+ end
51
+
52
+ private
53
+
54
+ def normalize_contract_id!(value, name:)
55
+ token = value.to_s.strip
56
+ unless token.match?(CommandTower::Configuration::Registry::ClientCompatibility::Config::CONTRACT_ID)
57
+ raise CommandTower::ClientCompatibility::InvalidContractIdError,
58
+ "entity requirement #{name} has invalid client_contract #{value.inspect}"
59
+ end
60
+
61
+ token
62
+ end
63
+ end
64
+ end
65
+ end
66
+ end
67
+ end
@@ -0,0 +1,46 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "class_composer"
4
+
5
+ module CommandTower
6
+ module Configuration
7
+ module Registry
8
+ module ClientCompatibility
9
+ # Direct per-platform minimum overrides on a host-owned entity requirement
10
+ # (mutually exclusive with a `client_contract` reference). Authority §6.
11
+ class MinimumOverrides
12
+ include ClassComposer::Generator
13
+
14
+ add_composer :web, desc: "Direct minimum for web", allowed: [String, NilClass], default: nil
15
+ add_composer :ios, desc: "Direct minimum for ios", allowed: [String, NilClass], default: nil
16
+ add_composer :android, desc: "Direct minimum for android", allowed: [String, NilClass], default: nil
17
+
18
+ def any_set?
19
+ [web, ios, android].any?(&:present?)
20
+ end
21
+
22
+ def for_platform(platform)
23
+ public_send(platform)
24
+ end
25
+
26
+ def validate!(name:)
27
+ %i[web ios android].each do |platform|
28
+ value = public_send(platform)
29
+ next if value.blank?
30
+
31
+ normalized = CommandTower::ClientCompatibility::Version.normalize(value)
32
+ if normalized.nil?
33
+ raise CommandTower::ClientCompatibility::InvalidVersionError,
34
+ "entity requirement #{name} has invalid minimum.#{platform} #{value.inspect}"
35
+ end
36
+
37
+ public_send("#{platform}=", normalized)
38
+ end
39
+
40
+ self
41
+ end
42
+ end
43
+ end
44
+ end
45
+ end
46
+ end
@@ -0,0 +1,66 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "class_composer"
4
+
5
+ module CommandTower
6
+ module Configuration
7
+ module Registry
8
+ module ClientCompatibility
9
+ # Host-owned platform-global policy for one canonical platform
10
+ # (`web`, `ios`, or `android`). Authority §5.
11
+ class PlatformDefinition
12
+ include ClassComposer::Generator
13
+
14
+ add_composer :minimum,
15
+ desc: "Platform-global minimum supported app version (MAJOR.MINOR.PATCH[.prerelease])",
16
+ allowed: [String, NilClass],
17
+ default: nil
18
+
19
+ add_composer :recommended,
20
+ desc: "Platform-global recommended app version, surfaced as non-blocking guidance",
21
+ allowed: [String, NilClass],
22
+ default: nil
23
+
24
+ add_composer :update_url,
25
+ desc: "Store/update destination surfaced on hard-incompatibility recovery (native platforms only)",
26
+ allowed: [String, NilClass],
27
+ default: nil
28
+
29
+ attr_reader :platform
30
+
31
+ def validate_definition!(platform:)
32
+ @platform = platform.to_sym
33
+ self.minimum = require_version!(minimum, field: "minimum")
34
+
35
+ if recommended.present?
36
+ self.recommended = require_version!(recommended, field: "recommended")
37
+ if Gem::Version.new(recommended) < Gem::Version.new(minimum)
38
+ raise CommandTower::ClientCompatibility::ConflictingRequirementError,
39
+ "platform #{@platform} recommended #{recommended} is lower than minimum #{minimum}"
40
+ end
41
+ end
42
+
43
+ if @platform == :web && update_url.present?
44
+ raise CommandTower::ClientCompatibility::InvalidPlatformError,
45
+ "platform web MUST NOT configure update_url (web recovery is reload, not an app store)"
46
+ end
47
+
48
+ self
49
+ end
50
+
51
+ private
52
+
53
+ def require_version!(value, field:)
54
+ normalized = CommandTower::ClientCompatibility::Version.normalize(value)
55
+ if normalized.nil?
56
+ raise CommandTower::ClientCompatibility::InvalidVersionError,
57
+ "platform #{@platform} has invalid #{field} #{value.inspect}"
58
+ end
59
+
60
+ normalized
61
+ end
62
+ end
63
+ end
64
+ end
65
+ end
66
+ end
@@ -4,6 +4,8 @@ require "class_composer"
4
4
  require "command_tower/configuration/registry/audit/config"
5
5
  require "command_tower/configuration/registry/admin_workspace/config"
6
6
  require "command_tower/configuration/registry/principal_capabilities/config"
7
+ require "command_tower/configuration/registry/client_compatibility/config"
8
+ require "command_tower/configuration/registry/inbox_presentations/config"
7
9
 
8
10
  module CommandTower
9
11
  module Configuration
@@ -28,6 +30,18 @@ module CommandTower
28
30
  allowed: PrincipalCapabilities::Config,
29
31
  dynamic_default: ->(_) { PrincipalCapabilities::Config.new },
30
32
  default_shown: "PrincipalCapabilities::Config.new"
33
+
34
+ add_composer :client_compatibility,
35
+ desc: "Registered client-version-compatibility policy (platforms, contracts, entity requirements, bindings)",
36
+ allowed: ClientCompatibility::Config,
37
+ dynamic_default: ->(_) { ClientCompatibility::Config.new },
38
+ default_shown: "ClientCompatibility::Config.new"
39
+
40
+ add_composer :inbox_presentations,
41
+ desc: "Registered Inbox presentation capabilities (host-owned; no CommandTower-owned capabilities yet)",
42
+ allowed: InboxPresentations::Config,
43
+ dynamic_default: ->(_) { InboxPresentations::Config.new },
44
+ default_shown: "InboxPresentations::Config.new"
31
45
  end
32
46
  end
33
47
  end
@@ -0,0 +1,105 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "command_tower/inbox_presentations"
4
+ require "command_tower/configuration/registry/inbox_presentations/presentation_definition"
5
+
6
+ module CommandTower
7
+ module Configuration
8
+ module Registry
9
+ module InboxPresentations
10
+ # No CommandTower-owned presentations are seeded — no PLATFORM_*
11
+ # constant, unlike AdminWorkspace/PrincipalCapabilities. Every
12
+ # registered key today is host-owned (Rich Messaging Slice 2.5).
13
+ class Config
14
+ KEY_FORMAT = /\A[a-z][a-z0-9]*(?:[._][a-z][a-z0-9]*)*\z/
15
+
16
+ def initialize
17
+ @definitions = {}
18
+ @finalized = false
19
+ end
20
+
21
+ def presentation(key, owner: :host)
22
+ if @finalized
23
+ raise CommandTower::InboxPresentations::FrozenRegistryError,
24
+ "inbox presentations registry is frozen"
25
+ end
26
+
27
+ normalized = normalize_key!(key)
28
+ if @definitions.key?(normalized)
29
+ raise CommandTower::InboxPresentations::DuplicateCapabilityError,
30
+ "inbox presentation #{normalized} is already registered"
31
+ end
32
+
33
+ definition = PresentationDefinition.new
34
+ yield definition if block_given?
35
+ definition.owner = owner
36
+ definition.validate_definition!(key: normalized)
37
+ @definitions[normalized] = definition
38
+ definition
39
+ end
40
+
41
+ def fetch(key)
42
+ normalized = normalize_key!(key)
43
+ definition = @definitions[normalized]
44
+ if definition.nil?
45
+ raise CommandTower::InboxPresentations::UnregisteredCapabilityError,
46
+ "inbox presentation #{normalized} is not registered"
47
+ end
48
+
49
+ definition
50
+ end
51
+
52
+ def registered?(key)
53
+ @definitions.key?(normalize_key!(key))
54
+ rescue CommandTower::InboxPresentations::InvalidKeyError
55
+ false
56
+ end
57
+
58
+ def definitions
59
+ @definitions.dup.freeze
60
+ end
61
+
62
+ def finalize!
63
+ @definitions.each_value do |definition|
64
+ next unless definition.respond_to?(:class_composer_freeze_objects!)
65
+
66
+ definition.class_composer_freeze_objects!(behavior: :raise, children: true)
67
+ end
68
+ @definitions.freeze
69
+ @finalized = true
70
+ self
71
+ end
72
+
73
+ def finalized?
74
+ @finalized
75
+ end
76
+
77
+ def reset_host_definitions!
78
+ thaw_for_test!
79
+ @definitions.delete_if { |_key, definition| definition.owner == :host }
80
+ self
81
+ end
82
+
83
+ private
84
+
85
+ def thaw_for_test!
86
+ return unless @finalized || @definitions.frozen?
87
+
88
+ @definitions = @definitions.dup
89
+ @finalized = false
90
+ end
91
+
92
+ def normalize_key!(key)
93
+ token = key.to_s.strip
94
+ unless token.match?(KEY_FORMAT)
95
+ raise CommandTower::InboxPresentations::InvalidKeyError,
96
+ "invalid inbox presentation key #{key.inspect}"
97
+ end
98
+
99
+ token
100
+ end
101
+ end
102
+ end
103
+ end
104
+ end
105
+ end