dev: sign in as alpha@example.com without a proxy
rake dev boots dev.ru, which wraps config.ru and writes the login in,
so a browser gets through without tailscale serve tuned in.
A request that names someone keeps the name it sent,
and deployments run config.ru, where no default is lent.

Assisted-by: Claude Opus 5 via Claude Code
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
change xrnuuotwtwqqnkoyovoxnpnqpoxxwrtn
commit af9f1190f1ffdcff4e90054a508bd38c9f486715
author Alpha Chen <alpha@kejadlen.dev>
date
parent nozpsyxs
diff --git a/AGENTS.md b/AGENTS.md
index 6abba31..cdef453 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -96,8 +96,9 @@ not a fixture; edit a `.vcf` to change what the replay serves.
   `DavXml`, whose methods are the only elements the server can send. Inside
   an element's block, `it` is the builder, not an enclosing block's value,
   so name the outer block's parameter.
-- Every request needs a `Remote-User` header or it gets a 401, so a bare
-  `curl` against `rake dev` is refused until you pass it. The security of
+- Every request needs a `Remote-User` header or it gets a 401. `rake dev`
+  fills it with `alpha@example.com` when a request has none (`dev.ru`),
+  so pass it only to ask as someone else. The security of
   that rests on the app being reachable only through a proxy that writes
   the header itself — never bind it to anything but localhost.
 - DAV exchanges that went wrong (a status of 400 or more but 401, a
diff --git a/Rakefile b/Rakefile
index 526a06e..ef30707 100644
--- a/Rakefile
+++ b/Rakefile
@@ -35,7 +35,7 @@ task :dev do
     ENV["PRO_TACTS_DATA_DIR"] = data_dir.to_s
     require_relative "test/fixture_data"
     FixtureData.install(data_dir).close
-    sh "fd -e rb . lib | entr -r rackup -o localhost"
+    sh "fd -e rb . lib | entr -r rackup -o localhost dev.ru"
   end
 end
 
diff --git a/dev.ru b/dev.ru
new file mode 100644
index 0000000..c479cc3
--- /dev/null
+++ b/dev.ru
@@ -0,0 +1,9 @@
+# config.ru for `rake dev`, which has no proxy in front of it to write
+# the login (ProTacts::DevLogin). Deployments run config.ru alone.
+require "pathname"
+
+$LOAD_PATH.unshift(Pathname.new(__dir__) / "lib")
+require "pro_tacts/dev_login"
+
+use ProTacts::DevLogin
+run Rack::Builder.parse_file(File.expand_path("config.ru", __dir__))
diff --git a/lib/pro_tacts/dev_login.rb b/lib/pro_tacts/dev_login.rb
new file mode 100644
index 0000000..27c0aa0
--- /dev/null
+++ b/lib/pro_tacts/dev_login.rb
@@ -0,0 +1,25 @@
+require "pro_tacts"
+require "pro_tacts/proxy_auth"
+
+module ProTacts
+  # Rack middleware that writes a fixed login to Remote-User, the way the
+  # proxy in front of a deployment does, so a dev server answers without
+  # one. A request that already carries the header keeps its own, so
+  # `curl -H` can still ask as someone else. Only dev.ru uses it.
+  class DevLogin
+    # @rbs @app: untyped
+
+    LOGIN = "alpha@example.com" #: String
+
+    #: (untyped app) -> void
+    def initialize(app)
+      @app = app
+    end
+
+    #: (Rack::env env) -> untyped
+    def call(env)
+      env[ProxyAuth::ENV_KEY] ||= LOGIN
+      @app.call(env)
+    end
+  end
+end
diff --git a/test/pro_tacts/test_dev_login.rb b/test/pro_tacts/test_dev_login.rb
new file mode 100644
index 0000000..d801c0b
--- /dev/null
+++ b/test/pro_tacts/test_dev_login.rb
@@ -0,0 +1,20 @@
+require_relative "../test_helper"
+
+require "pro_tacts/dev_login"
+
+class DevLoginTest < Minitest::Test
+  def login_seen(env = {})
+    seen = nil
+    app = ->(inner) { seen = ProTacts::ProxyAuth.login(inner); [200, {}, []] }
+    ProTacts::DevLogin.new(app).call(env)
+    seen
+  end
+
+  def test_a_request_with_no_login_gets_the_dev_login
+    assert_equal "alpha@example.com", login_seen
+  end
+
+  def test_a_request_naming_a_login_keeps_it
+    assert_equal "zoe@example.com", login_seen("HTTP_REMOTE_USER" => "zoe@example.com")
+  end
+end