command_tower 0.11.1 → 0.12.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: d495e295241fa7ac4f9179acc5fd2d4c47fcb8a0603dccdf5353ac08e7c98b28
4
- data.tar.gz: 9d56364ffd4a79ad9bcabe07e2713a2613b2d43112039bc5f31db855f3baad2d
3
+ metadata.gz: 478979c1e8cabe23c8a2b666d7d42ec610abef048a302f4e8e7a5b5b7d9d74b0
4
+ data.tar.gz: 82a5c9c35c78c0a22f1cddf038b203fd0d32433a78be139e386024971189fa05
5
5
  SHA512:
6
- metadata.gz: f605fe2a8117b8e5b6af32dc7c65df55c9400a33a5d50220f5a0c992efd74f28545a8fbfe8c293982f062f398dcc3b86aa552de12d1ec87f733e5a2d6435169e
7
- data.tar.gz: 85072dd065a8589a239166286a2261a331a7cd9c59e0b0c7eb870e5bc60dcfdeb4d32df84671015a93d2edcd3a93592e7647210cf5f553c4a47a346abb214bdb
6
+ metadata.gz: 22fe0f30d9fdd950b976dfad3cb8818bd51eb7cb4877a550fa1c58de69092e8e99ca940dc0c244c9c0343fb8d875b6ea2730f929ac722da7bcc980dabc45d1f0
7
+ data.tar.gz: b5db99a8faa23f2a9d872cae17354f4f005525e5105db924b338778912164fa52cab8c4456cc8c9ea0f6baab4a9a72de44243d713f0d4fa20abadd032294ecde
@@ -0,0 +1,115 @@
1
+ # frozen_string_literal: true
2
+
3
+ module CommandTower
4
+ module Deserializers
5
+ module Intervention
6
+ # Parses a canonical intervention envelope from untrusted params/JSON.
7
+ class EnvelopeDeserializer < CommandTower::Deserializers::ApplicationDeserializer
8
+ Input = Data.define(:action, :allowed, :blockers)
9
+
10
+ def call(params)
11
+ source = normalize_source(params)
12
+ action = required_text(source[:action] || source["action"], field: "action")
13
+ return action if deserializer_result?(action)
14
+
15
+ allowed_raw = source[:allowed].nil? ? source["allowed"] : source[:allowed]
16
+ allowed = allowed_raw == true || allowed_raw.to_s == "true"
17
+
18
+ blockers_raw = source[:blockers] || source["blockers"] || []
19
+ unless blockers_raw.is_a?(Array)
20
+ return failure(errors: [{ field: "blockers", code: "invalid_type", message: "must be an array" }])
21
+ end
22
+
23
+ blockers = []
24
+ blockers_raw.each_with_index do |row, index|
25
+ parsed = parse_blocker(row, index)
26
+ return parsed if deserializer_result?(parsed)
27
+
28
+ blockers << parsed
29
+ end
30
+
31
+ success(Input.new(action: action, allowed: allowed, blockers: blockers))
32
+ end
33
+
34
+ private
35
+
36
+ def normalize_source(params)
37
+ return params.to_unsafe_h if params.respond_to?(:to_unsafe_h)
38
+ return params.to_h if params.respond_to?(:to_h)
39
+
40
+ params.is_a?(Hash) ? params : {}
41
+ end
42
+
43
+ def required_text(raw, field:)
44
+ value = raw.is_a?(String) ? raw.strip : raw.to_s.strip
45
+ if value.empty?
46
+ return failure(errors: [{ field: field, code: "missing_required_fields", message: "is required" }])
47
+ end
48
+
49
+ value
50
+ end
51
+
52
+ def parse_blocker(row, index)
53
+ unless row.is_a?(Hash)
54
+ return failure(
55
+ errors: [{ field: "blockers[#{index}]", code: "invalid_type", message: "must be an object" }]
56
+ )
57
+ end
58
+
59
+ code = required_text(row[:code] || row["code"], field: "blockers[#{index}].code")
60
+ return code if deserializer_result?(code)
61
+
62
+ action = required_text(row[:action] || row["action"], field: "blockers[#{index}].action")
63
+ return action if deserializer_result?(action)
64
+
65
+ title = required_text(row[:title] || row["title"], field: "blockers[#{index}].title")
66
+ return title if deserializer_result?(title)
67
+
68
+ message = required_text(row[:message] || row["message"], field: "blockers[#{index}].message")
69
+ return message if deserializer_result?(message)
70
+
71
+ blocker = {
72
+ code: code,
73
+ action: action,
74
+ title: title,
75
+ message: message
76
+ }
77
+
78
+ severity = row[:severity] || row["severity"]
79
+ blocker[:severity] = severity.to_s if severity.present?
80
+
81
+ remediation_raw = row[:remediation] || row["remediation"]
82
+ if remediation_raw.present?
83
+ remediation = parse_remediation(remediation_raw, index)
84
+ return remediation if deserializer_result?(remediation)
85
+
86
+ blocker[:remediation] = remediation
87
+ end
88
+
89
+ blocker
90
+ end
91
+
92
+ def parse_remediation(row, index)
93
+ unless row.is_a?(Hash)
94
+ return failure(
95
+ errors: [
96
+ { field: "blockers[#{index}].remediation", code: "invalid_type", message: "must be an object" }
97
+ ]
98
+ )
99
+ end
100
+
101
+ kind = required_text(row[:kind] || row["kind"], field: "blockers[#{index}].remediation.kind")
102
+ return kind if deserializer_result?(kind)
103
+
104
+ action = required_text(row[:action] || row["action"], field: "blockers[#{index}].remediation.action")
105
+ return action if deserializer_result?(action)
106
+
107
+ remediation = { kind: kind, action: action }
108
+ label = row[:label] || row["label"]
109
+ remediation[:label] = label.to_s if label.present?
110
+ remediation
111
+ end
112
+ end
113
+ end
114
+ end
115
+ end
@@ -0,0 +1,24 @@
1
+ # frozen_string_literal: true
2
+
3
+ module CommandTower
4
+ module Serializers
5
+ module Intervention
6
+ # One presentation-independent blocker fact. No presentation mode / routes / host policy.
7
+ class BlockerSerializer < CommandTower::Serializers::ApplicationSerializer
8
+ def self.serialize(code:, action:, title:, message:, remediation: nil, severity: nil)
9
+ payload = {
10
+ code: code.to_s,
11
+ action: action.to_s,
12
+ title: title.to_s,
13
+ message: message.to_s
14
+ }
15
+ payload[:severity] = severity.to_s if severity.present?
16
+ if remediation
17
+ payload[:remediation] = RemediationSerializer.serialize(**remediation)
18
+ end
19
+ payload
20
+ end
21
+ end
22
+ end
23
+ end
24
+ end
@@ -0,0 +1,24 @@
1
+ # frozen_string_literal: true
2
+
3
+ module CommandTower
4
+ module Serializers
5
+ module Intervention
6
+ # Action-scoped intervention envelope for GET projections and mutation errors.
7
+ # `blockers` ordered; primary is first. No presentation-mode fields.
8
+ # Host/CT FE owns presentation modes (callout, intercept, sheet, blocking_region).
9
+ class EnvelopeSerializer < CommandTower::Serializers::ApplicationSerializer
10
+ def self.serialize(action:, allowed:, blockers: [])
11
+ {
12
+ action: action.to_s,
13
+ allowed: allowed == true,
14
+ blockers: map_serialize(blockers) { |blocker| BlockerSerializer.serialize(**blocker) }
15
+ }
16
+ end
17
+
18
+ def self.serialize_many(envelopes)
19
+ map_serialize(envelopes) { |envelope| serialize(**envelope) }
20
+ end
21
+ end
22
+ end
23
+ end
24
+ end
@@ -0,0 +1,19 @@
1
+ # frozen_string_literal: true
2
+
3
+ module CommandTower
4
+ module Serializers
5
+ module Intervention
6
+ # Canonical remediation facts (presentation-independent). Host maps `action` to routes.
7
+ class RemediationSerializer < CommandTower::Serializers::ApplicationSerializer
8
+ def self.serialize(kind:, action:, label: nil)
9
+ payload = {
10
+ kind: kind.to_s,
11
+ action: action.to_s
12
+ }
13
+ payload[:label] = label.to_s if label.present?
14
+ payload
15
+ end
16
+ end
17
+ end
18
+ end
19
+ end
@@ -46,7 +46,9 @@ Hosts configure scope behavior on `CommandTower.config.admin_scope` (see [Resour
46
46
 
47
47
  ## Resource scoping
48
48
 
49
- Optional **host-defined resource scoping** narrows Admin tool data (Users, Audit, …) after RBAC. CommandTower remains **domain-blind** — it does not know League, Season, or tenant semantics.
49
+ Optional **host-defined resource scoping** selects a scope value before entering a tool and, for Users/Audit, narrows Admin tool data after RBAC. CommandTower remains **domain-blind** — it does not know League, Season, or tenant semantics.
50
+
51
+ Every `admin_scope.register` requires **`options`**, **`validate`**, and **`availability`**. Resource-narrowing hooks (`narrow_users`, `narrow_audit`, `affected_users_in_scope`) are required for CT-owned **`users`** / **`audit`** (or whenever any narrowing hook is set). Product Admin host tools may register the three base hooks only — scope drives Workspace invocation (0/1/N), not CT SQL narrowing.
50
52
 
51
53
  ```ruby
52
54
  CommandTower.configure do |config|
@@ -64,23 +66,41 @@ CommandTower.configure do |config|
64
66
  registration.narrow_audit = ->(relation:, scope_value:, principal:, tool_id:) { relation }
65
67
  registration.affected_users_in_scope = ->(scope_value:, principal:, tool_id:) { [] }
66
68
  end
69
+
70
+ # Product Admin host tool — invocation scope only (no Users/Audit narrowing).
71
+ config.registry.admin_workspace.tool :example_product_admin do |tool|
72
+ tool.label = "Example Product Admin"
73
+ tool.route = "/admin/example-product"
74
+ tool.group = :product
75
+ tool.sort_order = 300
76
+ tool.required_entity = :host_example_entity
77
+ tool.scope_required = true
78
+ tool.scope_parameter = "resource_slug"
79
+ tool.scope_label = "Resource"
80
+ end
81
+
82
+ config.admin_scope.register(:example_product_admin) do |registration|
83
+ registration.options = ->(principal:) { [CommandTower::AdminScope::ScopeOption.new(value: "x", label: "X")] }
84
+ registration.validate = ->(value:, principal:) { value.to_s == "x" }
85
+ registration.availability = ->(principal:) { { enabled: true, reason: nil } }
86
+ end
67
87
  end
68
88
  ```
69
89
 
70
- | Hook | Purpose |
71
- |------|---------|
72
- | `options` | Eager scope choices for manifest (`scopeOptions`) |
73
- | `availability` | `{ enabled:, reason: }` — disabled tools render non-navigable in FE |
74
- | `validate` | Authorize requested scope value; fail closed → **403** |
75
- | `narrow_users` | SQL narrowing for Users list/show |
76
- | `narrow_audit` | SQL narrowing for host-scoped audit rows |
77
- | `affected_users_in_scope` | User ids for eligible **global** audit events in scoped admin views |
90
+ | Hook | Purpose | Required |
91
+ |------|---------|----------|
92
+ | `options` | Eager scope choices for manifest (`scopeOptions`) | Always |
93
+ | `availability` | `{ enabled:, reason: }` — disabled tools render non-navigable in FE | Always |
94
+ | `validate` | Authorize requested scope value; fail closed → **403** | Always |
95
+ | `narrow_users` | SQL narrowing for Users list/show | `users` / `audit` only |
96
+ | `narrow_audit` | SQL narrowing for host-scoped audit rows | `users` / `audit` only |
97
+ | `affected_users_in_scope` | User ids for eligible **global** audit events in scoped admin views | `users` / `audit` only |
78
98
 
79
- **HTTP disclosure policy:** missing/malformed/unauthorized scope → **403**; authorized scope + resource absent from narrowed relation (nonexistent **or** out of scope) → **404** (indistinguishable).
99
+ **HTTP disclosure policy** (Users/Audit resource APIs): missing/malformed/unauthorized scope → **403**; authorized scope + resource absent from narrowed relation (nonexistent **or** out of scope) → **404** (indistinguishable).
80
100
 
81
101
  Unscoped tools (`scope_required: false`, default) behave exactly as before — no scope query param, no `admin_scope` registration required.
82
102
 
83
- Synthetic proof lives in `rails_app` (`FoundationProof::AdminScope`); Pick'em League adoption is Phase 7 only.
103
+ Synthetic proof lives in `rails_app` (`FoundationProof::AdminScope`). Hosts own product-scoped destinations via FE `resolveScopedDestination` — CT does not interpret path params.
84
104
 
85
105
  ## Seeded CommandTower tools
86
106
 
@@ -0,0 +1,26 @@
1
+ # Upgrade: CommandTower 0.12.0
2
+
3
+ **From:** `0.11.1`
4
+ **To:** `0.12.0`
5
+
6
+ Minor release: canonical action-intervention envelope ser/de, and Admin Scope registrations that do not require Users/Audit resource-narrowing for host product tools.
7
+
8
+ ## Host-visible changes
9
+
10
+ | Change | Host impact |
11
+ |--------|-------------|
12
+ | Intervention envelope | `CommandTower::Serializers::Intervention::EnvelopeSerializer` / `EnvelopeDeserializer` (plus blocker/remediation serializers). Canonical `{ action, allowed, blockers[] }` with ordered blockers; **no** presentation-mode fields. Host/CT FE owns `callout` / `intercept` / `sheet` / `blocking_region`. |
13
+ | Admin Scope hook requirements | Every `admin_scope.register` still requires `options`, `validate`, and `availability`. Resource-narrowing hooks (`narrow_users`, `narrow_audit`, `affected_users_in_scope`) are required for CT-owned **`users`** / **`audit`**, or whenever any narrowing hook is set. Host product tools may register the three base hooks only. |
14
+
15
+ ## Host actions
16
+
17
+ 1. Bump gem to `0.12.0`.
18
+ 2. **No new migration.**
19
+ 3. To emit interventions from host workflows, serialize with `EnvelopeSerializer` (and `serialize_many` for action maps). Do not invent a parallel envelope shape.
20
+ 4. Hosts with scoped product Admin tools can drop unused Users/Audit narrowing hooks from those registrations.
21
+
22
+ ## Not in this release
23
+
24
+ - Frontend `ActionIntervention` presentation (ships in `@commandtower/frontend`)
25
+ - Changing Users/Audit narrowing requirements
26
+ - New HTTP routes
@@ -4,6 +4,7 @@ Host-facing upgrade / change summaries for CommandTower releases.
4
4
 
5
5
  | Version | Summary |
6
6
  |---------|---------|
7
+ | [0.12.0](0.12.0.md) | Intervention envelope serializers/deserializers; product-tool admin_scope without Users/Audit narrowing |
7
8
  | [0.11.1](0.11.1.md) | Audit Event `attribute :json` (MariaDB); audit migration without `utf8mb4_0900_ai_ci` |
8
9
  | [0.11.0](0.11.0.md) | Execution context, audit ledger, Admin Workspace, principal capabilities, impersonation, Admin Users |
9
10
  | [0.10.0](0.10.0.md) | Modern Auth/Me/Messaging platform; SchemaHelper removal; host RBAC required |
@@ -4,13 +4,15 @@ module CommandTower
4
4
  module Configuration
5
5
  module AdminScope
6
6
  class ToolRegistration
7
- REQUIRED_HOOKS = %i[options validate availability narrow_users narrow_audit affected_users_in_scope].freeze
7
+ BASE_HOOKS = %i[options validate availability].freeze
8
+ RESOURCE_NARROWING_HOOKS = %i[narrow_users narrow_audit affected_users_in_scope].freeze
9
+ RESOURCE_SCOPED_TOOL_IDS = %w[users audit].freeze
8
10
 
9
11
  attr_accessor :options, :validate, :availability, :narrow_users, :narrow_audit,
10
12
  :affected_users_in_scope, :host_context_type
11
13
 
12
14
  def validate!(tool_id:)
13
- missing = REQUIRED_HOOKS.reject { |hook| callable?(public_send(hook)) }
15
+ missing = required_hooks_for(tool_id).reject { |hook| callable?(public_send(hook)) }
14
16
  return self if missing.empty?
15
17
 
16
18
  raise CommandTower::AdminScope::InvalidToolRegistrationError,
@@ -19,6 +21,19 @@ module CommandTower
19
21
 
20
22
  private
21
23
 
24
+ def required_hooks_for(tool_id)
25
+ hooks = BASE_HOOKS.dup
26
+ normalized = tool_id.to_s
27
+ if RESOURCE_SCOPED_TOOL_IDS.include?(normalized) || resource_narrowing_hooks_present?
28
+ hooks.concat(RESOURCE_NARROWING_HOOKS)
29
+ end
30
+ hooks
31
+ end
32
+
33
+ def resource_narrowing_hooks_present?
34
+ RESOURCE_NARROWING_HOOKS.any? { |hook| !public_send(hook).nil? }
35
+ end
36
+
22
37
  def callable?(value)
23
38
  value.respond_to?(:call)
24
39
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module CommandTower
4
- VERSION = "0.11.1"
4
+ VERSION = "0.12.0"
5
5
  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.11.1
4
+ version: 0.12.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-08-22 00:00:00.000000000 Z
11
+ date: 2026-08-24 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: phonelib
@@ -249,6 +249,7 @@ files:
249
249
  - app/deserializers/command_tower/deserializers/clients/types.rb
250
250
  - app/deserializers/command_tower/deserializers/coercion_result.rb
251
251
  - app/deserializers/command_tower/deserializers/failure.rb
252
+ - app/deserializers/command_tower/deserializers/intervention/envelope_deserializer.rb
252
253
  - app/deserializers/command_tower/deserializers/me/change_password_deserializer.rb
253
254
  - app/deserializers/command_tower/deserializers/me/phone_verification/verify_deserializer.rb
254
255
  - app/deserializers/command_tower/deserializers/me/pushover/credentials_deserializer.rb
@@ -353,6 +354,9 @@ files:
353
354
  - app/serializers/command_tower/serializers/auth/user_serializer.rb
354
355
  - app/serializers/command_tower/serializers/auth/username_availability_serializer.rb
355
356
  - app/serializers/command_tower/serializers/impersonation/session_serializer.rb
357
+ - app/serializers/command_tower/serializers/intervention/blocker_serializer.rb
358
+ - app/serializers/command_tower/serializers/intervention/envelope_serializer.rb
359
+ - app/serializers/command_tower/serializers/intervention/remediation_serializer.rb
356
360
  - app/serializers/command_tower/serializers/me/account_serializer.rb
357
361
  - app/serializers/command_tower/serializers/me/change_password_response_serializer.rb
358
362
  - app/serializers/command_tower/serializers/me/pushover_serializer.rb
@@ -760,6 +764,7 @@ files:
760
764
  - docs/upgrades/0.10.0.md
761
765
  - docs/upgrades/0.11.0.md
762
766
  - docs/upgrades/0.11.1.md
767
+ - docs/upgrades/0.12.0.md
763
768
  - docs/upgrades/README.md
764
769
  - lib/command_tower.rb
765
770
  - lib/command_tower/admin_scope.rb