eleven / ocho Public

ios: the contact-email step can actually save (it never could) (#1471)

setGroupSettings read the current trio from GET /api/admin/group — a route
that does not exist (main.go registers POST only). So the read always
failed and every write carried name: "", which setGroup rejects: on
native, Eleven's setup conversation answered every contact email with
"Something went wrong saving that", and a name change silently cleared
any email already stored.

Read the trio from the member read instead (/api/group?token=), as the
web bridge does. Covered in the claim integration rig: red before, green
after, against a real local server.


Claude-Session: https://claude.ai/code/session_01GUA1EJFYwgr2nZNVBRFf8k
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Paul Campbell pushed by codex agent, operated by paul@keymail.dev 94dd89d3ceaff9ce4addea0763db8b940e9a1447 parent 8a0fccf
3 files changed, +49 −21
  • ios/LChatCore/Package.resolved +0 −9
  • ios/LChatCore/Sources/LChatCore/LChatAPI.swift +25 −12
  • ios/LChatCore/Tests/LChatCoreTests/SpaceClaimIntegrationTests.swift +24 −0
diff --git a/ios/LChatCore/Package.resolved b/ios/LChatCore/Package.resolved
index fe25d1c..d82f91f 100644
--- a/ios/LChatCore/Package.resolved
+++ b/ios/LChatCore/Package.resolved
@@ -1,14 +1,5 @@
{
"pins" : [
- {
- "identity" : "sparkle",
- "kind" : "remoteSourceControl",
- "location" : "https://github.com/sparkle-project/Sparkle",
- "state" : {
- "revision" : "b6496a74a087257ef5e6da1c5b29a447a60f5bd7",
- "version" : "2.9.4"
- }
- },
{
"identity" : "swift-cmark",
"kind" : "remoteSourceControl",
diff --git a/ios/LChatCore/Sources/LChatCore/LChatAPI.swift b/ios/LChatCore/Sources/LChatCore/LChatAPI.swift
index 8dd359b..dee57e4 100644
--- a/ios/LChatCore/Sources/LChatCore/LChatAPI.swift
+++ b/ios/LChatCore/Sources/LChatCore/LChatAPI.swift
@@ -347,20 +347,33 @@ public struct LChatAPI {
/// saveGroupSettings does the same GET+merge). Admin: the device token's
/// user holds the role.
public func setGroupSettings(name: String? = nil, adminEmail: String? = nil) async -> Bool {
- var current: [String: Any] = [:]
- if let url = URL(string: "/api/admin/group", relativeTo: baseURL) {
- var req = URLRequest(url: url)
- req.setValue("Bearer " + token, forHTTPHeaderField: "Authorization")
- if let (data, resp) = try? await session.data(for: req),
- (resp as? HTTPURLResponse)?.statusCode == 200,
- let obj = try? JSONSerialization.jsonObject(with: data) as? [String: Any] {
- current = obj
- }
+ // setGroup clears any field it isn't sent, so a change to ONE field
+ // must carry the current other two — which means the read has to work.
+ // It didn't: there is no GET /api/admin/group (main.go registers POST
+ // only), so `current` was always empty and every write went out with
+ // an empty name, which the server rejects outright ("name must be
+ // 1-60 characters"). Two bugs in one: setting the contact email could
+ // never succeed on native (Eleven's setup conversation answered every
+ // address with "Something went wrong saving that"), and a name change
+ // silently cleared any email already stored.
+ //
+ // The trio lives on the MEMBER read instead — /api/group?token=…,
+ // where the admin fields are member-gated (getGroup) — which is
+ // exactly what the web bridge reads (shell.js groupInfo).
+ struct Trio: Codable {
+ let name: String?; let name_set: Bool?
+ let admin_name: String?; let admin_email: String?
}
+ let current = try? await getJSON("/api/group", Trio.self)
+ // chosenName, not the raw field: an unnamed space reports the
+ // compiled-in default, and carrying that would silently adopt "lchat"
+ // as this space's chosen name. Empty fails the write instead — honest,
+ // and unreachable from the setup conversation, which names the space
+ // before it asks anything else.
let body: [String: Any] = [
- "name": name ?? (current["name"] as? String ?? ""),
- "admin_name": current["admin_name"] as? String ?? "",
- "admin_email": adminEmail ?? (current["admin_email"] as? String ?? ""),
+ "name": name ?? Self.chosenName(current?.name, nameSet: current?.name_set),
+ "admin_name": current?.admin_name ?? "",
+ "admin_email": adminEmail ?? (current?.admin_email ?? ""),
]
return await postAdmin("/api/admin/group", body)
}
diff --git a/ios/LChatCore/Tests/LChatCoreTests/SpaceClaimIntegrationTests.swift b/ios/LChatCore/Tests/LChatCoreTests/SpaceClaimIntegrationTests.swift
index 81150c5..c9a724c 100644
--- a/ios/LChatCore/Tests/LChatCoreTests/SpaceClaimIntegrationTests.swift
+++ b/ios/LChatCore/Tests/LChatCoreTests/SpaceClaimIntegrationTests.swift
@@ -69,6 +69,30 @@ final class SpaceClaimIntegrationTests: XCTestCase {
let groupName = await api.groupName()
XCTAssertEqual(groupName, "Goes To Eleven")
+ // The setup conversation's contact-email step (setGroupSettings), which
+ // could never succeed: the trio write carried an empty name — rejected
+ // by setGroup — because it read a GET /api/admin/group that does not
+ // exist. So: the email lands, AND the name it had to carry survives.
+ let emailOK = await api.setGroupSettings(adminEmail: "admin@example.com")
+ XCTAssertTrue(emailOK, "the contact email must save")
+ let nameAfterEmail = await api.groupName()
+ XCTAssertEqual(nameAfterEmail, "Goes To Eleven",
+ "writing the email must not clear the space's name")
+
+ // And the mirror: a later name change must not silently clear the
+ // email that is already stored (the same read, the other direction).
+ let renamed = await api.setGroupSettings(name: "Renamed Space")
+ XCTAssertTrue(renamed)
+ let nameAfterRename = await api.groupName()
+ XCTAssertEqual(nameAfterRename, "Renamed Space")
+ struct GroupRead: Codable { let admin_email: String? }
+ let after = try await api.getJSON("/api/group", GroupRead.self)
+ XCTAssertEqual(after.admin_email, "admin@example.com",
+ "renaming the space must not clear the contact email")
+ // Put the name back for the assertions further down.
+ let restored = await api.setGroupSettings(name: "Goes To Eleven")
+ XCTAssertTrue(restored)
+
// A second claim of the same (single-use) invite must fail.
do {
_ = try await SpaceClaim.claim(setupLink: origin + "/join/setup#" + t,