command_tower 0.16.0 → 0.18.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 (92) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +8 -8
  3. data/app/controllers/command_tower/application_controller.rb +1 -0
  4. data/app/controllers/command_tower/me/inbox_controller.rb +16 -0
  5. data/app/controllers/concerns/command_tower/execution/client_compatibility_boundary.rb +39 -0
  6. data/app/deserializers/command_tower/deserializers/messaging/inbox.rb +85 -0
  7. data/app/errors/command_tower/errors/client_update_required_error.rb +27 -0
  8. data/app/jobs/command_tower/messaging/communications/produce_recipient_job.rb +2 -0
  9. data/app/models/command_tower/messaging/communication.rb +31 -0
  10. data/app/serializers/command_tower/serializers/messaging/inbox.rb +45 -1
  11. data/app/services/command_tower/email_theme/resolver.rb +45 -0
  12. data/app/services/command_tower/messaging/accept/coordinator.rb +16 -1
  13. data/app/services/command_tower/messaging/accept/persister.rb +13 -0
  14. data/app/services/command_tower/messaging/contract/mappers/communication_mapper.rb +7 -0
  15. data/app/services/command_tower/messaging/contract/results/communication_result.rb +1 -0
  16. data/app/services/command_tower/messaging/inbox/conversation_result.rb +31 -0
  17. data/app/services/command_tower/messaging/inbox/entry_result.rb +36 -0
  18. data/app/services/command_tower/messaging/inbox/reader.rb +231 -18
  19. data/app/services/command_tower/messaging/inbox.rb +4 -0
  20. data/app/services/command_tower/messaging/notification_types/declaration.rb +14 -1
  21. data/app/services/command_tower/messaging/rendering/channel_renderer.rb +44 -13
  22. data/app/services/command_tower/messaging/rendering/inbox_document.rb +131 -0
  23. data/app/services/command_tower/messaging/rendering/inbox_document_renderer.rb +224 -0
  24. data/app/services/command_tower/messaging/rendering/inbox_presentation_resolver.rb +52 -0
  25. data/app/services/command_tower/messaging/rendering/inbox_presentation_snapshot.rb +64 -0
  26. data/app/services/command_tower/messaging/rendering/template_resolver.rb +128 -0
  27. data/app/services/command_tower/messaging.rb +5 -1
  28. data/app/services/command_tower/services/client_compatibility/evaluate.rb +219 -0
  29. data/app/services/command_tower/services/messaging/communications/produce.rb +4 -0
  30. data/app/services/command_tower/services/messaging/communications/produce_many.rb +6 -0
  31. data/app/services/command_tower/services/messaging/inbox.rb +55 -2
  32. data/app/views/command_tower/email_verification_mailer/verify_email.html.erb +18 -17
  33. data/app/views/command_tower/messaging/rendering/email.html.erb +6 -5
  34. data/app/views/command_tower/password_reset_mailer/reset_password.html.erb +24 -23
  35. data/app/workflows/command_tower/workflows/auth/plain_text/login_workflow.rb +3 -0
  36. data/app/workflows/command_tower/workflows/auth/session/show_workflow.rb +3 -0
  37. data/app/workflows/command_tower/workflows/client_compatibility/evaluate_workflow.rb +83 -0
  38. data/app/workflows/command_tower/workflows/client_compatibility/recommendation_meta.rb +25 -0
  39. data/app/workflows/command_tower/workflows/messaging/communications/produce_recipient_workflow.rb +5 -1
  40. data/app/workflows/command_tower/workflows/messaging/inbox.rb +15 -1
  41. data/config/routes.rb +1 -0
  42. data/db/migrate/20261010000001_add_inbox_presentation_snapshot_to_messaging_communications.rb +7 -0
  43. data/db/migrate/20261010000002_add_conversation_identity_to_messaging_communications.rb +10 -0
  44. data/docs/api_reference.md +13 -2
  45. data/docs/authorization.md +1 -1
  46. data/docs/bootstrap/00-ownership.md +77 -0
  47. data/docs/bootstrap/01-docker-make-compose.md +267 -0
  48. data/docs/bootstrap/02-create-the-rails-app.md +75 -0
  49. data/docs/bootstrap/03-pin-the-gem.md +53 -0
  50. data/docs/bootstrap/04-secrets-and-env.md +59 -0
  51. data/docs/bootstrap/05-install-migrate-doctor.md +74 -0
  52. data/docs/bootstrap/06-mount-and-health.md +53 -0
  53. data/docs/bootstrap/07-execution-bases.md +38 -0
  54. data/docs/bootstrap/08-initializer.md +90 -0
  55. data/docs/bootstrap/09-rbac.md +83 -0
  56. data/docs/bootstrap/10-roles-and-gates.md +62 -0
  57. data/docs/bootstrap/11-auth-client-path.md +57 -0
  58. data/docs/bootstrap/12-smoke-check.md +81 -0
  59. data/docs/bootstrap/13-optional.md +50 -0
  60. data/docs/bootstrap/14-sanity-checks.md +44 -0
  61. data/docs/bootstrap/README.md +90 -0
  62. data/docs/cookie_authentication_guide.md +11 -0
  63. data/docs/extending.md +5 -4
  64. data/docs/host_integration_guide.md +3 -246
  65. data/docs/initializing.md +37 -28
  66. data/docs/messaging_integration_guide.md +39 -1
  67. data/docs/principal_capabilities.md +1 -1
  68. data/docs/upgrades/0.10.0.md +5 -5
  69. data/docs/upgrades/0.11.0.md +5 -5
  70. data/docs/upgrades/0.17.0.md +33 -0
  71. data/docs/upgrades/0.18.0.md +35 -0
  72. data/docs/upgrades/README.md +3 -1
  73. data/lib/command_tower/authorization/default.yml +1 -0
  74. data/lib/command_tower/client_compatibility/version.rb +45 -0
  75. data/lib/command_tower/client_compatibility.rb +23 -0
  76. data/lib/command_tower/configuration/config.rb +6 -0
  77. data/lib/command_tower/configuration/email_theme/config.rb +69 -0
  78. data/lib/command_tower/configuration/registry/client_compatibility/binding_definition.rb +42 -0
  79. data/lib/command_tower/configuration/registry/client_compatibility/config.rb +353 -0
  80. data/lib/command_tower/configuration/registry/client_compatibility/contract_definition.rb +16 -0
  81. data/lib/command_tower/configuration/registry/client_compatibility/entity_requirement_definition.rb +67 -0
  82. data/lib/command_tower/configuration/registry/client_compatibility/minimum_overrides.rb +46 -0
  83. data/lib/command_tower/configuration/registry/client_compatibility/platform_definition.rb +66 -0
  84. data/lib/command_tower/configuration/registry/config.rb +14 -0
  85. data/lib/command_tower/configuration/registry/inbox_presentations/config.rb +105 -0
  86. data/lib/command_tower/configuration/registry/inbox_presentations/presentation_definition.rb +71 -0
  87. data/lib/command_tower/current.rb +1 -0
  88. data/lib/command_tower/engine.rb +4 -0
  89. data/lib/command_tower/inbox_presentations.rb +19 -0
  90. data/lib/command_tower/install/baseline.rb +2 -0
  91. data/lib/command_tower/version.rb +1 -1
  92. metadata +47 -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
@@ -15,7 +15,9 @@ module CommandTower
15
15
  title:,
16
16
  body:,
17
17
  platform_enabled_channels:,
18
- metadata: nil
18
+ metadata: nil,
19
+ conversation_key: nil,
20
+ conversation_title: nil
19
21
  )
20
22
  user = User.find_by(id: user_id)
21
23
  unless user
@@ -34,6 +36,8 @@ module CommandTower
34
36
  body:,
35
37
  metadata:,
36
38
  platform_enabled_channels:,
39
+ conversation_key:,
40
+ conversation_title:,
37
41
  )
38
42
 
39
43
  unless result.success?
@@ -26,12 +26,26 @@ module CommandTower
26
26
  result = CommandTower::Services::Messaging::Inbox::List.call(user:, limit:, offset:, scope:)
27
27
  return result_or_failure(result) unless result.success?
28
28
 
29
- payload = result.data[:items].map { |item| CommandTower::Serializers::Messaging::Inbox::ItemSerializer.serialize(item) }
29
+ payload = result.data[:items].map { |item| CommandTower::Serializers::Messaging::Inbox::EntrySerializer.serialize(item) }
30
30
  meta = CommandTower::Serializers::Messaging::Inbox::PaginationMetaSerializer.serialize(result.data[:pagination])
31
31
  success(payload:, meta:, http_status: :ok)
32
32
  end
33
33
  end
34
34
 
35
+ class ConversationWorkflow < BaseWorkflow
36
+ def call(user:, key:, scope:, around: nil, before_id: nil, after_id: nil, limit: nil)
37
+ result = CommandTower::Services::Messaging::Inbox::Conversation.call(
38
+ user:, key:, scope:, around:, before_id:, after_id:, limit:,
39
+ )
40
+ return result_or_failure(result) unless result.success?
41
+
42
+ success(
43
+ payload: CommandTower::Serializers::Messaging::Inbox::ConversationSerializer.serialize(result.data[:conversation]),
44
+ http_status: :ok,
45
+ )
46
+ end
47
+ end
48
+
35
49
  class ShowWorkflow < BaseWorkflow
36
50
  def call(user:, inbox_item_id:)
37
51
  result = CommandTower::Services::Messaging::Inbox::Show.call(user:, inbox_item_id:)
data/config/routes.rb CHANGED
@@ -64,6 +64,7 @@ CommandTower::Engine.routes.draw do
64
64
 
65
65
  resources :inbox, only: [:index, :show, :destroy], controller: "inbox" do
66
66
  collection do
67
+ get :conversation
67
68
  get :unread_count, path: "unread-count"
68
69
  post "bulk/read", action: :bulk_read
69
70
  post "bulk/unread", action: :bulk_unread
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ class AddInboxPresentationSnapshotToMessagingCommunications < ActiveRecord::Migration[7.2]
4
+ def change
5
+ add_column :messaging_communications, :inbox_presentation_snapshot, :json
6
+ end
7
+ end
@@ -0,0 +1,10 @@
1
+ # frozen_string_literal: true
2
+
3
+ class AddConversationIdentityToMessagingCommunications < ActiveRecord::Migration[7.2]
4
+ def change
5
+ add_column :messaging_communications, :conversation_key, :string, limit: 128
6
+ add_column :messaging_communications, :conversation_title, :string
7
+ add_index :messaging_communications, %i[user_id conversation_key],
8
+ name: "index_messaging_communications_on_user_and_conversation_key"
9
+ end
10
+ 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`.
@@ -2,7 +2,7 @@
2
2
 
3
3
  Authorization establishes **permission** after authentication. Failed authorization returns `403`.
4
4
 
5
- Host `rbac_groups.yml` is a **required integration step** (Step 4) — see [Host integration](host_integration_guide.md#step-4--host-rbac-required). CommandTower ships CT-owned entity definitions. Hosts grant those names to product roles; they must not copy CT controller mappings. Without a host role that grants Me/Auth entities, authenticated calls return **403**.
5
+ Host `rbac_groups.yml` is a **required integration step** — see [Bootstrap — RBAC](bootstrap/09-rbac.md). CommandTower ships CT-owned entity definitions. Hosts grant those names to product roles; they must not copy CT controller mappings. Without a host role that grants Me/Auth entities, authenticated calls return **403**.
6
6
 
7
7
  ## Quick usage (host provisional)
8
8
 
@@ -0,0 +1,77 @@
1
+ # 00 — Ownership
2
+
3
+ ## Purpose
4
+
5
+ Know what CommandTower owns vs what the host owns **before** you generate a Rails app. Wrong ownership here produces forked platform code and duplicate schema.
6
+
7
+ ## Prerequisites
8
+
9
+ Read [`README.md`](README.md). This step is conceptual; no files yet.
10
+
11
+ ## Engine vs host
12
+
13
+ CommandTower is a **mountable Rails engine**. The host is a Rails API application that:
14
+
15
+ - Depends on the `command_tower` gem
16
+ - Runs install/migrate/doctor **inside Compose**
17
+ - Mounts the engine
18
+ - Configures secrets, RBAC composition, and feature gates
19
+ - Adds **product** routes, workflows, and schema
20
+
21
+ The engine owns:
22
+
23
+ - CommandTower-owned schema (users, sessions, JWT-related tables, Me/Auth, messaging platform tables, and the rest of the engine `db/migrate`)
24
+ - Platform HTTP under the mount
25
+ - Workflow / service / serializer framework
26
+ - Default entity catalog (`lib/command_tower/authorization/default.yml`)
27
+ - Shared FactoryBot definitions
28
+
29
+ The host owns:
30
+
31
+ - `Dockerfile`, `docker-compose.yml`, `Makefile`
32
+ - Product schema, product routes, product workflows
33
+ - `config/initializers/command_tower.rb` after generate
34
+ - `config/rbac_groups.yml` (grants of **CT entity names** plus host-owned entities)
35
+ - Secrets in Compose / env
36
+ - Feature-gate choices
37
+ - Messaging catalogs and adapter credentials (optional)
38
+
39
+ ## Schema (AD-SCH-01)
40
+
41
+ CommandTower is the **sole authoring authority** for CommandTower-owned schema. Hosts must **not** manually write, edit, or copy-paste CommandTower schema migrations.
42
+
43
+ Hosts install copies via `command_tower:install` / `command_tower:install:migrations`, then `db:migrate`. Installed `*.command_tower.rb` files are an **execution context**, not a second authoring home.
44
+
45
+ Contract: [`../initializing.md`](../initializing.md).
46
+
47
+ ## Workflow layer
48
+
49
+ Meaningful business behavior enters through a **workflow**. Controllers, jobs, and scheduled tasks do not call services as a full business action. Workflows do not call other workflows (extract a shared sequence). See [`../architecture.md`](../architecture.md).
50
+
51
+ Do not invent a parallel “manager” layer.
52
+
53
+ ## Dummy `rails_app` is not a product template
54
+
55
+ [`../../rails_app/`](../../rails_app/) is the **in-engine dummy host** used to develop and test the gem. Later steps cite its initializer and `rbac_groups.yml` for **shape**.
56
+
57
+ Do not copy `rails_app/` as a new product. Do not copy this gem’s [`docker-compose.yaml`](../../docker-compose.yaml) (`mysql:latest`, engine ports) — that file is for **engine development**, not a product host.
58
+
59
+ This tree writes **generic** Makefile / Compose templates in [`01-docker-make-compose.md`](01-docker-make-compose.md).
60
+
61
+ ## Docker-only
62
+
63
+ No Ruby or Rails on the host OS. See the README **Docker-only** section. Step 01 exists so `rails new` never runs on the Mac.
64
+
65
+ ## What a platform-proof host is
66
+
67
+ Enough to:
68
+
69
+ 1. Boot in Compose
70
+ 2. Pass `make doctor`
71
+ 3. Register, log in, and receive **200** from `GET /me` as a `member`
72
+
73
+ Product domain, Solid Queue workers, SMS/Pushover adapters, and admin RBAC bundles are **not** required to finish this manual.
74
+
75
+ ## Stop
76
+
77
+ You can explain engine vs host, AD-SCH-01, and why Docker exists before `rails new`. Next: [`01-docker-make-compose.md`](01-docker-make-compose.md).
@@ -0,0 +1,267 @@
1
+ # 01 — Docker, Make, Compose
2
+
3
+ ## Purpose
4
+
5
+ Author the local toolchain **before** any Rails app exists. After this step you can `make help` and `make build` on an empty repository. Ruby is only inside the image.
6
+
7
+ ## Prerequisites
8
+
9
+ - Docker Desktop (or equivalent) with the Compose plugin
10
+ - Make
11
+ - Git
12
+ - Empty (or nearly empty) host repository
13
+
14
+ Do **not** install Ruby, Bundler, or Rails on the Mac.
15
+
16
+ ## Files the host creates
17
+
18
+ | File | Role |
19
+ |------|------|
20
+ | `Dockerfile` | Ruby image: Bundler, MariaDB client, Node (asset/JS tooling if needed) |
21
+ | `docker-compose.yml` | `api`, MariaDB **10.11+**, Redis |
22
+ | `Makefile` | Operator surface: `build`, `rails-new`, `rails`, `migrate`, `doctor`, … |
23
+ | `.dockerignore` | Keep the build context small |
24
+ | `.env` (gitignored) | Local secrets later ([`04-secrets-and-env.md`](04-secrets-and-env.md)); create a stub if Compose interpolates vars |
25
+
26
+ The database in **this recipe** is MariaDB 10.11+ with `utf8mb4` / `utf8mb4_unicode_ci`. That is a host choice documented here. Do not copy the gem’s engine-dev compose (`mysql:latest`).
27
+
28
+ Redis is required for CommandTower cache/features that expect it. Optional `worker` is only if the host uses Solid Queue (add after the app exists).
29
+
30
+ ## Procedure
31
+
32
+ ### 1. `.dockerignore`
33
+
34
+ ```
35
+ .git
36
+ log
37
+ tmp
38
+ .env
39
+ coverage
40
+ vendor/bundle
41
+ node_modules
42
+ .DS_Store
43
+ ```
44
+
45
+ ### 2. `Dockerfile`
46
+
47
+ Use an MRI image CommandTower’s doctor accepts (Rails **>= 7 and < 9**). A current floor that matches engine CI is Ruby **4.0.x** and Rails **8.1**. Do not freeze an older patch than the gem you will pin in step 03.
48
+
49
+ The image must **bind Puma on `0.0.0.0`**. Binding `127.0.0.1` inside the container is not reachable from the Mac.
50
+
51
+ ```dockerfile
52
+ FROM ruby:4.0.1
53
+
54
+ ENV RAILS_LOG_TO_STDOUT=1
55
+ ENV BUNDLE_PATH=/usr/local/bundle
56
+ ENV BUNDLE_JOBS=4
57
+
58
+ RUN apt-get update -qq && apt-get install --no-install-recommends -y \
59
+ build-essential \
60
+ git \
61
+ default-libmysqlclient-dev \
62
+ default-mysql-client \
63
+ libyaml-dev \
64
+ pkg-config \
65
+ redis-tools \
66
+ && rm -rf /var/lib/apt/lists/*
67
+
68
+ WORKDIR /app
69
+
70
+ # Gemfile does not exist until `make rails-new`. Copy the context; install gems when present.
71
+ COPY . .
72
+ RUN if [ -f Gemfile ]; then bundle install; fi
73
+
74
+ EXPOSE 3000
75
+ CMD ["bundle", "exec", "bin/rails", "server", "-b", "0.0.0.0", "-p", "3000"]
76
+ ```
77
+
78
+ Until [`02-create-the-rails-app.md`](02-create-the-rails-app.md) runs, there is **no** `Gemfile`. That is expected. `make build` still produces an image with Ruby, Bundler, and the MariaDB client. `make rails-new` uses that image.
79
+
80
+ After `rails new` you will have a Gemfile; rebuild so `bundle install` during image build caches gems. Day-to-day `make bundle` still refreshes gems in the running volume.
81
+
82
+ ### 3. `docker-compose.yml`
83
+
84
+ ```yaml
85
+ services:
86
+ api:
87
+ build: .
88
+ working_dir: /app
89
+ command: bundle exec bin/rails server -b 0.0.0.0 -p 3000
90
+ volumes:
91
+ - .:/app
92
+ - bundle_cache:/usr/local/bundle
93
+ ports:
94
+ - "3000:3000"
95
+ environment:
96
+ RAILS_ENV: development
97
+ DATABASE_URL: mysql2://app:app@db:3306/app_development
98
+ REDIS_URL: redis://redis:6379/0
99
+ SECRET_KEY_BASE: ${SECRET_KEY_BASE:-dev-secret-key-base-change-me}
100
+ SIGNUP_SESSION_JWT_SECRET: ${SIGNUP_SESSION_JWT_SECRET:-dev-signup-session-change-me}
101
+ PASSWORD_RECOVERY_SESSION_JWT_SECRET: ${PASSWORD_RECOVERY_SESSION_JWT_SECRET:-dev-password-recovery-change-me}
102
+ CORS_ALLOWED_ORIGINS: ${CORS_ALLOWED_ORIGINS:-http://localhost:8081,http://localhost:8082,http://localhost:19006}
103
+ depends_on:
104
+ db:
105
+ condition: service_healthy
106
+ redis:
107
+ condition: service_started
108
+ stdin_open: true
109
+ tty: true
110
+
111
+ db:
112
+ image: mariadb:10.11
113
+ environment:
114
+ MARIADB_ROOT_PASSWORD: root
115
+ MARIADB_DATABASE: app_development
116
+ MARIADB_USER: app
117
+ MARIADB_PASSWORD: app
118
+ ports:
119
+ - "3306:3306"
120
+ volumes:
121
+ - mariadb_data:/var/lib/mysql
122
+ command:
123
+ - --character-set-server=utf8mb4
124
+ - --collation-server=utf8mb4_unicode_ci
125
+ healthcheck:
126
+ test: ["CMD", "healthcheck.sh", "--connect", "--innodb_initialized"]
127
+ interval: 5s
128
+ timeout: 5s
129
+ retries: 20
130
+
131
+ redis:
132
+ image: redis:7-alpine
133
+ ports:
134
+ - "6379:6379"
135
+
136
+ volumes:
137
+ bundle_cache:
138
+ mariadb_data:
139
+ ```
140
+
141
+ Rename `app_development` / user / password to product names when you have them. Keep utf8mb4.
142
+
143
+ `command:` (and the Dockerfile `CMD`) must use `-b 0.0.0.0`. Without it, `make server` is not reachable from the Mac or from an Expo app on the host OS.
144
+
145
+ Published port **3000** is this recipe’s default for a single local API. The frontend `HostConfig` API origin must use the **same** host-published port. A second Command Tower API on this machine publishes a different host port. Engine-dev compose in this gem uses different ports; ignore it.
146
+
147
+ `CORS_ALLOWED_ORIGINS` in the sample (`8081`, `8082`, `19006`) is one web product’s origins. A second local web app uses its own Expo port. Change that origin, this API’s published port, and `config.application.url` (step 08) together. The list is consumed in [`11-auth-client-path.md`](11-auth-client-path.md). `curl` on the Mac does not need CORS.
148
+
149
+ ### 4. `Makefile`
150
+
151
+ Generic operator surface. `COMPOSE` is the only Compose invocation.
152
+
153
+ ```makefile
154
+ .PHONY: help build bundle setup fresh rebuild rails-new rails db-prepare migrate doctor \
155
+ bash console runner server s halt down down-v rspec test spec rubocop
156
+
157
+ COMPOSE := docker compose
158
+ API := $(COMPOSE) run --rm api
159
+ API_T := $(COMPOSE) run --rm -T api
160
+
161
+ help:
162
+ @echo "build Build the api image"
163
+ @echo "bundle bundle install in the container"
164
+ @echo "setup build + db-prepare"
165
+ @echo "fresh down-v + setup"
166
+ @echo "rebuild build --no-cache + bundle"
167
+ @echo "rails-new rails new (API) inside the container"
168
+ @echo "rails Forward to bin/rails (ARGS=...)"
169
+ @echo "db-prepare db:prepare"
170
+ @echo "migrate db:migrate"
171
+ @echo "doctor command_tower:doctor"
172
+ @echo "bash Shell in api"
173
+ @echo "console rails console"
174
+ @echo "runner rails runner CMD='...'"
175
+ @echo "server / s Foreground api server"
176
+ @echo "halt Stop compose"
177
+ @echo "down compose down"
178
+ @echo "down-v compose down -v (destroys DB volume)"
179
+ @echo "rspec/test rspec"
180
+ @echo "spec rspec SPEC=path"
181
+ @echo "rubocop rubocop"
182
+
183
+ build:
184
+ $(COMPOSE) build api
185
+
186
+ bundle:
187
+ $(API) bundle install
188
+
189
+ setup: build db-prepare
190
+
191
+ fresh: down-v setup
192
+
193
+ rebuild:
194
+ $(COMPOSE) build --no-cache api
195
+ $(MAKE) bundle
196
+
197
+ # Chicken-and-egg: image has Ruby; Gemfile may not exist yet.
198
+ # Doctor accepts Rails >= 7 and < 9. Prefer 8.1 to match current engine CI.
199
+ RAILS_NEW_VERSION ?= ~> 8.1.0
200
+ rails-new:
201
+ $(API) bash -lc 'gem install rails -v "$(RAILS_NEW_VERSION)" --no-document && rails new . --api --database=mysql --skip-git --force'
202
+
203
+ rails:
204
+ $(COMPOSE) run --rm -e SKIP_MOUNT -e SKIP_CONFIGURE -e FORCE api bundle exec bin/rails $(ARGS)
205
+
206
+ db-prepare:
207
+ $(API) bundle exec bin/rails db:prepare
208
+
209
+ migrate:
210
+ $(API) bundle exec bin/rails db:migrate
211
+
212
+ doctor:
213
+ $(API) bundle exec bin/rails command_tower:doctor
214
+
215
+ bash:
216
+ $(COMPOSE) run --rm api bash
217
+
218
+ console:
219
+ $(API) bundle exec bin/rails console
220
+
221
+ runner:
222
+ $(API) bundle exec bin/rails runner "$(CMD)"
223
+
224
+ server s:
225
+ $(COMPOSE) up api
226
+
227
+ halt:
228
+ $(COMPOSE) stop
229
+
230
+ down:
231
+ $(COMPOSE) down
232
+
233
+ down-v:
234
+ $(COMPOSE) down -v
235
+
236
+ rspec test:
237
+ $(API_T) bundle exec rspec
238
+
239
+ spec:
240
+ $(API_T) bundle exec rspec $(SPEC)
241
+
242
+ rubocop:
243
+ $(API_T) bundle exec rubocop
244
+ ```
245
+
246
+ `rails-new` installs the Rails **gem inside the ephemeral container** and writes the app onto the mounted `/app`. That is still Docker-only: the Mac never runs `gem` or `rails`.
247
+
248
+ After the app exists, prefer `make rails ARGS='…'` over re-running `rails-new`.
249
+
250
+ Flags such as `SKIP_MOUNT=1` belong on the Make line. The `rails` recipe forwards `SKIP_MOUNT`, `SKIP_CONFIGURE`, and `FORCE` into the container:
251
+
252
+ ```bash
253
+ SKIP_MOUNT=1 make rails ARGS='command_tower:install'
254
+ ```
255
+
256
+ ### 5. Build
257
+
258
+ ```bash
259
+ make help
260
+ make build
261
+ ```
262
+
263
+ `make build` may warn that `Gemfile` is missing. That is expected until step 02.
264
+
265
+ ## Stop
266
+
267
+ `make help` prints the operator surface. `make build` produces an `api` image with Ruby. There is still no Rails application. Next: [`02-create-the-rails-app.md`](02-create-the-rails-app.md).
@@ -0,0 +1,75 @@
1
+ # 02 — Create the Rails app
2
+
3
+ ## Purpose
4
+
5
+ Generate a Rails **8.1 API** application **inside the Compose image**. The host OS never receives a `rails` binary.
6
+
7
+ ## Prerequisites
8
+
9
+ - [`01-docker-make-compose.md`](01-docker-make-compose.md) complete: `make build` works
10
+ - Working directory is the host repository (already contains Dockerfile / Compose / Makefile)
11
+
12
+ ## Files this step creates (inside the container, onto the mount)
13
+
14
+ Rails’ `rails new .` writes `Gemfile`, `config/`, `app/`, `bin/`, and the rest of a standard API app into the current directory.
15
+
16
+ `--force` is required because Dockerfile, Compose, and Makefile already exist.
17
+
18
+ ## Procedure
19
+
20
+ ```bash
21
+ make rails-new
22
+ ```
23
+
24
+ That target (from step 01) runs in the `api` service:
25
+
26
+ ```text
27
+ gem install rails -v "~> 8.1.0" --no-document
28
+ rails new . --api --database=mysql --skip-git --force
29
+ ```
30
+
31
+ Both commands run **in the container**. `--skip-git` avoids nesting a repo; you already have Git on the host. `--database=mysql` matches MariaDB via the `mysql2` adapter.
32
+
33
+ If `rails new` overwrites `Dockerfile`, `docker-compose.yml`, or `Makefile`, **restore the files from step 01**. Rails generators are not the source of the host toolchain. Confirm `command:` / `CMD` still bind `-b 0.0.0.0`.
34
+
35
+ Then add host gems CommandTower does not replace. In `Gemfile` (keep `mysql2` and `puma` from `rails new`):
36
+
37
+ ```ruby
38
+ gem "rack-cors"
39
+ ```
40
+
41
+ `command_tower` (step 03) already depends on `redis`. You do not need a second Redis gem unless the host uses Redis for its own cache.
42
+
43
+ Set `config/database.yml` so development points at the `db` service, not `localhost` on the Mac:
44
+
45
+ ```yaml
46
+ development:
47
+ adapter: mysql2
48
+ encoding: utf8mb4
49
+ collation: utf8mb4_unicode_ci
50
+ url: <%= ENV.fetch("DATABASE_URL") %>
51
+ ```
52
+
53
+ Leave test until you add a test database; `db:prepare` can create it later.
54
+
55
+ Local mail for verify/reset codes (Compose-mounted `tmp/` is visible on the Mac):
56
+
57
+ ```ruby
58
+ # config/environments/development.rb
59
+ config.action_mailer.perform_deliveries = true
60
+ config.action_mailer.delivery_method = :file
61
+ config.action_mailer.file_settings = { location: Rails.root.join("tmp/mails") }
62
+ ```
63
+
64
+ Then:
65
+
66
+ ```bash
67
+ make build
68
+ make bundle
69
+ ```
70
+
71
+ Do not run `rails new` on the Mac. Do not `brew install ruby` to make this step “easier.”
72
+
73
+ ## Stop
74
+
75
+ `Gemfile` exists, `bin/rails` exists **in the repo** (executed only via `make rails`). Dockerfile/Compose from step 01 are still the operator toolchain. `make bundle` succeeds. Next: [`03-pin-the-gem.md`](03-pin-the-gem.md).