Add users from the proxy's Remote-User header
Follow pro-tacts: the Tailscale proxy writes the login to Remote-User,
a request without one gets a 401, and demo.ru stubs a login for the dev
server and the Fly apps, which fly.toml now runs. Lists belong to the
user who made them and are private to them; lists from before users
existed have no owner and stay visible to everyone for now.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LG4iASZnHb1yEM1JAASiWn
diff --git a/AGENTS.md b/AGENTS.md
index 2580ade..8de48bb 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -15,14 +15,17 @@ Rank King is a webapp for ranking items along multiple axes through pairwise com
## Repository layout
```
-config.ru — Rack entrypoint
+config.ru — Rack entrypoint (expects the proxy's Remote-User header)
+demo.ru — config.ru plus StubLogin, for dev and the Fly apps
Gemfile — dependencies
Rakefile — rake tasks (test, dev)
db/migrate/ — Sequel migrations
lib/rank_king/ — app code
config.rb — Config data class, reads from ENV
db.rb — Sequel connection + migration runner
- models.rb — Sequel models (List, Item, Axis, Result)
+ models.rb — Sequel models (User, List, Item, Axis, Result)
+ proxy_auth.rb — reads the login from Remote-User
+ stub_login.rb — writes a fixed login where there is no proxy (demo.ru)
web.rb — Roda routes
views/ — Phlex view components
lib/open_skill/ — OpenSkill rating library (pure Ruby)
@@ -38,7 +41,8 @@ fly.toml — Fly config for the main demo and PR previews
## Domain model
-- **List** has many Items and Axes
+- **User** has many Lists; identified by the login on the `Remote-User` header
+- **List** belongs to a User and has many Items and Axes
- **Item** belongs to a List
- **Axis** belongs to a List, has many Results
- **Result** belongs to an Axis, a winner (Item), and a loser (Item)
@@ -50,8 +54,8 @@ fly.toml — Fly config for the main demo and PR previews
| Type check and test | `bundle exec rake` |
| Run tests | `bundle exec rake test` |
| Type check | `bundle exec rake steep` |
-| Run dev server | `bundle exec rake dev` (uses `fd \| entr -r rackup`) |
-| Start server | `bundle exec rackup` |
+| Run dev server | `bundle exec rake dev` (uses `fd \| entr -r rackup demo.ru`) |
+| Start server | `bundle exec rackup` (behind the proxy) or `bundle exec rackup demo.ru` (stub login) |
## CI and deployment
@@ -80,6 +84,8 @@ Tests set `DATABASE_URL=:memory:` in `test/test_helper.rb`.
- Models use `Sequel::Model` with `timestamps` plugin
- Views are Phlex components under `lib/rank_king/views/`
- CSRF protection is on for all POST routes
+- Every request needs a `Remote-User` header or it gets a 401. That is safe only because a proxy (Tailscale identity) writes the header. `StubLogin` fills it in for `demo.ru` (dev server, Fly apps) and nowhere else: never in `config.ru`, and never in front of real data.
+- Lists are private to their owner (404 for anyone else); lists from before users existed have no owner and are visible to everyone
- Config is a frozen `Data.define` value object
- Types are inline RBS comments (`#:` before a method, `# @rbs` for the
rest), checked by `rake steep`. `sig/` holds only what that syntax
diff --git a/Rakefile b/Rakefile
index 30154cc..f07e090 100644
--- a/Rakefile
+++ b/Rakefile
@@ -6,7 +6,7 @@ Minitest::TestTask.create
desc "Start dev server with auto-restart"
task :dev do
- sh "fd | entr -r rackup"
+ sh "fd | entr -r rackup -o localhost demo.ru"
end
desc "Type check lib against the RBS comments in it"
diff --git a/db/migrate/002_create_users.rb b/db/migrate/002_create_users.rb
new file mode 100644
index 0000000..27e118c
--- /dev/null
+++ b/db/migrate/002_create_users.rb
@@ -0,0 +1,17 @@
+# frozen_string_literal: true
+
+Sequel.migration do
+ change do
+ create_table(:users) do
+ primary_key :id
+ String :login, null: false, unique: true
+ DateTime :created_at
+ DateTime :updated_at
+ end
+
+ # Lists created before users existed have no owner.
+ alter_table(:lists) do
+ add_foreign_key :user_id, :users
+ end
+ end
+end
diff --git a/demo.ru b/demo.ru
new file mode 100644
index 0000000..e9958a2
--- /dev/null
+++ b/demo.ru
@@ -0,0 +1,12 @@
+# frozen_string_literal: true
+
+# The demo entrypoint: config.ru plus a login written the way the proxy would
+# write it. The dev server and the Fly apps serve from here; a deployment
+# behind the proxy runs config.ru alone.
+
+$LOAD_PATH.unshift(File.expand_path("lib", __dir__))
+
+require "rank_king/stub_login"
+
+use RankKing::StubLogin
+run Rack::Builder.parse_file(File.expand_path("config.ru", __dir__))
diff --git a/fly.toml b/fly.toml
index aee9e8b..ffa4d2d 100644
--- a/fly.toml
+++ b/fly.toml
@@ -2,7 +2,10 @@
# deploy job in .github/workflows/ci.yml) and the per-PR previews (the
# review-app action in .github/workflows/preview.yml, which overrides
# the app name with the PR's). DATABASE_URL defaults to :memory:, so
-# every deploy starts empty.
+# every deploy starts empty. Both run demo.ru rather than the image's
+# config.ru: nothing writes the Remote-User header in front of them, so
+# StubLogin stands in a login, and the one demo user's lists are served
+# to anyone who opens the URL.
#
# The app name is Fly's, not GitHub's, so it has to be unique across all
# of Fly and a taken one fails the deploy. ci.yml spells it twice more,
@@ -13,6 +16,9 @@ primary_region = 'sjc'
[build]
+[processes]
+ app = 'rackup -o 0.0.0.0 demo.ru'
+
[http_service]
internal_port = 9292
force_https = true
diff --git a/lib/rank_king/models.rb b/lib/rank_king/models.rb
index 8c0f6ac..7abee23 100644
--- a/lib/rank_king/models.rb
+++ b/lib/rank_king/models.rb
@@ -6,8 +6,16 @@ Sequel::Model.plugin :timestamps, update_on_create: true
module RankKing
# Signed in sig/rank_king/models.rbs: the superclass is not a constant.
+ # @rbs skip
+ class User < Sequel::Model(DB[:users])
+ one_to_many :lists, class: "RankKing::List"
+
+ def self.for_login(login) = find_or_create(login:)
+ end
+
# @rbs skip
class List < Sequel::Model(DB[:lists])
+ many_to_one :user, class: "RankKing::User"
one_to_many :items, class: "RankKing::Item"
one_to_many :axes, class: "RankKing::Axis"
end
diff --git a/lib/rank_king/proxy_auth.rb b/lib/rank_king/proxy_auth.rb
new file mode 100644
index 0000000..2c210fd
--- /dev/null
+++ b/lib/rank_king/proxy_auth.rb
@@ -0,0 +1,26 @@
+# frozen_string_literal: true
+
+module RankKing
+ # Who is asking: the login on Remote-User, which the proxy in front of this
+ # app writes (Tailscale identity, through Caddy). It is trustworthy only
+ # behind such a proxy, so the app must not listen anywhere but localhost.
+ # StubLogin stands in for the proxy where there is none (demo.ru).
+ module ProxyAuth
+ HEADER = "Remote-User" #: String
+ ENV_KEY = "HTTP_#{HEADER.upcase.tr("-", "_")}" #: String
+
+ class MissingLogin < StandardError
+ end
+
+ #: (Hash[String, untyped] env) -> String
+ def self.login(env)
+ login = env[ENV_KEY].to_s.dup.force_encoding(Encoding::UTF_8)
+ raise MissingLogin, "the #{HEADER} header is not valid UTF-8" unless login.valid_encoding?
+
+ login = login.strip
+ raise MissingLogin, "no login on the #{HEADER} header" if login.empty?
+
+ login
+ end
+ end
+end
diff --git a/lib/rank_king/stub_login.rb b/lib/rank_king/stub_login.rb
new file mode 100644
index 0000000..812bd4c
--- /dev/null
+++ b/lib/rank_king/stub_login.rb
@@ -0,0 +1,27 @@
+# frozen_string_literal: true
+
+require_relative "proxy_auth"
+
+module RankKing
+ # Rack middleware that writes a fixed login to Remote-User, the way the
+ # proxy in front of a deployment does. Only demo.ru uses it (the dev server
+ # and the Fly apps); config.ru must not, or every anonymous caller would be
+ # that user. A request that already carries the header keeps its own, so
+ # `curl -H` can still ask as someone else.
+ class StubLogin
+ # @rbs @app: untyped
+
+ LOGIN = "alpha@example.com" #: String
+
+ #: (untyped app) -> void
+ def initialize(app)
+ @app = app
+ end
+
+ #: (Hash[String, untyped] env) -> untyped
+ def call(env)
+ env[ProxyAuth::ENV_KEY] ||= LOGIN
+ @app.call(env)
+ end
+ end
+end
diff --git a/lib/rank_king/web.rb b/lib/rank_king/web.rb
index 5a539ac..48fda45 100644
--- a/lib/rank_king/web.rb
+++ b/lib/rank_king/web.rb
@@ -3,6 +3,7 @@
require "roda"
require_relative "models"
+require_relative "proxy_auth"
require_relative "views/home"
require_relative "views/list/show"
@@ -12,12 +13,18 @@ module RankKing
plugin :static, %w[ /css /favicon.svg ]
plugin :sessions, secret: CONFIG.session_secret
plugin :route_csrf, csrf_failure: :empty_403, check_request_methods: %w[POST]
+ plugin :error_handler, classes: [ProxyAuth::MissingLogin] do |e|
+ response.status = 401
+ response["Content-Type"] = "text/plain"
+ "Unauthorized: #{e.message}.\n"
+ end
route do |r|
check_csrf!
+ user = User.for_login(ProxyAuth.login(r.env))
r.root do
- lists = List.all
+ lists = List.where(user_id: [user.id, nil]).order(:id).all
Views::Home.new(lists: lists, csrf: method(:csrf_token)).call
end
@@ -37,7 +44,7 @@ module RankKing
axis_names = axes_text.lines.map(&:strip).reject(&:empty?)
list = DB.transaction do
- l = List.create(name: name)
+ l = List.create(name: name, user_id: user.id)
item_names.each { |n| Item.create(list_id: l.id, name: n) }
axis_names.each { |n| Axis.create(list_id: l.id, name: n) }
l
@@ -48,7 +55,9 @@ module RankKing
end
r.on Integer do |list_id|
- @list = List[list_id]
+ # Lists are private to their owner; ones from before users
+ # existed have none and stay visible to everyone.
+ @list = List.where(id: list_id, user_id: [user.id, nil]).first
r.halt 404 unless @list
r.is do
diff --git a/sig/gems/roda.rbs b/sig/gems/roda.rbs
index f329db5..742fbb9 100644
--- a/sig/gems/roda.rbs
+++ b/sig/gems/roda.rbs
@@ -4,7 +4,9 @@
# Matchers stay untyped on purpose. Roda's matcher vocabulary is open,
# and the block arguments a match yields depend on which matcher matched.
class Roda
- def self.plugin: (Symbol plugin, *untyped args, **untyped options) -> void
+ # The configure blocks vary by plugin (error_handler passes the
+ # exception), so the block takes anything, like the matchers below.
+ def self.plugin: (Symbol plugin, *untyped args, **untyped options) ?{ (*untyped) [self: instance] -> untyped } -> void
# The block is the router, run against a fresh instance per request.
def self.route: () { (RodaRequest) [self: instance] -> untyped } -> void
@@ -18,7 +20,11 @@ class Roda
def check_csrf!: () -> void
def csrf_token: (?String? path, ?String? method) -> String
+ def response: () -> RodaResponse
+
class RodaRequest
+ def env: () -> Hash[String, untyped]
+
def is: (*untyped matchers) { (*untyped) -> untyped } -> void
def on: (*untyped matchers) { (*untyped) -> untyped } -> void
def root: () { () -> untyped } -> void
@@ -33,4 +39,9 @@ class Roda
# The halt plugin's: ends the request with this status.
def halt: (?Integer status) -> bot
end
+
+ class RodaResponse
+ def []=: (String header, String value) -> void
+ def status=: (Integer status) -> void
+ end
end
diff --git a/sig/gems/sequel.rbs b/sig/gems/sequel.rbs
index 54ae822..c9068b3 100644
--- a/sig/gems/sequel.rbs
+++ b/sig/gems/sequel.rbs
@@ -25,6 +25,10 @@ module Sequel
class Dataset[out T]
def where: (*untyped conditions) -> Dataset[T]
+ def order: (*untyped columns) -> Dataset[T]
+
+ def all: () -> Array[T]
+
def first: () -> T?
end
@@ -39,6 +43,11 @@ module Sequel
def self.all: () -> Array[instance]
+ def self.where: (*untyped conditions) -> Dataset[instance]
+
+ # The first row matching these columns, created when there is none.
+ def self.find_or_create: (**untyped values) -> instance
+
# The row with this primary key, or nil.
def self.[]: (untyped id) -> instance?
diff --git a/sig/rank_king/models.rbs b/sig/rank_king/models.rbs
index 4c373f5..7497348 100644
--- a/sig/rank_king/models.rbs
+++ b/sig/rank_king/models.rbs
@@ -2,7 +2,15 @@
# the inline parser to read, so the models are marked `@rbs skip` in
# models.rb and signed here: their columns and associations.
module RankKing
+ class User < Sequel::Model
+ def login: () -> String
+ def lists: () -> Array[List]
+ def self.for_login: (String login) -> User
+ end
+
class List < Sequel::Model
+ def user_id: () -> Integer?
+ def user: () -> User?
def name: () -> String
def items: () -> Array[Item]
def axes: () -> Array[Axis]
diff --git a/test/test_web.rb b/test/test_web.rb
index 85d47f9..90ed3e4 100644
--- a/test/test_web.rb
+++ b/test/test_web.rb
@@ -12,11 +12,15 @@ class TestWeb < Minitest::Test
def app = RankKing::Web.app
+ LOGIN = "alpha@example.com"
+
def setup
+ header "Remote-User", LOGIN
RankKing::DB[:results].delete
RankKing::DB[:axes].delete
RankKing::DB[:items].delete
RankKing::DB[:lists].delete
+ RankKing::DB[:users].delete
end
def test_root_shows_empty_state
@@ -121,8 +125,41 @@ class TestWeb < Minitest::Test
assert_equal 403, last_response.status
end
+ def test_lists_belong_to_the_current_user
+ create_list(name: "Foods", items: "Pizza\nSushi")
+
+ assert_equal LOGIN, RankKing::List.first.user.login
+ end
+
+ def test_requires_login
+ header "Remote-User", nil
+ get "/"
+ assert_equal 401, last_response.status
+ end
+
+ def test_lists_are_private
+ as("alice@example.com") { create_list(name: "Alpaca", items: "A\nB") }
+ assert_equal 1, RankKing::List.count
+ list = RankKing::List.first
+
+ as("bob@example.com") do
+ get "/"
+ refute_includes last_response.body, "Alpaca"
+
+ get "/lists/#{list.id}"
+ assert_equal 404, last_response.status
+ end
+ end
+
private
+ def as(login)
+ header "Remote-User", login
+ yield
+ ensure
+ header "Remote-User", LOGIN
+ end
+
def csrf_post(path, params = {})
get "/"
token = last_response.body[/name="_csrf" value="([^"]+)"/, 1]