command_tower 0.15.0 → 0.17.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (65) hide show
  1. checksums.yaml +4 -4
  2. data/app/controllers/command_tower/application_controller.rb +1 -0
  3. data/app/controllers/command_tower/me/experience_states_controller.rb +51 -0
  4. data/app/controllers/concerns/command_tower/execution/client_compatibility_boundary.rb +39 -0
  5. data/app/deserializers/command_tower/deserializers/me/experience_states/complete_deserializer.rb +52 -0
  6. data/app/errors/command_tower/errors/account/experience_states_host_unconfigured_error.rb +21 -0
  7. data/app/errors/command_tower/errors/client_update_required_error.rb +27 -0
  8. data/app/models/command_tower/user_experience_state.rb +17 -0
  9. data/app/serializers/command_tower/serializers/me/experience_states/experience_state_serializer.rb +36 -0
  10. data/app/serializers/command_tower/serializers/messaging/inbox.rb +2 -1
  11. data/app/services/command_tower/email_theme/resolver.rb +45 -0
  12. data/app/services/command_tower/messaging/notification_types/declaration.rb +14 -1
  13. data/app/services/command_tower/messaging/rendering/channel_renderer.rb +44 -13
  14. data/app/services/command_tower/messaging/rendering/inbox_document.rb +131 -0
  15. data/app/services/command_tower/messaging/rendering/inbox_document_renderer.rb +207 -0
  16. data/app/services/command_tower/messaging/rendering/template_resolver.rb +128 -0
  17. data/app/services/command_tower/services/account/experience_states/complete.rb +55 -0
  18. data/app/services/command_tower/services/account/experience_states/list.rb +28 -0
  19. data/app/services/command_tower/services/client_compatibility/evaluate.rb +219 -0
  20. data/app/services/command_tower/services/messaging/inbox.rb +8 -1
  21. data/app/views/command_tower/email_verification_mailer/verify_email.html.erb +18 -17
  22. data/app/views/command_tower/messaging/rendering/email.html.erb +6 -5
  23. data/app/views/command_tower/password_reset_mailer/reset_password.html.erb +24 -23
  24. data/app/workflows/command_tower/workflows/auth/plain_text/login_workflow.rb +3 -0
  25. data/app/workflows/command_tower/workflows/auth/session/show_workflow.rb +3 -0
  26. data/app/workflows/command_tower/workflows/client_compatibility/evaluate_workflow.rb +83 -0
  27. data/app/workflows/command_tower/workflows/client_compatibility/recommendation_meta.rb +25 -0
  28. data/app/workflows/command_tower/workflows/me/error_mapping.rb +2 -1
  29. data/app/workflows/command_tower/workflows/me/experience_states/complete_workflow.rb +49 -0
  30. data/app/workflows/command_tower/workflows/me/experience_states/list_workflow.rb +30 -0
  31. data/app/workflows/command_tower/workflows/me/experience_states/workflow_support.rb +27 -0
  32. data/config/routes.rb +3 -0
  33. data/db/migrate/20260906180000_create_user_experience_states.rb +25 -0
  34. data/docs/api_reference.md +34 -2
  35. data/docs/controllers.md +1 -0
  36. data/docs/extending.md +2 -1
  37. data/docs/host_integration_guide.md +37 -1
  38. data/docs/initializing.md +1 -0
  39. data/docs/messaging_integration_guide.md +38 -0
  40. data/docs/upgrades/0.16.0.md +30 -0
  41. data/docs/upgrades/0.17.0.md +33 -0
  42. data/docs/upgrades/README.md +2 -0
  43. data/lib/command_tower/authorization/default.yml +5 -0
  44. data/lib/command_tower/client_compatibility/version.rb +45 -0
  45. data/lib/command_tower/client_compatibility.rb +23 -0
  46. data/lib/command_tower/configuration/application/config.rb +5 -0
  47. data/lib/command_tower/configuration/config.rb +6 -0
  48. data/lib/command_tower/configuration/email_theme/config.rb +69 -0
  49. data/lib/command_tower/configuration/registry/audit/config.rb +12 -0
  50. data/lib/command_tower/configuration/registry/client_compatibility/binding_definition.rb +42 -0
  51. data/lib/command_tower/configuration/registry/client_compatibility/config.rb +353 -0
  52. data/lib/command_tower/configuration/registry/client_compatibility/contract_definition.rb +16 -0
  53. data/lib/command_tower/configuration/registry/client_compatibility/entity_requirement_definition.rb +67 -0
  54. data/lib/command_tower/configuration/registry/client_compatibility/minimum_overrides.rb +46 -0
  55. data/lib/command_tower/configuration/registry/client_compatibility/platform_definition.rb +66 -0
  56. data/lib/command_tower/configuration/registry/config.rb +14 -0
  57. data/lib/command_tower/configuration/registry/inbox_presentations/config.rb +105 -0
  58. data/lib/command_tower/configuration/registry/inbox_presentations/presentation_definition.rb +71 -0
  59. data/lib/command_tower/current.rb +1 -0
  60. data/lib/command_tower/engine.rb +4 -0
  61. data/lib/command_tower/inbox_presentations.rb +19 -0
  62. data/lib/command_tower/install/baseline.rb +1 -0
  63. data/lib/command_tower/version.rb +1 -1
  64. data/spec/factories/user_experience_states.rb +13 -0
  65. metadata +37 -2
@@ -1,11 +1,12 @@
1
- <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: #f5f7fa; padding: 40px 20px;">
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: #ffffff; border-radius: 12px; box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1); overflow: hidden;">
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: linear-gradient(135deg, #667eea 0%, #764ba2 100%); padding: 40px 30px; text-align: center;">
8
- <h1 style="margin: 0; color: #ffffff; font-size: 28px; font-weight: 600; letter-spacing: -0.5px;">
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: #4a5568; font-size: 16px; line-height: 1.6;">
18
- Hello <strong style="color: #2d3748;"><%= @user.full_name %></strong>,
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: #4a5568; font-size: 16px; line-height: 1.6;">
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: linear-gradient(135deg, #f6f8fb 0%, #e9ecef 100%); border-radius: 8px; border: 2px dashed #cbd5e0;">
29
- <div style="font-size: 36px; font-weight: 700; letter-spacing: 8px; color: #667eea; font-family: 'Courier New', monospace; text-align: center;">
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: #718096; font-size: 12px; text-transform: uppercase; letter-spacing: 1px;">
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: #718096; font-size: 14px; line-height: 1.6;">
40
- <strong style="color: #4a5568;">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.
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: #f7fafc; border-top: 1px solid #e2e8f0;">
48
- <p style="margin: 0 0 15px 0; color: #4a5568; font-size: 14px; text-align: center; line-height: 1.6;">
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: #667eea;"><%= CommandTower.config.app.communication_name %> Team</strong>
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: #667eea; text-decoration: none; font-size: 14px; font-weight: 500;">
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: #a0aec0; font-size: 12px; line-height: 1.5;">
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
- <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: #f4f5f7; padding: 32px 16px;">
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: #ffffff; border: 1px solid #e2e8f0; border-radius: 8px; overflow: hidden;">
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: #1a202c; font-size: 22px; font-weight: 600; line-height: 1.35;">
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: #4a5568; font-size: 16px; line-height: 1.6; white-space: pre-wrap;">
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: #2b6cb0; font-size: 14px; text-decoration: underline;">
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
- <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: #f5f7fa; padding: 40px 20px;">
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: #ffffff; border-radius: 12px; box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1); overflow: hidden;">
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: linear-gradient(135deg, #667eea 0%, #764ba2 100%); padding: 40px 30px; text-align: center;">
8
- <h1 style="margin: 0; color: #ffffff; font-size: 28px; font-weight: 600; letter-spacing: -0.5px;">
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: #4a5568; font-size: 16px; line-height: 1.6;">
18
- Hello <strong style="color: #2d3748;"><%= @user.full_name %></strong>,
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: #4a5568; font-size: 16px; line-height: 1.6;">
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: linear-gradient(135deg, #f6f8fb 0%, #e9ecef 100%); border-radius: 8px; border: 2px dashed #cbd5e0;">
29
- <div style="font-size: 18px; font-weight: 600; color: #667eea; font-family: 'Courier New', monospace; text-align: center; word-break: break-all; padding: 10px;">
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: #718096; font-size: 12px; text-transform: uppercase; letter-spacing: 1px;">
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: linear-gradient(135deg, #667eea 0%, #764ba2 100%); color: #ffffff; text-decoration: none; border-radius: 8px; font-weight: 600; font-size: 16px; text-align: center;">
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: #718096; font-size: 14px; line-height: 1.6;">
53
- <strong style="color: #4a5568;">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.
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: #718096; font-size: 14px; line-height: 1.6;">
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: #f7fafc; border-radius: 8px; border: 1px solid #e2e8f0;">
64
- <p style="margin: 0 0 10px 0; color: #4a5568; font-size: 14px; font-weight: 600;">
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: #667eea; font-size: 14px; word-break: break-all;">
68
- <a href="<%= CommandTower.config.app.composed_url %><%= @reset_password_path %>" style="color: #667eea; text-decoration: underline;">
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: #f7fafc; border-top: 1px solid #e2e8f0;">
81
- <p style="margin: 0 0 15px 0; color: #4a5568; font-size: 14px; text-align: center; line-height: 1.6;">
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: #667eea;"><%= CommandTower.config.app.communication_name %> Team</strong>
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: #667eea; text-decoration: none; font-size: 14px; font-weight: 500;">
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: #a0aec0; font-size: 12px; line-height: 1.5;">
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
@@ -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. |