command_tower 0.16.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 (44) hide show
  1. checksums.yaml +4 -4
  2. data/app/controllers/command_tower/application_controller.rb +1 -0
  3. data/app/controllers/concerns/command_tower/execution/client_compatibility_boundary.rb +39 -0
  4. data/app/errors/command_tower/errors/client_update_required_error.rb +27 -0
  5. data/app/serializers/command_tower/serializers/messaging/inbox.rb +2 -1
  6. data/app/services/command_tower/email_theme/resolver.rb +45 -0
  7. data/app/services/command_tower/messaging/notification_types/declaration.rb +14 -1
  8. data/app/services/command_tower/messaging/rendering/channel_renderer.rb +44 -13
  9. data/app/services/command_tower/messaging/rendering/inbox_document.rb +131 -0
  10. data/app/services/command_tower/messaging/rendering/inbox_document_renderer.rb +207 -0
  11. data/app/services/command_tower/messaging/rendering/template_resolver.rb +128 -0
  12. data/app/services/command_tower/services/client_compatibility/evaluate.rb +219 -0
  13. data/app/services/command_tower/services/messaging/inbox.rb +8 -1
  14. data/app/views/command_tower/email_verification_mailer/verify_email.html.erb +18 -17
  15. data/app/views/command_tower/messaging/rendering/email.html.erb +6 -5
  16. data/app/views/command_tower/password_reset_mailer/reset_password.html.erb +24 -23
  17. data/app/workflows/command_tower/workflows/auth/plain_text/login_workflow.rb +3 -0
  18. data/app/workflows/command_tower/workflows/auth/session/show_workflow.rb +3 -0
  19. data/app/workflows/command_tower/workflows/client_compatibility/evaluate_workflow.rb +83 -0
  20. data/app/workflows/command_tower/workflows/client_compatibility/recommendation_meta.rb +25 -0
  21. data/docs/api_reference.md +13 -2
  22. data/docs/extending.md +2 -1
  23. data/docs/host_integration_guide.md +35 -0
  24. data/docs/messaging_integration_guide.md +38 -0
  25. data/docs/upgrades/0.17.0.md +33 -0
  26. data/docs/upgrades/README.md +1 -0
  27. data/lib/command_tower/client_compatibility/version.rb +45 -0
  28. data/lib/command_tower/client_compatibility.rb +23 -0
  29. data/lib/command_tower/configuration/config.rb +6 -0
  30. data/lib/command_tower/configuration/email_theme/config.rb +69 -0
  31. data/lib/command_tower/configuration/registry/client_compatibility/binding_definition.rb +42 -0
  32. data/lib/command_tower/configuration/registry/client_compatibility/config.rb +353 -0
  33. data/lib/command_tower/configuration/registry/client_compatibility/contract_definition.rb +16 -0
  34. data/lib/command_tower/configuration/registry/client_compatibility/entity_requirement_definition.rb +67 -0
  35. data/lib/command_tower/configuration/registry/client_compatibility/minimum_overrides.rb +46 -0
  36. data/lib/command_tower/configuration/registry/client_compatibility/platform_definition.rb +66 -0
  37. data/lib/command_tower/configuration/registry/config.rb +14 -0
  38. data/lib/command_tower/configuration/registry/inbox_presentations/config.rb +105 -0
  39. data/lib/command_tower/configuration/registry/inbox_presentations/presentation_definition.rb +71 -0
  40. data/lib/command_tower/current.rb +1 -0
  41. data/lib/command_tower/engine.rb +4 -0
  42. data/lib/command_tower/inbox_presentations.rb +19 -0
  43. data/lib/command_tower/version.rb +1 -1
  44. metadata +24 -2
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 9e983f8f191ccb75948cdad73067d1cd43b7b1e5a8a3e0d34fc0130a76c313da
4
- data.tar.gz: 5ef89ea100c06425afe3aee6f87b8b71503e4d28be6a4ff6e309af8a5b02c83f
3
+ metadata.gz: 465f6aea711f7cb8158e7b3eb9389663c2ac06d5104e89335da339d04cda81b6
4
+ data.tar.gz: 3e8c5a0b870ec8fd71f954ee3e04573cf87bf3eb3b8091e056dddb0db37176a6
5
5
  SHA512:
6
- metadata.gz: 6555727a8b33efc95493ecdeec48067e39c8ee9eb4f1750c8fd183599a8d41bf1e763d538d379d0be7888697e6f09ca0795ebb643817d5aea2c548c61cd867f6
7
- data.tar.gz: ca51dafb697b03848b2509f16660df1e9e349f325a01da7d589300ac7db0ee087df33c08d46194a97e60ae6cbc984de8bcbf1fbc65f41d7bd31f31013d64add4
6
+ metadata.gz: 38ac950878823bda838001b2948ef4c54fe6b07cd0520242681607415f0ffce8c3ac795280fda9248a8a7ba449ffeef1cd8403a89777940d1ee0e846ba683906
7
+ data.tar.gz: b42d36dc8afb1cc9d0df9f254622b01981984d50f7839eebe8040056714e91d04b1b30dd397828133f270056f7032a695b06071c7cb958d65f4f0885d95c415f
@@ -3,6 +3,7 @@
3
3
  module CommandTower
4
4
  class ApplicationController < ActionController::API
5
5
  include CommandTower::Execution::HttpBoundary
6
+ include CommandTower::Execution::ClientCompatibilityBoundary
6
7
 
7
8
  AUTHENTICATION_HEADER = CommandTower::Jwt::AuthorizationHelper::AUTHENTICATION_HEADER
8
9
  AUTHENTICATION_EXPIRE_HEADER = CommandTower::Jwt::AuthorizationHelper::AUTHENTICATION_EXPIRE_HEADER
@@ -0,0 +1,39 @@
1
+ # frozen_string_literal: true
2
+
3
+ module CommandTower
4
+ module Execution
5
+ # Runs the client-version-compatibility check for every JSON request,
6
+ # before any authentication/authorization filter. Transport-only: it
7
+ # invokes exactly one workflow and renders its result — all decisioning
8
+ # lives in `Workflows::ClientCompatibility::EvaluateWorkflow` /
9
+ # `Services::ClientCompatibility::Evaluate` (authority §10).
10
+ #
11
+ # Hosts that need to exempt a controller (e.g. a healthz-style endpoint)
12
+ # do so with `skip_before_action :evaluate_client_compatibility!`.
13
+ module ClientCompatibilityBoundary
14
+ extend ActiveSupport::Concern
15
+
16
+ included do
17
+ include CommandTower::Api::ApplicationResponseRenderer
18
+ before_action :evaluate_client_compatibility!
19
+ end
20
+
21
+ private
22
+
23
+ def evaluate_client_compatibility!
24
+ result = CommandTower::Workflows::ClientCompatibility::EvaluateWorkflow.call(
25
+ request: request,
26
+ controller_class: self.class,
27
+ action_name: action_name
28
+ )
29
+
30
+ unless result.success?
31
+ render_application_result(result)
32
+ return false
33
+ end
34
+
35
+ true
36
+ end
37
+ end
38
+ end
39
+ end
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ module CommandTower
4
+ module Errors
5
+ # Hard client-version incompatibility (authority §14). Deliberately NOT
6
+ # registered in `SessionErrorStatus` — its HTTP status (426) is set
7
+ # directly by `Workflows::ClientCompatibility::EvaluateWorkflow`, not
8
+ # derived from that closed 401/403/412 mapping table.
9
+ class ClientUpdateRequiredError < ApplicationError
10
+ def initialize(details: {})
11
+ super(details:)
12
+ end
13
+
14
+ def code
15
+ "client_update_required"
16
+ end
17
+
18
+ def message
19
+ "Client update required"
20
+ end
21
+
22
+ def log_level
23
+ :warn
24
+ end
25
+ end
26
+ end
27
+ end
@@ -16,7 +16,8 @@ module CommandTower
16
16
  def self.serialize(item)
17
17
  ItemSerializer.serialize(item).merge(
18
18
  body: item.fetch(:body), metadata: item[:metadata],
19
- notificationTypeKey: item.fetch(:notification_type_key)
19
+ notificationTypeKey: item.fetch(:notification_type_key),
20
+ content: item.fetch(:content)
20
21
  )
21
22
  end
22
23
  end
@@ -0,0 +1,45 @@
1
+ # frozen_string_literal: true
2
+
3
+ module CommandTower
4
+ module EmailTheme
5
+ # Internal rendering-time collaborator (not a Service/Workflow/Controller).
6
+ #
7
+ # Assembles the full semantic token set an email template needs: the
8
+ # visual tokens from CommandTower.config.email_theme plus the identity
9
+ # tokens CommandTower already exposes (config.application). Shared by
10
+ # Messaging email templates and, later, CT's own auth mail templates —
11
+ # hence its own top-level namespace rather than living under
12
+ # Messaging::Rendering.
13
+ #
14
+ # No template calls this yet (Rich Messaging Strategy §12 steps C/D are
15
+ # separate, later slices). This is deliberately not a
16
+ # CommandTower::Services::ApplicationService: it takes no arguments,
17
+ # performs no I/O, and cannot fail under normal config state — there is
18
+ # nothing to validate and nothing to fail. Mirrors
19
+ # Messaging::Rendering::TemplateResolver's own documented exemption.
20
+ class Resolver
21
+ class << self
22
+ def resolve
23
+ new.resolve
24
+ end
25
+ end
26
+
27
+ def resolve
28
+ theme = CommandTower.config.email_theme
29
+ {
30
+ canvas_background: theme.canvas_background,
31
+ surface_background: theme.surface_background,
32
+ surface_border: theme.surface_border,
33
+ primary_text: theme.primary_text,
34
+ body_text: theme.body_text,
35
+ muted_text: theme.muted_text,
36
+ accent: theme.accent,
37
+ text_on_accent: theme.text_on_accent,
38
+ primary_action: theme.primary_action,
39
+ product_name: CommandTower.app_name_for_comms,
40
+ product_url: CommandTower.config.application.composed_url
41
+ }.freeze
42
+ end
43
+ end
44
+ end
45
+ end
@@ -22,6 +22,7 @@ module CommandTower
22
22
  :retention,
23
23
  :delivery_status_visible,
24
24
  :host_ownership,
25
+ :inbox_document_composer,
25
26
  ) do
26
27
  def self.build(
27
28
  key:,
@@ -41,7 +42,8 @@ module CommandTower
41
42
  priority: nil,
42
43
  retention: nil,
43
44
  delivery_status_visible: nil,
44
- host_ownership: nil
45
+ host_ownership: nil,
46
+ inbox_document_composer: nil
45
47
  )
46
48
  new(
47
49
  key: key.nil? ? nil : key.to_s,
@@ -62,6 +64,17 @@ module CommandTower
62
64
  retention:,
63
65
  delivery_status_visible:,
64
66
  host_ownership:,
67
+ # Rich Messaging Slice 2.5 revision — String class name of a
68
+ # pure-Ruby Inbox document composer (`.call(communication:) ->
69
+ # InboxDocument`), lazily `.constantize`d by
70
+ # `InboxDocumentRenderer` at render time, never at registration
71
+ # time (mirrors the existing RBAC `Entity#controller` deferred
72
+ # class-string convention — decouples host `to_prepare`
73
+ # registration order from composer class-load order). `nil`
74
+ # means this type intentionally has no custom Inbox document and
75
+ # renders via `InboxDocumentRenderer#build_generic`. See
76
+ # artifacts/visual-improvements/rich-messaging/authority/RICH_MESSAGING_RUBY_INBOX_DEFINITION_DISCOVERY.md.
77
+ inbox_document_composer: inbox_document_composer.nil? ? nil : inbox_document_composer.to_s,
65
78
  ).freeze
66
79
  end
67
80
 
@@ -1,6 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "erb"
4
3
  require "cgi"
5
4
 
6
5
  module CommandTower
@@ -12,7 +11,7 @@ module CommandTower
12
11
  PUSHOVER_CHANNEL = "pushover"
13
12
  PUSH_CHANNEL = "push"
14
13
  SUPPORTED_CHANNELS = [EMAIL_CHANNEL, SMS_CHANNEL, PUSHOVER_CHANNEL, PUSH_CHANNEL].freeze
15
- TEMPLATE_DIR = CommandTower::Engine.root.join("app/views/command_tower/messaging/rendering")
14
+ TEMPLATE_FILENAME_PATTERN = /\A(?<basename>[a-z]+)\.(?<format>html|text)\.erb\z/
16
15
  DEFAULT_TITLE = "Notification"
17
16
 
18
17
  def self.render(communication:, channel_key:, recipient_address:)
@@ -120,24 +119,56 @@ module CommandTower
120
119
  alias pushover_title notification_title
121
120
 
122
121
  def render_template(filename)
123
- path = TEMPLATE_DIR.join(filename)
124
- raise Errno::ENOENT, "missing template #{filename}" unless path.file?
125
-
126
- template = ERB.new(path.read, trim_mode: "-")
127
- template.result_with_hash(template_locals)
122
+ match = TEMPLATE_FILENAME_PATTERN.match(filename.to_s)
123
+ raise Errno::ENOENT, "missing template #{filename}" unless match
124
+
125
+ TemplateResolver.render(
126
+ basename: match[:basename],
127
+ format: match[:format].to_sym,
128
+ notification_type_key: @communication.notification_type_key,
129
+ generic_locals: template_locals,
130
+ type_locals: type_template_locals,
131
+ )
128
132
  end
129
133
 
134
+ # ActionView's OutputBuffer auto-escapes any interpolated value that is
135
+ # not marked `html_safe?`, for every format (html and text alike) —
136
+ # see `ActionView::OutputBuffer#<<`. Generic templates historically
137
+ # relied on plain ERB, which never auto-escaped anything; callers
138
+ # (namely `email.html.erb`) explicitly call `h.call(...)` when they
139
+ # want escaping. To preserve that exact contract under ActionView
140
+ # rendering, `title`/`body`/`deep_link` are pre-marked `html_safe` (so
141
+ # raw interpolation stays unescaped, matching legacy behavior) and
142
+ # `h.call` marks its own already-escaped output `html_safe` too, so it
143
+ # is not escaped a second time by the output buffer.
130
144
  def template_locals
145
+ {
146
+ title: @communication.title.to_s.html_safe,
147
+ body: @communication.body.to_s.html_safe,
148
+ deep_link: deep_link_from_metadata&.html_safe,
149
+ h: ->(value) { CGI.escapeHTML(value.to_s).html_safe },
150
+ }
151
+ end
152
+
153
+ def type_template_locals
154
+ template_locals.merge(
155
+ metadata: frozen_metadata,
156
+ notification_type_key: @communication.notification_type_key.to_s,
157
+ )
158
+ end
159
+
160
+ def deep_link_from_metadata
131
161
  metadata = @communication.metadata
132
162
  deep_link = metadata.is_a?(Hash) ? metadata["deep_link"] || metadata[:deep_link] : nil
133
163
  deep_link = deep_link.to_s if deep_link
164
+ deep_link.presence
165
+ end
134
166
 
135
- {
136
- title: @communication.title.to_s,
137
- body: @communication.body.to_s,
138
- deep_link: deep_link.presence,
139
- h: ->(value) { CGI.escapeHTML(value.to_s) },
140
- }
167
+ def frozen_metadata
168
+ metadata = @communication.metadata
169
+ return {}.freeze unless metadata.is_a?(Hash)
170
+
171
+ metadata.to_h.transform_keys(&:to_s).freeze
141
172
  end
142
173
  end
143
174
  end
@@ -0,0 +1,131 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "uri"
4
+
5
+ module CommandTower
6
+ module Messaging
7
+ module Rendering
8
+ # Frozen v1 Inbox content document. Rendered at read (never persisted) by
9
+ # `InboxDocumentRenderer`. Blocks are plain frozen Hashes, not nested
10
+ # `Data` objects, so `#to_h` is already the exact JSON-serializable shape
11
+ # the `content` response key needs — no extra conversion at the
12
+ # serializer boundary.
13
+ #
14
+ # This value object only guarantees the envelope (`schema` tag, `blocks`
15
+ # is an Array); block-level construction and validation is
16
+ # `InboxDocumentRenderer`'s responsibility.
17
+ InboxDocument = Data.define(:schema, :blocks)
18
+
19
+ # Reopened (rather than defined inside the `Data.define` block) so that
20
+ # `SCHEMA_V1` and `.build` attach to `InboxDocument` under normal
21
+ # lexical constant scoping — a block passed to `Data.define` does not
22
+ # change the lexical scope for constant assignment the way `class ...
23
+ # end` does.
24
+ class InboxDocument
25
+ SCHEMA_V1 = "inbox_document_v1"
26
+
27
+ def self.build(blocks:)
28
+ raise ArgumentError, "blocks must be an Array" unless blocks.is_a?(Array)
29
+ raise ArgumentError, "arbitrary hashes are not accepted as blocks entries" if blocks.any? { |b| !b.is_a?(Hash) }
30
+
31
+ new(schema: SCHEMA_V1, blocks: blocks.freeze).freeze
32
+ end
33
+
34
+ # Small pure-Ruby authoring DSL (Rich Messaging Slice 2.5 revision —
35
+ # replaces `inbox_document.json.erb` entirely; see
36
+ # artifacts/visual-improvements/rich-messaging/authority/RICH_MESSAGING_RUBY_INBOX_DEFINITION_DISCOVERY.md).
37
+ # A notification-type Inbox document composer calls `.compose` and
38
+ # authors an ordered document via exactly three methods:
39
+ #
40
+ # doc.paragraph "..."
41
+ # doc.cta "label", path: "/internal/route"
42
+ # doc.cta "label", href: "https://external.example/..."
43
+ # doc.presentation "pickem.make_picks", variant: :compact
44
+ #
45
+ # `cta` takes exactly one of `path:` (an internal, origin-free
46
+ # destination the host's own navigation owns) or `href:` (an
47
+ # absolute external URL opened via the platform's normal external-
48
+ # link handling). The composer — which already knows whether a
49
+ # destination is its own app's route or a third-party URL — states
50
+ # that intent explicitly via a typed `destination`, rather than
51
+ # having the frontend infer it from an absolute URL (Slice 2.6.4;
52
+ # see artifacts/visual-improvements/rich-messaging/plans/
53
+ # 2.6.4-ct-fe-inbox-navigation-capability.md).
54
+ # `presentation` intentionally has no `data:`/`props:` argument at
55
+ # all — the message type may only *name* a registered presentation
56
+ # capability. Its data is composed separately, post-authoring, by
57
+ # `InboxDocumentRenderer` and the registered presentation composer
58
+ # (Rich Messaging Presentation Authority §8). There is no argument
59
+ # slot to violate that boundary with.
60
+ def self.compose
61
+ builder = Builder.new
62
+ yield builder
63
+ build(blocks: builder.blocks)
64
+ end
65
+
66
+ class Builder
67
+ UNSAFE_SCHEMES = %w[javascript data vbscript].freeze
68
+ DEFAULT_CTA_LABEL = "Open"
69
+
70
+ def initialize
71
+ @blocks = []
72
+ end
73
+
74
+ def paragraph(text)
75
+ return self unless text.is_a?(String) && !text.empty?
76
+
77
+ @blocks << { type: "paragraph", text: }.freeze
78
+ self
79
+ end
80
+
81
+ def cta(label, path: nil, href: nil)
82
+ destination = cta_destination(path:, href:)
83
+ return self unless destination
84
+
85
+ resolved_label = label.is_a?(String) && !label.strip.empty? ? label : DEFAULT_CTA_LABEL
86
+ @blocks << { type: "cta", label: resolved_label, destination: }.freeze
87
+ self
88
+ end
89
+
90
+ def presentation(key, variant: nil)
91
+ @blocks << { type: "presentation", key: key.to_s, variant: variant&.to_s }.freeze
92
+ self
93
+ end
94
+
95
+ def blocks
96
+ @blocks
97
+ end
98
+
99
+ private
100
+
101
+ # Internal takes precedence when both are supplied — a `cta` only
102
+ # ever means one navigation intent, and a composer that mistakenly
103
+ # supplies both almost certainly means the internal one.
104
+ def cta_destination(path:, href:)
105
+ if safe_internal_path?(path)
106
+ { kind: "internal", path: }
107
+ elsif safe_href?(href)
108
+ { kind: "external", href: }
109
+ end
110
+ end
111
+
112
+ # A leading `//` is rejected even though it "starts with a slash" —
113
+ # it is a protocol-relative URL (browsers/RN resolve it against
114
+ # whatever scheme is active), not a safe same-app-only destination.
115
+ def safe_internal_path?(path)
116
+ path.is_a?(String) && path.start_with?("/") && !path.start_with?("//")
117
+ end
118
+
119
+ def safe_href?(href)
120
+ return false unless href.is_a?(String) && !href.strip.empty?
121
+
122
+ scheme = URI.parse(href).scheme&.downcase
123
+ scheme.present? && UNSAFE_SCHEMES.exclude?(scheme)
124
+ rescue URI::InvalidURIError
125
+ false
126
+ end
127
+ end
128
+ end
129
+ end
130
+ end
131
+ end
@@ -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