setup: carry the requester's login as the username
Every profile said alpha@example.com, no matter who it was for;
now /setup writes the asker's login, and rake a placeholder, nothing more.

Assisted-by: Claude Opus 5 via Claude Code
change usnmopmvklyktopuwxrwzvurqwmvmvlv
commit d1e790dd92da04f4ff84c793619203621d979cb4
author Alpha Chen <alpha@kejadlen.dev>
date
parent swulokwo
diff --git a/Rakefile b/Rakefile
index 419f592..aa19bed 100644
--- a/Rakefile
+++ b/Rakefile
@@ -75,6 +75,10 @@ end
 desc "Render the macOS configuration profile (carddav.mobileconfig)"
 task profile: "carddav.mobileconfig"
 
+# The server ignores the username (see ProTacts::Profile.render), and a
+# profile rendered here has no request to read a login from.
+PROFILE_USERNAME = "pro-tacts@example.com"
+
 # Rebuilds when the template changes but not when the environment does
 # (PRO_TACTS_HOSTNAME, PRO_TACTS_PROFILE_NAME); delete carddav.mobileconfig
 # to force a rerender.
@@ -82,7 +86,7 @@ file "carddav.mobileconfig" => "lib/pro_tacts/profile.rb" do |task|
   require "pro_tacts/profile"
 
   File.write(task.name, ProTacts::Profile.render(
-    hostname: ENV.fetch("PRO_TACTS_HOSTNAME")
+    hostname: ENV.fetch("PRO_TACTS_HOSTNAME"), username: PROFILE_USERNAME
   ))
 end
 
@@ -93,7 +97,7 @@ namespace :profile do
     require "pro_tacts/profile"
 
     File.write("carddav.mobileconfig", ProTacts::Profile.render(
-      hostname: ENV.fetch("PRO_TACTS_HOSTNAME")
+      hostname: ENV.fetch("PRO_TACTS_HOSTNAME"), username: PROFILE_USERNAME
     ))
     sh "open", "carddav.mobileconfig"
     sh "open", "x-apple.systempreferences:com.apple.preferences.configurationprofiles"
diff --git a/lib/pro_tacts/profile.rb b/lib/pro_tacts/profile.rb
index 69fcf71..afc52d8 100644
--- a/lib/pro_tacts/profile.rb
+++ b/lib/pro_tacts/profile.rb
@@ -1,4 +1,3 @@
-
 require "securerandom"
 
 require "nokogiri"
@@ -45,17 +44,19 @@ module ProTacts
       ProTacts.config.profile_name || DEFAULT_NAME
     end
 
-    # 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) -> String
-    def self.render(hostname:)
+    # The server ignores the username and password: identity comes from
+    # the Tailscale headers that serve injects (see
+    # ProTacts::TailscaleAuth). /setup passes the requester's login
+    # anyway, so the account says which tailnet user it is for. The
+    # password stays a placeholder because the account form expects the
+    # field, and dropping it is untested.
+    #: (hostname: String, username: String) -> String
+    def self.render(hostname:, username:)
       identifier = "#{IDENTIFIER_PREFIX}-#{unique_hex}"
 
       template % {
         hostname: escape(hostname),
+        username: escape(username),
         name: escape(account_name),
         identifier:,
         account_identifier: "#{identifier}.account",
@@ -103,7 +104,7 @@ module ProTacts
               <key>CardDAVHostName</key>
               <string>%{hostname}</string>
               <key>CardDAVUsername</key>
-              <string>alpha@example.com</string>
+              <string>%{username}</string>
               <key>CardDAVPassword</key>
               <string>carddav-dev</string>
               <key>CardDAVUseSSL</key>
diff --git a/lib/pro_tacts/web/setup.rb b/lib/pro_tacts/web/setup.rb
index 759dc03..219f291 100644
--- a/lib/pro_tacts/web/setup.rb
+++ b/lib/pro_tacts/web/setup.rb
@@ -35,7 +35,7 @@ module ProTacts
       # is the point.
       r.get "carddav.mobileconfig" do
         response["Content-Type"] = "application/x-apple-aspen-config"
-        Profile.render(hostname: r.host)
+        Profile.render(hostname: r.host, username: @identity.login)
       end
     end
   end
diff --git a/test/pro_tacts/admin/test_device_setup.rb b/test/pro_tacts/admin/test_device_setup.rb
index 92e82ef..3e5cef3 100644
--- a/test/pro_tacts/admin/test_device_setup.rb
+++ b/test/pro_tacts/admin/test_device_setup.rb
@@ -79,6 +79,13 @@ class AdminDeviceSetupTest < Minitest::Test
     assert_includes last_response.body, "<string>other.example.ts.net</string>"
   end
 
+  def test_document_names_the_requester_as_the_username
+    header "Tailscale-User-Login", "zoe@example.com"
+    get "/setup/carddav.mobileconfig"
+
+    assert_match(%r{<key>CardDAVUsername</key>\s*<string>zoe@example.com</string>}, last_response.body)
+  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
diff --git a/test/pro_tacts/test_profile.rb b/test/pro_tacts/test_profile.rb
index 41f7839..df91453 100644
--- a/test/pro_tacts/test_profile.rb
+++ b/test/pro_tacts/test_profile.rb
@@ -4,8 +4,8 @@ require_relative "../test_helper"
 require "pro_tacts/profile"
 
 class ProfileTest < Minitest::Test
-  def render(hostname: "example.ts.net")
-    ProTacts::Profile.render(hostname:)
+  def render(hostname: "example.ts.net", username: "ada@example.com")
+    ProTacts::Profile.render(hostname:, username:)
   end
 
   # The name comes from the environment now rather than an argument, so
@@ -27,11 +27,11 @@ class ProfileTest < Minitest::Test
     assert_includes render, "<string>com.apple.carddav.account</string>"
   end
 
-  def test_embeds_hostname_and_fixed_dev_credentials
+  def test_embeds_hostname_username_and_placeholder_password
     xml = render
 
     assert_includes xml, "<string>example.ts.net</string>"
-    assert_includes xml, "<string>alpha@example.com</string>"
+    assert_match(%r{<key>CardDAVUsername</key>\s*<string>ada@example.com</string>}, xml)
     assert_includes xml, "<string>carddav-dev</string>"
   end
 
@@ -131,10 +131,11 @@ class ProfileTest < Minitest::Test
   end
 
   def test_escapes_xml_in_field_values
-    xml = render(hostname: "a&b.ts.net")
+    xml = render(hostname: "a&b.ts.net", username: "Ada <& Co>")
 
     assert_includes xml, "<string>a&amp;b.ts.net</string>"
     refute_includes xml, "<string>a&b.ts.net</string>"
+    assert_includes xml, "<string>Ada &lt;&amp; Co&gt;</string>"
     assert_empty Nokogiri::XML(xml).errors
   end
 end