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 +4 -4
- data/app/controllers/command_tower/me/push_controller.rb +8 -0
- data/app/errors/command_tower/errors/account/push_test_no_endpoint_error.rb +21 -0
- data/app/errors/command_tower/errors/account/push_test_rate_limit_error.rb +25 -0
- data/app/services/command_tower/services/account/push/check_self_test_rate_limit.rb +41 -0
- data/app/services/command_tower/services/account/push/send_self_test.rb +57 -0
- data/app/workflows/command_tower/workflows/me/error_mapping.rb +3 -1
- data/app/workflows/command_tower/workflows/me/push/test_workflow.rb +54 -0
- data/config/routes.rb +2 -0
- data/docs/upgrades/0.15.0.md +32 -0
- data/docs/upgrades/README.md +1 -0
- data/lib/command_tower/authorization/default.yml +1 -0
- data/lib/command_tower/configuration/messaging/expo.rb +13 -0
- data/lib/command_tower/version.rb +1 -1
- metadata +7 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 408a811a73f66bbaa2785653542b4b94e1824381f325f5f60a1cfc06e97a913a
|
|
4
|
+
data.tar.gz: 84a05525bd6cba9201672711ffee110ad20e856d3a0d278f23994610e57d389e
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
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.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 |
|
|
@@ -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
|
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.
|
|
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
|