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>
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