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.
- checksums.yaml +4 -4
- data/app/controllers/command_tower/application_controller.rb +1 -0
- data/app/controllers/command_tower/me/experience_states_controller.rb +51 -0
- data/app/controllers/concerns/command_tower/execution/client_compatibility_boundary.rb +39 -0
- data/app/deserializers/command_tower/deserializers/me/experience_states/complete_deserializer.rb +52 -0
- data/app/errors/command_tower/errors/account/experience_states_host_unconfigured_error.rb +21 -0
- data/app/errors/command_tower/errors/client_update_required_error.rb +27 -0
- data/app/models/command_tower/user_experience_state.rb +17 -0
- data/app/serializers/command_tower/serializers/me/experience_states/experience_state_serializer.rb +36 -0
- data/app/serializers/command_tower/serializers/messaging/inbox.rb +2 -1
- data/app/services/command_tower/email_theme/resolver.rb +45 -0
- data/app/services/command_tower/messaging/notification_types/declaration.rb +14 -1
- data/app/services/command_tower/messaging/rendering/channel_renderer.rb +44 -13
- data/app/services/command_tower/messaging/rendering/inbox_document.rb +131 -0
- data/app/services/command_tower/messaging/rendering/inbox_document_renderer.rb +207 -0
- data/app/services/command_tower/messaging/rendering/template_resolver.rb +128 -0
- data/app/services/command_tower/services/account/experience_states/complete.rb +55 -0
- data/app/services/command_tower/services/account/experience_states/list.rb +28 -0
- data/app/services/command_tower/services/client_compatibility/evaluate.rb +219 -0
- data/app/services/command_tower/services/messaging/inbox.rb +8 -1
- data/app/views/command_tower/email_verification_mailer/verify_email.html.erb +18 -17
- data/app/views/command_tower/messaging/rendering/email.html.erb +6 -5
- data/app/views/command_tower/password_reset_mailer/reset_password.html.erb +24 -23
- data/app/workflows/command_tower/workflows/auth/plain_text/login_workflow.rb +3 -0
- data/app/workflows/command_tower/workflows/auth/session/show_workflow.rb +3 -0
- data/app/workflows/command_tower/workflows/client_compatibility/evaluate_workflow.rb +83 -0
- data/app/workflows/command_tower/workflows/client_compatibility/recommendation_meta.rb +25 -0
- data/app/workflows/command_tower/workflows/me/error_mapping.rb +2 -1
- data/app/workflows/command_tower/workflows/me/experience_states/complete_workflow.rb +49 -0
- data/app/workflows/command_tower/workflows/me/experience_states/list_workflow.rb +30 -0
- data/app/workflows/command_tower/workflows/me/experience_states/workflow_support.rb +27 -0
- data/config/routes.rb +3 -0
- data/db/migrate/20260906180000_create_user_experience_states.rb +25 -0
- data/docs/api_reference.md +34 -2
- data/docs/controllers.md +1 -0
- data/docs/extending.md +2 -1
- data/docs/host_integration_guide.md +37 -1
- data/docs/initializing.md +1 -0
- data/docs/messaging_integration_guide.md +38 -0
- data/docs/upgrades/0.16.0.md +30 -0
- data/docs/upgrades/0.17.0.md +33 -0
- data/docs/upgrades/README.md +2 -0
- data/lib/command_tower/authorization/default.yml +5 -0
- data/lib/command_tower/client_compatibility/version.rb +45 -0
- data/lib/command_tower/client_compatibility.rb +23 -0
- data/lib/command_tower/configuration/application/config.rb +5 -0
- data/lib/command_tower/configuration/config.rb +6 -0
- data/lib/command_tower/configuration/email_theme/config.rb +69 -0
- data/lib/command_tower/configuration/registry/audit/config.rb +12 -0
- data/lib/command_tower/configuration/registry/client_compatibility/binding_definition.rb +42 -0
- data/lib/command_tower/configuration/registry/client_compatibility/config.rb +353 -0
- data/lib/command_tower/configuration/registry/client_compatibility/contract_definition.rb +16 -0
- data/lib/command_tower/configuration/registry/client_compatibility/entity_requirement_definition.rb +67 -0
- data/lib/command_tower/configuration/registry/client_compatibility/minimum_overrides.rb +46 -0
- data/lib/command_tower/configuration/registry/client_compatibility/platform_definition.rb +66 -0
- data/lib/command_tower/configuration/registry/config.rb +14 -0
- data/lib/command_tower/configuration/registry/inbox_presentations/config.rb +105 -0
- data/lib/command_tower/configuration/registry/inbox_presentations/presentation_definition.rb +71 -0
- data/lib/command_tower/current.rb +1 -0
- data/lib/command_tower/engine.rb +4 -0
- data/lib/command_tower/inbox_presentations.rb +19 -0
- data/lib/command_tower/install/baseline.rb +1 -0
- data/lib/command_tower/version.rb +1 -1
- data/spec/factories/user_experience_states.rb +13 -0
- 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
|
data/lib/command_tower/configuration/registry/client_compatibility/entity_requirement_definition.rb
ADDED
|
@@ -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
|