web: move the admin screens into hash branches
Contacts, groups, and setup each move to a file of their own in web/,
and web.rb keeps the gate and the root, the trunk the branches grow off.

Assisted-by: Claude Opus 5 via Claude Code
change swzwlxxlzqusprsnmvrovpnsqusrktqm
commit fd8c9e77cda80d9472f0c4acf66dcd748d9ad2f3
author Alpha Chen <alpha@kejadlen.dev>
date
parent qkspuwkr
diff --git a/AGENTS.md b/AGENTS.md
index 36992e4..6b3d61b 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -35,10 +35,13 @@ is already `pro-tacts` — don't pass `--backlog` or verify it.
 
 ```
 lib/pro_tacts/
-├── web.rb          # The Roda app: the identity gate, the root, and
-│                   # the admin routes
+├── web.rb          # The Roda app: the identity gate and the root;
+│                   # each first segment is a hash_branch in web/
 ├── web/dav.rb      # Discovery and the address book: every DAV route
 │                   # and response body
+├── web/contacts.rb # The card browser and its editor
+├── web/groups.rb   # The group screens and their editor
+├── web/setup.rb    # The device setup screen and its profile
 ├── store.rb        # Sequel over SQLite: cards, change log, derived index
 ├── contact.rb      # the one model of a contact: id, vCard, etag,
 │                   # and the structured accessors over the card
diff --git a/lib/pro_tacts/web.rb b/lib/pro_tacts/web.rb
index 52cb2c4..2d65247 100644
--- a/lib/pro_tacts/web.rb
+++ b/lib/pro_tacts/web.rb
@@ -1,6 +1,5 @@
 
 require "pathname"
-require "securerandom"
 
 require "pro_tacts"
 require "sentry-ruby"
@@ -8,18 +7,8 @@ require "sentry-ruby"
 require "rack/rewindable_input"
 require "roda"
 
-require "pro_tacts/admin/card_form"
-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/admin/groups_edit"
-require "pro_tacts/admin/groups_index"
-require "pro_tacts/admin/groups_show"
 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"
@@ -88,10 +77,10 @@ module ProTacts
       "Not Found"
     end
 
-    # The router's trunk: the identity gate, the static files, the admin
-    # screens, and the root the admin and DAV halves share. The DAV
-    # routes are hash_branches in lib/pro_tacts/web/dav.rb, run on this
-    # same instance, so what the trunk sets they see.
+    # The router's trunk: the identity gate, the static files, and the
+    # root the admin and DAV halves share. Every other first segment is
+    # a hash_branch in lib/pro_tacts/web/, run on this same instance, so
+    # what the trunk sets they see.
     route do |r|
       # Every request names a tailnet user or is refused, the static
       # files included (ProTacts::TailscaleAuth).
@@ -100,176 +89,6 @@ module ProTacts
       r.public
       r.hash_branches
 
-      # The card browser, one segment down from the page at the root:
-      # /contacts/:id names what the id is without spending the whole
-      # single-segment namespace on contact ids. Under the same auth
-      # gate as the CardDAV routes — "a few family members, all
-      # trusted" is the whole access model this app has, see README's
-      # simplifying assumptions.
-      r.on "contacts" do
-        # The browser's create, from the dashboard's dialog: POST is
-        # the one method the admin UI adds to the DAV set (see
-        # config/puma.rb, whose list Puma replaces rather than
-        # extends), and the collection is the resource a create
-        # names. Stored through Store#put like any client write, so
-        # the change log a sync token counts on lands with the card.
-        # A nameless create is a dashboard re-render with a toast: the
-        # browser cannot produce one (the dialog's first field is
-        # required), so this is the backstop, and a popover cannot be
-        # declared open in markup — the toast is the refusal the
-        # re-rendered page can actually show. `r.is` because a bare
-        # verb block matches any remaining path in Roda — without it,
-        # the collection's create would swallow the record's apply,
-        # POST /contacts/:id below.
-        r.is do
-          r.post do
-            first = r.params["first"].to_s.strip
-            last = r.params["last"].to_s.strip
-            if first.empty? && last.empty?
-              dashboard(query: r.params["q"], notice: "A contact needs a name.")
-            else
-              id = SecureRandom.uuid
-              store.put(id, Admin::CardForm.new_card(id, first, last))
-              r.redirect "/contacts/#{id}", 303
-            end
-          end
-        end
-
-        r.on String do |id|
-          # The browser's edit of one contact
-          # (docs/plans/2026-09-05-web-card-editor.md): an explicit
-          # mode — GET renders the form, POST applies it, success is a
-          # 303 back to the details page so the back button cannot
-          # double-submit. POST stays the wire verb, the create's
-          # precedent: HTML forms speak only GET and POST, and the
-          # admin surface's one client is the form.
-          r.get "edit" do
-            contact = store.contact(id)
-
-            # No match falls through to the empty-body 404 the
-            # not_found handler fills in, same as the page below.
-            if contact
-              response["Content-Type"] = "text/html; charset=utf-8"
-              Admin::ContactsEdit.call(contact:)
-            end
-          end
-
-          r.post do
-            apply_edit(r, id)
-          end
-
-          # The picture the avatars render (Admin::Avatar): decoded
-          # bytes under their own content type, served from a route
-          # rather than inlined as base64 so a page of avatars is a
-          # page of cacheable image requests instead of ten 330 KB
-          # payloads stitched into the HTML. The contact's etag, so a
-          # picture changes exactly when its card does.
-          r.get "photo" do
-            contact = store.contact(id)
-
-            # The views never point at a photo the card lacks, so a
-            # contact with none is the 404 case above.
-            if contact && (photo = contact.photo)
-              response["Content-Type"] = photo.mime_type
-              response["ETag"] = contact.etag
-              photo.bytes
-            end
-          end
-
-          r.get do
-            contact = store.contact(id)
-
-            if contact
-              response["Content-Type"] = "text/html; charset=utf-8"
-              Admin::ContactsShow.call(contact:, groups: store.groups_of(id),
-                                       changes: store.changes_of(id))
-            end
-          end
-        end
-      end
-
-      # The group screens, the contacts' own shape: the collection's
-      # GET and create, then a record's GET, its editor, and the POST
-      # that applies it. What the writes rest on is
-      # docs/plans/2026-09-09-group-edits-propagate.md. A create lands
-      # on the editor, a new group being nothing until something is
-      # added to it.
-      r.on "groups" do
-        r.is do
-          r.get do
-            response["Content-Type"] = "text/html; charset=utf-8"
-            Admin::GroupsIndex.call(groups: store.all_groups)
-          end
-
-          r.post do
-            name = r.params["name"].to_s.strip
-            id = store.create_group(name: name.empty? ? nil : name)
-            r.redirect "/groups/#{id}/edit", 303
-          rescue Sequel::UniqueConstraintViolation
-            # A taken `sync:` name, the one kind that must be unique
-            # (db/migrations/007_sync_names.rb).
-            response["Content-Type"] = "text/html; charset=utf-8"
-            Admin::GroupsIndex.call(groups: store.all_groups, notice: "Another group is already named #{name}.")
-          end
-        end
-
-        r.on String do |id|
-          r.get "edit" do
-            group = store.group(id)
-            group_edit_screen(group) if group
-          end
-
-          r.post do
-            apply_group_edit(r, id)
-          end
-
-          r.get do
-            group = store.group(id)
-
-            if group
-              response["Content-Type"] = "text/html; charset=utf-8"
-              Admin::GroupsShow.call(group:, members: members_of(group))
-            end
-          end
-        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.account_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 —
@@ -288,21 +107,6 @@ module ProTacts
 
     private
 
-    # The dashboard GET and a refused create render the same page,
-    # which is why the query travels both paths — a create refused
-    # under a search re-renders the results it was refused over.
-    #: (query: String?, ?notice: String?) -> String
-    def dashboard(query:, notice: nil)
-      response["Content-Type"] = "text/html; charset=utf-8"
-      Admin::ContactsIndex.call(
-        recent: store.contacts_by_recency,
-        upcoming: store.upcoming_birthdays(Admin::UpcomingBirthdays::LIMIT),
-        query:,
-        groups: store.all_groups,
-        notice:,
-      )
-    end
-
     # The refusal of a request that names nobody. A 401 must carry a
     # challenge (RFC 9110 section 15.5.2), and `Tailscale` is no
     # registered scheme: it tells the client that the credential is its
@@ -320,116 +124,11 @@ module ProTacts
     def store
       self.class.store
     end
-
-    # The whole of the edit POST, a private method for the same reason
-    # write_card is one: a Roda route block cannot return early, so each
-    # refusal is a value the block ends with rather than a branch it
-    # exits. The checks run in write_card's order — the request's own
-    # validity first, the conditionals on stored state after.
-    #: (untyped r, String id) -> String?
-    def apply_edit(r, id)
-      contact = store.contact(id)
-      return if contact.nil?
-
-      # N and FN are mandatory (RFC 2426 section 4), so a save blank
-      # throughout is refused — the toast is the backstop, the form's
-      # required field being the browser's own refusal of the same.
-      first = r.params["first"].to_s.strip
-      last = r.params["last"].to_s.strip
-      return edit_screen(contact, notice: "A contact needs a name.") if first.empty? && last.empty?
-
-      # Request validity, standing with the name check rather than the
-      # stored-state conditionals below (write_card's ordering rule). A
-      # POST that carries no birthday group keeps the model, the phones'
-      # is-a-Hash posture — this form's own save does when it rendered
-      # no birthday row, and anything else never carried one; a group of
-      # three blanks is the row's blank-equals-absent, a removal.
-      birthday =
-        if (fields = r.params["birthday"]).is_a?(Hash)
-          begin
-            Admin::CardForm.birthday(fields)
-          rescue ArgumentError
-            return edit_screen(contact, notice: "That birthday is not a shape a date can take.")
-          end
-        else
-          contact.birthday
-        end
-
-      # The snapshot guard, the lost-update half If-Match gives DAV
-      # clients (RFC 7232 section 3.1): the form carried the etag of
-      # the card it was rendered from, and a contact that hashes
-      # differently now was edited in between — another tab, or a
-      # client sync — so applying this save over that one would revert
-      # it. The refusal re-renders from the current card, so the screen
-      # shows what changed; the check shares write_card's millisecond
-      # race window between check and write, noted there.
-      return edit_screen(contact, notice: "This contact changed since the page loaded; nothing was saved.") if r.params["etag"].to_s != contact.etag
-
-      # The one state the birthday row cannot write, refused whole:
-      # docs/plans/2026-09-07-web-birthday-editor.md, "The one hazard:
-      # a card that carries its own BDAY", which also records the
-      # migration not taken.
-      if birthday && contact.stored.lines.any? { it.names?("BDAY") }
-        return edit_screen(contact, notice: "This contact's card carries its own birthday spelling; nothing was saved.")
-      end
-
-      store.rewrite(id, Admin::CardForm.contact_card(contact, first, last, r.params), birthday:)
-      r.redirect "/contacts/#{id}", 303
-    end
-
-    # The group editor's POST, #apply_edit's shape over a group: the
-    # snapshot guard, then the lines the form splices
-    # (Admin::CardForm.group_lines), and one store write for the lot.
-    #: (untyped r, String id) -> String?
-    def apply_group_edit(r, id)
-      group = store.group(id)
-      return if group.nil?
-
-      if r.params["version"].to_s != group.version
-        return group_edit_screen(group, notice: "This group changed since the page loaded; nothing was saved.")
-      end
-
-      # Only ids that name a card: a membership row is a foreign key,
-      # and a doctored id is ordinary bad input rather than a 500. A
-      # POST carrying no list keeps the membership it found, the
-      # phones' is-a-Hash posture (Admin::GroupsEdit).
-      submitted = r.params["members"]
-      members =
-        if submitted.is_a?(Array)
-          submitted.map(&:to_s) & store.contacts.map(&:id)
-        else
-          group.members
-        end #: Array[String]
-
-      begin
-        store.edit_group(id, name: r.params["name"].to_s, lines: Admin::CardForm.group_lines(group, r.params), members:)
-      rescue Sequel::UniqueConstraintViolation
-        # A taken `sync:` name (db/migrations/007_sync_names.rb). The save
-        # is one transaction, so nothing of it landed.
-        return group_edit_screen(group, notice: "Another group is already named #{r.params['name']}; nothing was saved.")
-      end
-      r.redirect "/groups/#{id}", 303
-    end
-
-    #: (Store::Group group, ?notice: String) -> String
-    def group_edit_screen(group, notice: nil)
-      response["Content-Type"] = "text/html; charset=utf-8"
-      Admin::GroupsEdit.call(group:, notice:)
-    end
-
-    # A group's members as contacts, in the listing's own order.
-    #: (Store::Group group) -> Array[Contact]
-    def members_of(group)
-      store.contacts.select { group.members.include?(it.id) }
-    end
-
-    #: (Contact contact, ?notice: String) -> String
-    def edit_screen(contact, notice: nil)
-      response["Content-Type"] = "text/html; charset=utf-8"
-      Admin::ContactsEdit.call(contact:, notice:)
-    end
   end
 end
 
 # The branches reopen Web, so they load once the plugins they call are in.
+require "pro_tacts/web/contacts"
 require "pro_tacts/web/dav"
+require "pro_tacts/web/groups"
+require "pro_tacts/web/setup"
diff --git a/lib/pro_tacts/web/contacts.rb b/lib/pro_tacts/web/contacts.rb
new file mode 100644
index 0000000..a321dd7
--- /dev/null
+++ b/lib/pro_tacts/web/contacts.rb
@@ -0,0 +1,178 @@
+require "securerandom"
+
+require "pro_tacts/admin/card_form"
+require "pro_tacts/admin/contact_dialog"
+require "pro_tacts/admin/contacts_edit"
+require "pro_tacts/admin/contacts_index"
+require "pro_tacts/admin/contacts_show"
+
+module ProTacts
+  class Web < Roda
+    # The card browser, one segment down from the page at the root:
+    # /contacts/:id names what the id is without spending the whole
+    # single-segment namespace on contact ids. Under the same auth
+    # gate as the CardDAV routes — "a few family members, all
+    # trusted" is the whole access model this app has, see README's
+    # simplifying assumptions.
+    hash_branch("contacts") do |r|
+      # The browser's create, from the dashboard's dialog: POST is
+      # the one method the admin UI adds to the DAV set (see
+      # config/puma.rb, whose list Puma replaces rather than
+      # extends), and the collection is the resource a create
+      # names. Stored through Store#put like any client write, so
+      # the change log a sync token counts on lands with the card.
+      # A nameless create is a dashboard re-render with a toast: the
+      # browser cannot produce one (the dialog's first field is
+      # required), so this is the backstop, and a popover cannot be
+      # declared open in markup — the toast is the refusal the
+      # re-rendered page can actually show. `r.is` because a bare
+      # verb block matches any remaining path in Roda — without it,
+      # the collection's create would swallow the record's apply,
+      # POST /contacts/:id below.
+      r.is do
+        r.post do
+          first = r.params["first"].to_s.strip
+          last = r.params["last"].to_s.strip
+          if first.empty? && last.empty?
+            dashboard(query: r.params["q"], notice: "A contact needs a name.")
+          else
+            id = SecureRandom.uuid
+            store.put(id, Admin::CardForm.new_card(id, first, last))
+            r.redirect "/contacts/#{id}", 303
+          end
+        end
+      end
+
+      r.on String do |id|
+        # The browser's edit of one contact
+        # (docs/plans/2026-09-05-web-card-editor.md): an explicit
+        # mode — GET renders the form, POST applies it, success is a
+        # 303 back to the details page so the back button cannot
+        # double-submit. POST stays the wire verb, the create's
+        # precedent: HTML forms speak only GET and POST, and the
+        # admin surface's one client is the form.
+        r.get "edit" do
+          contact = store.contact(id)
+
+          # No match falls through to the empty-body 404 the
+          # not_found handler fills in, same as the page below.
+          if contact
+            response["Content-Type"] = "text/html; charset=utf-8"
+            Admin::ContactsEdit.call(contact:)
+          end
+        end
+
+        r.post do
+          apply_edit(r, id)
+        end
+
+        # The picture the avatars render (Admin::Avatar): decoded
+        # bytes under their own content type, served from a route
+        # rather than inlined as base64 so a page of avatars is a
+        # page of cacheable image requests instead of ten 330 KB
+        # payloads stitched into the HTML. The contact's etag, so a
+        # picture changes exactly when its card does.
+        r.get "photo" do
+          contact = store.contact(id)
+
+          # The views never point at a photo the card lacks, so a
+          # contact with none is the 404 case above.
+          if contact && (photo = contact.photo)
+            response["Content-Type"] = photo.mime_type
+            response["ETag"] = contact.etag
+            photo.bytes
+          end
+        end
+
+        r.get do
+          contact = store.contact(id)
+
+          if contact
+            response["Content-Type"] = "text/html; charset=utf-8"
+            Admin::ContactsShow.call(contact:, groups: store.groups_of(id),
+                                     changes: store.changes_of(id))
+          end
+        end
+      end
+    end
+
+    private
+
+    # The dashboard GET and a refused create render the same page,
+    # which is why the query travels both paths — a create refused
+    # under a search re-renders the results it was refused over.
+    #: (query: String?, ?notice: String?) -> String
+    def dashboard(query:, notice: nil)
+      response["Content-Type"] = "text/html; charset=utf-8"
+      Admin::ContactsIndex.call(
+        recent: store.contacts_by_recency,
+        upcoming: store.upcoming_birthdays(Admin::UpcomingBirthdays::LIMIT),
+        query:,
+        groups: store.all_groups,
+        notice:,
+      )
+    end
+
+    # The whole of the edit POST, a private method for the same reason
+    # write_card is one: a Roda route block cannot return early, so each
+    # refusal is a value the block ends with rather than a branch it
+    # exits. The checks run in write_card's order — the request's own
+    # validity first, the conditionals on stored state after.
+    #: (untyped r, String id) -> String?
+    def apply_edit(r, id)
+      contact = store.contact(id)
+      return if contact.nil?
+
+      # N and FN are mandatory (RFC 2426 section 4), so a save blank
+      # throughout is refused — the toast is the backstop, the form's
+      # required field being the browser's own refusal of the same.
+      first = r.params["first"].to_s.strip
+      last = r.params["last"].to_s.strip
+      return edit_screen(contact, notice: "A contact needs a name.") if first.empty? && last.empty?
+
+      # Request validity, standing with the name check rather than the
+      # stored-state conditionals below (write_card's ordering rule). A
+      # POST that carries no birthday group keeps the model, the phones'
+      # is-a-Hash posture — this form's own save does when it rendered
+      # no birthday row, and anything else never carried one; a group of
+      # three blanks is the row's blank-equals-absent, a removal.
+      birthday =
+        if (fields = r.params["birthday"]).is_a?(Hash)
+          begin
+            Admin::CardForm.birthday(fields)
+          rescue ArgumentError
+            return edit_screen(contact, notice: "That birthday is not a shape a date can take.")
+          end
+        else
+          contact.birthday
+        end
+
+      # The snapshot guard, the lost-update half If-Match gives DAV
+      # clients (RFC 7232 section 3.1): the form carried the etag of
+      # the card it was rendered from, and a contact that hashes
+      # differently now was edited in between — another tab, or a
+      # client sync — so applying this save over that one would revert
+      # it. The refusal re-renders from the current card, so the screen
+      # shows what changed; the check shares write_card's millisecond
+      # race window between check and write, noted there.
+      return edit_screen(contact, notice: "This contact changed since the page loaded; nothing was saved.") if r.params["etag"].to_s != contact.etag
+
+      # The one state the birthday row cannot write, refused whole:
+      # docs/plans/2026-09-07-web-birthday-editor.md, "The one hazard:
+      # a card that carries its own BDAY", which also records the
+      # migration not taken.
+      if birthday && contact.stored.lines.any? { it.names?("BDAY") }
+        return edit_screen(contact, notice: "This contact's card carries its own birthday spelling; nothing was saved.")
+      end
+
+      store.rewrite(id, Admin::CardForm.contact_card(contact, first, last, r.params), birthday:)
+      r.redirect "/contacts/#{id}", 303
+    end
+
+    #: (Contact contact, ?notice: String) -> String
+    def edit_screen(contact, notice: nil)
+      response["Content-Type"] = "text/html; charset=utf-8"
+      Admin::ContactsEdit.call(contact:, notice:)
+    end
+  end
+end
diff --git a/lib/pro_tacts/web/groups.rb b/lib/pro_tacts/web/groups.rb
new file mode 100644
index 0000000..122b7ef
--- /dev/null
+++ b/lib/pro_tacts/web/groups.rb
@@ -0,0 +1,102 @@
+require "pro_tacts/admin/card_form"
+require "pro_tacts/admin/groups_edit"
+require "pro_tacts/admin/groups_index"
+require "pro_tacts/admin/groups_show"
+
+module ProTacts
+  class Web < Roda
+    # The group screens, the contacts' own shape: the collection's
+    # GET and create, then a record's GET, its editor, and the POST
+    # that applies it. What the writes rest on is
+    # docs/plans/2026-09-09-group-edits-propagate.md. A create lands
+    # on the editor, a new group being nothing until something is
+    # added to it.
+    hash_branch("groups") do |r|
+      r.is do
+        r.get do
+          response["Content-Type"] = "text/html; charset=utf-8"
+          Admin::GroupsIndex.call(groups: store.all_groups)
+        end
+
+        r.post do
+          name = r.params["name"].to_s.strip
+          id = store.create_group(name: name.empty? ? nil : name)
+          r.redirect "/groups/#{id}/edit", 303
+        rescue Sequel::UniqueConstraintViolation
+          # A taken `sync:` name, the one kind that must be unique
+          # (db/migrations/007_sync_names.rb).
+          response["Content-Type"] = "text/html; charset=utf-8"
+          Admin::GroupsIndex.call(groups: store.all_groups, notice: "Another group is already named #{name}.")
+        end
+      end
+
+      r.on String do |id|
+        r.get "edit" do
+          group = store.group(id)
+          group_edit_screen(group) if group
+        end
+
+        r.post do
+          apply_group_edit(r, id)
+        end
+
+        r.get do
+          group = store.group(id)
+
+          if group
+            response["Content-Type"] = "text/html; charset=utf-8"
+            Admin::GroupsShow.call(group:, members: members_of(group))
+          end
+        end
+      end
+    end
+
+    private
+
+    # The group editor's POST, #apply_edit's shape over a group: the
+    # snapshot guard, then the lines the form splices
+    # (Admin::CardForm.group_lines), and one store write for the lot.
+    #: (untyped r, String id) -> String?
+    def apply_group_edit(r, id)
+      group = store.group(id)
+      return if group.nil?
+
+      if r.params["version"].to_s != group.version
+        return group_edit_screen(group, notice: "This group changed since the page loaded; nothing was saved.")
+      end
+
+      # Only ids that name a card: a membership row is a foreign key,
+      # and a doctored id is ordinary bad input rather than a 500. A
+      # POST carrying no list keeps the membership it found, the
+      # phones' is-a-Hash posture (Admin::GroupsEdit).
+      submitted = r.params["members"]
+      members =
+        if submitted.is_a?(Array)
+          submitted.map(&:to_s) & store.contacts.map(&:id)
+        else
+          group.members
+        end #: Array[String]
+
+      begin
+        store.edit_group(id, name: r.params["name"].to_s, lines: Admin::CardForm.group_lines(group, r.params), members:)
+      rescue Sequel::UniqueConstraintViolation
+        # A taken `sync:` name (db/migrations/007_sync_names.rb). The save
+        # is one transaction, so nothing of it landed.
+        return group_edit_screen(group, notice: "Another group is already named #{r.params['name']}; nothing was saved.")
+      end
+      r.redirect "/groups/#{id}", 303
+    end
+
+    #: (Store::Group group, ?notice: String) -> String
+    def group_edit_screen(group, notice: nil)
+      response["Content-Type"] = "text/html; charset=utf-8"
+      Admin::GroupsEdit.call(group:, notice:)
+    end
+
+    # A group's members as contacts, in the listing's own order.
+    #: (Store::Group group) -> Array[Contact]
+    def members_of(group)
+      store.contacts.select { group.members.include?(it.id) }
+    end
+  end
+end
diff --git a/lib/pro_tacts/web/setup.rb b/lib/pro_tacts/web/setup.rb
new file mode 100644
index 0000000..759dc03
--- /dev/null
+++ b/lib/pro_tacts/web/setup.rb
@@ -0,0 +1,42 @@
+require "pro_tacts/admin/device_setup"
+require "pro_tacts/profile"
+
+module ProTacts
+  class Web < Roda
+    # 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 identity gate in web.rb's trunk, the only
+    # devices asking are on the tailnet.
+    hash_branch("setup") do |r|
+      r.is do
+        r.get do
+          response["Content-Type"] = "text/html; charset=utf-8"
+          Admin::DeviceSetup.call(hostname: r.host, name: Profile.account_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
+  end
+end