Serve the preview from one demo entrypoint, seeded from fixtures
The preview app was a conditional in config.ru: a login written onto
anonymous requests when PRO_TACTS_DEFAULT_LOGIN named one, on an empty
book. Both halves of that are gone.

demo.ru is now the one demo entrypoint, and the dev server and the
preview app are the same thing wearing different branding. It seeds the
fixture book from test/fixtures/cards when it finds no database, writes
the stand-in login, and runs config.ru underneath. `rake dev` reaches it
through dev.ru and no longer seeds for itself; fly.preview.toml runs it
as the preview's process. config.ru goes back to being the composition
root for a deployment alone, with no demo path in it and no flag that
would turn one on, and Config#default_login is gone with it.

Seeding on a missing database rather than every boot keeps what `rake
dev` documented: an entr reload finds the book already there, so a
client's edits survive a restart, and a fresh `rake dev` (a new tmpdir)
or a new deploy (a new image) is what resets to the fixtures.

The image therefore carries the seeder and the cards, so .dockerignore
re-includes those two out of test/ and nothing else.

demo.ru is read by Rack::Builder.parse_file, so the ASCII-only rule that
config.ru had is its rule too, and the preview boots with no locale set.
The test that enforced it now covers every .ru in the root rather than
config.ru alone, which is why it is named for rackup files now.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Rz9eYTeKgfMPoBpCjGkkaW
change
commit 408073bbf0848890779788df47d3d55d72a7760b
author Claude <noreply@anthropic.com>
date
parent b2ed5ac2
diff --git a/.dockerignore b/.dockerignore
index d201826..8ebbe76 100644
--- a/.dockerignore
+++ b/.dockerignore
@@ -12,7 +12,16 @@ data
 log
 tmp
 docs
+# The suite and the recorded exchanges stay out, but demo.ru seeds the
+# preview's book from the fixture cards, so those and the seeder have to
+# be in the image — fixture_data.rb reaches for nothing else under test/.
+# Only the preview runs demo.ru; a deployment runs config.ru, which reads
+# neither. The cards are spelled twice because a re-included directory
+# and the files under it are separate matches.
 test
+!test/fixture_data.rb
+!test/fixtures/cards
+!test/fixtures/cards/**
 servers
 clients
 monica
diff --git a/Rakefile b/Rakefile
index 78e9c16..40a5a93 100644
--- a/Rakefile
+++ b/Rakefile
@@ -13,13 +13,16 @@ end
 
 desc "Start development server, reloading on changes"
 task :dev do
-  # The dev server serves the fixture book, rebuilt from
-  # test/fixtures/cards on every start, so a client always sees known
-  # state and the real data/ directory stays out of the dev loop. Only
-  # rackup reloads under entr, so a client's edits survive a restart and
-  # a fresh `rake dev` is what resets to the fixtures. The data lives in
-  # a session-scoped tmpdir: two servers running at once each get their
-  # own database, and the directory goes when the task does.
+  # The dev server serves the fixture book, which demo.ru seeds from
+  # test/fixtures/cards when it finds no database — so a client always
+  # sees known state and the real data/ directory stays out of the dev
+  # loop. Seeded there rather than here because the preview app serves
+  # the same book from the same file. Only rackup reloads under entr, and
+  # a reload finds the database already seeded, so a client's edits
+  # survive a restart and a fresh `rake dev` is what resets to the
+  # fixtures: the data lives in a session-scoped tmpdir, so two servers
+  # running at once each get their own database, the directory goes when
+  # the task does, and the next run starts from no database at all.
   # entr's watch list is fd's snapshot at launch: a file created
   # after `rake dev` starts is never watched, and edits to it never
   # reload the server — restart the task when work adds a file.
@@ -31,14 +34,7 @@ task :dev do
   File.truncate("log/dev.log", 0) if File.exist?("log/dev.log")
 
   Dir.mktmpdir("pro-tacts-dev") do |dir|
-    data_dir = Pathname.new(dir)
-    ENV["PRO_TACTS_DATA_DIR"] = data_dir.to_s
-    require_relative "test/fixture_data"
-    require "pro_tacts/stub_login"
-    store = FixtureData.install(data_dir)
-    # Here rather than in FixtureData, which the tests share.
-    store.name_book(ProTacts::StubLogin::LOGIN, "alpha")
-    store.close
+    ENV["PRO_TACTS_DATA_DIR"] = dir
     sh "fd -e rb . lib | entr -r rackup -o localhost dev.ru"
   end
 end
diff --git a/config.ru b/config.ru
index dee36d8..5d7615c 100644
--- a/config.ru
+++ b/config.ru
@@ -8,7 +8,6 @@ require "sentry-ruby"
 $LOAD_PATH.unshift(Pathname.new(__dir__) / "lib")
 require "pro_tacts/web"
 require "pro_tacts/store"
-require "pro_tacts/stub_login"
 
 config = ProTacts.config
 
@@ -53,14 +52,4 @@ end
 
 ProTacts::Web.plugin :common_logger, $stderr
 
-# A deployment with nothing in front to write the identity header (the
-# preview app) names a login in PRO_TACTS_DEFAULT_LOGIN, and StubLogin
-# writes it onto every request that arrives without one, so the app
-# answers rather than refusing everyone (ProxyAuth). Left unset where the
-# proxy is the only way in, which is every deployment that serves real
-# data.
-if (login = config.default_login)
-  use ProTacts::StubLogin, login
-end
-
 run ProTacts::Web.freeze.app
diff --git a/demo.ru b/demo.ru
new file mode 100644
index 0000000..d255d08
--- /dev/null
+++ b/demo.ru
@@ -0,0 +1,34 @@
+# The demo entrypoint: the fixture book, and a login written the way the
+# proxy would write it. Both the dev server (dev.ru, which adds what
+# marks a dev run) and the preview app (fly.preview.toml, which runs this
+# file) serve from here, so the two differ in their branding and nothing
+# else. A deployment runs config.ru, which has neither the seed nor the
+# login and reads no flag that would turn them on.
+require "pathname"
+
+$LOAD_PATH.unshift(Pathname.new(__dir__) / "lib")
+require "pro_tacts"
+require "pro_tacts/stub_login"
+require_relative "test/fixture_data"
+
+config = ProTacts.config
+
+# Seeded when there is no database yet, not on every boot: a fresh data
+# directory gets the fixture book, and a restart keeps whatever has been
+# done to it since (an entr reload in dev, a machine coming back in
+# preview). A fresh `rake dev` is a new tmpdir and a new deploy is a new
+# image, so either is what resets to the fixtures. FixtureData.install
+# rebuilds the directory it is handed, which is why it is asked only
+# once and only where the database it names is missing (this is the one
+# entrypoint that never runs against a deployment's data, and it leaves
+# PRO_TACTS_DATABASE alone so the two paths agree).
+unless config.database_path.exist?
+  store = FixtureData.install(config.data_dir)
+  # The book the stand-in login syncs; `rake dev` named it here before
+  # the preview wanted the same book.
+  store.name_book(ProTacts::StubLogin::LOGIN, "alpha")
+  store.close
+end
+
+use ProTacts::StubLogin
+run Rack::Builder.parse_file(File.expand_path("config.ru", __dir__))
diff --git a/dev.ru b/dev.ru
index 57674bb..315d27a 100644
--- a/dev.ru
+++ b/dev.ru
@@ -1,19 +1,19 @@
-# config.ru for `rake dev`, which has no proxy in front of it to write
-# the login (ProTacts::StubLogin). Deployments run config.ru alone; the
-# preview one reaches the same middleware through PRO_TACTS_DEFAULT_LOGIN.
+# config.ru for `rake dev`: the demo entrypoint (demo.ru, which seeds the
+# fixture book and writes the login) plus what marks a dev run. The
+# preview app runs demo.ru with its own branding; deployments run
+# config.ru alone.
 require "pathname"
 
 $LOAD_PATH.unshift(Pathname.new(__dir__) / "lib")
 require "pro_tacts"
-require "pro_tacts/stub_login"
 
 # The change being worked on names a dev build, the way a release's tag
 # names an image; a change id holds still while its content is edited.
 change = `jj log --no-graph -r @ -T 'change_id.short()'`
 fail "jj could not name the working-copy change" unless $?.success?
 
-# What marks a dev run, set before config.ru reads the config. An
-# exported variable still wins over each.
+# What marks a dev run, set before demo.ru and config.ru read the config.
+# An exported variable still wins over each.
 ProTacts.config = ProTacts::Config.new({
   "PRO_TACTS_INSTANCE_NAME" => "pro-tacts (dev)",
   "PRO_TACTS_FAVICON" => "/favicon-dev.svg",
@@ -21,5 +21,4 @@ ProTacts.config = ProTacts::Config.new({
   "VERSION" => change,
 }.merge(ENV))
 
-use ProTacts::StubLogin
-run Rack::Builder.parse_file(File.expand_path("config.ru", __dir__))
+run Rack::Builder.parse_file(File.expand_path("demo.ru", __dir__))
diff --git a/fly.preview.toml b/fly.preview.toml
index 5abce52..245d35f 100644
--- a/fly.preview.toml
+++ b/fly.preview.toml
@@ -1,14 +1,17 @@
 # The per-PR preview app (fly-pr-review-apps overrides the name with the
-# PR's). No volume, so the database is the ephemeral one config.ru builds
-# in the image on boot and every deploy starts empty. Nothing writes the
-# Remote-User header in front of it, so PRO_TACTS_DEFAULT_LOGIN names the
-# login StubLogin stands in with (config.ru); the preview serves that one
-# demo book to anyone who opens the URL.
+# PR's). It runs demo.ru rather than the image's own config.ru, which is
+# the whole of what makes it a demo: the fixture book seeded on first
+# boot, and a stand-in login, the same two the dev server gets. No
+# volume, so every deploy starts from the fixtures again, and the one
+# demo book is served to anyone who opens the URL.
 app = 'pro-tacts-preview'
 primary_region = 'sjc'
 
 [build]
 
+[processes]
+  app = 'rackup -o 0.0.0.0 demo.ru'
+
 [http_service]
   internal_port = 9292
   force_https = true
@@ -25,4 +28,3 @@ primary_region = 'sjc'
 [env]
   PRO_TACTS_INSTANCE_NAME = 'pro-tacts (preview)'
   PRO_TACTS_ACCENT = 'clay'
-  PRO_TACTS_DEFAULT_LOGIN = 'demo@example.com'
diff --git a/lib/pro_tacts/config.rb b/lib/pro_tacts/config.rb
index 7703fae..4b46cc5 100644
--- a/lib/pro_tacts/config.rb
+++ b/lib/pro_tacts/config.rb
@@ -34,19 +34,6 @@ module ProTacts
       !value.nil? && value.match?(TRUTHY)
     end
 
-    # A login to answer requests that name nobody, PRO_TACTS_DEFAULT_LOGIN;
-    # nil when unset. A deployment behind a proxy leaves it unset, so the
-    # proxy's Remote-User is the only identity (ProxyAuth). A deployment
-    # with nothing in front — the preview app — names one, and StubLogin
-    # writes it onto every request that arrives without the header, so the
-    # app is reachable without a proxy. Never set where real data is
-    # served: it hands every anonymous caller that login's address book.
-    #: () -> String?
-    def default_login
-      value = @env.fetch("PRO_TACTS_DEFAULT_LOGIN", nil)
-      value unless value.nil? || value.strip.empty?
-    end
-
     # Root data directory: holds the contacts database, and the exported
     # card mirror once there is one. Overridable with
     # PRO_TACTS_DATA_DIR.
diff --git a/lib/pro_tacts/stub_login.rb b/lib/pro_tacts/stub_login.rb
index 7f0ef56..90f61d6 100644
--- a/lib/pro_tacts/stub_login.rb
+++ b/lib/pro_tacts/stub_login.rb
@@ -2,34 +2,30 @@ require "pro_tacts"
 require "pro_tacts/proxy_auth"
 
 module ProTacts
-  # Rack middleware that writes a login to Remote-User, the way the proxy
-  # in front of a deployment does, so a server with nothing in front
-  # answers instead of refusing every request (ProxyAuth, Web#unauthorized).
-  # A request that already carries the header keeps its own, so `curl -H`
-  # can still ask as someone else.
+  # Rack middleware that writes a fixed login to Remote-User, the way the
+  # proxy in front of a deployment does, so a server with nothing in
+  # front answers instead of refusing every request (ProxyAuth,
+  # Web#unauthorized). A request that already carries the header keeps
+  # its own, so `curl -H` can still ask as someone else.
   #
-  # It stands in for the proxy, so it belongs only where there is none:
-  # the dev server (dev.ru) and the preview app, which names the login in
-  # PRO_TACTS_DEFAULT_LOGIN and config.ru inserts it. A deployment that
-  # serves real data leaves that unset and keeps the proxy as the only way
-  # in — the app trusts whatever it is handed, so writing a login here in
-  # front of one would hand every anonymous caller that address book.
+  # It stands in for the proxy, so it belongs only where there is none.
+  # Only demo.ru uses it — the dev server and the preview app both — and
+  # a deployment runs config.ru, which does not: reached directly the app
+  # trusts whatever it is handed, so writing a login in front of real
+  # data would hand every anonymous caller that address book.
   class StubLogin
     # @rbs @app: untyped
-    # @rbs @login: String
 
-    # The dev server's login; the preview passes its own.
     LOGIN = "alpha@example.com" #: String
 
-    #: (untyped app, ?String login) -> void
-    def initialize(app, login = LOGIN)
+    #: (untyped app) -> void
+    def initialize(app)
       @app = app
-      @login = login
     end
 
     #: (Rack::env env) -> untyped
     def call(env)
-      env[ProxyAuth::ENV_KEY] ||= @login
+      env[ProxyAuth::ENV_KEY] ||= LOGIN
       @app.call(env)
     end
   end
diff --git a/test/pro_tacts/test_config_ru.rb b/test/pro_tacts/test_config_ru.rb
deleted file mode 100644
index 1c58c2c..0000000
--- a/test/pro_tacts/test_config_ru.rb
+++ /dev/null
@@ -1,14 +0,0 @@
-require "test_helper"
-
-# Rack reads config.ru with the process's default external encoding,
-# which is US-ASCII under a C locale; any non-ASCII byte in the file
-# crashes boot (seen with rack 3.2.4). Ordinary .rb files are safe
-# because Ruby parses source as UTF-8 regardless of locale.
-class ConfigRuTest < Minitest::Test
-  def test_config_ru_is_ascii_only
-    config_ru = File.expand_path("../../config.ru", __dir__)
-
-    assert File.read(config_ru).ascii_only?,
-           "config.ru must stay ASCII-only or boot crashes under a C locale"
-  end
-end
diff --git a/test/pro_tacts/test_rackup_files.rb b/test/pro_tacts/test_rackup_files.rb
new file mode 100644
index 0000000..f8f32a7
--- /dev/null
+++ b/test/pro_tacts/test_rackup_files.rb
@@ -0,0 +1,26 @@
+require "test_helper"
+
+# Rack reads a rackup file with the process's default external encoding,
+# which is US-ASCII under a C locale; any non-ASCII byte in one crashes
+# boot (seen with rack 3.2.4). Ordinary .rb files are safe because Ruby
+# parses source as UTF-8 regardless of locale.
+#
+# Every .ru in the root is checked rather than config.ru alone, because
+# each of them is read that same way: dev.ru and demo.ru are handed to
+# Rack::Builder.parse_file too, and demo.ru is what boots the preview
+# app, where nothing sets a locale.
+class RackupFilesTest < Minitest::Test
+  ROOT = File.expand_path("../..", __dir__)
+
+  rackup_files = Dir.glob(File.join(ROOT, "*.ru")).sort
+  raise "no rackup files found in #{ROOT}" if rackup_files.empty?
+
+  rackup_files.each do |path|
+    name = File.basename(path)
+
+    define_method(:"test_#{name.tr(".", "_")}_is_ascii_only") do
+      assert File.read(path).ascii_only?,
+             "#{name} must stay ASCII-only or boot crashes under a C locale"
+    end
+  end
+end
diff --git a/test/pro_tacts/test_stub_login.rb b/test/pro_tacts/test_stub_login.rb
index bfd17d6..b3eb305 100644
--- a/test/pro_tacts/test_stub_login.rb
+++ b/test/pro_tacts/test_stub_login.rb
@@ -3,22 +3,18 @@ require_relative "../test_helper"
 require "pro_tacts/stub_login"
 
 class StubLoginTest < Minitest::Test
-  def login_seen(env = {}, *args)
+  def login_seen(env = {})
     seen = nil
     app = ->(inner) { seen = ProTacts::ProxyAuth.login(inner); [200, {}, []] }
-    ProTacts::StubLogin.new(app, *args).call(env)
+    ProTacts::StubLogin.new(app).call(env)
     seen
   end
 
-  def test_a_request_with_no_login_gets_the_default
+  def test_a_request_with_no_login_gets_the_stand_in
     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
-
-  def test_the_stand_in_login_is_configurable
-    assert_equal "demo@example.com", login_seen({}, "demo@example.com")
-  end
 end