monkrb 0.20.0 → 0.21.1

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 (111) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +99 -0
  3. data/README.md +7 -6
  4. data/exe/monk +3 -181
  5. data/lib/monk/assets.rb +39 -9
  6. data/lib/monk/auth/errors.rb +3 -0
  7. data/lib/monk/auth/helpers.rb +11 -0
  8. data/lib/monk/auth/send_login_link.rb +36 -0
  9. data/lib/monk/auth.rb +34 -5
  10. data/lib/monk/bin.rb +107 -0
  11. data/lib/monk/cli.rb +277 -0
  12. data/lib/monk/context.rb +17 -0
  13. data/lib/monk/errors.rb +0 -6
  14. data/lib/monk/generator/definition.rb +153 -0
  15. data/lib/monk/generator/result.rb +279 -0
  16. data/lib/monk/generator/run.rb +449 -0
  17. data/lib/monk/generator.rb +48 -0
  18. data/lib/monk/generators/auth.rb +29 -0
  19. data/lib/monk/generators/base.rb +19 -0
  20. data/lib/monk/generators/jobs.rb +21 -0
  21. data/lib/monk/generators/live.rb +26 -0
  22. data/lib/monk/generators/mail.rb +24 -0
  23. data/lib/monk/generators/postgres.rb +23 -0
  24. data/lib/monk/generators/redis.rb +20 -0
  25. data/lib/monk/generators/websocket.rb +24 -0
  26. data/lib/monk/generators.rb +7 -0
  27. data/lib/monk/jobs.rb +7 -0
  28. data/lib/monk/live/helpers.rb +13 -0
  29. data/lib/monk/live.rb +12 -1
  30. data/lib/monk/log.rb +10 -1
  31. data/lib/monk/mail/deliver_later.rb +54 -0
  32. data/lib/monk/mail/later.rb +3 -51
  33. data/lib/monk/mail.rb +4 -0
  34. data/lib/monk/templates/auth/agents.md +29 -0
  35. data/lib/monk/templates/auth/app/mailers/{app_mailer.rb → auth_mailer.rb} +5 -5
  36. data/lib/monk/templates/auth/app/routes/auth.rb +48 -0
  37. data/lib/monk/templates/auth/app/views/auth/login.erb +25 -0
  38. data/lib/monk/templates/auth/config/auth.rb +8 -8
  39. data/lib/monk/templates/auth/setup.md +37 -0
  40. data/lib/monk/templates/auth/test/auth_test.rb +17 -0
  41. data/lib/monk/templates/base/CLAUDE.md +1 -0
  42. data/lib/monk/templates/base/Dockerfile +10 -5
  43. data/lib/monk/templates/base/Gemfile +7 -2
  44. data/lib/monk/templates/base/Rakefile +8 -0
  45. data/lib/monk/templates/base/agents.md +41 -0
  46. data/lib/monk/templates/base/app/app.rb +5 -0
  47. data/lib/monk/templates/base/app/routes/.keep +0 -0
  48. data/lib/monk/templates/base/app/views/index.erb +1 -1
  49. data/lib/monk/templates/base/app/views/layouts/app.erb +2 -0
  50. data/lib/monk/templates/base/config/load.rb +14 -0
  51. data/lib/monk/templates/base/config/settings.rb +4 -5
  52. data/lib/monk/templates/base/setup.md +22 -0
  53. data/lib/monk/templates/base/test/app_test.rb +12 -0
  54. data/lib/monk/templates/base/test/test_helper.rb +18 -0
  55. data/lib/monk/templates/jobs/agents.md +25 -0
  56. data/lib/monk/templates/jobs/app/routes/jobs.rb +9 -0
  57. data/lib/monk/templates/jobs/bin/jobs +19 -13
  58. data/lib/monk/templates/jobs/setup.md +30 -0
  59. data/lib/monk/templates/jobs/test/jobs_test.rb +16 -0
  60. data/lib/monk/templates/live/agents.md +25 -0
  61. data/lib/monk/templates/live/app/broadcasts/greeting.rb +13 -0
  62. data/lib/monk/templates/live/app/routes/demo_live.rb +21 -0
  63. data/lib/monk/templates/live/app/routes/live.rb +10 -0
  64. data/lib/monk/templates/live/app/views/demo/live.erb +18 -0
  65. data/lib/monk/templates/live/app/views/live/_greeting.erb +1 -0
  66. data/lib/monk/templates/live/config/live.rb +30 -32
  67. data/lib/monk/templates/live/config/live_without_demo.rb +38 -0
  68. data/lib/monk/templates/live/setup.md +39 -0
  69. data/lib/monk/templates/live/test/live_test.rb +22 -0
  70. data/lib/monk/templates/mail/agents.md +22 -0
  71. data/lib/monk/templates/mail/app/mailers/app_mailer.rb +14 -0
  72. data/lib/monk/templates/mail/app/routes/mail.rb +12 -0
  73. data/lib/monk/templates/mail/app/views/mail/welcome.erb +1 -0
  74. data/lib/monk/templates/mail/config/mail.rb +4 -4
  75. data/lib/monk/templates/mail/setup.md +32 -0
  76. data/lib/monk/templates/mail/test/mail_test.rb +13 -0
  77. data/lib/monk/templates/postgres/agents.md +25 -0
  78. data/lib/monk/templates/postgres/app/routes/postgres.rb +22 -0
  79. data/lib/monk/templates/postgres/bin/console +3 -1
  80. data/lib/monk/templates/postgres/bin/migrate +23 -17
  81. data/lib/monk/templates/postgres/bin/setup_db +11 -5
  82. data/lib/monk/templates/postgres/config/persistence.rb +1 -0
  83. data/lib/monk/templates/postgres/setup.md +38 -0
  84. data/lib/monk/templates/postgres/test/persistence_test.rb +11 -0
  85. data/lib/monk/templates/redis/agents.md +18 -0
  86. data/lib/monk/templates/redis/app/routes/redis.rb +15 -0
  87. data/lib/monk/templates/redis/config/redis.rb +13 -0
  88. data/lib/monk/templates/redis/setup.md +20 -0
  89. data/lib/monk/templates/redis/test/redis_test.rb +12 -0
  90. data/lib/monk/templates/websocket/agents.md +28 -0
  91. data/lib/monk/templates/websocket/app/sockets/chat.rb +18 -0
  92. data/lib/monk/templates/websocket/bin/websocket_server +62 -0
  93. data/lib/monk/templates/websocket/config/websocket_postgres.rb +38 -0
  94. data/lib/monk/templates/websocket/config/websocket_redis.rb +26 -0
  95. data/lib/monk/templates/websocket/setup.md +54 -0
  96. data/lib/monk/templates/websocket/test/websocket_test.rb +21 -0
  97. data/lib/monk/version.rb +1 -1
  98. data/lib/monk/websocket/server.rb +3 -1
  99. metadata +81 -14
  100. data/lib/monk/scaffold.rb +0 -1063
  101. data/lib/monk/templates/base/bin/websocket_server +0 -77
  102. data/lib/monk/templates/jobs/app/jobs/send_login_link.rb +0 -28
  103. data/lib/monk/templates/live/app/app.rb +0 -22
  104. data/lib/monk/templates/live/app/views/index.erb +0 -18
  105. data/lib/monk/templates/live/bin/websocket_server +0 -50
  106. data/lib/monk/templates/live/config/live_pg.rb +0 -60
  107. data/lib/monk/templates/mail/Gemfile.extra +0 -1
  108. data/lib/monk/templates/postgres/Dockerfile +0 -32
  109. data/lib/monk/templates/postgres/Gemfile.extra +0 -2
  110. data/lib/monk/templates/redis/Gemfile.extra +0 -1
  111. /data/lib/monk/templates/live/app/views/{live → demo}/_hits.erb +0 -0
@@ -0,0 +1,24 @@
1
+ # The WebSocket server process, over a transport between processes:
2
+ # Postgres or Redis.
3
+ Monk::Generator.define(:websocket) do
4
+ summary "WebSocket server process"
5
+ installed_if "config/websocket.rb"
6
+ option :transport, values: %w[postgres redis], dependency: true,
7
+ describe: { postgres: "LISTEN/NOTIFY, nothing else to run; 8 KB per broadcast",
8
+ redis: "no size limit; Redis must be running", }
9
+
10
+ copy "config/websocket.rb", role: :wiring,
11
+ from: ->(options) { "websocket/config/websocket_#{options[:transport]}.rb" }
12
+ copy "bin/websocket_server", role: :wiring, executable: true
13
+ copy "app/sockets/chat.rb", role: :example
14
+ copy "test/websocket_test.rb", role: :test
15
+
16
+ env :example, { "WS_PORT" => "9293" }
17
+ env :example, { "WS_ALLOWED_ORIGINS" => "https://example.com" }, commented: true
18
+
19
+ setup_section "websocket/setup.md"
20
+ agents_section "websocket/agents.md"
21
+ set_before_production "WS_ALLOWED_ORIGINS", "defaults to PUBLIC_URL; list every origin your pages use"
22
+ next_step "bin/websocket_server", note: "beside bin/server"
23
+ example "websocket-chat", todo: "uncomment for one chat room, or write your own handler in app/sockets/"
24
+ end
@@ -0,0 +1,7 @@
1
+ require_relative "generator"
2
+
3
+ # Monk's own modules, one generator each (docs/adr/0017), in dependency
4
+ # order only for readability: the registry resolves dependencies itself.
5
+ %w[base postgres redis mail auth jobs websocket live].each do |name|
6
+ require_relative "generators/#{name}"
7
+ end
data/lib/monk/jobs.rb CHANGED
@@ -227,3 +227,10 @@ module Monk
227
227
  end
228
228
 
229
229
  require_relative "jobs/job"
230
+
231
+ # What jobs provide together with another module, when that module is
232
+ # loaded too -- either order works (lib/monk/mail.rb and lib/monk/auth.rb
233
+ # end the same way), so an app's config files never require these
234
+ # themselves (docs/adr/0017).
235
+ require_relative "mail/deliver_later" if Monk.const_defined?(:Mail)
236
+ require_relative "auth/send_login_link" if Monk.const_defined?(:Auth)
@@ -17,6 +17,19 @@ module Monk
17
17
 
18
18
  raw(%(data-live-topic="#{names.join(" ")}"))
19
19
  end
20
+
21
+ # For the layout's <%= monk_head %>: where the client opens its
22
+ # WebSocket (live_ws_url, which config/live.rb declares) and the
23
+ # client runtime itself. Nothing until Live is configured, i.e. until
24
+ # config/live.rb has run.
25
+ def head_tags
26
+ return super unless Monk::Live.registry
27
+
28
+ super + [
29
+ %(<meta name="monk-live-url" content="#{h(settings[:live_ws_url])}">),
30
+ %(<script type="module" src="#{h(asset_path("#{Live::CLIENT_PATH}/monk_live.js"))}"></script>),
31
+ ]
32
+ end
20
33
  end
21
34
  end
22
35
  end
data/lib/monk/live.rb CHANGED
@@ -35,14 +35,25 @@ module Monk
35
35
  ).run
36
36
  end
37
37
 
38
+ # A rule block that lets everyone in, for Monk::Live.authorize:
39
+ # Monk::Live.authorize("demo:hits", anonymous: true, &Monk::Live::ALLOW_ALL)
40
+ # A block written at the top of config/live.rb isn't Ractor-shareable
41
+ # (self there is the main object), so rules need one built here.
42
+ ALLOW_ALL = Ractor.make_shareable(proc { |_subject, _topic| true })
43
+
38
44
  CLIENT_DIR = File.expand_path("live/client", __dir__).freeze
45
+ # Where Monk serves CLIENT_DIR from: the layout's <%= monk_head %> loads
46
+ # CLIENT_PATH/monk_live.js (Live::Helpers#head_tags), so an app never
47
+ # copies the client and never serves an older copy than its gem.
48
+ CLIENT_PATH = "/_monk/live".freeze
49
+ Monk::Assets.mount(CLIENT_PATH, CLIENT_DIR)
39
50
 
40
51
  class << self
41
52
  attr_reader :publisher, :registry, :rules, :max_topics
42
53
 
43
54
  # Where the browser runtime ships inside the gem: monk_live.js,
44
55
  # protocol.js and idiomorph.js (they import each other by relative
45
- # path, so an app serves or copies the directory as a unit).
56
+ # path, so they're served as a unit, from CLIENT_PATH).
46
57
  def client_dir = CLIENT_DIR
47
58
 
48
59
  # Boot-time, main Ractor. `registry` is a Monk::WebSocket::Registry,
data/lib/monk/log.rb CHANGED
@@ -118,7 +118,16 @@ module Monk
118
118
  end
119
119
 
120
120
  def handle
121
- Ractor.current[:monk_log_handle] ||= File.open(@path, "a")
121
+ Ractor.current[:monk_log_handle] ||= File.open(@path || path_before_boot, "a")
122
+ end
123
+
124
+ # Before Boot -- bin/console, a script that loads config/load.rb and
125
+ # never boots -- nothing has frozen the path yet. Worked out here
126
+ # instead, the way #freeze_registry! does: only the main Ractor runs
127
+ # before Boot, so reading Monk.env and writing log/ is safe.
128
+ def path_before_boot
129
+ FileUtils.mkdir_p(root)
130
+ File.join(root, "#{Monk.env}.log")
122
131
  end
123
132
  end
124
133
 
@@ -0,0 +1,54 @@
1
+ # Monk::Mail.deliver_later and its job. Loaded at the end of whichever of
2
+ # monk/mail and monk/jobs is required second, once both are defined
3
+ # (docs/adr/0017), so it requires neither itself.
4
+
5
+ module Monk
6
+ module Mail
7
+ # Sends one message from a job process (docs/adr/0014-mail-from-jobs-and-login-links-created-in-the-job.md).
8
+ # Enqueued by Monk::Mail.deliver_later, never directly: its argument
9
+ # is the message's fields, already checked and with from: resolved.
10
+ class DeliveryJob < Monk::Job
11
+ queue "mailers"
12
+ never_retry PermanentDeliveryError
13
+
14
+ # The SMTP failures that are final, by class name so this file never
15
+ # loads net/smtp itself (the app's Gemfile decides that, ADR 0012):
16
+ # a 5xx reply, a malformed command, refused credentials. Everything
17
+ # else -- a 4xx "try later", a refused connection, TLS, a timeout --
18
+ # is worth retrying.
19
+ PERMANENT_SMTP_ERRORS = %w[Net::SMTPFatalError Net::SMTPSyntaxError Net::SMTPAuthenticationError].freeze
20
+
21
+ def self.perform(fields)
22
+ Monk::Mail.deliver(**fields.transform_keys(&:to_sym))
23
+ rescue DeliveryError => e
24
+ raise unless PERMANENT_SMTP_ERRORS.include?(e.cause&.class&.name)
25
+
26
+ raise PermanentDeliveryError, e.message
27
+ end
28
+ end
29
+
30
+ class << self
31
+ # deliver, later: the same arguments, plus enqueue's wait:, at: and
32
+ # conn: (the last one enqueues inside the caller's transaction, so the
33
+ # email commits or rolls back with the app's own writes). Returns the
34
+ # job's id. The message is built -- and so checked -- here, before
35
+ # anything is enqueued: bad input raises InvalidMessageError in the
36
+ # caller, exactly as deliver would, rather than failing later in the
37
+ # job process. Render HTML before calling (html: Monk::Mail.render(...)):
38
+ # the job stores the finished message and only sends it.
39
+ #
40
+ # Monk::Mail.deliver_later(to: order[:email], subject: "Your receipt", text: receipt_text)
41
+ def deliver_later(to:, subject:, text: nil, html: nil, reply_to: nil, from: nil, wait: nil, at: nil, conn: nil)
42
+ raise NotConfiguredError, "call Monk::Mail.configure(url:, from:) before Monk::Mail.deliver_later" unless config
43
+
44
+ from ||= config[:from]
45
+ if from.nil?
46
+ raise InvalidMessageError, "no from: given, and Monk::Mail.configure has no default from: (MAIL_FROM)"
47
+ end
48
+
49
+ message = Message.new(from: from, to: to, subject: subject, text: text, html: html, reply_to: reply_to)
50
+ DeliveryJob.enqueue(message.to_h.transform_keys(&:to_s), wait: wait, at: at, conn: conn)
51
+ end
52
+ end
53
+ end
54
+ end
@@ -1,53 +1,5 @@
1
+ # Monk::Mail.deliver_later (docs/adr/0014). Loading monk/mail and
2
+ # monk/jobs is enough -- the second one loads it (docs/adr/0017) -- so
3
+ # this file only exists for apps whose config/jobs.rb still requires it.
1
4
  require_relative "../mail"
2
5
  require_relative "../jobs"
3
-
4
- module Monk
5
- module Mail
6
- # Sends one message from a job process (docs/adr/0014-mail-from-jobs-and-login-links-created-in-the-job.md).
7
- # Enqueued by Monk::Mail.deliver_later, never directly: its argument
8
- # is the message's fields, already checked and with from: resolved.
9
- class DeliveryJob < Monk::Job
10
- queue "mailers"
11
- never_retry PermanentDeliveryError
12
-
13
- # The SMTP failures that are final, by class name so this file never
14
- # loads net/smtp itself (the app's Gemfile decides that, ADR 0012):
15
- # a 5xx reply, a malformed command, refused credentials. Everything
16
- # else -- a 4xx "try later", a refused connection, TLS, a timeout --
17
- # is worth retrying.
18
- PERMANENT_SMTP_ERRORS = %w[Net::SMTPFatalError Net::SMTPSyntaxError Net::SMTPAuthenticationError].freeze
19
-
20
- def self.perform(fields)
21
- Monk::Mail.deliver(**fields.transform_keys(&:to_sym))
22
- rescue DeliveryError => e
23
- raise unless PERMANENT_SMTP_ERRORS.include?(e.cause&.class&.name)
24
-
25
- raise PermanentDeliveryError, e.message
26
- end
27
- end
28
-
29
- class << self
30
- # deliver, later: the same arguments, plus enqueue's wait:, at: and
31
- # conn: (the last one enqueues inside the caller's transaction, so the
32
- # email commits or rolls back with the app's own writes). Returns the
33
- # job's id. The message is built -- and so checked -- here, before
34
- # anything is enqueued: bad input raises InvalidMessageError in the
35
- # caller, exactly as deliver would, rather than failing later in the
36
- # job process. Render HTML before calling (html: Monk::Mail.render(...)):
37
- # the job stores the finished message and only sends it.
38
- #
39
- # Monk::Mail.deliver_later(to: order[:email], subject: "Your receipt", text: receipt_text)
40
- def deliver_later(to:, subject:, text: nil, html: nil, reply_to: nil, from: nil, wait: nil, at: nil, conn: nil)
41
- raise NotConfiguredError, "call Monk::Mail.configure(url:, from:) before Monk::Mail.deliver_later" unless config
42
-
43
- from ||= config[:from]
44
- if from.nil?
45
- raise InvalidMessageError, "no from: given, and Monk::Mail.configure has no default from: (MAIL_FROM)"
46
- end
47
-
48
- message = Message.new(from: from, to: to, subject: subject, text: text, html: html, reply_to: reply_to)
49
- DeliveryJob.enqueue(message.to_h.transform_keys(&:to_s), wait: wait, at: at, conn: conn)
50
- end
51
- end
52
- end
53
- end
data/lib/monk/mail.rb CHANGED
@@ -119,3 +119,7 @@ module Monk
119
119
  Monk.freeze_hooks << self
120
120
  end
121
121
  end
122
+
123
+ # Monk::Mail.deliver_later, when jobs are loaded too -- either order works
124
+ # (lib/monk/jobs.rb ends the same way, docs/adr/0017).
125
+ require_relative "mail/deliver_later" if Monk.const_defined?(:Jobs)
@@ -0,0 +1,29 @@
1
+ ## auth
2
+
3
+ **Where:** `config/auth.rb` (settings, `callback_path:`),
4
+ `app/routes/auth.rb` (the routes, commented: block `auth-routes`),
5
+ `app/mailers/auth_mailer.rb` (sends the link), `app/views/auth/login.erb`
6
+ (the login page), `app/views/mail/magic_link.erb` (the email's HTML).
7
+
8
+ **Main calls:** `Monk::Auth.request_login(email)` returns a token;
9
+ `Monk::Auth.login_link(token)` builds its link;
10
+ `Monk::Auth.deliver_link(email:, link:, token:)` sends it;
11
+ `Monk::Auth.redeem(token)` returns a session or nil. In routes:
12
+ `set_session_cookie(session)`, `current_subject` (nil when logged out),
13
+ `require_user!` (halts 401), `require_csrf!`, `log_out!`. With jobs:
14
+ `Monk::Auth::SendLoginLink.enqueue(email)`.
15
+
16
+ **Test:** `test/auth_test.rb` sends a link to `log/test.log` and redeems it.
17
+
18
+ **Pitfalls:**
19
+ - `/auth/request` needs a per-email rate limit before it goes live: without
20
+ one, anyone can make the app send mail to any address.
21
+ - Never point `deliver:` at `Monk::Mail.deliver_later`: the link, token
22
+ included, would sit in the job queue. Use `SendLoginLink` instead.
23
+ - Links come from `Monk::Settings[:public_url]`, never from request headers.
24
+ - A `redirect_to` must be listed in `redirect_allowlist`.
25
+ - Monk reads `params` from JSON bodies and query strings, not form bodies:
26
+ pages post with `fetch`. A cookie-authenticated POST sends the
27
+ `x-csrf-token` header (the `csrf_token` cookie's value).
28
+
29
+ **Examples:** `auth-routes` in `app/routes/auth.rb`.
@@ -1,9 +1,9 @@
1
- # The emails this app sends, through Monk::Mail (config/mail.rb). Module
2
- # constants and module methods rather than an instance, since Monk::Auth's
3
- # deliver: hook must be Ractor-shareable (a lambda built at a file's top
4
- # level, where self isn't, would fail) -- the same constraint as
1
+ # The login email Monk::Auth sends (config/auth.rb's deliver:), through
2
+ # Monk::Mail (config/mail.rb). A lambda in a module constant: the deliver:
3
+ # hook must be Ractor-shareable, and a lambda built at a file's top level,
4
+ # where self isn't, would fail -- the same constraint as
5
5
  # Monk::Live.authorize blocks.
6
- module AppMailer
6
+ module AuthMailer
7
7
  # Monk::Auth's deliver: hook (config/auth.rb), called by
8
8
  # Monk::Auth.deliver_link. The HTML part is
9
9
  # app/views/mail/magic_link.erb. Swap the body for SMS or any other
@@ -0,0 +1,48 @@
1
+ # Logging in by email link (monk add auth). Monk::Auth makes and checks the
2
+ # tokens and sessions; these routes are the app's own, because they carry
3
+ # its decisions: the rate limit, where to go after logging in, JSON or HTML.
4
+ #
5
+ # monk:example auth-routes -- log in by email link, log out, a page for logged-in users.
6
+ # # Before going live: keep a rate limit (without one, anyone can make this
7
+ # # app send mail to any address), and list your redirects in config/auth.rb.
8
+ # module AuthRoutes
9
+ # # Per process and approximate: 5 links per address per 10 minutes. With
10
+ # # several bin/server processes, count in the database instead.
11
+ # LIMIT = Monk::Auth::RateLimiter.new(limit: 5, window: 600)
12
+ # end
13
+ #
14
+ # class App
15
+ # get("/login") { @title = "Log in"; render "auth/login" }
16
+ #
17
+ # # app/views/auth/login.erb sends {"email": "..."} as JSON.
18
+ # post("/auth/request") do
19
+ # email = params[:email].to_s.strip.downcase
20
+ # halt(422) if email.empty?
21
+ # halt(429) if AuthRoutes::LIMIT.exceeded?(email)
22
+ #
23
+ # # Sent here, in the request. With jobs, replace these two lines with
24
+ # # Monk::Auth::SendLoginLink.enqueue(email): the token is then made and
25
+ # # sent in bin/jobs, and never stored.
26
+ # token = Monk::Auth.request_login(email)
27
+ # Monk::Auth.deliver_link(email: email, link: Monk::Auth.login_link(token), token: token)
28
+ # json(sent: true)
29
+ # end
30
+ #
31
+ # # The link in the email: callback_path in config/auth.rb.
32
+ # get("/auth/callback/:token") do
33
+ # session = Monk::Auth.redeem(params[:token]) || halt(401, "This link has expired or was already used.")
34
+ # set_session_cookie(session)
35
+ # redirect(session[:redirect_to] || "/")
36
+ # end
37
+ #
38
+ # # From a page: fetch("/auth/logout", { method: "POST", headers: { "x-csrf-token": csrfToken } }),
39
+ # # where csrfToken is the csrf_token cookie's value.
40
+ # post("/auth/logout") do
41
+ # require_csrf!
42
+ # log_out!
43
+ # json(logged_out: true)
44
+ # end
45
+ #
46
+ # get("/me") { json(email: require_user!) }
47
+ # end
48
+ # monk:end
@@ -0,0 +1,25 @@
1
+ <h1>Log in</h1>
2
+
3
+ <%# Sent with fetch and a JSON body: Monk reads params from JSON bodies,
4
+ not form bodies. POST /auth/request is in app/routes/auth.rb. %>
5
+ <form id="login">
6
+ <label>Email <input type="email" name="email" required autocomplete="email"></label>
7
+ <button>Send me a login link</button>
8
+ </form>
9
+ <p id="login-sent" hidden>Check your email for the link.</p>
10
+
11
+ <script type="module">
12
+ const form = document.getElementById("login")
13
+ form.addEventListener("submit", async (event) => {
14
+ event.preventDefault()
15
+ const response = await fetch("/auth/request", {
16
+ method: "POST",
17
+ headers: { "content-type": "application/json" },
18
+ body: JSON.stringify({ email: form.email.value }),
19
+ })
20
+ if (response.ok) {
21
+ form.hidden = true
22
+ document.getElementById("login-sent").hidden = false
23
+ }
24
+ })
25
+ </script>
@@ -2,17 +2,16 @@ require "monk"
2
2
  require "monk/auth"
3
3
  require_relative "persistence"
4
4
 
5
- # config/settings.rb already declares public_url -- the trusted origin for
6
- # the magic link your own login route builds (e.g.
7
- # "#{Monk::Settings[:public_url]}/auth/callback/#{token}"), see its comment
8
- # there and auth.md's "Sending the magic link".
5
+ # Magic links are Monk::Auth.login_link(token): config/settings.rb's
6
+ # public_url (the trusted origin), then callback_path: below, then the
7
+ # token -- see auth.md's "Sending the magic link".
9
8
 
10
- # Sends the magic link by email: AppMailer::MAGIC_LINK, in
11
- # app/mailers/app_mailer.rb. Required here, not left to config/load.rb:
9
+ # Sends the magic link by email: AuthMailer::MAGIC_LINK, in
10
+ # app/mailers/auth_mailer.rb. Required here, not left to config/load.rb:
12
11
  # Monk::Auth.configure checks deliver: right away, and bin/websocket_server
13
12
  # loads this file without config/load.rb. The mailer only touches
14
13
  # Monk::Mail when it's called, so that process needs no mail config.
15
- require_relative "../app/mailers/app_mailer"
14
+ require_relative "../app/mailers/auth_mailer"
16
15
 
17
16
  Monk::Auth.configure(
18
17
  db_name: :primary,
@@ -20,8 +19,9 @@ Monk::Auth.configure(
20
19
  login_ttl: 600, # seconds a login token stays redeemable
21
20
  session_ttl: 1_209_600, # seconds a session stays valid (14 days)
22
21
  redirect_allowlist: [], # paths request_login(redirect_to:) is allowed to target
22
+ callback_path: "/auth/callback", # your route redeeming a token: GET "#{callback_path}/:token"
23
23
  secure: !Monk.env.development?, # Secure cookie flag; off in dev so plain-http testing works (Safari drops it)
24
- deliver: AppMailer::MAGIC_LINK,
24
+ deliver: AuthMailer::MAGIC_LINK,
25
25
  )
26
26
 
27
27
  # bin/websocket_server checks each socket's session through this pool, so
@@ -0,0 +1,37 @@
1
+ ## auth
2
+
3
+ Logging in by email link. `config/auth.rb` configures Monk::Auth,
4
+ `app/mailers/auth_mailer.rb` sends the link through mail, and a migration
5
+ creates the `login_tokens` and `sessions` tables: run `bin/setup_db` and
6
+ `DB_NAME={{app}}_test bin/setup_db` (postgres, above).
7
+
8
+ ### Before production
9
+
10
+ `AUTH_SECRET` in `.env` and `.env.test` is a placeholder. Set a long random
11
+ one in production's environment, e.g. from
12
+ `ruby -rsecurerandom -e 'puts SecureRandom.hex(32)'`. `config/auth.rb`
13
+ reads it with `ENV.fetch`: unset, the app doesn't boot.
14
+
15
+ ### Check it works
16
+
17
+ 1. `bundle exec rake test` runs `test/auth_test.rb`: a link is sent (to
18
+ `log/test.log`) and redeemed into a session.
19
+ 2. By hand: the login routes are commented out in `app/routes/auth.rb`,
20
+ block `auth-routes`. Uncomment them and restart `bin/server`. Then:
21
+ - http://localhost:9292/me answers `401`: nobody is logged in.
22
+ - http://localhost:9292/login: enter an email. In development the link
23
+ is printed, not sent: in `bin/server`'s console, or in `bin/jobs`'
24
+ when a job sends it (`Monk::Auth::SendLoginLink`), and in
25
+ `log/development.log` either way. Open it: it logs you in and goes
26
+ back to `/`.
27
+ - http://localhost:9292/me now shows your email.
28
+ - To log out, from the browser's console on any page of the app:
29
+
30
+ ```js
31
+ fetch("/auth/logout", { method: "POST", headers: {
32
+ "x-csrf-token": document.cookie.match(/csrf_token=([^;]+)/)[1] } })
33
+ ```
34
+
35
+ and `/me` answers `401` again.
36
+ 3. Before going live: keep the rate limit, and list in `config/auth.rb`
37
+ (`redirect_allowlist`) the paths a login may send the user back to.
@@ -0,0 +1,17 @@
1
+ require_relative "test_helper"
2
+
3
+ # Monk::Auth works end to end without any route: a login link is created,
4
+ # sent by config/auth.rb's deliver: (AuthMailer, to log/test.log here), and
5
+ # redeemed into a session. SETUP.md, auth.
6
+ class AuthTest < Minitest::Test
7
+ def test_a_login_link_is_sent_and_redeems_into_a_session
8
+ email = "login-check-#{rand(1_000_000)}@example.test"
9
+ token = Monk::Auth.request_login(email)
10
+
11
+ Monk::Auth.deliver_link(email: email, link: Monk::Auth.login_link(token), token: token)
12
+
13
+ assert_includes File.read(File.expand_path("../log/test.log", __dir__)), Monk::Auth.login_link(token)
14
+ session = Monk::Auth.redeem(token)
15
+ assert_equal email, Monk::Auth.verify(session[:token])
16
+ end
17
+ end
@@ -0,0 +1 @@
1
+ @AGENTS.md
@@ -1,15 +1,19 @@
1
1
  # Two-stage build: the builder resolves and installs gems, the final image
2
2
  # only keeps the installed bundle plus the app -- no compiler, no gem
3
- # cache, in the image that actually runs. No system packages needed here:
4
- # kino ships as precompiled platform gems, and a plain `bundle install`
3
+ # cache, in the image that actually runs. The builder has a compiler for
4
+ # the gems that build a native extension (irb's dependencies, with monk
5
+ # add postgres); kino ships as precompiled platform gems, and pg 1.6+
6
+ # ships precompiled with its own libpq. A plain `bundle install`
5
7
  # (Gemfile.lock committed) already resolves every compatible platform, not
6
8
  # just the machine that ran it -- nothing to add by hand before building on
7
9
  # a different host (docs/guides/deploying.md, "Before the first build").
8
- # monk/rackup/webrick are pure Ruby. The --postgres/--auth scaffold
9
- # overrides this file to add libpq for the pg gem's native extension.
10
10
  FROM ruby:4.0-slim AS builder
11
11
  WORKDIR /app
12
12
 
13
+ RUN apt-get update \
14
+ && apt-get install -y --no-install-recommends build-essential \
15
+ && rm -rf /var/lib/apt/lists/*
16
+
13
17
  COPY Gemfile Gemfile.lock* ./
14
18
  RUN bundle install
15
19
 
@@ -23,6 +27,7 @@ COPY . .
23
27
  # platform that assigns its own). bin/websocket_server (WS_PORT default
24
28
  # 9293) is a separate process/deploy unit -- run this same image with
25
29
  # `bin/websocket_server` as the command instead of publishing another
26
- # port from this Dockerfile -- see docs/guides/deploying.md.
30
+ # port from this Dockerfile -- see docs/guides/deploying.md. Every other
31
+ # process in bin/ (bin/jobs, say) is the same: this image, that command.
27
32
  EXPOSE 9292
28
33
  CMD ["bin/server", "--bind", "0.0.0.0"]
@@ -1,7 +1,12 @@
1
1
  source "https://rubygems.org"
2
2
 
3
- gem "monkrb", require: "monk"
3
+ gem "monkrb", "~> 0.21", require: "monk"
4
4
  gem "rackup"
5
5
  gem "webrick"
6
6
  gem "kino"
7
- # gem "dotenv" # uncomment to load a local .env file (config/settings.rb)
7
+ gem "dotenv" # loads .env files, see config/settings.rb
8
+
9
+ group :test do
10
+ gem "minitest"
11
+ gem "rake"
12
+ end
@@ -0,0 +1,8 @@
1
+ require "rake/testtask"
2
+
3
+ Rake::TestTask.new do |t|
4
+ t.libs << "test"
5
+ t.pattern = "test/**/*_test.rb"
6
+ end
7
+
8
+ task default: :test
@@ -0,0 +1,41 @@
1
+ # {{app}}: notes for coding agents
2
+
3
+ A Monk app (Ruby, Ractor-based). Read this before changing code.
4
+
5
+ ## Commands
6
+
7
+ - `bin/server`: the app on :9292.
8
+ - `bundle exec rake test`: all tests. Run them after every change.
9
+ - `monk add --list --json`: the modules available, and which are installed.
10
+ - `monk add <module> --json`: add a module. Never wire one by hand in
11
+ `config/load.rb`: it loads every module's config already.
12
+
13
+ ## Where code goes
14
+
15
+ | Path | What |
16
+ |---|---|
17
+ | `app/app.rb` | `class App`, its base routes; loads `app/routes/*.rb` |
18
+ | `app/routes/<name>.rb` | more routes, each file reopening `class App` |
19
+ | `app/models/` `presenters/` `helpers/` `mailers/` `broadcasts/` `jobs/` | code by role, loaded in that order by `config/load.rb` |
20
+ | `app/views/` | `.erb` templates only, the one template root |
21
+ | `config/<module>.rb` | one per Monk module, loaded by `config/load.rb` in a fixed order |
22
+ | `test/` | Minitest; `test/test_helper.rb` boots the app as `APP` |
23
+
24
+ ## Rules that break the app if ignored
25
+
26
+ - Everything loads before `Monk.boot` freezes the app. Nothing can be
27
+ required later, from a route or a job.
28
+ - Routes, jobs and Live rules run in Ractors. A block they use must be
29
+ Ractor-shareable: define it inside a module or class body, never at the
30
+ top level of a file (`self` there is the main object).
31
+ - State shared across requests lives in a `Monk::StateRactor`, never in a
32
+ constant or a class variable.
33
+ - Templates escape what they print. `raw(...)` is only for HTML the app
34
+ built itself.
35
+
36
+ ## Examples
37
+
38
+ Commented examples are tagged `# monk:example <name>` ... `# monk:end`.
39
+ `grep -rn "monk:example" app config` lists them; uncomment one and adapt it.
40
+
41
+ ## Modules
@@ -7,3 +7,8 @@ class App < Monk::Base
7
7
  get("/hello") { "hello from monk" }
8
8
  get("/api/hello") { json(message: "hello from monk") }
9
9
  end
10
+
11
+ # More routes, each file reopening `class App`: the ones Monk modules add
12
+ # (monk add), and any of your own. Loaded in name order, before Monk.boot
13
+ # freezes the app.
14
+ Dir[File.expand_path("routes/*.rb", __dir__)].sort.each { |file| require file }
File without changes
@@ -1,6 +1,6 @@
1
1
  <h1>It works</h1>
2
2
  <p>
3
- Edit <code>views/index.erb</code> and restart the server to see this
3
+ Edit <code>app/views/index.erb</code> and restart the server to see this
4
4
  change. Templates compile once at boot; <code>&lt;%= %&gt;</code>
5
5
  escapes by default, <code>raw(...)</code> opts out.
6
6
  </p>
@@ -11,6 +11,8 @@
11
11
  { "imports": {} }
12
12
  </script>
13
13
  <script type="module" src="<%= asset_path "/js/app.js" %>"></script>
14
+ <%# The <head> tags of the Monk modules this app uses (Monk::Live's, say). %>
15
+ <%= monk_head %>
14
16
  </head>
15
17
  <body>
16
18
  <main><%= yield %></main>
@@ -3,6 +3,14 @@
3
3
  # it never boots the app itself (that's config.ru's Monk.boot).
4
4
  require_relative "settings"
5
5
 
6
+ # Each Monk module's config, if this app has it (`monk add <module>` writes
7
+ # it), in Monk's fixed order: a module's config may rely on the ones
8
+ # before it. Nothing to edit here when a module is added.
9
+ %w[persistence redis auth mail jobs websocket live storage].each do |name|
10
+ path = File.expand_path("#{name}.rb", __dir__)
11
+ require path if File.exist?(path)
12
+ end
13
+
6
14
  # The app's code, one directory per role, in the order they call each
7
15
  # other: presenters read models, helpers, mailers and broadcasts use both,
8
16
  # and jobs call all of them. Everything is loaded here, before Monk.boot
@@ -16,3 +24,9 @@ require_relative "settings"
16
24
  %w[models presenters helpers mailers broadcasts jobs].each do |role|
17
25
  Dir[File.expand_path("../app/#{role}/**/*.rb", __dir__)].sort.each { |file| require file }
18
26
  end
27
+
28
+ # With websocket (monk add websocket), every process that loads this file
29
+ # can broadcast (bin/server, bin/jobs, bin/console, the tests), so its
30
+ # transport is started here, once everything above has declared its
31
+ # databases.
32
+ AppWebSocket.start! if defined?(AppWebSocket)
@@ -1,8 +1,7 @@
1
- # Loads a local .env file in development, if the app has uncommented the
2
- # dotenv gem in its Gemfile (see the commented-out line there) and added
3
- # a .env file of its own. A missing .env, or the gem not being in the
4
- # bundle at all, is a harmless no-op either way -- production deploys
5
- # get their env vars from the hosting platform directly, not from here.
1
+ # Loads a local .env file, written by the modules `monk add` adds (DB_*,
2
+ # MAIL_*, ...). A missing .env, or the dotenv gem not being in the bundle
3
+ # at all, is a harmless no-op either way -- production deploys get their
4
+ # env vars from the hosting platform directly, not from here.
6
5
  begin
7
6
  require "dotenv/load"
8
7
  rescue LoadError
@@ -0,0 +1,22 @@
1
+ # Setting up {{app}}
2
+
3
+ ## First run
4
+
5
+ ```bash
6
+ bundle install
7
+ bin/server # the app on http://localhost:9292
8
+ ```
9
+
10
+ ## Check it works
11
+
12
+ 1. `bundle exec rake test` runs the tests.
13
+ 2. By hand: with `bin/server` running, http://localhost:9292 shows
14
+ "It works", and http://localhost:9292/hello answers `hello from monk`.
15
+
16
+ `test/test_helper.rb` loads `.env.test` (if there is one), then the app the
17
+ way `config.ru` does, booted once as `APP`; `test/app_test.rb` makes a
18
+ request through it. Minitest, like Monk's own suite.
19
+
20
+ Each module added with `monk add` appends its own section below: the
21
+ services it needs, how to start them, and how to check it works, with its
22
+ test and by hand.