monkrb 0.19.0 → 0.21.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (127) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +166 -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/base.rb +20 -0
  11. data/lib/monk/bin.rb +107 -0
  12. data/lib/monk/cli.rb +277 -0
  13. data/lib/monk/context.rb +17 -0
  14. data/lib/monk/errors.rb +0 -6
  15. data/lib/monk/generator/definition.rb +153 -0
  16. data/lib/monk/generator/result.rb +279 -0
  17. data/lib/monk/generator/run.rb +449 -0
  18. data/lib/monk/generator.rb +48 -0
  19. data/lib/monk/generators/auth.rb +29 -0
  20. data/lib/monk/generators/base.rb +19 -0
  21. data/lib/monk/generators/jobs.rb +21 -0
  22. data/lib/monk/generators/live.rb +26 -0
  23. data/lib/monk/generators/mail.rb +24 -0
  24. data/lib/monk/generators/postgres.rb +23 -0
  25. data/lib/monk/generators/redis.rb +20 -0
  26. data/lib/monk/generators/websocket.rb +24 -0
  27. data/lib/monk/generators.rb +7 -0
  28. data/lib/monk/jobs/adapters/pg.rb +3 -1
  29. data/lib/monk/jobs.rb +7 -0
  30. data/lib/monk/live/helpers.rb +13 -0
  31. data/lib/monk/live/policy.rb +39 -4
  32. data/lib/monk/live/session.rb +7 -0
  33. data/lib/monk/live.rb +17 -4
  34. data/lib/monk/log.rb +10 -1
  35. data/lib/monk/mail/deliver_later.rb +54 -0
  36. data/lib/monk/mail/later.rb +3 -51
  37. data/lib/monk/mail.rb +4 -0
  38. data/lib/monk/persistence/errors.rb +32 -0
  39. data/lib/monk/persistence/pg.rb +92 -1
  40. data/lib/monk/persistence/pool.rb +513 -0
  41. data/lib/monk/persistence.rb +196 -7
  42. data/lib/monk/templates/auth/agents.md +29 -0
  43. data/lib/monk/templates/auth/app/mailers/{app_mailer.rb → auth_mailer.rb} +5 -5
  44. data/lib/monk/templates/auth/app/routes/auth.rb +48 -0
  45. data/lib/monk/templates/auth/app/views/auth/login.erb +25 -0
  46. data/lib/monk/templates/auth/config/auth.rb +14 -8
  47. data/lib/monk/templates/auth/setup.md +37 -0
  48. data/lib/monk/templates/auth/test/auth_test.rb +17 -0
  49. data/lib/monk/templates/base/CLAUDE.md +1 -0
  50. data/lib/monk/templates/base/Dockerfile +4 -3
  51. data/lib/monk/templates/base/Gemfile +7 -2
  52. data/lib/monk/templates/base/Rakefile +8 -0
  53. data/lib/monk/templates/base/agents.md +41 -0
  54. data/lib/monk/templates/base/app/app.rb +5 -0
  55. data/lib/monk/templates/base/app/routes/.keep +0 -0
  56. data/lib/monk/templates/base/app/views/index.erb +1 -1
  57. data/lib/monk/templates/base/app/views/layouts/app.erb +2 -0
  58. data/lib/monk/templates/base/config/load.rb +8 -0
  59. data/lib/monk/templates/base/config/settings.rb +4 -5
  60. data/lib/monk/templates/base/setup.md +22 -0
  61. data/lib/monk/templates/base/test/app_test.rb +12 -0
  62. data/lib/monk/templates/base/test/test_helper.rb +18 -0
  63. data/lib/monk/templates/jobs/agents.md +25 -0
  64. data/lib/monk/templates/jobs/app/routes/jobs.rb +9 -0
  65. data/lib/monk/templates/jobs/bin/jobs +19 -13
  66. data/lib/monk/templates/jobs/setup.md +30 -0
  67. data/lib/monk/templates/jobs/test/jobs_test.rb +16 -0
  68. data/lib/monk/templates/live/agents.md +25 -0
  69. data/lib/monk/templates/live/app/broadcasts/greeting.rb +13 -0
  70. data/lib/monk/templates/live/app/routes/demo_live.rb +21 -0
  71. data/lib/monk/templates/live/app/routes/live.rb +10 -0
  72. data/lib/monk/templates/live/app/views/demo/live.erb +18 -0
  73. data/lib/monk/templates/live/app/views/live/_greeting.erb +1 -0
  74. data/lib/monk/templates/live/config/live.rb +30 -32
  75. data/lib/monk/templates/live/config/live_without_demo.rb +38 -0
  76. data/lib/monk/templates/live/setup.md +39 -0
  77. data/lib/monk/templates/live/test/live_test.rb +22 -0
  78. data/lib/monk/templates/mail/agents.md +22 -0
  79. data/lib/monk/templates/mail/app/mailers/app_mailer.rb +14 -0
  80. data/lib/monk/templates/mail/app/routes/mail.rb +12 -0
  81. data/lib/monk/templates/mail/app/views/mail/welcome.erb +1 -0
  82. data/lib/monk/templates/mail/config/mail.rb +4 -4
  83. data/lib/monk/templates/mail/setup.md +32 -0
  84. data/lib/monk/templates/mail/test/mail_test.rb +13 -0
  85. data/lib/monk/templates/postgres/agents.md +25 -0
  86. data/lib/monk/templates/postgres/app/routes/postgres.rb +22 -0
  87. data/lib/monk/templates/postgres/bin/console +3 -1
  88. data/lib/monk/templates/postgres/bin/migrate +23 -17
  89. data/lib/monk/templates/postgres/bin/setup_db +11 -5
  90. data/lib/monk/templates/postgres/config/persistence.rb +1 -0
  91. data/lib/monk/templates/postgres/db/migrate/.keep +0 -0
  92. data/lib/monk/templates/postgres/setup.md +38 -0
  93. data/lib/monk/templates/postgres/test/persistence_test.rb +11 -0
  94. data/lib/monk/templates/redis/agents.md +18 -0
  95. data/lib/monk/templates/redis/app/routes/redis.rb +15 -0
  96. data/lib/monk/templates/redis/config/redis.rb +13 -0
  97. data/lib/monk/templates/redis/setup.md +20 -0
  98. data/lib/monk/templates/redis/test/redis_test.rb +12 -0
  99. data/lib/monk/templates/websocket/agents.md +25 -0
  100. data/lib/monk/templates/websocket/app/sockets/chat.rb +18 -0
  101. data/lib/monk/templates/websocket/bin/websocket_server +58 -0
  102. data/lib/monk/templates/websocket/config/websocket_postgres.rb +24 -0
  103. data/lib/monk/templates/websocket/config/websocket_redis.rb +21 -0
  104. data/lib/monk/templates/websocket/setup.md +51 -0
  105. data/lib/monk/templates/websocket/test/websocket_test.rb +21 -0
  106. data/lib/monk/version.rb +1 -1
  107. data/lib/monk/websocket/connection.rb +25 -6
  108. data/lib/monk/websocket/listeners.rb +24 -0
  109. data/lib/monk/websocket/pg_fanout.rb +143 -20
  110. data/lib/monk/websocket/redis_fanout.rb +78 -29
  111. data/lib/monk/websocket/registry.rb +24 -0
  112. data/lib/monk/websocket/server.rb +57 -16
  113. data/lib/monk/websocket.rb +2 -0
  114. data/lib/monk.rb +10 -0
  115. metadata +83 -14
  116. data/lib/monk/scaffold.rb +0 -1061
  117. data/lib/monk/templates/base/bin/websocket_server +0 -68
  118. data/lib/monk/templates/jobs/app/jobs/send_login_link.rb +0 -28
  119. data/lib/monk/templates/live/app/app.rb +0 -22
  120. data/lib/monk/templates/live/app/views/index.erb +0 -18
  121. data/lib/monk/templates/live/bin/websocket_server +0 -42
  122. data/lib/monk/templates/live/config/live_pg.rb +0 -60
  123. data/lib/monk/templates/mail/Gemfile.extra +0 -1
  124. data/lib/monk/templates/postgres/Dockerfile +0 -32
  125. data/lib/monk/templates/postgres/Gemfile.extra +0 -2
  126. data/lib/monk/templates/redis/Gemfile.extra +0 -1
  127. /data/lib/monk/templates/live/app/views/{live → demo}/_hits.erb +0 -0
@@ -1,4 +1,5 @@
1
1
  require_relative "freeze_hooks"
2
+ require_relative "persistence/pool"
2
3
 
3
4
  module Monk
4
5
  # Backend-agnostic persistence. Concrete backends (e.g.
@@ -8,6 +9,16 @@ module Monk
8
9
  # can seal every backend actually in use without needing to know their
9
10
  # names.
10
11
  module Persistence
12
+ # Closes every connection this Ractor opened, in every backend. A
13
+ # Ractor that ends leaves its connections to the garbage collector,
14
+ # which may not run for a long time; one that opened any calls this
15
+ # as it finishes (Monk::WebSocket::Server.serve, for a socket's).
16
+ def self.disconnect_all
17
+ # A copy: each backend's disconnect_all removes its own key.
18
+ backends = Ractor.current[:monk_persistence]&.keys || []
19
+ backends.each(&:disconnect_all)
20
+ end
21
+
11
22
  # Shared by every backend module: per-Ractor connection lifecycle, a
12
23
  # registry of named configs, and boot-time shareability sealing. A
13
24
  # backend `extend`s this and implements #connect(**opts) /
@@ -27,6 +38,14 @@ module Monk
27
38
  configs[name] = opts
28
39
  end
29
40
 
41
+ # The options a database was registered with, for something that
42
+ # opens its own connection to it (PgFanout's LISTEN).
43
+ def connection_options(name)
44
+ configs.fetch(name) do
45
+ raise Monk::UnknownPersistenceError, "no database registered as #{name.inspect}"
46
+ end
47
+ end
48
+
30
49
  # Registered connection names (e.g. :primary) -- Monk.boot's log
31
50
  # line reads this to report which backends an app actually uses.
32
51
  def names
@@ -37,6 +56,76 @@ module Monk
37
56
  entry(name).conn
38
57
  end
39
58
 
59
+ # With options, declares a named pool (docs/history/plan-pg-pool.md):
60
+ # `size` worker Ractors, each with its own connection to the
61
+ # registered database `db`, that run calls sent from any other
62
+ # Ractor. Nothing connects until #start_pools! runs, in the process
63
+ # that uses it. Every process loads the declarations
64
+ # (config/persistence.rb), next to #register.
65
+ #
66
+ # Without options, returns the started pool, from any Ractor.
67
+ # Raises if it was never declared, or declared but not started in
68
+ # this process.
69
+ def pool(name, **options)
70
+ return declare_pool(name, **options) unless options.empty?
71
+
72
+ handle = @running && @running[name]
73
+ return handle if handle
74
+
75
+ pool_config(name)
76
+ raise Monk::PoolNotStartedError,
77
+ "pool #{name.inspect} is declared but not started in this process -- call " \
78
+ "#{self}.start_pools!(#{name.inspect}) at boot in the process that uses it"
79
+ end
80
+
81
+ # Starts declared pools in this process: each pool's dispatcher and
82
+ # `size` workers, each worker with its own connection. Returns once
83
+ # every worker has connected; raises Monk::PoolStartError if any
84
+ # can't, and starts nothing for that pool. Main Ractor only, at boot,
85
+ # after every register and pool declaration: it freezes the
86
+ # registry, since the workers read it. A pool already started is
87
+ # left alone.
88
+ def start_pools!(*names)
89
+ unless Ractor.current == Ractor.main
90
+ raise ArgumentError, "#{self}.start_pools! runs in the main Ractor, at boot"
91
+ end
92
+
93
+ freeze_registry!
94
+ names.each { |name| start_pool(name) unless @running&.key?(name) }
95
+ self
96
+ end
97
+
98
+ # Hook: makes a copy of an exception fit to leave a pool's worker for
99
+ # another Ractor, by dropping what can't be copied. A backend whose
100
+ # errors carry nothing like that keeps this default.
101
+ def make_portable(_error) = nil
102
+
103
+ # This Ractor's own connections to this backend, closed and
104
+ # forgotten. A pool worker runs it as it stops; the next checkout in
105
+ # this Ractor reconnects.
106
+ def disconnect_all
107
+ Ractor.current[:monk_persistence]&.delete(self)&.each_value do |e|
108
+ disconnect(e.conn)
109
+ rescue StandardError
110
+ nil
111
+ end
112
+ end
113
+
114
+ # The pools started in this process, name => size (Monk.boot's line).
115
+ def running_pools
116
+ (@running || {}).transform_values { |handle| handle.config.size }
117
+ end
118
+
119
+ # A declared pool's options (a frozen Pool::Config).
120
+ def pool_config(name)
121
+ pools.fetch(name) do
122
+ raise Monk::UnknownPoolError,
123
+ "pool #{name.inspect} was never declared -- declare it once at boot with " \
124
+ "#{self}.pool(#{name.inspect}, size: #{Pool::DEFAULTS[:size]}); " \
125
+ "pool(#{name.inspect}) with no options only looks one up"
126
+ end
127
+ end
128
+
40
129
  def checkout(name, timeout: DEFAULT_CHECKOUT_TIMEOUT)
41
130
  e = entry(name)
42
131
  token = e.slot.pop(timeout: timeout)
@@ -46,9 +135,18 @@ module Monk
46
135
  "(checkout held longer than #{timeout}s)"
47
136
  end
48
137
 
138
+ # Checked here, before the block, and never by re-running it: Monk
139
+ # can't tell whether a failed block's writes reached the server,
140
+ # so a drop in the middle of a block fails that block, and the
141
+ # next checkout finds the connection dead and revives it. A
142
+ # revive that can't reach the server raises from here.
143
+ revive(e.conn) unless alive?(e.conn)
49
144
  yield e.conn
50
145
  ensure
51
- e.slot << true if token
146
+ if token
147
+ release(e.conn)
148
+ e.slot << true
149
+ end
52
150
  end
53
151
 
54
152
  # Called from Base#freeze! (Seam B), via Monk.freeze_hooks.
@@ -61,17 +159,16 @@ module Monk
61
159
  # the value (not the module) fixes it, the same way it did there.
62
160
  def freeze_registry!
63
161
  @configs = Ractor.make_shareable(configs)
162
+ @pools = Ractor.make_shareable(pools)
64
163
  end
65
164
 
66
165
  # Test-only: drops all registered configs and this Ractor's cached
67
166
  # connections. Not part of the app-facing API.
68
167
  def reset!
69
- Ractor.current[:monk_persistence]&.each_value do |e|
70
- disconnect(e.conn)
71
- rescue StandardError
72
- end
73
- Ractor.current[:monk_persistence] = {}
168
+ stop_pools
169
+ disconnect_all
74
170
  @configs = {}
171
+ @pools = {}
75
172
  end
76
173
 
77
174
  private
@@ -80,8 +177,87 @@ module Monk
80
177
  @configs ||= {}
81
178
  end
82
179
 
180
+ def pools
181
+ @pools ||= {}
182
+ end
183
+
184
+ # The started pools' dispatchers and inboxes, which only this (the
185
+ # main) Ractor uses: to stop them. Kept apart from @running, the
186
+ # frozen handles every Ractor reads.
187
+ def pool_ractors
188
+ @pool_ractors ||= {}
189
+ end
190
+
191
+ def start_pool(name)
192
+ ready = Ractor::Port.new
193
+ dispatcher = Ractor.new(self, pool_config(name), ready, name: "monk-pool-#{name}") do |backend, config, r|
194
+ Monk::Persistence::Pool::Dispatcher.run(backend, config, r)
195
+ end
196
+ status, value = ready.receive
197
+ unless status == :ok
198
+ dispatcher.join
199
+ raise Monk::PoolStartError, "pool #{name.inspect} couldn't connect its workers: #{value}"
200
+ end
201
+
202
+ pool_ractors[name] = { dispatcher: dispatcher, inbox: value }
203
+ handle = Pool::Handle.new(config: pool_config(name), inbox: value)
204
+ @running = Ractor.make_shareable((@running || {}).merge(name => handle))
205
+ ensure
206
+ ready&.close
207
+ end
208
+
209
+ # Test-only path (reset!). The dispatcher stops its workers, each
210
+ # finishing the call in hand, and answers every caller still waiting.
211
+ def stop_pools
212
+ pool_ractors.each_key { |name| stop_pool(name) }
213
+ @running = nil
214
+ end
215
+
216
+ def stop_pool(name)
217
+ stopping = pool_ractors.delete(name)
218
+ return unless stopping
219
+
220
+ send_stop(stopping[:inbox], [:stop])
221
+ stopping[:dispatcher].join
222
+ end
223
+
224
+ def send_stop(port, message)
225
+ port.send(message)
226
+ rescue Ractor::ClosedError
227
+ nil # already ended
228
+ end
229
+
230
+ def declare_pool(name, **options)
231
+ unknown = options.keys - Pool::DEFAULTS.keys
232
+ raise ArgumentError, "unknown pool option(s) #{unknown.join(", ")} for #{name.inspect}" if unknown.any?
233
+ raise ArgumentError, "pool #{name.inspect} is already declared" if pools.key?(name)
234
+
235
+ config = Pool::Config.new(name: name, **Pool::DEFAULTS, **options)
236
+ validate_pool!(config)
237
+ pools[name] = Ractor.make_shareable(config)
238
+ end
239
+
240
+ def validate_pool!(config)
241
+ unless configs.key?(config.db)
242
+ raise Monk::UnknownPersistenceError,
243
+ "pool #{config.name.inspect} uses db: #{config.db.inspect}, but no database is registered as " \
244
+ "#{config.db.inspect} -- call #{self}.register(#{config.db.inspect}, ...) before declaring the pool"
245
+ end
246
+
247
+ { size: Integer, queue: Integer, timeout: Numeric }.each do |option, type|
248
+ value = config.public_send(option)
249
+ next if value.is_a?(type) && value.positive?
250
+
251
+ raise ArgumentError,
252
+ "pool #{config.name.inspect}: #{option}: must be a positive #{type}, got #{value.inspect}"
253
+ end
254
+ end
255
+
256
+ # This Ractor's connections, by backend, then by name: so
257
+ # Persistence.disconnect_all can find every backend this Ractor
258
+ # used, from any Ractor.
83
259
  def ractor_local
84
- Ractor.current[:monk_persistence] ||= {}
260
+ (Ractor.current[:monk_persistence] ||= {})[self] ||= {}
85
261
  end
86
262
 
87
263
  def entry(name)
@@ -108,6 +284,19 @@ module Monk
108
284
  def disconnect(conn)
109
285
  raise NotImplementedError, "#{self} must implement #disconnect(conn)"
110
286
  end
287
+
288
+ # Optional hooks around #checkout's block. A backend that can't tell
289
+ # a dead connection from a live one keeps these defaults.
290
+
291
+ # Whether conn can take a query. Cheap: runs before every checkout.
292
+ def alive?(_conn) = true
293
+
294
+ # Reconnects a connection #alive? said was dead; raises if it can't.
295
+ def revive(_conn) = nil
296
+
297
+ # Runs after every checkout's block, however it ended. Must not
298
+ # raise: it runs in an ensure, and would hide the block's exception.
299
+ def release(_conn) = nil
111
300
  end
112
301
  end
113
302
  end
@@ -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,6 +19,13 @@ 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
+
27
+ # bin/websocket_server checks each socket's session through this pool, so
28
+ # it holds 4 connections however many pages are open, instead of one per
29
+ # open page. Declaring it opens nothing: only bin/websocket_server starts
30
+ # it. See docs/guides/persistence.md, "Pools", in the monk repo.
31
+ Monk::Persistence::Pg.pool(:auth, size: 4)
@@ -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
@@ -5,8 +5,8 @@
5
5
  # (Gemfile.lock committed) already resolves every compatible platform, not
6
6
  # just the machine that ran it -- nothing to add by hand before building on
7
7
  # 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.
8
+ # monk/rackup/webrick are pure Ruby, and pg (monk add postgres) 1.6+
9
+ # ships precompiled with its own libpq.
10
10
  FROM ruby:4.0-slim AS builder
11
11
  WORKDIR /app
12
12
 
@@ -23,6 +23,7 @@ COPY . .
23
23
  # platform that assigns its own). bin/websocket_server (WS_PORT default
24
24
  # 9293) is a separate process/deploy unit -- run this same image with
25
25
  # `bin/websocket_server` as the command instead of publishing another
26
- # port from this Dockerfile -- see docs/guides/deploying.md.
26
+ # port from this Dockerfile -- see docs/guides/deploying.md. Every other
27
+ # process in bin/ (bin/jobs, say) is the same: this image, that command.
27
28
  EXPOSE 9292
28
29
  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
@@ -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.