maquina-generators 0.4.1 → 0.6.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 (82) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +96 -17
  3. data/lib/generators/maquina/app/app_generator.rb +75 -19
  4. data/lib/generators/maquina/app/templates/.rubocop.yml +6 -0
  5. data/lib/generators/maquina/app/templates/README.md.erb +6 -4
  6. data/lib/generators/maquina/app/templates/app/views/home/index.html.erb +18 -5
  7. data/lib/generators/maquina/app/templates/config/database.yml.tt +12 -0
  8. data/lib/generators/maquina/mission_control_jobs/mission_control_jobs_generator.rb +32 -1
  9. data/lib/generators/maquina/mission_control_jobs/templates/app/controllers/backstage_dashboard_controller.rb +47 -0
  10. data/lib/generators/maquina/mission_control_jobs/templates/app/views/backstage_dashboard/index.html.erb.tt +93 -0
  11. data/lib/generators/maquina/mission_control_jobs/templates/app/views/layouts/_admin_navigation.html.erb.tt +18 -6
  12. data/lib/generators/maquina/mission_control_jobs/templates/app/views/layouts/admin.html.erb +21 -0
  13. data/lib/generators/maquina/mission_control_jobs/templates/app/views/mission_control/jobs/jobs/_general_information.html.erb +1 -1
  14. data/lib/generators/maquina/mission_control_jobs/templates/app/views/mission_control/jobs/jobs/failed/_job.html.erb +2 -2
  15. data/lib/generators/maquina/mission_control_jobs/templates/app/views/mission_control/jobs/shared/_job.html.erb +1 -1
  16. data/lib/generators/maquina/rack_attack/USAGE +8 -6
  17. data/lib/generators/maquina/rack_attack/rack_attack_generator.rb +6 -2
  18. data/lib/generators/maquina/rack_attack/templates/config/initializers/rack_attack.rb.tt +139 -57
  19. data/lib/generators/maquina/security/USAGE +15 -0
  20. data/lib/generators/maquina/security/security_generator.rb +278 -0
  21. data/lib/generators/maquina/security/templates/app/controllers/backstage/security_controller.rb.tt +45 -0
  22. data/lib/generators/maquina/security/templates/app/controllers/backstage_controller.rb.tt +7 -0
  23. data/lib/generators/maquina/security/templates/app/controllers/backstage_dashboard_controller.rb +47 -0
  24. data/lib/generators/maquina/security/templates/app/models/security/abuse_event.rb +36 -0
  25. data/lib/generators/maquina/security/templates/app/models/security/abuse_report.rb +112 -0
  26. data/lib/generators/maquina/security/templates/app/models/security/record.rb +6 -0
  27. data/lib/generators/maquina/security/templates/app/views/backstage/security/_addresses.html.erb +37 -0
  28. data/lib/generators/maquina/security/templates/app/views/backstage/security/_blocked.html.erb +53 -0
  29. data/lib/generators/maquina/security/templates/app/views/backstage/security/_by_day.html.erb +33 -0
  30. data/lib/generators/maquina/security/templates/app/views/backstage/security/_hosts.html.erb +31 -0
  31. data/lib/generators/maquina/security/templates/app/views/backstage/security/_paths.html.erb +33 -0
  32. data/lib/generators/maquina/security/templates/app/views/backstage/security/_recent.html.erb +37 -0
  33. data/lib/generators/maquina/security/templates/app/views/backstage/security/_utc_time.html.erb +2 -0
  34. data/lib/generators/maquina/security/templates/app/views/backstage/security/show.html.erb +47 -0
  35. data/lib/generators/maquina/security/templates/app/views/backstage_dashboard/index.html.erb.tt +93 -0
  36. data/lib/generators/maquina/security/templates/app/views/layouts/_admin_navigation.html.erb.tt +40 -0
  37. data/lib/generators/maquina/security/templates/app/views/layouts/admin.html.erb +21 -0
  38. data/lib/generators/maquina/security/templates/app/views/layouts/backstage/security.html.erb +23 -0
  39. data/lib/generators/maquina/security/templates/config/initializers/rack_attack_events.rb +34 -0
  40. data/lib/generators/maquina/security/templates/db/security_schema.rb.tt +27 -0
  41. data/lib/generators/maquina/solid_errors/USAGE +24 -0
  42. data/lib/generators/maquina/solid_errors/solid_errors_generator.rb +171 -14
  43. data/lib/generators/maquina/solid_errors/templates/app/agents/error_redactor.rb +34 -0
  44. data/lib/generators/maquina/solid_errors/templates/app/agents/errors_query.rb +84 -0
  45. data/lib/generators/maquina/solid_errors/templates/app/agents/failure_triage.rb +21 -0
  46. data/lib/generators/maquina/solid_errors/templates/app/agents/get_exception_tool.rb +26 -0
  47. data/lib/generators/maquina/solid_errors/templates/app/agents/list_failures_tool.rb +20 -0
  48. data/lib/generators/maquina/solid_errors/templates/app/agents/top_exceptions_tool.rb +18 -0
  49. data/lib/generators/maquina/solid_errors/templates/app/controllers/backstage_dashboard_controller.rb +47 -0
  50. data/lib/generators/maquina/solid_errors/templates/app/controllers/failures_mcp_controller.rb.tt +65 -0
  51. data/lib/generators/maquina/solid_errors/templates/app/views/backstage_dashboard/index.html.erb.tt +93 -0
  52. data/lib/generators/maquina/solid_errors/templates/app/views/layouts/_admin_navigation.html.erb.tt +18 -6
  53. data/lib/generators/maquina/solid_errors/templates/app/views/layouts/admin.html.erb +21 -0
  54. data/lib/generators/maquina/solid_errors/templates/app/views/solid_errors/error_mailer/error_occurred.html.erb +65 -0
  55. data/lib/generators/maquina/solid_errors/templates/app/views/solid_errors/error_mailer/error_occurred.text.erb +30 -0
  56. data/lib/generators/maquina/solid_errors/templates/bin/failures +37 -0
  57. data/lib/generators/maquina/solid_errors/templates/bin/failures-mcp +21 -0
  58. data/lib/generators/maquina/solid_errors/templates/config/initializers/solid_errors.rb.tt +12 -0
  59. data/lib/maquina_generators/version.rb +1 -1
  60. data/test/generators/maquina/app_generator_test.rb +142 -5
  61. data/test/generators/maquina/failures_mcp_tools_test.rb +37 -0
  62. data/test/generators/maquina/mission_control_jobs_generator_test.rb +28 -4
  63. data/test/generators/maquina/rack_attack_generator_test.rb +41 -19
  64. data/test/generators/maquina/security_generator_test.rb +380 -0
  65. data/test/generators/maquina/solid_errors_generator_test.rb +199 -4
  66. data/test/tmp/Gemfile +1 -1
  67. data/test/tmp/app/controllers/application_controller.rb +2 -0
  68. data/test/tmp/app/controllers/registrations_controller.rb +37 -0
  69. data/test/tmp/app/models/account.rb +5 -0
  70. data/test/tmp/app/models/current.rb +5 -0
  71. data/test/tmp/app/models/user.rb +12 -0
  72. data/test/tmp/app/views/registrations/new.html.erb +72 -0
  73. data/test/tmp/app/views/sessions/new.html.erb +46 -0
  74. data/test/tmp/config/locales/registration.en.yml +44 -0
  75. data/test/tmp/config/locales/registration.es.yml +44 -0
  76. data/test/tmp/config/routes.rb +4 -0
  77. data/test/tmp/db/migrate/20260927024163_create_accounts.rb +9 -0
  78. data/test/tmp/db/migrate/20260927024164_add_account_fields_to_users.rb +7 -0
  79. metadata +72 -7
  80. data/test/tmp/Procfile.dev +0 -3
  81. data/test/tmp/config/application.rb +0 -9
  82. data/test/tmp/config/solid_queue.yml +0 -21
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: ca10b8ebcd86d74709ad16f64e6366457b53fc9a55da72a997f9084c5819d267
4
- data.tar.gz: 3147bb48486e66744e0b2af74ab06368a7b6e782a77b8c3946069f6c6178d76f
3
+ metadata.gz: d8023146b756cacff577544a89e379d751c5f4f1e1df5fd1cb61932a514049bd
4
+ data.tar.gz: 1bff652af72d0e15d9dbd66e90e758b8f900200d5ae5dbd5c783157cfaa49f3a
5
5
  SHA512:
6
- metadata.gz: 6d3ab8cb8656f06fa0c1ced220473bf6bfc76c7931ea70133a91405fdc28b866d2f840f35ced6f9056a382b812a4c03898e2b94ec40f7ab76c39297900789b39
7
- data.tar.gz: 7c5cec9099144f5a9216afe62df79d5c5561d23886accd332017d2e491bf29fe25d787129288f743ec0784d19ff9c85cfe15274f00976c60c554fe018d84007e
6
+ metadata.gz: 882a19b2e1d69f38e9acc0e73a312bb5a9750155b7521c6a2dc9597ddb93c1934c83536d5525a74916b7750a49f3bfd94543bd09dcd01a43a2aa2d8b0525d794
7
+ data.tar.gz: b9a6a548f3b6dbf7f6871e68588066f97bad710e2fc5a978dc9dcb32636b2448c60c86928dbc874b87cc4b3012030f94becc893049635418fb0df6e29e19b474
data/README.md CHANGED
@@ -10,13 +10,13 @@ A collection of Rails generators from the Maquina umbrella. Each generator produ
10
10
 
11
11
  #### What it generates
12
12
 
13
- - **Models:** `User`, `Session`, `EmailVerification`, `Current`
13
+ - **Models:** `Account`, `User`, `Session`, `EmailVerification`, `Current`
14
14
  - **Controllers:** Sign-in/sign-up flows with email code verification
15
15
  - **Views:** Minimal, responsive forms styled with Tailwind CSS
16
16
  - **Mailer:** Verification code emails (HTML + text)
17
17
  - **Job:** Cleanup job for expired sessions and verifications
18
18
  - **Locale files:** English and Spanish translations
19
- - **Migrations:** 3 migrations (users, sessions, email_verifications)
19
+ - **Migrations:** 4 migrations (accounts, users, sessions, email_verifications)
20
20
  - **Test helper:** `sign_in_as(user)` and `sign_out` for integration tests
21
21
 
22
22
  #### Installation
@@ -24,7 +24,7 @@ A collection of Rails generators from the Maquina umbrella. Each generator produ
24
24
  Add to your Gemfile:
25
25
 
26
26
  ```ruby
27
- gem "maquina_generators", group: :development
27
+ gem "maquina-generators", group: :development
28
28
  ```
29
29
 
30
30
  Run the generator:
@@ -114,8 +114,10 @@ All generated code lives in your app -- edit it directly:
114
114
 
115
115
  - **BackstageController:** Inherits from `ActionController::Base` (bypasses app's ApplicationController concerns)
116
116
  - **Initializer:** Credentials-first auth with ENV variable fallback, database connection config
117
- - **Route:** Mounts `SolidErrors::Engine` under a configurable prefix
118
- - **Admin navigation:** Shared navigation bar linking Solid Errors and Mission Control Jobs dashboards
117
+ - **Route:** Mounts `SolidErrors::Engine` at `<prefix>/errors`
118
+ - **Mailer templates:** Self-contained `error_occurred` HTML and text templates, so `send_emails` works without the dashboard's helpers
119
+ - **Admin navigation:** Shared navigation bar with Overview, Errors, Jobs and Security tabs
120
+ - **Admin overview:** `BackstageDashboardController` at the prefix root with cards for each installed dashboard and a `@metrics` slot for your own aggregate counts; answers 503 until backstage credentials are set
119
121
  - **Custom layout:** Tailwind-styled layout with admin navigation and toast flash messages
120
122
  - **Stimulus controllers:** `clipboard_controller.js` and `backtrace_filter_controller.js`
121
123
  - **Custom views:** Tailwind-styled views to override the gem defaults (included by default, use `--no-copy-views` to skip)
@@ -156,8 +158,9 @@ Credentials are resolved in order:
156
158
  - **BackstageController:** Inherits from `ActionController::Base` with maquina_components helpers (bypasses app's ApplicationController concerns)
157
159
  - **Helper:** `MissionControlHelper` with `job_status_badge_variant` and `nav_icon_for_section`
158
160
  - **Initializer:** Sets base controller class, credentials-first auth with ENV variable fallback
159
- - **Route:** Mounts `MissionControl::Jobs::Engine` under a configurable prefix
160
- - **Admin navigation:** Shared navigation bar linking Solid Errors and Mission Control Jobs dashboards
161
+ - **Route:** Mounts `MissionControl::Jobs::Engine` at `<prefix>/jobs`
162
+ - **Admin navigation:** Shared navigation bar with Overview, Errors, Jobs and Security tabs
163
+ - **Admin overview:** `BackstageDashboardController` at the prefix root with cards for each installed dashboard and a `@metrics` slot for your own aggregate counts; answers 503 until backstage credentials are set
161
164
  - **Custom layout:** Tailwind-styled layout with admin navigation, toast flash messages, application/server selection, and tab navigation
162
165
  - **Custom views:** Tailwind-styled views for jobs, queues, workers, and recurring tasks (included by default, use `--no-copy-views` to skip)
163
166
 
@@ -218,28 +221,104 @@ rails g maquina:solid_queue --database postgresql # PostgreSQL
218
221
 
219
222
  ### Rack Attack -- Request Protection
220
223
 
221
- **Rack Attack** installs the [rack-attack](https://github.com/rack/rack-attack) gem with default security rules to block common vulnerability scans and throttle abusive requests.
224
+ **Rack Attack** installs the [rack-attack](https://github.com/rack/rack-attack) gem with rules that ban vulnerability scanners and addresses that ignore the throttle.
222
225
 
223
226
  #### What it generates
224
227
 
225
- - **Initializer:** `config/initializers/rack_attack.rb` with blocklists, safelists, and throttles
228
+ - **Initializer:** `config/initializers/rack_attack.rb` with the bans, throttles, safelist, 403 responder and an `[ATTACK]` log line per refusal. Its knobs are constants on `Rack::Attack` (`SCANNER_*`, `GENERAL_*`, `FLOOD_*`, `LOGIN_*`) and `Rack::Attack.banned?(ip)` answers whether an address is banned now.
226
229
 
227
230
  #### Usage
228
231
 
229
232
  ```bash
230
233
  rails g maquina:rack_attack
234
+ rails g maquina:rack_attack --login-path /sign_in # Throttle a different sign-in path
231
235
  ```
232
236
 
233
237
  The generator automatically runs `bundle install`.
234
238
 
235
239
  #### Default Protections
236
240
 
237
- - **Blocklists:** PHP files (`*.php`), WordPress paths (`wp-admin`, `wp-login`, etc.), sensitive files (`.env`, `.git`, `/etc/passwd`, etc.), scanner targets (`phpmyadmin`, `cgi-bin`, etc.)
241
+ - **Scanner ban (Fail2Ban):** three scanner paths in 10 minutes bans the IP for 7 days. Scanner paths are PHP files (`*.php`), WordPress paths (`wp-admin`, `wp-login`, etc.), sensitive files anywhere in the path (`.env`, `.git`, `/etc/passwd`, etc.), backup archives (`.zip`, `.sql`, `.tar.gz`, `.bak`, etc.) and scanner targets (`phpmyadmin`, `cgi-bin`, etc.). `/rails/active_storage` is exempt.
242
+ - **Flood ban (Allow2Ban):** 600 requests in 5 minutes (twice the general throttle) bans the IP for 1 day.
243
+ - **Throttles:** 300 requests/5min per IP (general; `/assets` and `/rails/active_storage` exempt), 5 `POST /session`/20s per IP
238
244
  - **Safelists:** Localhost (`127.0.0.1`, `::1`)
239
- - **Throttles:** 300 requests/5min per IP (general), 5 login attempts/20s per IP
240
- - **Responses:** 403 Forbidden for blocklisted, 429 Too Many Requests for throttled
245
+ - **Responses:** 403 Forbidden for blocklisted and banned, 429 Too Many Requests for throttled
241
246
 
242
- Customize rules in `config/initializers/rack_attack.rb`.
247
+ Bans live in `Rails.cache`, so they need a shared cache store (Solid Cache) in production. Customize rules in `config/initializers/rack_attack.rb`. To see the refusals, add `maquina:security`.
248
+
249
+ ---
250
+
251
+ ### Security -- Rack::Attack with an Abuse Dashboard
252
+
253
+ **Security** records every Rack::Attack refusal in its own database and summarises the last week on a backstage page next to Solid Errors and Mission Control Jobs.
254
+
255
+ #### What it generates
256
+
257
+ - **Rack::Attack rules:** runs `maquina:rack_attack` unless `config/initializers/rack_attack.rb` already defines `Rack::Attack.banned?` (an older initializer without it is replaced)
258
+ - **Subscriber:** `config/initializers/rack_attack_events.rb` writes a `Security::AbuseEvent` per throttled, blocked or banned request; a failed write never turns a 403 into a 500
259
+ - **Models:** `Security::Record` (`connects_to` the `security` database), `Security::AbuseEvent` (30-day retention), `Security::AbuseReport` (the page's figures)
260
+ - **Database:** `db/security_schema.rb` and a `security:` entry in every multi-database environment of `config/database.yml` (and `config/database.yml.example`)
261
+ - **Retention:** a daily `purge_abuse_events` task in `config/recurring.yml`
262
+ - **BackstageController**, **controller** (`Backstage::SecurityController`), **route** (`<prefix>/security`), **layout** and **views**: stats, throttled addresses, blocked addresses (banned now, expired or not yet banned), scanner paths, sign-in throttles, targeted hosts, refusals by day, recent refusals (views skipped with `--no-copy-views`)
263
+ - **Admin navigation:** a Security tab, added to an existing `_admin_navigation` partial too
264
+ - **Admin overview:** the shared dashboard at the prefix root, if Solid Errors or Mission Control Jobs have not installed it already
265
+
266
+ #### What the page shows
267
+
268
+ The last 7 days of refusals, top five of each, read from `Security::AbuseEvent` through `Security::AbuseReport`. It is a briefing, not a log viewer: no filters, no pagination. Times are UTC.
269
+
270
+ - **Stats:** refused requests, throttled, blocked or banned, and distinct addresses
271
+ - **Throttled addresses:** over the general or sign-in limit, busiest first, with the rules they hit
272
+ - **Blocked addresses:** every address a blocklist refused, marked **Banned now** (Rack::Attack's own answer at render time), **Expired**, or **Not banned** -- an address with a strike or two on the scanner rule, or a slow scanner that stays under three paths in ten minutes
273
+ - **Most requested blocked paths:** what the scanners were after. A banned address is refused on every path, its ordinary pages included, so only the paths `Rack::Attack.scanner_path?` recognises are listed
274
+ - **Sign-in throttles:** addresses refused on `POST <login-path>`, kept apart so scanner noise never buries them
275
+ - **Targeted hosts:** which hostnames the refusals were aimed at
276
+ - **By day:** throttled, blocked and banned per day, zeros kept so a spike reads against its neighbours
277
+ - **Recent refusals:** the last 20, newest first
278
+
279
+ #### Usage
280
+
281
+ ```bash
282
+ rails g maquina:security --prefix /admin
283
+ bin/rails db:prepare
284
+ ```
285
+
286
+ The generator automatically runs `bundle install`. The page needs maquina_components.
287
+
288
+ #### Options
289
+
290
+ ```bash
291
+ rails g maquina:security --prefix /admin # Default
292
+ rails g maquina:security --prefix /admin --login-path /sign_in # Different sign-in path
293
+ rails g maquina:security --prefix /admin --no-copy-views # Without views
294
+ rails g maquina:security --prefix /backstage \
295
+ --user-env-var ADMIN_USER --password-env-var ADMIN_PASSWORD # Custom env vars
296
+ ```
297
+
298
+ #### Authentication
299
+
300
+ HTTP Basic Auth, with credentials resolved in order:
301
+
302
+ 1. `Rails.application.credentials.backstage.username` / `.password`
303
+ 2. `ENV["SECURITY_USER"]` / `ENV["SECURITY_PASSWORD"]` (configurable)
304
+
305
+ The page answers 503 until one of them is set, so it is never left open.
306
+
307
+ #### Trying it locally
308
+
309
+ Localhost is safelisted, so your own requests are never refused. Rack::Attack trusts `X-Forwarded-For` from `127.0.0.1`, so send one to stand in for another address:
310
+
311
+ ```bash
312
+ for path in /wp-login.php /.env /backup.zip /; do
313
+ curl -s -o /dev/null -w "%{http_code} $path\n" -H "X-Forwarded-For: 203.0.113.10" http://localhost:3000$path
314
+ done
315
+ # 403 /wp-login.php
316
+ # 403 /.env
317
+ # 403 /backup.zip <- third scanner path: banned for 7 days
318
+ # 403 / <- banned, so every path is refused
319
+ ```
320
+
321
+ Development's `:memory_store` keeps bans inside the server process, so they reset on restart and a `bin/rails runner` script cannot see them. Production needs a shared store (Solid Cache).
243
322
 
244
323
  ---
245
324
 
@@ -259,10 +338,10 @@ Customize rules in `config/initializers/rack_attack.rb`.
259
338
  8. Sets up ActiveStorage JavaScript imports
260
339
  9. Adds turbo morphing, `yield :head`, and simplifies `<main>` tag in layout
261
340
  10. Optionally installs authentication (`maquina:clave` or `maquina:registration`)
262
- 11. Invokes sub-generators: `maquina:rack_attack`, `maquina:mission_control_jobs`, `maquina:solid_errors`
263
- 12. Runs external installers: `solid_queue:install`, `solid_errors:install`, `solid_cache:install`, `solid_cable:install`, `maquina_components:install`
341
+ 11. Invokes sub-generators: `maquina:mission_control_jobs`, `maquina:solid_errors`
342
+ 12. Runs external installers: `solid_queue:install`, `solid_errors:install`, `solid_cache:install`, `solid_cable:install`, `maquina_components:install`, then `maquina:security` (which installs the `maquina:rack_attack` rules)
264
343
  13. Restores custom layouts overwritten by gem installers
265
- 14. Configures multi-database `database.yml` (primary, queue, cache, cable, errors)
344
+ 14. Configures multi-database `database.yml` (primary, queue, cache, cable, errors, security)
266
345
  15. Creates a HomeController with root route
267
346
  16. Generates a README and `database.yml.example`
268
347
  17. Runs `db:prepare`
@@ -277,7 +356,7 @@ rails g maquina:app --auth registration
277
356
 
278
357
  #### Options
279
358
 
280
- - `--prefix` (default: `/admin`) -- Base path prefix for backstage tools (Solid Errors, Mission Control Jobs)
359
+ - `--prefix` (default: `/admin`) -- Base path prefix for backstage tools (Solid Errors, Mission Control Jobs, Security)
281
360
  - `--port` (default: `3000`) -- Default port for the development server
282
361
  - `--auth` (default: `none`) -- Authentication type: `none`, `clave`, or `registration`
283
362
 
@@ -20,7 +20,16 @@ module Maquina
20
20
  content = File.read(gemfile_path)
21
21
 
22
22
  remove_gems = ["rubocop-rails-omakase"]
23
- dev_gems = {"brakeman" => nil, "bundle-audit" => nil, "letter_opener" => nil, "standard" => nil}
23
+ # standard >= 1.54: avoids the inoperative placeholder releases
24
+ # (1.34.0.1 / 1.35.0.1) whose too-loose `rubocop ~> 1.62` requirement
25
+ # makes bundler backtrack onto them and pair them with an incompatible
26
+ # rubocop, so the `standard` binary refuses to run.
27
+ #
28
+ # standard.rb runs through the generated .rubocop.yml (see
29
+ # create_config_files) — that file is standard.rb's runner bridge, not
30
+ # custom RuboCop rules, so it ships even though guidance says "no
31
+ # .rubocop.yml".
32
+ dev_gems = {"brakeman" => nil, "bundle-audit" => nil, "letter_opener" => nil, "standard" => ">= 1.54"}
24
33
  runtime_gems = {"rails-i18n" => nil, "maquina-components" => nil}
25
34
  production_gems = {"aws-sdk-s3" => nil}
26
35
 
@@ -28,22 +37,19 @@ module Maquina
28
37
  gsub_file "Gemfile", /^\s*gem\s+["']#{gem_name}["'].*\n/, ""
29
38
  end
30
39
 
31
- dev_gems.each do |gem_name, _|
32
- unless content.include?("gem \"#{gem_name}\"")
33
- append_to_file "Gemfile", "\ngem \"#{gem_name}\", group: :development\n"
34
- end
40
+ dev_gems.each do |gem_name, version|
41
+ next if content.include?("gem \"#{gem_name}\"")
42
+ append_to_file "Gemfile", "\n#{gem_line(gem_name, version, group: :development)}\n"
35
43
  end
36
44
 
37
- runtime_gems.each do |gem_name, _|
38
- unless content.include?("gem \"#{gem_name}\"")
39
- append_to_file "Gemfile", "\ngem \"#{gem_name}\"\n"
40
- end
45
+ runtime_gems.each do |gem_name, version|
46
+ next if content.include?("gem \"#{gem_name}\"")
47
+ append_to_file "Gemfile", "\n#{gem_line(gem_name, version)}\n"
41
48
  end
42
49
 
43
- production_gems.each do |gem_name, _|
44
- unless content.include?("gem \"#{gem_name}\"")
45
- append_to_file "Gemfile", "\ngem \"#{gem_name}\", group: :production\n"
46
- end
50
+ production_gems.each do |gem_name, version|
51
+ next if content.include?("gem \"#{gem_name}\"")
52
+ append_to_file "Gemfile", "\n#{gem_line(gem_name, version, group: :production)}\n"
47
53
  end
48
54
 
49
55
  return unless rails_app?
@@ -193,7 +199,6 @@ module Maquina
193
199
  def install_maquina_generators
194
200
  return unless rails_app?
195
201
 
196
- generate "maquina:rack_attack", "--quiet"
197
202
  generate "maquina:mission_control_jobs", "--prefix", options[:prefix], "--quiet"
198
203
  generate "maquina:solid_errors", "--prefix", options[:prefix], "--quiet"
199
204
 
@@ -204,6 +209,10 @@ module Maquina
204
209
  system("bin/rails generate solid_cable:install", chdir: destination_root)
205
210
  system("bin/rails generate maquina_components:install", chdir: destination_root)
206
211
  end
212
+
213
+ # After solid_queue:install, so config/recurring.yml exists for the purge.
214
+ # Installs the maquina:rack_attack rules too.
215
+ generate "maquina:security", "--prefix", options[:prefix], "--quiet"
207
216
  end
208
217
 
209
218
  # 13. Restore custom layouts overwritten by gem installers
@@ -255,7 +264,45 @@ module Maquina
255
264
  end
256
265
  end
257
266
 
258
- # 18. Run all migrations
267
+ # 18. Restore database.yml from its example in bin/setup. config/database.yml
268
+ # is gitignored (it differs per environment), so a fresh clone has only
269
+ # the committed example — bin/setup must copy it before db:prepare.
270
+ def configure_bin_setup
271
+ setup_file = File.join(destination_root, "bin/setup")
272
+ return unless File.exist?(setup_file)
273
+ return if File.read(setup_file).include?("config/database.yml.example")
274
+
275
+ inject_into_file "bin/setup",
276
+ after: %r{system\("bundle check"\) \|\| system!\("bundle install"\)\n} do
277
+ <<~RUBY.indent(2)
278
+
279
+ # config/database.yml is gitignored; restore it from the committed example
280
+ unless File.exist?("config/database.yml")
281
+ puts "\\n== Copying config/database.yml =="
282
+ FileUtils.cp "config/database.yml.example", "config/database.yml"
283
+ end
284
+ RUBY
285
+ end
286
+ end
287
+
288
+ # 19. Restore database.yml from its example in CI. Without it, the test job
289
+ # boots with no database.yml and every Rails task fails. Only touched
290
+ # when the host app already has a GitHub Actions workflow.
291
+ def configure_ci
292
+ ci_file = File.join(destination_root, ".github/workflows/ci.yml")
293
+ return unless File.exist?(ci_file)
294
+ return if File.read(ci_file).include?("config/database.yml.example")
295
+
296
+ inject_into_file ".github/workflows/ci.yml",
297
+ before: /^ - name: Run tests$/ do
298
+ <<~YAML.indent(6)
299
+ - name: Prepare database config
300
+ run: cp config/database.yml.example config/database.yml
301
+ YAML
302
+ end
303
+ end
304
+
305
+ # 20. Run all migrations
259
306
  def run_migrations
260
307
  return unless rails_app?
261
308
 
@@ -264,7 +311,7 @@ module Maquina
264
311
  end
265
312
  end
266
313
 
267
- # 19. Post-install message
314
+ # 21. Post-install message
268
315
  def show_post_install
269
316
  say ""
270
317
  say "=" * 60, :green
@@ -279,10 +326,12 @@ module Maquina
279
326
  say " 2. Start the app: bin/dev"
280
327
  say ""
281
328
  say "Installed components:", :yellow
282
- say " - Rack::Attack: rate limiting and blocklists (config/initializers/rack_attack.rb)"
329
+ say " - Rack::Attack: scanner and flood bans, throttles (config/initializers/rack_attack.rb)"
330
+ say " - Security: Rack::Attack refusals at #{options[:prefix]}/security"
283
331
  say " - Solid Queue: background jobs (config/solid_queue.yml)"
284
- say " - Mission Control Jobs: job dashboard at #{options[:prefix]}/mission_control_jobs"
285
- say " - Solid Errors: error tracking at #{options[:prefix]}/solid_errors"
332
+ say " - Mission Control Jobs: job dashboard at #{options[:prefix]}/jobs"
333
+ say " - Solid Errors: error tracking at #{options[:prefix]}/errors"
334
+ say " - Admin overview: #{options[:prefix]}"
286
335
  say " - Maquina Components: UI components"
287
336
  if options[:auth] != "none"
288
337
  say ""
@@ -298,6 +347,13 @@ module Maquina
298
347
 
299
348
  private
300
349
 
350
+ def gem_line(name, version, group: nil)
351
+ line = "gem \"#{name}\""
352
+ line << ", \"#{version}\"" if version
353
+ line << ", group: :#{group}" if group
354
+ line
355
+ end
356
+
301
357
  def rails_app?
302
358
  File.exist?(File.join(destination_root, "bin/rails"))
303
359
  end
@@ -1,3 +1,9 @@
1
+ # standard.rb's runner bridge, not custom RuboCop rules.
2
+ #
3
+ # This is the entry point RuboCop (and editors/LSP) need to run the Standard
4
+ # ruleset — all style comes from the `standard` gem via config/base.yml below.
5
+ # Keep this file even though general guidance says "no .rubocop.yml": for
6
+ # standard.rb this IS the runner config, not a custom rule set.
1
7
  require:
2
8
  - standard
3
9
 
@@ -34,15 +34,17 @@ Your app will be running at **http://localhost:<%= options[:port] %>**.
34
34
  | **WebSockets** | Solid Cable |
35
35
  | **Rich Text** | Action Text |
36
36
  | **File Storage** | Active Storage (S3-ready in production) |
37
- | **Multi-database** | Separate databases for queue, cache, cable, and errors |
37
+ | **Multi-database** | Separate databases for queue, cache, cable, errors, and security |
38
38
 
39
39
  ## Development URLs
40
40
 
41
41
  | URL | Description |
42
42
  |-----|-------------|
43
43
  | http://localhost:<%= options[:port] %> | Application |
44
- | http://localhost:<%= options[:port] %><%= options[:prefix] %>/mission_control_jobs | Jobs dashboard |
45
- | http://localhost:<%= options[:port] %><%= options[:prefix] %>/solid_errors | Errors dashboard |
44
+ | http://localhost:<%= options[:port] %><%= options[:prefix] %> | Admin overview |
45
+ | http://localhost:<%= options[:port] %><%= options[:prefix] %>/errors | Errors dashboard |
46
+ | http://localhost:<%= options[:port] %><%= options[:prefix] %>/jobs | Jobs dashboard |
47
+ | http://localhost:<%= options[:port] %><%= options[:prefix] %>/security | Security dashboard |
46
48
  <% if options[:auth] != "none" -%>
47
49
  | http://localhost:<%= options[:port] %>/session/new | Sign in |
48
50
  <% if options[:auth] == "registration" -%>
@@ -65,7 +67,7 @@ Edit credentials with:
65
67
  bin/rails credentials:edit
66
68
  ```
67
69
 
68
- Add backstage credentials for the jobs and errors dashboards:
70
+ Add backstage credentials for the admin dashboards (overview, errors, jobs, security):
69
71
 
70
72
  ```yaml
71
73
  backstage:
@@ -32,17 +32,17 @@
32
32
  <%% if authenticated? %>
33
33
  <section class="max-w-5xl mx-auto px-5 py-12">
34
34
  <p class="text-sm font-semibold uppercase tracking-wider text-primary mb-4">Admin Tools</p>
35
- <div class="grid grid-cols-1 md:grid-cols-2 gap-4">
35
+ <div class="grid grid-cols-1 md:grid-cols-3 gap-4">
36
36
  <%%= render "components/card" do %>
37
37
  <%%= render "components/card/header" do %>
38
38
  <div class="flex items-center gap-2">
39
- <%%= icon_for(:activity, class: "size-5 text-primary") %>
39
+ <%%= icon_for(:triangle_alert, class: "size-5 text-primary") %>
40
40
  <%%= render "components/card/title", text: "Solid Errors" %>
41
41
  </div>
42
42
  <%%= render "components/card/description", text: "Browse, resolve, and clean up application exceptions in real time." %>
43
43
  <%% end %>
44
44
  <%%= render "components/card/footer" do %>
45
- <%%= link_to "Open Dashboard", "<%= options[:prefix] %>/solid_errors", data: { component: "button", variant: "outline", size: "sm" } %>
45
+ <%%= link_to "Open Dashboard", "<%= options[:prefix] %>/errors", data: { component: "button", variant: "outline", size: "sm" } %>
46
46
  <%% end %>
47
47
  <%% end %>
48
48
 
@@ -55,7 +55,20 @@
55
55
  <%%= render "components/card/description", text: "Monitor queues, retry failed jobs, and manage background processing." %>
56
56
  <%% end %>
57
57
  <%%= render "components/card/footer" do %>
58
- <%%= link_to "Open Dashboard", "<%= options[:prefix] %>/mission_control_jobs", data: { component: "button", variant: "outline", size: "sm" } %>
58
+ <%%= link_to "Open Dashboard", "<%= options[:prefix] %>/jobs", data: { component: "button", variant: "outline", size: "sm" } %>
59
+ <%% end %>
60
+ <%% end %>
61
+
62
+ <%%= render "components/card" do %>
63
+ <%%= render "components/card/header" do %>
64
+ <div class="flex items-center gap-2">
65
+ <%%= icon_for(:slash, class: "size-5 text-primary") %>
66
+ <%%= render "components/card/title", text: "Security" %>
67
+ </div>
68
+ <%%= render "components/card/description", text: "See what Rack::Attack throttled, blocked, and banned this week." %>
69
+ <%% end %>
70
+ <%%= render "components/card/footer" do %>
71
+ <%%= link_to "Open Dashboard", "<%= options[:prefix] %>/security", data: { component: "button", variant: "outline", size: "sm" } %>
59
72
  <%% end %>
60
73
  <%% end %>
61
74
  </div>
@@ -109,7 +122,7 @@
109
122
  <%%= render "components/card/title", text: "Rate Limiting" %>
110
123
  <%% end %>
111
124
  <%%= render "components/card/content" do %>
112
- <p class="text-sm text-muted-foreground">Rack::Attack configured with blocklists and throttles. Protect login, registration, and API endpoints.</p>
125
+ <p class="text-sm text-muted-foreground">Rack::Attack bans scanners and floods and throttles sign-in. Every refusal shows on the Security dashboard.</p>
113
126
  <%% end %>
114
127
  <%% end %>
115
128
 
@@ -29,6 +29,10 @@ development:
29
29
  <<: *default
30
30
  database: storage/development_errors.sqlite3
31
31
  migrations_paths: db/errors_migrate
32
+ security:
33
+ <<: *default
34
+ database: storage/development_security.sqlite3
35
+ migrations_paths: db/security_migrate
32
36
 
33
37
  # Warning: The database defined as "test" will be erased and
34
38
  # re-generated from your development database when you run "rake".
@@ -53,6 +57,10 @@ test:
53
57
  <<: *default
54
58
  database: storage/test_errors.sqlite3
55
59
  migrations_paths: db/errors_migrate
60
+ security:
61
+ <<: *default
62
+ database: storage/test_security.sqlite3
63
+ migrations_paths: db/security_migrate
56
64
 
57
65
  # Store production database in the storage/ directory, which by default
58
66
  # is mounted as a persistent Docker volume in config/deploy.yml.
@@ -76,3 +84,7 @@ production:
76
84
  <<: *default
77
85
  database: storage/production_errors.sqlite3
78
86
  migrations_paths: db/errors_migrate
87
+ security:
88
+ <<: *default
89
+ database: storage/production_security.sqlite3
90
+ migrations_paths: db/security_migrate
@@ -49,7 +49,7 @@ module Maquina
49
49
 
50
50
  # 5. Routes
51
51
  def add_route
52
- mount_path = "#{options[:prefix]}/mission_control_jobs"
52
+ mount_path = "#{options[:prefix]}/jobs"
53
53
 
54
54
  route "mount MissionControl::Jobs::Engine, at: \"#{mount_path}\""
55
55
  end
@@ -67,6 +67,13 @@ module Maquina
67
67
  layout_files.each { |f| copy_file f, f }
68
68
  end
69
69
 
70
+ # 7b. Backstage dashboard — admin overview at the prefix root, linking to
71
+ # the installed engines. Shared with the solid_errors generator, so
72
+ # guard every step for idempotency when both run.
73
+ def create_backstage_dashboard
74
+ install_backstage_dashboard
75
+ end
76
+
70
77
  # 8. Custom views
71
78
  def copy_views
72
79
  return unless options[:copy_views]
@@ -105,6 +112,30 @@ module Maquina
105
112
  File.exist?(File.join(destination_root, "bin/rails"))
106
113
  end
107
114
 
115
+ # Idempotent install of the shared backstage dashboard (controller, layout,
116
+ # prefix-aware view, and the prefix-root route). Safe to run from both this
117
+ # generator and solid_errors.
118
+ def install_backstage_dashboard
119
+ unless File.exist?(File.join(destination_root, "app/controllers/backstage_dashboard_controller.rb"))
120
+ copy_file "app/controllers/backstage_dashboard_controller.rb",
121
+ "app/controllers/backstage_dashboard_controller.rb"
122
+ end
123
+
124
+ unless File.exist?(File.join(destination_root, "app/views/layouts/admin.html.erb"))
125
+ copy_file "app/views/layouts/admin.html.erb", "app/views/layouts/admin.html.erb"
126
+ end
127
+
128
+ unless File.exist?(File.join(destination_root, "app/views/backstage_dashboard/index.html.erb"))
129
+ template "app/views/backstage_dashboard/index.html.erb.tt",
130
+ "app/views/backstage_dashboard/index.html.erb"
131
+ end
132
+
133
+ routes_path = File.join(destination_root, "config/routes.rb")
134
+ if File.exist?(routes_path) && !File.read(routes_path).include?("backstage_dashboard#index")
135
+ route %(get "#{options[:prefix]}" => "backstage_dashboard#index", as: :backstage_dashboard)
136
+ end
137
+ end
138
+
108
139
  def layout_files
109
140
  layouts_dir = File.join(self.class.source_root, "app/views/layouts/mission_control")
110
141
  Dir.glob("**/*.erb", base: layouts_dir).map do |file|
@@ -0,0 +1,47 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Admin overview — a backstage surface alongside the SolidErrors and Mission
4
+ # Control Jobs engines, under the same backstage Basic auth.
5
+ #
6
+ # Out of the box it shows the installed engine entry points and an empty metrics
7
+ # area. Populate @metrics in #index with aggregate, non-sensitive counts for
8
+ # your app — counts, not names or per-record detail.
9
+ class BackstageDashboardController < BackstageController
10
+ layout "admin"
11
+
12
+ before_action :require_backstage_credentials
13
+ before_action :authenticate_backstage
14
+
15
+ def index
16
+ @generated_at = Time.current
17
+
18
+ # Add overview metrics here. Each entry renders as a card:
19
+ #
20
+ # @metrics = [
21
+ # {label: "Accounts", icon: :folder, value: Account.count, hint: "tenants"},
22
+ # {label: "Users", icon: :user, value: User.count, hint: "members"}
23
+ # ]
24
+ #
25
+ # Keep them aggregate-only. :total and :hint are optional.
26
+ @metrics = []
27
+ end
28
+
29
+ private
30
+
31
+ # Closed (503) until credentials.backstage is set. Without this an unset
32
+ # username and password are both "", and an empty Basic header matches them.
33
+ def require_backstage_credentials
34
+ backstage = Rails.application.credentials.backstage || {}
35
+ head :service_unavailable if backstage[:username].blank? || backstage[:password].blank?
36
+ end
37
+
38
+ # Same backstage credentials the engines use.
39
+ def authenticate_backstage
40
+ authenticate_or_request_with_http_basic("Admin") do |username, password|
41
+ backstage = Rails.application.credentials.backstage || {}
42
+
43
+ ActiveSupport::SecurityUtils.secure_compare(username.to_s, backstage[:username].to_s) &
44
+ ActiveSupport::SecurityUtils.secure_compare(password.to_s, backstage[:password].to_s)
45
+ end
46
+ end
47
+ end