monkrb 0.15.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 (80) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +581 -0
  3. data/LICENSE.txt +21 -0
  4. data/README.md +88 -0
  5. data/exe/monk +116 -0
  6. data/lib/monk/assets.rb +197 -0
  7. data/lib/monk/auth/errors.rb +13 -0
  8. data/lib/monk/auth/helpers.rb +76 -0
  9. data/lib/monk/auth/login_token.rb +11 -0
  10. data/lib/monk/auth/rate_limiter.rb +46 -0
  11. data/lib/monk/auth/session.rb +11 -0
  12. data/lib/monk/auth.rb +301 -0
  13. data/lib/monk/base.rb +323 -0
  14. data/lib/monk/context.rb +78 -0
  15. data/lib/monk/environment.rb +50 -0
  16. data/lib/monk/errors.rb +37 -0
  17. data/lib/monk/freeze_hooks.rb +23 -0
  18. data/lib/monk/live/client/idiomorph.LICENSE +13 -0
  19. data/lib/monk/live/client/idiomorph.js +4 -0
  20. data/lib/monk/live/client/monk_live.js +204 -0
  21. data/lib/monk/live/client/protocol.js +87 -0
  22. data/lib/monk/live/envelope.rb +51 -0
  23. data/lib/monk/live/errors.rb +9 -0
  24. data/lib/monk/live/helpers.rb +22 -0
  25. data/lib/monk/live/policy.rb +58 -0
  26. data/lib/monk/live/publisher.rb +91 -0
  27. data/lib/monk/live/renderer.rb +47 -0
  28. data/lib/monk/live/session.rb +121 -0
  29. data/lib/monk/live.rb +96 -0
  30. data/lib/monk/log.rb +130 -0
  31. data/lib/monk/persistence/errors.rb +7 -0
  32. data/lib/monk/persistence/model.rb +41 -0
  33. data/lib/monk/persistence/pg/errors.rb +4 -0
  34. data/lib/monk/persistence/pg/migrator.rb +165 -0
  35. data/lib/monk/persistence/pg/model.rb +233 -0
  36. data/lib/monk/persistence/pg.rb +34 -0
  37. data/lib/monk/persistence.rb +113 -0
  38. data/lib/monk/scaffold.rb +606 -0
  39. data/lib/monk/settings.rb +151 -0
  40. data/lib/monk/state_ractor.rb +45 -0
  41. data/lib/monk/templates/auth/config/auth.rb +28 -0
  42. data/lib/monk/templates/auth/db/migrate/00000000000001_create_auth_tables.down.sql +2 -0
  43. data/lib/monk/templates/auth/db/migrate/00000000000001_create_auth_tables.up.sql +18 -0
  44. data/lib/monk/templates/base/.dockerignore +5 -0
  45. data/lib/monk/templates/base/.gitignore +4 -0
  46. data/lib/monk/templates/base/.ruby-version +1 -0
  47. data/lib/monk/templates/base/Dockerfile +28 -0
  48. data/lib/monk/templates/base/Gemfile +7 -0
  49. data/lib/monk/templates/base/bin/server +5 -0
  50. data/lib/monk/templates/base/bin/websocket_server +62 -0
  51. data/lib/monk/templates/base/config/settings.rb +30 -0
  52. data/lib/monk/templates/base/config.ru +13 -0
  53. data/lib/monk/templates/base/public/css/app.css +17 -0
  54. data/lib/monk/templates/base/public/js/app.js +5 -0
  55. data/lib/monk/templates/base/views/index.erb +6 -0
  56. data/lib/monk/templates/base/views/layouts/app.erb +18 -0
  57. data/lib/monk/templates/live/bin/websocket_server +30 -0
  58. data/lib/monk/templates/live/config/live.rb +47 -0
  59. data/lib/monk/templates/live/config.ru +27 -0
  60. data/lib/monk/templates/live/views/index.erb +18 -0
  61. data/lib/monk/templates/live/views/live/_hits.erb +1 -0
  62. data/lib/monk/templates/postgres/Dockerfile +30 -0
  63. data/lib/monk/templates/postgres/Gemfile.extra +2 -0
  64. data/lib/monk/templates/postgres/bin/console +7 -0
  65. data/lib/monk/templates/postgres/bin/migrate +22 -0
  66. data/lib/monk/templates/postgres/bin/setup_db +9 -0
  67. data/lib/monk/templates/postgres/config/persistence.rb +10 -0
  68. data/lib/monk/templates/redis/Gemfile.extra +1 -0
  69. data/lib/monk/version.rb +9 -0
  70. data/lib/monk/views.rb +175 -0
  71. data/lib/monk/websocket/connection.rb +226 -0
  72. data/lib/monk/websocket/errors.rb +9 -0
  73. data/lib/monk/websocket/frame.rb +71 -0
  74. data/lib/monk/websocket/handshake.rb +77 -0
  75. data/lib/monk/websocket/redis_fanout.rb +103 -0
  76. data/lib/monk/websocket/registry.rb +92 -0
  77. data/lib/monk/websocket/server.rb +234 -0
  78. data/lib/monk/websocket.rb +19 -0
  79. data/lib/monk.rb +45 -0
  80. metadata +252 -0
data/README.md ADDED
@@ -0,0 +1,88 @@
1
+ # Monk
2
+
3
+ A light Ruby web framework designed to be fully `Ractor`-safe: every app it produces is a valid Rack 3 app that is also `Ractor.shareable?`, so it can be served in parallel across Ractor worker pools without silently losing that safety property. Named after Thelonious Sphere Monk, great and unique Jazz piano player and composer.
4
+
5
+ **Built in** (loaded by `require "monk"`): routing, context and error handling, boot-time Ractor-shareability checks, `Monk::StateRactor` for shared state, settings, ERB views, static assets and logging. **Opt-in** (each needs its own `require`): Postgres persistence and migrations, passwordless auth and sessions, a WebSocket server with Redis fan-out, and `Monk::Live` server-pushed HTML updates. Details for each are in the [Features](#features) table below.
6
+
7
+ Monk is Kino-agnostic — it's built on stdlib `Ractor` primitives only, with no runtime dependency on any particular server. [Kino](https://github.com/yaroslav/kino) is the reference/development server (see `bin/server`), but any Ractor-aware Rack server, or a conventional one, can run a Monk app.
8
+
9
+ Requires **Ruby 4.0+**. Runtime dependencies: `rack` and `base64`.
10
+
11
+ ## Quick start
12
+
13
+ Install the gem — published as `monkrb` (the `monk` name on RubyGems belongs to an unrelated, long-abandoned project), the CLI and `require` stay `monk`:
14
+
15
+ ```
16
+ gem install monkrb
17
+
18
+ monk new my_app && cd my_app
19
+ bundle install
20
+ bin/server # -> http://localhost:9292/hello
21
+ ```
22
+
23
+ `monk new` scaffolds the app's own `Gemfile` with `gem "monkrb", require: "monk"`.
24
+
25
+ `monk new` writes a working skeleton (an HTML home page, a `/hello` route, a `/api/hello` JSON route, `views/`, `public/`, a `SETUP.md`). Flags add Postgres, auth, Redis and live updates: see [`docs/guides/scaffolding.md`](docs/guides/scaffolding.md).
26
+
27
+ All `monk` commands and flags are listed by:
28
+
29
+ ```
30
+ monk --help
31
+ ```
32
+
33
+ ## A taste
34
+
35
+ ```ruby
36
+ class App < Monk::Base
37
+ get("/hello") { "hello from monk" }
38
+ get("/users/:id") { json(id: params[:id]) }
39
+ get("/") { @title = "Home"; render "index", posts: Post.where(published: true) }
40
+
41
+ error(404) { json(error: "not found") }
42
+ end
43
+
44
+ run Monk.boot(App) # seals routes into a Ractor.shareable? structure, or raises
45
+ ```
46
+
47
+ Routes can't close over mutable state: `Monk.boot` raises `Monk::UnshareableRouteError` naming the offending route instead of failing on a live request. Shared mutable state goes through `Monk::StateRactor`.
48
+
49
+ ## Features
50
+
51
+ Everything beyond the core is opt-in (`require "monk"` alone loads none of it).
52
+
53
+ | Feature | What it is | Opt-in | Guide |
54
+ |---|---|---|---|
55
+ | Routing, context, errors | `get`/`post`/…, path params, `halt`, `json`, `error`, experimental `resources` | — | [`routing.md`](docs/guides/routing.md) |
56
+ | Boot and shared state | `Monk.boot`, Ractor-shareability checks, `Monk::StateRactor` | — | [`boot-and-shared-state.md`](docs/guides/boot-and-shared-state.md) |
57
+ | Settings | `Monk::Settings`, `MONK_ENV`, env-var config validated at boot | — | [`settings.md`](docs/guides/settings.md) |
58
+ | Views and static assets | ERB compiled at boot, escaped by default, layouts/partials, frozen asset manifest | — | [`views.md`](docs/guides/views.md) |
59
+ | Logging | request log per environment, `Monk::Log.debug`/`info`/`warn`/`error` | — | [`logging.md`](docs/guides/logging.md) |
60
+ | Persistence | `Monk::Persistence::Pg`: raw `pg`, per-Ractor connections, hash-based `Model` | `require "monk/persistence/pg"` (+ `.../pg/model`); needs the `pg` gem | [`persistence.md`](docs/guides/persistence.md) |
61
+ | Migrations | plain `.sql` up/down pairs, `Migrator` | `require "monk/persistence/pg/migrator"`; needs the `pg` gem | [`migrations.md`](docs/guides/migrations.md) |
62
+ | Auth and sessions | `Monk::Auth`: passwordless tokens, Bearer or cookie + CSRF | `require "monk/auth"`; needs the `pg` gem and a registered Postgres connection | [`auth.md`](docs/guides/auth.md) |
63
+ | WebSocket | `Monk::WebSocket`: RFC 6455 server as its own process, Redis fan-out | `require "monk/websocket"`; Redis fan-out: `require "monk/websocket/redis_fanout"` and the `redis` gem | [`websocket.md`](docs/guides/websocket.md) |
64
+ | Live updates | `Monk::Live`: server-rendered HTML patches pushed to open tabs | `require "monk/live"`; needs the WebSocket server and, across processes, Redis | [`live.md`](docs/guides/live.md) |
65
+ | Scaffolding | `monk new` and its flags; retrofitting Postgres, Auth or Redis | — (the `monk` command; flags `--postgres`, `--auth`, `--redis`, `--live`) | [`scaffolding.md`](docs/guides/scaffolding.md) |
66
+
67
+ ## More documentation
68
+
69
+ - [`docs/framework-comparison.md`](docs/framework-comparison.md): Monk vs. Sinatra vs. Rails, with LOC per module.
70
+ - [`docs/guides/deploying.md`](docs/guides/deploying.md): worked deployment examples.
71
+ - [`docs/design/ractor.md`](docs/design/ractor.md): how Monk uses Ruby's `Ractor`.
72
+ - [`docs/adr/`](docs/adr) and [`CONTEXT.md`](CONTEXT.md): architectural decisions and domain vocabulary.
73
+ - Design docs and phase-by-phase plans behind each feature: [`docs/design/views.md`](docs/design/views.md), [`docs/design/auth-sessions.md`](docs/design/auth-sessions.md), [`docs/design/websocket.md`](docs/design/websocket.md), [`docs/design/persistence-ractor-connections.md`](docs/design/persistence-ractor-connections.md), and the phase-by-phase plans and archived notes in [`docs/history/`](docs/history).
74
+ - [`CHANGELOG.md`](CHANGELOG.md).
75
+
76
+ ## Working on this repo
77
+
78
+ ```
79
+ bundle install
80
+ bundle exec rake test # Minitest
81
+ bin/server # demo app from config.ru via kino
82
+ ```
83
+
84
+ More (server modes, Docker) in [`docs/development.md`](docs/development.md).
85
+
86
+ ## Status
87
+
88
+ Monk is **pre-1.0** (see `lib/monk/version.rb` and the [changelog](CHANGELOG.md)): the API may still change between minor versions. The core is described in [`docs/history/core-plan.md`](docs/history/core-plan.md). There's no open roadmap issue at the moment; new work is proposed and tracked as it comes up. Done so far, each with its own design doc and plan: persistence, migrations, HTML templating and static assets, auth and sessions, WebSocket with Redis fan-out, log levels, live updates (`Monk::Live`, as of 2026-09-19), deployment support (Dockerfile scaffolding, `docs/guides/deploying.md`), and the RubyGems release as `monkrb` (both 2026-09-22).
data/exe/monk ADDED
@@ -0,0 +1,116 @@
1
+ #!/usr/bin/env ruby
2
+ require_relative "../lib/monk/scaffold"
3
+
4
+ USAGE = "Usage: monk new APP_NAME [--postgres] [--auth] [--redis] [--live]".freeze
5
+
6
+ HELP = <<~TEXT.freeze
7
+ #{USAGE}
8
+
9
+ Creates a new Monk project at APP_NAME from a static skeleton -- refuses
10
+ to run if APP_NAME already exists.
11
+
12
+ Base skeleton: Gemfile, config.ru, .ruby-version, bin/server,
13
+ bin/websocket_server, SETUP.md -- WebSocket support needs no external
14
+ service, so unlike Postgres/Redis below it isn't gated behind a flag:
15
+ bin/websocket_server auto-authenticates if --auth's config/auth.rb is
16
+ present, and fans out across processes over Redis if --redis's
17
+ REDIS_URL is set, adapting at boot either way. SETUP.md is generated
18
+ for every flag combination, walking through dev-then-test setup for
19
+ this exact project -- including, since monk new scaffolds no test
20
+ framework itself, the minimum Minitest wiring to get a working
21
+ `bundle exec rake test`.
22
+
23
+ Options:
24
+ --postgres Also scaffold Postgres persistence/migrations support:
25
+ config/persistence.rb, bin/console, bin/setup_db,
26
+ bin/migrate, db/migrate/. Also wires config.ru to
27
+ require it, writes .env/.env.test/.env.example
28
+ (DB_NAME defaulting to APP_NAME_development/_test,
29
+ not the generic app_development fallback baked into
30
+ config/persistence.rb itself), and uncomments
31
+ gem "dotenv" in the Gemfile so .env actually loads
32
+ (config/settings.rb's require "dotenv/load" is a
33
+ no-op with it left commented). SETUP.md's dev/test
34
+ steps grow to cover Postgres containers, database
35
+ creation, and migrations.
36
+ --auth Also scaffold Monk::Auth (passwordless token auth).
37
+ Implies --postgres -- Auth is Postgres-only, so this
38
+ always brings the persistence scaffold with it. Adds
39
+ config/auth.rb and a migration creating the
40
+ login_tokens/sessions tables it needs. config.ru
41
+ requires config/auth instead of config/persistence
42
+ (config/auth.rb itself requires persistence), and
43
+ .env/.env.test/.env.example get an AUTH_SECRET line.
44
+ --redis Add the redis gem to the Gemfile, for
45
+ bin/websocket_server's cross-process fan-out. Fully
46
+ independent -- doesn't imply, and isn't implied by,
47
+ --postgres or --auth. Writes/extends .env and
48
+ .env.example with a REDIS_URL line (deliberately
49
+ never .env.test -- only a test that exercises
50
+ RedisFanout needs it) and uncomments gem "dotenv"
51
+ if it isn't already (same reasoning as --postgres
52
+ above), same whether or not --postgres is also set.
53
+
54
+ --live Also scaffold Monk::Live: a demo counter whose open tabs
55
+ update by themselves, pushed as HTML over a WebSocket.
56
+ Implies --redis (bin/server and bin/websocket_server
57
+ are separate processes, and Redis carries an update
58
+ from one to the other). Adds config/live.rb,
59
+ views/live/_hits.erb and the browser runtime under
60
+ public/js/monk_live/, replaces config.ru, views/index.erb
61
+ and bin/websocket_server with live versions, and adds
62
+ the runtime's <meta>/<script> tags to the layout.
63
+
64
+ Commands:
65
+ new APP_NAME Create a new project (see Options above)
66
+ help Show this help (also --help, -h)
67
+
68
+ After creating a project:
69
+ cd APP_NAME
70
+ bundle install
71
+
72
+ See the generated SETUP.md for a full dev-then-test walkthrough, tailored
73
+ to whichever flags were passed. With --postgres (or --auth, which implies
74
+ it), short version, once a reachable Postgres exists (.env/.env.test have
75
+ the connection details):
76
+ bin/setup_db Apply pending migrations
77
+ bin/migrate migrate | rollback [N] | status
78
+ bin/console IRB with persistence loaded
79
+
80
+ With --auth, .env/.env.test ship a placeholder AUTH_SECRET -- change it
81
+ before relying on it; config/auth.rb reads it via ENV.fetch and raises
82
+ if it's missing entirely.
83
+
84
+ With --redis, .env ships a placeholder REDIS_URL to turn
85
+ bin/websocket_server's cross-process fan-out on; unset (as in .env.test),
86
+ it runs in-process only, same as without --redis.
87
+ TEXT
88
+
89
+ def usage_error!
90
+ warn USAGE
91
+ exit 1
92
+ end
93
+
94
+ command, *rest = ARGV
95
+
96
+ case command
97
+ when nil, "help", "--help", "-h"
98
+ puts HELP
99
+ when "new"
100
+ app_name = rest.find { |arg| !arg.start_with?("-") }
101
+ usage_error! if app_name.nil?
102
+
103
+ auth = rest.include?("--auth")
104
+ redis = rest.include?("--redis")
105
+ live = rest.include?("--live")
106
+ Monk::Scaffold.new(app_name, postgres: rest.include?("--postgres"), auth: auth, redis: redis, live: live).write!
107
+
108
+ puts "Created #{app_name}. Next steps:"
109
+ puts " cd #{app_name}"
110
+ puts " bundle install"
111
+ puts " see SETUP.md for the full dev-then-test walkthrough"
112
+ puts " change the placeholder AUTH_SECRET in .env/.env.test before relying on it" if auth
113
+ puts " Monk::Live needs Redis running, bin/server and bin/websocket_server (see SETUP.md)" if live
114
+ else
115
+ usage_error!
116
+ end
@@ -0,0 +1,197 @@
1
+ require "digest"
2
+
3
+ require_relative "freeze_hooks"
4
+ require_relative "environment"
5
+
6
+ module Monk
7
+ # Static files -- CSS, vanilla JS, images, fonts -- served by Monk
8
+ # itself out of a manifest built once at Boot and frozen, so a worker
9
+ # Ractor reads bodies and ETags by reference and never touches the
10
+ # filesystem or any mutable cache.
11
+ #
12
+ # One property worth naming: in production a lookup is an exact-match
13
+ # fetch of a path enumerated at boot, so path traversal isn't
14
+ # defended against, it's structurally impossible -- "/../../etc/passwd"
15
+ # simply isn't a key. Development does hit the disk (so an edited file
16
+ # shows up on the next request, no restart) and is the only mode that
17
+ # needs the containment check in #disk_entry.
18
+ module Assets
19
+ DEFAULT_ROOT = "public".freeze
20
+
21
+ # Ractor.make_shareable, not just #freeze: Hash#freeze only freezes the
22
+ # hash object itself, not the String keys/values inside it, so a plain
23
+ # `{ ... }.freeze` here is still rejected as unshareable the moment a
24
+ # worker Ractor reads it -- exactly what #content_type does from
25
+ # #disk_entry on every static-asset request in development.
26
+ TEXT_TYPES = Ractor.make_shareable({
27
+ ".css" => "text/css",
28
+ ".js" => "text/javascript",
29
+ ".mjs" => "text/javascript",
30
+ ".html" => "text/html",
31
+ ".json" => "application/json",
32
+ ".svg" => "image/svg+xml",
33
+ ".txt" => "text/plain",
34
+ ".xml" => "application/xml",
35
+ ".map" => "application/json",
36
+ })
37
+
38
+ BINARY_TYPES = Ractor.make_shareable({
39
+ ".png" => "image/png",
40
+ ".jpg" => "image/jpeg",
41
+ ".jpeg" => "image/jpeg",
42
+ ".gif" => "image/gif",
43
+ ".webp" => "image/webp",
44
+ ".avif" => "image/avif",
45
+ ".ico" => "image/x-icon",
46
+ ".woff" => "font/woff",
47
+ ".woff2" => "font/woff2",
48
+ ".ttf" => "font/ttf",
49
+ ".otf" => "font/otf",
50
+ ".pdf" => "application/pdf",
51
+ ".wasm" => "application/wasm",
52
+ })
53
+
54
+ FALLBACK_TYPE = "application/octet-stream".freeze
55
+ REVALIDATE = "public, max-age=0, must-revalidate".freeze
56
+ IMMUTABLE = "public, max-age=31536000, immutable".freeze
57
+ NO_CACHE = "no-cache".freeze
58
+
59
+ class << self
60
+ # Plain readers over eagerly-initialized ivars, never `@x ||= ...`:
61
+ # a lazy reader writes on first access, and writing a module ivar
62
+ # from a non-main Ractor is an isolation error. `production?` is
63
+ # settled once at Boot for the same family of reasons -- ENV is
64
+ # main-Ractor state, so a per-request read from a worker is a
65
+ # hazard (Monk.env resolves this the same way, through Settings'
66
+ # own frozen-at-Boot snapshot). `assets false` sets root to false
67
+ # and disables serving.
68
+ attr_reader :root, :manifest
69
+ attr_writer :root
70
+
71
+ def enabled?
72
+ !!root
73
+ end
74
+
75
+ def production?
76
+ @production
77
+ end
78
+
79
+ # A Rack response for a GET/HEAD of a known asset, or nil so the
80
+ # request falls through to routing. Called from Base#dispatch before
81
+ # route matching, which is the conventional order (it's where
82
+ # Rack::Static sits) and keeps a splat route from shadowing a
83
+ # stylesheet.
84
+ def response(env)
85
+ return nil unless enabled?
86
+
87
+ verb = env["REQUEST_METHOD"]
88
+ return nil unless verb == "GET" || verb == "HEAD"
89
+
90
+ entry = lookup(env["PATH_INFO"].to_s)
91
+ return nil unless entry
92
+
93
+ headers = {
94
+ "content-type" => entry[:type],
95
+ "etag" => entry[:etag],
96
+ "cache-control" => cache_control(env, entry),
97
+ }
98
+
99
+ return [304, headers, []] if fresh?(env, entry)
100
+ return [200, headers.merge("content-length" => entry[:body].bytesize.to_s), []] if verb == "HEAD"
101
+
102
+ [200, headers, [entry[:body]]]
103
+ end
104
+
105
+ # The URL to link an asset by. In production it carries a `?v=`
106
+ # digest, so a stamped request can be cached for a year; in
107
+ # development it doesn't, because the boot-time digest goes stale
108
+ # the moment the file is edited and a cached stylesheet is exactly
109
+ # what you don't want while editing one.
110
+ def path_for(path)
111
+ return path unless production?
112
+
113
+ entry = manifest[path]
114
+ entry ? "#{path}?v=#{entry[:digest]}" : path
115
+ end
116
+
117
+ # Called from Base#freeze! (Seam B), via Monk.freeze_hooks.
118
+ def freeze_registry!
119
+ @production = !Monk.env.development?
120
+ @manifest = Ractor.make_shareable(build_manifest)
121
+ # #root is read on every request (#enabled?, and the disk lookup
122
+ # in development). Left unfrozen, reading it from a worker Ractor
123
+ # raises Ractor::IsolationError before the manifest is even
124
+ # consulted.
125
+ @root = root.freeze
126
+ end
127
+
128
+ # Test-only.
129
+ def reset!
130
+ @root = DEFAULT_ROOT
131
+ @manifest = {}
132
+ @production = false
133
+ end
134
+
135
+ def content_type(path)
136
+ extension = File.extname(path).downcase
137
+ return "#{TEXT_TYPES[extension]}; charset=utf-8" if TEXT_TYPES.key?(extension)
138
+
139
+ BINARY_TYPES.fetch(extension, FALLBACK_TYPE)
140
+ end
141
+
142
+ private
143
+
144
+ def build_manifest
145
+ return {} unless enabled? && Dir.exist?(root)
146
+
147
+ Dir.glob("**/*", base: root).each_with_object({}) do |relative, manifest|
148
+ path = File.join(root, relative)
149
+ next unless File.file?(path)
150
+
151
+ manifest["/#{relative}"] = entry_for(relative, File.binread(path))
152
+ end
153
+ end
154
+
155
+ def lookup(path)
156
+ return manifest[path] if production?
157
+
158
+ disk_entry(path)
159
+ end
160
+
161
+ # Development only. PATH_INFO is not un-escaped here on purpose: an
162
+ # encoded "%2e%2e" stays a literal, nonexistent filename, and a
163
+ # decoded ".." is caught by the containment check below.
164
+ def disk_entry(path)
165
+ return nil if path.empty? || path.include?("\0")
166
+
167
+ root_path = File.expand_path(root)
168
+ full = File.expand_path(File.join(root_path, path.delete_prefix("/")))
169
+ return nil unless full.start_with?("#{root_path}#{File::SEPARATOR}")
170
+ return nil unless File.file?(full)
171
+
172
+ entry_for(path, File.binread(full))
173
+ end
174
+
175
+ def entry_for(path, body)
176
+ digest = Digest::SHA256.hexdigest(body)[0, 16]
177
+ { type: content_type(path), digest: digest, etag: %("#{digest}"), body: body }
178
+ end
179
+
180
+ def fresh?(env, entry)
181
+ env["HTTP_IF_NONE_MATCH"].to_s.split(",").any? { |candidate| candidate.strip == entry[:etag] }
182
+ end
183
+
184
+ def cache_control(env, entry)
185
+ return NO_CACHE unless production?
186
+
187
+ env["QUERY_STRING"].to_s.split("&").include?("v=#{entry[:digest]}") ? IMMUTABLE : REVALIDATE
188
+ end
189
+ end
190
+
191
+ @root = DEFAULT_ROOT
192
+ @manifest = {}
193
+ @production = false
194
+
195
+ Monk.freeze_hooks << self
196
+ end
197
+ end
@@ -0,0 +1,13 @@
1
+ module Monk
2
+ class AuthNotConfiguredError < StandardError
3
+ end
4
+
5
+ class InvalidRedirectError < StandardError
6
+ end
7
+
8
+ class MissingAuthConfigError < StandardError
9
+ end
10
+
11
+ class MissingAuthDeliveryError < StandardError
12
+ end
13
+ end
@@ -0,0 +1,76 @@
1
+ require_relative "../context"
2
+
3
+ module Monk
4
+ module Auth
5
+ # Mixed into Monk::Context (docs/design/auth-sessions.md's "Helpers, not
6
+ # state"): a Context is per-request and never crosses Ractors, so
7
+ # memoizing current_subject on it is exempt from the app's
8
+ # shareability constraints -- nothing here needs Ractor.make_shareable.
9
+ module Helpers
10
+ def current_subject
11
+ return @current_subject if defined?(@current_subject)
12
+
13
+ @current_subject = Monk::Auth.verify(bearer_token || session_cookie_token)
14
+ end
15
+
16
+ def require_user!
17
+ current_subject || halt(401)
18
+ end
19
+
20
+ # A no-op for Bearer-authenticated requests: a cross-origin attacker
21
+ # page has no channel to set an Authorization header at all, so the
22
+ # CSRF vector only exists for the cookie-authenticated path
23
+ # (docs/design/auth-sessions.md's "CSRF: stateless double-submit").
24
+ def require_csrf!
25
+ return if bearer_token
26
+
27
+ cookie_token = session_cookie_token
28
+ provided = header("x-csrf-token")
29
+ return if cookie_token && provided && OpenSSL.secure_compare(Monk::Auth.csrf_token_for(cookie_token), provided)
30
+
31
+ halt(403)
32
+ end
33
+
34
+ def set_session_cookie(session)
35
+ max_age = [(session[:expires_at] - Time.now).to_i, 0].max
36
+ add_response_cookie("session_token", session[:token], http_only: true, max_age: max_age)
37
+ add_response_cookie("csrf_token", Monk::Auth.csrf_token_for(session[:token]), http_only: false, max_age: max_age)
38
+ end
39
+
40
+ def clear_session_cookie
41
+ add_response_cookie("session_token", "", http_only: true, max_age: 0)
42
+ add_response_cookie("csrf_token", "", http_only: false, max_age: 0)
43
+ end
44
+
45
+ private
46
+
47
+ def bearer_token
48
+ auth_header = header("authorization")
49
+ return nil unless auth_header&.start_with?("Bearer ")
50
+
51
+ token = auth_header.delete_prefix("Bearer ")
52
+ token.empty? ? nil : token
53
+ end
54
+
55
+ def add_response_cookie(name, value, http_only:, max_age:)
56
+ flags = ["Path=/", "SameSite=Lax", "Max-Age=#{max_age}"]
57
+ flags << "Secure" if Monk::Auth.config[:secure]
58
+ flags << "HttpOnly" if http_only
59
+ headers["set-cookie"] = Array(headers["set-cookie"]) + ["#{name}=#{value}; #{flags.join("; ")}"]
60
+ end
61
+
62
+ def request_cookies
63
+ header("cookie").to_s.split(";").each_with_object({}) do |pair, cookies|
64
+ name, value = pair.strip.split("=", 2)
65
+ cookies[name] = value if name
66
+ end
67
+ end
68
+
69
+ def session_cookie_token
70
+ request_cookies["session_token"]
71
+ end
72
+ end
73
+ end
74
+ end
75
+
76
+ Monk::Context.include(Monk::Auth::Helpers)
@@ -0,0 +1,11 @@
1
+ require_relative "../persistence/pg/model"
2
+
3
+ module Monk
4
+ module Auth
5
+ # Internal storage for magic-link tokens -- not part of Monk::Auth's
6
+ # public API (docs/design/auth-sessions.md's "Two tokens, not one").
7
+ class LoginToken < Monk::Persistence::Pg::Model
8
+ self.table_name = "login_tokens"
9
+ end
10
+ end
11
+ end
@@ -0,0 +1,46 @@
1
+ require_relative "../state_ractor"
2
+
3
+ module Monk
4
+ module Auth
5
+ # Per-process, approximate rate limiting for POST /auth/request
6
+ # (docs/design/auth-sessions.md: StateRactor's "real job" here -- durability is
7
+ # explicitly not a goal). Frozen at construction, like StateRactor
8
+ # itself: that's what makes #exceeded? able to build a fresh #update
9
+ # block per call with a shareable self, satisfying CONTEXT.md's
10
+ # StateRactor rule ("build it where self is shareable... not inline in
11
+ # a route handler") without needing StateRactor#update to grow an
12
+ # argument-passing API just to thread a per-call key through.
13
+ class RateLimiter
14
+ def initialize(limit:, window:)
15
+ @limit = limit
16
+ @window = window
17
+ @counts = Monk::StateRactor.new({})
18
+ freeze
19
+ end
20
+
21
+ # True if `key` has already made more than `limit` calls within the
22
+ # current `window`-second window; also counts this call toward it.
23
+ def exceeded?(key)
24
+ # Ractor.make_shareable on a Proc requires every variable it
25
+ # captures to already be shareable -- it verifies, it doesn't
26
+ # freeze on the caller's behalf. Integers are always frozen; a
27
+ # caller-supplied String generally isn't, so dup+freeze a local
28
+ # copy (a distinct binding, not a reassignment of `key` -- a
29
+ # captured variable that could be reassigned is itself rejected as
30
+ # unshareable, regardless of what it holds at call time).
31
+ frozen_key = key.dup.freeze
32
+ limit = @limit
33
+ window = @window
34
+ now = Time.now.to_i
35
+
36
+ counts = @counts.update do |current|
37
+ count, window_start = current[frozen_key] || [0, now]
38
+ count, window_start = 0, now if now - window_start >= window
39
+ current.merge(frozen_key => [count + 1, window_start])
40
+ end
41
+
42
+ counts[frozen_key].first > limit
43
+ end
44
+ end
45
+ end
46
+ end
@@ -0,0 +1,11 @@
1
+ require_relative "../persistence/pg/model"
2
+
3
+ module Monk
4
+ module Auth
5
+ # Internal storage for session tokens -- not part of Monk::Auth's public
6
+ # API (docs/design/auth-sessions.md's "Two tokens, not one").
7
+ class Session < Monk::Persistence::Pg::Model
8
+ self.table_name = "sessions"
9
+ end
10
+ end
11
+ end