setup: name the requester's book over POST
POST /setup/book takes a name and gives the asker's book that new label,
the rake task for one login: the header's, and nobody else's to enable.
A name that's taken or `*` comes back 409 with nothing changed;
the star gets its own error, so a real ArgumentError still gets arraigned.

Assisted-by: Claude Opus 5 via Claude Code
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
change wouqtwvvnokzsuqvtlvqwlszllzrptmv
commit 4461eb820b2efe0a812e9daafbb642c70e5c3692
author Alpha Chen <alpha@kejadlen.dev>
date
parent vsnynpwt
diff --git a/README.md b/README.md
index d4d18d8..27bcc82 100644
--- a/README.md
+++ b/README.md
@@ -72,7 +72,8 @@ Each user syncs a book of their own rather than every card: the members
 of the group `sync:*`, which everyone gets, and of `sync:<login>`,
 matched exactly. `rake book:name LOGIN=... NAME=...` gives a login's
 book a name to use in place of the login, renaming its group to
-`sync:<name>`. A card a client creates joins `sync:*`, so everyone
+`sync:<name>`; a user can name their own with `POST /setup/book` and a
+`name` field. A card a client creates joins `sync:*`, so everyone
 gets it until someone takes it out. See
 `docs/plans/2026-09-12-per-user-books.md`,
 `docs/plans/2026-09-15-client-creates-join-everyone.md`,
diff --git a/lib/pro_tacts/store.rb b/lib/pro_tacts/store.rb
index b8a696f..0b35349 100644
--- a/lib/pro_tacts/store.rb
+++ b/lib/pro_tacts/store.rb
@@ -178,6 +178,9 @@ module ProTacts
     SYNC_PREFIX = "sync:" #: String
     EVERYONE = "#{SYNC_PREFIX}*" #: String
 
+    # A book named `*`, which would be everyone's (#name_book).
+    class EveryonesBookName < ArgumentError; end
+
     # Sequel's migrations, run on open. They ship with the code rather
     # than with a deployment, so the path is relative to this file.
     # `__dir__` is nil only for code with no file behind it, which a
@@ -387,7 +390,7 @@ module ProTacts
     def name_book(login, name)
       name = name.to_s.strip
       name = nil if name.empty?
-      raise ArgumentError, "#{EVERYONE} is everyone's book, not #{login}'s" if name == "*"
+      raise EveryonesBookName, "#{EVERYONE} is everyone's book, not #{login}'s" if name == "*"
 
       @database.transaction do
         group = groups.where(name: own_sync_name(login)).select_map(:id).first
diff --git a/lib/pro_tacts/web/setup.rb b/lib/pro_tacts/web/setup.rb
index a355e62..c8345fc 100644
--- a/lib/pro_tacts/web/setup.rb
+++ b/lib/pro_tacts/web/setup.rb
@@ -38,6 +38,21 @@ module ProTacts
         response["Content-Type"] = "application/x-apple-aspen-config"
         Profile.render(hostname: r.host, username: @login)
       end
+
+      # Names the requester's own book, `rake book:name` for whoever is
+      # asking (docs/plans/2026-09-16-book-names.md); no name goes back
+      # to the login. Plain text, for curl rather than a screen.
+      r.post "book" do
+        response["Content-Type"] = "text/plain"
+        begin
+          store.name_book(@login, r.params["name"]&.to_s)
+          "#{@login} syncs #{Store::SYNC_PREFIX}#{store.book_name(@login) || @login}\n"
+        rescue Sequel::UniqueConstraintViolation, Store::EveryonesBookName
+          # Another login's name, a group's already, or everyone's.
+          response.status = 409
+          "#{Store::SYNC_PREFIX}#{r.params['name'].to_s.strip} is taken; #{@login}'s book is unchanged.\n"
+        end
+      end
     end
   end
 end
diff --git a/test/pro_tacts/admin/test_device_setup.rb b/test/pro_tacts/admin/test_device_setup.rb
index 3719278..777fd5b 100644
--- a/test/pro_tacts/admin/test_device_setup.rb
+++ b/test/pro_tacts/admin/test_device_setup.rb
@@ -6,11 +6,12 @@ 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.
+# screens are: real requests through the Roda app. The profile is
+# rendered from the request alone, so only naming the book needs a
+# database standing behind it.
 class AdminDeviceSetupTest < Minitest::Test
   include Rack::Test::Methods
+  include ThrowawayContacts
 
   def app
     ProTacts::Web
@@ -96,4 +97,39 @@ class AdminDeviceSetupTest < Minitest::Test
     assert_includes first, ProTacts::Profile::IDENTIFIER_PREFIX
     assert_includes last_response.body, ProTacts::Profile::IDENTIFIER_PREFIX
   end
+
+  ## Naming the book
+
+  def test_naming_the_book_names_the_requesters_own
+    with_contacts({}) do |store|
+      id = store.create_group(name: "sync:test@example.com")
+
+      post "/setup/book", name: "test"
+
+      assert_equal 200, last_response.status
+      assert_equal "test@example.com syncs sync:test\n", last_response.body
+      assert_equal "sync:test", store.group(id).name
+
+      post "/setup/book"
+
+      assert_equal "test@example.com syncs sync:test@example.com\n", last_response.body
+      assert_nil store.book_name("test@example.com")
+    end
+  end
+
+  def test_a_taken_book_name_is_a_conflict
+    with_contacts({}) do |store|
+      store.name_book("zoe@example.com", "zoe")
+
+      post "/setup/book", name: "zoe"
+
+      assert_equal 409, last_response.status
+      assert_equal "sync:zoe is taken; test@example.com's book is unchanged.\n", last_response.body
+      assert_nil store.book_name("test@example.com")
+
+      post "/setup/book", name: "*"
+
+      assert_equal 409, last_response.status
+    end
+  end
 end
diff --git a/test/pro_tacts/test_store.rb b/test/pro_tacts/test_store.rb
index baa3290..e2c166c 100644
--- a/test/pro_tacts/test_store.rb
+++ b/test/pro_tacts/test_store.rb
@@ -1417,7 +1417,7 @@ class StoreTest < Minitest::Test
       store.name_book("alpha@example.com", "Alpha Chen")
 
       assert_raises(Sequel::UniqueConstraintViolation) { store.name_book("zoe@example.com", "Alpha Chen") }
-      assert_raises(ArgumentError) { store.name_book("zoe@example.com", "*") }
+      assert_raises(ProTacts::Store::EveryonesBookName) { store.name_book("zoe@example.com", "*") }
       assert_nil store.book_name("zoe@example.com")
     end
   end