command_tower 0.15.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.
- checksums.yaml +4 -4
- data/app/controllers/command_tower/me/experience_states_controller.rb +51 -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/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/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/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 +21 -0
- data/docs/controllers.md +1 -0
- data/docs/host_integration_guide.md +2 -1
- data/docs/initializing.md +1 -0
- data/docs/upgrades/0.16.0.md +30 -0
- data/docs/upgrades/README.md +1 -0
- data/lib/command_tower/authorization/default.yml +5 -0
- data/lib/command_tower/configuration/application/config.rb +5 -0
- data/lib/command_tower/configuration/registry/audit/config.rb +12 -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 +15 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 9e983f8f191ccb75948cdad73067d1cd43b7b1e5a8a3e0d34fc0130a76c313da
|
|
4
|
+
data.tar.gz: 5ef89ea100c06425afe3aee6f87b8b71503e4d28be6a4ff6e309af8a5b02c83f
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
data/app/deserializers/command_tower/deserializers/me/experience_states/complete_deserializer.rb
ADDED
|
@@ -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,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
|
data/app/serializers/command_tower/serializers/me/experience_states/experience_state_serializer.rb
ADDED
|
@@ -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
|
|
@@ -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
|
@@ -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,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
|
data/docs/upgrades/README.md
CHANGED
|
@@ -4,6 +4,7 @@ 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 |
|
|
7
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` |
|
|
8
9
|
| [0.14.0](0.14.0.md) | Expo push Messaging channel (`config.messaging.expo`) + `/api/me/push*` registration HTTP; `me_push` RBAC |
|
|
9
10
|
| [0.13.1](0.13.1.md) | Canonical `Intervention::Severity` constants (`blocking`, `warning`, `informational`) |
|
|
@@ -76,6 +76,11 @@ entities:
|
|
|
76
76
|
- update
|
|
77
77
|
- destroy
|
|
78
78
|
- test
|
|
79
|
+
- name: me_experience_states
|
|
80
|
+
controller: CommandTower::Me::ExperienceStatesController
|
|
81
|
+
only:
|
|
82
|
+
- index
|
|
83
|
+
- complete
|
|
79
84
|
- name: me_audit_events
|
|
80
85
|
controller: CommandTower::Me::AuditEventsController
|
|
81
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,
|
|
@@ -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
|
|
@@ -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.
|
|
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-
|
|
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
|
|
@@ -338,6 +341,7 @@ files:
|
|
|
338
341
|
- app/models/command_tower/messaging/endpoint_pushover_credential.rb
|
|
339
342
|
- app/models/command_tower/messaging/inbox_item.rb
|
|
340
343
|
- app/models/command_tower/messaging/notification_preference.rb
|
|
344
|
+
- app/models/command_tower/user_experience_state.rb
|
|
341
345
|
- app/models/user.rb
|
|
342
346
|
- app/models/user_secret.rb
|
|
343
347
|
- app/serializers/command_tower/serializers/admin/messaging/announcement_response_serializer.rb
|
|
@@ -369,6 +373,7 @@ files:
|
|
|
369
373
|
- app/serializers/command_tower/serializers/me/account_serializer.rb
|
|
370
374
|
- app/serializers/command_tower/serializers/me/change_password_response_serializer.rb
|
|
371
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
|
|
372
377
|
- app/serializers/command_tower/serializers/me/push_serializer.rb
|
|
373
378
|
- app/serializers/command_tower/serializers/me/pushover_serializer.rb
|
|
374
379
|
- app/serializers/command_tower/serializers/messaging/inbox.rb
|
|
@@ -599,6 +604,8 @@ files:
|
|
|
599
604
|
- app/services/command_tower/service_base.rb
|
|
600
605
|
- app/services/command_tower/service_logging.rb
|
|
601
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
|
|
602
609
|
- app/services/command_tower/services/account/phone_verification/send.rb
|
|
603
610
|
- app/services/command_tower/services/account/phone_verification/verify.rb
|
|
604
611
|
- app/services/command_tower/services/account/push/check_self_test_rate_limit.rb
|
|
@@ -738,6 +745,9 @@ files:
|
|
|
738
745
|
- app/workflows/command_tower/workflows/me/delete_account_workflow.rb
|
|
739
746
|
- app/workflows/command_tower/workflows/me/error_mapping.rb
|
|
740
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
|
|
741
751
|
- app/workflows/command_tower/workflows/me/phone_verification/send_workflow.rb
|
|
742
752
|
- app/workflows/command_tower/workflows/me/phone_verification/verify_workflow.rb
|
|
743
753
|
- app/workflows/command_tower/workflows/me/push/create_workflow.rb
|
|
@@ -774,6 +784,7 @@ files:
|
|
|
774
784
|
- db/migrate/20260817000001_add_scope_columns_to_command_tower_audit_events.rb
|
|
775
785
|
- db/migrate/20260817000003_create_command_tower_impersonation_sessions.rb
|
|
776
786
|
- db/migrate/20260826140000_add_deleted_at_to_users.rb
|
|
787
|
+
- db/migrate/20260906180000_create_user_experience_states.rb
|
|
777
788
|
- docs/admin_workspace.md
|
|
778
789
|
- docs/api_reference.md
|
|
779
790
|
- docs/architecture.md
|
|
@@ -804,6 +815,7 @@ files:
|
|
|
804
815
|
- docs/upgrades/0.13.1.md
|
|
805
816
|
- docs/upgrades/0.14.0.md
|
|
806
817
|
- docs/upgrades/0.15.0.md
|
|
818
|
+
- docs/upgrades/0.16.0.md
|
|
807
819
|
- docs/upgrades/README.md
|
|
808
820
|
- lib/command_tower.rb
|
|
809
821
|
- lib/command_tower/admin_scope.rb
|
|
@@ -915,6 +927,7 @@ files:
|
|
|
915
927
|
- spec/factories/messaging.rb
|
|
916
928
|
- spec/factories/role.rb
|
|
917
929
|
- spec/factories/user.rb
|
|
930
|
+
- spec/factories/user_experience_states.rb
|
|
918
931
|
- spec/factories/user_secret.rb
|
|
919
932
|
homepage: https://github.com/matt-taylor/command_tower
|
|
920
933
|
licenses:
|