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.
- checksums.yaml +4 -4
- data/app/controllers/command_tower/application_controller.rb +1 -0
- data/app/controllers/concerns/command_tower/execution/client_compatibility_boundary.rb +39 -0
- data/app/errors/command_tower/errors/client_update_required_error.rb +27 -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/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/docs/api_reference.md +13 -2
- data/docs/extending.md +2 -1
- data/docs/host_integration_guide.md +35 -0
- data/docs/messaging_integration_guide.md +38 -0
- data/docs/upgrades/0.17.0.md +33 -0
- data/docs/upgrades/README.md +1 -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/config.rb +6 -0
- data/lib/command_tower/configuration/email_theme/config.rb +69 -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/version.rb +1 -1
- metadata +24 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 465f6aea711f7cb8158e7b3eb9389663c2ac06d5104e89335da339d04cda81b6
|
|
4
|
+
data.tar.gz: 3e8c5a0b870ec8fd71f954ee3e04573cf87bf3eb3b8091e056dddb0db37176a6
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
-
|
|
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
|
-
|
|
124
|
-
raise Errno::ENOENT, "missing template #{filename}" unless
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
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
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
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
|