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.
- checksums.yaml +4 -4
- data/app/controllers/command_tower/application_controller.rb +1 -0
- data/app/controllers/concerns/command_tower/execution/client_compatibility_boundary.rb +39 -0
- data/app/errors/command_tower/errors/client_update_required_error.rb +27 -0
- data/app/serializers/command_tower/serializers/messaging/inbox.rb +2 -1
- data/app/services/command_tower/email_theme/resolver.rb +45 -0
- data/app/services/command_tower/messaging/notification_types/declaration.rb +14 -1
- data/app/services/command_tower/messaging/rendering/channel_renderer.rb +44 -13
- data/app/services/command_tower/messaging/rendering/inbox_document.rb +131 -0
- data/app/services/command_tower/messaging/rendering/inbox_document_renderer.rb +207 -0
- data/app/services/command_tower/messaging/rendering/template_resolver.rb +128 -0
- data/app/services/command_tower/services/client_compatibility/evaluate.rb +219 -0
- data/app/services/command_tower/services/messaging/inbox.rb +8 -1
- data/app/views/command_tower/email_verification_mailer/verify_email.html.erb +18 -17
- data/app/views/command_tower/messaging/rendering/email.html.erb +6 -5
- data/app/views/command_tower/password_reset_mailer/reset_password.html.erb +24 -23
- data/app/workflows/command_tower/workflows/auth/plain_text/login_workflow.rb +3 -0
- data/app/workflows/command_tower/workflows/auth/session/show_workflow.rb +3 -0
- data/app/workflows/command_tower/workflows/client_compatibility/evaluate_workflow.rb +83 -0
- data/app/workflows/command_tower/workflows/client_compatibility/recommendation_meta.rb +25 -0
- data/docs/api_reference.md +13 -2
- data/docs/extending.md +2 -1
- data/docs/host_integration_guide.md +35 -0
- data/docs/messaging_integration_guide.md +38 -0
- data/docs/upgrades/0.17.0.md +33 -0
- data/docs/upgrades/README.md +1 -0
- data/lib/command_tower/client_compatibility/version.rb +45 -0
- data/lib/command_tower/client_compatibility.rb +23 -0
- data/lib/command_tower/configuration/config.rb +6 -0
- data/lib/command_tower/configuration/email_theme/config.rb +69 -0
- data/lib/command_tower/configuration/registry/client_compatibility/binding_definition.rb +42 -0
- data/lib/command_tower/configuration/registry/client_compatibility/config.rb +353 -0
- data/lib/command_tower/configuration/registry/client_compatibility/contract_definition.rb +16 -0
- data/lib/command_tower/configuration/registry/client_compatibility/entity_requirement_definition.rb +67 -0
- data/lib/command_tower/configuration/registry/client_compatibility/minimum_overrides.rb +46 -0
- data/lib/command_tower/configuration/registry/client_compatibility/platform_definition.rb +66 -0
- data/lib/command_tower/configuration/registry/config.rb +14 -0
- data/lib/command_tower/configuration/registry/inbox_presentations/config.rb +105 -0
- data/lib/command_tower/configuration/registry/inbox_presentations/presentation_definition.rb +71 -0
- data/lib/command_tower/current.rb +1 -0
- data/lib/command_tower/engine.rb +4 -0
- data/lib/command_tower/inbox_presentations.rb +19 -0
- data/lib/command_tower/version.rb +1 -1
- 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
|
data/docs/api_reference.md
CHANGED
|
@@ -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
|
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.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
|