Replace hardcoded Tailscale headers with configurable auth header
Single AUTH_HEADER env var (defaults to Remote-User) replaces the
two Tailscale-specific headers. Name is no longer extracted from
a separate header — set to login on first visit, editable later.

Closes #30

Assisted-by: Claude Opus 4.6 via pi
change kqrtzzolxtvovspomoymwtpztxmzrsky
commit 3231c542b67bc5f37f0032753c7471c8116c0fc5
author Alpha Chen <alpha@kejadlen.dev>
date
parent trwwqywo
diff --git a/AGENTS.md b/AGENTS.md
index 0ae3593..0a0b5e7 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -32,7 +32,7 @@ lib/
     models.rb        # User, Series, Task models and associations
     seed.rb          # Seed.call(user:, series:) — creates series, tasks, and history
     snapshots.rb     # Ferrum-driven headless screenshot capture
-    web.rb           # Roda app (routes, current_user from Tailscale headers)
+    web.rb           # Roda app (routes, current_user from auth header)
     views/
       layout.rb      # Phlex base layout (head, nav, body wrapper)
       dashboard.rb   # Main view: overdue, upcoming, series detail/new sidebar
@@ -96,8 +96,8 @@ Output goes to `~/.cache/ketchup/snapshots/` (or `$XDG_CACHE_HOME`). Templates f
 
 - **Views:** Phlex component classes under `lib/ketchup/views/`, not ERB templates.
 - **Migrations:** Sequel migrations in `db/migrate/`, numbered sequentially (`001_`, `002_`, …). Migrations auto-run on boot.
-- **User identification:** Current user from `HTTP_TAILSCALE_USER_LOGIN` / `HTTP_TAILSCALE_USER_NAME` request headers.
-- **Testing:** Minitest with `Rack::Test`. Fake Tailscale headers via helper.
+- **User identification:** Current user from a single auth header (`AUTH_HEADER` env var, defaults to `Remote-User`). Set `AUTH_HEADER=Tailscale-User-Login` for Tailscale deployments.
+- **Testing:** Minitest with `Rack::Test`. Fake auth headers via helper.
 - **Client-side:** Alpine.js for reactivity, Alpine Persist for state persistence, OverType for markdown editing. No build step — all loaded via CDN with pinned versions and SRI hashes in `views/layout.rb`. To update a dependency: fetch the new versioned URL, generate a hash with `curl -sL <url> | openssl dgst -sha384 -binary | openssl base64 -A`, and update both the `src` and `integrity` attributes.
 - **Ownership scoping:** User has `many_through_many :tasks` through `:series`. Routes use `@user.tasks_dataset` and `@user.series_dataset` to scope lookups.
 - **Observability:** OpenTelemetry with Rack instrumentation, gated on `OTEL_EXPORTER_OTLP_ENDPOINT`. The SDK reads standard `OTEL_EXPORTER_OTLP_*` env vars directly — no app-level proxying. No-op when unset.
diff --git a/README.md b/README.md
index 6b21d2b..98299ec 100644
--- a/README.md
+++ b/README.md
@@ -4,7 +4,7 @@ A personal tool for tracking recurring tasks and catching up on what's overdue.
 
 ## Overview
 
-Ketchup is for me and my family. Authentication comes from Tailscale headers — there's no in-app login. The stack is Ruby 4, Roda, Sequel, SQLite, Phlex, Alpine.js, and OverType (for inline markdown editing). Puma serves it, Sentry tracks errors, and OpenTelemetry sends traces to Honeycomb.
+Ketchup is for me and my family. Authentication comes from a reverse proxy header (configurable via `AUTH_HEADER`, defaults to `Remote-User`) — there's no in-app login. The stack is Ruby 4, Roda, Sequel, SQLite, Phlex, Alpine.js, and OverType (for inline markdown editing). Puma serves it, Sentry tracks errors, and OpenTelemetry sends traces to Honeycomb.
 
 ## Domain model
 
diff --git a/lib/ketchup/config.rb b/lib/ketchup/config.rb
index 41fb9e5..53c1ff1 100644
--- a/lib/ketchup/config.rb
+++ b/lib/ketchup/config.rb
@@ -6,9 +6,10 @@ require "securerandom"
 Config = Data.define(
   :database_url, #: String
   :session_secret, #: String
+  :auth_header, #: String
   :sentry, #: SentryConfig?
   :otel, #: OtelConfig?
-  :default_user, #: DefaultUser?
+  :default_user, #: String?
   :commit_sha, #: String?
   :change_id, #: String?
   :build_date, #: String?
@@ -24,26 +25,13 @@ class Config
     :endpoint, #: String
   )
 
-  DefaultUser = Data.define(
-    :login, #: String
-    :name, #: String
-  )
-
-  # Reopened because rbs-inline ignores methods defined inside Data.define blocks.
-  class DefaultUser
-    #: (String) -> DefaultUser
-    def self.parse(value)
-      login, name = value.split(":", 2) #: [String, String?]
-      new(login: login, name: name || login)
-    end
-  end
-
   #: () -> String
   def to_s
     parts = ["database=#{database_url}"]
+    parts << "auth=#{auth_header}"
     parts << "sentry=#{sentry.env || "on"}" if sentry
     parts << "otel=on" if otel
-    parts << "default_user=#{default_user.login}" if default_user
+    parts << "default_user=#{default_user}" if default_user
     parts << "commit=#{commit_sha}" if commit_sha
     parts << "change=#{change_id}" if change_id
     parts << "built=#{build_date}" if build_date
@@ -54,13 +42,13 @@ class Config
   def self.from_env(env = ENV)
     sentry_dsn = env["SENTRY_DSN"]
     otel_endpoint = env["OTEL_EXPORTER_OTLP_ENDPOINT"]
-    default_user = env["DEFAULT_USER"]
     new(
       database_url: env.fetch("DATABASE_URL") { "db/ketchup.db" },
       session_secret: env.fetch("SESSION_SECRET") { SecureRandom.hex(64) },
+      auth_header: env.fetch("AUTH_HEADER", "Remote-User"),
       sentry: sentry_dsn ? SentryConfig.new(dsn: sentry_dsn, env: env["SENTRY_ENV"]) : nil,
       otel: otel_endpoint ? OtelConfig.new(endpoint: otel_endpoint) : nil,
-      default_user: default_user ? DefaultUser.parse(default_user) : nil,
+      default_user: env["DEFAULT_USER"],
       commit_sha: env["COMMIT_SHA"],
       change_id: env["CHANGE_ID"]&.slice(0, 8),
       build_date: env["BUILD_DATE"]
diff --git a/lib/ketchup/dev_auth.rb b/lib/ketchup/dev_auth.rb
index 540c289..4b8ec1a 100644
--- a/lib/ketchup/dev_auth.rb
+++ b/lib/ketchup/dev_auth.rb
@@ -2,17 +2,14 @@
 
 module Ketchup
   class DevAuth
-    def initialize(app, default_user)
+    def initialize(app, login)
       @app = app
-      @login = default_user.login
-      @name = default_user.name
+      @login = login
+      @rack_header = "HTTP_#{CONFIG.auth_header.upcase.tr("-", "_")}"
     end
 
     def call(env)
-      unless env["HTTP_TAILSCALE_USER_LOGIN"]
-        env["HTTP_TAILSCALE_USER_LOGIN"] = @login
-        env["HTTP_TAILSCALE_USER_NAME"] = @name
-      end
+      env[@rack_header] ||= @login
       @app.call(env)
     end
   end
diff --git a/lib/ketchup/snapshots.rb b/lib/ketchup/snapshots.rb
index ecfa0d2..edc5ddc 100644
--- a/lib/ketchup/snapshots.rb
+++ b/lib/ketchup/snapshots.rb
@@ -294,8 +294,7 @@ module Ketchup
         require_relative "dev_auth"
 
         app = Rack::Builder.app do
-          default_user = Config::DefaultUser.new(login: "snapshot@example.com", name: "Snapshot User")
-          use Ketchup::DevAuth, default_user
+          use Ketchup::DevAuth, "snapshot@example.com"
           run Web.freeze.app
         end
 
diff --git a/lib/ketchup/web.rb b/lib/ketchup/web.rb
index 8d4a4d8..a4a61aa 100644
--- a/lib/ketchup/web.rb
+++ b/lib/ketchup/web.rb
@@ -21,11 +21,11 @@ class Web < Roda
   end
 
   def current_user
-    login = env["HTTP_TAILSCALE_USER_LOGIN"]
+    rack_header = "HTTP_#{CONFIG.auth_header.upcase.tr("-", "_")}"
+    login = env[rack_header]
     return unless login
 
-    name = env["HTTP_TAILSCALE_USER_NAME"]
-    User.find_or_create(login: login) { |u| u.name = name }
+    User.find_or_create(login: login) { |u| u.name = login }
   end
 
   route do |r|
diff --git a/test/test_web.rb b/test/test_web.rb
index f54e4c5..133b7ad 100644
--- a/test/test_web.rb
+++ b/test/test_web.rb
@@ -19,14 +19,14 @@ class TestWeb < Minitest::Test
   end
 
   def test_root_shows_new_series_form
-    get "/", {}, tailscale_headers
+    get "/", {}, auth_headers
     assert last_response.ok?
     assert_includes last_response.body, "New Series"
     assert_includes last_response.body, 'action="/series"'
   end
 
   def test_root_shows_empty_state
-    get "/", {}, tailscale_headers
+    get "/", {}, auth_headers
     assert_includes last_response.body, "Nothing overdue."
     assert_includes last_response.body, "Nothing upcoming."
   end
@@ -34,7 +34,7 @@ class TestWeb < Minitest::Test
   def test_root_shows_active_tasks
     create_series(note: "Call Mom", first_due_date: "2026-03-01", interval_unit: "week", interval_count: "2")
 
-    get "/", {}, tailscale_headers
+    get "/", {}, auth_headers
     assert_includes last_response.body, "Call Mom"
     assert_includes last_response.body, "Mar 1"
   end
@@ -43,32 +43,32 @@ class TestWeb < Minitest::Test
     create_series(
       note: "Alice task", interval_unit: "week", interval_count: "1",
       first_due_date: "2026-03-01",
-      headers: tailscale_headers(login: "alice@example.com", name: "Alice")
+      headers: auth_headers(login: "alice@example.com")
     )
 
     create_series(
       note: "Bob task", interval_unit: "day", interval_count: "1",
       first_due_date: "2026-03-01",
-      headers: tailscale_headers(login: "bob@example.com", name: "Bob")
+      headers: auth_headers(login: "bob@example.com")
     )
 
-    get "/", {}, tailscale_headers(login: "alice@example.com", name: "Alice")
+    get "/", {}, auth_headers(login: "alice@example.com")
     assert_includes last_response.body, "Alice task"
     refute_includes last_response.body, "Bob task"
   end
 
   def test_root_shows_current_user
-    get "/", {}, tailscale_headers(name: "Alice")
-    assert_includes last_response.body, "Alice"
+    get "/", {}, auth_headers
+    assert_includes last_response.body, "alice@example.com"
   end
 
   def test_root_creates_user_record
-    get "/", {}, tailscale_headers(login: "bob@example.com", name: "Bob")
+    get "/", {}, auth_headers(login: "bob@example.com")
     user = DB[:users].first(login: "bob@example.com")
-    assert_equal "Bob", user[:name]
+    assert_equal "bob@example.com", user[:name]
   end
 
-  def test_root_requires_tailscale_user
+  def test_root_requires_auth
     get "/"
     assert_equal 403, last_response.status
   end
@@ -103,7 +103,7 @@ class TestWeb < Minitest::Test
     create_series(
       note: "Dentist", interval_unit: "quarter", interval_count: "1",
       first_due_date: "2026-06-01",
-      headers: tailscale_headers(login: "dave@example.com", name: "Dave")
+      headers: auth_headers(login: "dave@example.com")
     )
 
     series = DB[:series].first
@@ -163,7 +163,7 @@ class TestWeb < Minitest::Test
     task = DB[:tasks].first
     series = DB[:series].first
     complete_path = "/series/#{series[:id]}/tasks/#{task[:id]}/complete"
-    csrf_post complete_path, {}, tailscale_headers
+    csrf_post complete_path, {}, auth_headers
     assert last_response.redirect?
 
     assert_includes last_response["Location"], "/series/#{series[:id]}"
@@ -181,7 +181,7 @@ class TestWeb < Minitest::Test
 
     task = DB[:tasks].first
     series = DB[:series].first
-    csrf_post "/series/#{series[:id]}/tasks/#{task[:id]}/complete", {}, tailscale_headers
+    csrf_post "/series/#{series[:id]}/tasks/#{task[:id]}/complete", {}, auth_headers
 
     new_task = DB[:tasks].where(completed_at: nil).first
     assert_equal Date.today >> 3, new_task[:due_date]
@@ -202,15 +202,15 @@ class TestWeb < Minitest::Test
     create_series(
       note: "Alice task", interval_unit: "day", interval_count: "1",
       first_due_date: "2026-03-01",
-      headers: tailscale_headers(login: "alice@example.com", name: "Alice")
+      headers: auth_headers(login: "alice@example.com")
     )
 
     task = DB[:tasks].first
-    csrf_post "/series/#{DB[:series].first[:id]}/tasks/#{task[:id]}/complete", {}, tailscale_headers(login: "bob@example.com", name: "Bob")
+    csrf_post "/series/#{DB[:series].first[:id]}/tasks/#{task[:id]}/complete", {}, auth_headers(login: "bob@example.com")
     assert_includes [403, 404], last_response.status
   end
 
-  def test_complete_task_requires_tailscale_user
+  def test_complete_task_requires_auth
     create_series(note: "Call Mom", interval_unit: "day", interval_count: "1",
                   first_due_date: "2026-03-01")
 
@@ -219,7 +219,7 @@ class TestWeb < Minitest::Test
     assert_equal 403, last_response.status
   end
 
-  def test_create_series_requires_tailscale_user
+  def test_create_series_requires_auth
     post "/series", {
       note: "Nope", interval_unit: "day", interval_count: "1",
       first_due_date: "2026-03-01"
@@ -232,7 +232,7 @@ class TestWeb < Minitest::Test
                   first_due_date: "2026-03-01")
 
     series = DB[:series].first
-    get "/", {}, tailscale_headers
+    get "/", {}, auth_headers
     assert_includes last_response.body, "href=\"/series/#{series[:id]}\""
     assert_includes last_response.body, "Call Mom"
   end
@@ -242,7 +242,7 @@ class TestWeb < Minitest::Test
                   first_due_date: "2026-03-01")
 
     task = DB[:tasks].first
-    get "/", {}, tailscale_headers
+    get "/", {}, auth_headers
     series = DB[:series].first
     assert_includes last_response.body, "action=\"/series/#{series[:id]}/tasks/#{task[:id]}/complete\""
   end
@@ -251,7 +251,7 @@ class TestWeb < Minitest::Test
     create_series(note: "Call Mom", interval_unit: "week", interval_count: "2",
                   first_due_date: "2026-03-01")
 
-    get "/", {}, tailscale_headers
+    get "/", {}, auth_headers
     assert_includes last_response.body, 'name="_csrf"'
   end
 
@@ -260,7 +260,7 @@ class TestWeb < Minitest::Test
                   first_due_date: "2026-03-01")
 
     series = DB[:series].first
-    get "/series/#{series[:id]}", {}, tailscale_headers
+    get "/series/#{series[:id]}", {}, auth_headers
     assert_includes last_response.body, "New"
     assert_includes last_response.body, 'href="/"'
   end
@@ -270,7 +270,7 @@ class TestWeb < Minitest::Test
                   first_due_date: "2026-03-01")
 
     series = DB[:series].first
-    get "/series/#{series[:id]}", {}, tailscale_headers
+    get "/series/#{series[:id]}", {}, auth_headers
     assert last_response.ok?
     assert_includes last_response.body, "2 weeks"
     assert_includes last_response.body, "2026-03-01"
@@ -283,11 +283,11 @@ class TestWeb < Minitest::Test
 
     task = DB[:tasks].first
     series = DB[:series].first
-    csrf_post "/series/#{series[:id]}/tasks/#{task[:id]}/complete", {}, tailscale_headers
+    csrf_post "/series/#{series[:id]}/tasks/#{task[:id]}/complete", {}, auth_headers
 
     completed_task = DB[:tasks].first(id: task[:id])
-    patch "/series/#{series[:id]}/tasks/#{completed_task[:id]}/note", { note: "Left a message" }, tailscale_headers
-    get "/series/#{series[:id]}", {}, tailscale_headers
+    patch "/series/#{series[:id]}/tasks/#{completed_task[:id]}/note", { note: "Left a message" }, auth_headers
+    get "/series/#{series[:id]}", {}, auth_headers
     assert last_response.ok?
     assert_includes last_response.body, "Left a message"
     assert_includes last_response.body, "task-history"
@@ -297,16 +297,16 @@ class TestWeb < Minitest::Test
     create_series(
       note: "Alice task", interval_unit: "day", interval_count: "1",
       first_due_date: "2026-03-01",
-      headers: tailscale_headers(login: "alice@example.com", name: "Alice")
+      headers: auth_headers(login: "alice@example.com")
     )
 
     series = DB[:series].first
-    get "/series/#{series[:id]}", {}, tailscale_headers(login: "bob@example.com", name: "Bob")
+    get "/series/#{series[:id]}", {}, auth_headers(login: "bob@example.com")
     assert_equal 404, last_response.status
   end
 
   def test_get_series_404_for_nonexistent
-    get "/series/999999", {}, tailscale_headers
+    get "/series/999999", {}, auth_headers
     assert_equal 404, last_response.status
   end
 
@@ -316,10 +316,10 @@ class TestWeb < Minitest::Test
 
     task = DB[:tasks].first
     series = DB[:series].first
-    csrf_post "/series/#{series[:id]}/tasks/#{task[:id]}/complete", {}, tailscale_headers
+    csrf_post "/series/#{series[:id]}/tasks/#{task[:id]}/complete", {}, auth_headers
 
     completed_task = DB[:tasks].first(id: task[:id])
-    patch "/series/#{series[:id]}/tasks/#{completed_task[:id]}/note", { note: "Called, all good" }, tailscale_headers
+    patch "/series/#{series[:id]}/tasks/#{completed_task[:id]}/note", { note: "Called, all good" }, auth_headers
     assert last_response.ok?
 
     body = JSON.parse(last_response.body)
@@ -333,7 +333,7 @@ class TestWeb < Minitest::Test
 
     task = DB[:tasks].first
     series = DB[:series].first
-    patch "/series/#{series[:id]}/tasks/#{task[:id]}/note", { note: "nope" }, tailscale_headers
+    patch "/series/#{series[:id]}/tasks/#{task[:id]}/note", { note: "nope" }, auth_headers
     assert_equal 422, last_response.status
   end
 
@@ -341,15 +341,15 @@ class TestWeb < Minitest::Test
     create_series(
       note: "Alice task", interval_unit: "day", interval_count: "1",
       first_due_date: "2026-03-01",
-      headers: tailscale_headers(login: "alice@example.com", name: "Alice")
+      headers: auth_headers(login: "alice@example.com")
     )
 
     task = DB[:tasks].first
     series = DB[:series].first
-    csrf_post "/series/#{series[:id]}/tasks/#{task[:id]}/complete", {}, tailscale_headers(login: "alice@example.com", name: "Alice")
+    csrf_post "/series/#{series[:id]}/tasks/#{task[:id]}/complete", {}, auth_headers(login: "alice@example.com")
 
     completed_task = DB[:tasks].first(id: task[:id])
-    patch "/series/#{series[:id]}/tasks/#{completed_task[:id]}/note", { note: "hacked" }, tailscale_headers(login: "bob@example.com", name: "Bob")
+    patch "/series/#{series[:id]}/tasks/#{completed_task[:id]}/note", { note: "hacked" }, auth_headers(login: "bob@example.com")
     assert_equal 404, last_response.status
   end
 
@@ -358,7 +358,7 @@ class TestWeb < Minitest::Test
                   first_due_date: "2026-03-01")
 
     series = DB[:series].first
-    patch "/series/#{series[:id]}", { note: "Call Dad" }, tailscale_headers
+    patch "/series/#{series[:id]}", { note: "Call Dad" }, auth_headers
     assert last_response.ok?
 
     body = JSON.parse(last_response.body)
@@ -371,7 +371,7 @@ class TestWeb < Minitest::Test
                   first_due_date: "2026-03-01")
 
     series = DB[:series].first
-    patch "/series/#{series[:id]}", { interval_count: "3", interval_unit: "month" }, tailscale_headers
+    patch "/series/#{series[:id]}", { interval_count: "3", interval_unit: "month" }, auth_headers
     assert last_response.ok?
 
     body = JSON.parse(last_response.body)
@@ -389,7 +389,7 @@ class TestWeb < Minitest::Test
 
     series = DB[:series].first
     task = DB[:tasks].first(series_id: series[:id])
-    patch "/series/#{series[:id]}", { due_date: "2026-04-15" }, tailscale_headers
+    patch "/series/#{series[:id]}", { due_date: "2026-04-15" }, auth_headers
     assert last_response.ok?
 
     body = JSON.parse(last_response.body)
@@ -402,7 +402,7 @@ class TestWeb < Minitest::Test
                   first_due_date: "2026-03-01")
 
     series = DB[:series].first
-    patch "/series/#{series[:id]}", { interval_unit: "fortnight" }, tailscale_headers
+    patch "/series/#{series[:id]}", { interval_unit: "fortnight" }, auth_headers
     assert_equal 422, last_response.status
   end
 
@@ -411,7 +411,7 @@ class TestWeb < Minitest::Test
                   first_due_date: "2026-03-01")
 
     series = DB[:series].first
-    patch "/series/#{series[:id]}", { interval_count: "0" }, tailscale_headers
+    patch "/series/#{series[:id]}", { interval_count: "0" }, auth_headers
     assert_equal 422, last_response.status
   end
 
@@ -419,11 +419,11 @@ class TestWeb < Minitest::Test
     create_series(
       note: "Alice task", interval_unit: "day", interval_count: "1",
       first_due_date: "2026-03-01",
-      headers: tailscale_headers(login: "alice@example.com", name: "Alice")
+      headers: auth_headers(login: "alice@example.com")
     )
 
     series = DB[:series].first
-    patch "/series/#{series[:id]}", { note: "hacked" }, tailscale_headers(login: "bob@example.com", name: "Bob")
+    patch "/series/#{series[:id]}", { note: "hacked" }, auth_headers(login: "bob@example.com")
     assert_equal 404, last_response.status
   end
 
@@ -432,7 +432,7 @@ class TestWeb < Minitest::Test
                   first_due_date: "2026-03-01")
 
     series = DB[:series].first
-    patch "/series/#{series[:id]}", { note: "Call Dad" }, tailscale_headers
+    patch "/series/#{series[:id]}", { note: "Call Dad" }, auth_headers
     assert last_response.ok?
 
     updated = DB[:series].first(id: series[:id])
@@ -445,17 +445,17 @@ class TestWeb < Minitest::Test
   end
 
   def test_csrf_rejects_post_without_token
-    get "/", {}, tailscale_headers  # establish session
+    get "/", {}, auth_headers  # establish session
     post "/series", {
       note: "No token", interval_unit: "day", interval_count: "1",
       first_due_date: "2026-03-01"
-    }, tailscale_headers
+    }, auth_headers
     assert_equal 403, last_response.status
   end
 
   private
 
-  def csrf_post(path, params = {}, headers = tailscale_headers)
+  def csrf_post(path, params = {}, headers = auth_headers)
     get "/", {}, headers  # establish session and get tokens
     token = last_response.body[/name="_csrf" value="([^"]+)"/, 1]
     post path, params.merge("_csrf" => token), headers
@@ -463,7 +463,7 @@ class TestWeb < Minitest::Test
 
 
 
-  def create_series(note:, interval_unit:, interval_count:, first_due_date:, headers: tailscale_headers)
+  def create_series(note:, interval_unit:, interval_count:, first_due_date:, headers: auth_headers)
     get "/", {}, headers  # establish session and get tokens
     token = last_response.body[/name="_csrf" value="([^"]+)"/, 1]
     post "/series", {
@@ -473,15 +473,7 @@ class TestWeb < Minitest::Test
     }, headers
   end
 
-  def tailscale_headers(
-    login: "alice@example.com",
-    name: "Alice",
-    profile_pic: "https://example.com/alice.jpg"
-  )
-    {
-      "HTTP_TAILSCALE_USER_LOGIN" => login,
-      "HTTP_TAILSCALE_USER_NAME" => name,
-      "HTTP_TAILSCALE_USER_PROFILE_PIC" => profile_pic
-    }
+  def auth_headers(login: "alice@example.com")
+    { "HTTP_REMOTE_USER" => login }
   end
 end