monkrb 0.15.1 → 0.17.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,76 @@
1
+ require "uri"
2
+
3
+ module Monk
4
+ module Mail
5
+ # MAIL_URL -> a transport value. Runs once, from Monk::Mail.configure
6
+ # in the main Ractor at boot, so a malformed URL fails the boot rather
7
+ # than the first send (docs/adr/0003-boot-time-fail-fast-shareability.md's
8
+ # posture). Error messages redact the password: the URL is a secret.
9
+ module URL
10
+ SUPPORTED = "smtp://, smtps://, log://".freeze
11
+ STARTTLS_MODES = %w[auto always never].freeze
12
+
13
+ module_function
14
+
15
+ def parse(url)
16
+ uri = URI.parse(url)
17
+ case uri.scheme&.downcase
18
+ when "log" then Transports::Log.new
19
+ when "smtp" then smtp(uri, url, tls: false)
20
+ when "smtps" then smtp(uri, url, tls: true)
21
+ else invalid!(url, "unsupported scheme #{uri.scheme.inspect}; supported: #{SUPPORTED}")
22
+ end
23
+ rescue URI::InvalidURIError
24
+ invalid!(url, "not a valid URL; expected e.g. smtp://user:pass@smtp.example.com:587")
25
+ end
26
+
27
+ def smtp(uri, url, tls:)
28
+ invalid!(url, "missing host") if uri.host.nil? || uri.host.empty?
29
+
30
+ Transports::SMTP.require_library!
31
+ user = decode(uri.user)
32
+ Transports::SMTP.new(
33
+ host: uri.host,
34
+ port: uri.port || (tls ? 465 : 587),
35
+ user: user,
36
+ password: decode(uri.password),
37
+ tls: tls,
38
+ starttls: starttls(uri, url, tls: tls, credentials: !user.nil?),
39
+ )
40
+ end
41
+
42
+ # Default :always with credentials (a stripped STARTTLS would
43
+ # otherwise send the password in clear), :auto without (a local
44
+ # relay usually has no TLS set up).
45
+ def starttls(uri, url, tls:, credentials:)
46
+ options = URI.decode_www_form(uri.query || "").to_h
47
+ unknown = options.keys - ["starttls"]
48
+ invalid!(url, "unknown option(s) #{unknown.join(", ")}; the only one is starttls=") if unknown.any?
49
+
50
+ mode = options["starttls"]
51
+ if tls
52
+ invalid!(url, "starttls= doesn't apply to smtps://, which is TLS from the start") if mode
53
+ return :never
54
+ end
55
+ return credentials ? :always : :auto if mode.nil?
56
+
57
+ invalid!(url, "starttls= must be one of #{STARTTLS_MODES.join(", ")}") unless STARTTLS_MODES.include?(mode)
58
+ mode.to_sym
59
+ end
60
+
61
+ def decode(component)
62
+ return nil if component.nil? || component.empty?
63
+
64
+ URI.decode_uri_component(component)
65
+ end
66
+
67
+ def invalid!(url, reason)
68
+ raise InvalidMailUrlError, "MAIL_URL #{redact(url).inspect}: #{reason}"
69
+ end
70
+
71
+ def redact(url)
72
+ url.to_s.sub(%r{(://[^:/@]*:)[^@/]*@}, '\1***@')
73
+ end
74
+ end
75
+ end
76
+ end
data/lib/monk/mail.rb ADDED
@@ -0,0 +1,121 @@
1
+ require_relative "freeze_hooks"
2
+ require_relative "environment"
3
+ require_relative "log"
4
+ require_relative "views"
5
+ require_relative "context"
6
+ require_relative "mail/errors"
7
+ require_relative "mail/address"
8
+ require_relative "mail/mime"
9
+ require_relative "mail/message"
10
+ require_relative "mail/transports"
11
+ require_relative "mail/url"
12
+
13
+ module Monk
14
+ # Sending email. Opt-in: require "monk/mail" explicitly -- `require
15
+ # "monk"` alone does not load this. Text and/or HTML messages only, MIME
16
+ # built by Monk itself, since the `mail` gem can't run inside a worker
17
+ # Ractor (docs/adr/0012-minimal-built-in-mailer.md).
18
+ module Mail
19
+ class << self
20
+ # Typically, in config/mail.rb:
21
+ #
22
+ # Monk::Mail.configure(url: ENV["MAIL_URL"], from: ENV["MAIL_FROM"])
23
+ #
24
+ # url: picks the transport (smtp://, smtps://, log://) and is parsed
25
+ # here, so a bad one fails the boot. Unset or empty means log:// in
26
+ # development and raises everywhere else -- same posture as
27
+ # Monk::Auth.deliver_link: an app that forgot to wire mail should
28
+ # find out at boot, not when a user never gets their login link.
29
+ # from: is the default sender, optional since each send can pass its own.
30
+ def configure(url:, from: nil)
31
+ Fields.header!(:from, from) unless from.nil?
32
+
33
+ @config = { transport: transport_for(url), from: from }
34
+ end
35
+
36
+ attr_reader :config
37
+
38
+ # Builds a Message and sends it through the configured transport,
39
+ # synchronously -- the calling worker Ractor waits for the send
40
+ # (docs/adr/0012-minimal-built-in-mailer.md accepts that cost; a
41
+ # local relay or a fast HTTPS provider keeps it short). Returns the
42
+ # Message. Invalid input raises InvalidMessageError before anything
43
+ # is sent; a failed send raises DeliveryError.
44
+ #
45
+ # Monk::Mail.deliver(to: email, subject: "Your login link", text: "Log in: #{link}")
46
+ def deliver(to:, subject:, text: nil, html: nil, reply_to: nil, from: nil)
47
+ raise NotConfiguredError, "call Monk::Mail.configure(url:, from:) before Monk::Mail.deliver" unless config
48
+
49
+ from ||= config[:from]
50
+ if from.nil?
51
+ raise InvalidMessageError, "no from: given, and Monk::Mail.configure has no default from: (MAIL_FROM)"
52
+ end
53
+
54
+ message = Message.new(from: from, to: to, subject: subject, text: text, html: html, reply_to: reply_to)
55
+ config[:transport].deliver(message)
56
+ end
57
+
58
+ # Renders a Monk::Views template to an HTML String for deliver's
59
+ # html: -- e.g. views/mail/magic_link.erb:
60
+ #
61
+ # html: Monk::Mail.render("mail/magic_link", link: link)
62
+ #
63
+ # The same detached-Context render Monk::Live::Renderer does: no
64
+ # request behind it, so the template sees only its locals, never
65
+ # params or the session. The app's page layout is skipped (layout:
66
+ # false); pass layout: "mail/layout" for an email-specific one.
67
+ # `<%= %>` escapes as usual, which is right for HTML and wrong for a
68
+ # plain-text body -- build text: as a Ruby String instead.
69
+ def render(template, layout: false, **locals)
70
+ ensure_views_frozen!
71
+ String.new(Monk::Context.new({}).render(template, layout: layout, **locals)).freeze
72
+ end
73
+
74
+ # Called from Base#freeze! (Seam B), via Monk.freeze_hooks. Freezes
75
+ # the value, not the module, same as Monk::Auth.freeze_registry! --
76
+ # an unfrozen config Hash can't be read from a worker Ractor at all.
77
+ def freeze_registry!
78
+ transport = @config&.fetch(:transport)
79
+ transport.class.prepare! if transport.class.respond_to?(:prepare!)
80
+ @config = Ractor.make_shareable(@config)
81
+ end
82
+
83
+ # Test-only: drops the current config. Not part of the app-facing API.
84
+ def reset!
85
+ @config = nil
86
+ end
87
+
88
+ private
89
+
90
+ # Unfrozen, a render from a worker Ractor dies with an opaque
91
+ # Ractor::IsolationError inside Monk::Views, and one from the main
92
+ # Ractor works by accident and then fails in production -- so both
93
+ # fail here, naming the fix (same check as Monk::Live::Renderer).
94
+ def ensure_views_frozen!
95
+ return if Ractor.shareable?(Monk::Views.registry)
96
+
97
+ raise_views_not_frozen
98
+ rescue Ractor::IsolationError
99
+ raise_views_not_frozen
100
+ end
101
+
102
+ def raise_views_not_frozen
103
+ raise ViewsNotFrozenError,
104
+ "Monk::Mail.render can't render before views are compiled and frozen -- call Monk.boot(app) " \
105
+ "(or Monk.freeze!) in the main Ractor first"
106
+ end
107
+
108
+ def transport_for(url)
109
+ return URL.parse(url) unless url.nil? || url.empty?
110
+ return Transports::Log.new if Monk.env.development?
111
+
112
+ raise MissingMailUrlError,
113
+ "Monk::Mail.configure(url:) is empty and this isn't development (MONK_ENV=#{Monk.env}) -- " \
114
+ "no email would ever be sent. Set MAIL_URL, e.g. smtp://user:pass@smtp.example.com:587, " \
115
+ "or log:// if logging messages instead of sending them is truly intended here."
116
+ end
117
+ end
118
+
119
+ Monk.freeze_hooks << self
120
+ end
121
+ end
data/lib/monk/scaffold.rb CHANGED
@@ -45,6 +45,15 @@ module Monk
45
45
  "config/auth.rb" => "auth/config/auth.rb",
46
46
  "db/migrate/00000000000001_create_auth_tables.up.sql" => "auth/db/migrate/00000000000001_create_auth_tables.up.sql",
47
47
  "db/migrate/00000000000001_create_auth_tables.down.sql" => "auth/db/migrate/00000000000001_create_auth_tables.down.sql",
48
+ # The magic link's HTML part -- config/auth.rb's AppMailer::DELIVER
49
+ # renders it and sends through Monk::Mail (MAIL_FILES, implied by --auth).
50
+ "views/mail/magic_link.erb" => "auth/views/mail/magic_link.erb",
51
+ }.freeze
52
+
53
+ # --mail, or implied by --auth: Monk::Mail's config. Its Gemfile line
54
+ # (net-smtp) and MAIL_* env vars are added by #write! / #write_env_files!.
55
+ MAIL_FILES = {
56
+ "config/mail.rb" => "mail/config/mail.rb",
48
57
  }.freeze
49
58
 
50
59
  # --live: Monk::Live's demo (a counter whose open tabs update when
@@ -58,11 +67,18 @@ module Monk
58
67
  "views/index.erb" => "live/views/index.erb",
59
68
  }.freeze
60
69
 
70
+ # config/live.rb itself isn't here -- #write_live! picks between
71
+ # LIVE_CONFIG_TEMPLATES[@live_transport] instead, since which one gets
72
+ # written depends on --redis vs --postgres, not a fixed mapping.
61
73
  LIVE_FILES = {
62
- "config/live.rb" => "live/config/live.rb",
63
74
  "views/live/_hits.erb" => "live/views/live/_hits.erb",
64
75
  }.freeze
65
76
 
77
+ LIVE_CONFIG_TEMPLATES = {
78
+ redis: "live/config/live.rb",
79
+ postgres: "live/config/live_pg.rb",
80
+ }.freeze
81
+
66
82
  # Where the client runtime lands in the app's public root (an app serves
67
83
  # it like any other static file; its files import each other by relative
68
84
  # path, so they stay together in one directory).
@@ -87,15 +103,45 @@ module Monk
87
103
  # and only then requires "redis", so the gem has to already be in the
88
104
  # bundle for that to work.
89
105
  #
90
- # live: implies redis: -- bin/server and bin/websocket_server are separate
91
- # processes, so an update published by one only reaches a socket held by
92
- # the other through Redis.
93
- def initialize(dir, postgres: false, auth: false, redis: false, live: false)
106
+ # live: bin/server and bin/websocket_server are always separate
107
+ # processes, so *something* has to carry a publish between them --
108
+ # --redis (Monk::WebSocket::RedisFanout) or --postgres
109
+ # (Monk::WebSocket::PgFanout, reusing --postgres's own DB_* settings,
110
+ # no Redis to run). --live no longer silently defaults to one: with
111
+ # neither flag, there's no way to tell whether this app has Postgres
112
+ # available at all, so guessing would be as likely to hand back a
113
+ # template that can't connect as one that can. --live --redis wins
114
+ # over --postgres if both are passed (an explicit ask beats an
115
+ # implied default); --live --postgres alone (or via --auth, which
116
+ # implies --postgres) picks PgFanout; --live with neither raises.
117
+ # See docs/design/live-pg-fanout.md / docs/history/plan-live-pg-fanout.md
118
+ # for the design this decision comes from.
119
+ #
120
+ # mail: Monk::Mail on its own, no Postgres needed. auth: implies it, the
121
+ # same way it implies postgres: a magic link has to reach someone.
122
+ def initialize(dir, postgres: false, auth: false, redis: false, live: false, mail: false)
94
123
  @dir = dir
95
124
  @auth = auth
96
125
  @postgres = postgres || auth
126
+ @mail = mail || auth
97
127
  @live = live
98
- @redis = redis || live
128
+
129
+ if @live
130
+ @redis = redis || false
131
+ @live_transport = if redis
132
+ :redis
133
+ elsif @postgres
134
+ :postgres
135
+ else
136
+ raise Monk::AmbiguousLiveTransportError,
137
+ "monk new --live needs --redis or --postgres to pick Monk::Live's cross-process " \
138
+ "transport (bin/server and bin/websocket_server are always separate processes) -- " \
139
+ "pass one explicitly, e.g. `--live --postgres` (docs/guides/live.md's " \
140
+ "\"Without Redis\" section has the tradeoffs)"
141
+ end
142
+ else
143
+ @redis = redis
144
+ end
99
145
  end
100
146
 
101
147
  def write!
@@ -112,15 +158,20 @@ module Monk
112
158
  POSTGRES_FILES.each { |relative, template| write_file(relative, template, executable: EXECUTABLE_FILES.include?(relative)) }
113
159
  FileUtils.mkdir_p(File.join(@dir, "db/migrate"))
114
160
  append_gemfile_extra("postgres/Gemfile.extra")
115
- wire_config_ru!
116
161
  end
117
162
 
163
+ if @mail
164
+ MAIL_FILES.each { |relative, template| write_file(relative, template) }
165
+ append_gemfile_extra("mail/Gemfile.extra")
166
+ end
167
+
168
+ wire_config_ru! if @postgres || @mail
118
169
  append_gemfile_extra("redis/Gemfile.extra") if @redis
119
170
 
120
- # --postgres and --redis are the two flags with anything worth
121
- # putting in an env file (a database, a REDIS_URL) -- the base
122
- # skeleton alone has nothing to configure this way.
123
- if @postgres || @redis
171
+ # --postgres, --redis and --mail are the flags with anything worth
172
+ # putting in an env file (a database, a REDIS_URL, MAIL_*) -- the
173
+ # base skeleton alone has nothing to configure this way.
174
+ if @postgres || @redis || @mail
124
175
  write_env_files!
125
176
  uncomment_dotenv!
126
177
  end
@@ -136,6 +187,7 @@ module Monk
136
187
  private
137
188
 
138
189
  def write_live!
190
+ write_file("config/live.rb", LIVE_CONFIG_TEMPLATES.fetch(@live_transport))
139
191
  LIVE_FILES.each { |relative, template| write_file(relative, template) }
140
192
  copy_live_client!
141
193
  add_live_to_layout!
@@ -190,12 +242,20 @@ module Monk
190
242
  def wire_config_ru!
191
243
  path = File.join(@dir, "config.ru")
192
244
  settings_require = %(require_relative "config/settings"\n)
193
- target = @auth ? "config/auth" : "config/persistence" # config/auth.rb itself require_relative "persistence"
245
+ requires = +""
246
+ if @postgres
247
+ target = @auth ? "config/auth" : "config/persistence" # config/auth.rb itself require_relative "persistence"
248
+ requires << "require_relative \"#{target}\"\n"
249
+ end
250
+ # config/mail.rb from config.ru only, never from config/auth.rb:
251
+ # bin/websocket_server loads config/auth.rb too and never sends mail,
252
+ # so it shouldn't need MAIL_URL to boot.
253
+ requires << "require_relative \"config/mail\"\n" if @mail
194
254
 
195
255
  content = File.read(path)
196
256
  raise "config.ru wiring failed: #{settings_require.inspect} not found" unless content.include?(settings_require)
197
257
 
198
- File.write(path, content.sub(settings_require, "#{settings_require}require_relative \"#{target}\"\n"))
258
+ File.write(path, content.sub(settings_require, "#{settings_require}#{requires}"))
199
259
  end
200
260
 
201
261
  # .env/.env.test/.env.example are the other deliberate exception to
@@ -218,6 +278,17 @@ module Monk
218
278
  example << "AUTH_SECRET=change-me"
219
279
  end
220
280
 
281
+ if @mail
282
+ # MAIL_URL is left out of .env on purpose: unset in development
283
+ # means log://, the message printed to the console. Tests boot
284
+ # outside development, where an unset one raises, hence log:// there.
285
+ dev << %(MAIL_FROM="#{app_name} <no-reply@localhost>")
286
+ test << "MAIL_URL=log://"
287
+ test << %(MAIL_FROM="#{app_name} <no-reply@localhost>")
288
+ example << "MAIL_URL=smtp://user:password@smtp.example.com:587"
289
+ example << %(MAIL_FROM="#{app_name} <no-reply@example.com>")
290
+ end
291
+
221
292
  # REDIS_URL is deliberately absent from .env.test -- only a test that
222
293
  # actually exercises RedisFanout needs it, unlike DB_NAME/AUTH_SECRET
223
294
  # which every test touching persistence/auth needs.
@@ -253,12 +324,17 @@ module Monk
253
324
  end
254
325
 
255
326
  def live_setup_md_content
327
+ @live_transport == :postgres ? live_setup_md_content_postgres : live_setup_md_content_redis
328
+ end
329
+
330
+ def live_setup_md_content_redis
256
331
  <<~MARKDOWN
257
332
 
258
333
  ## Live updates (Monk::Live)
259
334
 
260
- `monk new --live` wired a demo: a counter whose open tabs update by
261
- themselves. It needs three things running, each in its own terminal:
335
+ `monk new --live --redis` wired a demo: a counter whose open tabs
336
+ update by themselves. It needs three things running, each in its own
337
+ terminal:
262
338
 
263
339
  ```bash
264
340
  docker run --rm -d -p 6379:6379 --name #{File.basename(@dir)}_redis redis:7 # skip if one is already up
@@ -272,8 +348,45 @@ module Monk
272
348
  (`REDIS_URL`, already in `.env`) is what carries an update from one to
273
349
  the other. Where things are:
274
350
 
275
- - `config/live.rb` -- the Redis wiring and the subscribe rules (nothing
276
- is allowed unless a rule says so).
351
+ - `config/live.rb` -- the Redis wiring (`Monk::WebSocket::RedisFanout`)
352
+ and the subscribe rules (nothing is allowed unless a rule says so).
353
+ - `config.ru` -- `POST /hit` changes state and calls `Monk::Live.patch`.
354
+ - `views/index.erb` -- `live_topic "hits"` marks what to subscribe to.
355
+ - `views/live/_hits.erb` -- the fragment that gets pushed. Partials
356
+ used this way see only their locals (`locals[:hits]`), never
357
+ `params` or the session.
358
+ - `public/js/monk_live/` -- the browser runtime, copied from the gem.
359
+ The layout points at it and at `LIVE_WS_URL` (default
360
+ `ws://localhost:9293`; use `wss://` in production).
361
+
362
+ `WS_ALLOWED_ORIGINS` (default `http://localhost:9292`) must list the
363
+ origin your pages are served from, or the browser's socket is refused.
364
+ MARKDOWN
365
+ end
366
+
367
+ def live_setup_md_content_postgres
368
+ <<~MARKDOWN
369
+
370
+ ## Live updates (Monk::Live)
371
+
372
+ `monk new --live --postgres` wired a demo: a counter whose open tabs
373
+ update by themselves. It needs two things running, each in its own
374
+ terminal -- no Redis, since Postgres `LISTEN`/`NOTIFY` carries the
375
+ update instead:
376
+
377
+ ```bash
378
+ bin/server # the HTTP app on :9292, which publishes updates
379
+ bin/websocket_server # the sockets on :9293, which deliver them
380
+ ```
381
+
382
+ Open http://localhost:9292 in two tabs and press the button in one.
383
+
384
+ `bin/server` and `bin/websocket_server` are separate processes, so
385
+ Postgres `LISTEN`/`NOTIFY` (the same `DB_*` settings already in `.env`)
386
+ is what carries an update from one to the other. Where things are:
387
+
388
+ - `config/live.rb` -- the Postgres wiring (`Monk::WebSocket::PgFanout`)
389
+ and the subscribe rules (nothing is allowed unless a rule says so).
277
390
  - `config.ru` -- `POST /hit` changes state and calls `Monk::Live.patch`.
278
391
  - `views/index.erb` -- `live_topic "hits"` marks what to subscribe to.
279
392
  - `views/live/_hits.erb` -- the fragment that gets pushed. Partials
@@ -285,6 +398,12 @@ module Monk
285
398
 
286
399
  `WS_ALLOWED_ORIGINS` (default `http://localhost:9292`) must list the
287
400
  origin your pages are served from, or the browser's socket is refused.
401
+
402
+ A single broadcast is capped at just under 8000 bytes
403
+ (`Monk::WebSocket::PgFanout::MAX_NOTIFY_PAYLOAD_BYTES`, Postgres's own
404
+ `NOTIFY` limit) -- far more than this demo ever sends, but worth
405
+ knowing before pushing much larger partials this way. Regenerate with
406
+ `--redis` instead of `--postgres` if that becomes a real constraint.
288
407
  MARKDOWN
289
408
  end
290
409
 
@@ -311,7 +430,7 @@ module Monk
311
430
  bin/server # HTTP app on :9292 -> http://localhost:9292/hello
312
431
  bin/websocket_server # WS chat process on :9293, in another terminal
313
432
  ```
314
- #{redis_only_note}
433
+ #{redis_only_note}#{mail_setup_note}
315
434
  ## Test environment
316
435
 
317
436
  `monk new` scaffolds no test framework at all -- this is the minimum to
@@ -333,9 +452,9 @@ module Monk
333
452
  $LOAD_PATH.unshift(File.expand_path("..", __dir__))
334
453
 
335
454
  ENV["MONK_ENV"] ||= "test"
336
-
455
+ #{base_test_dotenv_lines}
337
456
  require "minitest/autorun"
338
- require_relative "../config/settings"
457
+ require_relative "../config/settings"#{%(\nrequire_relative "../config/mail") if @mail}
339
458
  ```
340
459
 
341
460
  **Rakefile**:
@@ -471,7 +590,7 @@ module Monk
471
590
  bin/server # HTTP app on :9292
472
591
  bin/websocket_server # WS chat process on :9293, in another terminal
473
592
  ```
474
- #{auth_or_redis_confirmation_note}
593
+ #{auth_or_redis_confirmation_note}#{mail_setup_note}
475
594
  ## Test environment
476
595
 
477
596
  ### 1. Create the test database
@@ -518,7 +637,7 @@ module Monk
518
637
 
519
638
  require "minitest/autorun"
520
639
  require_relative "../config/settings"
521
- require_relative "../config/#{@auth ? "auth" : "persistence"}"
640
+ require_relative "../config/#{@auth ? "auth" : "persistence"}"#{%(\nrequire_relative "../config/mail") if @mail}
522
641
  ```
523
642
 
524
643
  **Rakefile**:
@@ -575,6 +694,43 @@ module Monk
575
694
  "#{visible} visible to it -- that confirms everything's actually wired up.\n"
576
695
  end
577
696
 
697
+ # Interpolated into a squiggly heredoc, so no leading indentation of
698
+ # its own (interpolated text isn't dedented).
699
+ def mail_setup_note
700
+ return "" unless @mail
701
+
702
+ <<~MARKDOWN
703
+
704
+ ### Email
705
+
706
+ `config/mail.rb` configures `Monk::Mail`; send with
707
+ `Monk::Mail.deliver(to:, subject:, text:, html:)`.#{auth_mail_sentence} In
708
+ development `MAIL_URL` is unset, so messages are printed to the
709
+ console instead of sent. Outside
710
+ development set `MAIL_URL` (e.g. `smtp://user:pass@smtp.provider.com:587`)
711
+ and `MAIL_FROM` to a sender on a domain you've verified with your
712
+ provider -- an unset `MAIL_URL` fails the boot there. Provider
713
+ examples: `docs/guides/mail.md` in the monk repo.
714
+ MARKDOWN
715
+ end
716
+
717
+ # The no-Postgres test helper normally has nothing to load from
718
+ # .env.test -- but with --mail it has MAIL_URL=log://, which tests need:
719
+ # they boot outside development, where an unset MAIL_URL raises.
720
+ # Interpolated into a squiggly heredoc, so no indentation of its own.
721
+ def base_test_dotenv_lines
722
+ return "" unless @mail
723
+
724
+ %(\nrequire "dotenv"\nDotenv.load(File.expand_path(".env.test", __dir__ + "/.."))\n)
725
+ end
726
+
727
+ def auth_mail_sentence
728
+ return "" unless @auth
729
+
730
+ " `config/auth.rb`'s `AppMailer::DELIVER` sends each login link through it " \
731
+ "(HTML part: `views/mail/magic_link.erb`)."
732
+ end
733
+
578
734
  def sample_test_body
579
735
  if @auth
580
736
  <<~RUBY.chomp.gsub(/^/, " ")
@@ -7,14 +7,24 @@ require_relative "persistence"
7
7
  # "#{Monk::Settings[:public_url]}/auth/callback/#{token}"), see its comment
8
8
  # there and auth.md's "Sending the magic link".
9
9
 
10
- # Sends the magic link once you have real delivery (email, SMS, ...) --
11
- # a module constant, not a lambda inline in Auth.configure below, since
12
- # self at this file's top level isn't Ractor-shareable (same constraint as
13
- # Monk::Live.authorize blocks). Until this is wired up for real,
14
- # Monk::Auth.deliver_link falls back to Monk::Auth.log_dev_link in
15
- # development and raises everywhere else -- see docs/guides/auth.md.
10
+ # Sends the magic link by email, through Monk::Mail (config/mail.rb, which
11
+ # config.ru requires) -- a module constant, not a lambda inline in
12
+ # Auth.configure below, since self at this file's top level isn't
13
+ # Ractor-shareable (same constraint as Monk::Live.authorize blocks). The
14
+ # HTML part is views/mail/magic_link.erb. Swap the body for SMS or any
15
+ # other channel; see docs/guides/auth.md.
16
16
  module AppMailer
17
- # DELIVER = ->(email:, link:, token:) { YourMailer.magic_link(email, link) }
17
+ DELIVER = lambda do |email:, link:, token:|
18
+ # Development only (a no-op elsewhere): the link on the console, plus a
19
+ # QR code for a phone if rqrcode is in the Gemfile.
20
+ Monk::Auth.log_dev_link(link, subject: email)
21
+ Monk::Mail.deliver(
22
+ to: email,
23
+ subject: "Your login link",
24
+ text: "Here's your login link:\n\n#{link}\n\nIf you didn't ask for it, you can ignore this email.",
25
+ html: Monk::Mail.render("mail/magic_link", link: link),
26
+ )
27
+ end
18
28
  end
19
29
 
20
30
  Monk::Auth.configure(
@@ -24,5 +34,5 @@ Monk::Auth.configure(
24
34
  session_ttl: 1_209_600, # seconds a session stays valid (14 days)
25
35
  redirect_allowlist: [], # paths request_login(redirect_to:) is allowed to target
26
36
  secure: !Monk.env.development?, # Secure cookie flag; off in dev so plain-http testing works (Safari drops it)
27
- # deliver: AppMailer::DELIVER,
37
+ deliver: AppMailer::DELIVER,
28
38
  )
@@ -0,0 +1,3 @@
1
+ <p>Here's your login link:</p>
2
+ <p><a href="<%= locals[:link] %>">Log in</a></p>
3
+ <p>If you didn't ask for it, you can ignore this email.</p>
@@ -0,0 +1,60 @@
1
+ # Monk::Live wiring, required by both processes: config.ru (this app
2
+ # publishes updates, e.g. from a route) and bin/websocket_server (the browsers'
3
+ # sockets terminate there and are handed the updates).
4
+ #
5
+ # They are separate processes, so *something* has to carry a publish from
6
+ # one to the other. `monk new --live --postgres` (and not --redis) wired
7
+ # this file instead of the Redis-based default -- Postgres LISTEN/NOTIFY
8
+ # carries the publish instead, reusing the exact DB_* env vars
9
+ # config/persistence.rb already reads, so there's no Redis to run. The
10
+ # tradeoff: a single broadcast is capped at just under 8000 bytes
11
+ # (Monk::WebSocket::PgFanout::MAX_NOTIFY_PAYLOAD_BYTES, Postgres's own
12
+ # NOTIFY limit) -- regenerate with --redis instead if that becomes a real
13
+ # constraint. Full design in the monk gem's own docs/design/live-pg-fanout.md.
14
+ require_relative "settings"
15
+ require "monk/live"
16
+ require "monk/websocket/pg_fanout"
17
+
18
+ # Where the browser opens its WebSocket (read by the layout). In
19
+ # development there's no reverse proxy in front, so this is the direct WS
20
+ # port; outside development it defaults to a /ws path under public_url
21
+ # (config/settings.rb) -- matching the path-based proxy routing
22
+ # docs/guides/deploying.md's "Adding Monk::WebSocket" section sets up, so
23
+ # setting PUBLIC_URL alone keeps this in sync instead of two env vars
24
+ # drifting apart. Override with LIVE_WS_URL directly if your setup doesn't
25
+ # fit that shape.
26
+ default_live_ws_url =
27
+ if Monk.env.development?
28
+ "ws://localhost:9293"
29
+ else
30
+ Monk::Settings[:public_url].sub(/\Ahttps:\/\//, "wss://").sub(/\Ahttp:\/\//, "ws://") + "/ws"
31
+ end
32
+
33
+ Monk::Settings.configure do
34
+ optional :live_ws_url, default: default_live_ws_url
35
+ end
36
+
37
+ Monk::Live.configure(
38
+ registry: Monk::WebSocket::PgFanout.new(
39
+ Monk::WebSocket::Registry.new,
40
+ pg_opts: {
41
+ host: ENV.fetch("DB_HOST", "127.0.0.1"),
42
+ port: ENV.fetch("DB_PORT", "5432").to_i,
43
+ user: ENV.fetch("DB_USER", "postgres"),
44
+ password: ENV.fetch("DB_PASSWORD", "postgres"),
45
+ dbname: ENV.fetch("DB_NAME", "app_development"),
46
+ },
47
+ ),
48
+ )
49
+
50
+ # Who may subscribe to what. Nothing is allowed unless a rule says so, and an
51
+ # anonymous connection is denied unless the rule opts in with `anonymous: true`.
52
+ # The rule blocks live in a module because they have to be Ractor-shareable
53
+ # (self at the top of this file is not).
54
+ module AppLive
55
+ # The demo counter is public. A real rule looks at who is asking:
56
+ # proc { |subject, topic| topic == "contacts:#{subject}" }
57
+ PUBLIC = proc { |_subject, _topic| true }
58
+ end
59
+
60
+ Monk::Live.authorize("hits", anonymous: true, &AppLive::PUBLIC)
@@ -0,0 +1 @@
1
+ gem "net-smtp" # Monk::Mail's smtp:// transport (config/mail.rb)
@@ -0,0 +1,19 @@
1
+ require "monk"
2
+ require "monk/mail"
3
+
4
+ # Sends email with Monk::Mail.deliver(to:, subject:, text:, html:) from
5
+ # any route -- and, with --auth, the magic links config/auth.rb's AppMailer
6
+ # delivers. See docs/guides/mail.md. Required from config.ru (not from
7
+ # config/auth.rb: bin/websocket_server loads that too, and never sends mail).
8
+ Monk::Settings.configure do
9
+ # Where mail goes. Unset in development means log:// (printed to the
10
+ # console, nothing sent); anywhere else an unset one fails the boot.
11
+ # e.g. smtp://user:pass@smtp.provider.com:587 or smtp://localhost:25,
12
+ # provider examples in docs/guides/mail.md.
13
+ optional :mail_url, default: ""
14
+ # The sender of every message, e.g. "My App <no-reply@myapp.com>" --
15
+ # a domain you've verified with your mail provider (SPF, DKIM).
16
+ optional :mail_from, default: "App <no-reply@localhost>"
17
+ end
18
+
19
+ Monk::Mail.configure(url: Monk::Settings[:mail_url], from: Monk::Settings[:mail_from])
data/lib/monk/version.rb CHANGED
@@ -5,5 +5,5 @@ module Monk
5
5
  # class of bug .freeze! guards against for routes/error handlers/models,
6
6
  # just on a top-level constant that every worker Ractor reads on boot
7
7
  # (found live under kino: GET /hello 500'd until this was frozen).
8
- VERSION = "0.15.1".freeze
8
+ VERSION = "0.17.0".freeze
9
9
  end
@@ -5,5 +5,12 @@ module Monk
5
5
 
6
6
  class ProtocolError < StandardError
7
7
  end
8
+
9
+ # Raised by PgFanout#broadcast before ever calling pg_notify, rather
10
+ # than letting a raw PG::Error surface from inside it or silently
11
+ # truncating -- see docs/design/live-pg-fanout.md's payload-cap
12
+ # tradeoff.
13
+ class PayloadTooLargeError < StandardError
14
+ end
8
15
  end
9
16
  end