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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +166 -0
- data/README.md +7 -6
- data/exe/monk +3 -181
- data/lib/monk/assets.rb +39 -9
- data/lib/monk/auth/errors.rb +3 -0
- data/lib/monk/auth/helpers.rb +11 -0
- data/lib/monk/auth/send_login_link.rb +36 -0
- data/lib/monk/auth.rb +34 -5
- data/lib/monk/base.rb +20 -0
- data/lib/monk/bin.rb +107 -0
- data/lib/monk/cli.rb +277 -0
- data/lib/monk/context.rb +17 -0
- data/lib/monk/errors.rb +0 -6
- data/lib/monk/generator/definition.rb +153 -0
- data/lib/monk/generator/result.rb +279 -0
- data/lib/monk/generator/run.rb +449 -0
- data/lib/monk/generator.rb +48 -0
- data/lib/monk/generators/auth.rb +29 -0
- data/lib/monk/generators/base.rb +19 -0
- data/lib/monk/generators/jobs.rb +21 -0
- data/lib/monk/generators/live.rb +26 -0
- data/lib/monk/generators/mail.rb +24 -0
- data/lib/monk/generators/postgres.rb +23 -0
- data/lib/monk/generators/redis.rb +20 -0
- data/lib/monk/generators/websocket.rb +24 -0
- data/lib/monk/generators.rb +7 -0
- data/lib/monk/jobs/adapters/pg.rb +3 -1
- data/lib/monk/jobs.rb +7 -0
- data/lib/monk/live/helpers.rb +13 -0
- data/lib/monk/live/policy.rb +39 -4
- data/lib/monk/live/session.rb +7 -0
- data/lib/monk/live.rb +17 -4
- data/lib/monk/log.rb +10 -1
- data/lib/monk/mail/deliver_later.rb +54 -0
- data/lib/monk/mail/later.rb +3 -51
- data/lib/monk/mail.rb +4 -0
- data/lib/monk/persistence/errors.rb +32 -0
- data/lib/monk/persistence/pg.rb +92 -1
- data/lib/monk/persistence/pool.rb +513 -0
- data/lib/monk/persistence.rb +196 -7
- data/lib/monk/templates/auth/agents.md +29 -0
- data/lib/monk/templates/auth/app/mailers/{app_mailer.rb → auth_mailer.rb} +5 -5
- data/lib/monk/templates/auth/app/routes/auth.rb +48 -0
- data/lib/monk/templates/auth/app/views/auth/login.erb +25 -0
- data/lib/monk/templates/auth/config/auth.rb +14 -8
- data/lib/monk/templates/auth/setup.md +37 -0
- data/lib/monk/templates/auth/test/auth_test.rb +17 -0
- data/lib/monk/templates/base/CLAUDE.md +1 -0
- data/lib/monk/templates/base/Dockerfile +4 -3
- data/lib/monk/templates/base/Gemfile +7 -2
- data/lib/monk/templates/base/Rakefile +8 -0
- data/lib/monk/templates/base/agents.md +41 -0
- data/lib/monk/templates/base/app/app.rb +5 -0
- data/lib/monk/templates/base/app/routes/.keep +0 -0
- data/lib/monk/templates/base/app/views/index.erb +1 -1
- data/lib/monk/templates/base/app/views/layouts/app.erb +2 -0
- data/lib/monk/templates/base/config/load.rb +8 -0
- data/lib/monk/templates/base/config/settings.rb +4 -5
- data/lib/monk/templates/base/setup.md +22 -0
- data/lib/monk/templates/base/test/app_test.rb +12 -0
- data/lib/monk/templates/base/test/test_helper.rb +18 -0
- data/lib/monk/templates/jobs/agents.md +25 -0
- data/lib/monk/templates/jobs/app/routes/jobs.rb +9 -0
- data/lib/monk/templates/jobs/bin/jobs +19 -13
- data/lib/monk/templates/jobs/setup.md +30 -0
- data/lib/monk/templates/jobs/test/jobs_test.rb +16 -0
- data/lib/monk/templates/live/agents.md +25 -0
- data/lib/monk/templates/live/app/broadcasts/greeting.rb +13 -0
- data/lib/monk/templates/live/app/routes/demo_live.rb +21 -0
- data/lib/monk/templates/live/app/routes/live.rb +10 -0
- data/lib/monk/templates/live/app/views/demo/live.erb +18 -0
- data/lib/monk/templates/live/app/views/live/_greeting.erb +1 -0
- data/lib/monk/templates/live/config/live.rb +30 -32
- data/lib/monk/templates/live/config/live_without_demo.rb +38 -0
- data/lib/monk/templates/live/setup.md +39 -0
- data/lib/monk/templates/live/test/live_test.rb +22 -0
- data/lib/monk/templates/mail/agents.md +22 -0
- data/lib/monk/templates/mail/app/mailers/app_mailer.rb +14 -0
- data/lib/monk/templates/mail/app/routes/mail.rb +12 -0
- data/lib/monk/templates/mail/app/views/mail/welcome.erb +1 -0
- data/lib/monk/templates/mail/config/mail.rb +4 -4
- data/lib/monk/templates/mail/setup.md +32 -0
- data/lib/monk/templates/mail/test/mail_test.rb +13 -0
- data/lib/monk/templates/postgres/agents.md +25 -0
- data/lib/monk/templates/postgres/app/routes/postgres.rb +22 -0
- data/lib/monk/templates/postgres/bin/console +3 -1
- data/lib/monk/templates/postgres/bin/migrate +23 -17
- data/lib/monk/templates/postgres/bin/setup_db +11 -5
- data/lib/monk/templates/postgres/config/persistence.rb +1 -0
- data/lib/monk/templates/postgres/db/migrate/.keep +0 -0
- data/lib/monk/templates/postgres/setup.md +38 -0
- data/lib/monk/templates/postgres/test/persistence_test.rb +11 -0
- data/lib/monk/templates/redis/agents.md +18 -0
- data/lib/monk/templates/redis/app/routes/redis.rb +15 -0
- data/lib/monk/templates/redis/config/redis.rb +13 -0
- data/lib/monk/templates/redis/setup.md +20 -0
- data/lib/monk/templates/redis/test/redis_test.rb +12 -0
- data/lib/monk/templates/websocket/agents.md +25 -0
- data/lib/monk/templates/websocket/app/sockets/chat.rb +18 -0
- data/lib/monk/templates/websocket/bin/websocket_server +58 -0
- data/lib/monk/templates/websocket/config/websocket_postgres.rb +24 -0
- data/lib/monk/templates/websocket/config/websocket_redis.rb +21 -0
- data/lib/monk/templates/websocket/setup.md +51 -0
- data/lib/monk/templates/websocket/test/websocket_test.rb +21 -0
- data/lib/monk/version.rb +1 -1
- data/lib/monk/websocket/connection.rb +25 -6
- data/lib/monk/websocket/listeners.rb +24 -0
- data/lib/monk/websocket/pg_fanout.rb +143 -20
- data/lib/monk/websocket/redis_fanout.rb +78 -29
- data/lib/monk/websocket/registry.rb +24 -0
- data/lib/monk/websocket/server.rb +57 -16
- data/lib/monk/websocket.rb +2 -0
- data/lib/monk.rb +10 -0
- metadata +83 -14
- data/lib/monk/scaffold.rb +0 -1061
- data/lib/monk/templates/base/bin/websocket_server +0 -68
- data/lib/monk/templates/jobs/app/jobs/send_login_link.rb +0 -28
- data/lib/monk/templates/live/app/app.rb +0 -22
- data/lib/monk/templates/live/app/views/index.erb +0 -18
- data/lib/monk/templates/live/bin/websocket_server +0 -42
- data/lib/monk/templates/live/config/live_pg.rb +0 -60
- data/lib/monk/templates/mail/Gemfile.extra +0 -1
- data/lib/monk/templates/postgres/Dockerfile +0 -32
- data/lib/monk/templates/postgres/Gemfile.extra +0 -2
- data/lib/monk/templates/redis/Gemfile.extra +0 -1
- /data/lib/monk/templates/live/app/views/{live → demo}/_hits.erb +0 -0
data/lib/monk/persistence.rb
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
70
|
-
|
|
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
|
|
2
|
-
#
|
|
3
|
-
#
|
|
4
|
-
#
|
|
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
|
|
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
|
|
6
|
-
# the
|
|
7
|
-
#
|
|
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:
|
|
11
|
-
# app/mailers/
|
|
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/
|
|
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:
|
|
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
|
|
9
|
-
#
|
|
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
|
-
|
|
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,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><%= %></code>
|
|
5
5
|
escapes by default, <code>raw(...)</code> opts out.
|
|
6
6
|
</p>
|
|
@@ -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
|
|
2
|
-
#
|
|
3
|
-
#
|
|
4
|
-
#
|
|
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.
|