command_tower 0.14.0 → 0.15.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: bce961e0a6770bd17a23396e3fdb5066655dbe937f91c85b00c4605802c48f7d
4
- data.tar.gz: e5247dda098ad94825317ec31cfff34c1fa060aac6fcf432ca5a082f93c83e02
3
+ metadata.gz: 408a811a73f66bbaa2785653542b4b94e1824381f325f5f60a1cfc06e97a913a
4
+ data.tar.gz: 84a05525bd6cba9201672711ffee110ad20e856d3a0d278f23994610e57d389e
5
5
  SHA512:
6
- metadata.gz: dd5117447c0bf551b4b55e3a402fbda78a915c8bd0e31a2fbc10a785ef747cc9c6b7c5c8e3aa5306978872fd89910233fb67a69563a3dc1c5105fe06ce67834b
7
- data.tar.gz: bb8d35c52ca0e32b44134015a81462350357fa5e678e3e3bce0daa11a3f76c2e14ff7d2e0731f8ddea812185f579df464886e01464670e68a375490d21528b03
6
+ metadata.gz: 1fe68a093c4add78fb8143068ba91c2aad354219aa33ed8f7b80b3c205e0d72025888842364c26034ed1856b7899c2ce05f4d52ee8a4a94c4a7d53d989c04eb5
7
+ data.tar.gz: f538a68acaffbe712d185ab5fc843b171c2c07d95837e6d81fc3f334b70f6fb6b2d71b4ffeb61805c2c72ada4828dbe68f165fe304bf84ae28c8274b38eb4740
@@ -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,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,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,9 +25,11 @@ 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,
@@ -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,8 +104,10 @@ 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"
@@ -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
@@ -4,6 +4,7 @@ Host-facing upgrade / change summaries for CommandTower releases.
4
4
 
5
5
  | Version | Summary |
6
6
  |---------|---------|
7
+ | [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
8
  | [0.14.0](0.14.0.md) | Expo push Messaging channel (`config.messaging.expo`) + `/api/me/push*` registration HTTP; `me_push` RBAC |
8
9
  | [0.13.1](0.13.1.md) | Canonical `Intervention::Severity` constants (`blocking`, `warning`, `informational`) |
9
10
  | [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,7 @@ entities:
75
75
  - create
76
76
  - update
77
77
  - destroy
78
+ - test
78
79
  - name: me_audit_events
79
80
  controller: CommandTower::Me::AuditEventsController
80
81
  only:
@@ -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
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module CommandTower
4
- VERSION = "0.14.0"
4
+ VERSION = "0.15.0"
5
5
  end
metadata CHANGED
@@ -1,7 +1,7 @@
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.15.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - matt-taylor
@@ -271,6 +271,8 @@ files:
271
271
  - app/errors/command_tower/errors/account/phone_verification_throttled_error.rb
272
272
  - app/errors/command_tower/errors/account/push_capability_unavailable_error.rb
273
273
  - app/errors/command_tower/errors/account/push_endpoint_not_found_error.rb
274
+ - app/errors/command_tower/errors/account/push_test_no_endpoint_error.rb
275
+ - app/errors/command_tower/errors/account/push_test_rate_limit_error.rb
274
276
  - app/errors/command_tower/errors/account/pushover_already_configured_error.rb
275
277
  - app/errors/command_tower/errors/account/pushover_capability_unavailable_error.rb
276
278
  - app/errors/command_tower/errors/account/pushover_not_configured_error.rb
@@ -599,11 +601,13 @@ files:
599
601
  - app/services/command_tower/services/account/clear_phone.rb
600
602
  - app/services/command_tower/services/account/phone_verification/send.rb
601
603
  - app/services/command_tower/services/account/phone_verification/verify.rb
604
+ - app/services/command_tower/services/account/push/check_self_test_rate_limit.rb
602
605
  - app/services/command_tower/services/account/push/create.rb
603
606
  - app/services/command_tower/services/account/push/ct_support.rb
604
607
  - app/services/command_tower/services/account/push/destroy.rb
605
608
  - app/services/command_tower/services/account/push/list.rb
606
609
  - app/services/command_tower/services/account/push/replace.rb
610
+ - app/services/command_tower/services/account/push/send_self_test.rb
607
611
  - app/services/command_tower/services/account/pushover/create.rb
608
612
  - app/services/command_tower/services/account/pushover/ct_support.rb
609
613
  - app/services/command_tower/services/account/pushover/destroy.rb
@@ -740,6 +744,7 @@ files:
740
744
  - app/workflows/command_tower/workflows/me/push/destroy_workflow.rb
741
745
  - app/workflows/command_tower/workflows/me/push/index_workflow.rb
742
746
  - app/workflows/command_tower/workflows/me/push/replace_workflow.rb
747
+ - app/workflows/command_tower/workflows/me/push/test_workflow.rb
743
748
  - app/workflows/command_tower/workflows/me/push/workflow_support.rb
744
749
  - app/workflows/command_tower/workflows/me/pushover/create_workflow.rb
745
750
  - app/workflows/command_tower/workflows/me/pushover/destroy_workflow.rb
@@ -798,6 +803,7 @@ files:
798
803
  - docs/upgrades/0.13.0.md
799
804
  - docs/upgrades/0.13.1.md
800
805
  - docs/upgrades/0.14.0.md
806
+ - docs/upgrades/0.15.0.md
801
807
  - docs/upgrades/README.md
802
808
  - lib/command_tower.rb
803
809
  - lib/command_tower/admin_scope.rb