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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +99 -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/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.rb +7 -0
- data/lib/monk/live/helpers.rb +13 -0
- data/lib/monk/live.rb +12 -1
- 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/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 +8 -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 +10 -5
- 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 +14 -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/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 +28 -0
- data/lib/monk/templates/websocket/app/sockets/chat.rb +18 -0
- data/lib/monk/templates/websocket/bin/websocket_server +62 -0
- data/lib/monk/templates/websocket/config/websocket_postgres.rb +38 -0
- data/lib/monk/templates/websocket/config/websocket_redis.rb +26 -0
- data/lib/monk/templates/websocket/setup.md +54 -0
- data/lib/monk/templates/websocket/test/websocket_test.rb +21 -0
- data/lib/monk/version.rb +1 -1
- data/lib/monk/websocket/server.rb +3 -1
- metadata +81 -14
- data/lib/monk/scaffold.rb +0 -1063
- data/lib/monk/templates/base/bin/websocket_server +0 -77
- 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 -50
- 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
|
@@ -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)
|
data/lib/monk/live/helpers.rb
CHANGED
|
@@ -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
|
|
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
|
data/lib/monk/mail/later.rb
CHANGED
|
@@ -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
|
|
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,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:
|
|
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.
|
|
4
|
-
#
|
|
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
|
-
|
|
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
|
|
@@ -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
|
|
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.
|