web: serve the configuration profile at /setup
Rake on the checkout machine was the whole of the install,
so a phone took the file by hand, or took none at all.
Now the route names whatever host the request arrived upon,
and the identifier stays fresh, so the old account lives on.

Assisted-by: Claude Opus 5 via Claude Code

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
change vnwpwtwwqnwoyurnmxrwlotzsrvllywq
commit fc25746dba518710faa0e70753235a0ece8b6d21
author Alpha Chen <alpha@kejadlen.dev>
date
parent d4b39fe6
diff --git a/lib/pro_tacts/admin/device_setup.rb b/lib/pro_tacts/admin/device_setup.rb
new file mode 100644
index 0000000..f6339d7
--- /dev/null
+++ b/lib/pro_tacts/admin/device_setup.rb
@@ -0,0 +1,71 @@
+require "pro_tacts/admin/phlex"
+
+require "pro_tacts/admin/layout"
+
+module ProTacts
+  module Admin
+    # GET /setup — the screen a device installs the CardDAV account
+    # from. Until this existed the profile was rendered by rake on the
+    # machine the repository is checked out on, so provisioning a phone
+    # meant mailing a file to it; here the device that can reach the app
+    # is the device that can provision itself from it.
+    #
+    # /setup rather than /profile: in a web app a profile is the reader's
+    # own account page, and this is the device's setup — the one thing
+    # somebody types after the hostname when a new phone arrives.
+    #
+    # The account it names is read off the request rather than out of
+    # the environment (see Web's route), so the two facts worth stating
+    # — which server, under which name — are the ones the download is
+    # about to carry, not a copy of them.
+    #
+    # The rest is what a download does not say for itself: the file
+    # stages an approval rather than installing, and a second install
+    # stands beside the first rather than replacing it — every render
+    # carries its own identifier, which is what makes a reinstall a cold
+    # account instead of a no-op (see Profile). Saying so is the whole
+    # reason that line is on the screen: the duplicate is the surprise,
+    # and removing the old account is the reader's move, not ours.
+    # Where the approval lives is deliberately vague — Settings, System
+    # Settings — because Apple has moved the pane between releases and a
+    # wrong path reads worse than no path.
+    class DeviceSetup < Phlex::HTML
+      # @rbs @hostname: String
+      # @rbs @name: String
+
+      #: (hostname: String, name: String) -> void
+      def initialize(hostname:, name:)
+        @hostname = hostname
+        @name = name
+      end
+
+      def view_template
+        render Layout.new(title: "Device setup") do
+          div(class: "record") do
+            div(class: "record-nav") do
+              a(href: "/", class: "type-label") { "‹ contacts" }
+            end
+            div(class: "card") do
+              div(class: "card-body") do
+                h1(class: "type-h2", style: "margin: 0;") { "CardDAV profile" }
+                dl(class: "detail-grid") do
+                  dt(class: "type-label") { "server" }
+                  dd(class: "type-body-sm") { @hostname }
+                  dt(class: "type-label") { "account" }
+                  dd(class: "type-body-sm") { @name }
+                end
+                p(class: "type-body-sm") do
+                  plain "Downloading stages the profile; the device asks for approval before it installs. " \
+                    "Each install provisions a new account, so remove the old one after reinstalling."
+                end
+                a(href: "/setup/carddav.mobileconfig", class: "btn", data_variant: "primary") {
+                  "download profile"
+                }
+              end
+            end
+          end
+        end
+      end
+    end
+  end
+end
diff --git a/lib/pro_tacts/profile.rb b/lib/pro_tacts/profile.rb
index c58fc03..caced9c 100644
--- a/lib/pro_tacts/profile.rb
+++ b/lib/pro_tacts/profile.rb
@@ -3,26 +3,43 @@ require "nokogiri"
 
 module ProTacts
   # Renders the configuration profile that provisions the pro-tacts CardDAV
-  # account on macOS, so the resync loop is one rake command instead of the
-  # Internet Accounts dance. Payload keys per Apple's Device Management
-  # reference for com.apple.carddav.account.
+  # account, so the resync loop is one rake command instead of the Internet
+  # Accounts dance, and so a device that can reach the app can provision
+  # itself from it (Web's /setup). Payload keys per Apple's Device
+  # Management reference for com.apple.carddav.account.
   #
   # Every render carries a fresh identifier and fresh UUIDs: the account
   # identity follows the profile, so each install provisions a cold account
   # with no cached sync state — exactly what the experiment loop needs. The
   # cost is that reinstalling without removing first orphans the old account;
   # rake profile:remove sweeps every profile carrying our prefix.
+  #
+  # The served route keeps that rule rather than deriving a stable identifier
+  # from the account, which would make a reinstall a no-op: an identifier is
+  # what a device replaces a profile by (Apple's Configuration Profile
+  # Reference, on PayloadIdentifier — "This string is used to determine
+  # whether a new profile should replace an existing one or should be
+  # added"), so a stable one turns the second install into an update of
+  # something already installed. Reinstalling is what someone does when sync
+  # has gone wrong, and a cold account is the thing that fixes it; the
+  # duplicate account that costs is visible and removable where the install
+  # happened.
   class Profile
     IDENTIFIER_PREFIX = "dev.kejadlen.pro-tacts.carddav"
     HEX = "0123456789abcdef"
 
+    # The account name when no caller overrides it. A constant because
+    # the install screen states the name the download is about to carry
+    # and must not restate it as a second literal.
+    DEFAULT_NAME = "pro-tacts"
+
     # The username and password are a throwaway fictional pair and the server
     # ignores them: identity comes from the Tailscale headers that serve
     # injects (see ProTacts::TailscaleAuth). They stay in the template
     # because the account form expects the fields; dropping them is
     # untested.
     #: (hostname: String, ?name: String) -> String
-    def self.render(hostname:, name: "pro-tacts")
+    def self.render(hostname:, name: DEFAULT_NAME)
       identifier = "#{IDENTIFIER_PREFIX}-#{unique_hex}"
 
       template % {
diff --git a/lib/pro_tacts/web.rb b/lib/pro_tacts/web.rb
index 47323d8..a2c226d 100644
--- a/lib/pro_tacts/web.rb
+++ b/lib/pro_tacts/web.rb
@@ -13,8 +13,10 @@ require "pro_tacts/admin/contact_dialog"
 require "pro_tacts/admin/contacts_edit"
 require "pro_tacts/admin/contacts_index"
 require "pro_tacts/admin/contacts_show"
+require "pro_tacts/admin/device_setup"
 require "pro_tacts/debug_logger"
 require "pro_tacts/contact"
+require "pro_tacts/profile"
 require "pro_tacts/store"
 require "pro_tacts/tailscale_auth"
 require "pro_tacts/unhandled_requests"
@@ -187,6 +189,42 @@ module ProTacts
         end
       end
 
+      # The configuration profile, served so the device reading this can
+      # provision itself: until this route existed the only renderer was
+      # the Rakefile, so putting the account on a phone meant running
+      # rake on the machine holding the checkout and mailing the file
+      # over. The screen and the document are separate paths because a
+      # mobileconfig is not a page — the screen says what the download
+      # will do, and only the link fires the install flow.
+      #
+      # The hostname is the request's rather than PRO_TACTS_HOSTNAME:
+      # the address a device reached this app at is reachable from that
+      # device by construction, where an environment variable is a claim
+      # about some other machine's idea of where we live. It is the
+      # client's Host header, which behind `tailscale serve` is serve's
+      # own — and past the auth gate above, the only devices asking are
+      # on the tailnet.
+      r.on "setup" do
+        r.is do
+          r.get do
+            response["Content-Type"] = "text/html; charset=utf-8"
+            Admin::DeviceSetup.call(hostname: r.host, name: Profile::DEFAULT_NAME)
+          end
+        end
+
+        # The media type is what starts the install: a device offers to
+        # install a profile because of what the response is, not what the
+        # path is called. The path carries .mobileconfig anyway, so a
+        # download that does land in a file system lands under the name
+        # the rake task writes. No Content-Disposition — an attachment
+        # disposition on this type is untested here, and the install flow
+        # is the point.
+        r.get "carddav.mobileconfig" do
+          response["Content-Type"] = "application/x-apple-aspen-config"
+          Profile.render(hostname: r.host)
+        end
+      end
+
       r.is "" do
         # The dashboard home: the one human-facing screen's home
         # (docs/DESIGN.md), a GET alongside the PROPFIND below it —
diff --git a/sig/gems/roda.rbs b/sig/gems/roda.rbs
index f427b41..016ad48 100644
--- a/sig/gems/roda.rbs
+++ b/sig/gems/roda.rbs
@@ -41,6 +41,11 @@ class Roda
     # RodaRequest does not.
     def media_type: () -> String?
 
+    # The host the request named, without any port, inherited from
+    # Rack::Request for the same reason as #media_type. The setup route
+    # provisions an account for it (see web.rb).
+    def host: () -> String
+
     # The request's query and form parameters, inherited from
     # Rack::Request in the gem and restated here for the same reason as
     # #media_type. Values stay untyped: repeated keys arrive as arrays.
diff --git a/sig/pro_tacts/admin.rbs b/sig/pro_tacts/admin.rbs
index 62ee2f2..9dce50b 100644
--- a/sig/pro_tacts/admin.rbs
+++ b/sig/pro_tacts/admin.rbs
@@ -18,6 +18,10 @@ module ProTacts
       def self.call: (recent: Array[Store::RecentContact], upcoming: Array[Store::UpcomingBirthday], query: String?, ?notice: String?) -> String
     end
 
+    class DeviceSetup
+      def self.call: (hostname: String, name: String) -> String
+    end
+
     # Rendered by ContactsIndex, not called from checked code; only
     # its LIMIT is read in web.rb.
     class UpcomingBirthdays
diff --git a/test/pro_tacts/admin/test_device_setup.rb b/test/pro_tacts/admin/test_device_setup.rb
new file mode 100644
index 0000000..75f7dc4
--- /dev/null
+++ b/test/pro_tacts/admin/test_device_setup.rb
@@ -0,0 +1,65 @@
+require_relative "../../test_helper"
+
+require "rack/test"
+
+require "pro_tacts/profile"
+require "pro_tacts/web"
+
+# GET /setup and the document it links to, exercised the way the other
+# screens are: real requests through the Roda app. No store is touched —
+# the profile is rendered from the request alone — so unlike the contacts
+# pages these need no database standing behind them.
+class AdminDeviceSetupTest < Minitest::Test
+  include Rack::Test::Methods
+
+  def app
+    ProTacts::Web
+  end
+
+  def setup
+    header "Tailscale-User-Login", "test@example.com"
+    header "Host", "box.example.ts.net"
+  end
+
+  def test_screen_names_the_account_the_download_carries
+    get "/setup"
+
+    assert_equal 200, last_response.status
+    assert_equal "text/html; charset=utf-8", last_response["Content-Type"]
+    assert_includes last_response.body, "box.example.ts.net"
+    assert_includes last_response.body, ProTacts::Profile::DEFAULT_NAME
+    assert_includes last_response.body, "/setup/carddav.mobileconfig"
+  end
+
+  # The media type is what makes a device offer to install rather than
+  # save, so it is the assertion that matters most here.
+  def test_document_is_served_as_a_configuration_profile
+    get "/setup/carddav.mobileconfig"
+
+    assert_equal 200, last_response.status
+    assert_equal "application/x-apple-aspen-config", last_response["Content-Type"]
+    assert_includes last_response.body, "<string>com.apple.carddav.account</string>"
+  end
+
+  # The point of reading the hostname off the request: whatever address
+  # the device reached us at is the address the account is provisioned
+  # for.
+  def test_document_names_the_host_the_request_arrived_on
+    header "Host", "other.example.ts.net"
+    get "/setup/carddav.mobileconfig"
+
+    assert_includes last_response.body, "<string>other.example.ts.net</string>"
+  end
+
+  # Fresh per download (see Profile): a reinstall is a cold account, not
+  # an update of the one already there.
+  def test_each_download_carries_its_own_identifier
+    get "/setup/carddav.mobileconfig"
+    first = last_response.body
+    get "/setup/carddav.mobileconfig"
+
+    refute_equal first, last_response.body
+    assert_includes first, ProTacts::Profile::IDENTIFIER_PREFIX
+    assert_includes last_response.body, ProTacts::Profile::IDENTIFIER_PREFIX
+  end
+end