command_tower 0.15.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 (65) hide show
  1. checksums.yaml +4 -4
  2. data/app/controllers/command_tower/application_controller.rb +1 -0
  3. data/app/controllers/command_tower/me/experience_states_controller.rb +51 -0
  4. data/app/controllers/concerns/command_tower/execution/client_compatibility_boundary.rb +39 -0
  5. data/app/deserializers/command_tower/deserializers/me/experience_states/complete_deserializer.rb +52 -0
  6. data/app/errors/command_tower/errors/account/experience_states_host_unconfigured_error.rb +21 -0
  7. data/app/errors/command_tower/errors/client_update_required_error.rb +27 -0
  8. data/app/models/command_tower/user_experience_state.rb +17 -0
  9. data/app/serializers/command_tower/serializers/me/experience_states/experience_state_serializer.rb +36 -0
  10. data/app/serializers/command_tower/serializers/messaging/inbox.rb +2 -1
  11. data/app/services/command_tower/email_theme/resolver.rb +45 -0
  12. data/app/services/command_tower/messaging/notification_types/declaration.rb +14 -1
  13. data/app/services/command_tower/messaging/rendering/channel_renderer.rb +44 -13
  14. data/app/services/command_tower/messaging/rendering/inbox_document.rb +131 -0
  15. data/app/services/command_tower/messaging/rendering/inbox_document_renderer.rb +207 -0
  16. data/app/services/command_tower/messaging/rendering/template_resolver.rb +128 -0
  17. data/app/services/command_tower/services/account/experience_states/complete.rb +55 -0
  18. data/app/services/command_tower/services/account/experience_states/list.rb +28 -0
  19. data/app/services/command_tower/services/client_compatibility/evaluate.rb +219 -0
  20. data/app/services/command_tower/services/messaging/inbox.rb +8 -1
  21. data/app/views/command_tower/email_verification_mailer/verify_email.html.erb +18 -17
  22. data/app/views/command_tower/messaging/rendering/email.html.erb +6 -5
  23. data/app/views/command_tower/password_reset_mailer/reset_password.html.erb +24 -23
  24. data/app/workflows/command_tower/workflows/auth/plain_text/login_workflow.rb +3 -0
  25. data/app/workflows/command_tower/workflows/auth/session/show_workflow.rb +3 -0
  26. data/app/workflows/command_tower/workflows/client_compatibility/evaluate_workflow.rb +83 -0
  27. data/app/workflows/command_tower/workflows/client_compatibility/recommendation_meta.rb +25 -0
  28. data/app/workflows/command_tower/workflows/me/error_mapping.rb +2 -1
  29. data/app/workflows/command_tower/workflows/me/experience_states/complete_workflow.rb +49 -0
  30. data/app/workflows/command_tower/workflows/me/experience_states/list_workflow.rb +30 -0
  31. data/app/workflows/command_tower/workflows/me/experience_states/workflow_support.rb +27 -0
  32. data/config/routes.rb +3 -0
  33. data/db/migrate/20260906180000_create_user_experience_states.rb +25 -0
  34. data/docs/api_reference.md +34 -2
  35. data/docs/controllers.md +1 -0
  36. data/docs/extending.md +2 -1
  37. data/docs/host_integration_guide.md +37 -1
  38. data/docs/initializing.md +1 -0
  39. data/docs/messaging_integration_guide.md +38 -0
  40. data/docs/upgrades/0.16.0.md +30 -0
  41. data/docs/upgrades/0.17.0.md +33 -0
  42. data/docs/upgrades/README.md +2 -0
  43. data/lib/command_tower/authorization/default.yml +5 -0
  44. data/lib/command_tower/client_compatibility/version.rb +45 -0
  45. data/lib/command_tower/client_compatibility.rb +23 -0
  46. data/lib/command_tower/configuration/application/config.rb +5 -0
  47. data/lib/command_tower/configuration/config.rb +6 -0
  48. data/lib/command_tower/configuration/email_theme/config.rb +69 -0
  49. data/lib/command_tower/configuration/registry/audit/config.rb +12 -0
  50. data/lib/command_tower/configuration/registry/client_compatibility/binding_definition.rb +42 -0
  51. data/lib/command_tower/configuration/registry/client_compatibility/config.rb +353 -0
  52. data/lib/command_tower/configuration/registry/client_compatibility/contract_definition.rb +16 -0
  53. data/lib/command_tower/configuration/registry/client_compatibility/entity_requirement_definition.rb +67 -0
  54. data/lib/command_tower/configuration/registry/client_compatibility/minimum_overrides.rb +46 -0
  55. data/lib/command_tower/configuration/registry/client_compatibility/platform_definition.rb +66 -0
  56. data/lib/command_tower/configuration/registry/config.rb +14 -0
  57. data/lib/command_tower/configuration/registry/inbox_presentations/config.rb +105 -0
  58. data/lib/command_tower/configuration/registry/inbox_presentations/presentation_definition.rb +71 -0
  59. data/lib/command_tower/current.rb +1 -0
  60. data/lib/command_tower/engine.rb +4 -0
  61. data/lib/command_tower/inbox_presentations.rb +19 -0
  62. data/lib/command_tower/install/baseline.rb +1 -0
  63. data/lib/command_tower/version.rb +1 -1
  64. data/spec/factories/user_experience_states.rb +13 -0
  65. metadata +37 -2
@@ -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
 
@@ -221,6 +222,41 @@ CommandTower::Testing.install!
221
222
 
222
223
  Details: [Testing](testing.md). Prefer `spec/requests/` patterns in the gem as HTTP contract proof.
223
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
+
224
260
  ## Done when
225
261
 
226
262
  - Doctor passes for secrets/migrations
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
 
@@ -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,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
@@ -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,8 @@ 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 |
8
+ | [0.16.0](0.16.0.md) | Me experience-states (`GET`/`POST complete`); `host_key`; `user_experience_states` migration; `me_experience_states` RBAC |
7
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` |
8
10
  | [0.14.0](0.14.0.md) | Expo push Messaging channel (`config.messaging.expo`) + `/api/me/push*` registration HTTP; `me_push` RBAC |
9
11
  | [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:
@@ -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
@@ -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,
@@ -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
@@ -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
 
@@ -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