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
change
commit e89896965000aab7e6fd1171441f73348c53f110
author Claude <noreply@anthropic.com>
date
parent 70e68d2c
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]