mixin_bot 2.6.0 → 3.0.2

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 (69) hide show
  1. checksums.yaml +4 -4
  2. data/AGENTS.md +3 -3
  3. data/API_COVERAGE.md +1 -0
  4. data/CHANGELOG.md +22 -1
  5. data/README.md +116 -5
  6. data/docs/agent/cookbook.md +49 -0
  7. data/examples/blaze_async.rb +6 -4
  8. data/lib/generators/mixin_bot/authentication/USAGE +29 -0
  9. data/lib/generators/mixin_bot/authentication/authentication_generator.rb +82 -0
  10. data/lib/generators/mixin_bot/authentication/templates/app/controllers/concerns/authentication.rb.tt +52 -0
  11. data/lib/generators/mixin_bot/authentication/templates/app/controllers/sessions_controller.rb.tt +24 -0
  12. data/lib/generators/mixin_bot/authentication/templates/app/models/current.rb.tt +4 -0
  13. data/lib/generators/mixin_bot/authentication/templates/app/models/session.rb.tt +3 -0
  14. data/lib/generators/mixin_bot/authentication/templates/app/models/user.rb.tt +12 -0
  15. data/lib/generators/mixin_bot/authentication/templates/app/views/sessions/new.html.erb.tt +3 -0
  16. data/lib/generators/mixin_bot/authentication/templates/config/initializers/omniauth.rb.tt +21 -0
  17. data/lib/generators/mixin_bot/blaze/USAGE +19 -0
  18. data/lib/generators/mixin_bot/blaze/blaze_generator.rb +43 -0
  19. data/lib/generators/mixin_bot/blaze/templates/app/mixin/handlers/text_handler.rb +14 -0
  20. data/lib/generators/mixin_bot/blaze/templates/config/initializers/mixin_bot_blaze.rb +23 -0
  21. data/lib/generators/mixin_bot/install/USAGE +24 -0
  22. data/lib/generators/mixin_bot/install/install_generator.rb +50 -0
  23. data/lib/generators/mixin_bot/notifications/USAGE +13 -0
  24. data/lib/generators/mixin_bot/notifications/notifications_generator.rb +49 -0
  25. data/lib/generators/mixin_bot/notifications/templates/app/notifications/payment_received_notification.rb +24 -0
  26. data/lib/generators/mixin_bot/outputs/USAGE +29 -0
  27. data/lib/generators/mixin_bot/outputs/outputs_generator.rb +50 -0
  28. data/lib/generators/mixin_bot/outputs/templates/app/mixin/processors/deposit_processor.rb +21 -0
  29. data/lib/generators/mixin_bot/outputs/templates/app/models/mixin_output.rb +8 -0
  30. data/lib/generators/mixin_bot/outputs/templates/db/migrate/create_mixin_outputs.rb.tt +26 -0
  31. data/lib/generators/mixin_bot/transfers/USAGE +37 -0
  32. data/lib/generators/mixin_bot/transfers/templates/app/jobs/mixin_transfers/perform_job.rb +13 -0
  33. data/lib/generators/mixin_bot/transfers/templates/app/jobs/mixin_transfers/reconcile_job.rb +14 -0
  34. data/lib/generators/mixin_bot/transfers/templates/app/models/mixin_transfer.rb +11 -0
  35. data/lib/generators/mixin_bot/transfers/templates/db/migrate/create_mixin_transfers.rb.tt +23 -0
  36. data/lib/generators/mixin_bot/transfers/transfers_generator.rb +67 -0
  37. data/lib/generators/mixin_bot.rb +10 -0
  38. data/lib/mixin_bot/api/app.rb +20 -0
  39. data/lib/mixin_bot/api/blaze_async.rb +5 -1
  40. data/lib/mixin_bot/api/snapshot.rb +5 -0
  41. data/lib/mixin_bot/blaze/reactor.rb +249 -0
  42. data/lib/mixin_bot/blaze/router.rb +333 -0
  43. data/lib/mixin_bot/cache_store_adapter.rb +3 -1
  44. data/lib/mixin_bot/client.rb +2 -1
  45. data/lib/mixin_bot/computer.rb +3 -1
  46. data/lib/mixin_bot/configuration.rb +52 -0
  47. data/lib/mixin_bot/json_decoder.rb +14 -0
  48. data/lib/mixin_bot/notifications/mixin_channel.rb +80 -0
  49. data/lib/mixin_bot/outputs/cursor_model.rb +33 -0
  50. data/lib/mixin_bot/outputs/envelope.rb +160 -0
  51. data/lib/mixin_bot/outputs/memory_stores.rb +97 -0
  52. data/lib/mixin_bot/outputs/poller.rb +178 -0
  53. data/lib/mixin_bot/outputs/processing_job.rb +54 -0
  54. data/lib/mixin_bot/outputs/processors.rb +124 -0
  55. data/lib/mixin_bot/outputs/receipt_model.rb +86 -0
  56. data/lib/mixin_bot/outputs.rb +42 -0
  57. data/lib/mixin_bot/rails/railtie.rb +44 -0
  58. data/lib/mixin_bot/rails/tasks/poller.rake +33 -0
  59. data/lib/mixin_bot/rails.rb +34 -0
  60. data/lib/mixin_bot/registry.rb +79 -0
  61. data/lib/mixin_bot/transfers/model.rb +112 -0
  62. data/lib/mixin_bot/transfers/performer.rb +64 -0
  63. data/lib/mixin_bot/transfers/reconcile.rb +53 -0
  64. data/lib/mixin_bot/transfers.rb +23 -0
  65. data/lib/mixin_bot/version.rb +1 -1
  66. data/lib/mixin_bot.rb +4 -0
  67. data/lib/puma/plugin/mixin_blaze.rb +263 -0
  68. data/llms.txt +1 -1
  69. metadata +69 -7
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 42fc9aa30981430bf8a4b32ac343994fedb94f3a318d4c519404f788f66d8c68
4
- data.tar.gz: df1037544f1eb11ec4cf196aacbd076db8a3aa6c2dc11ed1d138751cdb37e073
3
+ metadata.gz: 520bf980215da4e70c9fa6ae1d9297874fc16fc8264a43bb019a126f0fb0d555
4
+ data.tar.gz: 1d5cf99a0e645d4ae110e73148b501bd5f942b3f2c0e9ac0a4338d2d38699a7a
5
5
  SHA512:
6
- metadata.gz: a12c51222b50582cd31b4e80d17f3f95c73627845ec3500701a02de21d68de887e85ccf599e505be8ec81ce6c903485ec193ae6ee3e865dce9e2bd2b984ef023
7
- data.tar.gz: 1dbb16ececb43acf6febcc42467ffb631b064fcb508f235c4a6e19148c15c328935a9ec1a297bffa45baebd70fc0b7458bf98d374fc2f5c0930734345bf67d29
6
+ metadata.gz: b73f1bd21043e018410555d93a537b2314f3398a174bf7ce443cce1652b578e425569faa9118d413789607f106863dbf08b1b79ef4da643f28c40fdbad065479
7
+ data.tar.gz: 49b66d6300397db22af3f28688eca9999a68ad47f07689a84f94a6c43831d96e0ad42c88d7b95cb3a12f531ab4bf4d5540f84cb0f070508687027f20337ff36a
data/AGENTS.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # AGENTS.md — MixinBot
2
2
 
3
- Ruby gem (v2.3.0): Mixin Network REST SDK + `mixinbot` CLI. Parity targets: [bot-api-go-client](https://github.com/MixinNetwork/bot-api-go-client), [bot-api-nodejs-client](https://github.com/MixinNetwork/bot-api-nodejs-client).
3
+ Ruby gem (v3.0.2): Mixin Network REST SDK + `mixinbot` CLI. Parity targets: [bot-api-go-client](https://github.com/MixinNetwork/bot-api-go-client), [bot-api-nodejs-client](https://github.com/MixinNetwork/bot-api-nodejs-client).
4
4
 
5
5
  ## Commands
6
6
 
@@ -34,13 +34,13 @@ docs/agent/ # LLM-oriented CLI and cookbook docs
34
34
 
35
35
  ## CI and release
36
36
 
37
- - **CI** (`.github/workflows/ci.yml`): `pull_request` and `push` to `main` — `rake test` on Ruby 3.2/3.3/4.0, `rake rubocop` (3.3), `rake mixin_bot:api_coverage`.
37
+ - **CI** (`.github/workflows/ci.yml`): `pull_request` and `push` to `main` — `rake test` on Ruby 4.0, `rake rubocop` (4.0), `rake mixin_bot:api_coverage`.
38
38
  - **Release** (`.github/workflows/release.yml`): push tag `v*` (must match `MixinBot::VERSION`, e.g. tag `v2.1.0` for `VERSION = '2.1.0'`) → `rake build` → RubyGems via [trusted publishing](https://guides.rubygems.org/trusted-publishing/) (OIDC; workflow `release.yml`, no repo secret) → GitHub Release (notes from `CHANGELOG.md`, `.gem` attached).
39
39
  - **Dependabot** (`.github/dependabot.yml`): weekly Bundler and GitHub Actions updates; Dependabot PRs use the same CI workflow.
40
40
 
41
41
  ## Conventions
42
42
 
43
- - **Ruby** >= 3.2 (CI: 3.2, 3.3, 4.0)
43
+ - **Ruby** >= 4.0 (CI: 4.0)
44
44
  - **HTTP responses**: `MixinBot::Models::ApiEnvelope` — use `res['data']` or delegated keys
45
45
  - **Safe transfers**: require `spend_key`; prefer `create_safe_transfer` / `create_transfer` (Safe pipeline)
46
46
  - **Legacy APIs**: `create_legacy_transfer`, `POST /transfers` — deprecated, warns once
data/API_COVERAGE.md CHANGED
@@ -81,6 +81,7 @@ Status values: `done` | `alias` | `n/a` (CLI-only / config / platform-specific)
81
81
  | **Snapshots** |
82
82
  | SafeSnapshots* | `API#safe_snapshots` | GET `/safe/snapshots` | done |
83
83
  | SafeSnapshotById | `API#safe_snapshot` | GET `/safe/snapshots/:id` | done |
84
+ | SafeSnapshotByTrace | `API#safe_snapshot_by_trace` | GET `/safe/snapshots/trace/:trace_id` | done |
84
85
  | SafeNotifySnapshot | `API#create_safe_snapshot_notification` | POST notifications | done |
85
86
  | Snapshots / SnapshotById / SnapshotByTraceId | `API#snapshots`, `#snapshot`, `#snapshot_by_trace_id` | legacy | done |
86
87
  | NetworkSnapshot* | `API#network_snapshot(s)` | legacy | done |
data/CHANGELOG.md CHANGED
@@ -5,7 +5,28 @@ All notable changes to this project will be documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
- ## [Unreleased]
8
+ ## [3.0.2] - 2026-09-24
9
+
10
+ ### Added
11
+
12
+ - **`MixinBot::API#update_app_security`** — `POST /apps/:id/security`, the endpoint that persists an app's `allowed_ips` and `resource_patterns`. The generic `update_app` endpoint silently ignores both fields, so code that tried to manage the resource-pattern whitelist through it was a no-op.
13
+
14
+ ## [3.0.1] - 2026-09-19
15
+
16
+ ### Fixed
17
+
18
+ - **Blaze reconnect backoff no longer resets on short-lived connections.** `Reactor::STABLE_PERIOD` (the lifetime a connection needs for the backoff to reset) was exactly `MAX_BACKOFF` (30s), so any failure mode that closes sessions at a ~30s lifetime (e.g. a gateway throttling abusive reconnect patterns) read as "stable" on every cycle — the backoff pinned at the initial 1s and the reactor reconnected hot indefinitely, which is itself the abuse pattern that triggers such throttling. `STABLE_PERIOD` is now 120s (well above every single backoff step) and `MAX_BACKOFF` grows the ceiling to 300s so a sustained failure mode backs off hard while a single transient blip still reconnects in ~1s.
19
+
20
+ ## [3.0.0] - 2026-09-15
21
+
22
+ ### Added
23
+
24
+ - **Puma plugin hosting the Blaze connection** (`plugin :mixin_blaze`) — Solid Queue-style in-process hosting, removing the standalone Blaze deployment artifact. Dual run modes via the `mixin_blaze_mode` Puma DSL option: `:fork` (default) forks a dedicated Blaze child on `on_booted` with a monitor thread that stops Puma if the child dies, terminated on `on_stopped`/`on_restart`; `:async` runs the reactor on a Puma background thread in-process. Handler resolution comes from `MixinBot` configuration (resolved lazily after app boot) with an explicit ack policy (on-receipt vs after-handler); Puma 6/7 lifecycle-event compatibility; cluster mode requires `preload_app!` and fails loudly without it. Puma remains a host-app concern — no new gem dependency.
25
+ - **`MixinBot::Blaze::Reactor`** — reusable Blaze loop (connect, keepalive ping, read → handler → ack, reconnect with backoff) wrapping `API#blaze_async`; extracted from `examples/blaze_async.rb` so the plugin shell stays thin and the loop is unit-testable offline.
26
+
27
+ ### Changed
28
+
29
+ - **Breaking**: Ruby >= 4.0 is now required — `required_ruby_version` bumped from `>= 3.2.0` to `>= 4.0.0`. The CI matrix, RuboCop target, and Release workflow all run Ruby 4.0; the dev-only `parallel` version pin (kept for the old Ruby 3.2 floor) was dropped.
9
30
 
10
31
  ## [2.6.0] - 2026-09-07
11
32
 
data/README.md CHANGED
@@ -6,12 +6,12 @@ Ruby SDK and CLI for [Mixin Network](https://developers.mixin.one/docs): authent
6
6
 
7
7
  The gem aims for **parity with the official [bot-api-go-client](https://github.com/MixinNetwork/bot-api-go-client)** Go SDK and **[bot-api-nodejs-client](https://github.com/MixinNetwork/bot-api-nodejs-client)** Node SDK. See [API_COVERAGE.md](API_COVERAGE.md) for the full mapping; run `rake mixin_bot:api_coverage` to confirm no gaps are marked missing.
8
8
 
9
- Current gem version: **2.6.0** (see [CHANGELOG.md](CHANGELOG.md) for breaking changes and deprecations).
9
+ Current gem version: **3.0.2** (see [CHANGELOG.md](CHANGELOG.md) for breaking changes and deprecations).
10
10
 
11
11
  ## Requirements
12
12
 
13
- - **Ruby** ≥ 3.2 (CI runs 3.2, 3.3, and 4.0).
14
- - **Bundler** 2.5+ recommended, especially on Ruby 4.
13
+ - **Ruby** ≥ 4.0 (CI runs 4.0).
14
+ - **Bundler** 2.6+ recommended.
15
15
  - Optional: the **`mixin`** CLI in `PATH` if you use `MixinBot::API#encode_raw_transaction_native` / `#decode_raw_transaction_native` or the experimental `MixinBot::NodeCLI` helpers.
16
16
 
17
17
  ## Installation
@@ -290,6 +290,117 @@ ensure
290
290
  end
291
291
  ```
292
292
 
293
+ ### Running Blaze inside Puma (`plugin :mixin_blaze`)
294
+
295
+ For Rails/Puma apps, the gem ships a Puma plugin (Solid Queue-style) that hosts the Blaze connection inside the web process tree — no standalone Blaze process to deploy or supervise:
296
+
297
+ ```ruby
298
+ # config/puma.rb
299
+ plugin :mixin_blaze
300
+ mixin_blaze_mode :fork # default; see table below
301
+ ```
302
+
303
+ ```ruby
304
+ # config/initializers/mixin_bot.rb
305
+ MixinBot.configure do
306
+ # ... credentials ...
307
+ self.blaze_handler = ->(envelope) { MyBot.process! envelope }
308
+ self.blaze_ack_policy = :on_receipt # default; or :after_handler
309
+ end
310
+ ```
311
+
312
+ The handler receives each decoded message envelope (a Hash with `action` and `data` keys) and runs serially on the hosting thread — safe for ActiveRecord. Handlers run one message at a time; the keepalive ping runs separately.
313
+
314
+ | Mode | Where the connection lives | Notes |
315
+ |---|---|---|
316
+ | `:fork` (default) | Dedicated child process forked from the Puma launcher | Crash-isolated; if the child dies, Puma is stopped so the process manager restarts the unit |
317
+ | `:async` | Background thread inside the Puma process itself | In cluster mode that process is the master; no child process |
318
+
319
+ Ack policy: `:on_receipt` acknowledges each message immediately (at-most-once dispatch — a handler crash loses that message); `:after_handler` acknowledges only after the handler completes without raising (at-least-once — handlers must be idempotent, since unacknowledged messages are redelivered on reconnect).
320
+
321
+ Receipt confirmations (`ACKNOWLEDGE_MESSAGE_RECEIPT` frames the server pushes for your own outgoing/acknowledged messages) are consumed internally: the handler never sees them and they are never re-acknowledged.
322
+
323
+ Operational notes:
324
+
325
+ - **Remove any standalone Blaze process when enabling the plugin** — two connections double your handler invocations (or split deliveries).
326
+ - **Cluster mode requires `preload_app!`** — plugins run in the launcher process, which holds no application code without preloading; the plugin reports an error and stays off otherwise.
327
+ - **Phased restarts keep the old handler code** in the hosting process until a full restart; the same applies to Solid Queue's plugin.
328
+ - Workers that need to *send* messages should use the REST API (`create_message` / `create_messages`), not the socket.
329
+
330
+ ## Rails integration
331
+
332
+ The gem ships Rails generators under the `mixin_bot:` namespace. The first one sets up **"Sign in with Mixin"** (OAuth login via [`omniauth-mixin`](https://github.com/an-lee/omniauth-mixin)), modeled on Rails' built-in `rails g authentication`:
333
+
334
+ ```bash
335
+ rails generate mixin_bot:authentication
336
+ ```
337
+
338
+ This creates an identity-only Mixin login: OmniAuth initializer, `User` / `Session` / `Current` models, `SessionsController` with the OAuth callback, an `Authentication` controller concern (injected into `ApplicationController`), a sign-in view, routes, and the `CreateUsers` / `CreateSessions` migrations. It also adds `omniauth-mixin` and `omniauth-rails_csrf_protection` to the Gemfile (uncommenting existing entries, otherwise `bundle add`) and runs migrations generation. Run `bin/rails db:migrate` afterwards.
339
+
340
+ Then:
341
+
342
+ - Set `MIXIN_CLIENT_ID` and `MIXIN_CLIENT_SECRET` from the [Mixin Developer Dashboard](https://developers.mixin.one/dashboard).
343
+ - Register `https://<your-host>/auth/mixin/callback` as the OAuth redirect URL of your Mixin app.
344
+
345
+ Notes:
346
+
347
+ - **Identity only** — the OAuth exchange is used to establish identity (Mixin user id + name + avatar). Access/refresh tokens are not persisted; a future generator may add API-access wiring on top.
348
+ - **Fail loud at sign-in, not at boot** — the app boots fine without credentials (deploys, CI, `assets:precompile`); attempting to sign in raises an error naming the missing `MIXIN_CLIENT_ID` / `MIXIN_CLIENT_SECRET`.
349
+ - **CSRF-protected request phase** — the sign-in button POSTs to `/auth/mixin` (OmniAuth 2.x default request method, protected by `omniauth-rails_csrf_protection`); the provider redirects back to the `/auth/mixin/callback` GET route.
350
+ - **Existing apps beware** — like `rails g authentication`, the generator templates over target files (e.g. an existing `User` model) and will prompt on conflicts; reconcile manually if you already have users.
351
+ - Sessions are database-backed (`sessions` table with `ip_address` / `user_agent`) with a signed httponly cookie, exactly like Rails 8's `authentication` generator; `Current.user` is available after sign-in.
352
+
353
+ The generated code depends only on Rails and the two OmniAuth gems — not on `mixin_bot` itself — so the login keeps working even if you later drop the gem from the app.
354
+
355
+ ### The full Rails integration layer (`require 'mixin_bot/rails'`)
356
+
357
+ Beyond the generators, the gem ships an optional runtime integration layer. It is opt-in and Rails stays a soft dependency:
358
+
359
+ ```ruby
360
+ # config/application.rb, after Bundler.require
361
+ require 'mixin_bot/rails'
362
+ ```
363
+
364
+ Or scaffold everything in one command and skip pieces as needed:
365
+
366
+ ```bash
367
+ rails generate mixin_bot:install
368
+ rails generate mixin_bot:install --skip-notifications --skip-transfers
369
+ ```
370
+
371
+ What each piece gives you:
372
+
373
+ - **Blaze message routing** — `MixinBot::Blaze::Router` is a callable that plugs into `config.blaze_handler`. Handlers live in `app/mixin/handlers`, receive a decoded `Message`, and reply over the HTTP message API (no WebSocket access needed):
374
+
375
+ ```ruby
376
+ # config/initializers/mixin_bot_blaze.rb (generated by `rails g mixin_bot:blaze`)
377
+ Rails.application.config.to_prepare do
378
+ MixinBot.configure do
379
+ self.blaze_handler = MixinBot::Blaze::Router.new do
380
+ on 'text', Mixin::Handlers::TextHandler # 'text' => PLAIN_TEXT
381
+ on 'transfer', Mixin::Handlers::TransferHandler
382
+ end
383
+ end
384
+ end
385
+ ```
386
+
387
+ Pair with the Puma plugin (`plugin :mixin_blaze`) so messages are received inside the web process tree. Per-bot: `MixinBot::Blaze::Router.new(api: MixinBot.bot(:shop))`.
388
+
389
+ - **Outputs polling** — `rails g mixin_bot:outputs` creates a receipt table (unique per `bot_app_id` + `output_id` — the dedup gate) and an example processor in `app/mixin/processors`. The poller's resume cursor is derived from the receipts themselves (newest recorded output `sequence` per bot — unique, monotonic, and the key the outputs API paginates on) — no separate cursor table. Run `rails mixin_bot:poller` (standalone process; pass `[bot_name]` for a registered bot). Every output — inbound *and* outbound — is delivered to matching processors through your job queue; memos bridge lazily via the snapshot notification API. Processors implement `self.matches?(envelope)` + `#process` and should be idempotent (dispatch is at-least-once).
390
+
391
+ - **Outbound transfers** — `rails g mixin_bot:transfers` creates a `MixinTransfer` ledger with the state machine (`pending → broadcast → confirmed | failed | reconciling`). Creating a record enqueues the Safe pipeline with the record's `trace_id` as the request id — retries are network-deduplicated. A timeout after submission lands in `reconciling`; the recurring `MixinTransfers.ReconcileJob` confirms from the trace snapshot or re-sends safely.
392
+
393
+ - **Mixin notifications** — `rails g mixin_bot:notifications` adds `noticed` and an example notification with `deliver_by :mixin`. The channel (`MixinBot::Notifications::MixinChannel`) sends text/post/app-card messages with the configured bot's credentials; it loads only when Noticed is present.
394
+
395
+ Multiple bots in one process: register named bots, then reference them everywhere (`MixinBot.bot(:shop)`, `mixin_bot:poller[shop]`, `Router.new(api: ...)`, `deliver_by :mixin, bot: :shop`):
396
+
397
+ ```ruby
398
+ MixinBot.register_bot :shop,
399
+ app_id: '...', session_id: '...',
400
+ session_private_key: '...', server_public_key: '...',
401
+ spend_key: '...'
402
+ ```
403
+
293
404
  ## Deep links and bot auth
294
405
 
295
406
  ```ruby
@@ -396,8 +507,8 @@ Examples under `examples/` expect `examples/config.yml` (copy from `examples/con
396
507
 
397
508
  GitHub Actions runs on every pull request and on pushes to `main`:
398
509
 
399
- - **Test** — Ruby 3.2, 3.3, and 4.0: `bundle exec rake test`
400
- - **RuboCop** — Ruby 3.3: `bundle exec rake rubocop`
510
+ - **Test** — Ruby 4.0: `bundle exec rake test`
511
+ - **RuboCop** — Ruby 4.0: `bundle exec rake rubocop`
401
512
  - **API coverage** — `bundle exec rake mixin_bot:api_coverage`
402
513
 
403
514
  ### Release
@@ -130,6 +130,55 @@ end
130
130
 
131
131
  See [examples/blaze.rb](../../examples/blaze.rb).
132
132
 
133
+ ## Blaze inside Puma (`plugin :mixin_blaze`)
134
+
135
+ For Rails/Puma apps the gem ships a Puma plugin (Solid Queue-style) that hosts the Blaze connection inside the web process tree — no standalone Blaze process. Requires a configured `blaze_handler`; without one the plugin logs an error and stays off.
136
+
137
+ ```ruby
138
+ # config/puma.rb
139
+ plugin :mixin_blaze
140
+ mixin_blaze_mode :fork # default (:fork = supervised child process); :async = in-process thread
141
+
142
+ # config/initializers/mixin_bot.rb
143
+ MixinBot.configure do
144
+ # ... credentials ...
145
+ self.blaze_handler = ->(envelope) { MyBot.process! envelope } # required
146
+ self.blaze_ack_policy = :on_receipt # default; :after_handler = ack after handler success
147
+ end
148
+ ```
149
+
150
+ Behavior: reconnect with bounded backoff, keepalive pings, serial handler dispatch (ActiveRecord-safe), handler exceptions logged without ending delivery, graceful shutdown with Puma (child killed on stop; in `:fork` mode a dead child stops Puma so the unit restarts). `ACKNOWLEDGE_MESSAGE_RECEIPT` confirmations are consumed internally — the handler never sees them and they are never re-acknowledged.
151
+
152
+ Operational notes:
153
+
154
+ - **Remove any standalone Blaze process when enabling the plugin** — duplicate connections duplicate handler invocations.
155
+ - **Puma cluster mode requires `preload_app!`** — plugins run in the launcher process; without preloading there is no app code to resolve the handler and the plugin reports an error.
156
+ - **Phased restarts keep old handler code** in the hosting process until a full restart.
157
+ - Workers that need to *send* messages use the REST API (`create_message`), not the socket.
158
+
159
+ ## Mixin login for a Rails app (`rails g mixin_bot:authentication`)
160
+
161
+ Task: add "Sign in with Mixin" (OAuth identity login) to an existing Rails app.
162
+
163
+ ```bash
164
+ bundle add mixin_bot # if not already present
165
+ rails generate mixin_bot:authentication
166
+ bin/rails db:migrate
167
+ ```
168
+
169
+ Generated: `config/initializers/omniauth.rb` (OmniAuth `:mixin` provider), `app/models/{user,session,current}.rb`, `app/controllers/sessions_controller.rb` + `app/controllers/concerns/authentication.rb` (injected into `ApplicationController`), `app/views/sessions/new.html.erb`, `resource :session` + `/auth/mixin/callback` routes, and `CreateUsers` (`mixin_user_id` unique, `name`, `avatar_url`) / `CreateSessions` migrations. The Gemfile gains `omniauth-mixin` and `omniauth-rails_csrf_protection`.
170
+
171
+ Required environment: `MIXIN_CLIENT_ID`, `MIXIN_CLIENT_SECRET` (from the Mixin Developer Dashboard); register `https://<host>/auth/mixin/callback` as the OAuth redirect URL.
172
+
173
+ Behavior notes for agents:
174
+
175
+ - **Identity only** — sign-in stores the Mixin user id + name + avatar (`User.find_or_create_by_mixin_auth!`, matched by uid). OAuth access/refresh tokens are never persisted; do not add token columns to the generated models.
176
+ - **Boot is safe without credentials** (CI / `assets:precompile`); a sign-in attempt without them raises `ArgumentError` naming the missing env vars (OmniAuth setup phase).
177
+ - **Request phase is POST + CSRF** (`button_to` → `/auth/mixin`, `omniauth-rails_csrf_protection`); callback is the GET route → `sessions#create`. A GET to `/auth/mixin` is rejected (OmniAuth 2.x).
178
+ - **Sessions** are DB-backed (`sessions` rows, `ip_address`/`user_agent`) remembered in a signed httponly cookie; `Current.user` after sign-in; unauthenticated requests redirect to the sign-in page and return after login; `terminate_session` on sign-out.
179
+ - **Self-contained** — generated code references no `mixin_bot` APIs; the login survives removing the gem. Re-running the generator does not duplicate routes or Gemfile entries; template conflicts prompt like any Rails generator (an existing `User` model is overwritten only if you choose so).
180
+ - To call Mixin APIs on behalf of a signed-in user afterwards, use the gem's OAuth machinery (`MixinBot::API` with delegated authorization) separately from the generated authentication code.
181
+
133
182
  ## Discover API methods
134
183
 
135
184
  ```bash
@@ -40,10 +40,12 @@ def send_frame(connection, bytes)
40
40
  end
41
41
 
42
42
  Async do |task|
43
- # endpoint_options: is forwarded to Async::HTTP::Endpoint.parse (a connect
44
- # timeout here). handler: stays default; pass an Async::WebSocket::Connection
45
- # subclass to hook the raw frame events (e.g. PONG correlation).
46
- connection = API.blaze_async(endpoint_options: { timeout: 10 })
43
+ # endpoint_options: is forwarded to Async::HTTP::Endpoint.parse. No
44
+ # timeout: here — it would become the socket's per-read timeout and kill
45
+ # quiet-but-healthy connections; liveness is the keepalive ping below.
46
+ # handler: stays default; pass an Async::WebSocket::Connection subclass to
47
+ # hook the raw frame events (e.g. PONG correlation).
48
+ connection = API.blaze_async
47
49
  p [Time.now.to_s, :connected]
48
50
 
49
51
  # liveness is the caller's policy: nothing on the wire goes stale quietly
@@ -0,0 +1,29 @@
1
+ Description:
2
+ Adds "Sign in with Mixin" to this Rails application using OmniAuth
3
+ (omniauth-mixin): User/Session/Current models, a sessions controller with
4
+ the OAuth callback, an authentication concern, routes, migrations, and
5
+ the OmniAuth initializer.
6
+
7
+ The generated login is identity-only: the OAuth access token is used to
8
+ establish identity and is not persisted.
9
+
10
+ After generating, set MIXIN_CLIENT_ID and MIXIN_CLIENT_SECRET, and register
11
+ https://<your-host>/auth/mixin/callback as the OAuth redirect URL in the
12
+ Mixin developer dashboard.
13
+
14
+ Example:
15
+ rails generate mixin_bot:authentication
16
+
17
+ This will create:
18
+ config/initializers/omniauth.rb
19
+ app/models/user.rb
20
+ app/models/session.rb
21
+ app/models/current.rb
22
+ app/controllers/sessions_controller.rb
23
+ app/controllers/concerns/authentication.rb
24
+ app/views/sessions/new.html.erb
25
+ db/migrate/xxx_create_users.rb
26
+ db/migrate/xxx_create_sessions.rb
27
+
28
+ It will also add `omniauth-mixin` and `omniauth-rails_csrf_protection` to
29
+ the Gemfile and inject `include Authentication` into ApplicationController.
@@ -0,0 +1,82 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'rails/generators'
4
+
5
+ module MixinBot
6
+ module Generators
7
+ # `rails generate mixin_bot:authentication` — sets up "Sign in with Mixin"
8
+ # via OmniAuth, modeled on Rails' built-in `authentication` generator.
9
+ # Identity only: the OAuth tokens are used to establish identity and are
10
+ # not persisted.
11
+ class AuthenticationGenerator < Rails::Generators::Base
12
+ # Rails 8.1 moved bundle_command out of Actions into BundleHelper.
13
+ unless method_defined?(:bundle_command) || private_method_defined?(:bundle_command)
14
+ begin
15
+ require 'rails/generators/bundle_helper'
16
+ include Rails::Generators::BundleHelper
17
+ rescue LoadError
18
+ # Older Rails ships bundle_command in Rails::Generators::Actions.
19
+ end
20
+ end
21
+
22
+ source_root File.expand_path('templates', __dir__)
23
+
24
+ SESSION_ROUTE = 'resource :session, only: [:new, :destroy]'
25
+ CALLBACK_ROUTE = "get '/auth/mixin/callback', to: 'sessions#create'"
26
+ OMNIAUTH_GEMS = %w[omniauth-mixin omniauth-rails_csrf_protection].freeze
27
+
28
+ def create_authentication_files
29
+ template 'config/initializers/omniauth.rb'
30
+ template 'app/models/user.rb'
31
+ template 'app/models/session.rb'
32
+ template 'app/models/current.rb'
33
+ template 'app/controllers/sessions_controller.rb'
34
+ template 'app/controllers/concerns/authentication.rb'
35
+ template 'app/views/sessions/new.html.erb'
36
+ end
37
+
38
+ def configure_application_controller
39
+ inject_into_class 'app/controllers/application_controller.rb', 'ApplicationController', " include Authentication\n"
40
+ end
41
+
42
+ def configure_authentication_routes
43
+ route SESSION_ROUTE
44
+ route CALLBACK_ROUTE
45
+ end
46
+
47
+ def add_migrations
48
+ generate 'migration', 'CreateUsers', 'mixin_user_id:string!:uniq name:string avatar_url:string', '--force'
49
+ generate 'migration', 'CreateSessions', 'user:references ip_address:string user_agent:string', '--force'
50
+ end
51
+
52
+ def add_omniauth_gems
53
+ gemfile = File.read(gemfile_path)
54
+ uncommented = []
55
+
56
+ OMNIAUTH_GEMS.each do |name|
57
+ pattern = /gem\s+["']#{Regexp.escape(name)}["']/
58
+ next if gemfile.match?(/^\s*#{pattern}/) # already present
59
+
60
+ if gemfile.match?(/^\s*#\s*#{pattern}/)
61
+ uncomment_lines 'Gemfile', pattern
62
+ uncommented << name
63
+ else
64
+ # Single positional call: bundle_command signatures differ across
65
+ # railties versions (8.1 BundleHelper is (command, env, params);
66
+ # older Actions is (command, options)) — only this shape is valid
67
+ # on all of them, and bundler handles --quiet itself.
68
+ bundle_command("add #{name} --quiet")
69
+ end
70
+ end
71
+
72
+ bundle_command('install --quiet') unless uncommented.empty?
73
+ end
74
+
75
+ private
76
+
77
+ def gemfile_path
78
+ File.expand_path('Gemfile', destination_root)
79
+ end
80
+ end
81
+ end
82
+ end
@@ -0,0 +1,52 @@
1
+ module Authentication
2
+ extend ActiveSupport::Concern
3
+
4
+ included do
5
+ before_action :require_authentication
6
+ helper_method :authenticated?
7
+ end
8
+
9
+ class_methods do
10
+ def allow_unauthenticated_access(**options)
11
+ skip_before_action :require_authentication, **options
12
+ end
13
+ end
14
+
15
+ private
16
+ def authenticated?
17
+ resume_session
18
+ end
19
+
20
+ def require_authentication
21
+ resume_session || request_authentication
22
+ end
23
+
24
+ def resume_session
25
+ Current.session ||= find_session_by_cookie
26
+ end
27
+
28
+ def find_session_by_cookie
29
+ Session.find_by(id: cookies.signed[:session_id]) if cookies.signed[:session_id]
30
+ end
31
+
32
+ def request_authentication
33
+ session[:return_to_after_authenticating] = request.url
34
+ redirect_to new_session_path
35
+ end
36
+
37
+ def after_authentication_url
38
+ session.delete(:return_to_after_authenticating) || root_url
39
+ end
40
+
41
+ def start_new_session_for(user)
42
+ user.sessions.create!(user_agent: request.user_agent, ip_address: request.remote_ip).tap do |session|
43
+ Current.session = session
44
+ cookies.signed.permanent[:session_id] = { value: session.id, httponly: true, same_site: :lax }
45
+ end
46
+ end
47
+
48
+ def terminate_session
49
+ Current.session&.destroy
50
+ cookies.delete(:session_id)
51
+ end
52
+ end
@@ -0,0 +1,24 @@
1
+ class SessionsController < ApplicationController
2
+ allow_unauthenticated_access only: %i[new create]
3
+
4
+ def new
5
+ end
6
+
7
+ def create
8
+ auth = request.env["omniauth.auth"]
9
+
10
+ unless auth
11
+ redirect_to new_session_path, alert: "Sign in with Mixin failed. Please try again."
12
+ return
13
+ end
14
+
15
+ user = User.find_or_create_by_mixin_auth!(auth)
16
+ start_new_session_for user
17
+ redirect_to after_authentication_url
18
+ end
19
+
20
+ def destroy
21
+ terminate_session
22
+ redirect_to new_session_path, status: :see_other
23
+ end
24
+ end
@@ -0,0 +1,4 @@
1
+ class Current < ActiveSupport::CurrentAttributes
2
+ attribute :session
3
+ delegate :user, to: :session, allow_nil: true
4
+ end
@@ -0,0 +1,3 @@
1
+ class Session < ApplicationRecord
2
+ belongs_to :user
3
+ end
@@ -0,0 +1,12 @@
1
+ class User < ApplicationRecord
2
+ has_many :sessions, dependent: :destroy
3
+
4
+ # Finds or creates the user for an OmniAuth auth hash and refreshes the
5
+ # stored Mixin profile (name, avatar). The OAuth exchange is used only to
6
+ # establish identity; credentials are not stored.
7
+ def self.find_or_create_by_mixin_auth!(auth)
8
+ user = find_or_create_by!(mixin_user_id: auth.uid)
9
+ user.update!(name: auth.info.name, avatar_url: auth.info.image)
10
+ user
11
+ end
12
+ end
@@ -0,0 +1,3 @@
1
+ <p style="color: red"><%%= alert %></p>
2
+
3
+ <%%= button_to "Sign in with Mixin", "/auth/mixin", data: { turbo: false } %>
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "omniauth"
4
+ require "omniauth/strategies/mixin"
5
+
6
+ Rails.application.config.middleware.use OmniAuth::Builder do
7
+ provider :mixin,
8
+ ENV.fetch("MIXIN_CLIENT_ID", nil),
9
+ ENV.fetch("MIXIN_CLIENT_SECRET", nil),
10
+ setup: lambda { |env|
11
+ client_id = ENV["MIXIN_CLIENT_ID"]
12
+ client_secret = ENV["MIXIN_CLIENT_SECRET"]
13
+
14
+ if client_id.blank? || client_secret.blank?
15
+ raise ArgumentError, "Missing Mixin OAuth credentials: set MIXIN_CLIENT_ID and MIXIN_CLIENT_SECRET"
16
+ end
17
+
18
+ env["omniauth.strategy"].options[:client_id] = client_id
19
+ env["omniauth.strategy"].options[:client_secret] = client_secret
20
+ }
21
+ end
@@ -0,0 +1,19 @@
1
+ Description:
2
+ Adds Mixin Blaze message routing to this Rails application: a routes
3
+ initializer wired to MixinBot's `blaze_handler`, an example text handler
4
+ under app/mixin/handlers, and the `mixin_blaze` Puma plugin line in
5
+ config/puma.rb so messages are received inside the web process tree.
6
+
7
+ Requires MixinBot bot credentials (app_id, session_id,
8
+ session_private_key, server_public_key) to be configured, e.g. from
9
+ environment variables.
10
+
11
+ Example:
12
+ rails generate mixin_bot:blaze
13
+
14
+ This will create:
15
+ config/initializers/mixin_bot_blaze.rb
16
+ app/mixin/handlers/text_handler.rb
17
+
18
+ It will also add `plugin :mixin_blaze` to config/puma.rb (creating it
19
+ when missing) without duplicating an existing line.
@@ -0,0 +1,43 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'rails/generators'
4
+
5
+ module MixinBot
6
+ module Generators
7
+ # `rails generate mixin_bot:blaze` — scaffolds Mixin Blaze message
8
+ # routing: a routes initializer wired to `config.blaze_handler`, an
9
+ # example text handler under `app/mixin/handlers`, and the `mixin_blaze`
10
+ # Puma plugin line in `config/puma.rb` so the app receives messages in
11
+ # its own process tree.
12
+ class BlazeGenerator < Rails::Generators::Base
13
+ source_root File.expand_path('templates', __dir__)
14
+
15
+ PUMA_PLUGIN_LINE = 'plugin :mixin_blaze'
16
+ PUMA_COMMENT = '# Receive Mixin Blaze messages in the web process tree (MixinBot).'
17
+
18
+ def create_routes_initializer
19
+ template 'config/initializers/mixin_bot_blaze.rb'
20
+ end
21
+
22
+ def create_example_handler
23
+ template 'app/mixin/handlers/text_handler.rb'
24
+ end
25
+
26
+ def configure_puma_plugin
27
+ if File.exist?(puma_path)
28
+ return if File.read(puma_path).match?(/^\s*plugin\s+:mixin_blaze\b/)
29
+
30
+ append_to_file 'config/puma.rb', "\n#{PUMA_COMMENT}\n#{PUMA_PLUGIN_LINE}\n"
31
+ else
32
+ create_file 'config/puma.rb', "#{PUMA_COMMENT}\n#{PUMA_PLUGIN_LINE}\n"
33
+ end
34
+ end
35
+
36
+ private
37
+
38
+ def puma_path
39
+ File.expand_path('config/puma.rb', destination_root)
40
+ end
41
+ end
42
+ end
43
+ end
@@ -0,0 +1,14 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Mixin
4
+ module Handlers
5
+ # Example Blaze handler: echoes text messages back to the sender.
6
+ # Handlers are instantiated per message; `message` exposes decoded data,
7
+ # conversation/sender ids, and the `reply` helper (HTTP message API).
8
+ class TextHandler < MixinBot::Blaze::Router::Base
9
+ def handle
10
+ reply("You said: #{message.data}")
11
+ end
12
+ end
13
+ end
14
+ end
@@ -0,0 +1,23 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Mixin Blaze message routing.
4
+ #
5
+ # Requires bot credentials in MixinBot.configure (app_id, session_id,
6
+ # session_private_key, server_public_key, spend_key) — typically from
7
+ # environment variables — and the `mixin_blaze` Puma plugin in
8
+ # config/puma.rb to actually receive messages.
9
+ #
10
+ # Routes dispatch decoded envelopes in registration order (first match
11
+ # wins); handlers live under app/mixin/handlers and are re-resolved on
12
+ # every code reload in development.
13
+
14
+ Rails.application.config.to_prepare do
15
+ MixinBot.configure do
16
+ self.blaze_handler = MixinBot::Blaze::Router.new do
17
+ on 'text', Mixin::Handlers::TextHandler
18
+ # on 'transfer', Mixin::Handlers::TransferHandler
19
+ # on category: 'APP_CARD', handler: Mixin::Handlers::AppCardHandler
20
+ # on action: 'CREATE_MESSAGE', handler: ->(message) { ... }
21
+ end
22
+ end
23
+ end