command_tower 0.15.0 → 0.17.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/app/controllers/command_tower/application_controller.rb +1 -0
- data/app/controllers/command_tower/me/experience_states_controller.rb +51 -0
- data/app/controllers/concerns/command_tower/execution/client_compatibility_boundary.rb +39 -0
- data/app/deserializers/command_tower/deserializers/me/experience_states/complete_deserializer.rb +52 -0
- data/app/errors/command_tower/errors/account/experience_states_host_unconfigured_error.rb +21 -0
- data/app/errors/command_tower/errors/client_update_required_error.rb +27 -0
- data/app/models/command_tower/user_experience_state.rb +17 -0
- data/app/serializers/command_tower/serializers/me/experience_states/experience_state_serializer.rb +36 -0
- data/app/serializers/command_tower/serializers/messaging/inbox.rb +2 -1
- data/app/services/command_tower/email_theme/resolver.rb +45 -0
- data/app/services/command_tower/messaging/notification_types/declaration.rb +14 -1
- data/app/services/command_tower/messaging/rendering/channel_renderer.rb +44 -13
- data/app/services/command_tower/messaging/rendering/inbox_document.rb +131 -0
- data/app/services/command_tower/messaging/rendering/inbox_document_renderer.rb +207 -0
- data/app/services/command_tower/messaging/rendering/template_resolver.rb +128 -0
- data/app/services/command_tower/services/account/experience_states/complete.rb +55 -0
- data/app/services/command_tower/services/account/experience_states/list.rb +28 -0
- data/app/services/command_tower/services/client_compatibility/evaluate.rb +219 -0
- data/app/services/command_tower/services/messaging/inbox.rb +8 -1
- data/app/views/command_tower/email_verification_mailer/verify_email.html.erb +18 -17
- data/app/views/command_tower/messaging/rendering/email.html.erb +6 -5
- data/app/views/command_tower/password_reset_mailer/reset_password.html.erb +24 -23
- data/app/workflows/command_tower/workflows/auth/plain_text/login_workflow.rb +3 -0
- data/app/workflows/command_tower/workflows/auth/session/show_workflow.rb +3 -0
- data/app/workflows/command_tower/workflows/client_compatibility/evaluate_workflow.rb +83 -0
- data/app/workflows/command_tower/workflows/client_compatibility/recommendation_meta.rb +25 -0
- data/app/workflows/command_tower/workflows/me/error_mapping.rb +2 -1
- data/app/workflows/command_tower/workflows/me/experience_states/complete_workflow.rb +49 -0
- data/app/workflows/command_tower/workflows/me/experience_states/list_workflow.rb +30 -0
- data/app/workflows/command_tower/workflows/me/experience_states/workflow_support.rb +27 -0
- data/config/routes.rb +3 -0
- data/db/migrate/20260906180000_create_user_experience_states.rb +25 -0
- data/docs/api_reference.md +34 -2
- data/docs/controllers.md +1 -0
- data/docs/extending.md +2 -1
- data/docs/host_integration_guide.md +37 -1
- data/docs/initializing.md +1 -0
- data/docs/messaging_integration_guide.md +38 -0
- data/docs/upgrades/0.16.0.md +30 -0
- data/docs/upgrades/0.17.0.md +33 -0
- data/docs/upgrades/README.md +2 -0
- data/lib/command_tower/authorization/default.yml +5 -0
- data/lib/command_tower/client_compatibility/version.rb +45 -0
- data/lib/command_tower/client_compatibility.rb +23 -0
- data/lib/command_tower/configuration/application/config.rb +5 -0
- data/lib/command_tower/configuration/config.rb +6 -0
- data/lib/command_tower/configuration/email_theme/config.rb +69 -0
- data/lib/command_tower/configuration/registry/audit/config.rb +12 -0
- data/lib/command_tower/configuration/registry/client_compatibility/binding_definition.rb +42 -0
- data/lib/command_tower/configuration/registry/client_compatibility/config.rb +353 -0
- data/lib/command_tower/configuration/registry/client_compatibility/contract_definition.rb +16 -0
- data/lib/command_tower/configuration/registry/client_compatibility/entity_requirement_definition.rb +67 -0
- data/lib/command_tower/configuration/registry/client_compatibility/minimum_overrides.rb +46 -0
- data/lib/command_tower/configuration/registry/client_compatibility/platform_definition.rb +66 -0
- data/lib/command_tower/configuration/registry/config.rb +14 -0
- data/lib/command_tower/configuration/registry/inbox_presentations/config.rb +105 -0
- data/lib/command_tower/configuration/registry/inbox_presentations/presentation_definition.rb +71 -0
- data/lib/command_tower/current.rb +1 -0
- data/lib/command_tower/engine.rb +4 -0
- data/lib/command_tower/inbox_presentations.rb +19 -0
- data/lib/command_tower/install/baseline.rb +1 -0
- data/lib/command_tower/version.rb +1 -1
- data/spec/factories/user_experience_states.rb +13 -0
- metadata +37 -2
|
@@ -1,11 +1,12 @@
|
|
|
1
|
-
|
|
1
|
+
<% theme = CommandTower::EmailTheme::Resolver.resolve -%>
|
|
2
|
+
<table width="100%" cellpadding="0" cellspacing="0" border="0" style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif; background-color: <%= theme[:canvas_background] %>; padding: 40px 20px;">
|
|
2
3
|
<tr>
|
|
3
4
|
<td align="center">
|
|
4
|
-
<table width="600" cellpadding="0" cellspacing="0" border="0" style="background-color:
|
|
5
|
+
<table width="600" cellpadding="0" cellspacing="0" border="0" style="background-color: <%= theme[:surface_background] %>; border-radius: 12px; box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1); overflow: hidden;">
|
|
5
6
|
<!-- Header -->
|
|
6
7
|
<tr>
|
|
7
|
-
<td style="background:
|
|
8
|
-
<h1 style="margin: 0; color:
|
|
8
|
+
<td style="background: <%= theme[:accent] %>; padding: 40px 30px; text-align: center;">
|
|
9
|
+
<h1 style="margin: 0; color: <%= theme[:text_on_accent] %>; font-size: 28px; font-weight: 600; letter-spacing: -0.5px;">
|
|
9
10
|
Welcome to <%= CommandTower.config.app.communication_name %>!
|
|
10
11
|
</h1>
|
|
11
12
|
</td>
|
|
@@ -14,44 +15,44 @@
|
|
|
14
15
|
<!-- Content -->
|
|
15
16
|
<tr>
|
|
16
17
|
<td style="padding: 40px 30px;">
|
|
17
|
-
<p style="margin: 0 0 20px 0; color:
|
|
18
|
-
Hello <strong style="color:
|
|
18
|
+
<p style="margin: 0 0 20px 0; color: <%= theme[:body_text] %>; font-size: 16px; line-height: 1.6;">
|
|
19
|
+
Hello <strong style="color: <%= theme[:primary_text] %>;"><%= @user.full_name %></strong>,
|
|
19
20
|
</p>
|
|
20
21
|
|
|
21
|
-
<p style="margin: 0 0 30px 0; color:
|
|
22
|
+
<p style="margin: 0 0 30px 0; color: <%= theme[:body_text] %>; font-size: 16px; line-height: 1.6;">
|
|
22
23
|
Thank you for joining us! To complete your registration and verify your email address, please use the verification code below:
|
|
23
24
|
</p>
|
|
24
25
|
|
|
25
26
|
<!-- Verification Code Box -->
|
|
26
27
|
<table width="100%" cellpadding="0" cellspacing="0" border="0" style="margin: 30px 0;">
|
|
27
28
|
<tr>
|
|
28
|
-
<td align="center" style="padding: 30px; background:
|
|
29
|
-
<div style="font-size: 36px; font-weight: 700; letter-spacing: 8px; color:
|
|
29
|
+
<td align="center" style="padding: 30px; background-color: <%= theme[:canvas_background] %>; border-radius: 8px; border: 2px dashed <%= theme[:surface_border] %>;">
|
|
30
|
+
<div style="font-size: 36px; font-weight: 700; letter-spacing: 8px; color: <%= theme[:accent] %>; font-family: 'Courier New', monospace; text-align: center;">
|
|
30
31
|
<%= @code %>
|
|
31
32
|
</div>
|
|
32
|
-
<p style="margin: 15px 0 0 0; color:
|
|
33
|
+
<p style="margin: 15px 0 0 0; color: <%= theme[:muted_text] %>; font-size: 12px; text-transform: uppercase; letter-spacing: 1px;">
|
|
33
34
|
Verification Code
|
|
34
35
|
</p>
|
|
35
36
|
</td>
|
|
36
37
|
</tr>
|
|
37
38
|
</table>
|
|
38
39
|
|
|
39
|
-
<p style="margin: 30px 0 20px 0; color:
|
|
40
|
-
<strong style="color:
|
|
40
|
+
<p style="margin: 30px 0 20px 0; color: <%= theme[:muted_text] %>; font-size: 14px; line-height: 1.6;">
|
|
41
|
+
<strong style="color: <%= theme[:body_text] %>;">Important:</strong> This code will expire in <%= CommandTower.config.login.plain_text.email_verify.verify_code_link_valid_for.inspect %>. If you didn't request this code, please ignore this email.
|
|
41
42
|
</p>
|
|
42
43
|
</td>
|
|
43
44
|
</tr>
|
|
44
45
|
|
|
45
46
|
<!-- Footer -->
|
|
46
47
|
<tr>
|
|
47
|
-
<td style="padding: 30px; background-color:
|
|
48
|
-
<p style="margin: 0 0 15px 0; color:
|
|
48
|
+
<td style="padding: 30px; background-color: <%= theme[:canvas_background] %>; border-top: 1px solid <%= theme[:surface_border] %>;">
|
|
49
|
+
<p style="margin: 0 0 15px 0; color: <%= theme[:body_text] %>; font-size: 14px; text-align: center; line-height: 1.6;">
|
|
49
50
|
Best regards,<br>
|
|
50
|
-
<strong style="color:
|
|
51
|
+
<strong style="color: <%= theme[:accent] %>;"><%= CommandTower.config.app.communication_name %> Team</strong>
|
|
51
52
|
</p>
|
|
52
53
|
|
|
53
54
|
<p style="margin: 0; text-align: center;">
|
|
54
|
-
<a href="<%= CommandTower.config.app.composed_url %>" target="_blank" style="color:
|
|
55
|
+
<a href="<%= CommandTower.config.app.composed_url %>" target="_blank" style="color: <%= theme[:primary_action] %>; text-decoration: none; font-size: 14px; font-weight: 500;">
|
|
55
56
|
Visit <%= CommandTower.config.app.communication_name %>
|
|
56
57
|
</a>
|
|
57
58
|
</p>
|
|
@@ -63,7 +64,7 @@
|
|
|
63
64
|
<table width="600" cellpadding="0" cellspacing="0" border="0" style="margin-top: 20px;">
|
|
64
65
|
<tr>
|
|
65
66
|
<td style="text-align: center; padding: 20px 0;">
|
|
66
|
-
<p style="margin: 0; color:
|
|
67
|
+
<p style="margin: 0; color: <%= theme[:muted_text] %>; font-size: 12px; line-height: 1.5;">
|
|
67
68
|
This is an automated message. Please do not reply to this email.
|
|
68
69
|
</p>
|
|
69
70
|
</td>
|
|
@@ -1,21 +1,22 @@
|
|
|
1
|
-
|
|
1
|
+
<% theme = CommandTower::EmailTheme::Resolver.resolve -%>
|
|
2
|
+
<table width="100%" cellpadding="0" cellspacing="0" border="0" style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif; background-color: <%= theme[:canvas_background] %>; padding: 32px 16px;">
|
|
2
3
|
<tr>
|
|
3
4
|
<td align="center">
|
|
4
|
-
<table width="600" cellpadding="0" cellspacing="0" border="0" style="background-color:
|
|
5
|
+
<table width="600" cellpadding="0" cellspacing="0" border="0" style="background-color: <%= theme[:surface_background] %>; border: 1px solid <%= theme[:surface_border] %>; border-radius: 8px; overflow: hidden;">
|
|
5
6
|
<tr>
|
|
6
7
|
<td style="padding: 28px 28px 8px 28px;">
|
|
7
|
-
<h1 style="margin: 0; color:
|
|
8
|
+
<h1 style="margin: 0; color: <%= theme[:primary_text] %>; font-size: 22px; font-weight: 600; line-height: 1.35;">
|
|
8
9
|
<%= h.call(title) %>
|
|
9
10
|
</h1>
|
|
10
11
|
</td>
|
|
11
12
|
</tr>
|
|
12
13
|
<tr>
|
|
13
14
|
<td style="padding: 8px 28px 28px 28px;">
|
|
14
|
-
<p style="margin: 0; color:
|
|
15
|
+
<p style="margin: 0; color: <%= theme[:body_text] %>; font-size: 16px; line-height: 1.6; white-space: pre-wrap;">
|
|
15
16
|
<%= h.call(body) %></p>
|
|
16
17
|
<% if deep_link -%>
|
|
17
18
|
<p style="margin: 24px 0 0 0;">
|
|
18
|
-
<a href="<%= h.call(deep_link) %>" style="color:
|
|
19
|
+
<a href="<%= h.call(deep_link) %>" style="color: <%= theme[:primary_action] %>; font-size: 14px; text-decoration: underline;">
|
|
19
20
|
<%= h.call(deep_link) %>
|
|
20
21
|
</a>
|
|
21
22
|
</p>
|
|
@@ -1,11 +1,12 @@
|
|
|
1
|
-
|
|
1
|
+
<% theme = CommandTower::EmailTheme::Resolver.resolve -%>
|
|
2
|
+
<table width="100%" cellpadding="0" cellspacing="0" border="0" style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif; background-color: <%= theme[:canvas_background] %>; padding: 40px 20px;">
|
|
2
3
|
<tr>
|
|
3
4
|
<td align="center">
|
|
4
|
-
<table width="600" cellpadding="0" cellspacing="0" border="0" style="background-color:
|
|
5
|
+
<table width="600" cellpadding="0" cellspacing="0" border="0" style="background-color: <%= theme[:surface_background] %>; border-radius: 12px; box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1); overflow: hidden;">
|
|
5
6
|
<!-- Header -->
|
|
6
7
|
<tr>
|
|
7
|
-
<td style="background:
|
|
8
|
-
<h1 style="margin: 0; color:
|
|
8
|
+
<td style="background: <%= theme[:accent] %>; padding: 40px 30px; text-align: center;">
|
|
9
|
+
<h1 style="margin: 0; color: <%= theme[:text_on_accent] %>; font-size: 28px; font-weight: 600; letter-spacing: -0.5px;">
|
|
9
10
|
Reset Your Password
|
|
10
11
|
</h1>
|
|
11
12
|
</td>
|
|
@@ -14,22 +15,22 @@
|
|
|
14
15
|
<!-- Content -->
|
|
15
16
|
<tr>
|
|
16
17
|
<td style="padding: 40px 30px;">
|
|
17
|
-
<p style="margin: 0 0 20px 0; color:
|
|
18
|
-
Hello <strong style="color:
|
|
18
|
+
<p style="margin: 0 0 20px 0; color: <%= theme[:body_text] %>; font-size: 16px; line-height: 1.6;">
|
|
19
|
+
Hello <strong style="color: <%= theme[:primary_text] %>;"><%= @user.full_name %></strong>,
|
|
19
20
|
</p>
|
|
20
21
|
|
|
21
|
-
<p style="margin: 0 0 30px 0; color:
|
|
22
|
+
<p style="margin: 0 0 30px 0; color: <%= theme[:body_text] %>; font-size: 16px; line-height: 1.6;">
|
|
22
23
|
We received a request to reset your password for your <%= CommandTower.config.app.communication_name %> account. Click the button below to reset your password:
|
|
23
24
|
</p>
|
|
24
25
|
|
|
25
26
|
<!-- Reset Token Box -->
|
|
26
27
|
<table width="100%" cellpadding="0" cellspacing="0" border="0" style="margin: 30px 0;">
|
|
27
28
|
<tr>
|
|
28
|
-
<td align="center" style="padding: 30px; background:
|
|
29
|
-
<div style="font-size: 18px; font-weight: 600; color:
|
|
29
|
+
<td align="center" style="padding: 30px; background-color: <%= theme[:canvas_background] %>; border-radius: 8px; border: 2px dashed <%= theme[:surface_border] %>;">
|
|
30
|
+
<div style="font-size: 18px; font-weight: 600; color: <%= theme[:accent] %>; font-family: 'Courier New', monospace; text-align: center; word-break: break-all; padding: 10px;">
|
|
30
31
|
<%= @token %>
|
|
31
32
|
</div>
|
|
32
|
-
<p style="margin: 15px 0 0 0; color:
|
|
33
|
+
<p style="margin: 15px 0 0 0; color: <%= theme[:muted_text] %>; font-size: 12px; text-transform: uppercase; letter-spacing: 1px;">
|
|
33
34
|
Reset Token
|
|
34
35
|
</p>
|
|
35
36
|
</td>
|
|
@@ -42,30 +43,30 @@
|
|
|
42
43
|
<td align="center" style="padding: 15px 0;">
|
|
43
44
|
<% reset_url = "#{CommandTower.config.app.composed_url}#{@reset_password_path}?token=#{@token}" %>
|
|
44
45
|
<% reset_url += "&email=#{CGI.escape(@email)}" if @require_email %>
|
|
45
|
-
<a href="<%= reset_url %>" style="display: inline-block; padding: 14px 32px; background:
|
|
46
|
+
<a href="<%= reset_url %>" style="display: inline-block; padding: 14px 32px; background-color: <%= theme[:primary_action] %>; color: <%= theme[:text_on_accent] %>; text-decoration: none; border-radius: 8px; font-weight: 600; font-size: 16px; text-align: center;">
|
|
46
47
|
Reset Password
|
|
47
48
|
</a>
|
|
48
49
|
</td>
|
|
49
50
|
</tr>
|
|
50
51
|
</table>
|
|
51
52
|
|
|
52
|
-
<p style="margin: 30px 0 20px 0; color:
|
|
53
|
-
<strong style="color:
|
|
53
|
+
<p style="margin: 30px 0 20px 0; color: <%= theme[:muted_text] %>; font-size: 14px; line-height: 1.6;">
|
|
54
|
+
<strong style="color: <%= theme[:body_text] %>;">Important:</strong> This link will expire in <%= CommandTower.config.login.plain_text.password_reset.token_valid_for.inspect %>. If you didn't request a password reset, please ignore this email. Your password will remain unchanged.
|
|
54
55
|
</p>
|
|
55
56
|
|
|
56
|
-
<p style="margin: 20px 0 0 0; color:
|
|
57
|
+
<p style="margin: 20px 0 0 0; color: <%= theme[:muted_text] %>; font-size: 14px; line-height: 1.6;">
|
|
57
58
|
If the button above doesn't work, copy and paste the reset token above into the password reset form on our website.
|
|
58
59
|
</p>
|
|
59
60
|
|
|
60
61
|
<!-- Alternative Reset URL Link -->
|
|
61
62
|
<table width="100%" cellpadding="0" cellspacing="0" border="0" style="margin: 30px 0 0 0;">
|
|
62
63
|
<tr>
|
|
63
|
-
<td align="center" style="padding: 20px; background-color:
|
|
64
|
-
<p style="margin: 0 0 10px 0; color:
|
|
64
|
+
<td align="center" style="padding: 20px; background-color: <%= theme[:canvas_background] %>; border-radius: 8px; border: 1px solid <%= theme[:surface_border] %>;">
|
|
65
|
+
<p style="margin: 0 0 10px 0; color: <%= theme[:body_text] %>; font-size: 14px; font-weight: 600;">
|
|
65
66
|
Or visit the reset password page directly:
|
|
66
67
|
</p>
|
|
67
|
-
<p style="margin: 0; color:
|
|
68
|
-
<a href="<%= CommandTower.config.app.composed_url %><%= @reset_password_path %>" style="color:
|
|
68
|
+
<p style="margin: 0; color: <%= theme[:primary_action] %>; font-size: 14px; word-break: break-all;">
|
|
69
|
+
<a href="<%= CommandTower.config.app.composed_url %><%= @reset_password_path %>" style="color: <%= theme[:primary_action] %>; text-decoration: underline;">
|
|
69
70
|
<%= CommandTower.config.app.composed_url %><%= @reset_password_path %>
|
|
70
71
|
</a>
|
|
71
72
|
</p>
|
|
@@ -77,14 +78,14 @@
|
|
|
77
78
|
|
|
78
79
|
<!-- Footer -->
|
|
79
80
|
<tr>
|
|
80
|
-
<td style="padding: 30px; background-color:
|
|
81
|
-
<p style="margin: 0 0 15px 0; color:
|
|
81
|
+
<td style="padding: 30px; background-color: <%= theme[:canvas_background] %>; border-top: 1px solid <%= theme[:surface_border] %>;">
|
|
82
|
+
<p style="margin: 0 0 15px 0; color: <%= theme[:body_text] %>; font-size: 14px; text-align: center; line-height: 1.6;">
|
|
82
83
|
Best regards,<br>
|
|
83
|
-
<strong style="color:
|
|
84
|
+
<strong style="color: <%= theme[:accent] %>;"><%= CommandTower.config.app.communication_name %> Team</strong>
|
|
84
85
|
</p>
|
|
85
86
|
|
|
86
87
|
<p style="margin: 0; text-align: center;">
|
|
87
|
-
<a href="<%= CommandTower.config.app.composed_url %>" target="_blank" style="color:
|
|
88
|
+
<a href="<%= CommandTower.config.app.composed_url %>" target="_blank" style="color: <%= theme[:primary_action] %>; text-decoration: none; font-size: 14px; font-weight: 500;">
|
|
88
89
|
Visit <%= CommandTower.config.app.communication_name %>
|
|
89
90
|
</a>
|
|
90
91
|
</p>
|
|
@@ -96,7 +97,7 @@
|
|
|
96
97
|
<table width="600" cellpadding="0" cellspacing="0" border="0" style="margin-top: 20px;">
|
|
97
98
|
<tr>
|
|
98
99
|
<td style="text-align: center; padding: 20px 0;">
|
|
99
|
-
<p style="margin: 0; color:
|
|
100
|
+
<p style="margin: 0; color: <%= theme[:muted_text] %>; font-size: 12px; line-height: 1.5;">
|
|
100
101
|
This is an automated message. Please do not reply to this email.
|
|
101
102
|
</p>
|
|
102
103
|
</td>
|
|
@@ -5,6 +5,8 @@ module CommandTower
|
|
|
5
5
|
module Auth
|
|
6
6
|
module PlainText
|
|
7
7
|
class LoginWorkflow < CommandTower::Workflows::ApplicationWorkflow
|
|
8
|
+
include CommandTower::Workflows::ClientCompatibility::RecommendationMeta
|
|
9
|
+
|
|
8
10
|
retry_strategy :none
|
|
9
11
|
|
|
10
12
|
def call(input:, request_context: nil)
|
|
@@ -24,6 +26,7 @@ module CommandTower
|
|
|
24
26
|
token_expires_at: data[:expires_at]
|
|
25
27
|
),
|
|
26
28
|
http_status: :created,
|
|
29
|
+
meta: client_compatibility_meta,
|
|
27
30
|
response_effects: {
|
|
28
31
|
set_token: { token: data[:token], expires_at: data[:expires_at] },
|
|
29
32
|
ensure_csrf_cookie: { rotate: csrf_rotate_on_login? }
|
|
@@ -5,6 +5,8 @@ module CommandTower
|
|
|
5
5
|
module Auth
|
|
6
6
|
module Session
|
|
7
7
|
class ShowWorkflow < CommandTower::Workflows::ApplicationWorkflow
|
|
8
|
+
include CommandTower::Workflows::ClientCompatibility::RecommendationMeta
|
|
9
|
+
|
|
8
10
|
retry_strategy :none
|
|
9
11
|
|
|
10
12
|
def call(current_user:, auth_context:)
|
|
@@ -30,6 +32,7 @@ module CommandTower
|
|
|
30
32
|
actor: auth_context.actor_user
|
|
31
33
|
),
|
|
32
34
|
http_status: :ok,
|
|
35
|
+
meta: client_compatibility_meta,
|
|
33
36
|
response_effects: response_effects
|
|
34
37
|
)
|
|
35
38
|
end
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module CommandTower
|
|
4
|
+
module Workflows
|
|
5
|
+
module ClientCompatibility
|
|
6
|
+
# Orchestrates one HTTP request's client-version-compatibility check.
|
|
7
|
+
# Maps the pure `Services::ClientCompatibility::Evaluate` projection to
|
|
8
|
+
# a `WorkflowResult`, and is the only layer (along with the boundary)
|
|
9
|
+
# allowed to place recommendation metadata onto `CommandTower::Current`
|
|
10
|
+
# — `Evaluate` itself never touches `Current` (authority §10, §13).
|
|
11
|
+
class EvaluateWorkflow < CommandTower::Workflows::ApplicationWorkflow
|
|
12
|
+
retry_strategy :none
|
|
13
|
+
|
|
14
|
+
APP_VERSION_HEADER = "X-App-Version"
|
|
15
|
+
PLATFORM_HEADER = "X-Client-Platform"
|
|
16
|
+
|
|
17
|
+
def call(request:, controller_class:, action_name:)
|
|
18
|
+
decision = CommandTower::Services::ClientCompatibility::Evaluate.call(
|
|
19
|
+
app_version_header: request.headers[APP_VERSION_HEADER],
|
|
20
|
+
platform_header: request.headers[PLATFORM_HEADER],
|
|
21
|
+
controller_class: controller_class,
|
|
22
|
+
action_name: action_name
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
mode = CommandTower.config.registry.client_compatibility.mode
|
|
26
|
+
enforced = mode == :enforce
|
|
27
|
+
blocked = enforced && decision.incompatible?
|
|
28
|
+
|
|
29
|
+
log_decision(decision, mode:, enforced:, blocked:)
|
|
30
|
+
stash_recommendation!(decision)
|
|
31
|
+
|
|
32
|
+
if blocked
|
|
33
|
+
return failure(
|
|
34
|
+
errors: [CommandTower::Errors::ClientUpdateRequiredError.new(details: details_for(decision))],
|
|
35
|
+
http_status: :upgrade_required
|
|
36
|
+
)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
success(payload: { decision: decision })
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
private
|
|
43
|
+
|
|
44
|
+
def stash_recommendation!(decision)
|
|
45
|
+
return unless decision.recommendation_projection
|
|
46
|
+
|
|
47
|
+
CommandTower::Current.client_compatibility_recommendation = decision.recommendation_projection
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def details_for(decision)
|
|
51
|
+
{
|
|
52
|
+
platform: decision.platform&.to_s,
|
|
53
|
+
scope: decision.scope&.to_s,
|
|
54
|
+
currentVersion: decision.current_version,
|
|
55
|
+
minimumVersion: decision.effective_minimum,
|
|
56
|
+
recovery: decision.recovery&.to_s,
|
|
57
|
+
updateUrl: decision.update_url
|
|
58
|
+
}.compact
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# Structured (non-audit) decision log via the semantic `command_tower.log.*`
|
|
62
|
+
# event contract (authority §20). Workflows must not write lifecycle
|
|
63
|
+
# observation directly to `Rails.logger` — `publish_event` routes
|
|
64
|
+
# through `CommandTower::Logging::Subscriber` like every other
|
|
65
|
+
# semantic log line.
|
|
66
|
+
def log_decision(decision, mode:, enforced:, blocked:)
|
|
67
|
+
payload = {
|
|
68
|
+
message: "client_compatibility.evaluated",
|
|
69
|
+
mode: mode,
|
|
70
|
+
enforced: enforced,
|
|
71
|
+
platform: decision.platform,
|
|
72
|
+
app_version: decision.current_version,
|
|
73
|
+
matched_entities: decision.matched_entity_names,
|
|
74
|
+
decision: decision.decision,
|
|
75
|
+
http_status: blocked ? 426 : nil
|
|
76
|
+
}.compact
|
|
77
|
+
|
|
78
|
+
publish_event(category: :log, name: blocked ? :warn : :info, payload:)
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
end
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module CommandTower
|
|
4
|
+
module Workflows
|
|
5
|
+
module ClientCompatibility
|
|
6
|
+
# Shared sequence fragment: reads the recommendation projection that
|
|
7
|
+
# `EvaluateWorkflow` stashed on `Current` (never written by `Evaluate`
|
|
8
|
+
# itself) and shapes it into `WorkflowResult.meta`. Included only by
|
|
9
|
+
# Login and Session::Show — recommended-update guidance is deliberately
|
|
10
|
+
# not attached to every success envelope (authority §13).
|
|
11
|
+
module RecommendationMeta
|
|
12
|
+
extend ActiveSupport::Concern
|
|
13
|
+
|
|
14
|
+
private
|
|
15
|
+
|
|
16
|
+
def client_compatibility_meta
|
|
17
|
+
recommendation = CommandTower::Current.client_compatibility_recommendation
|
|
18
|
+
return {} if recommendation.blank?
|
|
19
|
+
|
|
20
|
+
{ clientCompatibility: recommendation }
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
end
|
|
@@ -33,7 +33,8 @@ module CommandTower
|
|
|
33
33
|
:too_many_requests
|
|
34
34
|
when CommandTower::Errors::Account::SmsCapabilityUnavailableError,
|
|
35
35
|
CommandTower::Errors::Account::PushoverCapabilityUnavailableError,
|
|
36
|
-
CommandTower::Errors::Account::PushCapabilityUnavailableError
|
|
36
|
+
CommandTower::Errors::Account::PushCapabilityUnavailableError,
|
|
37
|
+
CommandTower::Errors::Account::ExperienceStatesHostUnconfiguredError
|
|
37
38
|
:service_unavailable
|
|
38
39
|
when CommandTower::Errors::Account::PhoneVerificationSendFailedError,
|
|
39
40
|
CommandTower::Errors::Account::PushoverProviderUnavailableError
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module CommandTower
|
|
4
|
+
module Workflows
|
|
5
|
+
module Me
|
|
6
|
+
module ExperienceStates
|
|
7
|
+
class CompleteWorkflow < CommandTower::Workflows::ApplicationWorkflow
|
|
8
|
+
retry_strategy :none
|
|
9
|
+
|
|
10
|
+
def call(current_user:, experience_key:, scope_type:, scope_identifier:, version:, auth_context: nil)
|
|
11
|
+
result = CommandTower::Services::Account::ExperienceStates::Complete.call(
|
|
12
|
+
user: current_user,
|
|
13
|
+
experience_key:,
|
|
14
|
+
scope_type:,
|
|
15
|
+
scope_identifier:,
|
|
16
|
+
version:,
|
|
17
|
+
)
|
|
18
|
+
unless result.success?
|
|
19
|
+
error = result.errors.first
|
|
20
|
+
return failure(
|
|
21
|
+
errors: result.errors,
|
|
22
|
+
http_status: CommandTower::Workflows::Me::ErrorMapping.http_status_for(error),
|
|
23
|
+
)
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
if result.data[:created]
|
|
27
|
+
audit(
|
|
28
|
+
:experience_state_completed,
|
|
29
|
+
affected_user: current_user,
|
|
30
|
+
changes: {},
|
|
31
|
+
scope_class: :host,
|
|
32
|
+
host_context: {
|
|
33
|
+
type: scope_type,
|
|
34
|
+
identifier: scope_identifier,
|
|
35
|
+
},
|
|
36
|
+
)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
success(
|
|
40
|
+
payload: WorkflowSupport.serialize_view(result.data[:experience_state]),
|
|
41
|
+
http_status: :ok,
|
|
42
|
+
response_effects: WorkflowSupport.expire_header_effects(auth_context),
|
|
43
|
+
)
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module CommandTower
|
|
4
|
+
module Workflows
|
|
5
|
+
module Me
|
|
6
|
+
module ExperienceStates
|
|
7
|
+
class ListWorkflow < CommandTower::Workflows::ApplicationWorkflow
|
|
8
|
+
retry_strategy :none
|
|
9
|
+
|
|
10
|
+
def call(current_user:, auth_context: nil)
|
|
11
|
+
result = CommandTower::Services::Account::ExperienceStates::List.call(user: current_user)
|
|
12
|
+
unless result.success?
|
|
13
|
+
error = result.errors.first
|
|
14
|
+
return failure(
|
|
15
|
+
errors: result.errors,
|
|
16
|
+
http_status: CommandTower::Workflows::Me::ErrorMapping.http_status_for(error),
|
|
17
|
+
)
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
success(
|
|
21
|
+
payload: WorkflowSupport.serialize_collection(result.data[:experience_states]),
|
|
22
|
+
http_status: :ok,
|
|
23
|
+
response_effects: WorkflowSupport.expire_header_effects(auth_context),
|
|
24
|
+
)
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module CommandTower
|
|
4
|
+
module Workflows
|
|
5
|
+
module Me
|
|
6
|
+
module ExperienceStates
|
|
7
|
+
module WorkflowSupport
|
|
8
|
+
module_function
|
|
9
|
+
|
|
10
|
+
def expire_header_effects(auth_context)
|
|
11
|
+
return if auth_context.nil?
|
|
12
|
+
|
|
13
|
+
{ set_expire_header: auth_context.token_expires_at }
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def serialize_view(state)
|
|
17
|
+
CommandTower::Serializers::Me::ExperienceStates::ExperienceStateSerializer.serialize(state)
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def serialize_collection(states)
|
|
21
|
+
CommandTower::Serializers::Me::ExperienceStates::ExperienceStateSerializer.serialize_collection(states)
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|
data/config/routes.rb
CHANGED
|
@@ -111,6 +111,9 @@ CommandTower::Engine.routes.draw do
|
|
|
111
111
|
patch "push/:id", to: "push#update"
|
|
112
112
|
put "push/:id", to: "push#update"
|
|
113
113
|
delete "push/:id", to: "push#destroy"
|
|
114
|
+
|
|
115
|
+
get "experience-states", to: "experience_states#index"
|
|
116
|
+
post "experience-states/complete", to: "experience_states#complete"
|
|
114
117
|
end
|
|
115
118
|
|
|
116
119
|
namespace :admin do
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
class CreateUserExperienceStates < ActiveRecord::Migration[7.2]
|
|
4
|
+
def change
|
|
5
|
+
create_table :user_experience_states do |t|
|
|
6
|
+
t.timestamps
|
|
7
|
+
t.references :user, null: false, foreign_key: true
|
|
8
|
+
t.string :host_key, null: false, limit: 128
|
|
9
|
+
t.string :experience_key, null: false, limit: 128
|
|
10
|
+
t.string :scope_type, null: false, limit: 128
|
|
11
|
+
t.string :scope_identifier, null: false, limit: 128
|
|
12
|
+
t.string :version, null: false, limit: 128
|
|
13
|
+
t.datetime :completed_at, null: false
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
add_index :user_experience_states,
|
|
17
|
+
%i[user_id host_key experience_key scope_type scope_identifier version],
|
|
18
|
+
unique: true,
|
|
19
|
+
name: "index_user_experience_states_unique"
|
|
20
|
+
|
|
21
|
+
add_index :user_experience_states,
|
|
22
|
+
%i[user_id host_key],
|
|
23
|
+
name: "index_user_experience_states_on_user_host"
|
|
24
|
+
end
|
|
25
|
+
end
|
data/docs/api_reference.md
CHANGED
|
@@ -338,8 +338,8 @@ Pagination for list: query `limit` (default **50**, max **100**), `offset` (defa
|
|
|
338
338
|
| Method | Path | Notes |
|
|
339
339
|
|--------|------|--------|
|
|
340
340
|
| `GET` | `/me/inbox` | List — `data` array of items; pagination meta |
|
|
341
|
-
| `GET` | `/me/inbox/:id` | Detail (+ `body`, `metadata`, `notificationTypeKey`) |
|
|
342
|
-
| `POST` | `/me/inbox/:id/open` | Detail |
|
|
341
|
+
| `GET` | `/me/inbox/:id` | Detail (+ `body`, `metadata`, `notificationTypeKey`, `content`) |
|
|
342
|
+
| `POST` | `/me/inbox/:id/open` | Detail (+ `content`) |
|
|
343
343
|
| `PATCH` | `/me/inbox/:id/archive` | Item |
|
|
344
344
|
| `DELETE` | `/me/inbox/:id` | `data: null` |
|
|
345
345
|
| `GET` | `/me/inbox/unread-count` | `{ count }` |
|
|
@@ -349,6 +349,17 @@ Pagination for list: query `limit` (default **50**, max **100**), `offset` (defa
|
|
|
349
349
|
| `POST` | `/me/inbox/bulk/restore` | same |
|
|
350
350
|
| `POST` | `/me/inbox/bulk/delete` | same |
|
|
351
351
|
|
|
352
|
+
**`content` (detail only, response-only — `inbox_document_v1`):** rendered at read from the item's `Communication`, never persisted; absent from list items. Shape: `{ schema: "inbox_document_v1", blocks: [...] }`. Allowlisted block types:
|
|
353
|
+
|
|
354
|
+
| Block | Fields | Notes |
|
|
355
|
+
|-------|--------|-------|
|
|
356
|
+
| `paragraph` | `text` (string) | |
|
|
357
|
+
| `cta` | `label` (string), `href` (string) | `href` must be `http(s)` or a custom scheme (e.g. `pickem://...`); `javascript:`/`data:`/`vbscript:`, schemeless, blank, and unparsable hrefs are rejected — the `cta` block is simply omitted, never an error |
|
|
358
|
+
|
|
359
|
+
Generic (default) rendering: one `paragraph` block from `communication.body` (omitted if blank — `blocks` can legitimately be `[]`), plus one `cta` block if `metadata.deep_link` is a safe href (label from `metadata.cta_label`, default `"Open"`).
|
|
360
|
+
|
|
361
|
+
Hosts may override the document per `notificationTypeKey` with an `inbox_document.json.erb` view at `app/views/command_tower/messaging/rendering/<notification_type_key>/inbox_document.json.erb` (same lookup convention as [`messaging_integration_guide.md`](messaging_integration_guide.md#rendering-template-overrides)). Any failure resolving or rendering that template (missing file, malformed JSON, wrong `schema`/`blocks` shape, or a raising template) fails open to the generic document — the Inbox read path never 500s on a bad type template. A valid envelope with one invalid/unknown block strips only that block; if stripping empties `blocks`, the generic document is used instead.
|
|
362
|
+
|
|
352
363
|
**List item fields:** `id`, `title`, `status`, `read`, `viewedAt`, `createdAt`, `updatedAt`.
|
|
353
364
|
|
|
354
365
|
**Errors:** `401` / `403` / `422`; show/open may return `404` `not_found`.
|
|
@@ -447,6 +458,27 @@ There is **no** `POST /me/push/verification`. Create and replace call `Endpoints
|
|
|
447
458
|
|
|
448
459
|
---
|
|
449
460
|
|
|
461
|
+
## Experience states
|
|
462
|
+
|
|
463
|
+
Durable completion facts for host-composed experiences. CommandTower stores opaque identity keys only — no League / Season / Tenant semantics and no presentation instructions such as `showWelcome`.
|
|
464
|
+
|
|
465
|
+
`config.application.host_key` is **server-bound**. The client must not supply `hostKey` / `host_key`. When `host_key` is blank, both routes return **503** `experience_states_host_unconfigured`.
|
|
466
|
+
|
|
467
|
+
| Method | Path | Body | Notes |
|
|
468
|
+
|--------|------|------|--------|
|
|
469
|
+
| `GET` | `/me/experience-states` | — | `{ experienceStates: [...] }` — **completed rows only** for the configured host |
|
|
470
|
+
| `POST` | `/me/experience-states/complete` | `experienceKey`/`experience_key`, `scopeType`/`scope_type`, `scopeIdentifier`/`scope_identifier`, `version` (snake or camelCase) | Idempotent complete; returns the durable fact |
|
|
471
|
+
|
|
472
|
+
**Fact fields:** `hostKey`, `experienceKey`, `scopeType`, `scopeIdentifier`, `version`, `completedAt`. Never `showWelcome` / applicability / presentation instructions.
|
|
473
|
+
|
|
474
|
+
**RBAC:** `me_experience_states` (`index`, `complete`). Grant explicitly on host roles (dummy host `member` includes it).
|
|
475
|
+
|
|
476
|
+
**Audit:** first durable creation emits `experience_state_completed` (opaque `host_context` type/identifier). Idempotent replay does not emit again.
|
|
477
|
+
|
|
478
|
+
**Spec:** `spec/requests/command_tower/me/experience_states_spec.rb`.
|
|
479
|
+
|
|
480
|
+
---
|
|
481
|
+
|
|
450
482
|
## Admin Workspace
|
|
451
483
|
|
|
452
484
|
### `GET /admin/workspace`
|
data/docs/controllers.md
CHANGED
|
@@ -21,6 +21,7 @@ This page is an **index** of route areas. Detailed request/response contracts li
|
|
|
21
21
|
| Phone | `/me/phone*` | Phone endpoint + verification |
|
|
22
22
|
| Pushover | `/me/pushover*` | Pushover endpoint lifecycle + verification |
|
|
23
23
|
| Push | `/me/push*` | Expo push endpoint collection (register / replace / revoke) |
|
|
24
|
+
| Experience states | `/me/experience-states*` | Durable completed experience-state facts (list / complete) |
|
|
24
25
|
| Admin messaging | `/admin/messaging/announcements` | Cohort announcements |
|
|
25
26
|
|
|
26
27
|
Exact paths depend on where the host mounts the engine.
|
data/docs/extending.md
CHANGED
|
@@ -19,6 +19,7 @@ Layer map: [architecture.md](architecture.md). Install/configure/migrate/doctor:
|
|
|
19
19
|
| Model reopen | Product associations / behavior |
|
|
20
20
|
| Initializers | Configuration, including `config.registry.audit.event`, `config.registry.admin_workspace.tool`, and `config.registry.principal_capabilities.capability` |
|
|
21
21
|
| Notification catalogs / channel policy | Host-owned messaging customization |
|
|
22
|
+
| `app/views/command_tower/messaging/rendering/**` | Host override of generic and/or per-`notification_type_key` rendered Email/SMS/Pushover/Push templates — see [messaging_integration_guide.md](messaging_integration_guide.md#rendering-template-overrides) |
|
|
22
23
|
|
|
23
24
|
## Internal platform — do not extend
|
|
24
25
|
|
|
@@ -28,7 +29,7 @@ Layer map: [architecture.md](architecture.md). Install/configure/migrate/doctor:
|
|
|
28
29
|
| ServiceBase | Shared service framework |
|
|
29
30
|
| Serializers | Platform response shaping |
|
|
30
31
|
| Deserializers | Platform request trust boundary |
|
|
31
|
-
| Messaging execution pipeline | Handoff / execution / accept internals |
|
|
32
|
+
| Messaging execution pipeline | Handoff / execution / accept internals, including `Messaging::Rendering::ChannelRenderer` / `TemplateResolver` Ruby classes — hosts customize rendering by dropping ERB views (above), never by reopening or calling these classes |
|
|
32
33
|
| RequestContext | Framework request context |
|
|
33
34
|
| JWT primitives | Token issue / validate plumbing |
|
|
34
35
|
| Internal framework plumbing | Envelope renderer, workflow base mechanics, etc. |
|