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
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