command_tower 0.16.0 → 0.17.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 (44) hide show
  1. checksums.yaml +4 -4
  2. data/app/controllers/command_tower/application_controller.rb +1 -0
  3. data/app/controllers/concerns/command_tower/execution/client_compatibility_boundary.rb +39 -0
  4. data/app/errors/command_tower/errors/client_update_required_error.rb +27 -0
  5. data/app/serializers/command_tower/serializers/messaging/inbox.rb +2 -1
  6. data/app/services/command_tower/email_theme/resolver.rb +45 -0
  7. data/app/services/command_tower/messaging/notification_types/declaration.rb +14 -1
  8. data/app/services/command_tower/messaging/rendering/channel_renderer.rb +44 -13
  9. data/app/services/command_tower/messaging/rendering/inbox_document.rb +131 -0
  10. data/app/services/command_tower/messaging/rendering/inbox_document_renderer.rb +207 -0
  11. data/app/services/command_tower/messaging/rendering/template_resolver.rb +128 -0
  12. data/app/services/command_tower/services/client_compatibility/evaluate.rb +219 -0
  13. data/app/services/command_tower/services/messaging/inbox.rb +8 -1
  14. data/app/views/command_tower/email_verification_mailer/verify_email.html.erb +18 -17
  15. data/app/views/command_tower/messaging/rendering/email.html.erb +6 -5
  16. data/app/views/command_tower/password_reset_mailer/reset_password.html.erb +24 -23
  17. data/app/workflows/command_tower/workflows/auth/plain_text/login_workflow.rb +3 -0
  18. data/app/workflows/command_tower/workflows/auth/session/show_workflow.rb +3 -0
  19. data/app/workflows/command_tower/workflows/client_compatibility/evaluate_workflow.rb +83 -0
  20. data/app/workflows/command_tower/workflows/client_compatibility/recommendation_meta.rb +25 -0
  21. data/docs/api_reference.md +13 -2
  22. data/docs/extending.md +2 -1
  23. data/docs/host_integration_guide.md +35 -0
  24. data/docs/messaging_integration_guide.md +38 -0
  25. data/docs/upgrades/0.17.0.md +33 -0
  26. data/docs/upgrades/README.md +1 -0
  27. data/lib/command_tower/client_compatibility/version.rb +45 -0
  28. data/lib/command_tower/client_compatibility.rb +23 -0
  29. data/lib/command_tower/configuration/config.rb +6 -0
  30. data/lib/command_tower/configuration/email_theme/config.rb +69 -0
  31. data/lib/command_tower/configuration/registry/client_compatibility/binding_definition.rb +42 -0
  32. data/lib/command_tower/configuration/registry/client_compatibility/config.rb +353 -0
  33. data/lib/command_tower/configuration/registry/client_compatibility/contract_definition.rb +16 -0
  34. data/lib/command_tower/configuration/registry/client_compatibility/entity_requirement_definition.rb +67 -0
  35. data/lib/command_tower/configuration/registry/client_compatibility/minimum_overrides.rb +46 -0
  36. data/lib/command_tower/configuration/registry/client_compatibility/platform_definition.rb +66 -0
  37. data/lib/command_tower/configuration/registry/config.rb +14 -0
  38. data/lib/command_tower/configuration/registry/inbox_presentations/config.rb +105 -0
  39. data/lib/command_tower/configuration/registry/inbox_presentations/presentation_definition.rb +71 -0
  40. data/lib/command_tower/current.rb +1 -0
  41. data/lib/command_tower/engine.rb +4 -0
  42. data/lib/command_tower/inbox_presentations.rb +19 -0
  43. data/lib/command_tower/version.rb +1 -1
  44. metadata +24 -2
@@ -0,0 +1,83 @@
1
+ # frozen_string_literal: true
2
+
3
+ module CommandTower
4
+ module Workflows
5
+ module ClientCompatibility
6
+ # Orchestrates one HTTP request's client-version-compatibility check.
7
+ # Maps the pure `Services::ClientCompatibility::Evaluate` projection to
8
+ # a `WorkflowResult`, and is the only layer (along with the boundary)
9
+ # allowed to place recommendation metadata onto `CommandTower::Current`
10
+ # — `Evaluate` itself never touches `Current` (authority §10, §13).
11
+ class EvaluateWorkflow < CommandTower::Workflows::ApplicationWorkflow
12
+ retry_strategy :none
13
+
14
+ APP_VERSION_HEADER = "X-App-Version"
15
+ PLATFORM_HEADER = "X-Client-Platform"
16
+
17
+ def call(request:, controller_class:, action_name:)
18
+ decision = CommandTower::Services::ClientCompatibility::Evaluate.call(
19
+ app_version_header: request.headers[APP_VERSION_HEADER],
20
+ platform_header: request.headers[PLATFORM_HEADER],
21
+ controller_class: controller_class,
22
+ action_name: action_name
23
+ )
24
+
25
+ mode = CommandTower.config.registry.client_compatibility.mode
26
+ enforced = mode == :enforce
27
+ blocked = enforced && decision.incompatible?
28
+
29
+ log_decision(decision, mode:, enforced:, blocked:)
30
+ stash_recommendation!(decision)
31
+
32
+ if blocked
33
+ return failure(
34
+ errors: [CommandTower::Errors::ClientUpdateRequiredError.new(details: details_for(decision))],
35
+ http_status: :upgrade_required
36
+ )
37
+ end
38
+
39
+ success(payload: { decision: decision })
40
+ end
41
+
42
+ private
43
+
44
+ def stash_recommendation!(decision)
45
+ return unless decision.recommendation_projection
46
+
47
+ CommandTower::Current.client_compatibility_recommendation = decision.recommendation_projection
48
+ end
49
+
50
+ def details_for(decision)
51
+ {
52
+ platform: decision.platform&.to_s,
53
+ scope: decision.scope&.to_s,
54
+ currentVersion: decision.current_version,
55
+ minimumVersion: decision.effective_minimum,
56
+ recovery: decision.recovery&.to_s,
57
+ updateUrl: decision.update_url
58
+ }.compact
59
+ end
60
+
61
+ # Structured (non-audit) decision log via the semantic `command_tower.log.*`
62
+ # event contract (authority §20). Workflows must not write lifecycle
63
+ # observation directly to `Rails.logger` — `publish_event` routes
64
+ # through `CommandTower::Logging::Subscriber` like every other
65
+ # semantic log line.
66
+ def log_decision(decision, mode:, enforced:, blocked:)
67
+ payload = {
68
+ message: "client_compatibility.evaluated",
69
+ mode: mode,
70
+ enforced: enforced,
71
+ platform: decision.platform,
72
+ app_version: decision.current_version,
73
+ matched_entities: decision.matched_entity_names,
74
+ decision: decision.decision,
75
+ http_status: blocked ? 426 : nil
76
+ }.compact
77
+
78
+ publish_event(category: :log, name: blocked ? :warn : :info, payload:)
79
+ end
80
+ end
81
+ end
82
+ end
83
+ end
@@ -0,0 +1,25 @@
1
+ # frozen_string_literal: true
2
+
3
+ module CommandTower
4
+ module Workflows
5
+ module ClientCompatibility
6
+ # Shared sequence fragment: reads the recommendation projection that
7
+ # `EvaluateWorkflow` stashed on `Current` (never written by `Evaluate`
8
+ # itself) and shapes it into `WorkflowResult.meta`. Included only by
9
+ # Login and Session::Show — recommended-update guidance is deliberately
10
+ # not attached to every success envelope (authority §13).
11
+ module RecommendationMeta
12
+ extend ActiveSupport::Concern
13
+
14
+ private
15
+
16
+ def client_compatibility_meta
17
+ recommendation = CommandTower::Current.client_compatibility_recommendation
18
+ return {} if recommendation.blank?
19
+
20
+ { clientCompatibility: recommendation }
21
+ end
22
+ end
23
+ end
24
+ end
25
+ end
@@ -338,8 +338,8 @@ Pagination for list: query `limit` (default **50**, max **100**), `offset` (defa
338
338
  | Method | Path | Notes |
339
339
  |--------|------|--------|
340
340
  | `GET` | `/me/inbox` | List — `data` array of items; pagination meta |
341
- | `GET` | `/me/inbox/:id` | Detail (+ `body`, `metadata`, `notificationTypeKey`) |
342
- | `POST` | `/me/inbox/:id/open` | Detail |
341
+ | `GET` | `/me/inbox/:id` | Detail (+ `body`, `metadata`, `notificationTypeKey`, `content`) |
342
+ | `POST` | `/me/inbox/:id/open` | Detail (+ `content`) |
343
343
  | `PATCH` | `/me/inbox/:id/archive` | Item |
344
344
  | `DELETE` | `/me/inbox/:id` | `data: null` |
345
345
  | `GET` | `/me/inbox/unread-count` | `{ count }` |
@@ -349,6 +349,17 @@ Pagination for list: query `limit` (default **50**, max **100**), `offset` (defa
349
349
  | `POST` | `/me/inbox/bulk/restore` | same |
350
350
  | `POST` | `/me/inbox/bulk/delete` | same |
351
351
 
352
+ **`content` (detail only, response-only — `inbox_document_v1`):** rendered at read from the item's `Communication`, never persisted; absent from list items. Shape: `{ schema: "inbox_document_v1", blocks: [...] }`. Allowlisted block types:
353
+
354
+ | Block | Fields | Notes |
355
+ |-------|--------|-------|
356
+ | `paragraph` | `text` (string) | |
357
+ | `cta` | `label` (string), `href` (string) | `href` must be `http(s)` or a custom scheme (e.g. `pickem://...`); `javascript:`/`data:`/`vbscript:`, schemeless, blank, and unparsable hrefs are rejected — the `cta` block is simply omitted, never an error |
358
+
359
+ Generic (default) rendering: one `paragraph` block from `communication.body` (omitted if blank — `blocks` can legitimately be `[]`), plus one `cta` block if `metadata.deep_link` is a safe href (label from `metadata.cta_label`, default `"Open"`).
360
+
361
+ Hosts may override the document per `notificationTypeKey` with an `inbox_document.json.erb` view at `app/views/command_tower/messaging/rendering/<notification_type_key>/inbox_document.json.erb` (same lookup convention as [`messaging_integration_guide.md`](messaging_integration_guide.md#rendering-template-overrides)). Any failure resolving or rendering that template (missing file, malformed JSON, wrong `schema`/`blocks` shape, or a raising template) fails open to the generic document — the Inbox read path never 500s on a bad type template. A valid envelope with one invalid/unknown block strips only that block; if stripping empties `blocks`, the generic document is used instead.
362
+
352
363
  **List item fields:** `id`, `title`, `status`, `read`, `viewedAt`, `createdAt`, `updatedAt`.
353
364
 
354
365
  **Errors:** `401` / `403` / `422`; show/open may return `404` `not_found`.
data/docs/extending.md CHANGED
@@ -19,6 +19,7 @@ Layer map: [architecture.md](architecture.md). Install/configure/migrate/doctor:
19
19
  | Model reopen | Product associations / behavior |
20
20
  | Initializers | Configuration, including `config.registry.audit.event`, `config.registry.admin_workspace.tool`, and `config.registry.principal_capabilities.capability` |
21
21
  | Notification catalogs / channel policy | Host-owned messaging customization |
22
+ | `app/views/command_tower/messaging/rendering/**` | Host override of generic and/or per-`notification_type_key` rendered Email/SMS/Pushover/Push templates — see [messaging_integration_guide.md](messaging_integration_guide.md#rendering-template-overrides) |
22
23
 
23
24
  ## Internal platform — do not extend
24
25
 
@@ -28,7 +29,7 @@ Layer map: [architecture.md](architecture.md). Install/configure/migrate/doctor:
28
29
  | ServiceBase | Shared service framework |
29
30
  | Serializers | Platform response shaping |
30
31
  | Deserializers | Platform request trust boundary |
31
- | Messaging execution pipeline | Handoff / execution / accept internals |
32
+ | Messaging execution pipeline | Handoff / execution / accept internals, including `Messaging::Rendering::ChannelRenderer` / `TemplateResolver` Ruby classes — hosts customize rendering by dropping ERB views (above), never by reopening or calling these classes |
32
33
  | RequestContext | Framework request context |
33
34
  | JWT primitives | Token issue / validate plumbing |
34
35
  | Internal framework plumbing | Envelope renderer, workflow base mechanics, etc. |
@@ -222,6 +222,41 @@ CommandTower::Testing.install!
222
222
 
223
223
  Details: [Testing](testing.md). Prefer `spec/requests/` patterns in the gem as HTTP contract proof.
224
224
 
225
+ ## Step 11 — Client version compatibility (optional)
226
+
227
+ CommandTower can gate or warn on stale client app versions per platform, ahead of every request (before authn/authz). It is entirely opt-in: an untouched registry is a no-op, and the shipped CommandTower-owned catalog is empty by design.
228
+
229
+ ```ruby
230
+ CommandTower.configure do |c|
231
+ c.registry.client_compatibility.mode = :observe # or :enforce; default observe
232
+
233
+ c.registry.client_compatibility.platform :web do |platform|
234
+ platform.minimum = "1.0.0" # required; MAJOR.MINOR.PATCH[.prerelease]
235
+ platform.recommended = "1.2.0" # optional; non-blocking upgrade guidance
236
+ # platform.update_url is forbidden on :web (web recovery is "reload", not a store)
237
+ end
238
+
239
+ c.registry.client_compatibility.platform :ios do |platform|
240
+ platform.minimum = "2.0.0"
241
+ platform.update_url = "https://apps.apple.com/app/id..."
242
+ end
243
+
244
+ # Optional: a capability-scoped floor stricter than the platform-global minimum,
245
+ # bound to an existing RBAC entity (see Step 4).
246
+ c.registry.client_compatibility.entity :some_host_entity do |entity|
247
+ entity.minimum.ios = "2.1.0"
248
+ end
249
+ end
250
+ ```
251
+
252
+ - Canonical platforms are `web` | `ios` | `android`. Configure only what your app ships — `android` unconfigured never fails boot on its own.
253
+ - Zero configured platforms while otherwise opted in (non-default `mode`, any `entity`/`client_contract`/`bind`) fails boot fast; a completely untouched registry does not.
254
+ - `X-App-Version` / `X-Client-Platform` request headers drive evaluation — never User-Agent, never an allowlist.
255
+ - Every controller runs the check via `before_action :evaluate_client_compatibility!` on `CommandTower::ApplicationController`. Exempt a controller with `skip_before_action :evaluate_client_compatibility!`.
256
+ - Process-level ENV overlays may only **raise** an already-configured platform's minimum, never lower it or configure a platform the host didn't enable: `COMMAND_TOWER_CLIENT_COMPATIBILITY_MODE=observe|enforce`, `COMMAND_TOWER_CLIENT_COMPATIBILITY_MINIMUM_WEB|IOS|ANDROID=<version>`.
257
+ - In `:observe` mode nothing is ever blocked — decisions are logged only. In `:enforce` mode, an incompatible or unparseable identity renders HTTP `426` with a `client_update_required` error envelope.
258
+ - Recommended-update guidance (when a client is compatible but below `recommended`) is surfaced only in `meta.clientCompatibility` on login and session-show responses — never on every response.
259
+
225
260
  ## Done when
226
261
 
227
262
  - Doctor passes for secrets/migrations
@@ -59,6 +59,44 @@ CommandTower::Services::Messaging::Communications::Produce.call(
59
59
 
60
60
  Admin announcements HTTP is a product path over ProduceMany (async/sync, audience selection). Contract: [API reference — Admin messaging](api_reference.md#admin-messaging).
61
61
 
62
+ ## Rendering template overrides
63
+
64
+ `ChannelRenderer` resolves each rendered destination (email HTML/text, SMS, Pushover, push) by `notification_type_key` first, falling back to a generic template — and always prefers a **host** view over CommandTower's own engine default for either. Hosts customize by dropping ERB files at this path in their own `app/views/`; they never call `ChannelRenderer` or its internal `TemplateResolver` collaborator directly.
65
+
66
+ ```text
67
+ app/views/command_tower/messaging/rendering/
68
+ email.html.erb # optional host override of the generic chrome
69
+ email.text.erb
70
+ sms.text.erb
71
+ push.text.erb
72
+ pushover.text.erb
73
+ <notification_type_key>/
74
+ email.html.erb # optional type-specific override (only if the key matches [a-z0-9_]+)
75
+ email.text.erb
76
+ sms.text.erb
77
+ push.text.erb
78
+ pushover.text.erb
79
+ ```
80
+
81
+ Notes:
82
+
83
+ - `<notification_type_key>` directories only resolve when the key matches `/\A[a-z0-9_]+\z/`. Keys with dots (e.g. legacy `"example.type"`-style keys) or other characters always fall back to generic — they never attempt a type directory, even if one happens to exist on disk.
84
+ - Each rendered destination resolves independently — a type directory can override just `email.html.erb` while every other destination (email text, SMS, Pushover, push) still renders from the generic templates.
85
+ - Generic templates receive the same four locals as before (`title`, `body`, `deep_link`, `h` — an HTML-escaping helper). Type-specific templates additionally receive `metadata` (the communication's metadata Hash) and `notification_type_key`.
86
+ - A missing or failing template (generic or type-specific) surfaces the same way it always has: `RenderError` with code `"render_failed"`.
87
+
88
+ ### Inbox document override (`inbox_document.json.erb`)
89
+
90
+ The Me Inbox detail `content` field (`inbox_document_v1`, see [api_reference.md](api_reference.md#me-inbox)) is built by a separate collaborator, `InboxDocumentRenderer`, using the **same** type-directory convention and sanitized-key rule as above, resolved through `TemplateResolver.render_type_template`:
91
+
92
+ ```text
93
+ app/views/command_tower/messaging/rendering/
94
+ <notification_type_key>/
95
+ inbox_document.json.erb # optional type-specific inbox content override
96
+ ```
97
+
98
+ This override has **no generic ERB fallback file** — the generic Inbox document is built in pure Ruby from `communication.body`/`metadata`, not from a template. Because of that, the fail-open contract here is stricter than `ChannelRenderer`'s: a missing type template, malformed JSON, an envelope with the wrong `schema` or a non-Array `blocks`, or a template that raises mid-render all fall back silently to the generic document — the Inbox read path never surfaces a `RenderError` and never 500s. A valid envelope with one invalid/unknown block strips only that block, keeping the rest; if stripping empties `blocks`, the generic document is used instead.
99
+
62
100
  ## Me Inbox HTTP (summary)
63
101
 
64
102
  | Concern | Contract |
@@ -0,0 +1,33 @@
1
+ # Upgrade: CommandTower 0.17.0
2
+
3
+ **From:** `0.16.0`
4
+ **To:** `0.17.0`
5
+
6
+ Minor release: client-version-compatibility gating, Rich Messaging Inbox content documents, and shared email theming. No new migration.
7
+
8
+ ## Host-visible changes
9
+
10
+ | Change | Host impact |
11
+ |--------|-------------|
12
+ | `config.registry.client_compatibility` | Optional per-platform (`web`/`ios`/`android`) minimum/recommended version gating, evaluated on every request ahead of authn/authz via `before_action :evaluate_client_compatibility!`. Untouched registry is a no-op. |
13
+ | `:observe` / `:enforce` modes | `:observe` (default) only logs; `:enforce` renders `426` `client_update_required` for incompatible/unparseable client identity |
14
+ | `meta.clientCompatibility` | Non-blocking upgrade guidance surfaced only on login and session-show responses when a compatible client is below `recommended` |
15
+ | `X-App-Version` / `X-Client-Platform` headers | New request contract driving evaluation — never User-Agent |
16
+ | `GET`/`POST open` `/me/inbox/:id` `content` field | New `inbox_document_v1` field rendered at read time from the `Communication` (never persisted); generic Ruby-built `paragraph`/`cta` blocks, or a host `inbox_document.json.erb` override per `notification_type_key` |
17
+ | `Messaging::Rendering::ChannelRenderer` template resolution | Now resolves per-destination host ERB overrides (`app/views/command_tower/messaging/rendering/**`) before falling back to engine defaults, instead of a single fixed `TEMPLATE_DIR` |
18
+ | `config.email_theme` | New composer for semantic email theme tokens; verification and password-reset mailer views now render through `CommandTower::EmailTheme::Resolver` instead of hardcoded hex colors |
19
+ | `config.registry.inbox_presentations` | New host-owned-only registry (no CommandTower-seeded presentations) supporting Rich Messaging Inbox host customization |
20
+
21
+ ## Host actions
22
+
23
+ 1. Bump gem to `0.17.0` (or `>= 0.17.0`).
24
+ 2. No new migration — **`bundle exec rails command_tower:install:migrations` is not required** for this release.
25
+ 3. Optional: configure `config.registry.client_compatibility` per platform if you want version gating; leaving it untouched is a no-op.
26
+ 4. Optional: customize email theme via `config.email_theme`, or override rendered Messaging templates / `inbox_document.json.erb` per `notification_type_key` under `app/views/command_tower/messaging/rendering/`.
27
+ 5. If your host renders `/me/inbox/:id`, expect the new `content` field in detail responses (list items are unaffected).
28
+
29
+ ## Not in this release
30
+
31
+ - CommandTower frontend client-compatibility banners / upgrade prompts
32
+ - CommandTower-owned Inbox presentation catalog entries (registry remains host-owned-only)
33
+ - `android` platform enforcement examples beyond the documented ENV overlay contract
@@ -4,6 +4,7 @@ Host-facing upgrade / change summaries for CommandTower releases.
4
4
 
5
5
  | Version | Summary |
6
6
  |---------|---------|
7
+ | [0.17.0](0.17.0.md) | Client-version-compatibility gating (`observe`/`enforce`); Rich Messaging Inbox `content` documents; host-overridable rendering templates; shared email theming |
7
8
  | [0.16.0](0.16.0.md) | Me experience-states (`GET`/`POST complete`); `host_key`; `user_experience_states` migration; `me_experience_states` RBAC |
8
9
  | [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` |
9
10
  | [0.14.0](0.14.0.md) | Expo push Messaging channel (`config.messaging.expo`) + `/api/me/push*` registration HTTP; `me_push` RBAC |
@@ -0,0 +1,45 @@
1
+ # frozen_string_literal: true
2
+
3
+ module CommandTower
4
+ module ClientCompatibility
5
+ # Strict client-version identity parsing (authority §12).
6
+ #
7
+ # `Gem::Version` alone is too permissive: it accepts partial shapes such as
8
+ # "1" or "1.2". The authority requires the full MAJOR.MINOR.PATCH shape,
9
+ # with an optional dot-delimited prerelease tail (e.g. "1.2.3.beta.1").
10
+ # Build metadata (e.g. "+build"), hyphenated prerelease tokens, and any
11
+ # other malformed shape are rejected as invalid identity — never coerced
12
+ # into a "valid but low" version.
13
+ module Version
14
+ FORMAT = /\A[vV]?\d+\.\d+\.\d+(\.[A-Za-z0-9]+)*\z/
15
+
16
+ module_function
17
+
18
+ # Returns a normalized (leading "v"/"V" stripped) version string, or nil
19
+ # when the raw value does not conform to the strict MAJOR.MINOR.PATCH
20
+ # (+ optional prerelease) shape.
21
+ def normalize(raw)
22
+ token = raw.to_s.strip
23
+ return nil if token.empty?
24
+ return nil unless token.match?(FORMAT)
25
+
26
+ token.sub(/\A[vV]/, "")
27
+ end
28
+
29
+ def valid?(raw)
30
+ !normalize(raw).nil?
31
+ end
32
+
33
+ # Returns a Gem::Version for a strictly-valid raw value, or nil otherwise.
34
+ # Callers that need a hard failure on invalid input should check
35
+ # `valid?`/`normalize` explicitly rather than relying on Gem::Version's
36
+ # own (looser) parsing.
37
+ def parse(raw)
38
+ normalized = normalize(raw)
39
+ return nil if normalized.nil?
40
+
41
+ Gem::Version.new(normalized)
42
+ end
43
+ end
44
+ end
45
+ end
@@ -0,0 +1,23 @@
1
+ # frozen_string_literal: true
2
+
3
+ module CommandTower
4
+ module ClientCompatibility
5
+ class Error < CommandTower::Error; end
6
+
7
+ class HostOverrideError < Error; end
8
+ class DuplicateContractError < Error; end
9
+ class DuplicateEntityRequirementError < Error; end
10
+ class DuplicateBindingError < Error; end
11
+ class UnknownClientContractError < Error; end
12
+ class UnboundClientContractError < Error; end
13
+ class UnknownEntityError < Error; end
14
+ class InvalidPlatformError < Error; end
15
+ class InvalidContractIdError < Error; end
16
+ class InvalidEntityNameError < Error; end
17
+ class InvalidVersionError < Error; end
18
+ class InvalidModeError < Error; end
19
+ class ConflictingRequirementError < Error; end
20
+ class NoConfiguredPlatformsError < Error; end
21
+ class FrozenRegistryError < Error; end
22
+ end
23
+ end
@@ -10,6 +10,7 @@ require "command_tower/configuration/authorization/config"
10
10
  require "command_tower/configuration/base"
11
11
  require "command_tower/configuration/credentials/config"
12
12
  require "command_tower/configuration/email/config"
13
+ require "command_tower/configuration/email_theme/config"
13
14
  require "command_tower/configuration/identity/config"
14
15
  require "command_tower/configuration/impersonation/config"
15
16
  require "command_tower/configuration/jwt/config"
@@ -49,6 +50,11 @@ module CommandTower
49
50
  allowed: Configuration::Email::Config,
50
51
  default: Configuration::Email::Config.new
51
52
 
53
+ add_composer :email_theme,
54
+ desc: "Semantic email theme tokens shared by Messaging and (later) auth mail templates",
55
+ allowed: Configuration::EmailTheme::Config,
56
+ default: Configuration::EmailTheme::Config.new
57
+
52
58
  add_composer :credentials,
53
59
  desc: "Deployment provider credentials (typed per provider under config.credentials.<provider>). Consumed by Credential Resolution. Not provider behavior configuration.",
54
60
  allowed: Configuration::Credentials::Config,
@@ -0,0 +1,69 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "class_composer"
4
+
5
+ module CommandTower
6
+ module Configuration
7
+ module EmailTheme
8
+ # Semantic email theme tokens shared by Messaging email templates and,
9
+ # later, CT-owned auth mail templates. CT-owned, gem-level contract —
10
+ # see Rich Messaging Strategy §12 (email theme sequence, step A).
11
+ #
12
+ # Deliberately holds only visual/color tokens. Product identity
13
+ # (name/URL) already exists on Configuration::Application::Config and
14
+ # is composed in by CommandTower::EmailTheme::Resolver, not duplicated
15
+ # here. Defaults are the current unbranded hex values already
16
+ # hardcoded in command_tower's generic email.html.erb and Pick'em's
17
+ # incomplete_wager/email.html.erb, so introducing this contract is a
18
+ # no-op until a template is switched to consume it.
19
+ class Config < ::CommandTower::Configuration::Base
20
+ include ClassComposer::Generator
21
+
22
+ add_composer :canvas_background,
23
+ allowed: String,
24
+ default: "#f4f5f7",
25
+ desc: "Outer email canvas background color"
26
+
27
+ add_composer :surface_background,
28
+ allowed: String,
29
+ default: "#ffffff",
30
+ desc: "Card/surface background color"
31
+
32
+ add_composer :surface_border,
33
+ allowed: String,
34
+ default: "#e2e8f0",
35
+ desc: "Card/surface border color"
36
+
37
+ add_composer :primary_text,
38
+ allowed: String,
39
+ default: "#1a202c",
40
+ desc: "Headline/primary text color"
41
+
42
+ add_composer :body_text,
43
+ allowed: String,
44
+ default: "#4a5568",
45
+ desc: "Body copy text color"
46
+
47
+ add_composer :muted_text,
48
+ allowed: String,
49
+ default: "#718096",
50
+ desc: "De-emphasized/secondary text color"
51
+
52
+ add_composer :accent,
53
+ allowed: String,
54
+ default: "#2b6cb0",
55
+ desc: "Single flat accent color (collapses any gradient to one value)"
56
+
57
+ add_composer :text_on_accent,
58
+ allowed: String,
59
+ default: "#ffffff",
60
+ desc: "Text/icon color rendered on top of the accent color"
61
+
62
+ add_composer :primary_action,
63
+ allowed: String,
64
+ default: "#2b6cb0",
65
+ desc: "Link/button primary-action color"
66
+ end
67
+ end
68
+ end
69
+ end
@@ -0,0 +1,42 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "class_composer"
4
+
5
+ module CommandTower
6
+ module Configuration
7
+ module Registry
8
+ module ClientCompatibility
9
+ # Host-owned per-platform host-app version bound to a named client
10
+ # contract. Authority §7.
11
+ class BindingDefinition
12
+ include ClassComposer::Generator
13
+
14
+ add_composer :web, desc: "Bound host-app version for web", allowed: [String, NilClass], default: nil
15
+ add_composer :ios, desc: "Bound host-app version for ios", allowed: [String, NilClass], default: nil
16
+ add_composer :android, desc: "Bound host-app version for android", allowed: [String, NilClass], default: nil
17
+
18
+ def for_platform(platform)
19
+ public_send(platform)
20
+ end
21
+
22
+ def validate!(contract_id:)
23
+ %i[web ios android].each do |platform|
24
+ value = public_send(platform)
25
+ next if value.blank?
26
+
27
+ normalized = CommandTower::ClientCompatibility::Version.normalize(value)
28
+ if normalized.nil?
29
+ raise CommandTower::ClientCompatibility::InvalidVersionError,
30
+ "binding for client contract #{contract_id} has invalid #{platform} #{value.inspect}"
31
+ end
32
+
33
+ public_send("#{platform}=", normalized)
34
+ end
35
+
36
+ self
37
+ end
38
+ end
39
+ end
40
+ end
41
+ end
42
+ end