command_tower 0.14.0 → 0.16.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 (35) hide show
  1. checksums.yaml +4 -4
  2. data/app/controllers/command_tower/me/experience_states_controller.rb +51 -0
  3. data/app/controllers/command_tower/me/push_controller.rb +8 -0
  4. data/app/deserializers/command_tower/deserializers/me/experience_states/complete_deserializer.rb +52 -0
  5. data/app/errors/command_tower/errors/account/experience_states_host_unconfigured_error.rb +21 -0
  6. data/app/errors/command_tower/errors/account/push_test_no_endpoint_error.rb +21 -0
  7. data/app/errors/command_tower/errors/account/push_test_rate_limit_error.rb +25 -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/services/command_tower/services/account/experience_states/complete.rb +55 -0
  11. data/app/services/command_tower/services/account/experience_states/list.rb +28 -0
  12. data/app/services/command_tower/services/account/push/check_self_test_rate_limit.rb +41 -0
  13. data/app/services/command_tower/services/account/push/send_self_test.rb +57 -0
  14. data/app/workflows/command_tower/workflows/me/error_mapping.rb +5 -2
  15. data/app/workflows/command_tower/workflows/me/experience_states/complete_workflow.rb +49 -0
  16. data/app/workflows/command_tower/workflows/me/experience_states/list_workflow.rb +30 -0
  17. data/app/workflows/command_tower/workflows/me/experience_states/workflow_support.rb +27 -0
  18. data/app/workflows/command_tower/workflows/me/push/test_workflow.rb +54 -0
  19. data/config/routes.rb +5 -0
  20. data/db/migrate/20260906180000_create_user_experience_states.rb +25 -0
  21. data/docs/api_reference.md +21 -0
  22. data/docs/controllers.md +1 -0
  23. data/docs/host_integration_guide.md +2 -1
  24. data/docs/initializing.md +1 -0
  25. data/docs/upgrades/0.15.0.md +32 -0
  26. data/docs/upgrades/0.16.0.md +30 -0
  27. data/docs/upgrades/README.md +2 -0
  28. data/lib/command_tower/authorization/default.yml +6 -0
  29. data/lib/command_tower/configuration/application/config.rb +5 -0
  30. data/lib/command_tower/configuration/messaging/expo.rb +13 -0
  31. data/lib/command_tower/configuration/registry/audit/config.rb +12 -0
  32. data/lib/command_tower/install/baseline.rb +1 -0
  33. data/lib/command_tower/version.rb +1 -1
  34. data/spec/factories/user_experience_states.rb +13 -0
  35. metadata +21 -2
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: bce961e0a6770bd17a23396e3fdb5066655dbe937f91c85b00c4605802c48f7d
4
- data.tar.gz: e5247dda098ad94825317ec31cfff34c1fa060aac6fcf432ca5a082f93c83e02
3
+ metadata.gz: 9e983f8f191ccb75948cdad73067d1cd43b7b1e5a8a3e0d34fc0130a76c313da
4
+ data.tar.gz: 5ef89ea100c06425afe3aee6f87b8b71503e4d28be6a4ff6e309af8a5b02c83f
5
5
  SHA512:
6
- metadata.gz: dd5117447c0bf551b4b55e3a402fbda78a915c8bd0e31a2fbc10a785ef747cc9c6b7c5c8e3aa5306978872fd89910233fb67a69563a3dc1c5105fe06ce67834b
7
- data.tar.gz: bb8d35c52ca0e32b44134015a81462350357fa5e678e3e3bce0daa11a3f76c2e14ff7d2e0731f8ddea812185f579df464886e01464670e68a375490d21528b03
6
+ metadata.gz: 6555727a8b33efc95493ecdeec48067e39c8ee9eb4f1750c8fd183599a8d41bf1e763d538d379d0be7888697e6f09ca0795ebb643817d5aea2c548c61cd867f6
7
+ data.tar.gz: ca51dafb697b03848b2509f16660df1e9e349f325a01da7d589300ac7db0ee087df33c08d46194a97e60ae6cbc984de8bcbf1fbc65f41d7bd31f31013d64add4
@@ -0,0 +1,51 @@
1
+ # frozen_string_literal: true
2
+
3
+ module CommandTower
4
+ module Me
5
+ class ExperienceStatesController < CommandTower::ApplicationController
6
+ include CommandTower::Auth::AuthenticationBoundary
7
+ include CommandTower::Auth::AuthorizationBoundary
8
+
9
+ before_action :authenticate_request!
10
+ before_action :authorize_request!
11
+
12
+ def index
13
+ result = CommandTower::Workflows::Me::ExperienceStates::ListWorkflow.call(
14
+ current_user: current_user,
15
+ auth_context: current_auth_context,
16
+ )
17
+ render_application_result(result)
18
+ end
19
+
20
+ def complete
21
+ deserialized = CommandTower::Deserializers::Me::ExperienceStates::CompleteDeserializer.call(params)
22
+ return render_deserializer_errors unless deserialized.success?
23
+
24
+ result = CommandTower::Workflows::Me::ExperienceStates::CompleteWorkflow.call(
25
+ current_user: current_user,
26
+ experience_key: deserialized.input.experience_key,
27
+ scope_type: deserialized.input.scope_type,
28
+ scope_identifier: deserialized.input.scope_identifier,
29
+ version: deserialized.input.version,
30
+ auth_context: current_auth_context,
31
+ )
32
+ render_application_result(result)
33
+ end
34
+
35
+ private
36
+
37
+ def render_deserializer_errors
38
+ render_application_result(
39
+ CommandTower::Workflows::WorkflowResult.failure(
40
+ errors: [
41
+ CommandTower::Errors::ValidationError.new(
42
+ details: { base: "Missing or invalid experience state completion fields" },
43
+ ),
44
+ ],
45
+ http_status: :unprocessable_entity,
46
+ ),
47
+ )
48
+ end
49
+ end
50
+ end
51
+ end
@@ -51,6 +51,14 @@ module CommandTower
51
51
  render_application_result(result)
52
52
  end
53
53
 
54
+ def test
55
+ result = CommandTower::Workflows::Me::Push::TestWorkflow.call(
56
+ current_user: current_user,
57
+ auth_context: current_auth_context,
58
+ )
59
+ render_application_result(result)
60
+ end
61
+
54
62
  private
55
63
 
56
64
  def render_deserializer_errors
@@ -0,0 +1,52 @@
1
+ # frozen_string_literal: true
2
+
3
+ module CommandTower
4
+ module Deserializers
5
+ module Me
6
+ module ExperienceStates
7
+ class CompleteDeserializer < CommandTower::Deserializers::ApplicationDeserializer
8
+ Input = Data.define(:experience_key, :scope_type, :scope_identifier, :version)
9
+
10
+ MAX_LENGTH = 128
11
+
12
+ def call(params)
13
+ experience_key = extract(params, :experienceKey, :experience_key)
14
+ scope_type = extract(params, :scopeType, :scope_type)
15
+ scope_identifier = extract(params, :scopeIdentifier, :scope_identifier)
16
+ version = extract(params, :version, :Version)
17
+
18
+ if [experience_key, scope_type, scope_identifier, version].any?(&:blank?)
19
+ return failure(errors: { message: "missing_required_fields" })
20
+ end
21
+
22
+ if [experience_key, scope_type, scope_identifier, version].any? { |value| value.length > MAX_LENGTH }
23
+ return failure(errors: { message: "invalid_field_length" })
24
+ end
25
+
26
+ success(
27
+ Input.new(
28
+ experience_key:,
29
+ scope_type:,
30
+ scope_identifier:,
31
+ version:,
32
+ ),
33
+ )
34
+ end
35
+
36
+ private
37
+
38
+ def extract(params, *keys)
39
+ keys.each do |key|
40
+ raw = params[key] || params[key.to_s]
41
+ next if raw.nil?
42
+
43
+ value = raw.to_s.strip
44
+ return value unless value.empty?
45
+ end
46
+ ""
47
+ end
48
+ end
49
+ end
50
+ end
51
+ end
52
+ end
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ module CommandTower
4
+ module Errors
5
+ module Account
6
+ class ExperienceStatesHostUnconfiguredError < CommandTower::Errors::ApplicationError
7
+ def code
8
+ "experience_states_host_unconfigured"
9
+ end
10
+
11
+ def message
12
+ "Experience states are currently unavailable"
13
+ end
14
+
15
+ def log_level
16
+ :warn
17
+ end
18
+ end
19
+ end
20
+ end
21
+ end
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ module CommandTower
4
+ module Errors
5
+ module Account
6
+ class PushTestNoEndpointError < CommandTower::Errors::ApplicationError
7
+ def code
8
+ "push_test_no_endpoint"
9
+ end
10
+
11
+ def message
12
+ "No active push endpoint is registered for this account"
13
+ end
14
+
15
+ def log_level
16
+ :warn
17
+ end
18
+ end
19
+ end
20
+ end
21
+ end
@@ -0,0 +1,25 @@
1
+ # frozen_string_literal: true
2
+
3
+ module CommandTower
4
+ module Errors
5
+ module Account
6
+ class PushTestRateLimitError < CommandTower::Errors::ApplicationError
7
+ def initialize(retry_after_seconds: nil)
8
+ super(details: retry_after_seconds ? { retry_after_seconds: retry_after_seconds } : nil)
9
+ end
10
+
11
+ def code
12
+ "push_test_rate_limited"
13
+ end
14
+
15
+ def message
16
+ "Too many push test notifications. Please try again later."
17
+ end
18
+
19
+ def log_level
20
+ :warn
21
+ end
22
+ end
23
+ end
24
+ end
25
+ end
@@ -0,0 +1,17 @@
1
+ # frozen_string_literal: true
2
+
3
+ module CommandTower
4
+ class UserExperienceState < CommandTower::ApplicationRecord
5
+ self.table_name = "user_experience_states"
6
+
7
+ belongs_to :user
8
+
9
+ validates :host_key, :experience_key, :scope_type, :scope_identifier, :version, presence: true
10
+ validates :experience_key,
11
+ uniqueness: {
12
+ scope: %i[user_id host_key scope_type scope_identifier version],
13
+ }
14
+
15
+ scope :for_user_and_host, ->(user:, host_key:) { where(user:, host_key:) }
16
+ end
17
+ end
@@ -0,0 +1,36 @@
1
+ # frozen_string_literal: true
2
+
3
+ module CommandTower
4
+ module Serializers
5
+ module Me
6
+ module ExperienceStates
7
+ class ExperienceStateSerializer
8
+ def self.serialize(state)
9
+ new(state).serialize
10
+ end
11
+
12
+ def self.serialize_collection(states)
13
+ {
14
+ experienceStates: Array(states).map { |state| serialize(state) },
15
+ }
16
+ end
17
+
18
+ def initialize(state)
19
+ @state = state
20
+ end
21
+
22
+ def serialize
23
+ {
24
+ hostKey: @state.host_key,
25
+ experienceKey: @state.experience_key,
26
+ scopeType: @state.scope_type,
27
+ scopeIdentifier: @state.scope_identifier,
28
+ version: @state.version,
29
+ completedAt: @state.completed_at&.iso8601,
30
+ }
31
+ end
32
+ end
33
+ end
34
+ end
35
+ end
36
+ end
@@ -0,0 +1,55 @@
1
+ # frozen_string_literal: true
2
+
3
+ module CommandTower
4
+ module Services
5
+ module Account
6
+ module ExperienceStates
7
+ class Complete < CommandTower::Services::ApplicationService
8
+ validate :user, is_a: User, required: true
9
+ validate :experience_key, is_a: String, required: true
10
+ validate :scope_type, is_a: String, required: true
11
+ validate :scope_identifier, is_a: String, required: true
12
+ validate :version, is_a: String, required: true
13
+
14
+ def call
15
+ host_key = CommandTower.config.application.host_key.to_s
16
+ if host_key.blank?
17
+ context.fail!(
18
+ application_error: CommandTower::Errors::Account::ExperienceStatesHostUnconfiguredError.new,
19
+ )
20
+ return
21
+ end
22
+
23
+ identity = {
24
+ user:,
25
+ host_key:,
26
+ experience_key:,
27
+ scope_type:,
28
+ scope_identifier:,
29
+ version:,
30
+ }
31
+
32
+ existing = CommandTower::UserExperienceState.find_by(identity)
33
+ if existing
34
+ context.experience_state = existing
35
+ context.created = false
36
+ return
37
+ end
38
+
39
+ begin
40
+ created = CommandTower::UserExperienceState.create!(
41
+ identity.merge(completed_at: Time.current),
42
+ )
43
+ context.experience_state = created
44
+ context.created = true
45
+ rescue ActiveRecord::RecordNotUnique
46
+ recovered = CommandTower::UserExperienceState.find_by!(identity)
47
+ context.experience_state = recovered
48
+ context.created = false
49
+ end
50
+ end
51
+ end
52
+ end
53
+ end
54
+ end
55
+ end
@@ -0,0 +1,28 @@
1
+ # frozen_string_literal: true
2
+
3
+ module CommandTower
4
+ module Services
5
+ module Account
6
+ module ExperienceStates
7
+ class List < CommandTower::Services::ApplicationService
8
+ validate :user, is_a: User, required: true
9
+
10
+ def call
11
+ host_key = CommandTower.config.application.host_key.to_s
12
+ if host_key.blank?
13
+ context.fail!(
14
+ application_error: CommandTower::Errors::Account::ExperienceStatesHostUnconfiguredError.new,
15
+ )
16
+ return
17
+ end
18
+
19
+ context.experience_states = CommandTower::UserExperienceState.for_user_and_host(
20
+ user:,
21
+ host_key:,
22
+ ).order(:completed_at, :id).to_a
23
+ end
24
+ end
25
+ end
26
+ end
27
+ end
28
+ end
@@ -0,0 +1,41 @@
1
+ # frozen_string_literal: true
2
+
3
+ module CommandTower
4
+ module Services
5
+ module Account
6
+ module Push
7
+ # Enforces Me push self-test rate limits via existing RateLimits::Check.
8
+ # Ceiling and window come from config.messaging.expo composers — not hardcoded here.
9
+ class CheckSelfTestRateLimit < CommandTower::Services::ApplicationService
10
+ validate :user, is_a: User, required: true
11
+
12
+ def call
13
+ expo = CommandTower.config.messaging.expo
14
+ limit = expo.self_test_per_user_hour
15
+ ttl_seconds = expo.self_test_window_seconds
16
+
17
+ result = CommandTower::Services::RateLimits::Check.call(
18
+ key: "me:push:self_test:user:#{user.id}",
19
+ ttl_seconds:,
20
+ )
21
+ if result.failure?
22
+ context.fail!(application_error: result.errors.first)
23
+ return
24
+ end
25
+
26
+ count = result.data[:count]
27
+ return if count <= limit
28
+
29
+ ttl = result.data[:ttl]
30
+ retry_after = ttl.positive? ? ttl : nil
31
+ context.fail!(
32
+ application_error: CommandTower::Errors::Account::PushTestRateLimitError.new(
33
+ retry_after_seconds: retry_after,
34
+ ),
35
+ )
36
+ end
37
+ end
38
+ end
39
+ end
40
+ end
41
+ end
@@ -0,0 +1,57 @@
1
+ # frozen_string_literal: true
2
+
3
+ module CommandTower
4
+ module Services
5
+ module Account
6
+ module Push
7
+ # Me self-test: Produce a host-registered push_delivery_test communication.
8
+ # Does not bypass Messaging; does not talk to Expo directly.
9
+ class SendSelfTest < CommandTower::Services::ApplicationService
10
+ NOTIFICATION_TYPE_KEY = "push_delivery_test"
11
+ TITLE = "Push notifications"
12
+ BODY = "This is a test notification from your account."
13
+
14
+ validate :user, is_a: User, required: true
15
+
16
+ def call
17
+ list_result = CommandTower::Services::Account::Push::List.call(user:)
18
+ unless list_result.success?
19
+ context.fail!(application_error: list_result.errors.first)
20
+ return
21
+ end
22
+
23
+ if Array(list_result.data[:safe_views]).empty?
24
+ context.fail!(application_error: CommandTower::Errors::Account::PushTestNoEndpointError.new)
25
+ return
26
+ end
27
+
28
+ rate_result = CommandTower::Services::Account::Push::CheckSelfTestRateLimit.call(user:)
29
+ unless rate_result.success?
30
+ context.fail!(application_error: rate_result.errors.first)
31
+ return
32
+ end
33
+
34
+ produce_result = CommandTower::Services::Messaging::Communications::Produce.call(
35
+ user:,
36
+ notification_type_key: NOTIFICATION_TYPE_KEY,
37
+ host_event_identity: "push_delivery_test/#{user.id}/#{SecureRandom.uuid}",
38
+ title: TITLE,
39
+ body: BODY,
40
+ metadata: {},
41
+ platform_enabled_channels: CommandTower::Services::Messaging::Preferences::PlatformEnabledChannels.call,
42
+ )
43
+ unless produce_result.success?
44
+ context.fail!(application_error: produce_result.errors.first)
45
+ return
46
+ end
47
+
48
+ context.communication_id = produce_result.data[:communication_id]
49
+ context.destination_plan_id = produce_result.data[:destination_plan_id]
50
+ context.selected_channels = produce_result.data[:selected_channels]
51
+ context.communication_status = produce_result.data[:communication_status]
52
+ end
53
+ end
54
+ end
55
+ end
56
+ end
57
+ end
@@ -25,13 +25,16 @@ module CommandTower
25
25
  CommandTower::Errors::Account::PushoverAlreadyConfiguredError,
26
26
  CommandTower::Errors::Account::PushoverVerificationFailedError,
27
27
  CommandTower::Errors::Account::PushEndpointNotFoundError,
28
+ CommandTower::Errors::Account::PushTestNoEndpointError,
28
29
  CommandTower::Errors::ValidationError
29
30
  :unprocessable_entity
30
- when CommandTower::Errors::Account::PhoneVerificationThrottledError
31
+ when CommandTower::Errors::Account::PhoneVerificationThrottledError,
32
+ CommandTower::Errors::Account::PushTestRateLimitError
31
33
  :too_many_requests
32
34
  when CommandTower::Errors::Account::SmsCapabilityUnavailableError,
33
35
  CommandTower::Errors::Account::PushoverCapabilityUnavailableError,
34
- CommandTower::Errors::Account::PushCapabilityUnavailableError
36
+ CommandTower::Errors::Account::PushCapabilityUnavailableError,
37
+ CommandTower::Errors::Account::ExperienceStatesHostUnconfiguredError
35
38
  :service_unavailable
36
39
  when CommandTower::Errors::Account::PhoneVerificationSendFailedError,
37
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
@@ -0,0 +1,54 @@
1
+ # frozen_string_literal: true
2
+
3
+ module CommandTower
4
+ module Workflows
5
+ module Me
6
+ module Push
7
+ class TestWorkflow < CommandTower::Workflows::ApplicationWorkflow
8
+ retry_strategy :none
9
+
10
+ def call(current_user:, auth_context: nil)
11
+ unless CommandTower::Services::Me::PushProductGate.enabled?
12
+ return failure(**WorkflowSupport.capability_failure)
13
+ end
14
+
15
+ result = CommandTower::Services::Account::Push::SendSelfTest.call(user: current_user)
16
+ unless result.success?
17
+ error = result.errors.first
18
+ return failure(
19
+ errors: result.errors,
20
+ http_status: http_status_for(error),
21
+ )
22
+ end
23
+
24
+ success(
25
+ payload: {
26
+ communicationId: result.data[:communication_id],
27
+ destinationPlanId: result.data[:destination_plan_id],
28
+ selectedChannels: result.data[:selected_channels],
29
+ status: result.data[:communication_status],
30
+ },
31
+ http_status: :ok,
32
+ response_effects: WorkflowSupport.expire_header_effects(auth_context),
33
+ )
34
+ end
35
+
36
+ private
37
+
38
+ def http_status_for(error)
39
+ case error
40
+ when CommandTower::Errors::ValidationError,
41
+ CommandTower::Errors::Messaging::RecipientUnresolvedError,
42
+ CommandTower::Errors::Messaging::AcceptRejectedError
43
+ :unprocessable_entity
44
+ when CommandTower::Errors::Messaging::IdempotencyConflictError
45
+ :conflict
46
+ else
47
+ CommandTower::Workflows::Me::ErrorMapping.http_status_for(error)
48
+ end
49
+ end
50
+ end
51
+ end
52
+ end
53
+ end
54
+ end
data/config/routes.rb CHANGED
@@ -104,11 +104,16 @@ CommandTower::Engine.routes.draw do
104
104
  # Expo push lifecycle always drawn (no route constraints).
105
105
  # Product readiness is workflow-gated as 503 push_capability_unavailable.
106
106
  # Collection JSON (multi-active). No verification POST — create/replace mark_verified.
107
+ # Self-test must be declared before :id routes so "test" is not captured as an id.
107
108
  get "push", to: "push#index"
108
109
  post "push", to: "push#create"
110
+ post "push/test", to: "push#test"
109
111
  patch "push/:id", to: "push#update"
110
112
  put "push/:id", to: "push#update"
111
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"
112
117
  end
113
118
 
114
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
@@ -447,6 +447,27 @@ There is **no** `POST /me/push/verification`. Create and replace call `Endpoints
447
447
 
448
448
  ---
449
449
 
450
+ ## Experience states
451
+
452
+ 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`.
453
+
454
+ `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`.
455
+
456
+ | Method | Path | Body | Notes |
457
+ |--------|------|------|--------|
458
+ | `GET` | `/me/experience-states` | — | `{ experienceStates: [...] }` — **completed rows only** for the configured host |
459
+ | `POST` | `/me/experience-states/complete` | `experienceKey`/`experience_key`, `scopeType`/`scope_type`, `scopeIdentifier`/`scope_identifier`, `version` (snake or camelCase) | Idempotent complete; returns the durable fact |
460
+
461
+ **Fact fields:** `hostKey`, `experienceKey`, `scopeType`, `scopeIdentifier`, `version`, `completedAt`. Never `showWelcome` / applicability / presentation instructions.
462
+
463
+ **RBAC:** `me_experience_states` (`index`, `complete`). Grant explicitly on host roles (dummy host `member` includes it).
464
+
465
+ **Audit:** first durable creation emits `experience_state_completed` (opaque `host_context` type/identifier). Idempotent replay does not emit again.
466
+
467
+ **Spec:** `spec/requests/command_tower/me/experience_states_spec.rb`.
468
+
469
+ ---
470
+
450
471
  ## Admin Workspace
451
472
 
452
473
  ### `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.
@@ -32,10 +32,11 @@ In the host initializer, set at least:
32
32
  - `config.jwt.hmac_secret`
33
33
  - `config.signup_session.jwt_secret` (or `SIGNUP_SESSION_JWT_SECRET`)
34
34
  - `config.password_recovery_session.jwt_secret` (or `PASSWORD_RECOVERY_SESSION_JWT_SECRET`)
35
+ - `config.application.host_key` — server-bound host/product identity for CT-generic user-scoped state (for example experience states). Not client-supplied. Blank values cause Me experience-state routes to return **503** `experience_states_host_unconfigured`.
35
36
 
36
37
  Re-run `bin/rails command_tower:doctor`. Details: [Initializing — Configuration](initializing.md#configuration).
37
38
 
38
- Dummy-host reference: [`rails_app/config/initializers/command_tower.rb`](../rails_app/config/initializers/command_tower.rb).
39
+ Dummy-host reference: [`rails_app/config/initializers/command_tower.rb`](../rails_app/config/initializers/command_tower.rb) (sets `host_key` to `"command_tower"`).
39
40
 
40
41
  ### Email / SMTP
41
42
 
data/docs/initializing.md CHANGED
@@ -107,6 +107,7 @@ Required for production-ready hosts:
107
107
  - `config.jwt.hmac_secret` — typically `SECRET_KEY_BASE` / `Rails.application.secret_key_base`
108
108
  - `config.signup_session.jwt_secret` — or `SIGNUP_SESSION_JWT_SECRET`
109
109
  - `config.password_recovery_session.jwt_secret` — or `PASSWORD_RECOVERY_SESSION_JWT_SECRET`
110
+ - `config.application.host_key` — server-bound host/product identity for CT-generic user-scoped state (experience states). Not client-supplied. Blank → Me experience-state routes return **503**.
110
111
 
111
112
  Optional / feature-gated:
112
113
 
@@ -0,0 +1,32 @@
1
+ # Upgrade: CommandTower 0.15.0
2
+
3
+ **From:** `0.14.0`
4
+ **To:** `0.15.0`
5
+
6
+ Minor release: Me push self-test (`POST /me/push/test`) through Messaging Produce, with configurable rate limits. No schema migration.
7
+
8
+ ## Host-visible changes
9
+
10
+ | Change | Host impact |
11
+ |--------|-------------|
12
+ | `POST /api/me/push/test` | Authenticated self-test. Produces `push_delivery_test` via Communications::Produce (Expo path). Requires at least one active push endpoint. Returns Produce identifiers only (no Expo tokens). |
13
+ | RBAC `me_push` | Adds action `test` (same entity). Hosts that already grant `me_push` get the new action. |
14
+ | `config.messaging.expo.self_test_per_user_hour` | Max self-test sends per user per rolling window (default **10**). |
15
+ | `config.messaging.expo.self_test_window_seconds` | Rolling window length (default **3600**). |
16
+ | Rate limit HTTP | Exceeded limit → **429** `push_test_rate_limited` with `retry_after_seconds` details (existing CT envelope). |
17
+ | No endpoint | **422** `push_test_no_endpoint`. |
18
+ | Gate off | **503** `push_capability_unavailable` (same as other `/me/push*` when Expo adapter disabled). |
19
+
20
+ ## Host actions
21
+
22
+ 1. Bump gem to `0.15.0` (or `>= 0.15.0`).
23
+ 2. **No new migration.**
24
+ 3. **Required before enabling Account Push notifications settings:** register notification type `push_delivery_test` with push-only plan (e.g. `allowed/default %w[push]`, `inbox_available: false`, `user_configurable: false`, not preference-visible). CT hardcodes this type key for Me self-test.
25
+ 4. Set `config.messaging.expo.self_test_per_user_hour` explicitly when product policy differs from the default (Pick’em: **10**).
26
+ 5. Keep Expo adapter enabled (`http` / `fake`) and `me_push` granted for roles that use push.
27
+
28
+ ## Not in this release
29
+
30
+ - CT FE Account Push notifications settings / device lifecycle migration
31
+ - Host Pick’em enablement / type registration / IPA
32
+ - Physical TestFlight proof
@@ -0,0 +1,30 @@
1
+ # Upgrade: CommandTower 0.16.0
2
+
3
+ **From:** `0.15.0`
4
+ **To:** `0.16.0`
5
+
6
+ Minor release: generic Me experience-state persistence and HTTP (completion facts only). New migration.
7
+
8
+ ## Host-visible changes
9
+
10
+ | Change | Host impact |
11
+ |--------|-------------|
12
+ | Table `user_experience_states` | Durable completion rows keyed by `user + host_key + experience_key + scope_type + scope_identifier + version` |
13
+ | `config.application.host_key` | **Required** non-blank String for Me experience-states. Server-bound; never client-supplied. Blank → **503** `experience_states_host_unconfigured` |
14
+ | `GET /api/me/experience-states` | Authenticated list of **completed** facts for the current user on this host only |
15
+ | `POST /api/me/experience-states/complete` | Idempotent complete; audit `experience_state_completed` **only on first durable create** |
16
+ | RBAC `me_experience_states` | New entity (`index`, `complete`). Hosts must **explicitly grant** (not auto-added to a generic Admin role) |
17
+ | Audit | Configurable event `experience_state_completed` with opaque `host_context` |
18
+
19
+ ## Host actions
20
+
21
+ 1. Bump gem to `0.16.0` (or `>= 0.16.0`).
22
+ 2. Set `config.application.host_key` (e.g. Pick’em `"pickem"`; CT rails_app `"command_tower"`).
23
+ 3. Copy engine migrations and migrate: `bundle exec rails command_tower:install:migrations` then `db:migrate`.
24
+ 4. Grant RBAC entity `me_experience_states` to the roles/groups that should use Me experience-states (typically `member`).
25
+
26
+ ## Not in this release
27
+
28
+ - CommandTower frontend experience-states capability / hydrate hooks
29
+ - Host Welcome product composition
30
+ - Push silent-sync / Welcome notification step
@@ -4,6 +4,8 @@ Host-facing upgrade / change summaries for CommandTower releases.
4
4
 
5
5
  | Version | Summary |
6
6
  |---------|---------|
7
+ | [0.16.0](0.16.0.md) | Me experience-states (`GET`/`POST complete`); `host_key`; `user_experience_states` migration; `me_experience_states` RBAC |
8
+ | [0.15.0](0.15.0.md) | Me `POST /me/push/test` → Produce (`push_delivery_test`); configurable Expo self-test rate limit composers; `me_push#test` |
7
9
  | [0.14.0](0.14.0.md) | Expo push Messaging channel (`config.messaging.expo`) + `/api/me/push*` registration HTTP; `me_push` RBAC |
8
10
  | [0.13.1](0.13.1.md) | Canonical `Intervention::Severity` constants (`blocking`, `warning`, `informational`) |
9
11
  | [0.13.0](0.13.0.md) | Account self-service deletion (`DELETE /api/me/account`); tombstone + PII scrub; `me_account` RBAC; `users.deleted_at` migration |
@@ -75,6 +75,12 @@ entities:
75
75
  - create
76
76
  - update
77
77
  - destroy
78
+ - test
79
+ - name: me_experience_states
80
+ controller: CommandTower::Me::ExperienceStatesController
81
+ only:
82
+ - index
83
+ - complete
78
84
  - name: me_audit_events
79
85
  controller: CommandTower::Me::AuditEventsController
80
86
  only:
@@ -14,6 +14,11 @@ module CommandTower
14
14
  desc: "The default name of the application",
15
15
  default_shown: "# Auto Populates to the name of the application"
16
16
 
17
+ add_composer :host_key,
18
+ allowed: String,
19
+ default: "",
20
+ desc: "Server-bound host/product identity for CT-generic user-scoped state. Not client-supplied."
21
+
17
22
  add_composer :communication_name,
18
23
  allowed: String,
19
24
  dynamic_default: :app_name,
@@ -32,6 +32,19 @@ module CommandTower
32
32
  "Required only if the Expo project enables enhanced push security. Never log.",
33
33
  allowed: String,
34
34
  default: ""
35
+
36
+ add_composer :self_test_per_user_hour,
37
+ desc: "Max Me push self-test (POST /me/push/test) sends allowed per user per rolling window. " \
38
+ "Hosts enabling Account Push notifications settings should set this explicitly " \
39
+ "(Pick'em: 10).",
40
+ allowed: Integer,
41
+ default: 10
42
+
43
+ add_composer :self_test_window_seconds,
44
+ desc: "Rolling window length in seconds for Me push self-test rate limiting " \
45
+ "(default 3600 = one hour).",
46
+ allowed: Integer,
47
+ default: 3600
35
48
  end
36
49
  end
37
50
  end
@@ -265,6 +265,18 @@ module CommandTower
265
265
  subject_type: "User",
266
266
  affected_user_required: true,
267
267
  global_visible_in_host_scope: true
268
+ },
269
+ experience_state_completed: {
270
+ label: "Experience state completed",
271
+ tags: %w[experience account],
272
+ enabled: true,
273
+ enablement_configurable: true,
274
+ user_history: false,
275
+ sensitive_fields: [],
276
+ allowed_changes: [],
277
+ retention: :permanent,
278
+ subject_required: false,
279
+ affected_user_required: true
268
280
  }
269
281
  }.freeze
270
282
 
@@ -15,6 +15,7 @@ module CommandTower
15
15
  20260817000001_add_scope_columns_to_command_tower_audit_events.rb
16
16
  20260817000003_create_command_tower_impersonation_sessions.rb
17
17
  20260826140000_add_deleted_at_to_users.rb
18
+ 20260906180000_create_user_experience_states.rb
18
19
  ].freeze
19
20
 
20
21
  module_function
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module CommandTower
4
- VERSION = "0.14.0"
4
+ VERSION = "0.16.0"
5
5
  end
@@ -0,0 +1,13 @@
1
+ # frozen_string_literal: true
2
+
3
+ FactoryBot.define do
4
+ factory :user_experience_state, class: "CommandTower::UserExperienceState" do
5
+ user
6
+ host_key { "command_tower" }
7
+ experience_key { "welcome" }
8
+ scope_type { "example_scope" }
9
+ sequence(:scope_identifier) { |n| n.to_s }
10
+ version { "v1" }
11
+ completed_at { Time.current }
12
+ end
13
+ end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: command_tower
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.14.0
4
+ version: 0.16.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - matt-taylor
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2026-09-06 00:00:00.000000000 Z
11
+ date: 2026-09-07 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: phonelib
@@ -211,6 +211,7 @@ files:
211
211
  - app/controllers/command_tower/auth/username/availability_controller.rb
212
212
  - app/controllers/command_tower/me/account_controller.rb
213
213
  - app/controllers/command_tower/me/audit_events_controller.rb
214
+ - app/controllers/command_tower/me/experience_states_controller.rb
214
215
  - app/controllers/command_tower/me/inbox_controller.rb
215
216
  - app/controllers/command_tower/me/name_controller.rb
216
217
  - app/controllers/command_tower/me/password_controller.rb
@@ -254,6 +255,7 @@ files:
254
255
  - app/deserializers/command_tower/deserializers/intervention/envelope_deserializer.rb
255
256
  - app/deserializers/command_tower/deserializers/me/change_password_deserializer.rb
256
257
  - app/deserializers/command_tower/deserializers/me/delete_account_deserializer.rb
258
+ - app/deserializers/command_tower/deserializers/me/experience_states/complete_deserializer.rb
257
259
  - app/deserializers/command_tower/deserializers/me/phone_verification/verify_deserializer.rb
258
260
  - app/deserializers/command_tower/deserializers/me/push/token_deserializer.rb
259
261
  - app/deserializers/command_tower/deserializers/me/pushover/credentials_deserializer.rb
@@ -262,6 +264,7 @@ files:
262
264
  - app/deserializers/command_tower/deserializers/messaging/inbox.rb
263
265
  - app/deserializers/command_tower/deserializers/messaging/preferences/show_deserializer.rb
264
266
  - app/deserializers/command_tower/deserializers/messaging/preferences/update_deserializer.rb
267
+ - app/errors/command_tower/errors/account/experience_states_host_unconfigured_error.rb
265
268
  - app/errors/command_tower/errors/account/phone_already_verified_error.rb
266
269
  - app/errors/command_tower/errors/account/phone_missing_error.rb
267
270
  - app/errors/command_tower/errors/account/phone_verification_code_invalid_error.rb
@@ -271,6 +274,8 @@ files:
271
274
  - app/errors/command_tower/errors/account/phone_verification_throttled_error.rb
272
275
  - app/errors/command_tower/errors/account/push_capability_unavailable_error.rb
273
276
  - app/errors/command_tower/errors/account/push_endpoint_not_found_error.rb
277
+ - app/errors/command_tower/errors/account/push_test_no_endpoint_error.rb
278
+ - app/errors/command_tower/errors/account/push_test_rate_limit_error.rb
274
279
  - app/errors/command_tower/errors/account/pushover_already_configured_error.rb
275
280
  - app/errors/command_tower/errors/account/pushover_capability_unavailable_error.rb
276
281
  - app/errors/command_tower/errors/account/pushover_not_configured_error.rb
@@ -336,6 +341,7 @@ files:
336
341
  - app/models/command_tower/messaging/endpoint_pushover_credential.rb
337
342
  - app/models/command_tower/messaging/inbox_item.rb
338
343
  - app/models/command_tower/messaging/notification_preference.rb
344
+ - app/models/command_tower/user_experience_state.rb
339
345
  - app/models/user.rb
340
346
  - app/models/user_secret.rb
341
347
  - app/serializers/command_tower/serializers/admin/messaging/announcement_response_serializer.rb
@@ -367,6 +373,7 @@ files:
367
373
  - app/serializers/command_tower/serializers/me/account_serializer.rb
368
374
  - app/serializers/command_tower/serializers/me/change_password_response_serializer.rb
369
375
  - app/serializers/command_tower/serializers/me/delete_account_response_serializer.rb
376
+ - app/serializers/command_tower/serializers/me/experience_states/experience_state_serializer.rb
370
377
  - app/serializers/command_tower/serializers/me/push_serializer.rb
371
378
  - app/serializers/command_tower/serializers/me/pushover_serializer.rb
372
379
  - app/serializers/command_tower/serializers/messaging/inbox.rb
@@ -597,13 +604,17 @@ files:
597
604
  - app/services/command_tower/service_base.rb
598
605
  - app/services/command_tower/service_logging.rb
599
606
  - app/services/command_tower/services/account/clear_phone.rb
607
+ - app/services/command_tower/services/account/experience_states/complete.rb
608
+ - app/services/command_tower/services/account/experience_states/list.rb
600
609
  - app/services/command_tower/services/account/phone_verification/send.rb
601
610
  - app/services/command_tower/services/account/phone_verification/verify.rb
611
+ - app/services/command_tower/services/account/push/check_self_test_rate_limit.rb
602
612
  - app/services/command_tower/services/account/push/create.rb
603
613
  - app/services/command_tower/services/account/push/ct_support.rb
604
614
  - app/services/command_tower/services/account/push/destroy.rb
605
615
  - app/services/command_tower/services/account/push/list.rb
606
616
  - app/services/command_tower/services/account/push/replace.rb
617
+ - app/services/command_tower/services/account/push/send_self_test.rb
607
618
  - app/services/command_tower/services/account/pushover/create.rb
608
619
  - app/services/command_tower/services/account/pushover/ct_support.rb
609
620
  - app/services/command_tower/services/account/pushover/destroy.rb
@@ -734,12 +745,16 @@ files:
734
745
  - app/workflows/command_tower/workflows/me/delete_account_workflow.rb
735
746
  - app/workflows/command_tower/workflows/me/error_mapping.rb
736
747
  - app/workflows/command_tower/workflows/me/error_status.rb
748
+ - app/workflows/command_tower/workflows/me/experience_states/complete_workflow.rb
749
+ - app/workflows/command_tower/workflows/me/experience_states/list_workflow.rb
750
+ - app/workflows/command_tower/workflows/me/experience_states/workflow_support.rb
737
751
  - app/workflows/command_tower/workflows/me/phone_verification/send_workflow.rb
738
752
  - app/workflows/command_tower/workflows/me/phone_verification/verify_workflow.rb
739
753
  - app/workflows/command_tower/workflows/me/push/create_workflow.rb
740
754
  - app/workflows/command_tower/workflows/me/push/destroy_workflow.rb
741
755
  - app/workflows/command_tower/workflows/me/push/index_workflow.rb
742
756
  - app/workflows/command_tower/workflows/me/push/replace_workflow.rb
757
+ - app/workflows/command_tower/workflows/me/push/test_workflow.rb
743
758
  - app/workflows/command_tower/workflows/me/push/workflow_support.rb
744
759
  - app/workflows/command_tower/workflows/me/pushover/create_workflow.rb
745
760
  - app/workflows/command_tower/workflows/me/pushover/destroy_workflow.rb
@@ -769,6 +784,7 @@ files:
769
784
  - db/migrate/20260817000001_add_scope_columns_to_command_tower_audit_events.rb
770
785
  - db/migrate/20260817000003_create_command_tower_impersonation_sessions.rb
771
786
  - db/migrate/20260826140000_add_deleted_at_to_users.rb
787
+ - db/migrate/20260906180000_create_user_experience_states.rb
772
788
  - docs/admin_workspace.md
773
789
  - docs/api_reference.md
774
790
  - docs/architecture.md
@@ -798,6 +814,8 @@ files:
798
814
  - docs/upgrades/0.13.0.md
799
815
  - docs/upgrades/0.13.1.md
800
816
  - docs/upgrades/0.14.0.md
817
+ - docs/upgrades/0.15.0.md
818
+ - docs/upgrades/0.16.0.md
801
819
  - docs/upgrades/README.md
802
820
  - lib/command_tower.rb
803
821
  - lib/command_tower/admin_scope.rb
@@ -909,6 +927,7 @@ files:
909
927
  - spec/factories/messaging.rb
910
928
  - spec/factories/role.rb
911
929
  - spec/factories/user.rb
930
+ - spec/factories/user_experience_states.rb
912
931
  - spec/factories/user_secret.rb
913
932
  homepage: https://github.com/matt-taylor/command_tower
914
933
  licenses: