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,207 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "uri"
4
+
5
+ module CommandTower
6
+ module Messaging
7
+ module Rendering
8
+ # Internal Messaging::Rendering collaborator (peer of `ChannelRenderer`,
9
+ # not a host-facing service). Builds the `InboxDocument` shown at Inbox
10
+ # detail read time.
11
+ #
12
+ # Generic path: one `paragraph` block from `communication.body`, plus an
13
+ # optional `cta` block with a typed `destination` — internal
14
+ # (`metadata["deep_link_path"]`, origin-free, host-navigation-owned) if
15
+ # present and safe, else external (`metadata["deep_link"]`) if that is
16
+ # a safe href (D-INBOX-GENERIC; typed destination added Slice 2.6.4).
17
+ #
18
+ # Optional per-`notification_type_key` override: a pure-Ruby composer
19
+ # class (`declaration.inbox_document_composer`, resolved via
20
+ # `NotificationTypes.lookup` + `.constantize`), `.call(communication:) ->
21
+ # InboxDocument`. Any failure (unregistered type, absent composer,
22
+ # unresolvable class name, a raising composer, or a composer returning
23
+ # something other than an `InboxDocument`) fails open to the generic
24
+ # document — the Inbox read path must never 500 on a bad type composer
25
+ # (D-INBOX-TYPE). This replaces the Rich Messaging Campaign 2 ERB/JSON
26
+ # authoring path entirely — see
27
+ # artifacts/visual-improvements/rich-messaging/authority/RICH_MESSAGING_RUBY_INBOX_DEFINITION_DISCOVERY.md.
28
+ #
29
+ # After the document (type-specific or generic) is built, any
30
+ # `"presentation"` block is resolved against
31
+ # `CommandTower.config.registry.inbox_presentations` — at most one
32
+ # attempted/resolved presentation capability per document (Rich
33
+ # Messaging Slice 2.5). The budget is consumed the instant a
34
+ # `"presentation"`-typed block is seen, before lookup or composition,
35
+ # regardless of outcome, so a malformed/unregistered first node cannot
36
+ # fail open and then let a second node still execute composer domain
37
+ # I/O.
38
+ #
39
+ # The composer's raw result is always projected down to the
40
+ # registration's declared `output_keys` before it reaches
41
+ # `content.blocks[].data` (corrective slice — presentation
42
+ # ServiceResult/context leak fix). Composer inputs and any other
43
+ # incidental context/result keys never reach the wire, regardless of
44
+ # whether the composer is `ApplicationService`-shaped or a plain Hash.
45
+ class InboxDocumentRenderer
46
+ UNSAFE_SCHEMES = %w[javascript data vbscript].freeze
47
+ DEFAULT_CTA_LABEL = "Open"
48
+
49
+ def self.render(communication:, recipient_id:)
50
+ new(communication:, recipient_id:).render
51
+ end
52
+
53
+ def initialize(communication:, recipient_id:)
54
+ @communication = communication
55
+ @recipient_id = recipient_id
56
+ @presentation_attempted = false
57
+ end
58
+
59
+ def render
60
+ document = build_from_type_composer || build_generic
61
+
62
+ resolve_presentation_blocks(document)
63
+ end
64
+
65
+ private
66
+
67
+ attr_reader :communication, :recipient_id
68
+
69
+ def build_from_type_composer
70
+ declaration = CommandTower::Messaging::NotificationTypes.lookup(communication.notification_type_key)
71
+ composer_name = declaration.inbox_document_composer
72
+ return nil if composer_name.nil?
73
+
74
+ document = composer_name.constantize.call(communication:)
75
+ document.is_a?(InboxDocument) ? document : nil
76
+ rescue StandardError
77
+ # Fail-open: unregistered type, unresolvable class name, a raising
78
+ # composer, or a composer returning invalid data must never surface
79
+ # as a 500 on the Inbox read path (D-INBOX-TYPE).
80
+ nil
81
+ end
82
+
83
+ def build_generic
84
+ blocks = []
85
+ blocks << paragraph_block(communication.body) if communication.body.present?
86
+ cta = generic_cta_block
87
+ blocks << cta if cta
88
+
89
+ InboxDocument.build(blocks:)
90
+ end
91
+
92
+ # Re-walks the already-validated blocks array (produced either by a
93
+ # trusted Ruby composer via `InboxDocument::Builder` or by
94
+ # `build_generic`), resolving any `"presentation"` node in place.
95
+ # There is no untrusted JSON to parse/whitelist here — every block was
96
+ # already constructed via a whitelisted `Builder` method or this
97
+ # class's own `paragraph_block`/`cta_block` helpers.
98
+ def resolve_presentation_blocks(document)
99
+ resolved = document.blocks.map do |block|
100
+ block[:type] == "presentation" ? presentation_block(block) : block
101
+ end.compact
102
+
103
+ InboxDocument.build(blocks: resolved)
104
+ end
105
+
106
+ def presentation_block(block)
107
+ return nil if @presentation_attempted
108
+
109
+ @presentation_attempted = true
110
+ key = block[:key]
111
+ variant = block[:variant]
112
+
113
+ definition = CommandTower.config.registry.inbox_presentations.fetch(key)
114
+ composer_result = definition.composer_class.constantize.call(communication:, recipient_id:)
115
+
116
+ # Duck-typed: a registered composer may be a plain class returning a
117
+ # Hash directly, or a `CommandTower::Services::ApplicationService`
118
+ # (whose `.call` always returns a `ServiceResult`) — either way the
119
+ # unwrapped result below is a plain Hash (Rich Messaging Presentation
120
+ # Authority's composer contract).
121
+ if composer_result.respond_to?(:success?)
122
+ return nil unless composer_result.success?
123
+
124
+ raw_data = composer_result.data
125
+ else
126
+ raw_data = composer_result
127
+ end
128
+
129
+ # Explicit output projection (corrective slice — presentation
130
+ # ServiceResult/context leak fix): only the keys the presentation's
131
+ # own registration declares in `output_keys` ever reach
132
+ # `content.blocks[].data`. This is a declared allow-list owned by
133
+ # the registration, not an inference from `ApplicationService`
134
+ # argument metadata — composer inputs (e.g. `communication`,
135
+ # `recipient_id` on an `ApplicationService`-shaped composer),
136
+ # incidental intermediate context/result keys, and anything else
137
+ # are dropped unconditionally here, independent of composer shape.
138
+ { type: "presentation", key:, variant:, data: raw_data.to_h.slice(*definition.output_keys) }.freeze
139
+ rescue StandardError
140
+ # Fail-open: unregistered capability, unresolvable composer class,
141
+ # or a raising/failing composer must never surface as a 500 on the
142
+ # Inbox read path, and must never allow a subsequent presentation
143
+ # block in the same document to still attempt composition (the
144
+ # budget above is consumed regardless of this outcome).
145
+ nil
146
+ end
147
+
148
+ def paragraph_block(text)
149
+ return nil unless text.is_a?(String)
150
+ return nil if text.empty?
151
+
152
+ { type: "paragraph", text: }.freeze
153
+ end
154
+
155
+ def generic_cta_block
156
+ cta_block(
157
+ label: metadata_value("cta_label"),
158
+ path: metadata_value("deep_link_path"),
159
+ href: metadata_value("deep_link")
160
+ )
161
+ end
162
+
163
+ # Internal (`path`) takes precedence over external (`href`) when
164
+ # both are present — a `cta` only ever means one navigation intent
165
+ # (Slice 2.6.4 typed destination; see artifacts/visual-improvements/
166
+ # rich-messaging/plans/2.6.4-ct-fe-inbox-navigation-capability.md).
167
+ def cta_block(label:, path: nil, href: nil)
168
+ destination = cta_destination(path:, href:)
169
+ return nil unless destination
170
+
171
+ resolved_label = label.is_a?(String) && !label.strip.empty? ? label : DEFAULT_CTA_LABEL
172
+ { type: "cta", label: resolved_label, destination: }.freeze
173
+ end
174
+
175
+ def cta_destination(path:, href:)
176
+ if safe_internal_path?(path)
177
+ { kind: "internal", path: }
178
+ elsif safe_href?(href)
179
+ { kind: "external", href: }
180
+ end
181
+ end
182
+
183
+ # A leading `//` is rejected even though it "starts with a slash" —
184
+ # it is a protocol-relative URL, not a safe same-app-only destination.
185
+ def safe_internal_path?(path)
186
+ path.is_a?(String) && path.start_with?("/") && !path.start_with?("//")
187
+ end
188
+
189
+ def safe_href?(href)
190
+ return false unless href.is_a?(String) && !href.strip.empty?
191
+
192
+ scheme = URI.parse(href).scheme&.downcase
193
+ scheme.present? && UNSAFE_SCHEMES.exclude?(scheme)
194
+ rescue URI::InvalidURIError
195
+ false
196
+ end
197
+
198
+ def metadata_value(key)
199
+ metadata = communication.metadata
200
+ return nil unless metadata.is_a?(Hash)
201
+
202
+ metadata[key] || metadata[key.to_sym]
203
+ end
204
+ end
205
+ end
206
+ end
207
+ end
@@ -0,0 +1,128 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "action_view"
4
+
5
+ module CommandTower
6
+ module Messaging
7
+ module Rendering
8
+ # Internal Execution-pipeline collaborator (not a Service/Workflow/Controller).
9
+ #
10
+ # Resolves one rendered-destination basename (e.g. "email", "sms") to an
11
+ # ActionView template, preferring a `notification_type_key`-specific view
12
+ # over the generic one, and preferring the host application's `app/views`
13
+ # over CommandTower's own engine views for either.
14
+ #
15
+ # Host convention (documentation, not code):
16
+ #
17
+ # app/views/command_tower/messaging/rendering/
18
+ # email.html.erb # optional host override of the generic chrome
19
+ # <notification_type_key>/email.html.erb # optional type-specific override
20
+ #
21
+ # Hosts customize by dropping ERB files at that path; they never call this
22
+ # class directly. `ChannelRenderer` remains the only public Ruby entry into
23
+ # rendering. `prepend_view_path`/`reset_view_paths!` exist solely as a
24
+ # messaging spec fixture seam (mirrors Rails' own `prepend_view_path`
25
+ # testing idiom) and are not part of any host-facing contract.
26
+ class TemplateResolver
27
+ TEMPLATE_PREFIX = "command_tower/messaging/rendering"
28
+ TYPE_KEY_PATTERN = /\A[a-z0-9_]+\z/
29
+
30
+ class << self
31
+ def render(basename:, format:, notification_type_key:, generic_locals:, type_locals:)
32
+ new.render(
33
+ basename:,
34
+ format:,
35
+ notification_type_key:,
36
+ generic_locals:,
37
+ type_locals:,
38
+ )
39
+ end
40
+
41
+ # Spec-only seam: prepend an additional view root ahead of the host
42
+ # and engine defaults so fixture views can prove override precedence.
43
+ def prepend_view_path(path)
44
+ extra_view_paths.unshift(path.to_s)
45
+ end
46
+
47
+ def reset_view_paths!
48
+ extra_view_paths.clear
49
+ end
50
+
51
+ # Type-only, no-generic-fallback resolution: returns `nil` when
52
+ # there is no matching type template (invalid/sanitized-out key, or
53
+ # the type directory/basename simply does not exist) instead of
54
+ # raising `Errno::ENOENT` like `.render` does. Callers that have no
55
+ # generic ERB file to fall back to (e.g. `InboxDocumentRenderer`,
56
+ # whose generic path is a pure-Ruby builder) use this instead of
57
+ # `.render`. A template that is *found* but raises mid-render still
58
+ # propagates (via `render_named`'s `ActionView::Template::Error`
59
+ # unwrap) so callers can distinguish "no type content" from "type
60
+ # content is broken" if they choose to.
61
+ def render_type_template(basename:, format:, notification_type_key:, locals:)
62
+ new.render_type_template(basename:, format:, notification_type_key:, locals:)
63
+ end
64
+
65
+ def extra_view_paths
66
+ @extra_view_paths ||= []
67
+ end
68
+ end
69
+
70
+ def render(basename:, format:, notification_type_key:, generic_locals:, type_locals:)
71
+ type_key = sanitized_type_key(notification_type_key)
72
+
73
+ if type_key
74
+ begin
75
+ return render_named("#{TEMPLATE_PREFIX}/#{type_key}/#{basename}", format, type_locals)
76
+ rescue ActionView::MissingTemplate
77
+ # No type-specific template for this basename; fall through to generic.
78
+ end
79
+ end
80
+
81
+ begin
82
+ render_named("#{TEMPLATE_PREFIX}/#{basename}", format, generic_locals)
83
+ rescue ActionView::MissingTemplate
84
+ raise Errno::ENOENT, "missing template #{basename}.#{format}.erb"
85
+ end
86
+ end
87
+
88
+ def render_type_template(basename:, format:, notification_type_key:, locals:)
89
+ type_key = sanitized_type_key(notification_type_key)
90
+ return nil unless type_key
91
+
92
+ render_named("#{TEMPLATE_PREFIX}/#{type_key}/#{basename}", format, locals)
93
+ rescue ActionView::MissingTemplate
94
+ nil
95
+ end
96
+
97
+ private
98
+
99
+ def sanitized_type_key(notification_type_key)
100
+ key = notification_type_key.to_s
101
+ key.match?(TYPE_KEY_PATTERN) ? key : nil
102
+ end
103
+
104
+ def render_named(template_name, format, locals)
105
+ view.render(template: template_name, formats: [format], locals:)
106
+ rescue ActionView::Template::Error => error
107
+ # ActionView wraps any error raised while executing a *found*
108
+ # template in `ActionView::Template::Error`. Legacy plain-ERB
109
+ # rendering never wrapped errors, so unwrap here to preserve the
110
+ # original `error_class` that `ChannelRenderer#render`'s outer
111
+ # rescue surfaces on `RenderError`.
112
+ raise(error.cause || error)
113
+ end
114
+
115
+ def view
116
+ @view ||= ActionView::Base.with_empty_template_cache.with_view_paths(view_paths)
117
+ end
118
+
119
+ def view_paths
120
+ self.class.extra_view_paths + [
121
+ Rails.root.join("app/views").to_s,
122
+ CommandTower::Engine.root.join("app/views").to_s,
123
+ ]
124
+ end
125
+ end
126
+ end
127
+ end
128
+ end
@@ -0,0 +1,55 @@
1
+ # frozen_string_literal: true
2
+
3
+ module CommandTower
4
+ module Services
5
+ module Account
6
+ module ExperienceStates
7
+ class Complete < CommandTower::Services::ApplicationService
8
+ validate :user, is_a: User, required: true
9
+ validate :experience_key, is_a: String, required: true
10
+ validate :scope_type, is_a: String, required: true
11
+ validate :scope_identifier, is_a: String, required: true
12
+ validate :version, is_a: String, required: true
13
+
14
+ def call
15
+ host_key = CommandTower.config.application.host_key.to_s
16
+ if host_key.blank?
17
+ context.fail!(
18
+ application_error: CommandTower::Errors::Account::ExperienceStatesHostUnconfiguredError.new,
19
+ )
20
+ return
21
+ end
22
+
23
+ identity = {
24
+ user:,
25
+ host_key:,
26
+ experience_key:,
27
+ scope_type:,
28
+ scope_identifier:,
29
+ version:,
30
+ }
31
+
32
+ existing = CommandTower::UserExperienceState.find_by(identity)
33
+ if existing
34
+ context.experience_state = existing
35
+ context.created = false
36
+ return
37
+ end
38
+
39
+ begin
40
+ created = CommandTower::UserExperienceState.create!(
41
+ identity.merge(completed_at: Time.current),
42
+ )
43
+ context.experience_state = created
44
+ context.created = true
45
+ rescue ActiveRecord::RecordNotUnique
46
+ recovered = CommandTower::UserExperienceState.find_by!(identity)
47
+ context.experience_state = recovered
48
+ context.created = false
49
+ end
50
+ end
51
+ end
52
+ end
53
+ end
54
+ end
55
+ end
@@ -0,0 +1,28 @@
1
+ # frozen_string_literal: true
2
+
3
+ module CommandTower
4
+ module Services
5
+ module Account
6
+ module ExperienceStates
7
+ class List < CommandTower::Services::ApplicationService
8
+ validate :user, is_a: User, required: true
9
+
10
+ def call
11
+ host_key = CommandTower.config.application.host_key.to_s
12
+ if host_key.blank?
13
+ context.fail!(
14
+ application_error: CommandTower::Errors::Account::ExperienceStatesHostUnconfiguredError.new,
15
+ )
16
+ return
17
+ end
18
+
19
+ context.experience_states = CommandTower::UserExperienceState.for_user_and_host(
20
+ user:,
21
+ host_key:,
22
+ ).order(:completed_at, :id).to_a
23
+ end
24
+ end
25
+ end
26
+ end
27
+ end
28
+ end
@@ -0,0 +1,219 @@
1
+ # frozen_string_literal: true
2
+
3
+ module CommandTower
4
+ module Services
5
+ module ClientCompatibility
6
+ # Pure, deterministic client-version-compatibility decision.
7
+ #
8
+ # Same (headers, controller/action, registry state) always produces the
9
+ # same complete `Decision`. This service MUST NOT mutate
10
+ # `CommandTower::Current` or any other request-global — it has zero side
11
+ # effects beyond returning its result. Any placement of recommendation
12
+ # metadata onto `Current` is transport plumbing that belongs to the
13
+ # workflow or boundary layer, never here.
14
+ class Evaluate
15
+ DECISIONS = %i[compatible recommended incompatible identity_invalid].freeze
16
+
17
+ # Immutable decision/projection. `recommendation_projection` is the
18
+ # camelCase `clientCompatibility` shape (authority §13), present only
19
+ # when identity is parseable and current >= effective minimum.
20
+ class Decision
21
+ attr_reader :decision, :platform, :current_version, :effective_minimum, :scope,
22
+ :recovery, :update_url, :matched_entity_names, :recommendation_projection
23
+
24
+ def initialize(
25
+ decision:,
26
+ platform: nil,
27
+ current_version: nil,
28
+ effective_minimum: nil,
29
+ scope: nil,
30
+ recovery: nil,
31
+ update_url: nil,
32
+ matched_entity_names: [],
33
+ recommendation_projection: nil
34
+ )
35
+ @decision = decision
36
+ @platform = platform
37
+ @current_version = current_version
38
+ @effective_minimum = effective_minimum
39
+ @scope = scope
40
+ @recovery = recovery
41
+ @update_url = update_url
42
+ @matched_entity_names = matched_entity_names
43
+ @recommendation_projection = recommendation_projection
44
+ end
45
+
46
+ def compatible? = %i[compatible recommended].include?(@decision)
47
+
48
+ def incompatible? = !compatible?
49
+ end
50
+
51
+ def self.call(app_version_header:, platform_header:, controller_class:, action_name:, registry: CommandTower.config.registry.client_compatibility)
52
+ new(
53
+ app_version_header:,
54
+ platform_header:,
55
+ controller_class:,
56
+ action_name:,
57
+ registry:
58
+ ).call
59
+ end
60
+
61
+ def initialize(app_version_header:, platform_header:, controller_class:, action_name:, registry:)
62
+ @app_version_header = app_version_header
63
+ @platform_header = platform_header
64
+ @controller_class = controller_class
65
+ @action_name = action_name.to_s
66
+ @registry = registry
67
+ end
68
+
69
+ def call
70
+ return no_op_decision unless registry.opted_in?
71
+
72
+ platform = normalized_platform
73
+ version = normalized_version
74
+ return identity_invalid_decision(platform:) if platform.nil? || version.nil?
75
+
76
+ policy = registry.platform_policy(platform)
77
+ return identity_invalid_decision(platform:, current_version: version) if policy.nil?
78
+
79
+ matched = matched_entity_names
80
+ effective_minimum, scope = effective_minimum_and_scope(policy:, platform:, matched:)
81
+
82
+ current = Gem::Version.new(version)
83
+ minimum = Gem::Version.new(effective_minimum)
84
+
85
+ if current < minimum
86
+ return incompatible_decision(
87
+ platform:, current_version: version, effective_minimum:, scope:, matched:, policy:
88
+ )
89
+ end
90
+
91
+ compatible_or_recommended_decision(
92
+ platform:, current_version: version, effective_minimum:, matched:, policy:, current:
93
+ )
94
+ end
95
+
96
+ private
97
+
98
+ attr_reader :controller_class, :action_name, :registry
99
+
100
+ def normalized_platform
101
+ token = @platform_header.to_s.strip.downcase
102
+ return nil if token.empty?
103
+ return nil unless CommandTower::Configuration::Registry::ClientCompatibility::Config::PLATFORMS.map(&:to_s).include?(token)
104
+
105
+ token.to_sym
106
+ end
107
+
108
+ def normalized_version
109
+ CommandTower::ClientCompatibility::Version.normalize(@app_version_header)
110
+ end
111
+
112
+ def matched_entity_names
113
+ CommandTower::Authorization::Entity.entities.values
114
+ .select { |entity| entity.matches?(controller: controller_class, method: action_name.to_sym) == true }
115
+ .map { |entity| entity.name.to_s }
116
+ end
117
+
118
+ def effective_minimum_and_scope(policy:, platform:, matched:)
119
+ strictest = policy.minimum
120
+ scope = :application
121
+
122
+ matched.each do |entity_name|
123
+ requirement = registry.entity_requirement(entity_name)
124
+ next if requirement.nil?
125
+
126
+ entity_minimum = entity_minimum_for(requirement, platform:)
127
+ next if entity_minimum.nil?
128
+
129
+ if Gem::Version.new(entity_minimum) > Gem::Version.new(strictest)
130
+ strictest = entity_minimum
131
+ scope = :capability
132
+ end
133
+ end
134
+
135
+ [strictest, scope]
136
+ end
137
+
138
+ def entity_minimum_for(requirement, platform:)
139
+ if requirement.client_contract.present?
140
+ binding = registry.binding_for(requirement.client_contract)
141
+ binding&.for_platform(platform)
142
+ else
143
+ requirement.minimum.for_platform(platform)
144
+ end
145
+ end
146
+
147
+ def no_op_decision
148
+ Decision.new(decision: :compatible)
149
+ end
150
+
151
+ def identity_invalid_decision(platform: nil, current_version: nil)
152
+ Decision.new(
153
+ decision: :identity_invalid,
154
+ platform:,
155
+ current_version:,
156
+ scope: :application,
157
+ recovery: recovery_for(platform),
158
+ update_url: update_url_for(platform)
159
+ )
160
+ end
161
+
162
+ def incompatible_decision(platform:, current_version:, effective_minimum:, scope:, matched:, policy:)
163
+ Decision.new(
164
+ decision: :incompatible,
165
+ platform:,
166
+ current_version:,
167
+ effective_minimum:,
168
+ scope:,
169
+ recovery: recovery_for(platform),
170
+ update_url: update_url_for(platform),
171
+ matched_entity_names: matched,
172
+ recommendation_projection: nil
173
+ )
174
+ end
175
+
176
+ def compatible_or_recommended_decision(platform:, current_version:, effective_minimum:, matched:, policy:, current:)
177
+ recommended = policy.recommended
178
+ update_available = recommended.present? && current < Gem::Version.new(recommended)
179
+
180
+ Decision.new(
181
+ decision: update_available ? :recommended : :compatible,
182
+ platform:,
183
+ current_version:,
184
+ effective_minimum:,
185
+ scope: :application,
186
+ matched_entity_names: matched,
187
+ recommendation_projection: recommendation_projection(
188
+ platform:, current_version:, effective_minimum:, recommended:, update_available:
189
+ )
190
+ )
191
+ end
192
+
193
+ def recommendation_projection(platform:, current_version:, effective_minimum:, recommended:, update_available:)
194
+ {
195
+ platform: platform.to_s,
196
+ currentVersion: current_version,
197
+ minimumVersion: effective_minimum,
198
+ recommendedVersion: recommended,
199
+ updateAvailable: update_available,
200
+ recovery: recovery_for(platform).to_s,
201
+ updateUrl: update_url_for(platform)
202
+ }.compact
203
+ end
204
+
205
+ def recovery_for(platform)
206
+ return :reload if platform.nil? || platform == :web
207
+
208
+ :app_store
209
+ end
210
+
211
+ def update_url_for(platform)
212
+ return nil if platform.nil? || platform == :web
213
+
214
+ registry.platform_policy(platform)&.update_url
215
+ end
216
+ end
217
+ end
218
+ end
219
+ end
@@ -120,7 +120,14 @@ module CommandTower
120
120
  communication = CommandTower::Messaging::Contract::Communications.find(
121
121
  CommandTower::Messaging::Contract::Requests::FindCommunication.build(communication_id: item.communication_id, recipient_id:)
122
122
  )
123
- item(item).merge(body: communication.body, metadata: communication.metadata, notification_type_key: communication.notification_type_key)
123
+ content = CommandTower::Messaging::Rendering::InboxDocumentRenderer.render(communication:, recipient_id:).to_h
124
+
125
+ item(item).merge(
126
+ body: communication.body,
127
+ metadata: communication.metadata,
128
+ notification_type_key: communication.notification_type_key,
129
+ content:,
130
+ )
124
131
  end
125
132
  end
126
133
  end