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.
Files changed (27) hide show
  1. checksums.yaml +4 -4
  2. data/app/controllers/command_tower/me/experience_states_controller.rb +51 -0
  3. data/app/deserializers/command_tower/deserializers/me/experience_states/complete_deserializer.rb +52 -0
  4. data/app/errors/command_tower/errors/account/experience_states_host_unconfigured_error.rb +21 -0
  5. data/app/models/command_tower/user_experience_state.rb +17 -0
  6. data/app/serializers/command_tower/serializers/me/experience_states/experience_state_serializer.rb +36 -0
  7. data/app/services/command_tower/services/account/experience_states/complete.rb +55 -0
  8. data/app/services/command_tower/services/account/experience_states/list.rb +28 -0
  9. data/app/workflows/command_tower/workflows/me/error_mapping.rb +2 -1
  10. data/app/workflows/command_tower/workflows/me/experience_states/complete_workflow.rb +49 -0
  11. data/app/workflows/command_tower/workflows/me/experience_states/list_workflow.rb +30 -0
  12. data/app/workflows/command_tower/workflows/me/experience_states/workflow_support.rb +27 -0
  13. data/config/routes.rb +3 -0
  14. data/db/migrate/20260906180000_create_user_experience_states.rb +25 -0
  15. data/docs/api_reference.md +21 -0
  16. data/docs/controllers.md +1 -0
  17. data/docs/host_integration_guide.md +2 -1
  18. data/docs/initializing.md +1 -0
  19. data/docs/upgrades/0.16.0.md +30 -0
  20. data/docs/upgrades/README.md +1 -0
  21. data/lib/command_tower/authorization/default.yml +5 -0
  22. data/lib/command_tower/configuration/application/config.rb +5 -0
  23. data/lib/command_tower/configuration/registry/audit/config.rb +12 -0
  24. data/lib/command_tower/install/baseline.rb +1 -0
  25. data/lib/command_tower/version.rb +1 -1
  26. data/spec/factories/user_experience_states.rb +13 -0
  27. metadata +15 -2
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 408a811a73f66bbaa2785653542b4b94e1824381f325f5f60a1cfc06e97a913a
4
- data.tar.gz: 84a05525bd6cba9201672711ffee110ad20e856d3a0d278f23994610e57d389e
3
+ metadata.gz: 9e983f8f191ccb75948cdad73067d1cd43b7b1e5a8a3e0d34fc0130a76c313da
4
+ data.tar.gz: 5ef89ea100c06425afe3aee6f87b8b71503e4d28be6a4ff6e309af8a5b02c83f
5
5
  SHA512:
6
- metadata.gz: 1fe68a093c4add78fb8143068ba91c2aad354219aa33ed8f7b80b3c205e0d72025888842364c26034ed1856b7899c2ce05f4d52ee8a4a94c4a7d53d989c04eb5
7
- data.tar.gz: f538a68acaffbe712d185ab5fc843b171c2c07d95837e6d81fc3f334b70f6fb6b2d71b4ffeb61805c2c72ada4828dbe68f165fe304bf84ae28c8274b38eb4740
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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module CommandTower
4
- VERSION = "0.15.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.15.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
@@ -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: