command_tower 0.16.0 → 0.18.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 (92) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +8 -8
  3. data/app/controllers/command_tower/application_controller.rb +1 -0
  4. data/app/controllers/command_tower/me/inbox_controller.rb +16 -0
  5. data/app/controllers/concerns/command_tower/execution/client_compatibility_boundary.rb +39 -0
  6. data/app/deserializers/command_tower/deserializers/messaging/inbox.rb +85 -0
  7. data/app/errors/command_tower/errors/client_update_required_error.rb +27 -0
  8. data/app/jobs/command_tower/messaging/communications/produce_recipient_job.rb +2 -0
  9. data/app/models/command_tower/messaging/communication.rb +31 -0
  10. data/app/serializers/command_tower/serializers/messaging/inbox.rb +45 -1
  11. data/app/services/command_tower/email_theme/resolver.rb +45 -0
  12. data/app/services/command_tower/messaging/accept/coordinator.rb +16 -1
  13. data/app/services/command_tower/messaging/accept/persister.rb +13 -0
  14. data/app/services/command_tower/messaging/contract/mappers/communication_mapper.rb +7 -0
  15. data/app/services/command_tower/messaging/contract/results/communication_result.rb +1 -0
  16. data/app/services/command_tower/messaging/inbox/conversation_result.rb +31 -0
  17. data/app/services/command_tower/messaging/inbox/entry_result.rb +36 -0
  18. data/app/services/command_tower/messaging/inbox/reader.rb +231 -18
  19. data/app/services/command_tower/messaging/inbox.rb +4 -0
  20. data/app/services/command_tower/messaging/notification_types/declaration.rb +14 -1
  21. data/app/services/command_tower/messaging/rendering/channel_renderer.rb +44 -13
  22. data/app/services/command_tower/messaging/rendering/inbox_document.rb +131 -0
  23. data/app/services/command_tower/messaging/rendering/inbox_document_renderer.rb +224 -0
  24. data/app/services/command_tower/messaging/rendering/inbox_presentation_resolver.rb +52 -0
  25. data/app/services/command_tower/messaging/rendering/inbox_presentation_snapshot.rb +64 -0
  26. data/app/services/command_tower/messaging/rendering/template_resolver.rb +128 -0
  27. data/app/services/command_tower/messaging.rb +5 -1
  28. data/app/services/command_tower/services/client_compatibility/evaluate.rb +219 -0
  29. data/app/services/command_tower/services/messaging/communications/produce.rb +4 -0
  30. data/app/services/command_tower/services/messaging/communications/produce_many.rb +6 -0
  31. data/app/services/command_tower/services/messaging/inbox.rb +55 -2
  32. data/app/views/command_tower/email_verification_mailer/verify_email.html.erb +18 -17
  33. data/app/views/command_tower/messaging/rendering/email.html.erb +6 -5
  34. data/app/views/command_tower/password_reset_mailer/reset_password.html.erb +24 -23
  35. data/app/workflows/command_tower/workflows/auth/plain_text/login_workflow.rb +3 -0
  36. data/app/workflows/command_tower/workflows/auth/session/show_workflow.rb +3 -0
  37. data/app/workflows/command_tower/workflows/client_compatibility/evaluate_workflow.rb +83 -0
  38. data/app/workflows/command_tower/workflows/client_compatibility/recommendation_meta.rb +25 -0
  39. data/app/workflows/command_tower/workflows/messaging/communications/produce_recipient_workflow.rb +5 -1
  40. data/app/workflows/command_tower/workflows/messaging/inbox.rb +15 -1
  41. data/config/routes.rb +1 -0
  42. data/db/migrate/20261010000001_add_inbox_presentation_snapshot_to_messaging_communications.rb +7 -0
  43. data/db/migrate/20261010000002_add_conversation_identity_to_messaging_communications.rb +10 -0
  44. data/docs/api_reference.md +13 -2
  45. data/docs/authorization.md +1 -1
  46. data/docs/bootstrap/00-ownership.md +77 -0
  47. data/docs/bootstrap/01-docker-make-compose.md +267 -0
  48. data/docs/bootstrap/02-create-the-rails-app.md +75 -0
  49. data/docs/bootstrap/03-pin-the-gem.md +53 -0
  50. data/docs/bootstrap/04-secrets-and-env.md +59 -0
  51. data/docs/bootstrap/05-install-migrate-doctor.md +74 -0
  52. data/docs/bootstrap/06-mount-and-health.md +53 -0
  53. data/docs/bootstrap/07-execution-bases.md +38 -0
  54. data/docs/bootstrap/08-initializer.md +90 -0
  55. data/docs/bootstrap/09-rbac.md +83 -0
  56. data/docs/bootstrap/10-roles-and-gates.md +62 -0
  57. data/docs/bootstrap/11-auth-client-path.md +57 -0
  58. data/docs/bootstrap/12-smoke-check.md +81 -0
  59. data/docs/bootstrap/13-optional.md +50 -0
  60. data/docs/bootstrap/14-sanity-checks.md +44 -0
  61. data/docs/bootstrap/README.md +90 -0
  62. data/docs/cookie_authentication_guide.md +11 -0
  63. data/docs/extending.md +5 -4
  64. data/docs/host_integration_guide.md +3 -246
  65. data/docs/initializing.md +37 -28
  66. data/docs/messaging_integration_guide.md +39 -1
  67. data/docs/principal_capabilities.md +1 -1
  68. data/docs/upgrades/0.10.0.md +5 -5
  69. data/docs/upgrades/0.11.0.md +5 -5
  70. data/docs/upgrades/0.17.0.md +33 -0
  71. data/docs/upgrades/0.18.0.md +35 -0
  72. data/docs/upgrades/README.md +3 -1
  73. data/lib/command_tower/authorization/default.yml +1 -0
  74. data/lib/command_tower/client_compatibility/version.rb +45 -0
  75. data/lib/command_tower/client_compatibility.rb +23 -0
  76. data/lib/command_tower/configuration/config.rb +6 -0
  77. data/lib/command_tower/configuration/email_theme/config.rb +69 -0
  78. data/lib/command_tower/configuration/registry/client_compatibility/binding_definition.rb +42 -0
  79. data/lib/command_tower/configuration/registry/client_compatibility/config.rb +353 -0
  80. data/lib/command_tower/configuration/registry/client_compatibility/contract_definition.rb +16 -0
  81. data/lib/command_tower/configuration/registry/client_compatibility/entity_requirement_definition.rb +67 -0
  82. data/lib/command_tower/configuration/registry/client_compatibility/minimum_overrides.rb +46 -0
  83. data/lib/command_tower/configuration/registry/client_compatibility/platform_definition.rb +66 -0
  84. data/lib/command_tower/configuration/registry/config.rb +14 -0
  85. data/lib/command_tower/configuration/registry/inbox_presentations/config.rb +105 -0
  86. data/lib/command_tower/configuration/registry/inbox_presentations/presentation_definition.rb +71 -0
  87. data/lib/command_tower/current.rb +1 -0
  88. data/lib/command_tower/engine.rb +4 -0
  89. data/lib/command_tower/inbox_presentations.rb +19 -0
  90. data/lib/command_tower/install/baseline.rb +2 -0
  91. data/lib/command_tower/version.rb +1 -1
  92. metadata +47 -2
@@ -6,6 +6,9 @@ module CommandTower
6
6
  class Reader
7
7
  DEFAULT_LIMIT = 50
8
8
  MAX_LIMIT = 100
9
+ WINDOW_LIMIT = 5
10
+ WINDOW_MAX_OLDER = 2
11
+ OLDEST_UNREAD = "oldest_unread"
9
12
  SCOPE_INBOX = "inbox"
10
13
  SCOPE_ARCHIVED = "archived"
11
14
  ALLOWED_SCOPES = [SCOPE_INBOX, SCOPE_ARCHIVED].freeze
@@ -17,28 +20,36 @@ module CommandTower
17
20
  offset = normalize_offset!(offset)
18
21
  scope = normalize_scope!(scope)
19
22
 
20
- list_scope =
21
- if scope == SCOPE_ARCHIVED
22
- Messaging::InboxItem.archived_list
23
- else
24
- Messaging::InboxItem.default_list
25
- end
26
-
27
- scoped = Scope.for_recipient(recipient_id).merge(list_scope)
28
- total_count = scoped.count
29
- records =
30
- scoped
31
- .includes(:communication)
32
- .order(Arel.sql("messaging_inbox_items.created_at DESC, messaging_inbox_items.id DESC"))
33
- .limit(limit)
34
- .offset(offset)
35
- .to_a
23
+ scoped = Scope.for_recipient(recipient_id).merge(list_scope_for(scope))
24
+ page_rows = entry_page_rows(scoped, limit:, offset:)
25
+ members = page_members(scoped, page_rows)
36
26
 
37
27
  ListResult.build(
38
- items: records.map { |record| ItemResult.from_record(record) },
28
+ items: page_rows.map { |row| build_entry(row, members) },
39
29
  limit:,
40
30
  offset:,
41
- total_count:,
31
+ total_count: entry_count(scoped),
32
+ )
33
+ end
34
+
35
+ def conversation(recipient_id:, key:, scope: SCOPE_INBOX, around: nil, before_id: nil, after_id: nil, limit: nil)
36
+ recipient_id = require_recipient_id!(recipient_id)
37
+ raise ValidationError, "key is required" if blank?(key)
38
+
39
+ scope = normalize_scope!(scope)
40
+ records = members_for_key(recipient_id:, key:, scope:)
41
+ raise NotFoundError, "conversation not found" if records.empty?
42
+
43
+ window = conversation_window(records, around:, before_id:, after_id:, limit:)
44
+ latest = records.last
45
+ ConversationResult.build(
46
+ scope:,
47
+ subject: latest.communication.conversation_title,
48
+ message_count: records.size,
49
+ unread_count: records.count { |record| record.viewed_at.nil? },
50
+ inbox_item_ids: records.map(&:id),
51
+ message_ids: window ? window.fetch(:message_ids) : records.map(&:id),
52
+ window: window&.except(:message_ids),
42
53
  )
43
54
  end
44
55
 
@@ -106,6 +117,208 @@ module CommandTower
106
117
  value
107
118
  end
108
119
 
120
+ def list_scope_for(scope)
121
+ if scope == SCOPE_ARCHIVED
122
+ Messaging::InboxItem.archived_list
123
+ else
124
+ Messaging::InboxItem.default_list
125
+ end
126
+ end
127
+
128
+ def entry_page_rows(scoped, limit:, offset:)
129
+ sql = <<~SQL.squish
130
+ SELECT conversation_key, inbox_item_id AS latest_id, item_created_at AS activity_at
131
+ FROM (
132
+ SELECT
133
+ members.*,
134
+ ROW_NUMBER() OVER (
135
+ PARTITION BY entry_group
136
+ ORDER BY item_created_at DESC, inbox_item_id DESC
137
+ ) AS recency_rank
138
+ FROM (#{member_groups_sql(scoped)}) members
139
+ ) ranked
140
+ WHERE recency_rank = 1
141
+ ORDER BY activity_at DESC, latest_id DESC
142
+ LIMIT #{limit} OFFSET #{offset}
143
+ SQL
144
+
145
+ Messaging::InboxItem.connection.select_all(sql).to_a
146
+ end
147
+
148
+ def entry_count(scoped)
149
+ sql = <<~SQL.squish
150
+ SELECT COUNT(*) FROM (
151
+ SELECT entry_group
152
+ FROM (#{member_groups_sql(scoped)}) members
153
+ GROUP BY entry_group
154
+ ) entry_groups
155
+ SQL
156
+
157
+ Messaging::InboxItem.connection.select_value(sql).to_i
158
+ end
159
+
160
+ def member_groups_sql(scoped)
161
+ scoped.select(Arel.sql(<<~SQL.squish)).to_sql
162
+ messaging_inbox_items.id AS inbox_item_id,
163
+ messaging_inbox_items.created_at AS item_created_at,
164
+ messaging_communications.conversation_key AS conversation_key,
165
+ CASE
166
+ WHEN messaging_communications.conversation_key IS NULL
167
+ THEN CONCAT('m:', messaging_inbox_items.id)
168
+ ELSE CONCAT('c:', messaging_communications.conversation_key)
169
+ END AS entry_group
170
+ SQL
171
+ end
172
+
173
+ def page_members(scoped, page_rows)
174
+ keys = page_rows.filter_map { |row| row_value(row, "conversation_key").presence }
175
+ standalone_ids = page_rows.filter_map do |row|
176
+ next if row_value(row, "conversation_key").present?
177
+
178
+ Integer(row_value(row, "latest_id"))
179
+ end
180
+ ids = standalone_ids.dup
181
+ if keys.any?
182
+ ids.concat(
183
+ scoped.where(messaging_communications: { conversation_key: keys }).pluck("messaging_inbox_items.id"),
184
+ )
185
+ end
186
+ return [] if ids.empty?
187
+
188
+ scoped.where(messaging_inbox_items: { id: ids }).includes(:communication).to_a
189
+ end
190
+
191
+ def build_entry(row, members)
192
+ latest_id = Integer(row_value(row, "latest_id"))
193
+ key = row_value(row, "conversation_key").presence
194
+ group =
195
+ if key
196
+ members.select { |member| member.communication.conversation_key == key }
197
+ else
198
+ members.select { |member| member.id == latest_id }
199
+ end
200
+ group = group.sort_by { |member| [member.created_at, member.id] }
201
+ latest = group.find { |member| member.id == latest_id }
202
+ raise InvariantError, "latest inbox member missing" if latest.nil?
203
+
204
+ conversation = key.present?
205
+ EntryResult.build(
206
+ kind: conversation ? "conversation" : "message",
207
+ id: conversation ? key : latest.id,
208
+ title: conversation ? latest.communication.conversation_title : latest.communication.title,
209
+ preview: conversation ? latest.communication.title : nil,
210
+ activity_at: latest.created_at,
211
+ message_count: group.size,
212
+ unread_count: group.count { |member| member.viewed_at.nil? },
213
+ inbox_item_ids: group.map(&:id),
214
+ )
215
+ end
216
+
217
+ def conversation_window(records, around:, before_id:, after_id:, limit:)
218
+ windowed = !around.nil? || !before_id.nil? || !after_id.nil?
219
+ unless windowed
220
+ raise ValidationError, "invalid_limit" unless limit.nil?
221
+
222
+ return
223
+ end
224
+
225
+ modes = [around, before_id, after_id].count { |value| !value.nil? }
226
+ raise ValidationError, "invalid_window" unless modes == 1
227
+
228
+ page_limit = normalize_window_limit!(limit)
229
+ cursor = nil
230
+ anchor = nil
231
+ slice =
232
+ if !around.nil?
233
+ anchor = resolve_anchor(records, around)
234
+ around_slice(records, anchor, page_limit)
235
+ elsif !before_id.nil?
236
+ cursor = find_member!(records, before_id)
237
+ records.select { |record| before_record?(record, cursor) }.last(page_limit)
238
+ else
239
+ cursor = find_member!(records, after_id)
240
+ records.select { |record| before_record?(cursor, record) }.first(page_limit)
241
+ end
242
+
243
+ flags = window_flags(records, slice, cursor)
244
+ {
245
+ message_ids: slice.map(&:id),
246
+ anchor_inbox_item_id: anchor&.id,
247
+ has_before: flags.fetch(:has_before),
248
+ has_after: flags.fetch(:has_after),
249
+ before_cursor: flags.fetch(:before_cursor),
250
+ after_cursor: flags.fetch(:after_cursor),
251
+ }
252
+ end
253
+
254
+ def resolve_anchor(records, around)
255
+ return records.find { |record| record.viewed_at.nil? } || records.last if around.to_s == OLDEST_UNREAD
256
+
257
+ find_member!(records, around)
258
+ end
259
+
260
+ def around_slice(records, anchor, limit)
261
+ index = records.index(anchor)
262
+ before_count = [WINDOW_MAX_OLDER, index, limit - 1].min
263
+ after_count = [limit - 1 - before_count, records.length - 1 - index].min
264
+ records[(index - before_count)..(index + after_count)]
265
+ end
266
+
267
+ def find_member!(records, inbox_item_id)
268
+ id = Integer(inbox_item_id)
269
+ records.find { |record| record.id == id } || raise(ValidationError, "invalid_cursor")
270
+ rescue ArgumentError, TypeError
271
+ raise ValidationError, "invalid_cursor"
272
+ end
273
+
274
+ def window_flags(records, slice, cursor)
275
+ if slice.any?
276
+ first = slice.first
277
+ last = slice.last
278
+ {
279
+ has_before: records.any? { |record| before_record?(record, first) },
280
+ has_after: records.any? { |record| before_record?(last, record) },
281
+ before_cursor: first.id,
282
+ after_cursor: last.id,
283
+ }
284
+ else
285
+ {
286
+ has_before: !cursor.nil? && records.any? { |record| before_record?(record, cursor) },
287
+ has_after: !cursor.nil? && records.any? { |record| before_record?(cursor, record) },
288
+ before_cursor: nil,
289
+ after_cursor: nil,
290
+ }
291
+ end
292
+ end
293
+
294
+ def before_record?(candidate, boundary)
295
+ return candidate.id < boundary.id if candidate.created_at == boundary.created_at
296
+
297
+ candidate.created_at < boundary.created_at
298
+ end
299
+
300
+ def normalize_window_limit!(limit)
301
+ value = limit.nil? ? WINDOW_LIMIT : Integer(limit)
302
+ raise ValidationError, "invalid_limit" if value < 1 || value > WINDOW_LIMIT
303
+
304
+ value
305
+ rescue ArgumentError, TypeError
306
+ raise ValidationError, "invalid_limit"
307
+ end
308
+
309
+ def members_for_key(recipient_id:, key:, scope:)
310
+ Scope.for_recipient(recipient_id)
311
+ .merge(list_scope_for(scope))
312
+ .includes(:communication)
313
+ .where(messaging_communications: { conversation_key: key })
314
+ .order(Arel.sql("messaging_inbox_items.created_at ASC, messaging_inbox_items.id ASC"))
315
+ .to_a
316
+ end
317
+
318
+ def row_value(row, key)
319
+ row[key] || row[key.to_sym]
320
+ end
321
+
109
322
  def blank?(value)
110
323
  value.nil? || (value.respond_to?(:empty?) && value.empty?) ||
111
324
  (value.is_a?(String) && value.strip.empty?)
@@ -13,6 +13,10 @@ module CommandTower
13
13
  Reader.show(recipient_id:, inbox_item_id:)
14
14
  end
15
15
 
16
+ def conversation(recipient_id:, key:, scope: Reader::SCOPE_INBOX, around: nil, before_id: nil, after_id: nil, limit: nil)
17
+ Reader.conversation(recipient_id:, key:, scope:, around:, before_id:, after_id:, limit:)
18
+ end
19
+
16
20
  def unread_count(recipient_id:)
17
21
  Reader.unread_count(recipient_id:)
18
22
  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