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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +581 -0
- data/LICENSE.txt +21 -0
- data/README.md +88 -0
- data/exe/monk +116 -0
- data/lib/monk/assets.rb +197 -0
- data/lib/monk/auth/errors.rb +13 -0
- data/lib/monk/auth/helpers.rb +76 -0
- data/lib/monk/auth/login_token.rb +11 -0
- data/lib/monk/auth/rate_limiter.rb +46 -0
- data/lib/monk/auth/session.rb +11 -0
- data/lib/monk/auth.rb +301 -0
- data/lib/monk/base.rb +323 -0
- data/lib/monk/context.rb +78 -0
- data/lib/monk/environment.rb +50 -0
- data/lib/monk/errors.rb +37 -0
- data/lib/monk/freeze_hooks.rb +23 -0
- data/lib/monk/live/client/idiomorph.LICENSE +13 -0
- data/lib/monk/live/client/idiomorph.js +4 -0
- data/lib/monk/live/client/monk_live.js +204 -0
- data/lib/monk/live/client/protocol.js +87 -0
- data/lib/monk/live/envelope.rb +51 -0
- data/lib/monk/live/errors.rb +9 -0
- data/lib/monk/live/helpers.rb +22 -0
- data/lib/monk/live/policy.rb +58 -0
- data/lib/monk/live/publisher.rb +91 -0
- data/lib/monk/live/renderer.rb +47 -0
- data/lib/monk/live/session.rb +121 -0
- data/lib/monk/live.rb +96 -0
- data/lib/monk/log.rb +130 -0
- data/lib/monk/persistence/errors.rb +7 -0
- data/lib/monk/persistence/model.rb +41 -0
- data/lib/monk/persistence/pg/errors.rb +4 -0
- data/lib/monk/persistence/pg/migrator.rb +165 -0
- data/lib/monk/persistence/pg/model.rb +233 -0
- data/lib/monk/persistence/pg.rb +34 -0
- data/lib/monk/persistence.rb +113 -0
- data/lib/monk/scaffold.rb +606 -0
- data/lib/monk/settings.rb +151 -0
- data/lib/monk/state_ractor.rb +45 -0
- data/lib/monk/templates/auth/config/auth.rb +28 -0
- data/lib/monk/templates/auth/db/migrate/00000000000001_create_auth_tables.down.sql +2 -0
- data/lib/monk/templates/auth/db/migrate/00000000000001_create_auth_tables.up.sql +18 -0
- data/lib/monk/templates/base/.dockerignore +5 -0
- data/lib/monk/templates/base/.gitignore +4 -0
- data/lib/monk/templates/base/.ruby-version +1 -0
- data/lib/monk/templates/base/Dockerfile +28 -0
- data/lib/monk/templates/base/Gemfile +7 -0
- data/lib/monk/templates/base/bin/server +5 -0
- data/lib/monk/templates/base/bin/websocket_server +62 -0
- data/lib/monk/templates/base/config/settings.rb +30 -0
- data/lib/monk/templates/base/config.ru +13 -0
- data/lib/monk/templates/base/public/css/app.css +17 -0
- data/lib/monk/templates/base/public/js/app.js +5 -0
- data/lib/monk/templates/base/views/index.erb +6 -0
- data/lib/monk/templates/base/views/layouts/app.erb +18 -0
- data/lib/monk/templates/live/bin/websocket_server +30 -0
- data/lib/monk/templates/live/config/live.rb +47 -0
- data/lib/monk/templates/live/config.ru +27 -0
- data/lib/monk/templates/live/views/index.erb +18 -0
- data/lib/monk/templates/live/views/live/_hits.erb +1 -0
- data/lib/monk/templates/postgres/Dockerfile +30 -0
- data/lib/monk/templates/postgres/Gemfile.extra +2 -0
- data/lib/monk/templates/postgres/bin/console +7 -0
- data/lib/monk/templates/postgres/bin/migrate +22 -0
- data/lib/monk/templates/postgres/bin/setup_db +9 -0
- data/lib/monk/templates/postgres/config/persistence.rb +10 -0
- data/lib/monk/templates/redis/Gemfile.extra +1 -0
- data/lib/monk/version.rb +9 -0
- data/lib/monk/views.rb +175 -0
- data/lib/monk/websocket/connection.rb +226 -0
- data/lib/monk/websocket/errors.rb +9 -0
- data/lib/monk/websocket/frame.rb +71 -0
- data/lib/monk/websocket/handshake.rb +77 -0
- data/lib/monk/websocket/redis_fanout.rb +103 -0
- data/lib/monk/websocket/registry.rb +92 -0
- data/lib/monk/websocket/server.rb +234 -0
- data/lib/monk/websocket.rb +19 -0
- data/lib/monk.rb +45 -0
- 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
|
data/lib/monk/assets.rb
ADDED
|
@@ -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,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
|